diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 84a87e3..dbf223f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,6 +14,7 @@
"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,6 +23,7 @@
"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",
@@ -1717,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",
@@ -2454,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",
diff --git a/frontend/package.json b/frontend/package.json
index 906fa14..4ee7d91 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -16,6 +16,7 @@
"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",
@@ -24,6 +25,7 @@
"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",
diff --git a/frontend/src/types/share/ShareNetworkTab.ts b/frontend/src/types/share/ShareNetworkTab.ts
new file mode 100644
index 0000000..d3b8a12
--- /dev/null
+++ b/frontend/src/types/share/ShareNetworkTab.ts
@@ -0,0 +1 @@
+export type ShareNetworkTab = 'clearnet' | 'onion';
diff --git a/frontend/src/types/share/UploadedShareUrls.ts b/frontend/src/types/share/UploadedShareUrls.ts
new file mode 100644
index 0000000..471ee6a
--- /dev/null
+++ b/frontend/src/types/share/UploadedShareUrls.ts
@@ -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;
diff --git a/frontend/src/utils/share/buildBbcodeImage.spec.ts b/frontend/src/utils/share/buildBbcodeImage.spec.ts
new file mode 100644
index 0000000..5953896
--- /dev/null
+++ b/frontend/src/utils/share/buildBbcodeImage.spec.ts
@@ -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]'
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildBbcodeImage.ts b/frontend/src/utils/share/buildBbcodeImage.ts
new file mode 100644
index 0000000..55a00e0
--- /dev/null
+++ b/frontend/src/utils/share/buildBbcodeImage.ts
@@ -0,0 +1 @@
+export const buildBbcodeImage = (imageUrl: string): string => `[img]${imageUrl}[/img]`;
diff --git a/frontend/src/utils/share/buildDeletePath.spec.ts b/frontend/src/utils/share/buildDeletePath.spec.ts
new file mode 100644
index 0000000..5a768dc
--- /dev/null
+++ b/frontend/src/utils/share/buildDeletePath.spec.ts
@@ -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');
+ });
+});
diff --git a/frontend/src/utils/share/buildDeletePath.ts b/frontend/src/utils/share/buildDeletePath.ts
new file mode 100644
index 0000000..528cbc7
--- /dev/null
+++ b/frontend/src/utils/share/buildDeletePath.ts
@@ -0,0 +1,2 @@
+export const buildDeletePath = (uploadId: string, deleteToken: string): string =>
+ `/d/${uploadId}?token=${encodeURIComponent(deleteToken)}`;
diff --git a/frontend/src/utils/share/buildHtmlImage.spec.ts b/frontend/src/utils/share/buildHtmlImage.spec.ts
new file mode 100644
index 0000000..37066fc
--- /dev/null
+++ b/frontend/src/utils/share/buildHtmlImage.spec.ts
@@ -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(
+ '
'
+ );
+ });
+
+ it('escapes quotes and markup in alt text', () => {
+ expect(buildHtmlImage('https://example.com/i/1/secret', 'x" onerror="alert(1)')).toBe(
+ '
'
+ );
+ });
+
+ it('escapes special characters in src when present', () => {
+ expect(buildHtmlImage('https://example.com/i/1/"bad', 'image')).toBe(
+ '
'
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildHtmlImage.ts b/frontend/src/utils/share/buildHtmlImage.ts
new file mode 100644
index 0000000..72d8745
--- /dev/null
+++ b/frontend/src/utils/share/buildHtmlImage.ts
@@ -0,0 +1,4 @@
+import escapeHtml from 'escape-html';
+
+export const buildHtmlImage = (imageUrl: string, alt = 'image'): string =>
+ `
`;
diff --git a/frontend/src/utils/share/buildHtmlLink.spec.ts b/frontend/src/utils/share/buildHtmlLink.spec.ts
new file mode 100644
index 0000000..ca5098e
--- /dev/null
+++ b/frontend/src/utils/share/buildHtmlLink.spec.ts
@@ -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(
+ 'photo.jpg'
+ );
+ });
+
+ it('escapes HTML in link label', () => {
+ expect(buildHtmlLink('', 'https://example.com/v/1')).toBe(
+ '<script>alert(1)</script>'
+ );
+ });
+
+ it('escapes quotes in href', () => {
+ expect(buildHtmlLink('label', 'https://example.com/x" y')).toBe(
+ 'label'
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildHtmlLink.ts b/frontend/src/utils/share/buildHtmlLink.ts
new file mode 100644
index 0000000..cc9ead3
--- /dev/null
+++ b/frontend/src/utils/share/buildHtmlLink.ts
@@ -0,0 +1,4 @@
+import escapeHtml from 'escape-html';
+
+export const buildHtmlLink = (label: string, url: string): string =>
+ `${escapeHtml(label)}`;
diff --git a/frontend/src/utils/share/buildMarkdownImage.spec.ts b/frontend/src/utils/share/buildMarkdownImage.spec.ts
new file mode 100644
index 0000000..db43195
--- /dev/null
+++ b/frontend/src/utils/share/buildMarkdownImage.spec.ts
@@ -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(
+ ''
+ );
+ });
+
+ it('uses default alt when omitted', () => {
+ expect(buildMarkdownImage('https://example.com/i/1/secret')).toBe('');
+ });
+});
diff --git a/frontend/src/utils/share/buildMarkdownImage.ts b/frontend/src/utils/share/buildMarkdownImage.ts
new file mode 100644
index 0000000..accbe3d
--- /dev/null
+++ b/frontend/src/utils/share/buildMarkdownImage.ts
@@ -0,0 +1 @@
+export const buildMarkdownImage = (imageUrl: string, alt = 'image'): string => ``;
diff --git a/frontend/src/utils/share/buildMarkdownLink.spec.ts b/frontend/src/utils/share/buildMarkdownLink.spec.ts
new file mode 100644
index 0000000..eb9811f
--- /dev/null
+++ b/frontend/src/utils/share/buildMarkdownLink.spec.ts
@@ -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)'
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildMarkdownLink.ts b/frontend/src/utils/share/buildMarkdownLink.ts
new file mode 100644
index 0000000..c829620
--- /dev/null
+++ b/frontend/src/utils/share/buildMarkdownLink.ts
@@ -0,0 +1 @@
+export const buildMarkdownLink = (label: string, url: string): string => `[${label}](${url})`;
diff --git a/frontend/src/utils/share/buildSiteShareUrl.spec.ts b/frontend/src/utils/share/buildSiteShareUrl.spec.ts
new file mode 100644
index 0000000..d7d2c79
--- /dev/null
+++ b/frontend/src/utils/share/buildSiteShareUrl.spec.ts
@@ -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'
+ );
+ });
+});
diff --git a/frontend/src/utils/share/buildSiteShareUrl.ts b/frontend/src/utils/share/buildSiteShareUrl.ts
new file mode 100644
index 0000000..5cbbaed
--- /dev/null
+++ b/frontend/src/utils/share/buildSiteShareUrl.ts
@@ -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}`;
+};
diff --git a/frontend/src/utils/share/buildUploadedShareUrls.ts b/frontend/src/utils/share/buildUploadedShareUrls.ts
new file mode 100644
index 0000000..c342c84
--- /dev/null
+++ b/frontend/src/utils/share/buildUploadedShareUrls.ts
@@ -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)
+ };
+};