diff --git a/Dotnet/AppApi/Cef/ImageUploading.cs b/Dotnet/AppApi/Cef/ImageUploading.cs new file mode 100644 index 00000000..2c435c72 --- /dev/null +++ b/Dotnet/AppApi/Cef/ImageUploading.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using librsync.net; + +namespace VRCX; + +public partial class AppApi +{ + public string MD5File(string blob) + { + var fileData = Convert.FromBase64CharArray(blob.ToCharArray(), 0, blob.Length); + using var md5 = MD5.Create(); + var md5Hash = md5.ComputeHash(fileData); + return Convert.ToBase64String(md5Hash); + } + + public string SignFile(string blob) + { + var fileData = Convert.FromBase64String(blob); + using var sig = Librsync.ComputeSignature(new MemoryStream(fileData)); + using var memoryStream = new MemoryStream(); + sig.CopyTo(memoryStream); + var sigBytes = memoryStream.ToArray(); + return Convert.ToBase64String(sigBytes); + } + + public string FileLength(string blob) + { + var fileData = Convert.FromBase64String(blob); + return fileData.Length.ToString(); + } +} \ No newline at end of file diff --git a/Dotnet/AppApi/Common/AppApiCommon.cs b/Dotnet/AppApi/Common/AppApiCommon.cs index 4c6a6e79..70c88ad7 100644 --- a/Dotnet/AppApi/Common/AppApiCommon.cs +++ b/Dotnet/AppApi/Common/AppApiCommon.cs @@ -28,14 +28,6 @@ namespace VRCX } }; - public string MD5File(string blob) - { - var fileData = Convert.FromBase64CharArray(blob.ToCharArray(), 0, blob.Length); - using var md5 = MD5.Create(); - var md5Hash = md5.ComputeHash(fileData); - return Convert.ToBase64String(md5Hash); - } - public int GetColourFromUserID(string userId) { var hash = _hasher.ComputeHash(Encoding.UTF8.GetBytes(userId)); diff --git a/Dotnet/VRCX-Cef.csproj b/Dotnet/VRCX-Cef.csproj index 46728430..ef3e4931 100644 --- a/Dotnet/VRCX-Cef.csproj +++ b/Dotnet/VRCX-Cef.csproj @@ -61,6 +61,14 @@ + + + libs\Blake2Sharp.dll + + + libs\librsync.net.dll + + diff --git a/Dotnet/libs/Blake2Sharp.dll b/Dotnet/libs/Blake2Sharp.dll new file mode 100644 index 00000000..43b09138 Binary files /dev/null and b/Dotnet/libs/Blake2Sharp.dll differ diff --git a/Dotnet/libs/README.md b/Dotnet/libs/README.md index 1d5cdf46..a62daf8a 100644 --- a/Dotnet/libs/README.md +++ b/Dotnet/libs/README.md @@ -1,3 +1,11 @@ +### librsync.net.dll + +- [https://github.com/braddodson/librsync.net](https://github.com/braddodson/librsync.net) + +### Blake2Sharp.dll + +- [https://github.com/BLAKE2/BLAKE2/tree/master/csharp](https://github.com/BLAKE2/BLAKE2/tree/master/csharp) + ### openvr_api.dll - [https://github.com/ValveSoftware/openvr](https://github.com/ValveSoftware/openvr) diff --git a/Dotnet/libs/librsync.net.dll b/Dotnet/libs/librsync.net.dll new file mode 100644 index 00000000..0324aff5 Binary files /dev/null and b/Dotnet/libs/librsync.net.dll differ diff --git a/src/api/image.js b/src/api/image.js new file mode 100644 index 00000000..9d523897 --- /dev/null +++ b/src/api/image.js @@ -0,0 +1,297 @@ +import { request } from '../service/request'; +import { useAvatarStore, useWorldStore } from '../stores'; + +const imageReq = { + async uploadAvatarFailCleanup(id) { + const avatarStore = useAvatarStore(); + try { + const json = await request(`file/${id}`, { + method: 'GET' + }); + const fileId = json.id; + const fileVersion = json.versions[json.versions.length - 1].version; + request(`file/${fileId}/${fileVersion}/signature/finish`, { + method: 'PUT' + }).catch(err => console.error('Failed to finish signature:', err)); + request(`file/${fileId}/${fileVersion}/file/finish`, { + method: 'PUT' + }).catch(err => console.error('Failed to finish file:', err)); + } catch (error) { + console.error('Failed to cleanup avatar upload:', error); + } + avatarStore.avatarDialog.loading = false; + }, + + async uploadAvatarImage(params, fileId) { + try { + return await request(`file/${fileId}`, { + method: 'POST', + params + }).then((json) => { + const args = { + json, + params, + fileId + }; + return args; + }); + } catch (err) { + console.error(err); + imageReq.uploadAvatarFailCleanup(fileId); + throw err; + } + }, + + async uploadAvatarImageFileStart(params) { + try { + return await request( + `file/${params.fileId}/${params.fileVersion}/file/start`, + { + method: 'PUT' + } + ).then((json) => { + const args = { + json, + params + }; + return args; + }); + } catch (err) { + console.error(err); + imageReq.uploadAvatarFailCleanup(params.fileId); + } + }, + + uploadAvatarImageFileFinish(params) { + return request( + `file/${params.fileId}/${params.fileVersion}/file/finish`, + { + method: 'PUT', + params: { + maxParts: 0, + nextPartNumber: 0 + } + } + ).then((json) => { + const args = { + json, + params + }; + return args; + }); + }, + + async uploadAvatarImageSigStart(params) { + try { + return await request( + `file/${params.fileId}/${params.fileVersion}/signature/start`, + { + method: 'PUT' + } + ).then((json) => { + const args = { + json, + params + }; + return args; + }); + } catch (err) { + console.error(err); + imageReq.uploadAvatarFailCleanup(params.fileId); + } + }, + + uploadAvatarImageSigFinish(params) { + return request( + `file/${params.fileId}/${params.fileVersion}/signature/finish`, + { + method: 'PUT', + params: { + maxParts: 0, + nextPartNumber: 0 + } + } + ).then((json) => { + const args = { + json, + params + }; + return args; + }); + }, + + setAvatarImage(params) { + return request(`avatars/${params.id}`, { + method: 'PUT', + params + }).then((json) => { + const args = { + json, + params + }; + return args; + }); + }, + + async uploadWorldFailCleanup(id) { + const worldStore = useWorldStore(); + try { + const json = await request(`file/${id}`, { + method: 'GET' + }); + const fileId = json.id; + const fileVersion = json.versions[json.versions.length - 1].version; + request(`file/${fileId}/${fileVersion}/signature/finish`, { + method: 'PUT' + }).catch(err => console.error('Failed to finish signature:', err)); + request(`file/${fileId}/${fileVersion}/file/finish`, { + method: 'PUT' + }).catch(err => console.error('Failed to finish file:', err)); + } catch (error) { + console.error('Failed to cleanup world upload:', error); + } + worldStore.worldDialog.loading = false; + }, + + async uploadWorldImage(params, fileId) { + try { + return await request(`file/${fileId}`, { + method: 'POST', + params + }).then((json) => { + const args = { + json, + params, + fileId + }; + return args; + }); + } catch (err) { + console.error(err); + imageReq.uploadWorldFailCleanup(fileId); + } + return void 0; + }, + + async uploadWorldImageFileStart(params) { + try { + return await request( + `file/${params.fileId}/${params.fileVersion}/file/start`, + { + method: 'PUT' + } + ).then((json) => { + const args = { + json, + params + }; + return args; + }); + } catch (err) { + console.error(err); + imageReq.uploadWorldFailCleanup(params.fileId); + } + return void 0; + }, + + uploadWorldImageFileFinish(params) { + return request( + `file/${params.fileId}/${params.fileVersion}/file/finish`, + { + method: 'PUT', + params: { + maxParts: 0, + nextPartNumber: 0 + } + } + ).then((json) => { + const args = { + json, + params + }; + return args; + }); + }, + + async uploadWorldImageSigStart(params) { + try { + return await request( + `file/${params.fileId}/${params.fileVersion}/signature/start`, + { + method: 'PUT' + } + ).then((json) => { + const args = { + json, + params + }; + return args; + }); + } catch (err) { + console.error(err); + imageReq.uploadWorldFailCleanup(params.fileId); + } + return void 0; + }, + + uploadWorldImageSigFinish(params) { + return request( + `file/${params.fileId}/${params.fileVersion}/signature/finish`, + { + method: 'PUT', + params: { + maxParts: 0, + nextPartNumber: 0 + } + } + ).then((json) => { + const args = { + json, + params + }; + return args; + }); + }, + + setWorldImage(params) { + const worldStore = useWorldStore(); + return request(`worlds/${params.id}`, { + method: 'PUT', + params + }).then((json) => { + const args = { + json, + params + }; + args.ref = worldStore.applyWorld(json); + return args; + }); + }, + + getAvatarImages(params) { + return request(`file/${params.fileId}`, { + method: 'GET' + }).then((json) => { + const args = { + json, + params + }; + return args; + }); + }, + + getWorldImages(params) { + return request(`file/${params.fileId}`, { + method: 'GET', + params + }).then((json) => { + const args = { + json, + params + }; + return args; + }); + } +}; + +export default imageReq; diff --git a/src/api/index.js b/src/api/index.js index 25a49272..36c30f2c 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -21,6 +21,7 @@ import groupRequest from './group'; import authRequest from './auth'; import inventoryRequest from './inventory'; import propRequest from './prop'; +import imageRequest from './image'; window.request = { request, @@ -40,7 +41,8 @@ window.request = { authRequest, groupRequest, inventoryRequest, - propRequest + propRequest, + imageRequest }; export { @@ -61,5 +63,6 @@ export { authRequest, groupRequest, inventoryRequest, - propRequest + propRequest, + imageRequest }; diff --git a/src/components/dialogs/AvatarDialog/ChangeAvatarImageDialog.vue b/src/components/dialogs/AvatarDialog/ChangeAvatarImageDialog.vue index e87a4d3d..3b770cc2 100644 --- a/src/components/dialogs/AvatarDialog/ChangeAvatarImageDialog.vue +++ b/src/components/dialogs/AvatarDialog/ChangeAvatarImageDialog.vue @@ -35,7 +35,7 @@
- +
@@ -45,18 +45,21 @@ import { ElMessage } from 'element-plus'; import { Upload } from '@element-plus/icons-vue'; import { storeToRefs } from 'pinia'; - import { computed, ref } from 'vue'; + import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; - import { avatarRequest } from '../../../api'; + import { avatarRequest, imageRequest } from '../../../api'; import { handleImageUploadInput } from '../../../shared/utils/imageUpload'; import { useAvatarStore } from '../../../stores'; + import { $throw } from '../../../service/request'; + import { AppDebug } from '../../../service/appConfig'; + import { extractFileId } from '../../../shared/utils'; const { t } = useI18n(); const { avatarDialog } = storeToRefs(useAvatarStore()); const { applyAvatar } = useAvatarStore(); - const props = defineProps({ + defineProps({ changeAvatarImageDialogVisible: { type: Boolean, required: true @@ -68,7 +71,14 @@ }); const changeAvatarImageDialogLoading = ref(false); - const currentImageUrl = computed(() => props.previousImageUrl); + const avatarImage = ref({ + base64File: '', + fileMd5: '', + base64SignatureFile: '', + signatureMd5: '', + fileId: '', + avatarId: '' + }); const emit = defineEmits(['update:changeAvatarImageDialogVisible', 'update:previousImageUrl']); @@ -106,9 +116,15 @@ try { const base64File = await resizeImageToFitLimits(btoa(r.result.toString())); // 10MB - await initiateUpload(base64File); + if (LINUX) { + // use new website upload process on Linux, we're missing the needed libraries for Unity method + // website method clears avatar name and is missing world image uploading + await initiateUpload(base64File); + return; + } + await initiateUploadLegacy(base64File, file); } catch (error) { - console.error('Avatar image upload process failed:', error); + console.error('avatar image upload process failed:', error); } finally { finalize(); } @@ -123,6 +139,164 @@ } } + async function initiateUploadLegacy(base64File, file) { + const fileMd5 = await AppApi.MD5File(base64File); + const fileSizeInBytes = parseInt(file.size, 10); + const base64SignatureFile = await AppApi.SignFile(base64File); + const signatureMd5 = await AppApi.MD5File(base64SignatureFile); + const signatureSizeInBytes = parseInt(await AppApi.FileLength(base64SignatureFile), 10); + const avatarId = avatarDialog.value.id; + const { imageUrl } = avatarDialog.value.ref; + const fileId = extractFileId(imageUrl); + avatarImage.value = { + base64File, + fileMd5, + base64SignatureFile, + signatureMd5, + fileId, + avatarId + }; + const params = { + fileMd5, + fileSizeInBytes, + signatureMd5, + signatureSizeInBytes + }; + const res = await imageRequest.uploadAvatarImage(params, fileId); + return avatarImageInit(res); + } + + async function avatarImageInit(args) { + const fileId = args.json.id; + const fileVersion = args.json.versions[args.json.versions.length - 1].version; + const params = { + fileId, + fileVersion + }; + const res = await imageRequest.uploadAvatarImageFileStart(params); + return avatarImageFileStart(res); + } + + async function avatarImageFileStart(args) { + const { url } = args.json; + const { fileId, fileVersion } = args.params; + const params = { + url, + fileId, + fileVersion + }; + return uploadAvatarImageFileAWS(params); + } + + async function uploadAvatarImageFileAWS(params) { + const json = await webApiService.execute({ + url: params.url, + uploadFilePUT: true, + fileData: avatarImage.value.base64File, + fileMIME: 'image/png', + headers: { + 'Content-MD5': avatarImage.value.fileMd5 + } + }); + + if (json.status !== 200) { + changeAvatarImageDialogLoading.value = false; + $throw(json.status, 'avatar image upload failed', params.url); + } + const args = { + json, + params + }; + return avatarImageFileAWS(args); + } + + async function avatarImageFileAWS(args) { + const { fileId, fileVersion } = args.params; + const params = { + fileId, + fileVersion + }; + const res = await imageRequest.uploadAvatarImageFileFinish(params); + return avatarImageFileFinish(res); + } + + async function avatarImageFileFinish(args) { + const { fileId, fileVersion } = args.params; + const params = { + fileId, + fileVersion + }; + const res = await imageRequest.uploadAvatarImageSigStart(params); + return avatarImageSigStart(res); + } + + async function avatarImageSigStart(args) { + const { url } = args.json; + const { fileId, fileVersion } = args.params; + const params = { + url, + fileId, + fileVersion + }; + return uploadAvatarImageSigAWS(params); + } + + async function uploadAvatarImageSigAWS(params) { + const json = await webApiService.execute({ + url: params.url, + uploadFilePUT: true, + fileData: avatarImage.value.base64SignatureFile, + fileMIME: 'application/x-rsync-signature', + headers: { + 'Content-MD5': avatarImage.value.signatureMd5 + } + }); + + if (json.status !== 200) { + changeAvatarImageDialogLoading.value = false; + $throw(json.status, 'avatar image upload failed', params.url); + } + const args = { + json, + params + }; + return avatarImageSigAWS(args); + } + + async function avatarImageSigAWS(args) { + const { fileId, fileVersion } = args.params; + const params = { + fileId, + fileVersion + }; + const res = await imageRequest.uploadAvatarImageSigFinish(params); + return avatarImageSigFinish(res); + } + async function avatarImageSigFinish(args) { + const { fileId, fileVersion } = args.params; + const params = { + id: avatarImage.value.avatarId, + imageUrl: `${AppDebug.endpointDomain}/file/${fileId}/${fileVersion}/file` + }; + const res = await imageRequest.setAvatarImage(params); + return avatarImageSet(res); + } + + function avatarImageSet(args) { + changeAvatarImageDialogLoading.value = false; + if (args.json.imageUrl === args.params.imageUrl) { + ElMessage({ + message: t('message.avatar.image_changed'), + type: 'success' + }); + emit('update:previousImageUrl', args.json.imageUrl); + } else { + $throw(0, 'avatar image change failed', args.params.imageUrl); + } + } + + // ------------ Upload Process End ------------ + async function initiateUpload(base64File) { const args = await avatarRequest.uploadAvatarImage(base64File); const fileUrl = args.json.versions[args.json.versions.length - 1].file.url; @@ -131,8 +305,8 @@ imageUrl: fileUrl }); const ref = applyAvatar(avatarArgs.json); - emit('update:previousImageUrl', ref.imageUrl); changeAvatarImageDialogLoading.value = false; + emit('update:previousImageUrl', ref.imageUrl); ElMessage({ message: t('message.avatar.image_changed'), type: 'success' diff --git a/src/components/dialogs/WorldDialog/ChangeWorldImageDialog.vue b/src/components/dialogs/WorldDialog/ChangeWorldImageDialog.vue index 25ce239c..e826dea6 100644 --- a/src/components/dialogs/WorldDialog/ChangeWorldImageDialog.vue +++ b/src/components/dialogs/WorldDialog/ChangeWorldImageDialog.vue @@ -47,9 +47,12 @@ import { storeToRefs } from 'pinia'; import { ref } from 'vue'; import { useI18n } from 'vue-i18n'; - import { worldRequest } from '../../../api'; + import { worldRequest, imageRequest } from '../../../api'; import { handleImageUploadInput } from '../../../shared/utils/imageUpload'; import { useWorldStore } from '../../../stores'; + import { $throw } from '../../../service/request'; + import { AppDebug } from '../../../service/appConfig'; + import { extractFileId } from '../../../shared/utils'; const { t } = useI18n(); @@ -68,6 +71,14 @@ }); const changeWorldImageDialogLoading = ref(false); + const worldImage = ref({ + base64File: '', + fileMd5: '', + base64SignatureFile: '', + signatureMd5: '', + fileId: '', + worldId: '' + }); const emit = defineEmits(['update:changeWorldImageDialogVisible', 'update:previousImageUrl']); @@ -105,7 +116,8 @@ try { const base64File = await resizeImageToFitLimits(btoa(r.result.toString())); // 10MB - await initiateUpload(base64File); + await initiateUploadLegacy(base64File, file); + // await initiateUpload(base64File); } catch (error) { console.error('World image upload process failed:', error); } finally { @@ -122,6 +134,164 @@ } } + async function initiateUploadLegacy(base64File, file) { + const fileMd5 = await AppApi.MD5File(base64File); + const fileSizeInBytes = parseInt(file.size, 10); + const base64SignatureFile = await AppApi.SignFile(base64File); + const signatureMd5 = await AppApi.MD5File(base64SignatureFile); + const signatureSizeInBytes = parseInt(await AppApi.FileLength(base64SignatureFile), 10); + const worldId = worldDialog.value.id; + const { imageUrl } = worldDialog.value.ref; + const fileId = extractFileId(imageUrl); + worldImage.value = { + base64File, + fileMd5, + base64SignatureFile, + signatureMd5, + fileId, + worldId + }; + const params = { + fileMd5, + fileSizeInBytes, + signatureMd5, + signatureSizeInBytes + }; + const res = await imageRequest.uploadWorldImage(params, fileId); + return worldImageInit(res); + } + + async function worldImageInit(args) { + const fileId = args.json.id; + const fileVersion = args.json.versions[args.json.versions.length - 1].version; + const params = { + fileId, + fileVersion + }; + const res = await imageRequest.uploadWorldImageFileStart(params); + return worldImageFileStart(res); + } + + async function worldImageFileStart(args) { + const { url } = args.json; + const { fileId, fileVersion } = args.params; + const params = { + url, + fileId, + fileVersion + }; + return uploadWorldImageFileAWS(params); + } + + async function uploadWorldImageFileAWS(params) { + const json = await webApiService.execute({ + url: params.url, + uploadFilePUT: true, + fileData: worldImage.value.base64File, + fileMIME: 'image/png', + headers: { + 'Content-MD5': worldImage.value.fileMd5 + } + }); + + if (json.status !== 200) { + changeWorldImageDialogLoading.value = false; + $throw(json.status, 'World image upload failed', params.url); + } + const args = { + json, + params + }; + return worldImageFileAWS(args); + } + + async function worldImageFileAWS(args) { + const { fileId, fileVersion } = args.params; + const params = { + fileId, + fileVersion + }; + const res = await imageRequest.uploadWorldImageFileFinish(params); + return worldImageFileFinish(res); + } + + async function worldImageFileFinish(args) { + const { fileId, fileVersion } = args.params; + const params = { + fileId, + fileVersion + }; + const res = await imageRequest.uploadWorldImageSigStart(params); + return worldImageSigStart(res); + } + + async function worldImageSigStart(args) { + const { url } = args.json; + const { fileId, fileVersion } = args.params; + const params = { + url, + fileId, + fileVersion + }; + return uploadWorldImageSigAWS(params); + } + + async function uploadWorldImageSigAWS(params) { + const json = await webApiService.execute({ + url: params.url, + uploadFilePUT: true, + fileData: worldImage.value.base64SignatureFile, + fileMIME: 'application/x-rsync-signature', + headers: { + 'Content-MD5': worldImage.value.signatureMd5 + } + }); + + if (json.status !== 200) { + changeWorldImageDialogLoading.value = false; + $throw(json.status, 'World image upload failed', params.url); + } + const args = { + json, + params + }; + return worldImageSigAWS(args); + } + + async function worldImageSigAWS(args) { + const { fileId, fileVersion } = args.params; + const params = { + fileId, + fileVersion + }; + const res = await imageRequest.uploadWorldImageSigFinish(params); + return worldImageSigFinish(res); + } + async function worldImageSigFinish(args) { + const { fileId, fileVersion } = args.params; + const params = { + id: worldImage.value.worldId, + imageUrl: `${AppDebug.endpointDomain}/file/${fileId}/${fileVersion}/file` + }; + const res = await imageRequest.setWorldImage(params); + return worldImageSet(res); + } + + function worldImageSet(args) { + changeWorldImageDialogLoading.value = false; + if (args.json.imageUrl === args.params.imageUrl) { + ElMessage({ + message: t('message.world.image_changed'), + type: 'success' + }); + emit('update:previousImageUrl', args.json.imageUrl); + } else { + $throw(0, 'World image change failed', args.params.imageUrl); + } + } + + // ------------ Upload Process End ------------ + async function initiateUpload(base64File) { const args = await worldRequest.uploadWorldImage(base64File); const fileUrl = args.json.versions[args.json.versions.length - 1].file.url; diff --git a/src/components/dialogs/WorldDialog/WorldDialog.vue b/src/components/dialogs/WorldDialog/WorldDialog.vue index 017ab2d7..7650efc6 100644 --- a/src/components/dialogs/WorldDialog/WorldDialog.vue +++ b/src/components/dialogs/WorldDialog/WorldDialog.vue @@ -283,7 +283,7 @@ {{ t('dialog.world.actions.change_allowed_video_player_domains') }} - + {{ t('dialog.world.actions.change_image') }} WINDOWS); + const memo = computed({ get() { return worldDialog.value.memo; @@ -966,7 +968,7 @@ treeData.value = []; } - function showChangeAvatarImageDialog() { + function showChangeWorldImageDialog() { const { imageUrl } = worldDialog.value.ref; previousImageUrl.value = imageUrl; changeWorldImageDialogVisible.value = true; @@ -1120,7 +1122,7 @@ openExternalLink(replaceVrcPackageUrl(worldDialog.value.ref.unityPackageUrl)); break; case 'Change Image': - showChangeAvatarImageDialog(); + showChangeWorldImageDialog(); break; case 'Refresh': showWorldDialog(D.id); diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts index bfbfa88d..20773262 100644 --- a/src/types/globals.d.ts +++ b/src/types/globals.d.ts @@ -186,7 +186,6 @@ declare global { SetUserAgent(): Promise; // Common Functions - MD5File(blob: string): Promise; GetColourFromUserID(userId: string): Promise; OpenLink(url: string): Promise; GetLaunchCommand(): Promise; @@ -207,6 +206,11 @@ declare global { GetFileBase64(path: string): Promise; TryOpenInstanceInVrc(launchUrl: string): Promise; + // Image Upload (Cef Only) + MD5File(blob: string): Promise; + SignFile(blob: string): Promise; + FileLength(blob: string): Promise; + // Folders GetVRChatAppDataLocation(): Promise; GetVRChatPhotosLocation(): Promise;