11'use strict' ;
22
3+ /**
4+ * @module storage-handler
5+ * @description Manages per-user file storage backed by MongoDB GridFS. Files stored here are
6+ * typically draft attachments uploaded via the REST API (`POST /users/:user/storage`) and later
7+ * attached to outgoing messages. The download route (`GET /users/:user/storage/:file`) bypasses
8+ * the `get()` method and streams directly from the public `gridstore` property via
9+ * `gridstore.openDownloadStream()`.
10+ */
11+
312const GridFSBucket = require ( 'mongodb' ) . GridFSBucket ;
413const libbase64 = require ( 'libbase64' ) ;
514const libmime = require ( 'libmime' ) ;
615
16+ /**
17+ * @typedef {Object } StorageHandlerOptions
18+ * @property {import('mongodb').Db } database - Primary MongoDB database connection
19+ * @property {import('mongodb').Db } [gridfs] - GridFS database connection (defaults to `database`)
20+ * @property {import('mongodb').Db } [users] - Users database connection (defaults to `database`)
21+ * @property {Function } [loggelf] - Graylog logging function (accepted but unused by this class)
22+ */
23+
24+ /**
25+ * @typedef {Object } FileAddOptions
26+ * @property {string } [filename] - Original filename. If omitted, one is generated from the date and contentType.
27+ * @property {string } [contentType] - MIME type. If omitted, inferred from filename or defaults to `application/octet-stream`.
28+ * @property {string } [encoding] - Content encoding identifier. When falsy or omitted, `content` is
29+ * written directly to GridFS as raw bytes — the caller is expected to provide a Buffer (or a string
30+ * that Node.js writable streams accept). When set to `'base64'`, the `content` string is piped
31+ * through a `libbase64.Decoder` that strips base64 encoding before writing the decoded bytes to
32+ * GridFS. `'base64'` is the only value accepted by the storage API route's Joi validation
33+ * (`.valid('base64')`); the message submission route defaults it to `'base64'` as well. Internally
34+ * any truthy value triggers the same base64 decode path — there is no branching for other encodings.
35+ * @property {Buffer|string } content - File content as a Buffer (raw) or string (when base64-encoded).
36+ * @property {string } [cid] - Optional Content-ID for inline attachments (e.g. embedded images in HTML drafts).
37+ */
38+
39+ /**
40+ * @typedef {Object } StoredFileData
41+ * @property {string } id - Hex string of the GridFS file ObjectId
42+ * @property {string } filename - Stored filename
43+ * @property {string } contentType - MIME type
44+ * @property {number } size - File size in bytes
45+ * @property {Buffer } content - Entire file content buffered in memory
46+ * @property {string } [cid] - Content-ID if one was set during upload
47+ */
48+
49+ /**
50+ * Handles user-scoped file storage operations using MongoDB GridFS. Files are stored in the
51+ * `storage.files` / `storage.chunks` collections with a 255 KB chunk size. Each file's metadata
52+ * includes the owning user's ObjectId, ensuring all read and delete operations are user-scoped.
53+ *
54+ * Used by the storage REST API routes (`lib/api/storage.js`) and the message submission route
55+ * (`lib/api/messages.js`) to attach uploaded files to outgoing draft messages.
56+ */
757class StorageHandler {
58+ /**
59+ * Creates a new StorageHandler instance.
60+ *
61+ * @param {StorageHandlerOptions } options - Configuration options
62+ * @example
63+ * const storageHandler = new StorageHandler({
64+ * database: db.database,
65+ * users: db.users,
66+ * gridfs: db.gridfs,
67+ * loggelf: message => loggelf(message)
68+ * });
69+ */
870 constructor ( options ) {
971 this . database = options . database ;
1072 this . gridfs = options . gridfs || options . database ;
@@ -17,10 +79,42 @@ class StorageHandler {
1779 } ) ;
1880 }
1981
82+ /**
83+ * Stores a file in GridFS for the given user. If neither `filename` nor `contentType` is
84+ * provided, a date-based filename with `.bin` extension and `application/octet-stream` type
85+ * is generated. If only one is provided, the other is inferred using libmime. When
86+ * `options.encoding` is set (e.g. `'base64'`), the content is piped through a base64 decoder
87+ * before being written to GridFS.
88+ *
89+ * @param {import('mongodb').ObjectId } user - Owner user's ObjectId, stored in file metadata
90+ * @param {FileAddOptions } options - File data and metadata
91+ * @returns {Promise<import('mongodb').ObjectId> } The ObjectId of the newly stored GridFS file
92+ * @throws {Error } If the GridFS upload stream emits an error or content writing fails
93+ * @example
94+ * // Raw binary upload
95+ * let fileId = await storageHandler.add(userId, {
96+ * filename: 'report.pdf',
97+ * contentType: 'application/pdf',
98+ * content: pdfBuffer
99+ * });
100+ *
101+ * @example
102+ * // Base64-encoded upload (from REST API)
103+ * let fileId = await storageHandler.add(userId, {
104+ * filename: 'image.png',
105+ * contentType: 'image/png',
106+ * encoding: 'base64',
107+ * content: base64String,
108+ * cid: 'unique-cid@domain '
109+ * });
110+ */
20111 async add ( user , options ) {
21112 let { filename, contentType, cid } = options ;
22113
114+ // Generate a date-based default filename stem (e.g. 'upload-2024-01-15')
23115 let filebase = 'upload-' + new Date ( ) . toISOString ( ) . substr ( 0 , 10 ) ;
116+
117+ // Infer missing filename or contentType from whichever one is provided
24118 if ( ! contentType && ! filename ) {
25119 filename = filebase + '.bin' ;
26120 contentType = 'application/octet-stream' ;
@@ -30,6 +124,7 @@ class StorageHandler {
30124 filename = filebase + '.' + libmime . detectExtension ( contentType ) ;
31125 }
32126
127+ // Build GridFS metadata; user scopes the file for ownership checks in get/delete
33128 let metadata = {
34129 user
35130 } ;
@@ -62,6 +157,7 @@ class StorageHandler {
62157 return ;
63158 }
64159
160+ // Pipe content through a base64 decoder before writing to GridFS
65161 let decoder = new libbase64 . Decoder ( ) ;
66162 decoder . pipe ( store ) ;
67163
@@ -78,7 +174,27 @@ class StorageHandler {
78174 } ) ;
79175 }
80176
177+ /**
178+ * Retrieves a file from GridFS, buffering the entire content into memory. The file must
179+ * belong to the specified user (verified via `metadata.user`). Returns file metadata along
180+ * with the full content as a Buffer.
181+ *
182+ * Note: The entire file is buffered in memory (suitable for files up to the system's 64 MB
183+ * message size limit). For streaming large files to HTTP responses, use
184+ * `storageHandler.gridstore.openDownloadStream(fileId)` directly as the download route does.
185+ *
186+ * @param {import('mongodb').ObjectId } user - Owner user's ObjectId for ownership verification
187+ * @param {import('mongodb').ObjectId } file - GridFS file ObjectId to retrieve
188+ * @returns {Promise<StoredFileData> } File metadata and buffered content
189+ * @throws {Error } Throws with `responseCode: 404` and `code: 'FileNotFound'` if the file
190+ * does not exist or does not belong to the user
191+ * @throws {Error } If the GridFS download stream emits an error
192+ * @example
193+ * let fileData = await storageHandler.get(userId, new ObjectId(fileId));
194+ * // fileData: { id, filename, contentType, size, content: Buffer, cid }
195+ */
81196 async get ( user , file ) {
197+ // Query verifies both file existence and user ownership
82198 let fileData = await this . gridfs . collection ( 'storage.files' ) . findOne ( {
83199 _id : file ,
84200 'metadata.user' : user
@@ -91,6 +207,7 @@ class StorageHandler {
91207 throw err ;
92208 }
93209
210+ // Stream the file from GridFS and buffer all chunks into a single Buffer
94211 return new Promise ( ( resolve , reject ) => {
95212 let stream = this . gridstore . openDownloadStream ( file ) ;
96213 let chunks = [ ] ;
@@ -121,7 +238,22 @@ class StorageHandler {
121238 } ) ;
122239 }
123240
241+ /**
242+ * Deletes a file from GridFS. The file must belong to the specified user (verified via
243+ * `metadata.user`). Removes both the `storage.files` document and all associated
244+ * `storage.chunks` documents. Typically called to clean up uploaded attachments after a
245+ * draft message is submitted for delivery.
246+ *
247+ * @param {import('mongodb').ObjectId } user - Owner user's ObjectId for ownership verification
248+ * @param {import('mongodb').ObjectId } file - GridFS file ObjectId to delete
249+ * @returns {Promise<void> }
250+ * @throws {Error } Throws with `responseCode: 404` and `code: 'FileNotFound'` if the file
251+ * does not exist or does not belong to the user
252+ * @example
253+ * await storageHandler.delete(userId, new ObjectId(fileId));
254+ */
124255 async delete ( user , file ) {
256+ // Query verifies both file existence and user ownership before deletion
125257 let fileData = await this . gridfs . collection ( 'storage.files' ) . findOne ( {
126258 _id : file ,
127259 'metadata.user' : user
@@ -134,6 +266,7 @@ class StorageHandler {
134266 throw err ;
135267 }
136268
269+ // Deletes the files document and all associated chunks from GridFS
137270 return this . gridstore . delete ( file ) ;
138271 }
139272}
0 commit comments