● Zion Boggan
repos/Stepping App/apps/web/src/lib/uploads.ts
zionboggan.com ↗
72 lines · typescript
History for this file →
1
import { randomBytes } from "node:crypto";
2
import { mkdir, writeFile } from "node:fs/promises";
3
import path from "node:path";
4
 
5
export const UPLOADS_DIR =
6
  process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
7
 
8
export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
9
export const MAX_PDF_BYTES = 15 * 1024 * 1024;
10
 
11
const CONTENT_TYPES: Record<string, string> = {
12
  jpg: "image/jpeg",
13
  png: "image/png",
14
  webp: "image/webp",
15
  pdf: "application/pdf",
16
};
17
 
18
export function contentTypeFor(ext: string): string | null {
19
  return CONTENT_TYPES[ext] ?? null;
20
}
21
 
22
export const UPLOAD_NAME_RE = /^[a-f0-9]{32}\.[a-z0-9]{3,4}$/;
23
 
24
function sniffExt(buf: Buffer): keyof typeof CONTENT_TYPES | null {
25
  if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
26
    return "jpg";
27
  }
28
  if (
29
    buf.length >= 8 &&
30
    buf
31
      .subarray(0, 8)
32
      .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
33
  ) {
34
    return "png";
35
  }
36
  if (
37
    buf.length >= 12 &&
38
    buf.subarray(0, 4).toString("ascii") === "RIFF" &&
39
    buf.subarray(8, 12).toString("ascii") === "WEBP"
40
  ) {
41
    return "webp";
42
  }
43
  if (buf.length >= 4 && buf.subarray(0, 4).toString("ascii") === "%PDF") {
44
    return "pdf";
45
  }
46
  return null;
47
}
48
 
49
export type UploadKind = "image" | "pdf";
50
 
51
export async function saveUpload(file: File, kind: UploadKind): Promise<string> {
52
  const max = kind === "pdf" ? MAX_PDF_BYTES : MAX_IMAGE_BYTES;
53
  if (file.size === 0) throw new Error("Empty file.");
54
  if (file.size > max) {
55
    throw new Error(`File too large. Max ${Math.round(max / 1024 / 1024)}MB.`);
56
  }
57
  const buf = Buffer.from(await file.arrayBuffer());
58
  const ext = sniffExt(buf);
59
  const accepted =
60
    kind === "pdf" ? ext === "pdf" : ext === "jpg" || ext === "png" || ext === "webp";
61
  if (!ext || !accepted) {
62
    throw new Error(
63
      kind === "pdf"
64
        ? "Only PDF files are accepted."
65
        : "Only JPEG, PNG, or WebP images are accepted.",
66
    );
67
  }
68
  const name = `${randomBytes(16).toString("hex")}.${ext}`;
69
  await mkdir(UPLOADS_DIR, { recursive: true });
70
  await writeFile(path.join(UPLOADS_DIR, name), buf);
71
  return `/api/uploads/${name}`;
72
}