Skip to content

Commit a6c8159

Browse files
committed
feat(sdk-utils): add Attachment shape and validateAttachments helper
1 parent 989d8e3 commit a6c8159

5 files changed

Lines changed: 241 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./types"
2+
export * from "./validate"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Transport shape for user-supplied additional attachments. Lives in
3+
* sdk-utils because both packages/ui and packages/expo build this same
4+
* shape from their respective file pickers and hand it to their intake
5+
* clients. The dashboard's AttachmentDTO is a separate, render-side shape.
6+
*/
7+
export interface Attachment {
8+
/** Local UUID — used as React/Preact key and for picker dedupe. */
9+
id: string
10+
/** Raw file bytes. In Expo this is wrapped via fetch(uri).blob() before send. */
11+
blob: Blob
12+
/** Original filename, sanitized client-side. Server is the source of truth. */
13+
filename: string
14+
/** MIME type. Falls back to "application/octet-stream" if the picker doesn't supply one. */
15+
mime: string
16+
/** Bytes. */
17+
size: number
18+
/** Convenience: mime.startsWith("image/"). Set at construction. */
19+
isImage: boolean
20+
/** Object URL for thumbnail preview. Caller manages revocation. */
21+
previewUrl?: string
22+
}
23+
24+
export interface AttachmentLimits {
25+
maxCount: number
26+
maxFileBytes: number
27+
maxTotalBytes: number
28+
}
29+
30+
export const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits = {
31+
maxCount: 5,
32+
maxFileBytes: 10 * 1024 * 1024,
33+
maxTotalBytes: 25 * 1024 * 1024,
34+
}
35+
36+
/**
37+
* Client-side mime denylist. Mirrors (but does not replace) the
38+
* server-side denylist. Server is authoritative.
39+
*/
40+
export const DENIED_MIME_PREFIXES: readonly string[] = [
41+
"application/x-msdownload",
42+
"application/x-sh",
43+
"text/x-shellscript",
44+
"application/x-executable",
45+
] as const
46+
47+
export const DENIED_FILENAME_EXTENSIONS: readonly string[] = [
48+
".exe",
49+
".bat",
50+
".cmd",
51+
".com",
52+
".scr",
53+
".sh",
54+
".ps1",
55+
".vbs",
56+
] as const
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { expect, test } from "bun:test"
2+
import { DEFAULT_ATTACHMENT_LIMITS, validateAttachments, type Attachment } from "./index"
3+
4+
function makeFile(name: string, bytes: number, type = "image/png"): File {
5+
const blob = new Blob([new Uint8Array(bytes)], { type })
6+
return new File([blob], name, { type })
7+
}
8+
9+
function makeExisting(size: number, mime = "image/png"): Attachment {
10+
return {
11+
id: "x",
12+
blob: new Blob([new Uint8Array(size)], { type: mime }),
13+
filename: "existing.png",
14+
mime,
15+
size,
16+
isImage: mime.startsWith("image/"),
17+
}
18+
}
19+
20+
test("accepts a single small image", () => {
21+
const result = validateAttachments([makeFile("a.png", 100)], [], DEFAULT_ATTACHMENT_LIMITS)
22+
expect(result.accepted).toHaveLength(1)
23+
expect(result.rejected).toHaveLength(0)
24+
expect(result.accepted[0]?.isImage).toBe(true)
25+
})
26+
27+
test("rejects a file over per-file cap", () => {
28+
const big = makeFile("big.png", DEFAULT_ATTACHMENT_LIMITS.maxFileBytes + 1)
29+
const result = validateAttachments([big], [], DEFAULT_ATTACHMENT_LIMITS)
30+
expect(result.accepted).toHaveLength(0)
31+
expect(result.rejected).toEqual([{ filename: "big.png", reason: "too-large" }])
32+
})
33+
34+
test("rejects when count would exceed maxCount", () => {
35+
const existing = Array.from({ length: 5 }, () => makeExisting(10))
36+
const result = validateAttachments([makeFile("new.png", 10)], existing, DEFAULT_ATTACHMENT_LIMITS)
37+
expect(result.accepted).toHaveLength(0)
38+
expect(result.rejected).toEqual([{ filename: "new.png", reason: "count-exceeded" }])
39+
})
40+
41+
test("rejects when total bytes would exceed maxTotalBytes", () => {
42+
const existing = [makeExisting(20 * 1024 * 1024)]
43+
const result = validateAttachments(
44+
[makeFile("a.png", 10 * 1024 * 1024)],
45+
existing,
46+
DEFAULT_ATTACHMENT_LIMITS,
47+
)
48+
expect(result.accepted).toHaveLength(0)
49+
expect(result.rejected).toEqual([{ filename: "a.png", reason: "total-exceeded" }])
50+
})
51+
52+
test("rejects denylisted mime", () => {
53+
const exe = makeFile("evil.exe", 100, "application/x-msdownload")
54+
const result = validateAttachments([exe], [], DEFAULT_ATTACHMENT_LIMITS)
55+
expect(result.rejected).toEqual([{ filename: "evil.exe", reason: "denied-mime" }])
56+
})
57+
58+
test("rejects denylisted extension even if mime is benign", () => {
59+
const fake = makeFile("script.sh", 100, "text/plain")
60+
const result = validateAttachments([fake], [], DEFAULT_ATTACHMENT_LIMITS)
61+
expect(result.rejected).toEqual([{ filename: "script.sh", reason: "denied-mime" }])
62+
})
63+
64+
test("accumulates errors instead of bailing on first", () => {
65+
const ok = makeFile("ok.png", 100)
66+
const big = makeFile("big.png", DEFAULT_ATTACHMENT_LIMITS.maxFileBytes + 1)
67+
const evil = makeFile("evil.exe", 100, "application/x-msdownload")
68+
const result = validateAttachments([ok, big, evil], [], DEFAULT_ATTACHMENT_LIMITS)
69+
expect(result.accepted).toHaveLength(1)
70+
expect(result.accepted[0]?.filename).toBe("ok.png")
71+
expect(result.rejected).toEqual([
72+
{ filename: "big.png", reason: "too-large" },
73+
{ filename: "evil.exe", reason: "denied-mime" },
74+
])
75+
})
76+
77+
test("count check considers files added earlier in the same batch", () => {
78+
const existing = [makeExisting(10), makeExisting(10), makeExisting(10), makeExisting(10)]
79+
const a = makeFile("a.png", 10)
80+
const b = makeFile("b.png", 10)
81+
const result = validateAttachments([a, b], existing, DEFAULT_ATTACHMENT_LIMITS)
82+
expect(result.accepted).toHaveLength(1)
83+
expect(result.rejected).toEqual([{ filename: "b.png", reason: "count-exceeded" }])
84+
})
85+
86+
test("zero-byte file is rejected as unreadable", () => {
87+
const empty = makeFile("empty.png", 0)
88+
const result = validateAttachments([empty], [], DEFAULT_ATTACHMENT_LIMITS)
89+
expect(result.rejected).toEqual([{ filename: "empty.png", reason: "unreadable" }])
90+
})
91+
92+
test("missing mime defaults to application/octet-stream", () => {
93+
const blob = new Blob([new Uint8Array(100)])
94+
const file = new File([blob], "data.bin")
95+
const result = validateAttachments([file], [], DEFAULT_ATTACHMENT_LIMITS)
96+
expect(result.accepted[0]?.mime).toBe("application/octet-stream")
97+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {
2+
DENIED_FILENAME_EXTENSIONS,
3+
DENIED_MIME_PREFIXES,
4+
type Attachment,
5+
type AttachmentLimits,
6+
} from "./types"
7+
8+
export type ValidationFailureReason =
9+
| "too-large"
10+
| "denied-mime"
11+
| "count-exceeded"
12+
| "total-exceeded"
13+
| "unreadable"
14+
15+
export interface ValidationFailure {
16+
filename: string
17+
reason: ValidationFailureReason
18+
}
19+
20+
export interface ValidationResult {
21+
accepted: Attachment[]
22+
rejected: ValidationFailure[]
23+
}
24+
25+
function isDenied(filename: string, mime: string): boolean {
26+
if (DENIED_MIME_PREFIXES.some((p) => mime === p || mime.startsWith(`${p}/`))) return true
27+
const lower = filename.toLowerCase()
28+
return DENIED_FILENAME_EXTENSIONS.some((ext) => lower.endsWith(ext))
29+
}
30+
31+
function newId(): string {
32+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID()
33+
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
34+
}
35+
36+
export function validateAttachments(
37+
candidates: File[],
38+
existing: Attachment[],
39+
limits: AttachmentLimits,
40+
): ValidationResult {
41+
const accepted: Attachment[] = []
42+
const rejected: ValidationFailure[] = []
43+
44+
let runningCount = existing.length
45+
let runningTotal = existing.reduce((n, a) => n + a.size, 0)
46+
47+
for (const file of candidates) {
48+
const filename = file.name
49+
const mime = file.type || "application/octet-stream"
50+
51+
if (file.size === 0) {
52+
rejected.push({ filename, reason: "unreadable" })
53+
continue
54+
}
55+
if (isDenied(filename, mime)) {
56+
rejected.push({ filename, reason: "denied-mime" })
57+
continue
58+
}
59+
if (file.size > limits.maxFileBytes) {
60+
rejected.push({ filename, reason: "too-large" })
61+
continue
62+
}
63+
if (runningCount + 1 > limits.maxCount) {
64+
rejected.push({ filename, reason: "count-exceeded" })
65+
continue
66+
}
67+
if (runningTotal + file.size > limits.maxTotalBytes) {
68+
rejected.push({ filename, reason: "total-exceeded" })
69+
continue
70+
}
71+
72+
accepted.push({
73+
id: newId(),
74+
blob: file,
75+
filename,
76+
mime,
77+
size: file.size,
78+
isImage: mime.startsWith("image/"),
79+
})
80+
runningCount += 1
81+
runningTotal += file.size
82+
}
83+
84+
return { accepted, rejected }
85+
}

packages/sdk-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./redact"
33
export * from "./breadcrumbs"
44
export * from "./annotation"
55
export * from "./theme"
6+
export * from "./attachments"

0 commit comments

Comments
 (0)