-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlocal.ts
More file actions
104 lines (92 loc) · 3.31 KB
/
Copy pathlocal.ts
File metadata and controls
104 lines (92 loc) · 3.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
import { mkdir, writeFile, readFile, unlink, stat } from 'node:fs/promises';
import path from 'node:path';
import { env } from '../../env.js';
/*
* S3-shaped upload API backed by local disk in dev.
*
* Flow:
* 1. Client asks the backend to "sign" an upload → server returns a
* short-lived signed URL that includes an HMAC.
* 2. Client PUTs the file body to that URL.
* 3. Server verifies the HMAC and writes the file to STORAGE_LOCAL_DIR.
*
* The HMAC keeps the presign endpoint auth-gated (route middleware) while the
* actual upload URL can be hit unauthenticated — matching the S3 behavior
* where the presigned URL is the auth.
*/
const SIGN_TTL_SECONDS = 15 * 60;
export type UploadTicket = {
key: string;
uploadUrl: string;
expiresAt: number;
maxSize: number;
contentType: string;
};
export async function signUpload(input: {
userId: string;
kind: 'transcript' | 'aid_doc';
contentType: string;
size: number;
}): Promise<UploadTicket> {
const id = randomBytes(16).toString('base64url');
const key = `${input.kind}/${input.userId}/${id}`;
const expiresAt = Math.floor(Date.now() / 1000) + SIGN_TTL_SECONDS;
const sig = sign(key, input.contentType, input.size, expiresAt);
const uploadUrl =
`/api/uploads/put?key=${encodeURIComponent(key)}` +
`&exp=${expiresAt}` +
`&ct=${encodeURIComponent(input.contentType)}` +
`&sz=${input.size}` +
`&sig=${sig}`;
return { key, uploadUrl, expiresAt, maxSize: input.size, contentType: input.contentType };
}
function sign(key: string, contentType: string, size: number, exp: number): string {
const h = createHmac('sha256', env.SESSION_SECRET);
h.update(`${key}\n${contentType}\n${size}\n${exp}`);
return h.digest('base64url');
}
function verify(sig: string, key: string, contentType: string, size: number, exp: number): boolean {
const expected = sign(key, contentType, size, exp);
const a = Buffer.from(sig, 'base64url');
const b = Buffer.from(expected, 'base64url');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export function verifyPutParams(params: {
key: string;
contentType: string;
size: number;
exp: number;
sig: string;
}): boolean {
if (params.exp <= Math.floor(Date.now() / 1000)) return false;
return verify(params.sig, params.key, params.contentType, params.size, params.exp);
}
function keyToPath(key: string): string {
// Reject path-traversal attempts. Keys are `${kind}/${userId}/${id}` and
// both kind and userId/id are constrained by our signing flow.
if (key.includes('..') || key.startsWith('/')) {
throw new Error('invalid key');
}
return path.join(env.STORAGE_LOCAL_DIR, key);
}
export async function putObject(key: string, body: Buffer): Promise<void> {
const p = keyToPath(key);
await mkdir(path.dirname(p), { recursive: true });
await writeFile(p, body);
}
export async function getObject(key: string): Promise<Buffer> {
return readFile(keyToPath(key));
}
export async function deleteObject(key: string): Promise<void> {
try {
await unlink(keyToPath(key));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
}
export async function objectSize(key: string): Promise<number> {
const s = await stat(keyToPath(key));
return s.size;
}