230 lines
7.0 KiB
TypeScript
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
|
||
|
}
|
||
|
}
|