staff_data/packages/tus/src/store/file-store/index.ts

230 lines
7.0 KiB
TypeScript

// 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<Upload> {
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<void> {
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<number> {
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<Upload> {
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<number> {
const now = new Date()
const toDelete: Promise<void>[] = []
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
}
}