Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion lib/db/db-client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { createStore } from "zustand/vanilla"
import { hoist } from "zustand-hoist"
import { combine } from "zustand/middleware"
import { databaseSchema, type File, type FileServerEvent } from "./schema.ts"
import {
databaseSchema,
type File,
type FileProxy,
type FileServerEvent,
} from "./schema.ts"
import { normalizePath } from "../utils/normalize-path"

export const createDatabase = () => {
Expand Down Expand Up @@ -203,4 +208,56 @@ const initializer = combine(databaseSchema.parse({}), (set, get) => ({
events: [],
}))
},

createFileProxy: (
proxy: Omit<FileProxy, "file_proxy_id" | "created_at">,
): FileProxy => {
let newProxy: FileProxy
set((state) => {
newProxy = {
...proxy,
file_proxy_id: state.idCounter.toString(),
created_at: new Date().toISOString(),
} as FileProxy
return {
file_proxies: [...state.file_proxies, newProxy],
idCounter: state.idCounter + 1,
}
})
return newProxy!
},

getFileProxy: (query: {
file_proxy_id?: string
matching_pattern?: string
}): FileProxy | undefined => {
const state = get()
return state.file_proxies.find(
(p) =>
(query.file_proxy_id && p.file_proxy_id === query.file_proxy_id) ||
(query.matching_pattern &&
p.matching_pattern === query.matching_pattern),
)
},

listFileProxies: (): FileProxy[] => {
return get().file_proxies
},

matchFileProxy: (file_path: string): FileProxy | undefined => {
const state = get()
const normalizedPath = normalizePath(file_path)

for (const proxy of state.file_proxies) {
const pattern = proxy.matching_pattern
// Pattern format: "prefix/*" - match anything starting with "prefix/"
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -1) // Remove the "*" to get "prefix/"
if (normalizedPath.startsWith(prefix)) {
return proxy
}
}
}
return undefined
},
}))
19 changes: 19 additions & 0 deletions lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,28 @@ export const eventSchema = z.object({
})
export type FileServerEvent = z.infer<typeof eventSchema>

export const fileProxySchema = z.discriminatedUnion("proxy_type", [
z.object({
file_proxy_id: z.string(),
proxy_type: z.literal("disk"),
disk_path: z.string(),
matching_pattern: z.string(),
created_at: z.string(),
}),
z.object({
file_proxy_id: z.string(),
proxy_type: z.literal("http"),
http_target_url: z.string(),
matching_pattern: z.string(),
created_at: z.string(),
}),
])
export type FileProxy = z.infer<typeof fileProxySchema>

export const databaseSchema = z.object({
idCounter: z.number().default(0),
files: z.array(fileSchema).default([]),
events: z.array(eventSchema).default([]),
file_proxies: z.array(fileProxySchema).default([]),
})
export type DatabaseSchema = z.infer<typeof databaseSchema>
112 changes: 112 additions & 0 deletions lib/utils/resolve-file-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { FileProxy } from "../db/schema"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import { normalizePath } from "./normalize-path"

export async function resolveFileProxy(
proxy: FileProxy,
file_path: string,
): Promise<Response> {
const normalizedPath = normalizePath(file_path)
const pattern = proxy.matching_pattern

// Extract the relative path after the pattern prefix
// Pattern: "prefix/*" -> prefix is "prefix/"
const prefix = pattern.slice(0, -1) // Remove "*" to get "prefix/"
const relativePath = normalizedPath.slice(prefix.length)

if (proxy.proxy_type === "disk") {
return resolveDiskProxy(proxy.disk_path, relativePath)
} else {
return resolveHttpProxy(proxy.http_target_url, relativePath)
}
}

async function resolveDiskProxy(
diskPath: string,
relativePath: string,
): Promise<Response> {
const fullPath = join(diskPath, relativePath)

try {
const content = await readFile(fullPath)
const fileName = relativePath.split("/").pop() || "file"
const contentType = getContentType(fileName)

return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Length": content.byteLength.toString(),
},
})
} catch (error: any) {
if (error.code === "ENOENT") {
return new Response("File not found", { status: 404 })
}
console.error("Disk proxy error:", error)
return new Response("Failed to read file from disk", { status: 500 })
}
}

async function resolveHttpProxy(
httpTargetUrl: string,
relativePath: string,
): Promise<Response> {
// Ensure the URL doesn't have double slashes
const baseUrl = httpTargetUrl.endsWith("/")
? httpTargetUrl.slice(0, -1)
: httpTargetUrl
const targetUrl = `${baseUrl}/${relativePath}`

try {
const response = await fetch(targetUrl)

if (!response.ok) {
return new Response(`HTTP proxy returned status ${response.status}`, {
status: response.status,
})
}

// Pass through the response body and relevant headers
const headers = new Headers()
const contentType = response.headers.get("Content-Type")
if (contentType) {
headers.set("Content-Type", contentType)
}
const contentLength = response.headers.get("Content-Length")
if (contentLength) {
headers.set("Content-Length", contentLength)
}
const fileName = relativePath.split("/").pop() || "file"
headers.set("Content-Disposition", `attachment; filename="${fileName}"`)

return new Response(response.body, {
status: response.status,
headers,
})
} catch (error) {
console.error("HTTP proxy error:", error)
return new Response("Failed to fetch file from HTTP proxy", { status: 502 })
}
}

