// TODO: use /promises versions import fs from 'node:fs' import fsProm from 'node:fs/promises' import path from 'node:path' import stream from 'node:stream' import type http from 'node:http' import debug from 'debug' import { DataStore, Upload, ERRORS } from '../../utils' import { FileKvStore as FileConfigstore, MemoryKvStore as MemoryConfigstore, RedisKvStore as RedisConfigstore, KvStore as Configstore, } from '../../utils' type Options = { directory: string configstore?: Configstore expirationPeriodInMilliseconds?: number } const MASK = '0777' const IGNORED_MKDIR_ERROR = 'EEXIST' const FILE_DOESNT_EXIST = 'ENOENT' const log = debug('tus-node-server:stores:filestore') export class FileStore extends DataStore { directory: string configstore: Configstore expirationPeriodInMilliseconds: number constructor({ directory, configstore, expirationPeriodInMilliseconds }: Options) { super() this.directory = directory this.configstore = configstore ?? new FileConfigstore(directory) this.expirationPeriodInMilliseconds = expirationPeriodInMilliseconds ?? 0 this.extensions = [ 'creation', 'creation-with-upload', 'creation-defer-length', 'termination', 'expiration', ] // TODO: this async call can not happen in the constructor this.checkOrCreateDirectory() } /** * Ensure the directory exists. */ private checkOrCreateDirectory() { fs.mkdir(this.directory, { mode: MASK, recursive: true }, (error) => { if (error && error.code !== IGNORED_MKDIR_ERROR) { throw error } }) } /** * Create an empty file. */ async create(file: Upload): Promise { const dirs = file.id.split('/').slice(0, -1) const filePath = path.join(this.directory, file.id) await fsProm.mkdir(path.join(this.directory, ...dirs), { recursive: true }) await fsProm.writeFile(filePath, '') await this.configstore.set(file.id, file) file.storage = { type: 'file', path: filePath } return file } read(file_id: string) { return fs.createReadStream(path.join(this.directory, file_id)) } remove(file_id: string): Promise { return new Promise((resolve, reject) => { fs.unlink(`${this.directory}/${file_id}`, (err) => { if (err) { log('[FileStore] delete: Error', err) reject(ERRORS.FILE_NOT_FOUND) return } try { resolve(this.configstore.delete(file_id)) } catch (error) { reject(error) } }) }) } write( readable: http.IncomingMessage | stream.Readable, file_id: string, offset: number ): Promise { const file_path = path.join(this.directory, file_id) const writeable = fs.createWriteStream(file_path, { flags: 'r+', start: offset, }) let bytes_received = 0 const transform = new stream.Transform({ transform(chunk, _, callback) { bytes_received += chunk.length callback(null, chunk) }, }) return new Promise((resolve, reject) => { stream.pipeline(readable, transform, writeable, (err) => { if (err) { log('[FileStore] write: Error', err) return reject(ERRORS.FILE_WRITE_ERROR) } log(`[FileStore] write: ${bytes_received} bytes written to ${file_path}`) offset += bytes_received log(`[FileStore] write: File is now ${offset} bytes`) return resolve(offset) }) }) } async getUpload(id: string): Promise { const file = await this.configstore.get(id) if (!file) { throw ERRORS.FILE_NOT_FOUND } return new Promise((resolve, reject) => { const file_path = `${this.directory}/${id}` fs.stat(file_path, (error, stats) => { if (error && error.code === FILE_DOESNT_EXIST && file) { log( `[FileStore] getUpload: No file found at ${file_path} but db record exists`, file ) return reject(ERRORS.FILE_NO_LONGER_EXISTS) } if (error && error.code === FILE_DOESNT_EXIST) { log(`[FileStore] getUpload: No file found at ${file_path}`) return reject(ERRORS.FILE_NOT_FOUND) } if (error) { return reject(error) } if (stats.isDirectory()) { log(`[FileStore] getUpload: ${file_path} is a directory`) return reject(ERRORS.FILE_NOT_FOUND) } return resolve( new Upload({ id, size: file.size, offset: stats.size, metadata: file.metadata, creation_date: file.creation_date, storage: { type: 'file', path: file_path }, }) ) }) }) } async declareUploadLength(id: string, upload_length: number) { const file = await this.configstore.get(id) if (!file) { throw ERRORS.FILE_NOT_FOUND } file.size = upload_length await this.configstore.set(id, file) } async deleteExpired(): Promise { const now = new Date() const toDelete: Promise[] = [] if (!this.configstore.list) { throw ERRORS.UNSUPPORTED_EXPIRATION_EXTENSION } const uploadKeys = await this.configstore.list() for (const file_id of uploadKeys) { try { const info = await this.configstore.get(file_id) if ( info && 'creation_date' in info && this.getExpiration() > 0 && info.size !== info.offset && info.creation_date ) { const creation = new Date(info.creation_date) const expires = new Date(creation.getTime() + this.getExpiration()) if (now > expires) { toDelete.push(this.remove(file_id)) } } } catch (error) { if (error !== ERRORS.FILE_NO_LONGER_EXISTS) { throw error } } } await Promise.all(toDelete) return toDelete.length } getExpiration(): number { return this.expirationPeriodInMilliseconds } }