Skip to content

Commit 8395bb3

Browse files
authored
introduce file proxies (#24)
* introduce file proxies * format
1 parent 2330902 commit 8395bb3

13 files changed

Lines changed: 851 additions & 8 deletions

File tree

lib/db/db-client.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { createStore } from "zustand/vanilla"
22
import { hoist } from "zustand-hoist"
33
import { combine } from "zustand/middleware"
4-
import { databaseSchema, type File, type FileServerEvent } from "./schema.ts"
4+
import {
5+
databaseSchema,
6+
type File,
7+
type FileProxy,
8+
type FileServerEvent,
9+
} from "./schema.ts"
510
import { normalizePath } from "../utils/normalize-path"
611

712
export const createDatabase = () => {
@@ -203,4 +208,56 @@ const initializer = combine(databaseSchema.parse({}), (set, get) => ({
203208
events: [],
204209
}))
205210
},
211+
212+
createFileProxy: (
213+
proxy: Omit<FileProxy, "file_proxy_id" | "created_at">,
214+
): FileProxy => {
215+
let newProxy: FileProxy
216+
set((state) => {
217+
newProxy = {
218+
...proxy,
219+
file_proxy_id: state.idCounter.toString(),
220+
created_at: new Date().toISOString(),
221+
} as FileProxy
222+
return {
223+
file_proxies: [...state.file_proxies, newProxy],
224+
idCounter: state.idCounter + 1,
225+
}
226+
})
227+
return newProxy!
228+
},
229+
230+
getFileProxy: (query: {
231+
file_proxy_id?: string
232+
matching_pattern?: string
233+
}): FileProxy | undefined => {
234+
const state = get()
235+
return state.file_proxies.find(
236+
(p) =>
237+
(query.file_proxy_id && p.file_proxy_id === query.file_proxy_id) ||
238+
(query.matching_pattern &&
239+
p.matching_pattern === query.matching_pattern),
240+
)
241+
},
242+
243+
listFileProxies: (): FileProxy[] => {
244+
return get().file_proxies
245+
},
246+
247+
matchFileProxy: (file_path: string): FileProxy | undefined => {
248+
const state = get()
249+
const normalizedPath = normalizePath(file_path)
250+
251+
for (const proxy of state.file_proxies) {
252+
const pattern = proxy.matching_pattern
253+
// Pattern format: "prefix/*" - match anything starting with "prefix/"
254+
if (pattern.endsWith("/*")) {
255+
const prefix = pattern.slice(0, -1) // Remove the "*" to get "prefix/"
256+
if (normalizedPath.startsWith(prefix)) {
257+
return proxy
258+
}
259+
}
260+
}
261+
return undefined
262+
},
206263
}))

lib/db/schema.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,28 @@ export const eventSchema = z.object({
3232
})
3333
export type FileServerEvent = z.infer<typeof eventSchema>
3434

35+
export const fileProxySchema = z.discriminatedUnion("proxy_type", [
36+
z.object({
37+
file_proxy_id: z.string(),
38+
proxy_type: z.literal("disk"),
39+
disk_path: z.string(),
40+
matching_pattern: z.string(),
41+
created_at: z.string(),
42+
}),
43+
z.object({
44+
file_proxy_id: z.string(),
45+
proxy_type: z.literal("http"),
46+
http_target_url: z.string(),
47+
matching_pattern: z.string(),
48+
created_at: z.string(),
49+
}),
50+
])
51+
export type FileProxy = z.infer<typeof fileProxySchema>
52+
3553
export const databaseSchema = z.object({
3654
idCounter: z.number().default(0),
3755
files: z.array(fileSchema).default([]),
3856
events: z.array(eventSchema).default([]),
57+
file_proxies: z.array(fileProxySchema).default([]),
3958
})
4059
export type DatabaseSchema = z.infer<typeof databaseSchema>

