+8
.dockerignore
+8
.dockerignore
+10
-6
Dockerfile
+10
-6
Dockerfile
···
15
15
COPY public ./public
16
16
17
17
# Build the application (if needed)
18
-
# RUN bun run build
18
+
RUN bun build \
19
+
--compile \
20
+
--minify \
21
+
--outfile server \
22
+
src/index.ts
23
+
24
+
FROM scratch AS runtime
25
+
WORKDIR /app
26
+
COPY --from=base /app/server /app/server
19
27
20
28
# Set environment variables (can be overridden at runtime)
21
29
ENV PORT=3000
···
24
32
# Expose the application port
25
33
EXPOSE 3000
26
34
27
-
# Health check
28
-
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
29
-
CMD bun -e "fetch('http://localhost:3000/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
30
-
31
35
# Start the application
32
-
CMD ["bun", "src/index.ts"]
36
+
CMD ["./server"]
+259
-219
hosting-service/src/lib/firehose.ts
+259
-219
hosting-service/src/lib/firehose.ts
···
1
-
import { existsSync, rmSync } from 'fs';
2
-
import { getPdsForDid, downloadAndCacheSite, extractBlobCid, fetchSiteRecord } from './utils';
3
-
import { upsertSite, tryAcquireLock, releaseLock } from './db';
4
-
import { safeFetch } from './safe-fetch';
5
-
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs';
6
-
import { Firehose } from '@atproto/sync';
7
-
import { IdResolver } from '@atproto/identity';
1
+
import { existsSync, rmSync } from 'fs'
2
+
import {
3
+
getPdsForDid,
4
+
downloadAndCacheSite,
5
+
extractBlobCid,
6
+
fetchSiteRecord
7
+
} from './utils'
8
+
import { upsertSite, tryAcquireLock, releaseLock } from './db'
9
+
import { safeFetch } from './safe-fetch'
10
+
import { isRecord, validateRecord } from '../lexicon/types/place/wisp/fs'
11
+
import { Firehose } from '@atproto/sync'
12
+
import { IdResolver } from '@atproto/identity'
8
13
9
-
const CACHE_DIR = './cache/sites';
14
+
const CACHE_DIR = './cache/sites'
10
15
11
16
export class FirehoseWorker {
12
-
private firehose: Firehose | null = null;
13
-
private idResolver: IdResolver;
14
-
private isShuttingDown = false;
15
-
private lastEventTime = Date.now();
17
+
private firehose: Firehose | null = null
18
+
private idResolver: IdResolver
19
+
private isShuttingDown = false
20
+
private lastEventTime = Date.now()
16
21
17
-
constructor(
18
-
private logger?: (msg: string, data?: Record<string, unknown>) => void,
19
-
) {
20
-
this.idResolver = new IdResolver();
21
-
}
22
+
constructor(
23
+
private logger?: (msg: string, data?: Record<string, unknown>) => void
24
+
) {
25
+
this.idResolver = new IdResolver()
26
+
}
22
27
23
-
private log(msg: string, data?: Record<string, unknown>) {
24
-
const log = this.logger || console.log;
25
-
log(`[FirehoseWorker] ${msg}`, data || {});
26
-
}
28
+
private log(msg: string, data?: Record<string, unknown>) {
29
+
const log = this.logger || console.log
30
+
log(`[FirehoseWorker] ${msg}`, data || {})
31
+
}
27
32
28
-
start() {
29
-
this.log('Starting firehose worker');
30
-
this.connect();
31
-
}
33
+
start() {
34
+
this.log('Starting firehose worker')
35
+
this.connect()
36
+
}
32
37
33
-
stop() {
34
-
this.log('Stopping firehose worker');
35
-
this.isShuttingDown = true;
38
+
stop() {
39
+
this.log('Stopping firehose worker')
40
+
this.isShuttingDown = true
36
41
37
-
if (this.firehose) {
38
-
this.firehose.destroy();
39
-
this.firehose = null;
40
-
}
41
-
}
42
+
if (this.firehose) {
43
+
this.firehose.destroy()
44
+
this.firehose = null
45
+
}
46
+
}
42
47
43
-
private connect() {
44
-
if (this.isShuttingDown) return;
48
+
private connect() {
49
+
if (this.isShuttingDown) return
45
50
46
-
this.log('Connecting to AT Protocol firehose');
51
+
this.log('Connecting to AT Protocol firehose')
47
52
48
-
this.firehose = new Firehose({
49
-
idResolver: this.idResolver,
50
-
service: 'wss://bsky.network',
51
-
filterCollections: ['place.wisp.fs'],
52
-
handleEvent: async (evt: any) => {
53
-
this.lastEventTime = Date.now();
53
+
this.firehose = new Firehose({
54
+
idResolver: this.idResolver,
55
+
service: 'wss://bsky.network',
56
+
filterCollections: ['place.wisp.fs'],
57
+
handleEvent: async (evt: any) => {
58
+
this.lastEventTime = Date.now()
54
59
55
-
// Watch for write events
56
-
if (evt.event === 'create' || evt.event === 'update') {
57
-
const record = evt.record;
60
+
// Watch for write events
61
+
if (evt.event === 'create' || evt.event === 'update') {
62
+
const record = evt.record
58
63
59
-
// If the write is a valid place.wisp.fs record
60
-
if (
61
-
evt.collection === 'place.wisp.fs' &&
62
-
isRecord(record) &&
63
-
validateRecord(record).success
64
-
) {
65
-
this.log('Received place.wisp.fs event', {
66
-
did: evt.did,
67
-
event: evt.event,
68
-
rkey: evt.rkey,
69
-
});
64
+
// If the write is a valid place.wisp.fs record
65
+
if (
66
+
evt.collection === 'place.wisp.fs' &&
67
+
isRecord(record) &&
68
+
validateRecord(record).success
69
+
) {
70
+
this.log('Received place.wisp.fs event', {
71
+
did: evt.did,
72
+
event: evt.event,
73
+
rkey: evt.rkey
74
+
})
70
75
71
-
try {
72
-
await this.handleCreateOrUpdate(evt.did, evt.rkey, record, evt.cid?.toString());
73
-
} catch (err) {
74
-
this.log('Error handling event', {
75
-
did: evt.did,
76
-
event: evt.event,
77
-
rkey: evt.rkey,
78
-
error: err instanceof Error ? err.message : String(err),
79
-
});
80
-
}
81
-
}
82
-
} else if (evt.event === 'delete' && evt.collection === 'place.wisp.fs') {
83
-
this.log('Received delete event', {
84
-
did: evt.did,
85
-
rkey: evt.rkey,
86
-
});
76
+
try {
77
+
await this.handleCreateOrUpdate(
78
+
evt.did,
79
+
evt.rkey,
80
+
record,
81
+
evt.cid?.toString()
82
+
)
83
+
} catch (err) {
84
+
this.log('Error handling event', {
85
+
did: evt.did,
86
+
event: evt.event,
87
+
rkey: evt.rkey,
88
+
error:
89
+
err instanceof Error
90
+
? err.message
91
+
: String(err)
92
+
})
93
+
}
94
+
}
95
+
} else if (
96
+
evt.event === 'delete' &&
97
+
evt.collection === 'place.wisp.fs'
98
+
) {
99
+
this.log('Received delete event', {
100
+
did: evt.did,
101
+
rkey: evt.rkey
102
+
})
87
103
88
-
try {
89
-
await this.handleDelete(evt.did, evt.rkey);
90
-
} catch (err) {
91
-
this.log('Error handling delete', {
92
-
did: evt.did,
93
-
rkey: evt.rkey,
94
-
error: err instanceof Error ? err.message : String(err),
95
-
});
96
-
}
97
-
}
98
-
},
99
-
onError: (err: any) => {
100
-
this.log('Firehose error', {
101
-
error: err instanceof Error ? err.message : String(err),
102
-
stack: err instanceof Error ? err.stack : undefined,
103
-
fullError: err,
104
-
});
105
-
console.error('Full firehose error:', err);
106
-
},
107
-
});
104
+
try {
105
+
await this.handleDelete(evt.did, evt.rkey)
106
+
} catch (err) {
107
+
this.log('Error handling delete', {
108
+
did: evt.did,
109
+
rkey: evt.rkey,
110
+
error:
111
+
err instanceof Error ? err.message : String(err)
112
+
})
113
+
}
114
+
}
115
+
},
116
+
onError: (err: any) => {
117
+
this.log('Firehose error', {
118
+
error: err instanceof Error ? err.message : String(err),
119
+
stack: err instanceof Error ? err.stack : undefined,
120
+
fullError: err
121
+
})
122
+
console.error('Full firehose error:', err)
123
+
}
124
+
})
108
125
109
-
this.firehose.start();
110
-
this.log('Firehose started');
111
-
}
126
+
this.firehose.start()
127
+
this.log('Firehose started')
128
+
}
112
129
113
-
private async handleCreateOrUpdate(did: string, site: string, record: any, eventCid?: string) {
114
-
this.log('Processing create/update', { did, site });
130
+
private async handleCreateOrUpdate(
131
+
did: string,
132
+
site: string,
133
+
record: any,
134
+
eventCid?: string
135
+
) {
136
+
this.log('Processing create/update', { did, site })
115
137
116
-
// Record is already validated in handleEvent
117
-
const fsRecord = record;
138
+
// Record is already validated in handleEvent
139
+
const fsRecord = record
118
140
119
-
const pdsEndpoint = await getPdsForDid(did);
120
-
if (!pdsEndpoint) {
121
-
this.log('Could not resolve PDS for DID', { did });
122
-
return;
123
-
}
141
+
const pdsEndpoint = await getPdsForDid(did)
142
+
if (!pdsEndpoint) {
143
+
this.log('Could not resolve PDS for DID', { did })
144
+
return
145
+
}
124
146
125
-
this.log('Resolved PDS', { did, pdsEndpoint });
147
+
this.log('Resolved PDS', { did, pdsEndpoint })
126
148
127
-
// Verify record exists on PDS and fetch its CID
128
-
let verifiedCid: string;
129
-
try {
130
-
const result = await fetchSiteRecord(did, site);
149
+
// Verify record exists on PDS and fetch its CID
150
+
let verifiedCid: string
151
+
try {
152
+
const result = await fetchSiteRecord(did, site)
131
153
132
-
if (!result) {
133
-
this.log('Record not found on PDS, skipping cache', { did, site });
134
-
return;
135
-
}
154
+
if (!result) {
155
+
this.log('Record not found on PDS, skipping cache', {
156
+
did,
157
+
site
158
+
})
159
+
return
160
+
}
136
161
137
-
verifiedCid = result.cid;
162
+
verifiedCid = result.cid
138
163
139
-
// Verify event CID matches PDS CID (prevent cache poisoning)
140
-
if (eventCid && eventCid !== verifiedCid) {
141
-
this.log('CID mismatch detected - potential spoofed event', {
142
-
did,
143
-
site,
144
-
eventCid,
145
-
verifiedCid
146
-
});
147
-
return;
148
-
}
164
+
// Verify event CID matches PDS CID (prevent cache poisoning)
165
+
if (eventCid && eventCid !== verifiedCid) {
166
+
this.log('CID mismatch detected - potential spoofed event', {
167
+
did,
168
+
site,
169
+
eventCid,
170
+
verifiedCid
171
+
})
172
+
return
173
+
}
149
174
150
-
this.log('Record verified on PDS', { did, site, cid: verifiedCid });
151
-
} catch (err) {
152
-
this.log('Failed to verify record on PDS', {
153
-
did,
154
-
site,
155
-
error: err instanceof Error ? err.message : String(err),
156
-
});
157
-
return;
158
-
}
175
+
this.log('Record verified on PDS', { did, site, cid: verifiedCid })
176
+
} catch (err) {
177
+
this.log('Failed to verify record on PDS', {
178
+
did,
179
+
site,
180
+
error: err instanceof Error ? err.message : String(err)
181
+
})
182
+
return
183
+
}
159
184
160
-
// Cache the record with verified CID (uses atomic swap internally)
161
-
// All instances cache locally for edge serving
162
-
await downloadAndCacheSite(did, site, fsRecord, pdsEndpoint, verifiedCid);
185
+
// Cache the record with verified CID (uses atomic swap internally)
186
+
// All instances cache locally for edge serving
187
+
await downloadAndCacheSite(
188
+
did,
189
+
site,
190
+
fsRecord,
191
+
pdsEndpoint,
192
+
verifiedCid
193
+
)
163
194
164
-
// Acquire distributed lock only for database write to prevent duplicate writes
165
-
const lockKey = `db:upsert:${did}:${site}`;
166
-
const lockAcquired = await tryAcquireLock(lockKey);
195
+
// Acquire distributed lock only for database write to prevent duplicate writes
196
+
const lockKey = `db:upsert:${did}:${site}`
197
+
const lockAcquired = await tryAcquireLock(lockKey)
167
198
168
-
if (!lockAcquired) {
169
-
this.log('Another instance is writing to DB, skipping upsert', { did, site });
170
-
this.log('Successfully processed create/update (cached locally)', { did, site });
171
-
return;
172
-
}
199
+
if (!lockAcquired) {
200
+
this.log('Another instance is writing to DB, skipping upsert', {
201
+
did,
202
+
site
203
+
})
204
+
this.log('Successfully processed create/update (cached locally)', {
205
+
did,
206
+
site
207
+
})
208
+
return
209
+
}
173
210
174
-
try {
175
-
// Upsert site to database (only one instance does this)
176
-
await upsertSite(did, site, fsRecord.site);
177
-
this.log('Successfully processed create/update (cached + DB updated)', { did, site });
178
-
} finally {
179
-
// Always release lock, even if DB write fails
180
-
await releaseLock(lockKey);
181
-
}
182
-
}
211
+
try {
212
+
// Upsert site to database (only one instance does this)
213
+
await upsertSite(did, site, fsRecord.site)
214
+
this.log(
215
+
'Successfully processed create/update (cached + DB updated)',
216
+
{ did, site }
217
+
)
218
+
} finally {
219
+
// Always release lock, even if DB write fails
220
+
await releaseLock(lockKey)
221
+
}
222
+
}
183
223
184
-
private async handleDelete(did: string, site: string) {
185
-
this.log('Processing delete', { did, site });
224
+
private async handleDelete(did: string, site: string) {
225
+
this.log('Processing delete', { did, site })
186
226
187
-
// All instances should delete their local cache (no lock needed)
188
-
const pdsEndpoint = await getPdsForDid(did);
189
-
if (!pdsEndpoint) {
190
-
this.log('Could not resolve PDS for DID', { did });
191
-
return;
192
-
}
227
+
// All instances should delete their local cache (no lock needed)
228
+
const pdsEndpoint = await getPdsForDid(did)
229
+
if (!pdsEndpoint) {
230
+
this.log('Could not resolve PDS for DID', { did })
231
+
return
232
+
}
193
233
194
-
// Verify record is actually deleted from PDS
195
-
try {
196
-
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`;
197
-
const recordRes = await safeFetch(recordUrl);
234
+
// Verify record is actually deleted from PDS
235
+
try {
236
+
const recordUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(site)}`
237
+
const recordRes = await safeFetch(recordUrl)
198
238
199
-
if (recordRes.ok) {
200
-
this.log('Record still exists on PDS, not deleting cache', {
201
-
did,
202
-
site,
203
-
});
204
-
return;
205
-
}
239
+
if (recordRes.ok) {
240
+
this.log('Record still exists on PDS, not deleting cache', {
241
+
did,
242
+
site
243
+
})
244
+
return
245
+
}
206
246
207
-
this.log('Verified record is deleted from PDS', {
208
-
did,
209
-
site,
210
-
status: recordRes.status,
211
-
});
212
-
} catch (err) {
213
-
this.log('Error verifying deletion on PDS', {
214
-
did,
215
-
site,
216
-
error: err instanceof Error ? err.message : String(err),
217
-
});
218
-
}
247
+
this.log('Verified record is deleted from PDS', {
248
+
did,
249
+
site,
250
+
status: recordRes.status
251
+
})
252
+
} catch (err) {
253
+
this.log('Error verifying deletion on PDS', {
254
+
did,
255
+
site,
256
+
error: err instanceof Error ? err.message : String(err)
257
+
})
258
+
}
219
259
220
-
// Delete cache
221
-
this.deleteCache(did, site);
260
+
// Delete cache
261
+
this.deleteCache(did, site)
222
262
223
-
this.log('Successfully processed delete', { did, site });
224
-
}
263
+
this.log('Successfully processed delete', { did, site })
264
+
}
225
265
226
-
private deleteCache(did: string, site: string) {
227
-
const cacheDir = `${CACHE_DIR}/${did}/${site}`;
266
+
private deleteCache(did: string, site: string) {
267
+
const cacheDir = `${CACHE_DIR}/${did}/${site}`
228
268
229
-
if (!existsSync(cacheDir)) {
230
-
this.log('Cache directory does not exist, nothing to delete', {
231
-
did,
232
-
site,
233
-
});
234
-
return;
235
-
}
269
+
if (!existsSync(cacheDir)) {
270
+
this.log('Cache directory does not exist, nothing to delete', {
271
+
did,
272
+
site
273
+
})
274
+
return
275
+
}
236
276
237
-
try {
238
-
rmSync(cacheDir, { recursive: true, force: true });
239
-
this.log('Cache deleted', { did, site, path: cacheDir });
240
-
} catch (err) {
241
-
this.log('Failed to delete cache', {
242
-
did,
243
-
site,
244
-
path: cacheDir,
245
-
error: err instanceof Error ? err.message : String(err),
246
-
});
247
-
}
248
-
}
277
+
try {
278
+
rmSync(cacheDir, { recursive: true, force: true })
279
+
this.log('Cache deleted', { did, site, path: cacheDir })
280
+
} catch (err) {
281
+
this.log('Failed to delete cache', {
282
+
did,
283
+
site,
284
+
path: cacheDir,
285
+
error: err instanceof Error ? err.message : String(err)
286
+
})
287
+
}
288
+
}
249
289
250
-
getHealth() {
251
-
const isConnected = this.firehose !== null;
252
-
const timeSinceLastEvent = Date.now() - this.lastEventTime;
290
+
getHealth() {
291
+
const isConnected = this.firehose !== null
292
+
const timeSinceLastEvent = Date.now() - this.lastEventTime
253
293
254
-
return {
255
-
connected: isConnected,
256
-
lastEventTime: this.lastEventTime,
257
-
timeSinceLastEvent,
258
-
healthy: isConnected && timeSinceLastEvent < 300000, // 5 minutes
259
-
};
260
-
}
294
+
return {
295
+
connected: isConnected,
296
+
lastEventTime: this.lastEventTime,
297
+
timeSinceLastEvent,
298
+
healthy: isConnected && timeSinceLastEvent < 300000 // 5 minutes
299
+
}
300
+
}
261
301
}
+457
hosting-service/src/lib/html-rewriter.test.ts
+457
hosting-service/src/lib/html-rewriter.test.ts
···
1
+
import { describe, test, expect } from 'bun:test'
2
+
import { rewriteHtmlPaths, isHtmlContent } from './html-rewriter'
3
+
4
+
describe('rewriteHtmlPaths', () => {
5
+
const basePath = '/identifier/site/'
6
+
7
+
describe('absolute paths', () => {
8
+
test('rewrites absolute paths with leading slash', () => {
9
+
const html = '<img src="/image.png">'
10
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
11
+
expect(result).toBe('<img src="/identifier/site/image.png">')
12
+
})
13
+
14
+
test('rewrites nested absolute paths', () => {
15
+
const html = '<link href="/css/style.css">'
16
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
17
+
expect(result).toBe('<link href="/identifier/site/css/style.css">')
18
+
})
19
+
})
20
+
21
+
describe('relative paths from root document', () => {
22
+
test('rewrites relative paths with ./ prefix', () => {
23
+
const html = '<img src="./image.png">'
24
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
25
+
expect(result).toBe('<img src="/identifier/site/image.png">')
26
+
})
27
+
28
+
test('rewrites relative paths without prefix', () => {
29
+
const html = '<img src="image.png">'
30
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
31
+
expect(result).toBe('<img src="/identifier/site/image.png">')
32
+
})
33
+
34
+
test('rewrites relative paths with ../ (should stay at root)', () => {
35
+
const html = '<img src="../image.png">'
36
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
37
+
expect(result).toBe('<img src="/identifier/site/image.png">')
38
+
})
39
+
})
40
+
41
+
describe('relative paths from nested documents', () => {
42
+
test('rewrites relative path from nested document', () => {
43
+
const html = '<img src="./photo.jpg">'
44
+
const result = rewriteHtmlPaths(
45
+
html,
46
+
basePath,
47
+
'folder1/folder2/index.html'
48
+
)
49
+
expect(result).toBe(
50
+
'<img src="/identifier/site/folder1/folder2/photo.jpg">'
51
+
)
52
+
})
53
+
54
+
test('rewrites plain filename from nested document', () => {
55
+
const html = '<script src="app.js"></script>'
56
+
const result = rewriteHtmlPaths(
57
+
html,
58
+
basePath,
59
+
'folder1/folder2/index.html'
60
+
)
61
+
expect(result).toBe(
62
+
'<script src="/identifier/site/folder1/folder2/app.js"></script>'
63
+
)
64
+
})
65
+
66
+
test('rewrites ../ to go up one level', () => {
67
+
const html = '<img src="../image.png">'
68
+
const result = rewriteHtmlPaths(
69
+
html,
70
+
basePath,
71
+
'folder1/folder2/folder3/index.html'
72
+
)
73
+
expect(result).toBe(
74
+
'<img src="/identifier/site/folder1/folder2/image.png">'
75
+
)
76
+
})
77
+
78
+
test('rewrites multiple ../ to go up multiple levels', () => {
79
+
const html = '<link href="../../css/style.css">'
80
+
const result = rewriteHtmlPaths(
81
+
html,
82
+
basePath,
83
+
'folder1/folder2/folder3/index.html'
84
+
)
85
+
expect(result).toBe(
86
+
'<link href="/identifier/site/folder1/css/style.css">'
87
+
)
88
+
})
89
+
90
+
test('rewrites ../ with additional path segments', () => {
91
+
const html = '<img src="../assets/logo.png">'
92
+
const result = rewriteHtmlPaths(
93
+
html,
94
+
basePath,
95
+
'pages/about/index.html'
96
+
)
97
+
expect(result).toBe(
98
+
'<img src="/identifier/site/pages/assets/logo.png">'
99
+
)
100
+
})
101
+
102
+
test('handles complex nested relative paths', () => {
103
+
const html = '<script src="../../lib/vendor/jquery.js"></script>'
104
+
const result = rewriteHtmlPaths(
105
+
html,
106
+
basePath,
107
+
'pages/blog/post/index.html'
108
+
)
109
+
expect(result).toBe(
110
+
'<script src="/identifier/site/pages/lib/vendor/jquery.js"></script>'
111
+
)
112
+
})
113
+
114
+
test('handles ../ going past root (stays at root)', () => {
115
+
const html = '<img src="../../../image.png">'
116
+
const result = rewriteHtmlPaths(html, basePath, 'folder1/index.html')
117
+
expect(result).toBe('<img src="/identifier/site/image.png">')
118
+
})
119
+
})
120
+
121
+
describe('external URLs and special schemes', () => {
122
+
test('does not rewrite http URLs', () => {
123
+
const html = '<img src="http://example.com/image.png">'
124
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
125
+
expect(result).toBe('<img src="http://example.com/image.png">')
126
+
})
127
+
128
+
test('does not rewrite https URLs', () => {
129
+
const html = '<link href="https://cdn.example.com/style.css">'
130
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
131
+
expect(result).toBe(
132
+
'<link href="https://cdn.example.com/style.css">'
133
+
)
134
+
})
135
+
136
+
test('does not rewrite protocol-relative URLs', () => {
137
+
const html = '<script src="//cdn.example.com/script.js"></script>'
138
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
139
+
expect(result).toBe(
140
+
'<script src="//cdn.example.com/script.js"></script>'
141
+
)
142
+
})
143
+
144
+
test('does not rewrite data URIs', () => {
145
+
const html =
146
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
147
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
148
+
expect(result).toBe(
149
+
'<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA">'
150
+
)
151
+
})
152
+
153
+
test('does not rewrite mailto links', () => {
154
+
const html = '<a href="mailto:test@example.com">Email</a>'
155
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
156
+
expect(result).toBe('<a href="mailto:test@example.com">Email</a>')
157
+
})
158
+
159
+
test('does not rewrite tel links', () => {
160
+
const html = '<a href="tel:+1234567890">Call</a>'
161
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
162
+
expect(result).toBe('<a href="tel:+1234567890">Call</a>')
163
+
})
164
+
})
165
+
166
+
describe('different HTML attributes', () => {
167
+
test('rewrites src attribute', () => {
168
+
const html = '<img src="/image.png">'
169
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
170
+
expect(result).toBe('<img src="/identifier/site/image.png">')
171
+
})
172
+
173
+
test('rewrites href attribute', () => {
174
+
const html = '<a href="/page.html">Link</a>'
175
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
176
+
expect(result).toBe('<a href="/identifier/site/page.html">Link</a>')
177
+
})
178
+
179
+
test('rewrites action attribute', () => {
180
+
const html = '<form action="/submit"></form>'
181
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
182
+
expect(result).toBe('<form action="/identifier/site/submit"></form>')
183
+
})
184
+
185
+
test('rewrites data attribute', () => {
186
+
const html = '<object data="/document.pdf"></object>'
187
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
188
+
expect(result).toBe(
189
+
'<object data="/identifier/site/document.pdf"></object>'
190
+
)
191
+
})
192
+
193
+
test('rewrites poster attribute', () => {
194
+
const html = '<video poster="/thumbnail.jpg"></video>'
195
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
196
+
expect(result).toBe(
197
+
'<video poster="/identifier/site/thumbnail.jpg"></video>'
198
+
)
199
+
})
200
+
201
+
test('rewrites srcset attribute with single URL', () => {
202
+
const html = '<img srcset="/image.png 1x">'
203
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
204
+
expect(result).toBe(
205
+
'<img srcset="/identifier/site/image.png 1x">'
206
+
)
207
+
})
208
+
209
+
test('rewrites srcset attribute with multiple URLs', () => {
210
+
const html = '<img srcset="/image-1x.png 1x, /image-2x.png 2x">'
211
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
212
+
expect(result).toBe(
213
+
'<img srcset="/identifier/site/image-1x.png 1x, /identifier/site/image-2x.png 2x">'
214
+
)
215
+
})
216
+
217
+
test('rewrites srcset with width descriptors', () => {
218
+
const html = '<img srcset="/small.jpg 320w, /large.jpg 1024w">'
219
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
220
+
expect(result).toBe(
221
+
'<img srcset="/identifier/site/small.jpg 320w, /identifier/site/large.jpg 1024w">'
222
+
)
223
+
})
224
+
225
+
test('rewrites srcset with relative paths from nested document', () => {
226
+
const html = '<img srcset="../img1.png 1x, ../img2.png 2x">'
227
+
const result = rewriteHtmlPaths(
228
+
html,
229
+
basePath,
230
+
'folder1/folder2/index.html'
231
+
)
232
+
expect(result).toBe(
233
+
'<img srcset="/identifier/site/folder1/img1.png 1x, /identifier/site/folder1/img2.png 2x">'
234
+
)
235
+
})
236
+
})
237
+
238
+
describe('quote handling', () => {
239
+
test('handles double quotes', () => {
240
+
const html = '<img src="/image.png">'
241
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
242
+
expect(result).toBe('<img src="/identifier/site/image.png">')
243
+
})
244
+
245
+
test('handles single quotes', () => {
246
+
const html = "<img src='/image.png'>"
247
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
248
+
expect(result).toBe("<img src='/identifier/site/image.png'>")
249
+
})
250
+
251
+
test('handles mixed quotes in same document', () => {
252
+
const html = '<img src="/img1.png"><link href=\'/style.css\'>'
253
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
254
+
expect(result).toBe(
255
+
'<img src="/identifier/site/img1.png"><link href=\'/identifier/site/style.css\'>'
256
+
)
257
+
})
258
+
})
259
+
260
+
describe('multiple rewrites in same document', () => {
261
+
test('rewrites multiple attributes in complex HTML', () => {
262
+
const html = `
263
+
<!DOCTYPE html>
264
+
<html>
265
+
<head>
266
+
<link href="/css/style.css" rel="stylesheet">
267
+
<script src="/js/app.js"></script>
268
+
</head>
269
+
<body>
270
+
<img src="/images/logo.png" alt="Logo">
271
+
<a href="/about.html">About</a>
272
+
<form action="/submit">
273
+
<button type="submit">Submit</button>
274
+
</form>
275
+
</body>
276
+
</html>
277
+
`
278
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
279
+
expect(result).toContain('href="/identifier/site/css/style.css"')
280
+
expect(result).toContain('src="/identifier/site/js/app.js"')
281
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
282
+
expect(result).toContain('href="/identifier/site/about.html"')
283
+
expect(result).toContain('action="/identifier/site/submit"')
284
+
})
285
+
286
+
test('handles mix of relative and absolute paths', () => {
287
+
const html = `
288
+
<img src="/abs/image.png">
289
+
<img src="./rel/image.png">
290
+
<img src="../parent/image.png">
291
+
<img src="https://external.com/image.png">
292
+
`
293
+
const result = rewriteHtmlPaths(
294
+
html,
295
+
basePath,
296
+
'folder1/folder2/page.html'
297
+
)
298
+
expect(result).toContain('src="/identifier/site/abs/image.png"')
299
+
expect(result).toContain(
300
+
'src="/identifier/site/folder1/folder2/rel/image.png"'
301
+
)
302
+
expect(result).toContain(
303
+
'src="/identifier/site/folder1/parent/image.png"'
304
+
)
305
+
expect(result).toContain('src="https://external.com/image.png"')
306
+
})
307
+
})
308
+
309
+
describe('edge cases', () => {
310
+
test('handles empty src attribute', () => {
311
+
const html = '<img src="">'
312
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
313
+
expect(result).toBe('<img src="">')
314
+
})
315
+
316
+
test('handles basePath without trailing slash', () => {
317
+
const html = '<img src="/image.png">'
318
+
const result = rewriteHtmlPaths(html, '/identifier/site', 'index.html')
319
+
expect(result).toBe('<img src="/identifier/site/image.png">')
320
+
})
321
+
322
+
test('handles basePath with trailing slash', () => {
323
+
const html = '<img src="/image.png">'
324
+
const result = rewriteHtmlPaths(
325
+
html,
326
+
'/identifier/site/',
327
+
'index.html'
328
+
)
329
+
expect(result).toBe('<img src="/identifier/site/image.png">')
330
+
})
331
+
332
+
test('handles whitespace around equals sign', () => {
333
+
const html = '<img src = "/image.png">'
334
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
335
+
expect(result).toBe('<img src="/identifier/site/image.png">')
336
+
})
337
+
338
+
test('preserves query strings in URLs', () => {
339
+
const html = '<img src="/image.png?v=123">'
340
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
341
+
expect(result).toBe('<img src="/identifier/site/image.png?v=123">')
342
+
})
343
+
344
+
test('preserves hash fragments in URLs', () => {
345
+
const html = '<a href="/page.html#section">Link</a>'
346
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
347
+
expect(result).toBe(
348
+
'<a href="/identifier/site/page.html#section">Link</a>'
349
+
)
350
+
})
351
+
352
+
test('handles paths with special characters', () => {
353
+
const html = '<img src="/folder-name/file_name.png">'
354
+
const result = rewriteHtmlPaths(html, basePath, 'index.html')
355
+
expect(result).toBe(
356
+
'<img src="/identifier/site/folder-name/file_name.png">'
357
+
)
358
+
})
359
+
})
360
+
361
+
describe('real-world scenario', () => {
362
+
test('handles the example from the bug report', () => {
363
+
// HTML file at: /folder1/folder2/folder3/index.html
364
+
// Image at: /folder1/folder2/img.png
365
+
// Reference: src="../img.png"
366
+
const html = '<img src="../img.png">'
367
+
const result = rewriteHtmlPaths(
368
+
html,
369
+
basePath,
370
+
'folder1/folder2/folder3/index.html'
371
+
)
372
+
expect(result).toBe(
373
+
'<img src="/identifier/site/folder1/folder2/img.png">'
374
+
)
375
+
})
376
+
377
+
test('handles deeply nested static site structure', () => {
378
+
// A typical static site with nested pages and shared assets
379
+
const html = `
380
+
<!DOCTYPE html>
381
+
<html>
382
+
<head>
383
+
<link href="../../css/style.css" rel="stylesheet">
384
+
<link href="../../css/theme.css" rel="stylesheet">
385
+
<script src="../../js/main.js"></script>
386
+
</head>
387
+
<body>
388
+
<img src="../../images/logo.png" alt="Logo">
389
+
<img src="./post-image.jpg" alt="Post">
390
+
<a href="../index.html">Back to Blog</a>
391
+
<a href="../../index.html">Home</a>
392
+
</body>
393
+
</html>
394
+
`
395
+
const result = rewriteHtmlPaths(
396
+
html,
397
+
basePath,
398
+
'blog/posts/my-post.html'
399
+
)
400
+
401
+
// Assets two levels up
402
+
expect(result).toContain('href="/identifier/site/css/style.css"')
403
+
expect(result).toContain('href="/identifier/site/css/theme.css"')
404
+
expect(result).toContain('src="/identifier/site/js/main.js"')
405
+
expect(result).toContain('src="/identifier/site/images/logo.png"')
406
+
407
+
// Same directory
408
+
expect(result).toContain(
409
+
'src="/identifier/site/blog/posts/post-image.jpg"'
410
+
)
411
+
412
+
// One level up
413
+
expect(result).toContain('href="/identifier/site/blog/index.html"')
414
+
415
+
// Two levels up
416
+
expect(result).toContain('href="/identifier/site/index.html"')
417
+
})
418
+
})
419
+
})
420
+
421
+
describe('isHtmlContent', () => {
422
+
test('identifies HTML by content type', () => {
423
+
expect(isHtmlContent('file.txt', 'text/html')).toBe(true)
424
+
expect(isHtmlContent('file.txt', 'text/html; charset=utf-8')).toBe(
425
+
true
426
+
)
427
+
})
428
+
429
+
test('identifies HTML by .html extension', () => {
430
+
expect(isHtmlContent('index.html')).toBe(true)
431
+
expect(isHtmlContent('page.html', undefined)).toBe(true)
432
+
expect(isHtmlContent('/path/to/file.html')).toBe(true)
433
+
})
434
+
435
+
test('identifies HTML by .htm extension', () => {
436
+
expect(isHtmlContent('index.htm')).toBe(true)
437
+
expect(isHtmlContent('page.htm', undefined)).toBe(true)
438
+
})
439
+
440
+
test('handles case-insensitive extensions', () => {
441
+
expect(isHtmlContent('INDEX.HTML')).toBe(true)
442
+
expect(isHtmlContent('page.HTM')).toBe(true)
443
+
expect(isHtmlContent('File.HtMl')).toBe(true)
444
+
})
445
+
446
+
test('returns false for non-HTML files', () => {
447
+
expect(isHtmlContent('script.js')).toBe(false)
448
+
expect(isHtmlContent('style.css')).toBe(false)
449
+
expect(isHtmlContent('image.png')).toBe(false)
450
+
expect(isHtmlContent('data.json')).toBe(false)
451
+
})
452
+
453
+
test('returns false for files with no extension', () => {
454
+
expect(isHtmlContent('README')).toBe(false)
455
+
expect(isHtmlContent('Makefile')).toBe(false)
456
+
})
457
+
})
+178
-99
hosting-service/src/lib/html-rewriter.ts
+178
-99
hosting-service/src/lib/html-rewriter.ts
···
4
4
*/
5
5
6
6
const REWRITABLE_ATTRIBUTES = [
7
-
'src',
8
-
'href',
9
-
'action',
10
-
'data',
11
-
'poster',
12
-
'srcset',
13
-
] as const;
7
+
'src',
8
+
'href',
9
+
'action',
10
+
'data',
11
+
'poster',
12
+
'srcset'
13
+
] as const
14
14
15
15
/**
16
16
* Check if a path should be rewritten
17
17
*/
18
18
function shouldRewritePath(path: string): boolean {
19
-
// Don't rewrite empty paths
20
-
if (!path) return false;
19
+
// Don't rewrite empty paths
20
+
if (!path) return false
21
21
22
-
// Don't rewrite external URLs (http://, https://, //)
23
-
if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
24
-
return false;
25
-
}
22
+
// Don't rewrite external URLs (http://, https://, //)
23
+
if (
24
+
path.startsWith('http://') ||
25
+
path.startsWith('https://') ||
26
+
path.startsWith('//')
27
+
) {
28
+
return false
29
+
}
26
30
27
-
// Don't rewrite data URIs or other schemes (except file paths)
28
-
if (path.includes(':') && !path.startsWith('./') && !path.startsWith('../')) {
29
-
return false;
30
-
}
31
+
// Don't rewrite data URIs or other schemes (except file paths)
32
+
if (
33
+
path.includes(':') &&
34
+
!path.startsWith('./') &&
35
+
!path.startsWith('../')
36
+
) {
37
+
return false
38
+
}
31
39
32
-
// Don't rewrite pure anchors or paths that start with /#
33
-
if (path.startsWith('#') || path.startsWith('/#')) return false;
40
+
// Rewrite absolute paths (/) and relative paths (./ or ../ or plain filenames)
41
+
return true
42
+
}
43
+
44
+
/**
45
+
* Normalize a path by resolving . and .. segments
46
+
*/
47
+
function normalizePath(path: string): string {
48
+
const parts = path.split('/')
49
+
const result: string[] = []
50
+
51
+
for (const part of parts) {
52
+
if (part === '.' || part === '') {
53
+
// Skip current directory and empty parts (but keep leading empty for absolute paths)
54
+
if (part === '' && result.length === 0) {
55
+
result.push(part)
56
+
}
57
+
continue
58
+
}
59
+
if (part === '..') {
60
+
// Go up one directory (but not past root)
61
+
if (result.length > 0 && result[result.length - 1] !== '..') {
62
+
result.pop()
63
+
}
64
+
continue
65
+
}
66
+
result.push(part)
67
+
}
34
68
35
-
// Don't rewrite relative paths (./ or ../)
36
-
if (path.startsWith('./') || path.startsWith('../')) return false;
69
+
return result.join('/')
70
+
}
37
71
38
-
// Rewrite absolute paths (/)
39
-
return true;
72
+
/**
73
+
* Get the directory path from a file path
74
+
* e.g., "folder1/folder2/file.html" -> "folder1/folder2/"
75
+
*/
76
+
function getDirectory(filepath: string): string {
77
+
const lastSlash = filepath.lastIndexOf('/')
78
+
if (lastSlash === -1) {
79
+
return ''
80
+
}
81
+
return filepath.substring(0, lastSlash + 1)
40
82
}
41
83
42
84
/**
43
85
* Rewrite a single path
44
86
*/
45
-
function rewritePath(path: string, basePath: string): string {
46
-
if (!shouldRewritePath(path)) {
47
-
return path;
48
-
}
87
+
function rewritePath(
88
+
path: string,
89
+
basePath: string,
90
+
documentPath: string
91
+
): string {
92
+
if (!shouldRewritePath(path)) {
93
+
return path
94
+
}
95
+
96
+
// Handle absolute paths: /file.js -> /base/file.js
97
+
if (path.startsWith('/')) {
98
+
return basePath + path.slice(1)
99
+
}
100
+
101
+
// Handle relative paths by resolving against document directory
102
+
const documentDir = getDirectory(documentPath)
103
+
let resolvedPath: string
49
104
50
-
// Handle absolute paths: /file.js -> /base/file.js
51
-
if (path.startsWith('/')) {
52
-
return basePath + path.slice(1);
53
-
}
105
+
if (path.startsWith('./')) {
106
+
// ./file.js relative to current directory
107
+
resolvedPath = documentDir + path.slice(2)
108
+
} else if (path.startsWith('../')) {
109
+
// ../file.js relative to parent directory
110
+
resolvedPath = documentDir + path
111
+
} else {
112
+
// file.js (no prefix) - treat as relative to current directory
113
+
resolvedPath = documentDir + path
114
+
}
54
115
55
-
// At this point, only plain filenames without ./ or ../ prefix should reach here
56
-
// But since we're filtering those in shouldRewritePath, this shouldn't happen
57
-
return path;
116
+
// Normalize the path to resolve .. and .
117
+
resolvedPath = normalizePath(resolvedPath)
118
+
119
+
return basePath + resolvedPath
58
120
}
59
121
60
122
/**
61
123
* Rewrite srcset attribute (can contain multiple URLs)
62
124
* Format: "url1 1x, url2 2x" or "url1 100w, url2 200w"
63
125
*/
64
-
function rewriteSrcset(srcset: string, basePath: string): string {
65
-
return srcset
66
-
.split(',')
67
-
.map(part => {
68
-
const trimmed = part.trim();
69
-
const spaceIndex = trimmed.indexOf(' ');
126
+
function rewriteSrcset(
127
+
srcset: string,
128
+
basePath: string,
129
+
documentPath: string
130
+
): string {
131
+
return srcset
132
+
.split(',')
133
+
.map((part) => {
134
+
const trimmed = part.trim()
135
+
const spaceIndex = trimmed.indexOf(' ')
70
136
71
-
if (spaceIndex === -1) {
72
-
// No descriptor, just URL
73
-
return rewritePath(trimmed, basePath);
74
-
}
137
+
if (spaceIndex === -1) {
138
+
// No descriptor, just URL
139
+
return rewritePath(trimmed, basePath, documentPath)
140
+
}
75
141
76
-
const url = trimmed.substring(0, spaceIndex);
77
-
const descriptor = trimmed.substring(spaceIndex);
78
-
return rewritePath(url, basePath) + descriptor;
79
-
})
80
-
.join(', ');
142
+
const url = trimmed.substring(0, spaceIndex)
143
+
const descriptor = trimmed.substring(spaceIndex)
144
+
return rewritePath(url, basePath, documentPath) + descriptor
145
+
})
146
+
.join(', ')
81
147
}
82
148
83
149
/**
84
-
* Rewrite absolute paths in HTML content
150
+
* Rewrite absolute and relative paths in HTML content
85
151
* Uses simple regex matching for safety (no full HTML parsing)
86
152
*/
87
-
export function rewriteHtmlPaths(html: string, basePath: string): string {
88
-
// Ensure base path ends with /
89
-
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/';
153
+
export function rewriteHtmlPaths(
154
+
html: string,
155
+
basePath: string,
156
+
documentPath: string
157
+
): string {
158
+
// Ensure base path ends with /
159
+
const normalizedBase = basePath.endsWith('/') ? basePath : basePath + '/'
90
160
91
-
let rewritten = html;
161
+
let rewritten = html
92
162
93
-
// Rewrite each attribute type
94
-
// Use more specific patterns to prevent ReDoS attacks
95
-
for (const attr of REWRITABLE_ATTRIBUTES) {
96
-
if (attr === 'srcset') {
97
-
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
98
-
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
99
-
const srcsetRegex = new RegExp(
100
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
101
-
'gi'
102
-
);
103
-
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
104
-
const rewrittenValue = rewriteSrcset(value, normalizedBase);
105
-
return `${attr}="${rewrittenValue}"`;
106
-
});
107
-
} else {
108
-
// Regular attributes with quoted values
109
-
// Limit whitespace to prevent catastrophic backtracking
110
-
const doubleQuoteRegex = new RegExp(
111
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
112
-
'gi'
113
-
);
114
-
const singleQuoteRegex = new RegExp(
115
-
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
116
-
'gi'
117
-
);
163
+
// Rewrite each attribute type
164
+
// Use more specific patterns to prevent ReDoS attacks
165
+
for (const attr of REWRITABLE_ATTRIBUTES) {
166
+
if (attr === 'srcset') {
167
+
// Special handling for srcset - use possessive quantifiers via atomic grouping simulation
168
+
// Limit whitespace to reasonable amount (max 5 spaces) to prevent ReDoS
169
+
const srcsetRegex = new RegExp(
170
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
171
+
'gi'
172
+
)
173
+
rewritten = rewritten.replace(srcsetRegex, (match, value) => {
174
+
const rewrittenValue = rewriteSrcset(
175
+
value,
176
+
normalizedBase,
177
+
documentPath
178
+
)
179
+
return `${attr}="${rewrittenValue}"`
180
+
})
181
+
} else {
182
+
// Regular attributes with quoted values
183
+
// Limit whitespace to prevent catastrophic backtracking
184
+
const doubleQuoteRegex = new RegExp(
185
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}"([^"]*)"`,
186
+
'gi'
187
+
)
188
+
const singleQuoteRegex = new RegExp(
189
+
`\\b${attr}[ \\t]{0,5}=[ \\t]{0,5}'([^']*)'`,
190
+
'gi'
191
+
)
118
192
119
-
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
120
-
const rewrittenValue = rewritePath(value, normalizedBase);
121
-
return `${attr}="${rewrittenValue}"`;
122
-
});
193
+
rewritten = rewritten.replace(doubleQuoteRegex, (match, value) => {
194
+
const rewrittenValue = rewritePath(
195
+
value,
196
+
normalizedBase,
197
+
documentPath
198
+
)
199
+
return `${attr}="${rewrittenValue}"`
200
+
})
123
201
124
-
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
125
-
const rewrittenValue = rewritePath(value, normalizedBase);
126
-
return `${attr}='${rewrittenValue}'`;
127
-
});
128
-
}
129
-
}
202
+
rewritten = rewritten.replace(singleQuoteRegex, (match, value) => {
203
+
const rewrittenValue = rewritePath(
204
+
value,
205
+
normalizedBase,
206
+
documentPath
207
+
)
208
+
return `${attr}='${rewrittenValue}'`
209
+
})
210
+
}
211
+
}
130
212
131
-
return rewritten;
213
+
return rewritten
132
214
}
133
215
134
216
/**
135
217
* Check if content is HTML based on content or filename
136
218
*/
137
-
export function isHtmlContent(
138
-
filepath: string,
139
-
contentType?: string
140
-
): boolean {
141
-
if (contentType && contentType.includes('text/html')) {
142
-
return true;
143
-
}
219
+
export function isHtmlContent(filepath: string, contentType?: string): boolean {
220
+
if (contentType && contentType.includes('text/html')) {
221
+
return true
222
+
}
144
223
145
-
const ext = filepath.toLowerCase().split('.').pop();
146
-
return ext === 'html' || ext === 'htm';
224
+
const ext = filepath.toLowerCase().split('.').pop()
225
+
return ext === 'html' || ext === 'htm'
147
226
}
+3
-2
hosting-service/src/server.ts
+3
-2
hosting-service/src/server.ts
···
156
156
} else {
157
157
content = readFileSync(cachedFile, 'utf-8');
158
158
}
159
-
const rewritten = rewriteHtmlPaths(content, basePath);
159
+
const rewritten = rewriteHtmlPaths(content, basePath, requestPath);
160
160
161
161
// Recompress the HTML for efficient delivery
162
162
const { gzipSync } = await import('zlib');
···
224
224
} else {
225
225
content = readFileSync(indexFile, 'utf-8');
226
226
}
227
-
const rewritten = rewriteHtmlPaths(content, basePath);
227
+
const indexPath = `${requestPath}/index.html`;
228
+
const rewritten = rewriteHtmlPaths(content, basePath, indexPath);
228
229
229
230
// Recompress the HTML for efficient delivery
230
231
const { gzipSync } = await import('zlib');