2026-06-23 20:36:07 -04:00
|
|
|
import { promises as fs } from "fs"
|
|
|
|
|
import path from "path"
|
|
|
|
|
import { randomBytes } from "crypto"
|
2026-07-02 13:42:34 -04:00
|
|
|
import {
|
|
|
|
|
S3Client,
|
|
|
|
|
PutObjectCommand,
|
|
|
|
|
GetObjectCommand,
|
|
|
|
|
DeleteObjectCommand,
|
|
|
|
|
ListObjectsV2Command,
|
|
|
|
|
type GetObjectCommandOutput,
|
|
|
|
|
} from "@aws-sdk/client-s3"
|
|
|
|
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
2026-06-23 20:36:07 -04:00
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
// Local-disk fallback root. Used only when object storage is not configured.
|
|
|
|
|
// Kept OUTSIDE the public web root so files are only served through /api/files.
|
2026-06-23 20:36:07 -04:00
|
|
|
const STORAGE_DIR = path.resolve(process.cwd(), process.env.STORAGE_DIR ?? "./storage")
|
|
|
|
|
|
|
|
|
|
const MIME_BY_EXT: Record<string, string> = {
|
|
|
|
|
pdf: "application/pdf",
|
|
|
|
|
png: "image/png",
|
|
|
|
|
jpg: "image/jpeg",
|
|
|
|
|
jpeg: "image/jpeg",
|
|
|
|
|
gif: "image/gif",
|
|
|
|
|
webp: "image/webp",
|
|
|
|
|
doc: "application/msword",
|
|
|
|
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
|
|
|
xls: "application/vnd.ms-excel",
|
|
|
|
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
|
|
csv: "text/csv",
|
|
|
|
|
txt: "text/plain",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function contentTypeForKey(key: string): string {
|
|
|
|
|
const ext = key.split(".").pop()?.toLowerCase() ?? ""
|
|
|
|
|
return MIME_BY_EXT[ext] ?? "application/octet-stream"
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
// Single source of truth for what users may upload. Deliberately excludes svg
|
|
|
|
|
// and any html/script types, which can execute JavaScript when served inline
|
|
|
|
|
// from our origin. Enforce this at EVERY upload entry point (see /api/upload
|
|
|
|
|
// and /api/documents) — divergence is how an allowlist gets bypassed.
|
|
|
|
|
export const ALLOWED_UPLOAD_EXTENSIONS = [
|
|
|
|
|
"pdf", "png", "jpg", "jpeg", "gif", "webp",
|
|
|
|
|
"doc", "docx", "xls", "xlsx", "csv", "txt",
|
|
|
|
|
] as const
|
|
|
|
|
|
|
|
|
|
export function extOf(filename: string): string {
|
|
|
|
|
return filename.split(".").pop()?.toLowerCase() ?? ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isAllowedUploadExt(filename: string): boolean {
|
|
|
|
|
return (ALLOWED_UPLOAD_EXTENSIONS as readonly string[]).includes(extOf(filename))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Object storage (DigitalOcean Spaces / S3-compatible) ──────────────────────
|
|
|
|
|
// When SPACES_* are configured, uploads and serving use the bucket instead of
|
|
|
|
|
// local disk. The key scheme (`<userId>/<scope>/<file>`) is identical either
|
|
|
|
|
// way, so existing /api/files/<key> URLs stored in the DB keep working after the
|
|
|
|
|
// backend switch — only the bytes move.
|
|
|
|
|
const SPACES_BUCKET = process.env.SPACES_BUCKET ?? ""
|
|
|
|
|
|
|
|
|
|
export function usingSpaces(): boolean {
|
|
|
|
|
return Boolean(process.env.SPACES_KEY && process.env.SPACES_SECRET && SPACES_BUCKET)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Thrown when a write is attempted in production without object storage
|
|
|
|
|
* configured. The local-disk fallback is EPHEMERAL on App Platform, so silently
|
|
|
|
|
* using it means uploads vanish on the next deploy. We fail loud instead.
|
|
|
|
|
*/
|
|
|
|
|
export class StorageNotConfiguredError extends Error {
|
|
|
|
|
constructor() {
|
|
|
|
|
super(
|
|
|
|
|
"Object storage (SPACES_*) is not configured. Refusing to write uploads to " +
|
|
|
|
|
"ephemeral local disk in production — files would be lost on the next deploy."
|
|
|
|
|
)
|
|
|
|
|
this.name = "StorageNotConfiguredError"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function newClient(endpoint: string | undefined): S3Client {
|
|
|
|
|
return new S3Client({
|
|
|
|
|
region: process.env.SPACES_REGION || "us-east-1",
|
|
|
|
|
endpoint,
|
|
|
|
|
forcePathStyle: false,
|
|
|
|
|
credentials: {
|
|
|
|
|
accessKeyId: process.env.SPACES_KEY!,
|
|
|
|
|
secretAccessKey: process.env.SPACES_SECRET!,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Origin client — used for all mutating/reading operations (PUT/GET/DELETE/HEAD).
|
|
|
|
|
let _s3: S3Client | null = null
|
|
|
|
|
function s3(): S3Client {
|
|
|
|
|
if (!_s3) _s3 = newClient(process.env.SPACES_ENDPOINT) // e.g. https://nyc3.digitaloceanspaces.com
|
|
|
|
|
return _s3
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Rewrite a presigned origin URL to the Spaces CDN edge host when the CDN is
|
|
|
|
|
// enabled. The URL is signed against the ORIGIN host; the CDN forwards requests
|
|
|
|
|
// to origin with the origin Host header, so the SigV4 signature still validates.
|
|
|
|
|
// (Signing directly against the CDN host is rejected by origin with 403.)
|
|
|
|
|
function toCdnUrl(signedUrl: string): string {
|
|
|
|
|
const cdn = process.env.SPACES_CDN_ENDPOINT
|
|
|
|
|
const origin = process.env.SPACES_ENDPOINT
|
|
|
|
|
if (!cdn || !origin) return signedUrl
|
|
|
|
|
try {
|
|
|
|
|
const originHost = new URL(origin).host // e.g. nyc3.digitaloceanspaces.com
|
|
|
|
|
const cdnHost = new URL(cdn).host // e.g. nyc3.cdn.digitaloceanspaces.com
|
|
|
|
|
const u = new URL(signedUrl)
|
|
|
|
|
if (u.host.endsWith(originHost)) {
|
|
|
|
|
u.host = u.host.slice(0, u.host.length - originHost.length) + cdnHost
|
|
|
|
|
return u.toString()
|
|
|
|
|
}
|
|
|
|
|
return signedUrl
|
|
|
|
|
} catch {
|
|
|
|
|
return signedUrl
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── key helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
/** Validate a storage key, refusing empty/traversal segments. Returns it cleaned. */
|
|
|
|
|
function assertSafeKey(key: string): string {
|
2026-06-23 20:36:07 -04:00
|
|
|
const clean = key.replace(/^\/+/, "")
|
2026-07-02 13:42:34 -04:00
|
|
|
if (!clean || clean.split(/[\\/]+/).some((seg) => seg === "" || seg === "." || seg === "..")) {
|
2026-07-01 13:56:34 -04:00
|
|
|
throw new Error("Invalid storage path")
|
|
|
|
|
}
|
2026-07-02 13:42:34 -04:00
|
|
|
return clean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Resolve a key to an absolute local path (local-disk backend only). */
|
|
|
|
|
function resolveKey(key: string): string {
|
|
|
|
|
const clean = assertSafeKey(key)
|
2026-06-23 20:36:07 -04:00
|
|
|
const abs = path.resolve(STORAGE_DIR, clean)
|
|
|
|
|
if (abs !== STORAGE_DIR && !abs.startsWith(STORAGE_DIR + path.sep)) {
|
|
|
|
|
throw new Error("Invalid storage path")
|
|
|
|
|
}
|
|
|
|
|
return abs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sanitizeSegment(s: string): string {
|
|
|
|
|
return s.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
/**
|
|
|
|
|
* True iff a storage key lives in the given owner's namespace (`<ownerId>/…`).
|
|
|
|
|
* Keys are generated server-side as `<sanitized ownerId>/<scope>/<file>`, so any
|
|
|
|
|
* client-supplied key/path whose first segment differs belongs to another tenant
|
|
|
|
|
* (or is malformed) and must be rejected.
|
|
|
|
|
*/
|
|
|
|
|
export function keyBelongsToOwner(key: string | null | undefined, ownerId: string): boolean {
|
|
|
|
|
if (!key || !ownerId) return false
|
|
|
|
|
const first = key.replace(/^\/+/, "").split(/[\\/]+/)[0]
|
|
|
|
|
return first === sanitizeSegment(ownerId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Lightweight magic-byte check: reject a file whose real content doesn't match
|
|
|
|
|
* its claimed extension (e.g. an HTML/script payload renamed to `.pdf`). Types
|
|
|
|
|
* without a reliable file signature (csv/txt) are allowed through. `head` should
|
|
|
|
|
* be the first ~16 bytes of the file.
|
|
|
|
|
*/
|
|
|
|
|
export function contentMatchesExtension(head: Buffer, ext: string): boolean {
|
|
|
|
|
const at = (offset: number, sig: number[]) =>
|
|
|
|
|
head.length >= offset + sig.length && sig.every((b, i) => head[offset + i] === b)
|
|
|
|
|
switch (ext) {
|
|
|
|
|
case "pdf":
|
|
|
|
|
return at(0, [0x25, 0x50, 0x44, 0x46]) // %PDF
|
|
|
|
|
case "png":
|
|
|
|
|
return at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
|
|
|
case "jpg":
|
|
|
|
|
case "jpeg":
|
|
|
|
|
return at(0, [0xff, 0xd8, 0xff])
|
|
|
|
|
case "gif":
|
|
|
|
|
return at(0, [0x47, 0x49, 0x46, 0x38]) // GIF8
|
|
|
|
|
case "webp":
|
|
|
|
|
return at(0, [0x52, 0x49, 0x46, 0x46]) && at(8, [0x57, 0x45, 0x42, 0x50]) // RIFF…WEBP
|
|
|
|
|
case "docx":
|
|
|
|
|
case "xlsx":
|
|
|
|
|
return at(0, [0x50, 0x4b, 0x03, 0x04]) || at(0, [0x50, 0x4b, 0x05, 0x06]) // zip (PK)
|
|
|
|
|
case "doc":
|
|
|
|
|
case "xls":
|
|
|
|
|
return at(0, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || at(0, [0x50, 0x4b]) // OLE or zip
|
|
|
|
|
case "csv":
|
|
|
|
|
case "txt":
|
|
|
|
|
return true // no reliable signature
|
|
|
|
|
default:
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
async function bodyToBuffer(body: GetObjectCommandOutput["Body"]): Promise<Buffer> {
|
|
|
|
|
if (!body) return Buffer.alloc(0)
|
|
|
|
|
// The AWS SDK v3 Node runtime adds transformToByteArray() to the stream body.
|
|
|
|
|
const stream = body as { transformToByteArray?: () => Promise<Uint8Array> }
|
|
|
|
|
if (typeof stream.transformToByteArray === "function") {
|
|
|
|
|
return Buffer.from(await stream.transformToByteArray())
|
|
|
|
|
}
|
|
|
|
|
const chunks: Buffer[] = []
|
|
|
|
|
for await (const chunk of body as AsyncIterable<Uint8Array>) chunks.push(Buffer.from(chunk))
|
|
|
|
|
return Buffer.concat(chunks)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 20:36:07 -04:00
|
|
|
/**
|
|
|
|
|
* Persist an uploaded File under `${userId}/${scope}/<random>.<ext>` and return
|
|
|
|
|
* the storage key (relative path). Optionally pass `fixedName` to make the file
|
|
|
|
|
* name deterministic (e.g. one photo per property).
|
|
|
|
|
*/
|
|
|
|
|
export async function saveFile(
|
|
|
|
|
file: File,
|
|
|
|
|
opts: { userId: string; scope: string; fixedName?: string }
|
|
|
|
|
): Promise<{ key: string; size: number; type: string }> {
|
|
|
|
|
const ext = (file.name.split(".").pop() ?? "bin").toLowerCase().replace(/[^a-z0-9]/g, "")
|
|
|
|
|
const base = opts.fixedName
|
|
|
|
|
? sanitizeSegment(opts.fixedName)
|
|
|
|
|
: `${Date.now()}-${randomBytes(6).toString("hex")}`
|
|
|
|
|
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${base}.${ext}`
|
|
|
|
|
const buffer = Buffer.from(await file.arrayBuffer())
|
2026-07-02 13:42:34 -04:00
|
|
|
const type = file.type || contentTypeForKey(key)
|
2026-06-23 20:36:07 -04:00
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
if (usingSpaces()) {
|
|
|
|
|
await s3().send(
|
|
|
|
|
new PutObjectCommand({
|
|
|
|
|
Bucket: SPACES_BUCKET,
|
|
|
|
|
Key: key,
|
|
|
|
|
Body: buffer,
|
|
|
|
|
ContentType: type,
|
|
|
|
|
ACL: "private",
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
// In production the local-disk backend is ephemeral (lost on redeploy), so a
|
|
|
|
|
// misconfigured Spaces setup must fail loudly rather than silently drop data.
|
|
|
|
|
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
|
|
|
|
|
const abs = resolveKey(key)
|
|
|
|
|
await fs.mkdir(path.dirname(abs), { recursive: true })
|
|
|
|
|
await fs.writeFile(abs, buffer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { key, size: file.size, type }
|
2026-06-23 20:36:07 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
/**
|
|
|
|
|
* Persist raw bytes under `${userId}/${scope}/<random>.<ext>` (server-generated,
|
|
|
|
|
* so the key is always in the owner's namespace) and return the storage key.
|
|
|
|
|
* Used for server-side artifacts like signed e-sign PDFs.
|
|
|
|
|
*/
|
|
|
|
|
export async function saveBuffer(
|
|
|
|
|
buffer: Buffer,
|
|
|
|
|
opts: { userId: string; scope: string; ext: string }
|
|
|
|
|
): Promise<{ key: string }> {
|
|
|
|
|
const ext = opts.ext.replace(/[^a-z0-9]/gi, "").toLowerCase() || "bin"
|
|
|
|
|
const key = `${sanitizeSegment(opts.userId)}/${sanitizeSegment(opts.scope)}/${Date.now()}-${randomBytes(6).toString("hex")}.${ext}`
|
|
|
|
|
if (usingSpaces()) {
|
|
|
|
|
await s3().send(
|
|
|
|
|
new PutObjectCommand({
|
|
|
|
|
Bucket: SPACES_BUCKET,
|
|
|
|
|
Key: key,
|
|
|
|
|
Body: buffer,
|
|
|
|
|
ContentType: contentTypeForKey(key),
|
|
|
|
|
ACL: "private",
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
if (process.env.NODE_ENV === "production") throw new StorageNotConfiguredError()
|
|
|
|
|
const abs = resolveKey(key)
|
|
|
|
|
await fs.mkdir(path.dirname(abs), { recursive: true })
|
|
|
|
|
await fs.writeFile(abs, buffer)
|
|
|
|
|
}
|
|
|
|
|
return { key }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 20:36:07 -04:00
|
|
|
export async function readFile(key: string): Promise<Buffer> {
|
2026-07-02 13:42:34 -04:00
|
|
|
if (usingSpaces()) {
|
|
|
|
|
const res = await s3().send(new GetObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
|
|
|
|
|
return bodyToBuffer(res.Body)
|
|
|
|
|
}
|
2026-06-23 20:36:07 -04:00
|
|
|
return fs.readFile(resolveKey(key))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
/**
|
|
|
|
|
* Presigned, time-limited GET URL for an object in Spaces — lets the browser
|
|
|
|
|
* fetch the bytes directly from the bucket (offloading them from the app) while
|
|
|
|
|
* access stays gated: the caller must pass auth + ownership checks before this
|
|
|
|
|
* is issued, and the URL expires. `ResponseContent*` control how the browser
|
|
|
|
|
* treats the file (inline image vs. attachment download) with the right type.
|
|
|
|
|
*/
|
|
|
|
|
export async function presignGetUrl(
|
|
|
|
|
key: string,
|
|
|
|
|
opts: { expiresIn?: number; disposition?: "inline" | "attachment"; filename?: string } = {}
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const safe = assertSafeKey(key)
|
|
|
|
|
// The key's basename is already generated/sanitized at upload; keep only
|
|
|
|
|
// filename-safe characters for the Content-Disposition header.
|
|
|
|
|
const filename = (opts.filename ?? safe.split("/").pop() ?? "file").replace(/[^A-Za-z0-9._-]/g, "_")
|
|
|
|
|
const cmd = new GetObjectCommand({
|
|
|
|
|
Bucket: SPACES_BUCKET,
|
|
|
|
|
Key: safe,
|
|
|
|
|
ResponseContentType: contentTypeForKey(safe),
|
|
|
|
|
ResponseContentDisposition: `${opts.disposition ?? "inline"}; filename="${filename}"`,
|
|
|
|
|
})
|
|
|
|
|
const signed = await getSignedUrl(s3(), cmd, { expiresIn: opts.expiresIn ?? 3600 })
|
|
|
|
|
return toCdnUrl(signed)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
export async function deleteFile(key: string, ownerId: string): Promise<void> {
|
|
|
|
|
// Defense in depth: never delete an object outside the caller's own namespace,
|
|
|
|
|
// even if a stored storage_path was tampered with to point at another tenant.
|
|
|
|
|
if (!keyBelongsToOwner(key, ownerId)) return
|
2026-06-23 20:36:07 -04:00
|
|
|
try {
|
2026-07-02 13:42:34 -04:00
|
|
|
if (usingSpaces()) {
|
|
|
|
|
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: assertSafeKey(key) }))
|
|
|
|
|
} else {
|
|
|
|
|
await fs.unlink(resolveKey(key))
|
|
|
|
|
}
|
2026-06-23 20:36:07 -04:00
|
|
|
} catch {
|
|
|
|
|
// Already gone — ignore.
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-02 13:42:34 -04:00
|
|
|
|
2026-07-03 06:03:27 -04:00
|
|
|
/**
|
|
|
|
|
* Permanently delete EVERY stored object in a user's namespace (`<userId>/…`),
|
|
|
|
|
* across whichever backend is active. Used by GDPR account deletion — there is
|
|
|
|
|
* no undo. Returns the number of objects removed (best effort; local-disk
|
|
|
|
|
* removals aren't counted individually).
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteUserStorage(userId: string): Promise<number> {
|
|
|
|
|
const prefix = `${sanitizeSegment(userId)}/`
|
|
|
|
|
|
|
|
|
|
if (usingSpaces()) {
|
|
|
|
|
let deleted = 0
|
|
|
|
|
let token: string | undefined
|
|
|
|
|
do {
|
|
|
|
|
const res = await s3().send(
|
|
|
|
|
new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token })
|
|
|
|
|
)
|
|
|
|
|
for (const obj of res.Contents ?? []) {
|
|
|
|
|
if (!obj.Key) continue
|
|
|
|
|
await s3().send(new DeleteObjectCommand({ Bucket: SPACES_BUCKET, Key: obj.Key }))
|
|
|
|
|
deleted++
|
|
|
|
|
}
|
|
|
|
|
token = res.IsTruncated ? res.NextContinuationToken : undefined
|
|
|
|
|
} while (token)
|
|
|
|
|
return deleted
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await fs.rm(path.join(STORAGE_DIR, sanitizeSegment(userId)), { recursive: true, force: true })
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
async function walkDirSize(dir: string): Promise<number> {
|
|
|
|
|
let entries
|
|
|
|
|
try {
|
|
|
|
|
entries = await fs.readdir(dir, { withFileTypes: true })
|
|
|
|
|
} catch {
|
|
|
|
|
return 0 // directory doesn't exist yet → 0 bytes
|
|
|
|
|
}
|
|
|
|
|
let total = 0
|
|
|
|
|
for (const e of entries) {
|
|
|
|
|
const full = path.join(dir, e.name)
|
|
|
|
|
if (e.isDirectory()) total += await walkDirSize(full)
|
|
|
|
|
else if (e.isFile()) {
|
|
|
|
|
try {
|
|
|
|
|
total += (await fs.stat(full)).size
|
|
|
|
|
} catch {
|
|
|
|
|
// File vanished between readdir and stat — ignore.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return total
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Total bytes currently stored for a user, across whichever backend is active.
|
|
|
|
|
* Files are namespaced under `<userId>/…`, so we sum that prefix. Used to
|
|
|
|
|
* enforce per-plan storage quotas at upload time.
|
|
|
|
|
*/
|
|
|
|
|
export async function getUserStorageBytes(userId: string): Promise<number> {
|
|
|
|
|
const prefix = `${sanitizeSegment(userId)}/`
|
|
|
|
|
|
|
|
|
|
if (usingSpaces()) {
|
|
|
|
|
let total = 0
|
|
|
|
|
let token: string | undefined
|
|
|
|
|
do {
|
|
|
|
|
const res = await s3().send(
|
|
|
|
|
new ListObjectsV2Command({ Bucket: SPACES_BUCKET, Prefix: prefix, ContinuationToken: token })
|
|
|
|
|
)
|
|
|
|
|
for (const obj of res.Contents ?? []) total += obj.Size ?? 0
|
|
|
|
|
token = res.IsTruncated ? res.NextContinuationToken : undefined
|
|
|
|
|
} while (token)
|
|
|
|
|
return total
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return walkDirSize(path.join(STORAGE_DIR, sanitizeSegment(userId)))
|
|
|
|
|
}
|