From 95ed6e5f5857b927fcf91127a63c0f74bcb356a6 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sun, 25 Oct 2026 22:00:01 +0100 Subject: [PATCH] Add share URL builders and embed format helpers for uploaded images. Centralize clearnet/onion paths, delete links, and BBCode/Markdown/HTML output with unit tests and escape-html for safe HTML snippets. --- frontend/package-lock.json | 15 +++++ frontend/package.json | 2 + frontend/src/types/share/ShareNetworkTab.ts | 1 + frontend/src/types/share/UploadedShareUrls.ts | 25 ++++++++ .../src/utils/share/buildBbcodeImage.spec.ts | 10 +++ frontend/src/utils/share/buildBbcodeImage.ts | 1 + .../src/utils/share/buildDeletePath.spec.ts | 8 +++ frontend/src/utils/share/buildDeletePath.ts | 2 + .../src/utils/share/buildHtmlImage.spec.ts | 22 +++++++ frontend/src/utils/share/buildHtmlImage.ts | 4 ++ .../src/utils/share/buildHtmlLink.spec.ts | 22 +++++++ frontend/src/utils/share/buildHtmlLink.ts | 4 ++ .../utils/share/buildMarkdownImage.spec.ts | 14 +++++ .../src/utils/share/buildMarkdownImage.ts | 1 + .../src/utils/share/buildMarkdownLink.spec.ts | 10 +++ frontend/src/utils/share/buildMarkdownLink.ts | 1 + .../src/utils/share/buildSiteShareUrl.spec.ts | 14 +++++ frontend/src/utils/share/buildSiteShareUrl.ts | 7 +++ .../src/utils/share/buildUploadedShareUrls.ts | 63 +++++++++++++++++++ 19 files changed, 226 insertions(+) create mode 100644 frontend/src/types/share/ShareNetworkTab.ts create mode 100644 frontend/src/types/share/UploadedShareUrls.ts create mode 100644 frontend/src/utils/share/buildBbcodeImage.spec.ts create mode 100644 frontend/src/utils/share/buildBbcodeImage.ts create mode 100644 frontend/src/utils/share/buildDeletePath.spec.ts create mode 100644 frontend/src/utils/share/buildDeletePath.ts create mode 100644 frontend/src/utils/share/buildHtmlImage.spec.ts create mode 100644 frontend/src/utils/share/buildHtmlImage.ts create mode 100644 frontend/src/utils/share/buildHtmlLink.spec.ts create mode 100644 frontend/src/utils/share/buildHtmlLink.ts create mode 100644 frontend/src/utils/share/buildMarkdownImage.spec.ts create mode 100644 frontend/src/utils/share/buildMarkdownImage.ts create mode 100644 frontend/src/utils/share/buildMarkdownLink.spec.ts create mode 100644 frontend/src/utils/share/buildMarkdownLink.ts create mode 100644 frontend/src/utils/share/buildSiteShareUrl.spec.ts create mode 100644 frontend/src/utils/share/buildSiteShareUrl.ts create mode 100644 frontend/src/utils/share/buildUploadedShareUrls.ts 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( + 'photo.jpg' + ); + }); + + it('escapes quotes and markup in alt text', () => { + expect(buildHtmlImage('https://example.com/i/1/secret', 'x" onerror="alert(1)')).toBe( + 'x" onerror="alert(1)' + ); + }); + + it('escapes special characters in src when present', () => { + expect(buildHtmlImage('https://example.com/i/1/"bad', 'image')).toBe( + 'image' + ); + }); +}); 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 => + `${escapeHtml(alt)}`; 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( + '![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)'); + }); +}); 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 => `![${alt}](${imageUrl})`; 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) + }; +};