uploads.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import { randomUUID } from 'node:crypto'
  2. import { createWriteStream, mkdirSync, renameSync, rmSync, chmodSync, lstatSync } from 'node:fs'
  3. import { open } from 'node:fs/promises'
  4. import type { IncomingMessage } from 'node:http'
  5. import { basename, extname, join, resolve, sep } from 'node:path'
  6. import { DatabaseSync } from 'node:sqlite'
  7. import Busboy from 'busboy'
  8. import { HttpError } from './errors.js'
  9. export const MAX_UPLOAD_FILE_BYTES = 25 * 1_024 * 1_024
  10. export const MAX_UPLOAD_REQUEST_BYTES = 50 * 1_024 * 1_024
  11. export const MAX_UPLOAD_FILES = 8
  12. export const MAX_UPLOAD_QUOTA_BYTES = 2 * 1_024 * 1_024 * 1_024
  13. export interface UploadReference {
  14. id: string
  15. name: string
  16. mimeType: string
  17. size: number
  18. }
  19. export interface UploadRecord extends UploadReference {
  20. path: string
  21. accountKey: string
  22. referenced: boolean
  23. }
  24. interface StagedUpload extends UploadRecord {
  25. tempPath: string
  26. }
  27. function cleanName(value: string): string {
  28. const safe = basename(value)
  29. .replace(/[\u0000-\u001f\u007f]/g, '')
  30. .trim()
  31. .slice(0, 240)
  32. return safe || 'attachment'
  33. }
  34. function safeExtension(name: string): string {
  35. const extension = extname(name).toLowerCase()
  36. return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : ''
  37. }
  38. function within(root: string, path: string): boolean {
  39. const normalizedRoot = resolve(root)
  40. const normalizedPath = resolve(path)
  41. return normalizedPath.startsWith(`${normalizedRoot}${sep}`)
  42. }
  43. export class UploadStore {
  44. readonly uploadRoot: string
  45. readonly #database: DatabaseSync
  46. constructor(
  47. home: string,
  48. readonly quotaBytes = MAX_UPLOAD_QUOTA_BYTES,
  49. ) {
  50. mkdirSync(home, { recursive: true, mode: 0o700 })
  51. chmodSync(home, 0o700)
  52. this.uploadRoot = join(home, 'uploads')
  53. mkdirSync(this.uploadRoot, { recursive: true, mode: 0o700 })
  54. chmodSync(this.uploadRoot, 0o700)
  55. this.#database = new DatabaseSync(join(home, 'uploads.sqlite3'))
  56. this.#database.exec(`
  57. PRAGMA journal_mode = WAL;
  58. PRAGMA foreign_keys = ON;
  59. CREATE TABLE IF NOT EXISTS uploads (
  60. id TEXT PRIMARY KEY,
  61. account_key TEXT NOT NULL,
  62. name TEXT NOT NULL,
  63. mime_type TEXT NOT NULL,
  64. size INTEGER NOT NULL CHECK(size >= 0),
  65. path TEXT NOT NULL UNIQUE,
  66. referenced INTEGER NOT NULL DEFAULT 0,
  67. created_at INTEGER NOT NULL
  68. );
  69. CREATE INDEX IF NOT EXISTS uploads_account_created
  70. ON uploads(account_key, created_at);
  71. `)
  72. }
  73. close(): void {
  74. this.#database.close()
  75. }
  76. commit(staged: readonly StagedUpload[]): UploadReference[] {
  77. if (staged.length === 0) throw new HttpError(400, 'At least one file is required', 'missing_files')
  78. const addedBytes = staged.reduce((sum, file) => sum + file.size, 0)
  79. const usage = this.#database.prepare(
  80. 'SELECT COALESCE(SUM(size), 0) AS bytes FROM uploads',
  81. ).get() as { bytes: number }
  82. if (Number(usage.bytes) + addedBytes > this.quotaBytes) {
  83. this.discard(staged)
  84. throw new HttpError(413, 'Upload quota exceeded', 'upload_quota_exceeded')
  85. }
  86. const insert = this.#database.prepare(`
  87. INSERT INTO uploads(id, account_key, name, mime_type, size, path, referenced, created_at)
  88. VALUES (?, ?, ?, ?, ?, ?, 0, ?)
  89. `)
  90. const moved: string[] = []
  91. this.#database.exec('BEGIN IMMEDIATE')
  92. try {
  93. for (const file of staged) {
  94. if (!within(this.uploadRoot, file.path) || !within(this.uploadRoot, file.tempPath)) {
  95. throw new HttpError(500, 'Invalid upload destination')
  96. }
  97. renameSync(file.tempPath, file.path)
  98. chmodSync(file.path, 0o600)
  99. moved.push(file.path)
  100. insert.run(
  101. file.id,
  102. file.accountKey,
  103. file.name,
  104. file.mimeType,
  105. file.size,
  106. file.path,
  107. Date.now(),
  108. )
  109. }
  110. this.#database.exec('COMMIT')
  111. } catch (error) {
  112. this.#database.exec('ROLLBACK')
  113. this.discard(staged)
  114. for (const path of moved) rmSync(path, { force: true })
  115. throw error
  116. }
  117. return staged.map(({ id, name, mimeType, size }) => ({ id, name, mimeType, size }))
  118. }
  119. records(ids: readonly string[], accountKey: string): UploadRecord[] {
  120. if (ids.length > MAX_UPLOAD_FILES) {
  121. throw new HttpError(400, `At most ${MAX_UPLOAD_FILES} uploads may be referenced`, 'too_many_uploads')
  122. }
  123. const unique = [...new Set(ids)]
  124. const lookup = this.#database.prepare(`
  125. SELECT id, account_key, name, mime_type, size, path, referenced
  126. FROM uploads WHERE id = ? AND account_key = ?
  127. `)
  128. const records = unique.map((id) => {
  129. if (!/^[0-9a-f-]{36}$/.test(id)) {
  130. throw new HttpError(400, 'Invalid upload reference', 'invalid_upload')
  131. }
  132. const row = lookup.get(id, accountKey) as {
  133. id: string
  134. account_key: string
  135. name: string
  136. mime_type: string
  137. size: number
  138. path: string
  139. referenced: number
  140. } | undefined
  141. if (!row || !within(this.uploadRoot, row.path)) {
  142. throw new HttpError(404, 'Upload reference not found', 'upload_not_found')
  143. }
  144. const stat = lstatSync(row.path)
  145. if (!stat.isFile() || stat.isSymbolicLink()) {
  146. throw new HttpError(409, 'Upload reference is no longer safe', 'unsafe_upload')
  147. }
  148. return {
  149. id: row.id,
  150. accountKey: row.account_key,
  151. name: row.name,
  152. mimeType: row.mime_type,
  153. size: Number(row.size),
  154. path: row.path,
  155. referenced: Boolean(row.referenced),
  156. }
  157. })
  158. return records
  159. }
  160. markReferenced(ids: readonly string[], accountKey: string): void {
  161. const statement = this.#database.prepare(
  162. 'UPDATE uploads SET referenced = 1 WHERE id = ? AND account_key = ?',
  163. )
  164. this.#database.exec('BEGIN IMMEDIATE')
  165. try {
  166. for (const id of new Set(ids)) statement.run(id, accountKey)
  167. this.#database.exec('COMMIT')
  168. } catch (error) {
  169. this.#database.exec('ROLLBACK')
  170. throw error
  171. }
  172. }
  173. cleanupUncommitted(maxAgeMs = 24 * 60 * 60 * 1_000): number {
  174. const rows = this.#database.prepare(
  175. 'SELECT id, path FROM uploads WHERE referenced = 0 AND created_at < ?',
  176. ).all(Date.now() - maxAgeMs) as Array<{ id: string; path: string }>
  177. const remove = this.#database.prepare('DELETE FROM uploads WHERE id = ? AND referenced = 0')
  178. let count = 0
  179. this.#database.exec('BEGIN IMMEDIATE')
  180. try {
  181. for (const row of rows) {
  182. if (within(this.uploadRoot, row.path)) rmSync(row.path, { force: true })
  183. remove.run(row.id)
  184. count += 1
  185. }
  186. this.#database.exec('COMMIT')
  187. } catch (error) {
  188. this.#database.exec('ROLLBACK')
  189. throw error
  190. }
  191. return count
  192. }
  193. discard(staged: readonly Pick<StagedUpload, 'tempPath' | 'path'>[]): void {
  194. for (const file of staged) {
  195. if (within(this.uploadRoot, file.tempPath)) rmSync(file.tempPath, { force: true })
  196. if (within(this.uploadRoot, file.path)) rmSync(file.path, { force: true })
  197. }
  198. }
  199. }
  200. export async function receiveGroupUploads(
  201. request: IncomingMessage,
  202. store: UploadStore,
  203. accountKey: string,
  204. ): Promise<UploadReference[]> {
  205. const contentType = request.headers['content-type']
  206. if (!contentType?.toLowerCase().startsWith('multipart/form-data')) {
  207. throw new HttpError(415, 'Expected multipart/form-data', 'invalid_content_type')
  208. }
  209. const declared = Number(request.headers['content-length'] ?? '0')
  210. if (Number.isFinite(declared) && declared > MAX_UPLOAD_REQUEST_BYTES + 128 * 1_024) {
  211. throw new HttpError(413, 'Upload request is too large', 'upload_request_too_large')
  212. }
  213. const busboy = Busboy({
  214. headers: request.headers,
  215. limits: {
  216. files: MAX_UPLOAD_FILES,
  217. fileSize: MAX_UPLOAD_FILE_BYTES,
  218. fields: 8,
  219. parts: MAX_UPLOAD_FILES + 8,
  220. },
  221. })
  222. const staged: StagedUpload[] = []
  223. const tasks: Promise<void>[] = []
  224. let total = 0
  225. let parserError: Error | undefined
  226. busboy.on('file', (_field, stream, info) => {
  227. const id = randomUUID()
  228. const name = cleanName(info.filename)
  229. const destination = join(store.uploadRoot, `${id}${safeExtension(name)}`)
  230. const tempPath = join(store.uploadRoot, `.${id}.part`)
  231. const stagedFile: StagedUpload = {
  232. id,
  233. accountKey,
  234. name,
  235. mimeType: info.mimeType || 'application/octet-stream',
  236. size: 0,
  237. path: destination,
  238. tempPath,
  239. referenced: false,
  240. }
  241. staged.push(stagedFile)
  242. let limited = false
  243. stream.once('limit', () => { limited = true })
  244. tasks.push((async () => {
  245. const handle = await open(tempPath, 'wx', 0o600)
  246. const output = createWriteStream(tempPath, { fd: handle.fd, autoClose: false })
  247. try {
  248. for await (const chunk of stream) {
  249. const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
  250. total += data.byteLength
  251. stagedFile.size += data.byteLength
  252. if (total > MAX_UPLOAD_REQUEST_BYTES) {
  253. throw new HttpError(413, 'Combined uploads exceed 50 MiB', 'upload_request_too_large')
  254. }
  255. if (!output.write(data)) await new Promise<void>((resolve) => output.once('drain', resolve))
  256. }
  257. if (limited || stagedFile.size > MAX_UPLOAD_FILE_BYTES) {
  258. throw new HttpError(413, `${name} exceeds 25 MiB`, 'upload_file_too_large')
  259. }
  260. } finally {
  261. await new Promise<void>((resolve, reject) => {
  262. output.end((error?: Error | null) => error ? reject(error) : resolve())
  263. })
  264. await handle.close().catch(() => undefined)
  265. }
  266. })())
  267. })
  268. busboy.once('filesLimit', () => {
  269. parserError = new HttpError(413, `At most ${MAX_UPLOAD_FILES} files are allowed`, 'too_many_uploads')
  270. })
  271. busboy.once('partsLimit', () => {
  272. parserError = new HttpError(413, 'Multipart request has too many parts', 'too_many_parts')
  273. })
  274. try {
  275. await new Promise<void>((resolvePromise, reject) => {
  276. busboy.once('error', reject)
  277. busboy.once('finish', resolvePromise)
  278. request.pipe(busboy)
  279. })
  280. await Promise.all(tasks)
  281. if (parserError) throw parserError
  282. return store.commit(staged)
  283. } catch (error) {
  284. await Promise.allSettled(tasks)
  285. store.discard(staged)
  286. throw error
  287. }
  288. }
  289. function escapeMarkdown(value: string): string {
  290. return value.replace(/[\\\[\]]/g, '\\$&')
  291. }
  292. export function uploadMarkdown(records: readonly UploadRecord[]): string {
  293. return records.map((record) => `[${escapeMarkdown(record.name)}](<${record.path}>)`).join('\n')
  294. }