lib/utils/resolve-file-proxy.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { FileProxy } from "../db/schema"
2+
import { readFile } from "node:fs/promises"
3+
import { join } from "node:path"
4+
import { normalizePath } from "./normalize-path"
5+
6+
export async function resolveFileProxy(
7+
proxy: FileProxy,
8+
file_path: string,
9+
): Promise<Response> {
10+
const normalizedPath = normalizePath(file_path)
11+
const pattern = proxy.matching_pattern
12+
13+
// Extract the relative path after the pattern prefix
14+
// Pattern: "prefix/*" -> prefix is "prefix/"
15+
const prefix = pattern.slice(0, -1) // Remove "*" to get "prefix/"
16+
const relativePath = normalizedPath.slice(prefix.length)
17+
18+
if (proxy.proxy_type === "disk") {
19+
return resolveDiskProxy(proxy.disk_path, relativePath)
20+
} else {
21+
return resolveHttpProxy(proxy.http_target_url, relativePath)
22+
}
23+
}
24+
25+
async function resolveDiskProxy(
26+
diskPath: string,
27+
relativePath: string,
28+
): Promise<Response> {
29+
const fullPath = join(diskPath, relativePath)
30+
31+
try {
32+
const content = await readFile(fullPath)
33+
const fileName = relativePath.split("/").pop() || "file"
34+
const contentType = getContentType(fileName)
35+
36+
return new Response(content, {
37+
headers: {
38+
"Content-Type": contentType,
39+
"Content-Disposition": `attachment; filename="${fileName}"`,
40+
"Content-Length": content.byteLength.toString(),
41+
},
42+
})
43+
} catch (error: any) {
44+
if (error.code === "ENOENT") {
45+
return new Response("File not found", { status: 404 })
46+
}
47+
console.error("Disk proxy error:", error)
48+
return new Response("Failed to read file from disk", { status: 500 })
49+
}
50+
}
51+
52+
async function resolveHttpProxy(
53+
httpTargetUrl: string,
54+
relativePath: string,
55+
): Promise<Response> {
56+
// Ensure the URL doesn't have double slashes
57+
const baseUrl = httpTargetUrl.endsWith("/")
58+
? httpTargetUrl.slice(0, -1)
59+
: httpTargetUrl
60+
const targetUrl = `${baseUrl}/${relativePath}`
61+
62+
try {
63+
const response = await fetch(targetUrl)
64+
65+
if (!response.ok) {
66+
return new Response(`HTTP proxy returned status ${response.status}`, {
67+
status: response.status,
68+
})
69+
}
70+
71+
// Pass through the response body and relevant headers
72+
const headers = new Headers()
73+
const contentType = response.headers.get("Content-Type")
74+
if (contentType) {
75+
headers.set("Content-Type", contentType)
76+
}
77+
const contentLength = response.headers.get("Content-Length")
78+
if (contentLength) {
79+
headers.set("Content-Length", contentLength)
80+
}
81+
const fileName = relativePath.split("/").pop() || "file"
82+
headers.set("Content-Disposition", `attachment; filename="${fileName}"`)
83+
84+
return new Response(response.body, {
85+
status: response.status,
86+
headers,
87+
})
88+
} catch (error) {
89+
console.error("HTTP proxy error:", error)
90+
return new Response("Failed to fetch file from HTTP proxy", { status: 502 })
91+
}
92+
}
93+
94+
function getContentType(fileName: string): string {
95+
const ext = fileName.split(".").pop()?.toLowerCase()
96+
const mimeTypes: Record<string, string> = {
97+
txt: "text/plain",
98+
html: "text/html",
99+
css: "text/css",
100+
js: "application/javascript",
101+
json: "application/json",
102+
xml: "application/xml",
103+
png: "image/png",
104+
jpg: "image/jpeg",
105+
jpeg: "image/jpeg",
106+
gif: "image/gif",
107+
svg: "image/svg+xml",
108+
pdf: "application/pdf",
109+
zip: "application/zip",
110+
}
111+
return mimeTypes[ext || ""] || "application/octet-stream"
112+
}

