From dacf0818a67ad5b313fa0cb489acd4faedfe6070 Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Fri, 7 Nov 2025 22:23:32 -0500 Subject: [PATCH] update dockerfiles, solidify html path rewriting --- .dockerignore | 8 + Dockerfile | 16 +- hosting-service/src/lib/firehose.ts | 556 ++++++++++-------- hosting-service/src/lib/html-rewriter.test.ts | 457 ++++++++++++++ hosting-service/src/lib/html-rewriter.ts | 303 ++++++---- hosting-service/src/server.ts | 5 +- 6 files changed, 967 insertions(+), 378 deletions(-) create mode 100644 hosting-service/src/lib/html-rewriter.test.ts diff --git a/.dockerignore b/.dockerignore index 559c5b6..e945160 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,11 @@ dist *.log .vscode .idea +server +.prettierrc +testDeploy +.tangled +.crush +.claude +server +hosting-service \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5a1b973..ea741fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,15 @@ COPY src ./src COPY public ./public # Build the application (if needed) -# RUN bun run build +RUN bun build \ + --compile \ + --minify \ + --outfile server \ + src/index.ts + +FROM scratch AS runtime +WORKDIR /app +COPY --from=base /app/server /app/server # Set environment variables (can be overridden at runtime) ENV PORT=3000 @@ -24,9 +32,5 @@ ENV NODE_ENV=production # Expose the application port EXPOSE 3000 -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" - # Start the application -CMD ["bun", "src/index.ts"] +CMD ["./server"] diff --git a/hosting-service/src/lib/firehose.ts b/hosting-service/src/lib/firehose.ts index 06b9455..481953d 100644 --- a/hosting-service/src/lib/firehose.ts +++ b/hosting-service/src/lib/firehose.ts @@ -1,261 +1,301 @@ -import { existsSync, rmSync } from 'fs'; -import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils'; -import { upsertSite, tryAcquireLock, releaseLock } from './db'; -import { safeFetch } from './safe-fetch'; -import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'; -import { Firehose } from '@atproto/sync'; -import { IdResolver } from '@atproto/identity'; - -const CACHE_DIR = './cache/sites'; +import { existsSync, rmSync } from 'fs' +import { + getPdsForDid, + downloadAndCacheSite, + extractBlobCid, + fetchSiteRecord +} from './utils' +import { upsertSite, tryAcquireLock, releaseLock } from './db' +import { safeFetch } from './safe-fetch' +import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs' +import { Firehose } from '@atproto/sync' +import { IdResolver } from '@atproto/identity' + +const CACHE_DIR = './cache/sites' export class FirehoseWorker { - private firehose: Firehose | null = null; - private idResolver: IdResolver; - private isShuttingDown = false; - private lastEventTime = Date.now(); - - constructor( - private logger?: (msg: string, data?: Record) => void, - ) { - this.idResolver = new IdResolver(); - } - - private log(msg: string, data?: Record) { - const log = this.logger || console.log; - log(`[FirehoseWorker] ${msg}`, data || {}); - } - - start() { - this.log('Starting firehose worker'); - this.connect(); - } - - stop() { - this.log('Stopping firehose worker'); - this.isShuttingDown = true; - - if (this.firehose) { - this.firehose.destroy(); - this.firehose = null; - } - } - - private connect() { - if (this.isShuttingDown) return; - - this.log('Connecting to AT Protocol firehose'); - - this.firehose = new Firehose({ - idResolver: this.idResolver, - service: 'wss://bsky.network', - filterCollections: ['place.wisp.fs'], - handleEvent: async (evt: any) => { - this.lastEventTime = Date.now(); - - // Watch for write events - if (evt.event === 'create' || evt.event === 'update') { - const record = evt.record; - - // If the write is a valid place.wisp.fs record - if ( - evt.collection === 'place.wisp.fs' && - isRecord(record) && - validateRecord(record).success - ) { - this.log('Received place.wisp.fs event', { - did: evt.did, - event: evt.event, - rkey: evt.rkey, - }); - - try { - await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString()); - } catch (err) { - this.log('Error handling event', { - did: evt.did, - event: evt.event, - rkey: evt.rkey, - error: err instanceof Error ? err.message : String(err), - }); - } - } - } else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') { - this.log('Received delete event', { - did: evt.did, - rkey: evt.rkey, - }); - - try { - await this.handleDelete(evt.did, evt.rkey); - } catch (err) { - this.log('Error handling delete', { - did: evt.did, - rkey: evt.rkey, - error: err instanceof Error ? err.message : String(err), - }); - } - } - }, - onError: (err: any) => { - this.log('Firehose error', { - error: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined, - fullError: err, - }); - console.error('Full firehose error:', err); - }, - }); - - this.firehose.start(); - this.log('Firehose started'); - } - - private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) { - this.log('Processing create/update', { did, site }); - - // Record is already validated in handleEvent - const fsRecord = record; - - const pdsEndpoint = await getPdsForDid(did); - if (!pdsEndpoint) { - this.log('Could not resolve PDS for DID', { did }); - return; - } - - this.log('Resolved PDS', { did, pdsEndpoint }); - - // Verify record exists on PDS and fetch its CID - let verifiedCid: string; - try { - const result = await fetchSiteRecord(did, site); - - if (!result) { - this.log('Record not found on PDS, skipping cache', { did, site }); - return; - } - - verifiedCid = result.cid; - - // Verify event CID matches PDS CID (prevent cache poisoning) - if (eventCid && eventCid !== verifiedCid) { - this.log('CID mismatch detected - potential spoofed event', { - did, - site, - eventCid, - verifiedCid - }); - return; - } - - this.log('Record verified on PDS', { did, site, cid: verifiedCid }); - } catch (err) { - this.log('Failed to verify record on PDS', { - did, - site, - error: err instanceof Error ? err.message : String(err), - }); - return; - } - - // Cache the record with verified CID (uses atomic swap internally) - // All instances cache locally for edge serving - await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid); - - // Acquire distributed lock only for database write to prevent duplicate writes - const lockKey = `db:upsert:${did}:${site}`; - const lockAcquired = await tryAcquireLock(lockKey); - - if (!lockAcquired) { - this.log('Another instance is writing to DB, skipping upsert', { did, site }); - this.log('Successfully processed create/update (cached locally)', { did, site }); - return; - } - - try { - // Upsert site to database (only one instance does this) - await upsertSite(did, site, fsRecord.site); - this.log('Successfully processed create/update (cached + DB updated)', { did, site }); - } finally { - // Always release lock, even if DB write fails - await releaseLock(lockKey); - } - } - - private async handleDelete(did: string, site: string) { - this.log('Processing delete', { did, site }); - - // All instances should delete their local cache (no lock needed) - const pdsEndpoint = await getPdsForDid(did); - if (!pdsEndpoint) { - this.log('Could not resolve PDS for DID', { did }); - return; - } - - // Verify record is actually deleted from PDS - try { - const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`; - const recordRes = await safeFetch(recordUrl); - - if (recordRes.ok) { - this.log('Record still exists on PDS, not deleting cache', { - did, - site, - }); - return; - } - - this.log('Verified record is deleted from PDS', { - did, - site, - status: recordRes.status, - }); - } catch (err) { - this.log('Error verifying deletion on PDS', { - did, - site, - error: err instanceof Error ? err.message : String(err), - }); - } - - // Delete cache - this.deleteCache(did, site); - - this.log('Successfully processed delete', { did, site }); - } - - private deleteCache(did: string, site: string) { - const cacheDir = `${CACHE_DIR}/${did}/${site}`; - - if (!existsSync(cacheDir)) { - this.log('Cache directory does not exist, nothing to delete', { - did, - site, - }); - return; - } - - try { - rmSync(cacheDir, { recursive: true, force: true }); - this.log('Cache deleted', { did, site, path: cacheDir }); - } catch (err) { - this.log('Failed to delete cache', { - did, - site, - path: cacheDir, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - getHealth() { - const isConnected = this.firehose !== null; - const timeSinceLastEvent = Date.now() - this.lastEventTime; - - return { - connected: isConnected, - lastEventTime: this.lastEventTime, - timeSinceLastEvent, - healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes - }; - } + private firehose: Firehose | null = null + private idResolver: IdResolver + private isShuttingDown = false + private lastEventTime = Date.now() + + constructor( + private logger?: (msg: string, data?: Record) => void + ) { + this.idResolver = new IdResolver() + } + + private log(msg: string, data?: Record) { + const log = this.logger || console.log + log(`[FirehoseWorker] ${msg}`, data || {}) + } + + start() { + this.log('Starting firehose worker') + this.connect() + } + + stop() { + this.log('Stopping firehose worker') + this.isShuttingDown = true + + if (this.firehose) { + this.firehose.destroy() + this.firehose = null + } + } + + private connect() { + if (this.isShuttingDown) return + + this.log('Connecting to AT Protocol firehose') + + this.firehose = new Firehose({ + idResolver: this.idResolver, + service: 'wss://bsky.network', + filterCollections: ['place.wisp.fs'], + handleEvent: async (evt: any) => { + this.lastEventTime = Date.now() + + // Watch for write events + if (evt.event === 'create' || evt.event === 'update') { + const record = evt.record + + // If the write is a valid place.wisp.fs record + if ( + evt.collection === 'place.wisp.fs' && + isRecord(record) && + validateRecord(record).success + ) { + this.log('Received place.wisp.fs event', { + did: evt.did, + event: evt.event, + rkey: evt.rkey + }) + + try { + await this.handleCreateOrUpdate( + evt.did, + evt.rkey, + record, + evt.cid?.toString() + ) + } catch (err) { + this.log('Error handling event', { + did: evt.did, + event: evt.event, + rkey: evt.rkey, + error: + err instanceof Error + ? err.message + : String(err) + }) + } + } + } else if ( + evt.event === 'delete' && + evt.collection === 'place.wisp.fs' + ) { + this.log('Received delete event', { + did: evt.did, + rkey: evt.rkey + }) + + try { + await this.handleDelete(evt.did, evt.rkey) + } catch (err) { + this.log('Error handling delete', { + did: evt.did, + rkey: evt.rkey, + error: + err instanceof Error ? err.message : String(err) + }) + } + } + }, + onError: (err: any) => { + this.log('Firehose error', { + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + fullError: err + }) + console.error('Full firehose error:', err) + } + }) + + this.firehose.start() + this.log('Firehose started') + } + + private async handleCreateOrUpdate( + did: string, + site: string, + record: any, + eventCid?: string + ) { + this.log('Processing create/update', { did, site }) + + // Record is already validated in handleEvent + const fsRecord = record + + const pdsEndpoint = await getPdsForDid(did) + if (!pdsEndpoint) { + this.log('Could not resolve PDS for DID', { did }) + return + } + + this.log('Resolved PDS', { did, pdsEndpoint }) + + // Verify record exists on PDS and fetch its CID + let verifiedCid: string + try { + const result = await fetchSiteRecord(did, site) + + if (!result) { + this.log('Record not found on PDS, skipping cache', { + did, + site + }) + return + } + + verifiedCid = result.cid + + // Verify event CID matches PDS CID (prevent cache poisoning) + if (eventCid && eventCid !== verifiedCid) { + this.log('CID mismatch detected - potential spoofed event', { + did, + site, + eventCid, + verifiedCid + }) + return + } + + this.log('Record verified on PDS', { did, site, cid: verifiedCid }) + } catch (err) { + this.log('Failed to verify record on PDS', { + did, + site, + error: err instanceof Error ? err.message : String(err) + }) + return + } + + // Cache the record with verified CID (uses atomic swap internally) + // All instances cache locally for edge serving + await downloadAndCacheSite( + did, + site, + fsRecord, + pdsEndpoint, + verifiedCid + ) + + // Acquire distributed lock only for database write to prevent duplicate writes + const lockKey = `db:upsert:${did}:${site}` + const lockAcquired = await tryAcquireLock(lockKey) + + if (!lockAcquired) { + this.log('Another instance is writing to DB, skipping upsert', { + did, + site + }) + this.log('Successfully processed create/update (cached locally)', { + did, + site + }) + return + } + + try { + // Upsert site to database (only one instance does this) + await upsertSite(did, site, fsRecord.site) + this.log( + 'Successfully processed create/update (cached + DB updated)', + { did, site } + ) + } finally { + // Always release lock, even if DB write fails + await releaseLock(lockKey) + } + } + + private async handleDelete(did: string, site: string) { + this.log('Processing delete', { did, site }) + + // All instances should delete their local cache (no lock needed) + const pdsEndpoint = await getPdsForDid(did) + if (!pdsEndpoint) { + this.log('Could not resolve PDS for DID', { did }) + return + } + + // Verify record is actually deleted from PDS + try { + const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}` + const recordRes = await safeFetch(recordUrl) + + if (recordRes.ok) { + this.log('Record still exists on PDS, not deleting cache', { + did, + site + }) + return + } + + this.log('Verified record is deleted from PDS', { + did, + site, + status: recordRes.status + }) + } catch (err) { + this.log('Error verifying deletion on PDS', { + did, + site, + error: err instanceof Error ? err.message : String(err) + }) + } + + // Delete cache + this.deleteCache(did, site) + + this.log('Successfully processed delete', { did, site }) + } + + private deleteCache(did: string, site: string) { + const cacheDir = `${CACHE_DIR}/${did}/${site}` + + if (!existsSync(cacheDir)) { + this.log('Cache directory does not exist, nothing to delete', { + did, + site + }) + return + } + + try { + rmSync(cacheDir, { recursive: true, force: true }) + this.log('Cache deleted', { did, site, path: cacheDir }) + } catch (err) { + this.log('Failed to delete cache', { + did, + site, + path: cacheDir, + error: err instanceof Error ? err.message : String(err) + }) + } + } + + getHealth() { + const isConnected = this.firehose !== null + const timeSinceLastEvent = Date.now() - this.lastEventTime + + return { + connected: isConnected, + lastEventTime: this.lastEventTime, + timeSinceLastEvent, + healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes + } + } } diff --git a/hosting-service/src/lib/html-rewriter.test.ts b/hosting-service/src/lib/html-rewriter.test.ts new file mode 100644 index 0000000..26dfdce --- /dev/null +++ b/hosting-service/src/lib/html-rewriter.test.ts @@ -0,0 +1,457 @@ +import { describe, test, expect } from 'bun:test' +import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter' + +describe('rewriteHtmlPaths', () => { + const basePath = '/identifier/site/' + + describe('absolute paths', () => { + test('rewrites absolute paths with leading slash', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('rewrites nested absolute paths', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + }) + + describe('relative paths from root document', () => { + test('rewrites relative paths with ./ prefix', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('rewrites relative paths without prefix', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('rewrites relative paths with ../ (should stay at root)', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + }) + + describe('relative paths from nested documents', () => { + test('rewrites relative path from nested document', () => { + const html = '' + const result = rewriteHtmlPaths( + html, + basePath, + 'folder1/folder2/index.html' + ) + expect(result).toBe( + '' + ) + }) + + test('rewrites plain filename from nested document', () => { + const html = '' + const result = rewriteHtmlPaths( + html, + basePath, + 'folder1/folder2/index.html' + ) + expect(result).toBe( + '' + ) + }) + + test('rewrites ../ to go up one level', () => { + const html = '' + const result = rewriteHtmlPaths( + html, + basePath, + 'folder1/folder2/folder3/index.html' + ) + expect(result).toBe( + '' + ) + }) + + test('rewrites multiple ../ to go up multiple levels', () => { + const html = '' + const result = rewriteHtmlPaths( + html, + basePath, + 'folder1/folder2/folder3/index.html' + ) + expect(result).toBe( + '' + ) + }) + + test('rewrites ../ with additional path segments', () => { + const html = '' + const result = rewriteHtmlPaths( + html, + basePath, + 'pages/about/index.html' + ) + expect(result).toBe( + '' + ) + }) + + test('handles complex nested relative paths', () => { + const html = '' + const result = rewriteHtmlPaths( + html, + basePath, + 'pages/blog/post/index.html' + ) + expect(result).toBe( + '' + ) + }) + + test('handles ../ going past root (stays at root)', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html') + expect(result).toBe('') + }) + }) + + describe('external URLs and special schemes', () => { + test('does not rewrite http URLs', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('does not rewrite https URLs', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + + test('does not rewrite protocol-relative URLs', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + + test('does not rewrite data URIs', () => { + const html = + '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + + test('does not rewrite mailto links', () => { + const html = 'Email' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('Email') + }) + + test('does not rewrite tel links', () => { + const html = 'Call' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('Call') + }) + }) + + describe('different HTML attributes', () => { + test('rewrites src attribute', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('rewrites href attribute', () => { + const html = 'Link' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('Link') + }) + + test('rewrites action attribute', () => { + const html = '
' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('
') + }) + + test('rewrites data attribute', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + + test('rewrites poster attribute', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + + test('rewrites srcset attribute with single URL', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + + test('rewrites srcset attribute with multiple URLs', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + + test('rewrites srcset with width descriptors', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + + test('rewrites srcset with relative paths from nested document', () => { + const html = '' + const result = rewriteHtmlPaths( + html, + basePath, + 'folder1/folder2/index.html' + ) + expect(result).toBe( + '' + ) + }) + }) + + describe('quote handling', () => { + test('handles double quotes', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('handles single quotes', () => { + const html = "" + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe("") + }) + + test('handles mixed quotes in same document', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + }) + + describe('multiple rewrites in same document', () => { + test('rewrites multiple attributes in complex HTML', () => { + const html = ` + + + + + + + + Logo + About +
+ +
+ + + ` + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toContain('href="/identifier/site/css/style.css"') + expect(result).toContain('src="/identifier/site/js/app.js"') + expect(result).toContain('src="/identifier/site/images/logo.png"') + expect(result).toContain('href="/identifier/site/about.html"') + expect(result).toContain('action="/identifier/site/submit"') + }) + + test('handles mix of relative and absolute paths', () => { + const html = ` + + + + + ` + const result = rewriteHtmlPaths( + html, + basePath, + 'folder1/folder2/page.html' + ) + expect(result).toContain('src="/identifier/site/abs/image.png"') + expect(result).toContain( + 'src="/identifier/site/folder1/folder2/rel/image.png"' + ) + expect(result).toContain( + 'src="/identifier/site/folder1/parent/image.png"' + ) + expect(result).toContain('src="https://external.com/image.png"') + }) + }) + + describe('edge cases', () => { + test('handles empty src attribute', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('handles basePath without trailing slash', () => { + const html = '' + const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html') + expect(result).toBe('') + }) + + test('handles basePath with trailing slash', () => { + const html = '' + const result = rewriteHtmlPaths( + html, + '/identifier/site/', + 'index.html' + ) + expect(result).toBe('') + }) + + test('handles whitespace around equals sign', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('preserves query strings in URLs', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe('') + }) + + test('preserves hash fragments in URLs', () => { + const html = 'Link' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + 'Link' + ) + }) + + test('handles paths with special characters', () => { + const html = '' + const result = rewriteHtmlPaths(html, basePath, 'index.html') + expect(result).toBe( + '' + ) + }) + }) + + describe('real-world scenario', () => { + test('handles the example from the bug report', () => { + // HTML file at: /folder1/folder2/folder3/index.html + // Image at: /folder1/folder2/img.png + // Reference: src="../img.png" + const html = '' + const result = rewriteHtmlPaths( + html, + basePath, + 'folder1/folder2/folder3/index.html' + ) + expect(result).toBe( + '' + ) + }) + + test('handles deeply nested static site structure', () => { + // A typical static site with nested pages and shared assets + const html = ` + + + + + + + + + Logo + Post + Back to Blog + Home + + + ` + const result = rewriteHtmlPaths( + html, + basePath, + 'blog/posts/my-post.html' + ) + + // Assets two levels up + expect(result).toContain('href="/identifier/site/css/style.css"') + expect(result).toContain('href="/identifier/site/css/theme.css"') + expect(result).toContain('src="/identifier/site/js/main.js"') + expect(result).toContain('src="/identifier/site/images/logo.png"') + + // Same directory + expect(result).toContain( + 'src="/identifier/site/blog/posts/post-image.jpg"' + ) + + // One level up + expect(result).toContain('href="/identifier/site/blog/index.html"') + + // Two levels up + expect(result).toContain('href="/identifier/site/index.html"') + }) + }) +}) + +describe('isHtmlContent', () => { + test('identifies HTML by content type', () => { + expect(isHtmlContent('file.txt', 'text/html')).toBe(true) + expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe( + true + ) + }) + + test('identifies HTML by .html extension', () => { + expect(isHtmlContent('index.html')).toBe(true) + expect(isHtmlContent('page.html', undefined)).toBe(true) + expect(isHtmlContent('/path/to/file.html')).toBe(true) + }) + + test('identifies HTML by .htm extension', () => { + expect(isHtmlContent('index.htm')).toBe(true) + expect(isHtmlContent('page.htm', undefined)).toBe(true) + }) + + test('handles case-insensitive extensions', () => { + expect(isHtmlContent('INDEX.HTML')).toBe(true) + expect(isHtmlContent('page.HTM')).toBe(true) + expect(isHtmlContent('File.HtMl')).toBe(true) + }) + + test('returns false for non-HTML files', () => { + expect(isHtmlContent('script.js')).toBe(false) + expect(isHtmlContent('style.css')).toBe(false) + expect(isHtmlContent('image.png')).toBe(false) + expect(isHtmlContent('data.json')).toBe(false) + }) + + test('returns false for files with no extension', () => { + expect(isHtmlContent('README')).toBe(false) + expect(isHtmlContent('Makefile')).toBe(false) + }) +}) diff --git a/hosting-service/src/lib/html-rewriter.ts b/hosting-service/src/lib/html-rewriter.ts index d6a696c..71c54dc 100644 --- a/hosting-service/src/lib/html-rewriter.ts +++ b/hosting-service/src/lib/html-rewriter.ts @@ -4,144 +4,223 @@ */ const REWRITABLE_ATTRIBUTES = [ - 'src', - 'href', - 'action', - 'data', - 'poster', - 'srcset', -] as const; + 'src', + 'href', + 'action', + 'data', + 'poster', + 'srcset' +] as const /** * Check if a path should be rewritten */ function shouldRewritePath(path: string): boolean { - // Don't rewrite empty paths - if (!path) return false; - - // Don't rewrite external URLs (http://, https://, //) - if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) { - return false; - } - - // Don't rewrite data URIs or other schemes (except file paths) - if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) { - return false; - } - - // Don't rewrite pure anchors or paths that start with /# - if (path.startsWith('#') || path.startsWith('/#')) return false; + // Don't rewrite empty paths + if (!path) return false + + // Don't rewrite external URLs (http://, https://, //) + if ( + path.startsWith('http://') || + path.startsWith('https://') || + path.startsWith('//') + ) { + return false + } + + // Don't rewrite data URIs or other schemes (except file paths) + if ( + path.includes(':') && + !path.startsWith('./') && + !path.startsWith('../') + ) { + return false + } + + // Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames) + return true +} - // Don't rewrite relative paths (./ or ../) - if (path.startsWith('./') || path.startsWith('../')) return false; +/** + * Normalize a path by resolving . and .. segments + */ +function normalizePath(path: string): string { + const parts = path.split('/') + const result: string[] = [] + + for (const part of parts) { + if (part === '.' || part === '') { + // Skip current directory and empty parts (but keep leading empty for absolute paths) + if (part === '' && result.length === 0) { + result.push(part) + } + continue + } + if (part === '..') { + // Go up one directory (but not past root) + if (result.length > 0 && result[result.length - 1] !== '..') { + result.pop() + } + continue + } + result.push(part) + } + + return result.join('/') +} - // Rewrite absolute paths (/) - return true; +/** + * Get the directory path from a file path + * e.g., "folder1/folder2/file.html" -> "folder1/folder2/" + */ +function getDirectory(filepath: string): string { + const lastSlash = filepath.lastIndexOf('/') + if (lastSlash === -1) { + return '' + } + return filepath.substring(0, lastSlash + 1) } /** * Rewrite a single path */ -function rewritePath(path: string, basePath: string): string { - if (!shouldRewritePath(path)) { - return path; - } - - // Handle absolute paths: /file.js -> /base/file.js - if (path.startsWith('/')) { - return basePath + path.slice(1); - } - - // At this point, only plain filenames without ./ or ../ prefix should reach here - // But since we're filtering those in shouldRewritePath, this shouldn't happen - return path; +function rewritePath( + path: string, + basePath: string, + documentPath: string +): string { + if (!shouldRewritePath(path)) { + return path + } + + // Handle absolute paths: /file.js -> /base/file.js + if (path.startsWith('/')) { + return basePath + path.slice(1) + } + + // Handle relative paths by resolving against document directory + const documentDir = getDirectory(documentPath) + let resolvedPath: string + + if (path.startsWith('./')) { + // ./file.js relative to current directory + resolvedPath = documentDir + path.slice(2) + } else if (path.startsWith('../')) { + // ../file.js relative to parent directory + resolvedPath = documentDir + path + } else { + // file.js (no prefix) - treat as relative to current directory + resolvedPath = documentDir + path + } + + // Normalize the path to resolve .. and . + resolvedPath = normalizePath(resolvedPath) + + return basePath + resolvedPath } /** * Rewrite srcset attribute (can contain multiple URLs) * Format: "url1 1x, url2 2x" or "url1 100w, url2 200w" */ -function rewriteSrcset(srcset: string, basePath: string): string { - return srcset - .split(',') - .map(part => { - const trimmed = part.trim(); - const spaceIndex = trimmed.indexOf(' '); - - if (spaceIndex === -1) { - // No descriptor, just URL - return rewritePath(trimmed, basePath); - } - - const url = trimmed.substring(0, spaceIndex); - const descriptor = trimmed.substring(spaceIndex); - return rewritePath(url, basePath) + descriptor; - }) - .join(', '); +function rewriteSrcset( + srcset: string, + basePath: string, + documentPath: string +): string { + return srcset + .split(',') + .map((part) => { + const trimmed = part.trim() + const spaceIndex = trimmed.indexOf(' ') + + if (spaceIndex === -1) { + // No descriptor, just URL + return rewritePath(trimmed, basePath, documentPath) + } + + const url = trimmed.substring(0, spaceIndex) + const descriptor = trimmed.substring(spaceIndex) + return rewritePath(url, basePath, documentPath) + descriptor + }) + .join(', ') } /** - * Rewrite absolute paths in HTML content + * Rewrite absolute and relative paths in HTML content * Uses simple regex matching for safety (no full HTML parsing) */ -export function rewriteHtmlPaths(html: string, basePath: string): string { - // Ensure base path ends with / - const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'; - - let rewritten = html; - - // Rewrite each attribute type - // Use more specific patterns to prevent ReDoS attacks - for (const attr of REWRITABLE_ATTRIBUTES) { - if (attr === 'srcset') { - // Special handling for srcset - use possessive quantifiers via atomic grouping simulation - // Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS - const srcsetRegex = new RegExp( - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, - 'gi' - ); - rewritten = rewritten.replace(srcsetRegex, (match, value) => { - const rewrittenValue = rewriteSrcset(value, normalizedBase); - return `${attr}="${rewrittenValue}"`; - }); - } else { - // Regular attributes with quoted values - // Limit whitespace to prevent catastrophic backtracking - const doubleQuoteRegex = new RegExp( - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, - 'gi' - ); - const singleQuoteRegex = new RegExp( - `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`, - 'gi' - ); - - rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => { - const rewrittenValue = rewritePath(value, normalizedBase); - return `${attr}="${rewrittenValue}"`; - }); - - rewritten = rewritten.replace(singleQuoteRegex, (match, value) => { - const rewrittenValue = rewritePath(value, normalizedBase); - return `${attr}='${rewrittenValue}'`; - }); - } - } - - return rewritten; +export function rewriteHtmlPaths( + html: string, + basePath: string, + documentPath: string +): string { + // Ensure base path ends with / + const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/' + + let rewritten = html + + // Rewrite each attribute type + // Use more specific patterns to prevent ReDoS attacks + for (const attr of REWRITABLE_ATTRIBUTES) { + if (attr === 'srcset') { + // Special handling for srcset - use possessive quantifiers via atomic grouping simulation + // Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS + const srcsetRegex = new RegExp( + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, + 'gi' + ) + rewritten = rewritten.replace(srcsetRegex, (match, value) => { + const rewrittenValue = rewriteSrcset( + value, + normalizedBase, + documentPath + ) + return `${attr}="${rewrittenValue}"` + }) + } else { + // Regular attributes with quoted values + // Limit whitespace to prevent catastrophic backtracking + const doubleQuoteRegex = new RegExp( + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`, + 'gi' + ) + const singleQuoteRegex = new RegExp( + `\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`, + 'gi' + ) + + rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => { + const rewrittenValue = rewritePath( + value, + normalizedBase, + documentPath + ) + return `${attr}="${rewrittenValue}"` + }) + + rewritten = rewritten.replace(singleQuoteRegex, (match, value) => { + const rewrittenValue = rewritePath( + value, + normalizedBase, + documentPath + ) + return `${attr}='${rewrittenValue}'` + }) + } + } + + return rewritten } /** * Check if content is HTML based on content or filename */ -export function isHtmlContent( - filepath: string, - contentType?: string -): boolean { - if (contentType && contentType.includes('text/html')) { - return true; - } - - const ext = filepath.toLowerCase().split('.').pop(); - return ext === 'html' || ext === 'htm'; +export function isHtmlContent(filepath: string, contentType?: string): boolean { + if (contentType && contentType.includes('text/html')) { + return true + } + + const ext = filepath.toLowerCase().split('.').pop() + return ext === 'html' || ext === 'htm' } diff --git a/hosting-service/src/server.ts b/hosting-service/src/server.ts index ea6ad7b..e479198 100644 --- a/hosting-service/src/server.ts +++ b/hosting-service/src/server.ts @@ -156,7 +156,7 @@ async function serveFromCacheWithRewrite( } else { content = readFileSync(cachedFile, 'utf-8'); } - const rewritten = rewriteHtmlPaths(content, basePath); + const rewritten = rewriteHtmlPaths(content, basePath, requestPath); // Recompress the HTML for efficient delivery const { gzipSync } = await import('zlib'); @@ -224,7 +224,8 @@ async function serveFromCacheWithRewrite( } else { content = readFileSync(indexFile, 'utf-8'); } - const rewritten = rewriteHtmlPaths(content, basePath); + const indexPath = `${requestPath}/index.html`; + const rewritten = rewriteHtmlPaths(content, basePath, indexPath); // Recompress the HTML for efficient delivery const { gzipSync } = await import('zlib'); -- 2.54.0