Slave/upload ui #8

Merged
nobswebdev merged 15 commits from slave/upload-ui into master 2026-10-25 22:43:57 +00:00
19 changed files with 226 additions and 0 deletions
Showing only changes of commit 95ed6e5f58 - Show all commits
+15
View File
@@ -14,6 +14,7 @@
"altcha-lib": "^2.7.0", "altcha-lib": "^2.7.0",
"axios": "^1.20.0", "axios": "^1.20.0",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"escape-html": "^1.0.3",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"pretty-bytes": "^7.2.0", "pretty-bytes": "^7.2.0",
@@ -22,6 +23,7 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/escape-html": "^1.0.4",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
@@ -1717,6 +1719,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2454,6 +2463,12 @@
"@esbuild/win32-x64": "0.28.2" "@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": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+2
View File
@@ -16,6 +16,7 @@
"altcha-lib": "^2.7.0", "altcha-lib": "^2.7.0",
"axios": "^1.20.0", "axios": "^1.20.0",
"dayjs": "^1.11.23", "dayjs": "^1.11.23",
"escape-html": "^1.0.3",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"pinia": "^4.0.3", "pinia": "^4.0.3",
"pretty-bytes": "^7.2.0", "pretty-bytes": "^7.2.0",
@@ -24,6 +25,7 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/escape-html": "^1.0.4",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
@@ -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;
@@ -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,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,63 @@
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 { 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 viewerPath = buildZkViewerPath(upload.uploadId, upload.urlSecret);
const viewer = {
clearnet: buildSiteShareUrl(clearnet, viewerPath),
onion: buildSiteShareUrl(onion, viewerPath)
};
const deleteUrls = {
clearnet: buildSiteShareUrl(clearnet, deletePath),
onion: buildSiteShareUrl(onion, deletePath)
};
const core = { viewer, delete: deleteUrls };
if (upload.mode === 'hotlink') {
const directPath = buildHotlinkImagePath(upload.uploadId, upload.urlSecret);
const direct = {
clearnet: buildSiteShareUrl(clearnet, directPath),
onion: buildSiteShareUrl(onion, directPath)
};
return {
mode: 'hotlink',
...core,
direct,
bbcode: buildBbcodeImage(direct.clearnet),
markdownImage: buildMarkdownImage(direct.clearnet, fileLabel),
htmlImage: buildHtmlImage(direct.clearnet, fileLabel)
};
}
return {
mode: 'zk',
...core,
markdownLink: buildMarkdownLink(fileLabel, viewer.clearnet),
htmlLink: buildHtmlLink(fileLabel, viewer.clearnet)
};
};