Slave/upload ui #8

Merged
nobswebdev merged 15 commits from slave/upload-ui into master 2026-10-25 22:43:57 +00:00
75 changed files with 2253 additions and 347 deletions
+5 -4
View File
@@ -20,11 +20,9 @@ CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
ALTCHA_ENABLED=true
ALTCHA_HMAC_KEY=change-me-use-long-random-secret
ALTCHA_MAX_NUMBER=50000
ALTCHA_MAX_NUMBER=250000
ALTCHA_CHALLENGE_TTL_MS=600000
NEVER_MODE_INACTIVITY_DAYS=365
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=1000
THROTTLE_UPLOAD_TTL_MS=60000
@@ -35,8 +33,11 @@ VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES=2097152 # 2MiB
VITE_API_BASE_URL=http://localhost:3000/api
VITE_CLEARNET_SCHEME=http
VITE_CLEARNET_DOMAIN=localhost:5173
VITE_ONION_HOSTNAME=
VITE_ONION_HOSTNAME=abcdefghijklmnop.onion
VITE_ALTCHA_ENABLED=true
VITE_ALTCHA_MAX_NUMBER=250000
VITE_UPLOAD_MAX_QUEUE_SIZE=10
VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000
VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:22.23.4-alpine
WORKDIR /backend
-1
View File
@@ -17,7 +17,6 @@ const validConfig = (overrides: Record<string, unknown> = {}): Record<string, un
ALTCHA_HMAC_KEY: 'secret',
ALTCHA_MAX_NUMBER: 50000,
ALTCHA_CHALLENGE_TTL_MS: 600_000,
NEVER_MODE_INACTIVITY_DAYS: 365,
THROTTLE_TTL_MS: 60000,
THROTTLE_LIMIT: 1000,
THROTTLE_UPLOAD_TTL_MS: 60000,
-6
View File
@@ -65,12 +65,6 @@ class EnvironmentVariables {
@Max(3_600_000)
ALTCHA_CHALLENGE_TTL_MS: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(3650)
NEVER_MODE_INACTIVITY_DAYS: number;
@IsNotEmpty()
@IsNumber()
@Min(1000)
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine
FROM node:22.23.4-alpine
WORKDIR /frontend
+366
View File
@@ -11,7 +11,10 @@
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"@uswriting/exiftool": "1.0.9",
"altcha-lib": "^2.7.0",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
"escape-html": "^1.0.3",
"hash-wasm": "^4.12.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
@@ -20,10 +23,12 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/escape-html": "^1.0.4",
"@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.4",
"vitest": "^3.2.7",
"vue-tsc": "^3.2.6"
@@ -1714,6 +1719,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/escape-html": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz",
"integrity": "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2037,6 +2049,19 @@
}
}
},
"node_modules/acorn": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -2056,6 +2081,75 @@
"dev": true,
"license": "MIT"
},
"node_modules/altcha-lib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.7.0.tgz",
"integrity": "sha512-gnysjfIpeT6k3RLaWRd7xVvfs8U0a5DHIS95Xgwn8znKq5uIj6c/oTH8DtcdyJS/ZV3I5kYpyurvmJFb0eFWvA==",
"license": "MIT",
"bin": {
"altcha-lib": "bin/cli.mjs"
},
"peerDependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/formbody": "^8.0.0 || ^9.0.0",
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@sveltejs/kit": "^2.0.0 || ^3.0.0",
"cookie-parser": "^1.0.0",
"cors": "^2.0.0",
"express": "^4.0.0 || ^5.0.0",
"fastify": "^5.0.0",
"h3": "^2.0.0",
"hono": "^4.0.0"
},
"peerDependenciesMeta": {
"@fastify/cookie": {
"optional": true
},
"@fastify/cors": {
"optional": true
},
"@fastify/formbody": {
"optional": true
},
"@nestjs/common": {
"optional": true
},
"@nestjs/core": {
"optional": true
},
"@nestjs/platform-express": {
"optional": true
},
"@nestjs/platform-fastify": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"cookie-parser": {
"optional": true
},
"cors": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
},
"h3": {
"optional": true
},
"hono": {
"optional": true
}
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2144,6 +2238,22 @@
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^5.0.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2156,12 +2266,25 @@
"node": ">= 0.8"
}
},
"node_modules/confbox": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
"integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2340,6 +2463,12 @@
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -2356,6 +2485,13 @@
"node": ">=12.0.0"
}
},
"node_modules/exsolve": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz",
"integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==",
"dev": true,
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2817,6 +2953,24 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/local-pkg": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz",
"integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"mlly": "^1.7.4",
"pkg-types": "^2.3.0",
"quansync": "^0.2.11"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -2863,6 +3017,38 @@
"node": ">= 0.6"
}
},
"node_modules/mlly": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
"integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"pathe": "^2.0.3",
"pkg-types": "^1.3.1",
"ufo": "^1.6.3"
}
},
"node_modules/mlly/node_modules/confbox": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
"dev": true,
"license": "MIT"
},
"node_modules/mlly/node_modules/pkg-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
"mlly": "^1.7.4",
"pathe": "^2.0.1"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2900,6 +3086,20 @@
"integrity": "sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==",
"license": "MIT"
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2974,6 +3174,18 @@
}
}
},
"node_modules/pkg-types": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz",
"integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.3.1",
"exsolve": "^1.1.3",
"pathe": "^2.0.3"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
@@ -3023,6 +3235,37 @@
"node": ">=10"
}
},
"node_modules/quansync": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/antfu"
},
{
"type": "individual",
"url": "https://github.com/sponsors/sxzz"
}
],
"license": "MIT"
},
"node_modules/readdirp": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/rolldown": {
"version": "1.2.11",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz",
@@ -3238,6 +3481,13 @@
"node": ">=14.17"
}
},
"node_modules/ufo": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -3245,6 +3495,115 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/unplugin": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.4.0.tgz",
"integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"picomatch": "^4.0.7",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@farmfe/core": "*",
"@rsbuild/core": "*",
"@rspack/core": "*",
"bun-types-no-globals": "*",
"esbuild": "*",
"rolldown": "*",
"rollup": "*",
"unloader": "*",
"vite": "*",
"webpack": "*"
},
"peerDependenciesMeta": {
"@farmfe/core": {
"optional": true
},
"@rsbuild/core": {
"optional": true
},
"@rspack/core": {
"optional": true
},
"bun-types-no-globals": {
"optional": true
},
"esbuild": {
"optional": true
},
"rolldown": {
"optional": true
},
"rollup": {
"optional": true
},
"unloader": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/unplugin-utils": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
"integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pathe": "^2.0.3",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/unplugin-vue-components": {
"version": "32.1.0",
"resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz",
"integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^5.0.0",
"local-pkg": "^1.2.0",
"magic-string": "^0.30.21",
"mlly": "^1.8.2",
"obug": "^2.1.1",
"picomatch": "^4.0.4",
"tinyglobby": "^0.2.16",
"unplugin": "^3.0.0",
"unplugin-utils": "^0.3.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@nuxt/kit": "^3.2.2 || ^4.0.0",
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
@@ -3671,6 +4030,13 @@
"typescript": ">=5.0.0"
}
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+5
View File
@@ -13,7 +13,10 @@
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"@uswriting/exiftool": "1.0.9",
"altcha-lib": "^2.7.0",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
"escape-html": "^1.0.3",
"hash-wasm": "^4.12.0",
"pinia": "^4.0.3",
"pretty-bytes": "^7.2.0",
@@ -22,10 +25,12 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/escape-html": "^1.0.4",
"@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.4",
"vitest": "^3.2.7",
"vue-tsc": "^3.2.6"
+3
View File
@@ -20,6 +20,7 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -40,6 +41,7 @@
--accent: oklch(0.94 0.02 260);
--accent-foreground: oklch(0.28 0.03 260);
--destructive: oklch(0.55 0.2 25);
--success: oklch(0.45 0.14 155);
--border: oklch(0.9 0.01 260);
--input: oklch(0.9 0.01 260);
--ring: oklch(0.42 0.08 260);
@@ -59,6 +61,7 @@
--accent: oklch(0.28 0.03 260);
--accent-foreground: oklch(0.96 0.01 260);
--destructive: oklch(0.62 0.2 25);
--success: oklch(0.68 0.14 155);
--border: oklch(0.32 0.02 260);
--input: oklch(0.32 0.02 260);
--ring: oklch(0.72 0.1 260);
+26
View File
@@ -0,0 +1,26 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
InlineTabList: typeof import('./components/InlineTabList.vue')['default']
MetadataInspector: typeof import('./components/upload/MetadataInspector.vue')['default']
MetadataOutputPanel: typeof import('./components/upload/MetadataOutputPanel.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ShareLinkField: typeof import('./components/upload/ShareLinkField.vue')['default']
UploadConfigureModal: typeof import('./components/upload/UploadConfigureModal.vue')['default']
UploadDropzone: typeof import('./components/upload/UploadDropzone.vue')['default']
UploadQueueList: typeof import('./components/upload/UploadQueueList.vue')['default']
UploadSettingsPanel: typeof import('./components/upload/UploadSettingsPanel.vue')['default']
UploadShareResults: typeof import('./components/upload/UploadShareResults.vue')['default']
}
}
+36
View File
@@ -0,0 +1,36 @@
<template>
<div class="inline-flex w-fit rounded-md border border-border p-0.5">
<button
v-for="option in options"
:key="option.value"
type="button"
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
:class="
modelValue === option.value
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground'
"
@click="select(option.value)"
>
{{ option.label }}
</button>
</div>
</template>
<script setup lang="ts" generic="T extends string">
import { type PropType } from 'vue';
import type { InlineTabOption } from '@/types/ui/InlineTabOption';
const modelValue = defineModel<T>({ required: true });
defineProps({
options: {
type: Array as PropType<InlineTabOption<T>[]>,
required: true
}
});
const select = (value: T): void => {
modelValue.value = value;
};
</script>
@@ -1,58 +1,25 @@
<template>
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card" aria-live="polite">
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium text-card-foreground">Metadata</h2>
<h2 class="text-sm font-medium text-card-foreground">Metadata Inspector</h2>
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
</header>
<div class="flex flex-1 flex-col gap-3 p-4">
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view its metadata.</p>
<p v-else-if="entry.status === 'error'" class="text-sm text-destructive">
{{ entry.message }}
</p>
<template v-else-if="entry.status === 'error'">
<p class="text-sm text-destructive">{{ entry.message }}</p>
<p v-if="entry.output" class="text-xs font-medium text-foreground">Original</p>
<MetadataOutputPanel v-if="entry.output">{{ entry.output }}</MetadataOutputPanel>
</template>
<template v-else-if="entry.status === 'ready'">
<div
class="inline-flex w-fit rounded-md border border-border p-0.5"
role="tablist"
aria-label="Metadata view"
>
<button
type="button"
role="tab"
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
:class="
activeTab === 'original'
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground'
"
:aria-selected="activeTab === 'original'"
@click="activeTab = 'original'"
>
Original
</button>
<button
type="button"
role="tab"
class="rounded px-3 py-1.5 text-xs font-medium transition-colors"
:class="
activeTab === 'stripped'
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground'
"
:aria-selected="activeTab === 'stripped'"
@click="activeTab = 'stripped'"
>
Stripped
</button>
</div>
<InlineTabList v-model="activeTab" :options="metadataTabOptions" />
<pre
role="tabpanel"
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
>{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}</pre
>
<MetadataOutputPanel>
{{ activeTab === 'original' ? entry.output : entry.afterStripOutput }}
</MetadataOutputPanel>
</template>
<p v-else class="text-sm text-muted-foreground">{{ entry.message }}</p>
@@ -64,6 +31,12 @@
import { ref, watch, type PropType } from 'vue';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import type { MetadataTab } from '@/types/metadata/MetadataTab';
import type { InlineTabOption } from '@/types/ui/InlineTabOption';
const metadataTabOptions: InlineTabOption<MetadataTab>[] = [
{ value: 'original', label: 'Original' },
{ value: 'stripped', label: 'Stripped' }
];
const props = defineProps({
fileName: {
@@ -0,0 +1,5 @@
<template>
<pre
class="max-h-96 min-h-32 flex-1 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap text-foreground"
><slot /></pre>
</template>
@@ -0,0 +1,77 @@
<template>
<label class="flex flex-col gap-1.5 text-xs">
<span class="font-medium text-muted-foreground">{{ label }}</span>
<div class="flex gap-2">
<input
type="text"
readonly
class="min-w-0 flex-1 rounded-md border border-input bg-muted/40 px-3 py-2 font-mono text-xs text-foreground"
:value="value"
/>
<button
type="button"
class="shrink-0 rounded-md border border-border px-3 py-2 text-xs font-medium transition-colors hover:bg-muted/60"
@click="onCopyClick"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
</div>
</label>
</template>
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue';
const COPIED_RESET_MS = 1000;
const props = defineProps({
label: {
type: String,
required: true
},
value: {
type: String,
required: true
}
});
const copied = ref(false);
let copiedResetTimeout: ReturnType<typeof setTimeout> | undefined;
// component stay mounted when user switching between images so we need to reset the copied state when the value changes
watch(
() => props.value,
() => resetCopied()
);
onUnmounted(() => clearCopiedResetTimeout());
const clearCopiedResetTimeout = (): void => {
if (copiedResetTimeout !== undefined) {
clearTimeout(copiedResetTimeout);
copiedResetTimeout = undefined;
}
};
const resetCopied = (): void => {
clearCopiedResetTimeout();
copied.value = false;
};
const onCopyClick = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(props.value);
copied.value = true;
clearCopiedResetTimeout();
copiedResetTimeout = setTimeout(() => {
copied.value = false;
}, COPIED_RESET_MS);
} catch (error) {
console.error('[ShareLinkField] copy failed', error);
}
};
</script>
@@ -0,0 +1,134 @@
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center">
<div class="flex w-full max-w-lg flex-col rounded-lg border border-border bg-card shadow-lg">
<header class="border-b border-border px-4 py-3">
<h2 class="text-base font-medium text-card-foreground">
Configure upload
</h2>
<p class="mt-1 text-xs text-muted-foreground">Settings apply to all chosen images.</p>
</header>
<div class="px-4 py-4">
<UploadSettingsPanel v-model="settings" />
<p v-if="uploadValidationError" class="mt-3 text-sm text-destructive">
{{ uploadValidationError }}
</p>
</div>
<footer class="flex flex-col-reverse gap-2 border-t border-border px-4 py-3 sm:flex-row sm:justify-end">
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted/60"
@click="close"
>
Cancel
</button>
<button
type="button"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
@click="onUploadClick"
>
Upload
</button>
</footer>
</div>
</div>
<div
v-if="open && metadataErrorConfirmOpen"
class="fixed inset-0 z-60 flex items-end justify-center bg-black/50 p-4 sm:items-center"
>
<div class="flex w-full max-w-md flex-col rounded-lg border border-border bg-card shadow-lg">
<header class="border-b border-border px-4 py-3">
<h2 class="text-base font-medium text-card-foreground">
Metadata errors
</h2>
</header>
<p class="px-4 py-4 text-sm text-muted-foreground">
{{ metadataErrorCount }}
{{ metadataErrorCount === 1 ? 'image has' : 'images have' }} metadata error. Do you want to upload
them with original metadata?
</p>
<footer class="flex flex-col-reverse gap-2 border-t border-border px-4 py-3 sm:flex-row sm:justify-end">
<button
type="button"
class="rounded-md border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-muted/60"
@click="metadataErrorConfirmOpen = false"
>
Cancel
</button>
<button
type="button"
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
@click="emitUpload(true)"
>
Upload
</button>
</footer>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, type PropType } from 'vue';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
const open = defineModel<boolean>('open', { required: true });
const settings = defineModel<UploadBatchSettingsForm>('settings', { required: true });
const props = defineProps({
metadataErrorCount: {
type: Number as PropType<number>,
required: true
}
});
const emit = defineEmits<{
upload: [uploadDespiteMetadataFailure?: boolean];
}>();
const uploadValidationError = ref<string | null>(null);
const metadataErrorConfirmOpen = ref(false);
watch(open, isOpen => {
if (!isOpen) {
uploadValidationError.value = null;
metadataErrorConfirmOpen.value = false;
}
});
const close = (): void => {
open.value = false;
};
const emitUpload = (uploadDespiteMetadataFailure?: boolean): void => {
uploadValidationError.value = null;
metadataErrorConfirmOpen.value = false;
emit('upload', uploadDespiteMetadataFailure);
};
const onUploadClick = (): void => {
const error = validateUploadBatchSettings(settings.value);
if (error) {
uploadValidationError.value = error;
return;
}
if (props.metadataErrorCount > 0) {
metadataErrorConfirmOpen.value = true;
return;
}
emitUpload();
};
</script>
@@ -0,0 +1,57 @@
<template>
<div>
<label
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
:class="disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
>
Choose images
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="hidden"
:disabled="disabled"
@change="onFileInputChange"
/>
</label>
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, type PropType } from 'vue';
defineProps({
disabled: {
type: Boolean,
required: true
},
selectionError: {
type: String as PropType<string | null>,
default: null
}
});
const emit = defineEmits<{
pick: [files: File[]];
}>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const onFileInputChange = (event: Event): void => {
const input = event.target as HTMLInputElement;
const picked = input.files ? [...input.files] : [];
emit('pick', picked);
};
const clearInput = (): void => {
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
};
defineExpose({ clearInput });
</script>
@@ -0,0 +1,74 @@
<template>
<ul v-if="rows.length > 0" class="divide-y divide-border rounded-lg border border-border">
<li v-for="row in rows" :key="row.key">
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
:class="isRowSelected(row) ? 'bg-muted/80' : ''"
@click="emit('select', row.file)"
>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
</span>
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="metadataStatusClass(row.metadata)">
{{ row.metadata.message }}
</span>
<span class="shrink-0 max-w-32 truncate text-xs sm:max-w-40" :class="uploadStatusClass(row.upload)">
{{ row.upload.message }}
</span>
</button>
</li>
</ul>
</template>
<script setup lang="ts">
import { type PropType } from 'vue';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import type { UploadQueueRow } from '@/types/upload/UploadQueueRow';
import prettyBytes from 'pretty-bytes';
const props = defineProps({
rows: {
type: Array as PropType<UploadQueueRow[]>,
required: true
},
selectedFile: {
type: Object as PropType<File | null>,
default: null
}
});
const emit = defineEmits<{
select: [file: File];
}>();
const isRowSelected = (row: UploadQueueRow): boolean =>
props.selectedFile !== null && makeKeyFromFile(props.selectedFile) === row.key;
const metadataStatusClass = (entry: MetadataPreviewEntry): string => {
if (entry.status === 'error') {
return 'text-destructive';
}
if (entry.status === 'ready') {
return 'text-success';
}
return 'text-muted-foreground';
};
const uploadStatusClass = (entry: UploadQueueEntry): string => {
if (entry.status === 'failed') {
return 'text-destructive';
}
if (entry.status === 'uploaded') {
return 'text-success';
}
return 'text-muted-foreground';
};
</script>
@@ -0,0 +1,130 @@
<template>
<div class="flex flex-col gap-5">
<fieldset class="space-y-3">
<legend class="text-sm font-medium text-foreground">Share mode</legend>
<div class="space-y-3">
<label
class="flex cursor-pointer gap-3 rounded-md border border-border p-3 has-checked:border-primary has-checked:bg-muted/40"
>
<input v-model="settings.mode" class="mt-0.5" type="radio" name="upload-mode" value="zk" />
<span class="min-w-0 flex-1">
<span class="text-sm font-medium">Zero knowledge</span>
<p class="mt-1 text-xs text-muted-foreground">
Images are E2E encrypted. The server has no technical ability to decrypt them. Having the link
gives permission to view the image. You can additionally password-protect the upload so the
link alone is not enough. Great for private sharing, but doesn't work with BBCode, HTML,
etc.
</p>
</span>
</label>
<label
class="flex cursor-pointer gap-3 rounded-md border border-border p-3 has-checked:border-primary has-checked:bg-muted/40"
>
<input v-model="settings.mode" class="mt-0.5" type="radio" name="upload-mode" value="hotlink" />
<span class="min-w-0 flex-1">
<span class="text-sm font-medium">Hotlink</span>
<p class="mt-1 text-xs text-muted-foreground">
Still encrypted at rest, but the server decrypts on the fly. Works with BBCode, HTML,
etc.
Encryption here serves mainly as
<span class="whitespace-nowrap">
<span class="font-bold">crypto-shred</span
><sup class="font-bold text-[0.7em] text-muted-foreground">1</sup> </span
>, because hotlink images are intended to be public.
</p>
</span>
</label>
</div>
<p id="crypto-shred-footnote" class="text-xs italic leading-snug text-muted-foreground">
<sup class="mr-1 font-bold not-italic text-[0.85em]">1</sup>
Hard drives do not guarantee that deleted files cannot be recovered. Crypto-shred consists of storing
data as encrypted blobs, so recovered leftovers are useless without the decryption key, which is never
stored on the server.
</p>
</fieldset>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1.5 text-sm">
<span class="font-medium">Expiry</span>
<select
v-model="settings.expiryPreset"
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option v-for="option in expiryOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="flex flex-col gap-1.5 text-sm">
<span class="font-medium">Max views</span>
<select
v-model="settings.maxViewsPreset"
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option v-for="option in maxViewsOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<div v-if="settings.mode === 'zk'" class="space-y-3 rounded-md border border-border p-3">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input v-model="settings.passwordEnabled" type="checkbox" class="rounded border-input" />
<span class="font-medium">Set password</span>
</label>
<p class="text-xs text-muted-foreground">
Requires the link and this password to decrypt. Use when the link alone should not be enough.
</p>
<template v-if="settings.passwordEnabled">
<label class="flex flex-col gap-1.5 text-sm">
<span>Password</span>
<input
v-model="settings.password"
type="password"
autocomplete="new-password"
class="rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</label>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { config } from '@/config';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
const settings = defineModel<UploadBatchSettingsForm>({ required: true });
const { maxViewsMax } = config.validation.upload;
const expiryOptions: { value: UploadExpiryPreset; label: string }[] = [
{ value: 'never', label: 'Never' },
{ value: '1h', label: '1 hour' },
{ value: '24h', label: '24 hours' },
{ value: '7d', label: '7 days' },
{ value: '30d', label: '30 days' }
];
const maxViewsOptions = computed(() => {
const options: { value: UploadMaxViewsPreset; label: string; numeric: number | null }[] = [
{ value: 'unlimited', label: 'Unlimited', numeric: null },
{ value: '1', label: '1- Burn after read', numeric: 1 },
{ value: '10', label: '10', numeric: 10 },
{ value: '100', label: '100', numeric: 100 },
{ value: '1000', label: '1000', numeric: 1000 },
{ value: '10000', label: '10000', numeric: 10000 }
];
return options.filter(option => option.numeric === null || option.numeric <= maxViewsMax);
});
</script>
@@ -0,0 +1,78 @@
<template>
<section class="flex min-h-48 flex-col rounded-lg border border-border bg-card">
<header class="border-b border-border px-4 py-3">
<h2 class="text-sm font-medium text-card-foreground">Share links</h2>
<p v-if="fileName" class="mt-0.5 truncate text-xs text-muted-foreground">{{ fileName }}</p>
</header>
<div class="flex flex-1 flex-col gap-3 p-4">
<p v-if="!fileName" class="text-sm text-muted-foreground">Select an image to view share links.</p>
<template v-else-if="shareUrls">
<InlineTabList v-model="activeNetworkTab" :options="shareNetworkTabOptions" />
<div class="flex flex-col gap-3">
<ShareLinkField label="Viewer link" :value="shareUrls.viewer[activeNetworkTab]" />
<ShareLinkField label="Delete link" :value="shareUrls.delete[activeNetworkTab]" />
<ShareLinkField
v-if="shareUrls.mode === 'hotlink'"
label="Direct image"
:value="shareUrls.direct[activeNetworkTab]"
/>
<template v-if="activeNetworkTab === 'clearnet'">
<template v-if="shareUrls.mode === 'hotlink'">
<ShareLinkField label="BBCode" :value="shareUrls.bbcode" />
<ShareLinkField label="Markdown image" :value="shareUrls.markdownImage" />
<ShareLinkField label="HTML image" :value="shareUrls.htmlImage" />
</template>
<template v-else>
<ShareLinkField label="Markdown link" :value="shareUrls.markdownLink" />
<ShareLinkField label="HTML link" :value="shareUrls.htmlLink" />
</template>
</template>
</div>
</template>
<p v-else class="text-sm text-destructive">No share links are available.</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch, type PropType } from 'vue';
import type { ShareNetworkTab } from '@/types/share/ShareNetworkTab';
import type { InlineTabOption } from '@/types/ui/InlineTabOption';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import { buildUploadedShareUrls } from '@/utils/share/buildUploadedShareUrls';
const shareNetworkTabOptions: InlineTabOption<ShareNetworkTab>[] = [
{ value: 'clearnet', label: 'Clearnet' },
{ value: 'onion', label: 'Onion' }
];
const props = defineProps({
fileName: {
type: String as PropType<string | null>,
default: null
},
upload: {
type: Object as PropType<UploadQueueEntry>,
required: true
}
});
const activeNetworkTab = ref<ShareNetworkTab>('clearnet');
const shareUrls = computed(() =>
props.fileName ? buildUploadedShareUrls(props.upload, props.fileName) : null
);
watch(
() => props.fileName,
() => {
activeNetworkTab.value = 'clearnet';
}
);
</script>
+10 -2
View File
@@ -10,10 +10,18 @@ const env = (key: string): string => {
const envInt = (key: string): number => parseInt(env(key), 10);
const isEnabled = (key: string): boolean => env(key) === 'true';
export const config = {
baseUrl: env('VITE_API_BASE_URL'),
clearnetDomain: env('VITE_CLEARNET_DOMAIN'),
onionHostname: env('VITE_ONION_HOSTNAME'),
shareOrigins: {
clearnet: `${env('VITE_CLEARNET_SCHEME')}://${env('VITE_CLEARNET_DOMAIN')}`,
onion: `http://${env('VITE_ONION_HOSTNAME')}`
},
altcha: {
enabled: isEnabled('VITE_ALTCHA_ENABLED'),
maxNumber: envInt('VITE_ALTCHA_MAX_NUMBER')
},
validation: {
upload: {
maxQueueSize: envInt('VITE_UPLOAD_MAX_QUEUE_SIZE'),
+70
View File
@@ -0,0 +1,70 @@
import { solveChallengeWorkers } from 'altcha-lib/v1';
import type { Challenge, Payload } from 'altcha-lib/v1/types';
import { defineStore } from 'pinia';
import { config } from '@/config';
import { api } from '@/plugins/axios';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import AltchaSolveWorker from 'altcha-lib/v1/worker?worker';
export const useAltchaStore = defineStore('altcha', () => {
const runProofOfWork = async (): Promise<string | undefined> => {
if (!config.altcha.enabled) {
return undefined;
}
const challenge = await fetchChallenge();
return encodeSolvedChallenge(challenge);
};
const fetchChallenge = async (): Promise<Challenge> => {
const { data } = await api.get<Challenge>('/altcha/challenge');
return data;
};
const encodeSolvedChallenge = async ({
algorithm,
challenge: challengeValue,
salt,
signature,
maxnumber
}: Challenge): Promise<string> => {
const { maxNumber: fallbackMaxNumber } = config.altcha;
const maxNumber = maxnumber ?? fallbackMaxNumber;
const cuncurrency = Math.min(8, Math.max(1, navigator.hardwareConcurrency ?? 4));
const solution = await solveChallengeWorkers(
() => new AltchaSolveWorker(),
cuncurrency,
challengeValue,
salt,
algorithm,
maxNumber
);
if (!solution) {
throw new Error('ALTCHA proof of work could not be solved');
}
const payload: Payload = {
algorithm,
challenge: challengeValue,
number: solution.number,
salt,
signature
};
const json = JSON.stringify(payload);
const textEncoder = new TextEncoder();
const bytes = textEncoder.encode(json);
return bytesToBase64(bytes);
};
return {
runProofOfWork
};
});
+49 -59
View File
@@ -2,23 +2,29 @@ import { defineStore, storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useExiftoolStore } from '@/stores/exiftool';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool';
const METADATA_LOADING_MESSAGE = 'Loading metadata';
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata...';
const EXIFTOOL_INITIALIZING_MESSAGE = 'Initializing ExifTool...';
const METADATA_PROCESSING_MESSAGE = 'Stripping metadata...';
const METADATA_READY_MESSAGE = 'Metadata stripped';
const METADATA_LOAD_ERROR_MESSAGE = 'Could not load image metadata';
const METADATA_STRIP_ERROR_MESSAGE = 'Could not strip image metadata';
const METADATA_IDLE_MESSAGE = 'Waiting to load metadata';
const METADATA_READY_MESSAGE = 'Metadata stripped';
const EXIFTOOL_PREPARE_ERROR_MESSAGE = 'Could not prepare ExifTool';
const METADATA_IDLE_PREVIEW: MetadataPreviewEntry = {
status: 'idle',
message: METADATA_IDLE_MESSAGE
};
const METADATA_LOADING_PREVIEW: MetadataPreviewEntry = {
status: 'loading',
message: METADATA_LOADING_MESSAGE
const METADATA_LOAD_ERROR_PREVIEW: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE
};
const EXIFTOOL_PREPARE_ERROR_PREVIEW: MetadataPreviewEntry = {
status: 'error',
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
};
export const useMetadataStore = defineStore('metadata', () => {
@@ -30,7 +36,13 @@ export const useMetadataStore = defineStore('metadata', () => {
const { initStatus: exiftoolInitStatus } = storeToRefs(exiftoolStore);
const { initializeExiftool, readMetadata, stripAllMetadata } = exiftoolStore;
const makeKeyFromFile = (file: File): string => `${file.name}\0${file.size}\0${file.lastModified}`;
const setPreviewEntry = (key: string, entry: MetadataPreviewEntry): void => {
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
};
const setStrippedBlob = (key: string, blob: Blob): void => {
strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: blob };
};
const getPreview = (file?: File | null): MetadataPreviewEntry => {
if (!file) {
@@ -56,48 +68,27 @@ export const useMetadataStore = defineStore('metadata', () => {
const key = makeKeyFromFile(file);
if (exiftoolInitStatus.value === 'error') {
const entry: MetadataPreviewEntry = {
status: 'error',
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
};
setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
return entry;
return EXIFTOOL_PREPARE_ERROR_PREVIEW;
}
if (exiftoolInitStatus.value === 'ready') {
previewsByKey.value = {
...previewsByKey.value,
[key]: METADATA_LOADING_PREVIEW
};
} else {
previewsByKey.value = {
...previewsByKey.value,
[key]: { status: 'pending', message: EXIFTOOL_INITIALIZING_MESSAGE }
};
if (exiftoolInitStatus.value !== 'ready') {
setPreviewEntry(key, { status: 'initializing', message: EXIFTOOL_INITIALIZING_MESSAGE });
try {
await initializeExiftool();
previewsByKey.value = {
...previewsByKey.value,
[key]: METADATA_LOADING_PREVIEW
};
} catch (error) {
console.error('[metadata] ExifTool init failed', { file: file.name, error });
const entry: MetadataPreviewEntry = {
status: 'error',
message: EXIFTOOL_PREPARE_ERROR_MESSAGE
};
setPreviewEntry(key, EXIFTOOL_PREPARE_ERROR_PREVIEW);
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
return entry;
return EXIFTOOL_PREPARE_ERROR_PREVIEW;
}
}
setPreviewEntry(key, { status: 'processing', message: METADATA_PROCESSING_MESSAGE });
let output: string;
try {
@@ -105,14 +96,9 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) {
console.error('[metadata] read failed', { file: file.name, error });
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE
};
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
return entry;
return METADATA_LOAD_ERROR_PREVIEW;
}
let strippedBlob: Blob;
@@ -129,10 +115,11 @@ export const useMetadataStore = defineStore('metadata', () => {
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_STRIP_ERROR_MESSAGE
message: METADATA_STRIP_ERROR_MESSAGE,
output
};
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
setPreviewEntry(key, entry);
return entry;
}
@@ -149,17 +136,12 @@ export const useMetadataStore = defineStore('metadata', () => {
} catch (error) {
console.error('[metadata] read after strip failed', { file: file.name, error });
const entry: MetadataPreviewEntry = {
status: 'error',
message: METADATA_LOAD_ERROR_MESSAGE
};
setPreviewEntry(key, METADATA_LOAD_ERROR_PREVIEW);
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
return entry;
return METADATA_LOAD_ERROR_PREVIEW;
}
strippedBlobByKey.value = { ...strippedBlobByKey.value, [key]: strippedBlob };
setStrippedBlob(key, strippedBlob);
const entry: MetadataPreviewEntry = {
status: 'ready',
@@ -168,13 +150,22 @@ export const useMetadataStore = defineStore('metadata', () => {
afterStripOutput
};
previewsByKey.value = { ...previewsByKey.value, [key]: entry };
setPreviewEntry(key, entry);
return entry;
};
const loadPreviews = (files: readonly File[]): Promise<MetadataPreviewEntry[]> =>
Promise.all(files.map(file => loadPreview(file)));
const loadPreviews = async (files: readonly File[]): Promise<MetadataPreviewEntry[]> => {
const results: MetadataPreviewEntry[] = [];
for (const file of files) {
const result = await loadPreview(file);
results.push(result);
}
return results;
};
const clearAllPreviews = (): void => {
previewsByKey.value = {};
@@ -184,7 +175,6 @@ export const useMetadataStore = defineStore('metadata', () => {
return {
previewsByKey,
strippedBlobByKey,
makeKeyFromFile,
getPreview,
getStrippedBlob,
loadPreview,
+235 -7
View File
@@ -1,12 +1,86 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { ALTCHA_REQUEST_HEADER } from '@/consts/altchaRequestHeader';
import { api } from '@/plugins/axios';
import { useAltchaStore } from '@/stores/altcha';
import { useMetadataStore } from '@/stores/metadata';
import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
import type { CreatedUploadResult } from '@/types/upload/CreatedUploadResult';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import { buildUploadOptionsFromBatchSettings } from '@/utils/upload/buildUploadOptionsFromBatchSettings';
import { buildZkUploadPrepareOptionsFromBatchSettings } from '@/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings';
import { prepareHotlinkUploadRequest } from '@/utils/upload/prepareHotlinkUploadRequest';
import { prepareZkUploadRequest } from '@/utils/upload/prepareZkUploadRequest';
import type { PreparedZkUploadRequest } from '@/types/upload/PreparedZkUploadRequest';
import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
const UPLOAD_IDLE_MESSAGE = 'Waiting to upload...';
const UPLOAD_QUEUED_MESSAGE = 'Upload queued';
const UPLOAD_PREPARING_MESSAGE = 'Preparing upload...';
const UPLOAD_UPLOADING_MESSAGE = 'Uploading...';
const UPLOAD_UPLOADED_MESSAGE = 'Uploaded';
const UPLOAD_READ_ERROR_MESSAGE = 'Could not read image';
const UPLOAD_PROOF_OF_WORK_ERROR_MESSAGE = 'Proof of work failed';
const UPLOAD_FAILED_MESSAGE = 'Upload failed';
const UPLOAD_SKIPPED_METADATA_MESSAGE = 'Metadata not ready';
const UPLOAD_STRIPPED_MISSING_MESSAGE = 'Stripped image missing';
const UPLOAD_UNKNOWN_MODE_MESSAGE = 'Unknown share mode';
const UPLOAD_IDLE_ENTRY: UploadQueueEntry = {
status: 'idle',
message: UPLOAD_IDLE_MESSAGE
};
const UPLOAD_QUEUED_ENTRY: UploadQueueEntry = {
status: 'queued',
message: UPLOAD_QUEUED_MESSAGE
};
const UPLOAD_UPLOADING_ENTRY: UploadQueueEntry = {
status: 'uploading',
message: UPLOAD_UPLOADING_MESSAGE
};
const UPLOAD_FAILED_ENTRY: UploadQueueEntry = {
status: 'failed',
message: UPLOAD_FAILED_MESSAGE
};
const UPLOAD_STRIPPED_MISSING_ENTRY: UploadQueueEntry = {
status: 'failed',
message: UPLOAD_STRIPPED_MISSING_MESSAGE
};
export const useUploadStore = defineStore('upload', () => {
const createZkUpload = async (body: CreateZkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
const uploadByKey = ref<Record<string, UploadQueueEntry>>({});
const metadataStore = useMetadataStore();
const altchaStore = useAltchaStore();
const { getPreview, getStrippedBlob } = metadataStore;
const setUploadEntry = (key: string, entry: UploadQueueEntry): void => {
uploadByKey.value = { ...uploadByKey.value, [key]: entry };
};
const getUpload = (file?: File | null): UploadQueueEntry => {
if (!file) {
return UPLOAD_IDLE_ENTRY;
}
const key = makeKeyFromFile(file);
return uploadByKey.value[key] ?? UPLOAD_IDLE_ENTRY;
};
const clearAllUploads = (): void => {
uploadByKey.value = {};
};
const uploadZk = async (body: CreateZkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
const { data } = await api.post<CreatedUploadResult>('/uploads/zk', body, {
headers: getUploadRequestHeaders(altcha)
});
@@ -14,10 +88,7 @@ export const useUploadStore = defineStore('upload', () => {
return data;
};
const createHotlinkUpload = async (
body: CreateHotlinkUploadRequest,
altcha?: string
): Promise<CreatedUploadResult> => {
const uploadHotlink = async (body: CreateHotlinkUploadRequest, altcha?: string): Promise<CreatedUploadResult> => {
const { data } = await api.post<CreatedUploadResult>('/uploads/hotlink', body, {
headers: getUploadRequestHeaders(altcha)
});
@@ -27,8 +98,165 @@ export const useUploadStore = defineStore('upload', () => {
const getUploadRequestHeaders = (altcha?: string) => (altcha ? { [ALTCHA_REQUEST_HEADER]: altcha } : undefined);
const uploadBatch = async (
files: readonly File[],
settings: UploadBatchSettingsForm,
uploadDespiteMetadataFailure = false
): Promise<void> => {
initializeBatchUploadState(files, uploadDespiteMetadataFailure);
for (const file of files) {
const key = makeKeyFromFile(file);
const entry = uploadByKey.value[key];
if (!entry || entry.status !== 'queued') {
continue;
}
const strippedBlob = getStrippedBlob(file);
const preview = getPreview(file);
const uploadOriginal = !strippedBlob && uploadDespiteMetadataFailure && preview.status === 'error';
if (!strippedBlob && !uploadOriginal) {
setUploadEntry(key, UPLOAD_STRIPPED_MISSING_ENTRY);
continue;
}
const sourceBlob = strippedBlob ?? file;
setUploadEntry(key, { status: 'preparing', message: UPLOAD_PREPARING_MESSAGE });
let plaintext: Uint8Array;
const mimeType = sourceBlob.type || file.type || 'application/octet-stream';
try {
const arrayBuffer = await sourceBlob.arrayBuffer();
plaintext = new Uint8Array(arrayBuffer);
} catch (error) {
console.error('[upload] read stripped blob failed', { file: file.name, error });
setUploadEntry(key, {
status: 'failed',
message: UPLOAD_READ_ERROR_MESSAGE
});
continue;
}
let altcha: string | undefined;
try {
altcha = await altchaStore.runProofOfWork();
} catch (error) {
console.error('[upload] altcha failed', { file: file.name, error });
setUploadEntry(key, {
status: 'failed',
message: UPLOAD_PROOF_OF_WORK_ERROR_MESSAGE
});
continue;
}
if (settings.mode === 'zk') {
const options = buildZkUploadPrepareOptionsFromBatchSettings(settings);
let prepared: PreparedZkUploadRequest;
try {
prepared = await prepareZkUploadRequest(plaintext, options);
} catch (error) {
console.error('[upload] encrypt failed', { file: file.name, error });
setUploadEntry(key, UPLOAD_FAILED_ENTRY);
continue;
}
setUploadEntry(key, UPLOAD_UPLOADING_ENTRY);
try {
const { request, fragmentKey } = prepared;
const result = await uploadZk(request, altcha);
setUploadEntry(key, {
status: 'uploaded',
message: UPLOAD_UPLOADED_MESSAGE,
uploadId: result.id,
mode: 'zk',
deleteToken: result.deleteToken,
urlSecret: fragmentKey
});
} catch (error) {
console.error('[upload] upload failed', { file: file.name, error });
setUploadEntry(key, UPLOAD_FAILED_ENTRY);
}
} else if (settings.mode === 'hotlink') {
const options = buildUploadOptionsFromBatchSettings(settings);
let prepared: PreparedHotlinkUploadRequest;
try {
prepared = await prepareHotlinkUploadRequest(plaintext, mimeType, options);
} catch (error) {
console.error('[upload] encrypt failed', { file: file.name, error });
setUploadEntry(key, UPLOAD_FAILED_ENTRY);
continue;
}
setUploadEntry(key, UPLOAD_UPLOADING_ENTRY);
try {
const { request, secret } = prepared;
const result = await uploadHotlink(request, altcha);
setUploadEntry(key, {
status: 'uploaded',
message: UPLOAD_UPLOADED_MESSAGE,
uploadId: result.id,
mode: 'hotlink',
deleteToken: result.deleteToken,
urlSecret: secret
});
} catch (error) {
console.error('[upload] upload failed', { file: file.name, error });
setUploadEntry(key, UPLOAD_FAILED_ENTRY);
}
} else {
setUploadEntry(key, { status: 'failed', message: UPLOAD_UNKNOWN_MODE_MESSAGE });
}
}
};
const initializeBatchUploadState = (files: readonly File[], uploadDespiteMetadataFailure: boolean): void => {
uploadByKey.value = {};
for (const file of files) {
const key = makeKeyFromFile(file);
const preview = getPreview(file);
const strippedBlob = getStrippedBlob(file);
if (preview.status === 'ready') {
if (strippedBlob) {
setUploadEntry(key, UPLOAD_QUEUED_ENTRY);
} else {
setUploadEntry(key, UPLOAD_STRIPPED_MISSING_ENTRY);
}
} else if (preview.status === 'error' && uploadDespiteMetadataFailure) {
setUploadEntry(key, UPLOAD_QUEUED_ENTRY);
} else {
setUploadEntry(key, { status: 'skipped', message: UPLOAD_SKIPPED_METADATA_MESSAGE });
}
}
};
return {
createZkUpload,
createHotlinkUpload
uploadByKey,
getUpload,
clearAllUploads,
uploadBatch,
uploadZk,
uploadHotlink
};
});
@@ -1,6 +1,6 @@
export type MetadataPreviewEntry =
| { status: 'idle'; message: string }
| { status: 'pending'; message: string }
| { status: 'loading'; message: string }
| { status: 'initializing'; message: string }
| { status: 'processing'; message: string }
| { status: 'ready'; message: string; output: string; afterStripOutput: string }
| { status: 'error'; message: string };
| { status: 'error'; message: string; output?: string };
@@ -0,0 +1 @@
export type ShareNetworkTab = 'clearnet' | 'onion';
@@ -0,0 +1,25 @@
export type ShareNetworkPair = {
clearnet: string;
onion: string;
};
type ShareUrlsCore = {
viewer: ShareNetworkPair;
delete: ShareNetworkPair;
};
export type HotlinkShareUrls = ShareUrlsCore & {
mode: 'hotlink';
direct: ShareNetworkPair;
bbcode: string;
markdownImage: string;
htmlImage: string;
};
export type ZkShareUrls = ShareUrlsCore & {
mode: 'zk';
markdownLink: string;
htmlLink: string;
};
export type UploadedShareUrls = HotlinkShareUrls | ZkShareUrls;
+4
View File
@@ -0,0 +1,4 @@
export type InlineTabOption<T extends string = string> = {
value: T;
label: string;
};
@@ -0,0 +1,6 @@
import type { CreateHotlinkUploadRequest } from './CreateHotlinkUploadRequest';
export type PreparedHotlinkUploadRequest = {
secret: string;
request: CreateHotlinkUploadRequest;
};
@@ -0,0 +1,6 @@
import type { CreateZkUploadRequest } from './CreateZkUploadRequest';
export type PreparedZkUploadRequest = {
fragmentKey: string;
request: CreateZkUploadRequest;
};
@@ -0,0 +1,11 @@
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
import type { UploadMode } from '@/types/upload/UploadMode';
export type UploadBatchSettingsForm = {
mode: UploadMode;
expiryPreset: UploadExpiryPreset;
maxViewsPreset: UploadMaxViewsPreset;
passwordEnabled: boolean;
password: string;
};
@@ -0,0 +1,6 @@
import type { ManipulateType } from 'dayjs';
export type UploadExpiryOffset = {
amount: number;
unit: ManipulateType;
};
@@ -0,0 +1 @@
export type UploadExpiryPreset = 'never' | '1h' | '24h' | '7d' | '30d';
@@ -0,0 +1 @@
export type UploadMaxViewsPreset = 'unlimited' | '1' | '10' | '100' | '1000' | '10000';
@@ -0,0 +1,17 @@
import type { UploadMode } from '@/types/upload/UploadMode';
export type UploadQueueEntry =
| { status: 'idle'; message: string }
| { status: 'queued'; message: string }
| { status: 'preparing'; message: string }
| { status: 'uploading'; message: string }
| {
status: 'uploaded';
message: string;
uploadId: string;
deleteToken: string;
urlSecret: string;
mode: UploadMode;
}
| { status: 'failed'; message: string }
| { status: 'skipped'; message: string };
@@ -0,0 +1,9 @@
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
export type UploadQueueRow = {
file: File;
key: string;
metadata: MetadataPreviewEntry;
upload: UploadQueueEntry;
};
@@ -0,0 +1,2 @@
export const makeKeyFromFile = (file: File): string =>
`${file.name}\0${file.size}\0${file.lastModified}`;
@@ -1,82 +0,0 @@
import { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import { describe, expect, it } from 'vitest';
import { resolveAxiosErrorMessage } from '@/utils/http/resolveAxiosErrorMessage';
const fallback = 'Upload failed';
const axiosErrorWithResponse = (data: unknown, message = 'Request failed'): AxiosError => {
return new AxiosError(
message,
AxiosError.ERR_BAD_REQUEST,
{} as InternalAxiosRequestConfig,
{},
{
status: 400,
statusText: 'Bad Request',
headers: {},
config: {} as InternalAxiosRequestConfig,
data
}
);
};
describe('resolveAxiosErrorMessage', () => {
it('falls back for non-Axios errors', () => {
expect(resolveAxiosErrorMessage(new Error('network'), fallback)).toBe('network');
expect(resolveAxiosErrorMessage('oops', fallback)).toBe(fallback);
});
it('falls back when Axios error has no response', () => {
const error = new AxiosError('timeout', AxiosError.ETIMEDOUT);
expect(resolveAxiosErrorMessage(error, fallback)).toBe('timeout');
});
it('uses string response data', () => {
const error = axiosErrorWithResponse('Rate limit exceeded');
expect(resolveAxiosErrorMessage(error, fallback)).toBe('Rate limit exceeded');
});
it('uses object message string (Nest-style)', () => {
const error = axiosErrorWithResponse({ message: 'Invalid ciphertext' });
expect(resolveAxiosErrorMessage(error, fallback)).toBe('Invalid ciphertext');
});
it('uses a single string from message array', () => {
const error = axiosErrorWithResponse({ message: ['iv must be valid base64'] });
expect(resolveAxiosErrorMessage(error, fallback)).toBe('iv must be valid base64');
});
it('joins multiple strings in message array', () => {
const error = axiosErrorWithResponse({
message: ['expiresAt must be a date', 'maxViews must be a number']
});
expect(resolveAxiosErrorMessage(error, fallback)).toBe(
'expiresAt must be a date, maxViews must be a number'
);
});
it('ignores non-string entries in message array', () => {
const error = axiosErrorWithResponse({
message: ['valid', 42, null, 'also valid']
});
expect(resolveAxiosErrorMessage(error, fallback)).toBe('valid, also valid');
});
it('falls back when response data has no usable message', () => {
const error = axiosErrorWithResponse({ message: [] }, 'HTTP 400');
expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400');
});
it('falls back when response data is an object without message', () => {
const error = axiosErrorWithResponse({ statusCode: 400 }, 'HTTP 400');
expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400');
});
});
@@ -1,40 +0,0 @@
import { isAxiosError } from 'axios';
import { resolveErrorMessage } from '@/utils/resolveErrorMessage';
const getAxiosErrorResponseMessage = (data: unknown): string | null => {
if (typeof data === 'string') {
return data;
}
if (typeof data !== 'object' || data === null || !('message' in data)) {
return null;
}
const { message } = data;
if (typeof message === 'string') {
return message;
}
if (Array.isArray(message)) {
const texts = message.filter((item): item is string => typeof item === 'string');
if (texts.length === 1) {
return texts[0];
}
if (texts.length > 1) {
return texts.join(', ');
}
}
return null;
};
export const resolveAxiosErrorMessage = (error: unknown, fallback: string): string => {
if (!isAxiosError(error) || !error.response) {
return resolveErrorMessage(error, fallback);
}
return getAxiosErrorResponseMessage(error.response.data) ?? resolveErrorMessage(error, fallback);
};
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { buildBbcodeImage } from '@/utils/share/buildBbcodeImage';
describe('buildBbcodeImage', () => {
it('wraps the image URL in BBCode img tags', () => {
expect(buildBbcodeImage('https://example.com/i/1/secret')).toBe(
'[img]https://example.com/i/1/secret[/img]'
);
});
});
@@ -0,0 +1 @@
export const buildBbcodeImage = (imageUrl: string): string => `[img]${imageUrl}[/img]`;
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { buildDeletePath } from '@/utils/share/buildDeletePath';
describe('buildDeletePath', () => {
it('builds delete route with encoded token', () => {
expect(buildDeletePath('upload-id', 'abc+def/token')).toBe('/d/upload-id?token=abc%2Bdef%2Ftoken');
});
});
@@ -0,0 +1,2 @@
export const buildDeletePath = (uploadId: string, deleteToken: string): string =>
`/d/${uploadId}?token=${encodeURIComponent(deleteToken)}`;
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { buildHotlinkViewerPath } from '@/utils/share/buildHotlinkViewerPath';
describe('buildHotlinkViewerPath', () => {
it('places upload id and secret in the viewer path', () => {
expect(buildHotlinkViewerPath('upload-uuid', 'url-safe-secret')).toBe('/h/upload-uuid/url-safe-secret');
});
});
@@ -0,0 +1 @@
export const buildHotlinkViewerPath = (uploadId: string, secret: string): string => `/h/${uploadId}/${secret}`;
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { buildHtmlImage } from '@/utils/share/buildHtmlImage';
describe('buildHtmlImage', () => {
it('builds an img tag with escaped alt', () => {
expect(buildHtmlImage('https://example.com/i/1/secret', 'photo.jpg')).toBe(
'<img src="https://example.com/i/1/secret" alt="photo.jpg" />'
);
});
it('escapes quotes and markup in alt text', () => {
expect(buildHtmlImage('https://example.com/i/1/secret', 'x" onerror="alert(1)')).toBe(
'<img src="https://example.com/i/1/secret" alt="x&quot; onerror=&quot;alert(1)" />'
);
});
it('escapes special characters in src when present', () => {
expect(buildHtmlImage('https://example.com/i/1/"bad', 'image')).toBe(
'<img src="https://example.com/i/1/&quot;bad" alt="image" />'
);
});
});
@@ -0,0 +1,4 @@
import escapeHtml from 'escape-html';
export const buildHtmlImage = (imageUrl: string, alt = 'image'): string =>
`<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(alt)}" />`;
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { buildHtmlLink } from '@/utils/share/buildHtmlLink';
describe('buildHtmlLink', () => {
it('builds an anchor with escaped href and label', () => {
expect(buildHtmlLink('photo.jpg', 'https://example.com/v/1#key')).toBe(
'<a href="https://example.com/v/1#key">photo.jpg</a>'
);
});
it('escapes HTML in link label', () => {
expect(buildHtmlLink('<script>alert(1)</script>', 'https://example.com/v/1')).toBe(
'<a href="https://example.com/v/1">&lt;script&gt;alert(1)&lt;/script&gt;</a>'
);
});
it('escapes quotes in href', () => {
expect(buildHtmlLink('label', 'https://example.com/x" y')).toBe(
'<a href="https://example.com/x&quot; y">label</a>'
);
});
});
@@ -0,0 +1,4 @@
import escapeHtml from 'escape-html';
export const buildHtmlLink = (label: string, url: string): string =>
`<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`;
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { buildMarkdownImage } from '@/utils/share/buildMarkdownImage';
describe('buildMarkdownImage', () => {
it('builds a markdown image with alt and url', () => {
expect(buildMarkdownImage('https://example.com/i/1/secret', 'photo.jpg')).toBe(
'![photo.jpg](https://example.com/i/1/secret)'
);
});
it('uses default alt when omitted', () => {
expect(buildMarkdownImage('https://example.com/i/1/secret')).toBe('![image](https://example.com/i/1/secret)');
});
});
@@ -0,0 +1 @@
export const buildMarkdownImage = (imageUrl: string, alt = 'image'): string => `![${alt}](${imageUrl})`;
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { buildMarkdownLink } from '@/utils/share/buildMarkdownLink';
describe('buildMarkdownLink', () => {
it('builds a markdown link with label and url', () => {
expect(buildMarkdownLink('photo.jpg', 'https://example.com/v/1#key')).toBe(
'[photo.jpg](https://example.com/v/1#key)'
);
});
});
@@ -0,0 +1 @@
export const buildMarkdownLink = (label: string, url: string): string => `[${label}](${url})`;
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { buildSiteShareUrl } from '@/utils/share/buildSiteShareUrl';
describe('buildSiteShareUrl', () => {
it('appends path and hash to origin', () => {
expect(buildSiteShareUrl('https://example.com', '/v/abc#key')).toBe('https://example.com/v/abc#key');
});
it('handles path without hash', () => {
expect(buildSiteShareUrl('http://localhost:5173', '/i/id/secret')).toBe(
'http://localhost:5173/i/id/secret'
);
});
});
@@ -0,0 +1,7 @@
export const buildSiteShareUrl = (origin: string, pathWithOptionalHash: string): string => {
const hashIndex = pathWithOptionalHash.indexOf('#');
const pathname = hashIndex >= 0 ? pathWithOptionalHash.slice(0, hashIndex) : pathWithOptionalHash;
const hash = hashIndex >= 0 ? pathWithOptionalHash.slice(hashIndex) : '';
return `${origin}${pathname}${hash}`;
};
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { config } from '@/config';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import { buildUploadedShareUrls } from '@/utils/share/buildUploadedShareUrls';
const { clearnet, onion } = config.shareOrigins;
const uploaded = (
mode: 'zk' | 'hotlink',
overrides: Partial<Extract<UploadQueueEntry, { status: 'uploaded' }>> = {}
): UploadQueueEntry => ({
status: 'uploaded',
message: 'Uploaded',
uploadId: 'upload-id',
deleteToken: 'delete-token',
urlSecret: 'url-secret',
mode,
...overrides
});
describe('buildUploadedShareUrls', () => {
it('returns null unless the upload succeeded', () => {
const statuses = ['idle', 'queued', 'preparing', 'uploading', 'failed', 'skipped'] as const;
for (const status of statuses) {
expect(buildUploadedShareUrls({ status, message: status }, 'photo.jpg')).toBeNull();
}
});
it('builds zero-knowledge viewer, delete, and clearnet text embeds', () => {
const urls = buildUploadedShareUrls(
uploaded('zk', { deleteToken: 'a+b/c', urlSecret: 'fragment_key' }),
'a <photo> & "shot"'
);
expect(urls).toEqual({
mode: 'zk',
viewer: {
clearnet: `${clearnet}/v/upload-id#fragment_key`,
onion: `${onion}/v/upload-id#fragment_key`
},
delete: {
clearnet: `${clearnet}/d/upload-id?token=a%2Bb%2Fc`,
onion: `${onion}/d/upload-id?token=a%2Bb%2Fc`
},
markdownLink: `[a <photo> & "shot"](${clearnet}/v/upload-id#fragment_key)`,
htmlLink: `<a href="${clearnet}/v/upload-id#fragment_key">a &lt;photo&gt; &amp; &quot;shot&quot;</a>`
});
});
it('builds hotlink viewer, direct image, delete, and clearnet image embeds', () => {
const urls = buildUploadedShareUrls(
uploaded('hotlink', { deleteToken: 'a+b/c', urlSecret: 'path_secret' }),
'a <photo> & "shot"'
);
const directClearnet = `${clearnet}/i/upload-id/path_secret`;
expect(urls).toEqual({
mode: 'hotlink',
viewer: {
clearnet: `${clearnet}/h/upload-id/path_secret`,
onion: `${onion}/h/upload-id/path_secret`
},
direct: {
clearnet: directClearnet,
onion: `${onion}/i/upload-id/path_secret`
},
delete: {
clearnet: `${clearnet}/d/upload-id?token=a%2Bb%2Fc`,
onion: `${onion}/d/upload-id?token=a%2Bb%2Fc`
},
bbcode: `[img]${directClearnet}[/img]`,
markdownImage: `![a <photo> & "shot"](${directClearnet})`,
htmlImage: `<img src="${directClearnet}" alt="a &lt;photo&gt; &amp; &quot;shot&quot;" />`
});
});
});
@@ -0,0 +1,71 @@
import type { UploadedShareUrls } from '@/types/share/UploadedShareUrls';
import type { UploadQueueEntry } from '@/types/upload/UploadQueueEntry';
import { buildBbcodeImage } from '@/utils/share/buildBbcodeImage';
import { buildHtmlImage } from '@/utils/share/buildHtmlImage';
import { buildHtmlLink } from '@/utils/share/buildHtmlLink';
import { buildMarkdownImage } from '@/utils/share/buildMarkdownImage';
import { buildMarkdownLink } from '@/utils/share/buildMarkdownLink';
import { config } from '@/config';
import { buildDeletePath } from '@/utils/share/buildDeletePath';
import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath';
import { buildHotlinkViewerPath } from '@/utils/share/buildHotlinkViewerPath';
import { buildSiteShareUrl } from '@/utils/share/buildSiteShareUrl';
import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath';
const { clearnet, onion } = config.shareOrigins;
export const buildUploadedShareUrls = (
upload: UploadQueueEntry,
fileLabel: string
): UploadedShareUrls | null => {
if (upload.status !== 'uploaded') {
return null;
}
const deletePath = buildDeletePath(upload.uploadId, upload.deleteToken);
const deleteUrls = {
clearnet: buildSiteShareUrl(clearnet, deletePath),
onion: buildSiteShareUrl(onion, deletePath)
};
if (upload.mode === 'hotlink') {
const viewerPath = buildHotlinkViewerPath(upload.uploadId, upload.urlSecret);
const directPath = buildHotlinkImagePath(upload.uploadId, upload.urlSecret);
const viewer = {
clearnet: buildSiteShareUrl(clearnet, viewerPath),
onion: buildSiteShareUrl(onion, viewerPath)
};
const direct = {
clearnet: buildSiteShareUrl(clearnet, directPath),
onion: buildSiteShareUrl(onion, directPath)
};
return {
mode: 'hotlink',
delete: deleteUrls,
viewer,
direct,
bbcode: buildBbcodeImage(direct.clearnet),
markdownImage: buildMarkdownImage(direct.clearnet, fileLabel),
htmlImage: buildHtmlImage(direct.clearnet, fileLabel)
};
}
const viewerPath = buildZkViewerPath(upload.uploadId, upload.urlSecret);
const viewer = {
clearnet: buildSiteShareUrl(clearnet, viewerPath),
onion: buildSiteShareUrl(onion, viewerPath)
};
return {
mode: 'zk',
delete: deleteUrls,
viewer,
markdownLink: buildMarkdownLink(fileLabel, viewer.clearnet),
htmlLink: buildHtmlLink(fileLabel, viewer.clearnet)
};
};
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
const makeFile = (name: string, type: string, size: number): File =>
new File([new Uint8Array(size)], name, { type });
describe('analyzePickedUploadFiles', () => {
const maxImageBytes = 100;
const maxQueueSize = 2;
it('accepts images within size and queue limits', () => {
const picked = [makeFile('a.png', 'image/png', 50), makeFile('b.jpg', 'image/jpeg', 80)];
const result = analyzePickedUploadFiles(picked, maxImageBytes, maxQueueSize);
expect(result.accepted).toHaveLength(2);
expect(result.nonImageCount).toBe(0);
expect(result.oversizedImagesCount).toBe(0);
expect(result.queueOmittedImagesCount).toBe(0);
});
it('skips non-images and oversized files and caps the queue', () => {
const picked = [
makeFile('doc.pdf', 'application/pdf', 10),
makeFile('big.png', 'image/png', 200),
makeFile('1.png', 'image/png', 10),
makeFile('2.png', 'image/png', 10),
makeFile('3.png', 'image/png', 10)
];
const result = analyzePickedUploadFiles(picked, maxImageBytes, maxQueueSize);
expect(result.accepted.map(f => f.name)).toEqual(['1.png', '2.png']);
expect(result.nonImageCount).toBe(1);
expect(result.oversizedImagesCount).toBe(1);
expect(result.queueOmittedImagesCount).toBe(1);
});
});
@@ -0,0 +1,21 @@
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
export const analyzePickedUploadFiles = (
picked: File[],
maxImageBytes: number,
maxQueueSize: number
): PickedUploadAnalysis => {
const images = picked.filter(file => file.type.startsWith('image/'));
const nonImageCount = picked.length - images.length;
const imagesWithinSizeLimit = images.filter(file => file.size <= maxImageBytes);
const oversizedImagesCount = images.length - imagesWithinSizeLimit.length;
const accepted = imagesWithinSizeLimit.slice(0, maxQueueSize);
const queueOmittedImagesCount = imagesWithinSizeLimit.length - accepted.length;
return {
accepted,
nonImageCount,
oversizedImagesCount,
queueOmittedImagesCount
};
};
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
describe('buildPickedUploadWarning', () => {
it('returns null when nothing was skipped', () => {
expect(
buildPickedUploadWarning(
{ nonImageCount: 0, oversizedImagesCount: 0, queueOmittedImagesCount: 0 },
5
)
).toBeNull();
});
it('joins skip reasons into one message', () => {
const message = buildPickedUploadWarning(
{ nonImageCount: 1, oversizedImagesCount: 2, queueOmittedImagesCount: 3 },
4
);
expect(message).toContain('1 non-image');
expect(message).toContain('2 image(s) exceeded');
expect(message).toContain('first 4 valid images');
});
});
@@ -0,0 +1,22 @@
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
export const buildPickedUploadWarning = (
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>,
maxQueueSize: number
): string | null => {
const parts: string[] = [];
if (summary.nonImageCount > 0) {
parts.push(`${summary.nonImageCount} non-image file(s) were skipped.`);
}
if (summary.oversizedImagesCount > 0) {
parts.push(`${summary.oversizedImagesCount} image(s) exceeded the size limit and were skipped.`);
}
if (summary.queueOmittedImagesCount > 0) {
parts.push(`Only the first ${maxQueueSize} valid images were added.`);
}
return parts.length > 0 ? parts.join(' ') : null;
};
@@ -0,0 +1,9 @@
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
export const buildUploadOptionsFromBatchSettings = (settings: UploadBatchSettingsForm): UploadOptionsRequest => ({
expiresAt: resolveUploadBatchExpiresAt(settings.expiryPreset),
maxViews: resolveUploadBatchMaxViews(settings.maxViewsPreset)
});
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import { buildZkUploadPrepareOptionsFromBatchSettings } from '@/utils/upload/buildZkUploadPrepareOptionsFromBatchSettings';
const baseSettings: UploadBatchSettingsForm = {
mode: 'zk',
expiryPreset: 'never',
maxViewsPreset: 'unlimited',
passwordEnabled: false,
password: ''
};
describe('buildZkUploadPrepareOptionsFromBatchSettings', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-01T00:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('builds non-password options', () => {
const settings = {
...baseSettings,
expiryPreset: '24h' as const,
maxViewsPreset: '10' as const
};
expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
expiresAt: '2026-06-02T00:00:00.000Z',
maxViews: 10,
passwordProtected: false
});
});
it('builds password-protected options', () => {
const settings = {
...baseSettings,
passwordEnabled: true,
password: 'batch-secret'
};
expect(buildZkUploadPrepareOptionsFromBatchSettings(settings)).toEqual({
expiresAt: null,
maxViews: null,
passwordProtected: true,
password: 'batch-secret'
});
});
});
@@ -0,0 +1,22 @@
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
import { buildUploadOptionsFromBatchSettings } from '@/utils/upload/buildUploadOptionsFromBatchSettings';
export const buildZkUploadPrepareOptionsFromBatchSettings = (
settings: UploadBatchSettingsForm
): ZkUploadPrepareOptions => {
const uploadOptions = buildUploadOptionsFromBatchSettings(settings);
if (settings.passwordEnabled) {
return {
...uploadOptions,
passwordProtected: true,
password: settings.password
};
}
return {
...uploadOptions,
passwordProtected: false
};
};
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
const baseSettings: UploadBatchSettingsForm = {
mode: 'zk',
expiryPreset: 'never',
maxViewsPreset: 'unlimited',
passwordEnabled: false,
password: ''
};
describe('formatUploadBatchSettingsSummary', () => {
it('formats default zk settings', () => {
expect(formatUploadBatchSettingsSummary(baseSettings)).toBe(
'Zero knowledge · Never expires · Unlimited views'
);
});
it('includes password when enabled', () => {
const settings = {
...baseSettings,
passwordEnabled: true,
password: 'secret'
};
expect(formatUploadBatchSettingsSummary(settings)).toBe(
'Zero knowledge · Never expires · Unlimited views · Password protected'
);
});
it('formats hotlink mode', () => {
const settings = {
...baseSettings,
mode: 'hotlink' as const,
expiryPreset: '24h' as const,
maxViewsPreset: '10' as const
};
expect(formatUploadBatchSettingsSummary(settings)).toBe('Hotlink · 24 hours · 10 views');
});
});
@@ -0,0 +1,29 @@
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
const EXPIRY_LABELS: Record<UploadBatchSettingsForm['expiryPreset'], string> = {
never: 'Never expires',
'1h': '1 hour',
'24h': '24 hours',
'7d': '7 days',
'30d': '30 days'
};
const MAX_VIEWS_LABELS: Record<UploadBatchSettingsForm['maxViewsPreset'], string> = {
unlimited: 'Unlimited views',
'1': 'Burn after read',
'10': '10 views',
'100': '100 views',
'1000': '1,000 views',
'10000': '10,000 views'
};
export const formatUploadBatchSettingsSummary = (settings: UploadBatchSettingsForm): string => {
const modeLabel = settings.mode === 'zk' ? 'Zero knowledge' : 'Hotlink';
const parts = [modeLabel, EXPIRY_LABELS[settings.expiryPreset], MAX_VIEWS_LABELS[settings.maxViewsPreset]];
if (settings.mode === 'zk' && settings.passwordEnabled) {
parts.push('Password protected');
}
return parts.join(' · ');
};
@@ -1,4 +1,4 @@
import type { CreateHotlinkUploadRequest } from '@/types/upload/CreateHotlinkUploadRequest';
import type { PreparedHotlinkUploadRequest } from '@/types/upload/PreparedHotlinkUploadRequest';
import type { UploadOptionsRequest } from '@/types/upload/UploadOptionsRequest';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { encryptHotlinkUpload } from '@/utils/upload/encryptHotlinkUpload';
@@ -7,7 +7,7 @@ export const prepareHotlinkUploadRequest = async (
plaintext: Uint8Array,
mimeType: string,
options: UploadOptionsRequest
): Promise<{ request: CreateHotlinkUploadRequest; secret: string }> => {
): Promise<PreparedHotlinkUploadRequest> => {
const encrypted = await encryptHotlinkUpload(plaintext);
return {
@@ -1,4 +1,4 @@
import type { CreateZkUploadRequest } from '@/types/upload/CreateZkUploadRequest';
import type { PreparedZkUploadRequest } from '@/types/upload/PreparedZkUploadRequest';
import type { ZkUploadPrepareOptions } from '@/types/upload/ZkUploadPrepareOptions';
import { bytesToBase64 } from '@/utils/encoding/bytesToBase64';
import { bytesToBase64Url } from '@/utils/encoding/bytesToBase64Url';
@@ -9,7 +9,7 @@ import { wrapZkDek } from '@/utils/upload/wrapZkDek';
export const prepareZkUploadRequest = async (
plaintext: Uint8Array,
options: ZkUploadPrepareOptions
): Promise<{ request: CreateZkUploadRequest; fragmentKey: string }> => {
): Promise<PreparedZkUploadRequest> => {
if (!options.passwordProtected) {
const { dek, iv, ciphertext } = await encryptZkUpload(plaintext);
@@ -0,0 +1,24 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveUploadBatchExpiresAt } from '@/utils/upload/resolveUploadBatchExpiresAt';
describe('resolveUploadBatchExpiresAt', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns null for never', () => {
expect(resolveUploadBatchExpiresAt('never')).toBeNull();
});
it('adds preset offsets from the reference time', () => {
expect(resolveUploadBatchExpiresAt('1h')).toBe('2026-01-01T13:00:00.000Z');
expect(resolveUploadBatchExpiresAt('24h')).toBe('2026-01-02T12:00:00.000Z');
expect(resolveUploadBatchExpiresAt('7d')).toBe('2026-01-08T12:00:00.000Z');
expect(resolveUploadBatchExpiresAt('30d')).toBe('2026-01-31T12:00:00.000Z');
});
});
@@ -0,0 +1,20 @@
import dayjs from 'dayjs';
import type { UploadExpiryPreset } from '@/types/upload/UploadExpiryPreset';
import type { UploadExpiryOffset } from '@/types/upload/UploadExpiryOffset';
const EXPIRY_OFFSET: Record<Exclude<UploadExpiryPreset, 'never'>, UploadExpiryOffset> = {
'1h': { amount: 1, unit: 'hour' },
'24h': { amount: 24, unit: 'hour' },
'7d': { amount: 7, unit: 'day' },
'30d': { amount: 30, unit: 'day' }
};
export const resolveUploadBatchExpiresAt = (preset: UploadExpiryPreset): string | null => {
if (preset === 'never') {
return null;
}
const { amount, unit } = EXPIRY_OFFSET[preset];
return dayjs().add(amount, unit).toISOString();
};
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { resolveUploadBatchMaxViews } from '@/utils/upload/resolveUploadBatchMaxViews';
describe('resolveUploadBatchMaxViews', () => {
it('maps presets to API values', () => {
expect(resolveUploadBatchMaxViews('unlimited')).toBeNull();
expect(resolveUploadBatchMaxViews('1')).toBe(1);
expect(resolveUploadBatchMaxViews('10')).toBe(10);
expect(resolveUploadBatchMaxViews('100')).toBe(100);
expect(resolveUploadBatchMaxViews('1000')).toBe(1000);
expect(resolveUploadBatchMaxViews('10000')).toBe(10000);
});
});
@@ -0,0 +1,9 @@
import type { UploadMaxViewsPreset } from '@/types/upload/UploadMaxViewsPreset';
export const resolveUploadBatchMaxViews = (preset: UploadMaxViewsPreset): number | null => {
if (preset === 'unlimited') {
return null;
}
return parseInt(preset, 10);
};
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import { validateUploadBatchSettings } from '@/utils/upload/validateUploadBatchSettings';
const baseSettings: UploadBatchSettingsForm = {
mode: 'zk',
expiryPreset: 'never',
maxViewsPreset: 'unlimited',
passwordEnabled: false,
password: ''
};
describe('validateUploadBatchSettings', () => {
it('allows hotlink mode without password', () => {
const settings = {
...baseSettings,
mode: 'hotlink' as const,
passwordEnabled: true,
password: ''
};
expect(validateUploadBatchSettings(settings)).toBeNull();
});
it('requires password when zk protection is enabled', () => {
const settings = {
...baseSettings,
passwordEnabled: true,
password: ''
};
expect(validateUploadBatchSettings(settings)).toBe('Enter a password or turn off password protection.');
});
it('allows zk upload when password is set', () => {
const settings = {
...baseSettings,
passwordEnabled: true,
password: 'secret'
};
expect(validateUploadBatchSettings(settings)).toBeNull();
});
});
@@ -0,0 +1,13 @@
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
export const validateUploadBatchSettings = (settings: UploadBatchSettingsForm): string | null => {
if (settings.mode !== 'zk' || !settings.passwordEnabled) {
return null;
}
if (settings.password.length === 0) {
return 'Enter a password or turn off password protection.';
}
return null;
};
+108 -92
View File
@@ -9,72 +9,92 @@
</p>
</div>
<div>
<label
class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90"
:class="loadingMetadata ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'"
>
Choose images
<input
ref="fileInputRef"
type="file"
accept="image/*"
multiple
class="sr-only"
:disabled="loadingMetadata"
@change="onFileInputChange"
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<UploadDropzone
ref="dropzoneRef"
:disabled="loadingMetadata || batchUploading"
@pick="onFilesPicked"
/>
</label>
<p v-if="selectionError" class="mt-2 text-sm text-destructive">{{ selectionError }}</p>
<button
v-if="!uploadConfirmed"
type="button"
class="inline-flex shrink-0 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 sm:self-center"
:disabled="!canOpenConfigure"
@click="configureModalOpen = true"
>
Configure & upload
</button>
</div>
<p v-if="selectionError" class="text-sm text-destructive">{{ selectionError }}</p>
</div>
<ul v-if="queueRows.length > 0" class="divide-y divide-border rounded-lg border border-border">
<li v-for="row in queueRows" :key="row.key">
<button
type="button"
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/60"
:class="isFileSelected(row.file) ? 'bg-muted/80' : ''"
@click="selectedFile = row.file"
>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ row.file.name }}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{{ prettyBytes(row.file.size, { maximumFractionDigits: 1 }) }}
</span>
<span class="shrink-0 text-xs" :class="statusTextClass(row.preview)">
{{ row.preview.message }}
</span>
</button>
</li>
</ul>
<p v-if="confirmedBatchSummary" class="text-center text-xs text-muted-foreground">
{{ confirmedBatchSummary }}
</p>
<UploadQueueList :rows="queueRows" :selected-file="selectedFile" @select="selectedFile = $event" />
<UploadShareResults
v-if="showShareLinks"
:file-name="selectedFile?.name ?? null"
:upload="selectedUpload"
/>
<MetadataInspector :file-name="selectedFile?.name ?? null" :entry="selectedPreview" />
<UploadConfigureModal
v-model:open="configureModalOpen"
v-model:settings="batchSettings"
:metadata-error-count="metadataErrorCount"
@upload="onBatchUpload"
/>
</main>
</template>
<script setup lang="ts">
import { computed, onBeforeMount, ref } from 'vue';
import MetadataInspector from '@/components/upload/MetadataInspector.vue';
import type UploadDropzone from '@/components/upload/UploadDropzone.vue';
import { config } from '@/config';
import { useExiftoolStore } from '@/stores/exiftool';
import { useMetadataStore } from '@/stores/metadata';
import type { MetadataPreviewEntry } from '@/types/metadata/MetadataPreview';
import type { PickedUploadAnalysis } from '@/types/upload/PickedUploadAnalysis';
import { useUploadStore } from '@/stores/upload';
import type { UploadBatchSettingsForm } from '@/types/upload/UploadBatchSettingsForm';
import { makeKeyFromFile } from '@/utils/file/makeKeyFromFile';
import { analyzePickedUploadFiles } from '@/utils/upload/analyzePickedUploadFiles';
import { buildPickedUploadWarning } from '@/utils/upload/buildPickedUploadWarning';
import { formatUploadBatchSettingsSummary } from '@/utils/upload/formatUploadBatchSettingsSummary';
import prettyBytes from 'pretty-bytes';
const { maxQueueSize, maxImageBytes } = config.validation.upload;
const exiftoolStore = useExiftoolStore();
const metadataStore = useMetadataStore();
const uploadStore = useUploadStore();
const { initializeExiftool } = exiftoolStore;
const { makeKeyFromFile, getPreview, loadPreviews, clearAllPreviews } = metadataStore;
const { loadPreviews, clearAllPreviews } = metadataStore;
const { clearAllUploads, uploadBatch } = uploadStore;
const queuedFiles = ref<File[]>([]);
const selectedFile = ref<File | null>(null);
const selectionError = ref<string | null>(null);
const loadingMetadata = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const batchUploading = ref(false);
const dropzoneRef = ref<InstanceType<typeof UploadDropzone> | null>(null);
const batchSettings = ref<UploadBatchSettingsForm>({
mode: 'zk',
expiryPreset: 'never',
maxViewsPreset: 'unlimited',
passwordEnabled: false,
password: ''
});
const configureModalOpen = ref(false);
const uploadConfirmed = ref(false);
const showShareLinks = ref(false);
const confirmedBatchSummary = ref<string | null>(null);
onBeforeMount(async () => {
try {
@@ -87,36 +107,44 @@ onBeforeMount(async () => {
const maxImageSizeFormatted = computed(() => prettyBytes(maxImageBytes, { binary: true, maximumFractionDigits: 1 }));
const queueRows = computed(() =>
queuedFiles.value.map(file => ({
file,
key: makeKeyFromFile(file),
preview: getPreview(file)
}))
queuedFiles.value.map(file => {
const key = makeKeyFromFile(file);
return {
file,
key,
metadata: metadataStore.getPreview(file),
upload: uploadStore.getUpload(file)
};
})
);
const selectedPreview = computed(() => getPreview(selectedFile.value));
const selectedPreview = computed(() => metadataStore.getPreview(selectedFile.value));
const isFileSelected = (file: File): boolean =>
selectedFile.value !== null && makeKeyFromFile(selectedFile.value) === makeKeyFromFile(file);
const selectedUpload = computed(() => uploadStore.getUpload(selectedFile.value));
const statusTextClass = (entry: MetadataPreviewEntry): string =>
entry.status === 'error' ? 'text-destructive' : 'text-muted-foreground';
const metadataErrorCount = computed(
() => queuedFiles.value.filter(file => metadataStore.getPreview(file).status === 'error').length
);
const onFileInputChange = async (event: Event): Promise<void> => {
if (loadingMetadata.value) {
const canOpenConfigure = computed(() => queuedFiles.value.length > 0 && !loadingMetadata.value);
const onFilesPicked = async (picked: File[]): Promise<void> => {
if (loadingMetadata.value || batchUploading.value) {
return;
}
const input = event.target as HTMLInputElement;
const picked = input.files ? [...input.files] : [];
resetUploadSelection();
resetUploadSession();
if (picked.length === 0) {
return;
}
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(picked);
const { accepted, nonImageCount, oversizedImagesCount, queueOmittedImagesCount } = analyzePickedUploadFiles(
picked,
maxImageBytes,
maxQueueSize
);
if (nonImageCount === picked.length) {
selectionError.value = 'Only image files can be added.';
@@ -130,7 +158,10 @@ const onFileInputChange = async (event: Event): Promise<void> => {
return;
}
selectionError.value = buildPickedUploadWarning({ nonImageCount, oversizedImagesCount, queueOmittedImagesCount });
selectionError.value = buildPickedUploadWarning(
{ nonImageCount, oversizedImagesCount, queueOmittedImagesCount },
maxQueueSize
);
queuedFiles.value = accepted;
selectedFile.value = accepted[0] ?? null;
@@ -146,50 +177,35 @@ const onFileInputChange = async (event: Event): Promise<void> => {
}
};
const resetUploadSelection = (): void => {
const resetUploadSession = (): void => {
selectionError.value = null;
clearAllPreviews();
queuedFiles.value = [];
selectedFile.value = null;
if (fileInputRef.value) {
fileInputRef.value.value = '';
}
clearAllUploads();
dropzoneRef.value?.clearInput();
configureModalOpen.value = false;
uploadConfirmed.value = false;
showShareLinks.value = false;
confirmedBatchSummary.value = null;
};
const analyzePickedUploadFiles = (picked: File[]): PickedUploadAnalysis => {
const images = picked.filter(file => file.type.startsWith('image/'));
const nonImageCount = picked.length - images.length;
const imagesWithinSizeLimit = images.filter(file => file.size <= maxImageBytes);
const oversizedImagesCount = images.length - imagesWithinSizeLimit.length;
const accepted = imagesWithinSizeLimit.slice(0, maxQueueSize);
const queueOmittedImagesCount = imagesWithinSizeLimit.length - accepted.length;
const onBatchUpload = async (uploadDespiteMetadataFailure?: boolean): Promise<void> => {
const settings = batchSettings.value;
return {
accepted,
nonImageCount,
oversizedImagesCount,
queueOmittedImagesCount
};
};
confirmedBatchSummary.value = formatUploadBatchSettingsSummary(settings);
uploadConfirmed.value = true;
configureModalOpen.value = false;
const buildPickedUploadWarning = (
summary: Pick<PickedUploadAnalysis, 'nonImageCount' | 'oversizedImagesCount' | 'queueOmittedImagesCount'>
): string | null => {
const parts: string[] = [];
batchUploading.value = true;
if (summary.nonImageCount > 0) {
parts.push(`${summary.nonImageCount} non-image file(s) were skipped.`);
try {
await uploadBatch(queuedFiles.value, settings, uploadDespiteMetadataFailure);
} catch (error) {
console.error(error);
} finally {
batchUploading.value = false;
showShareLinks.value = true;
}
if (summary.oversizedImagesCount > 0) {
parts.push(`${summary.oversizedImagesCount} image(s) exceeded the size limit and were skipped.`);
}
if (summary.queueOmittedImagesCount > 0) {
parts.push(`Only the first ${maxQueueSize} valid images were added.`);
}
return parts.length > 0 ? parts.join(' ') : null;
};
</script>
+3
View File
@@ -2,8 +2,11 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_CLEARNET_SCHEME: string;
readonly VITE_CLEARNET_DOMAIN: string;
readonly VITE_ONION_HOSTNAME: string;
readonly VITE_ALTCHA_ENABLED: string;
readonly VITE_ALTCHA_MAX_NUMBER: string;
readonly VITE_UPLOAD_MAX_QUEUE_SIZE: string;
readonly VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX: string;
readonly VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES: string;
+8 -1
View File
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -12,5 +13,11 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src')
}
},
plugins: [vue(), tailwindcss()]
plugins: [
vue(),
Components({
dts: 'src/components.d.ts'
}),
tailwindcss()
]
});