Add upload and share functionality and minor improvements

This commit is contained in:
Yeicor
2025-08-02 12:50:20 +02:00
parent 56fc556c0f
commit 06a95d4875
8 changed files with 483 additions and 323 deletions

View File

@@ -1,5 +1,6 @@
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
import {mergeDocuments, unpartition} from "@gltf-transform/functions";
import {retrieveFile} from "../tools/upload-file.ts";
let io = new WebIO();
export let extrasNameKey = "__yacv_name";
@@ -140,7 +141,7 @@ async function fetchOrRead(url: string | Blob) {
});
} else {
// Fetch the URL
return fetch(url);
return retrieveFile(url);
}
}

View File

@@ -55,8 +55,14 @@ export class NetworkManager extends EventTarget {
else name = `blob-${Math.random()}`;
name = name.replace('.glb', '').replace('.gltf', '');
} else {
// Get the last part of the URL as the "name" of the model
name = url.split("/").pop();
// If there is a #name parameter in the URL, use it as the name
let urlObj = new URL(url);
let hashParams = new URLSearchParams(urlObj.hash.slice(1));
if (hashParams.has("name")) {
name = hashParams.get("name") || `unknown-${Math.random()}`;
} else { // Default to the last part of the URL as the "name" of the model
name = url.split("/").pop();
}
name = name?.split(".")[0] || `unknown-${Math.random()}`;
// Use a head request to get the hash of the file
let response = await fetch(url, {method: "HEAD"});

View File

@@ -1,6 +1,7 @@
// These are the default values for the settings, which are overridden below
import {ungzip} from "pako";
import {b64UrlDecode} from "../tools/b64.ts";
import {retrieveFile} from "../tools/upload-file.ts";
const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
export const settings = (async () => {
@@ -42,7 +43,6 @@ export const settings = (async () => {
// Playground settings
pg_code: "", // Automatically loaded and executed code for the playground
pg_code_url: "", // URL to load the code from (overrides pg_code)
pg_opacity_loading: -1, // Opacity of the code during first load and run (< 0 is 0.0 if preload and 0.9 if not)
pg_opacity_loaded: 0.9, // Opacity of the code after it has been run for the first time
};
@@ -60,21 +60,6 @@ export const settings = (async () => {
});
}
// Grab the code from the URL if it is set
if (settings.pg_code_url.length > 0) {
// If the code URL is set, override the code
try {
const response = await fetch(settings.pg_code_url);
if (response.ok) {
settings.pg_code = await response.text();
} else {
console.warn("Failed to load code from URL:", settings.pg_code_url);
}
} catch (error) {
console.error("Error fetching code from URL:", settings.pg_code_url, error);
}
}
// Get the default preload URL if not overridden (requires a fetch that is avoided if possible)
for (let i = 0; i < settings.preload.length; i++) {
let url = settings.preload[i];
@@ -105,17 +90,22 @@ export const settings = (async () => {
// Auto-decompress the code and other playground settings
if (settings.pg_code.length > 0) {
// pg_code has a few possible formats: URL, base64url+gzipped, or raw code (try them in that order)
try {
settings.pg_code = ungzip(b64UrlDecode(settings.pg_code), {to: 'string'});
} catch (error) {
console.log("pg_code is not base64url+gzipped, assuming raw code. Decoding error:", error);
new URL(settings.pg_code); // Check if it's a valid absolute URL
settings.pg_code = await (await retrieveFile(settings.pg_code)).text();
} catch (error1) { // Not a valid URL, try base64url+gzipped
try {
settings.pg_code = ungzip(b64UrlDecode(settings.pg_code), {to: 'string'});
} catch (error2) { // Not base64url+gzipped, assume it's raw code
console.log("pg_code is not a URL (", error1, ") or base64url+gzipped (", error2, "), using it as raw code:", settings.pg_code);
}
}
if (settings.pg_opacity_loading < 0) {
// If the opacity is not set, use 0.0 if preload is set, otherwise 0.9
settings.pg_opacity_loading = settings.preload.length > 0 ? 0.0 : 0.9;
}
}
return settings;
})()

View File

@@ -12,7 +12,8 @@ import {
mdiFolderOpen,
mdiPlay,
mdiReload,
mdiShare
mdiShare,
mdiUpload
} from "@mdi/js";
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle, VTooltip} from "vuetify/components";
// @ts-expect-error
@@ -25,6 +26,7 @@ import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts";
import {settings} from "../misc/settings.ts";
// @ts-expect-error
import playgroundStartupCode from './PlaygroundStartup.py?raw';
import {uploadFile} from "./upload-file.ts";
const model = defineModel<{ code: string, firstTime: boolean }>({required: true}); // Initial code should only be set on first load!
const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
@@ -139,9 +141,13 @@ function onModelData(modelData: string) {
const binaryData = Base64.toUint8Array(modelData.slice(i + 1)); // Extract the base64 part
console.assert(binaryData.slice(0, 4).toString() == "103,108,84,70", // Ugly...
"Invalid GLTF binary data received: " + binaryData.slice(0, 4).toString());
// - Save for upload and share link feature
builtModelsGlb[modelMetadata.name] = binaryData;
// - Create a Blob from the binary data to be used as a URL
const blob = new Blob([binaryData], {type: 'model/gltf-binary'});
modelMetadata.url = URL.createObjectURL(blob); // Set the hacked URL in the model metadata XXX: revoked on App.vue
} else {
delete builtModelsGlb[modelMetadata.name]; // Remove from built models if it's a remove request
}
// - Emit the event with the model metadata and URL
let networkUpdateEvent = new NetworkUpdateEvent([modelMetadata], () => {
@@ -158,15 +164,14 @@ function resetWorker(loadSnapshot: Uint8Array | undefined = undefined) {
setupPyodide(false, loadSnapshot); // Reinitialize Pyodide
}
function shareLink() {
function shareLinkCommon(added: Record<string, string>, forgotten: Array<string>) {
const baseUrl = window.location
const searchParams = new URLSearchParams(baseUrl.search);
searchParams.delete('pg_code_url'); // Remove any existing pg_code parameter
searchParams.delete('pg_code'); // Remove any existing pg_code parameter
for (const k of forgotten) searchParams.delete(k);
const hashParams = new URLSearchParams(baseUrl.hash.slice(1)); // Keep all previous URL parameters
hashParams.delete('pg_code_url') // Would overwrite the pg_code parameter
hashParams.set('pg_code', b64UrlEncode(gzip(model.value.code, {level: 9}))); // Compress and encode the code
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${searchParams}#${hashParams}`; // Prefer hash to GET
for (const k of forgotten) hashParams.delete(k);
for (const k in added) hashParams.append(k, added[k]); // Prefer hash to GET
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${searchParams}#${hashParams}`;
output(`Share link ready: ${shareUrl}\n`)
if (navigator.clipboard?.writeText === undefined) {
output("Clipboard API not available. Please copy the link manually.\n");
@@ -178,6 +183,35 @@ function shareLink() {
}
}
function shareLink() {
shareLinkCommon({'pg_code': b64UrlEncode(gzip(model.value.code, {level: 9}))}, ['pg_code']);
}
const builtModelsGlb: Record<string, Uint8Array> = {}; // Store built models to support uploading
async function uploadAndShareLink() {
try {
output("Uploading files...\n");
// Upload code.py
const codeBlob = new Blob([model.value.code], {type: 'text/x-python'});
const newParams: Record<string, string> = {
'pg_code': await uploadFile('code.py', new Uint8Array(await codeBlob.arrayBuffer()))
};
// Upload all models
for (const name in builtModelsGlb) {
const glb: any = builtModelsGlb[name];
newParams['preload'] = await uploadFile(name + '.glb', glb);
}
// Build share URL
return shareLinkCommon(newParams, ['pg_code'])
} catch (e) {
output(`Error uploading/sharing files: ${e}. Falling back to private share link.\n`);
return shareLink(); // Fallback to private share link if upload fails
}
}
function saveSnapshot() {
throw new Error("Not implemented yet!"); // TODO: Implement snapshot saving
}
@@ -230,38 +264,48 @@ onMounted(() => {
location="bottom">Opacity of the editor (0 = hidden, 1 = fully visible)</v-tooltip>
</span>
<span style="margin-right: -8px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
<span style="padding-left: 12px; width: 48px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
<v-btn icon disabled @click="saveSnapshot()">
<svg-icon :path="mdiContentSave" type="mdi"/>
</v-btn>
<v-tooltip activator="parent"
location="bottom">Save current state to a snapshot for fast startup (WIP)</v-tooltip>
</span>
<span style="margin-left: -8px"><!-- This span is only needed to force tooltip to work while button is disabled -->
<span style="padding-right: 12px; width: 48px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
<v-btn icon disabled @click="loadSnapshot()">
<svg-icon :path="mdiFolderOpen" type="mdi"/>
</v-btn>
<v-tooltip activator="parent" location="bottom">Load snapshot for fast startup (WIP)</v-tooltip>
</span>
<v-btn icon @click="resetWorker()">
<v-btn icon @click="shareLink()" style="padding-left: 12px;">
<svg-icon :path="mdiShare" type="mdi"/>
<v-tooltip activator="parent" location="bottom">Share link that automatically runs the code.<br/>Only people
with the link can see the code.
</v-tooltip>
</v-btn>
<v-btn icon @click="uploadAndShareLink()" style="padding-right: 12px">
<svg-icon :path="mdiShare" type="mdi" style="position: absolute; scale: 75%; top: 6px;"/>
<svg-icon :path="mdiUpload" type="mdi" style="position: absolute; scale: 75%; bottom: 6px;"/>
<v-tooltip activator="parent" location="bottom">Uploads all models and code and then shares a link to them.<br/>Useful
to view the models while the playground loads, but uses third-party storage.
</v-tooltip>
</v-btn>
<v-btn icon @click="resetWorker()" style="padding-left: 12px;">
<svg-icon :path="mdiReload" type="mdi"/>
<v-tooltip activator="parent" location="bottom">Reset Pyodide worker (this forgets all previous state and will
take a little while)
</v-tooltip>
</v-btn>
<v-btn icon @click="runCode()" :disabled="running">
<v-btn icon @click="runCode()" :disabled="running" style="padding-right: 12px">
<svg-icon :path="mdiPlay" type="mdi"/>
<Loading v-if="running" style="position: absolute; top: -16%; left: -16%"/><!-- Ugly positioning -->
<Loading v-if="running" style="position: absolute; top: -16%; left: -28%"/><!-- Ugly positioning -->
<v-tooltip activator="parent" location="bottom">Run code</v-tooltip>
</v-btn>
<v-btn icon @click="shareLink()">
<svg-icon :path="mdiShare" type="mdi"/>
<v-tooltip activator="parent" location="bottom">Share link that auto-runs the code</v-tooltip>
</v-btn>
<v-btn icon @click="emit('close')">
<svg-icon :path="mdiClose" type="mdi"/>
<v-tooltip activator="parent" location="bottom">Close (Pyodide remains loaded)</v-tooltip>

View File

@@ -0,0 +1,84 @@
import { encrypt } from "tanmayo7lock";
async function check(lockerName: string) {
const fileUrl = `https://vouz-backend.onrender.com/api/check_key`;
const response = await fetch(fileUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({name: encrypt(lockerName), key: encrypt(lockerName)}),
});
if (!response.ok) throw new Error(`Failed to get file URL: ${response.status} ${response.statusText} -- ${await response.text()}`);
const status = await response.json();
return {response, status};
}
export async function uploadFile(name: string, data: Uint8Array): Promise<string> {
// "Free" storage, let's see how long it lasts...
// Create a locker
const lockerUrl = `https://vouz-backend.onrender.com/api/locker`
const lockerName = `yacv-pg-${name}-${Date.now()}`; // Unique locker name
let responsePromise = fetch(lockerUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({name: encrypt(lockerName), passkey: encrypt(lockerName)}),
});
// The previous request never answers 🤮
responsePromise.then((response) => console.warn(`Locker creation response: ${response.status} ${response.statusText} -- ${response.headers.get('Content-Type')}`));
// Instead, poll the check endpoint until the locker is created
let i: number;
for (i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 250)); // Wait a bit before checking
try {
let {status} = await check(lockerName);
if (status && status.data && status.data.length == 0) break // Locker is created
} catch (e) { // Ignore errors, they will be thrown later
}
}
if (i >= 10) throw new Error(`Failed to create locker after 10 attempts: ${lockerName}`);
// Upload file to the locker
const uploadUrl = `https://vouz-backend.onrender.com/api/upload`;
const formData = new FormData();
formData.append('file', new Blob([data], {type: 'application/octet-stream'}), name);
formData.append("name", encrypt(lockerName));
formData.append("passkey", encrypt(lockerName));
const response = await fetch(uploadUrl, {
method: 'POST',
body: formData,
})
if (!response.ok) throw new Error(`Failed to upload file: ${response.status} ${response.statusText} -- ${await response.text()}`);
// Fake URL for retrieveFile to work
return "https://vouz.tech#name=" + encodeURIComponent(name) + "&locker=" + encodeURIComponent(lockerName);
}
/** Given any URL, it retrieves the file, with custom code for the vouz.tech locker. */
export async function retrieveFile(url: string): Promise<Response> {
let realUrl = url;// Normal fetch if the URL is not a vouz.tech locker URL
if (url.indexOf("https://vouz.tech#") !== -1) { // Check if the URL is a vouz.tech locker URL
// Parse the URL to get the locker name and file name
const urlObj = new URL(url);
const hashParams = new URLSearchParams(urlObj.hash.slice(1)); // Remove the leading '#'
const lockerName = hashParams.get('locker') || (() => {
throw new Error("Locker name not found in URL hash")
})();
const name = hashParams.get('name') || (() => {
throw new Error("File name not found in URL hash")
})();
// Get the URL of the uploaded file
let {status} = await check(lockerName);
if (!status || !status.data || status.data.length == 0 || !status.data[0].url) {
throw new Error(`No file URL found in response: ${JSON.stringify(status)}`);
}
console.debug("File access requested successfully, URL:", status.data[0].url);
realUrl = "https://corsproxy.io/?url=" + status.data[0].url + "#name=" + encodeURIComponent(name) + "&locker=" + encodeURIComponent(lockerName);
}
return await fetch(realUrl);
}