function getContentType(fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase()
const mimeTypes: Record<string, string> = {
txt: "text/plain",
html: "text/html",
css: "text/css",
js: "application/javascript",
json: "application/json",
xml: "application/xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml",
pdf: "application/pdf",
zip: "application/zip",
}
return mimeTypes[ext || ""] || "application/octet-stream"
}
71 changes: 71 additions & 0 deletions routes/file_proxies/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

const fileProxyInputSchema = z.discriminatedUnion("proxy_type", [
z.object({
proxy_type: z.literal("disk"),
disk_path: z.string(),
matching_pattern: z.string().regex(/^.+\/\*$/, {
message: "matching_pattern must end with /*",
}),
}),
z.object({
proxy_type: z.literal("http"),
http_target_url: z.string().url(),
matching_pattern: z.string().regex(/^.+\/\*$/, {
message: "matching_pattern must end with /*",
}),
}),
])

const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [
z.object({
file_proxy_id: z.string(),
proxy_type: z.literal("disk"),
disk_path: z.string(),
matching_pattern: z.string(),
created_at: z.string(),
}),
z.object({
file_proxy_id: z.string(),
proxy_type: z.literal("http"),
http_target_url: z.string(),
matching_pattern: z.string(),
created_at: z.string(),
}),
])

export default withRouteSpec({
methods: ["POST"],
jsonBody: fileProxyInputSchema,
jsonResponse: z.union([
z.object({
file_proxy: fileProxyOutputSchema,
}),
z.object({
error: z.object({
message: z.string(),
}),
}),
]),
})((req, ctx) => {
const proxyInput = req.jsonBody

// Check if a proxy with the same matching_pattern already exists
const existingProxy = ctx.db.getFileProxy({
matching_pattern: proxyInput.matching_pattern,
})
if (existingProxy) {
return ctx.json(
{
error: {
message: `A file proxy with matching_pattern "${proxyInput.matching_pattern}" already exists`,
},
},
{ status: 400 },
)
}

const file_proxy = ctx.db.createFileProxy(proxyInput)
return ctx.json({ file_proxy })
})
35 changes: 35 additions & 0 deletions routes/file_proxies/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [
z.object({
file_proxy_id: z.string(),
proxy_type: z.literal("disk"),
disk_path: z.string(),
matching_pattern: z.string(),
created_at: z.string(),
}),
z.object({
file_proxy_id: z.string(),
proxy_type: z.literal("http"),
http_target_url: z.string(),
matching_pattern: z.string(),
created_at: z.string(),
}),
])

export default withRouteSpec({
methods: ["GET"],
queryParams: z.object({
file_proxy_id: z.string().optional(),
matching_pattern: z.string().optional(),
}),
jsonResponse: z.object({
file_proxy: fileProxyOutputSchema.nullable(),
}),
})((req, ctx) => {
const { file_proxy_id, matching_pattern } = req.query
const file_proxy =
ctx.db.getFileProxy({ file_proxy_id, matching_pattern }) ?? null
return ctx.json({ file_proxy })
})
30 changes: 30 additions & 0 deletions routes/file_proxies/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { withRouteSpec } from "lib/middleware/with-winter-spec"
import { z } from "zod"

const fileProxyOutputSchema = z.discriminatedUnion("proxy_type", [
z.object({
file_proxy_id: z.string(),
proxy_type: z.literal("disk"),
disk_path: z.string(),
matching_pattern: z.string(),
created_at: z.string(),
}),
z.object({
file_proxy_id: z.string(),
proxy_type: z.literal("http"),
http_target_url: z.string(),
matching_pattern: z.string(),
created_at: z.string(),
}),
])

export default withRouteSpec({
methods: ["GET"],
jsonResponse: z.object({
file_proxies: z.array(fileProxyOutputSchema),
}),
})((req, ctx) => {
return ctx.json({
file_proxies: ctx.db.listFileProxies(),
})
})
10 changes: 9 additions & 1 deletion routes/files/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,26 @@ import {
decodeBase64ToUint8Array,
uint8ArrayToArrayBuffer,
} from "lib/utils/decode-base64"
import { resolveFileProxy } from "lib/utils/resolve-file-proxy"

export default withRouteSpec({
methods: ["GET"],
queryParams: z.object({
file_id: z.string().optional(),
file_path: z.string().optional(),
}),
})((req, ctx) => {
})(async (req, ctx) => {
const { file_id, file_path } = req.query
const file = ctx.db.getFile({ file_id, file_path })

if (!file) {
// Check if there's a matching proxy (only for file_path queries)
if (file_path) {
const proxy = ctx.db.matchFileProxy(file_path)
if (proxy) {
return resolveFileProxy(proxy, file_path)
}
}
return new Response("File not found", { status: 404 })
}

Expand Down
Loading