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.
This commit is contained in:
2026-10-25 22:00:01 +01:00
parent b4cc26a632
commit 95ed6e5f58
19 changed files with 226 additions and 0 deletions
@@ -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)
};
};