routes/file_proxies/create.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { withRouteSpec } from "lib/middleware/with-winter-spec"
2+
import { z } from "zod"
3+
4+
const fileProxyInputSchema = z.discriminatedUnion("proxy_type", [
5+
z.object({
6+
proxy_type: z.literal("disk"),
7+
disk_path: z.string(),
8+
matching_pattern: z.string().regex(/^.+\/\*$/, {
9+
message: "matching_pattern must end with /*",
10+
}),
11+
}),
12+
z.object({
13+
proxy_type: z.literal("http"),
14+
http_target_url: z.string().url(),
15+
matching_pattern: z.string().regex(/^.+\/\*$/, {
16+
message: "matching_pattern must end with /*",
17+
}),
18+
}),
19+
])
20+
21+
const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [
22+
z.object({
23+
file_proxy_id: z.string(),
24+
proxy_type: z.literal("disk"),
25+
disk_path: z.string(),
26+
matching_pattern: z.string(),
27+
created_at: z.string(),
28+
}),
29+
z.object({
30+
file_proxy_id: z.string(),
31+
proxy_type: z.literal("http"),
32+
http_target_url: z.string(),
33+
matching_pattern: z.string(),
34+
created_at: z.string(),
35+
}),
36+
])
37+
38+
export default withRouteSpec({
39+
methods: ["POST"],
40+
jsonBody: fileProxyInputSchema,
41+
jsonResponse: z.union([
42+
z.object({
43+
file_proxy: fileProxyOutputSchema,
44+
}),
45+
z.object({
46+
error: z.object({
47+
message: z.string(),
48+
}),
49+
}),
50+
]),
51+
})((req, ctx) => {
52+
const proxyInput = req.jsonBody
53+
54+
// Check if a proxy with the same matching_pattern already exists
55+
const existingProxy = ctx.db.getFileProxy({
56+
matching_pattern: proxyInput.matching_pattern,
57+
})
58+
if (existingProxy) {
59+
return ctx.json(
60+
{
61+
error: {
62+
message: `A file proxy with matching_pattern "${proxyInput.matching_pattern}" already exists`,
63+
},
64+
},
65+
{ status: 400 },
66+
)
67+
}
68+
69+
const file_proxy = ctx.db.createFileProxy(proxyInput)
70+
return ctx.json({ file_proxy })
71+
})

routes/file_proxies/get.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { withRouteSpec } from "lib/middleware/with-winter-spec"
2+
import { z } from "zod"
3+
4+
const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [
5+
z.object({
6+
file_proxy_id: z.string(),
7+
proxy_type: z.literal("disk"),
8+
disk_path: z.string(),
9+
matching_pattern: z.string(),
10+
created_at: z.string(),
11+
}),
12+
z.object({
13+
file_proxy_id: z.string(),
14+
proxy_type: z.literal("http"),
15+
http_target_url: z.string(),
16+
matching_pattern: z.string(),
17+
created_at: z.string(),
18+
}),
19+
])
20+
21+
export default withRouteSpec({
22+
methods: ["GET"],
23+
queryParams: z.object({
24+
file_proxy_id: z.string().optional(),
25+
matching_pattern: z.string().optional(),
26+
}),
27+
jsonResponse: z.object({
28+
file_proxy: fileProxyOutputSchema.nullable(),
29+
}),
30+
})((req, ctx) => {
31+
const { file_proxy_id, matching_pattern } = req.query
32+
const file_proxy =
33+
ctx.db.getFileProxy({ file_proxy_id, matching_pattern }) ?? null
34+
return ctx.json({ file_proxy })
35+
})

routes/file_proxies/list.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { withRouteSpec } from "lib/middleware/with-winter-spec"
2+
import { z } from "zod"
3+
4+
const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [
5+
z.object({
6+
file_proxy_id: z.string(),
7+
proxy_type: z.literal("disk"),
8+
disk_path: z.string(),
9+
matching_pattern: z.string(),
10+
created_at: z.string(),
11+
}),
12+
z.object({
13+
file_proxy_id: z.string(),
14+
proxy_type: z.literal("http"),
15+
http_target_url: z.string(),
16+
matching_pattern: z.string(),
17+
created_at: z.string(),
18+
}),
19+
])
20+
21+
export default withRouteSpec({
22+
methods: ["GET"],
23+
jsonResponse: z.object({
24+
file_proxies: z.array(fileProxyOutputSchema),
25+
}),
26+
})((req, ctx) => {
27+
return ctx.json({
28+
file_proxies: ctx.db.listFileProxies(),
29+
})
30+
})

routes/files/download.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,26 @@ import {
44
decodeBase64ToUint8Array,
55
uint8ArrayToArrayBuffer,
66
} from "lib/utils/decode-base64"
7+
import { resolveFileProxy } from "lib/utils/resolve-file-proxy"
78

89
export default withRouteSpec({
910
methods: ["GET"],
1011
queryParams: z.object({
1112
file_id: z.string().optional(),
1213
file_path: z.string().optional(),
1314
}),
14-
})((req, ctx) => {
15+
})(async (req, ctx) => {
1516
const { file_id, file_path } = req.query
1617
const file = ctx.db.getFile({ file_id, file_path })
1718

1819
if (!file) {
20+
// Check if there's a matching proxy (only for file_path queries)
21+
if (file_path) {
22+
const proxy = ctx.db.matchFileProxy(file_path)
23+
if (proxy) {
24+
return resolveFileProxy(proxy, file_path)
25+
}
26+
}
1927
return new Response("File not found", { status: 404 })
2028
}
2129

0 commit comments

Comments
 (0)