2026-11-05 10:28:10 +09:00

216 lines
5.5 KiB
JavaScript

'use strict';
const fsp = require('fs-promise');
const path = require('path');
const debug = require('debug')('psitransfer:db');
const config = require('../config');
module.exports = class DB {
constructor(uploadDir, store) {
this.initialized = false;
this.db = {};
// fids of one-time files with a running download
this.claims = new Set();
this.uploadDir = uploadDir;
this.store = store;
// delete expired files
const gc = () => {
const now = Date.now();
for (const sid of Object.keys(this.db)) {
for (const f of this.db[sid].slice()) {
if(this.expiresAt(f) <= now) {
debug(`Expired ${sid}++${f.key}`);
this.remove(sid, f.key).catch(e => console.error(e));
}
}
}
};
setInterval(gc, 60 * 1000).unref();
}
/**
* Point in time (ms) a file is deleted. The garbage collector and the
* expiry shown to users share this function so they can not differ.
* One-time downloads that are never fetched expire after maxAge.
*/
expiresAt(file) {
const created = +file.metadata.createdAt;
const maxExpire = created + config.maxAge * 1000;
const retention = +file.metadata.retention;
if(Number.isInteger(retention)) {
return Math.min(maxExpire, created + retention * 1000);
}
return maxExpire;
}
init() {
if(this.initialized) return;
this.initialized = true;
try {
this._sync();
} catch(e) {
this.initialized = false;
e.message = `db initialization failed with error ${e.message}`;
throw e;
}
}
/**
* @private
*/
_sync() {
fsp.ensureDirSync(this.uploadDir);
fsp.readdirSync(this.uploadDir).forEach((sid) => {
// hidden folders (e.g. .settings) are no buckets
if(sid.startsWith('.')) return;
this._import(sid);
});
}
/**
* @private
*/
_import(sid) {
const p = path.resolve(this.uploadDir, sid);
const stat = fsp.statSync(p);
if(!stat.isDirectory()) return;
// synchronous on purpose: the index is complete when init() returns
fsp.readdirSync(p).forEach((key) => {
if(path.extname(key) !== '') return;
try {
const file = this.store.getFilename(`${sid}++${key}`);
const info = fsp.readJsonSync(file + '.json');
const fileStat = fsp.statSync(file);
info.size = fileStat.size;
info.offset = fileStat.size;
this.add(sid, key, info);
} catch(e) {
console.error(e);
}
});
}
add(sid, key, data) {
if(!this.initialized) throw new Error('DB not initialized_');
if(!this.db[sid]) this.db[sid] = [];
data.key = key;
const old = this.db[sid].findIndex(i => i.key === key);
if(old !== -1) {
this.db[sid].splice(old, 1, data);
debug(`Updated ${sid}++${key}`);
} else {
this.db[sid].push(data);
debug(`Added ${sid}++${key}`);
}
}
async remove(sid, key) {
if(!this.initialized) throw new Error('DB not initialized');
debug(`Remove ${sid}++${key}`);
await this.store.del(sid + '++' + key);
// tolerate concurrent removal of the same file
const bucket = this.get(sid);
if(!bucket) return;
const i = bucket.findIndex(item => item.key === key);
if(i === -1) return;
bucket.splice(i, 1);
if(bucket.length === 0) {
delete this.db[sid];
try {
await fsp.rmdir(path.resolve(this.uploadDir, sid));
} catch(e) {
if(e.code !== 'ENOENT' && e.code !== 'ENOTEMPTY') throw e;
}
}
}
/**
* Atomically claim a file for a one-time download.
* Must stay synchronous: no await between check and set.
* @returns {boolean} false if already claimed or not (anymore) in the db
*/
claim(sid, key) {
const fid = `${sid}++${key}`;
if(this.claims.has(fid)) return false;
const bucket = this.get(sid);
if(!bucket || !bucket.some(item => item.key === key)) return false;
this.claims.add(fid);
return true;
}
release(sid, key) {
this.claims.delete(`${sid}++${key}`);
}
isClaimed(sid, key) {
return this.claims.has(`${sid}++${key}`);
}
async updateLastDownload(sid, key) {
debug(`Update last download ${sid}++${key}`);
const data = this.get(sid).find(item => item.key === key);
if(!data) return;
data.metadata.lastDownload = Date.now();
await this.store.update(`${sid}++${key}`, data);
}
async updateMetadata(sid, key, data) {
debug(`Update metadata ${ sid }++${ key }`);
const file = this.get(sid).find(item => item.key === key);
if (!file) return;
file.metadata = { ...file.metadata, ...data };
await this.store.update(`${ sid }++${ key }`, file);
}
async lock(sid) {
const files = this.get(sid);
if(!files) return;
await Promise.all(files.map(async file => {
await this.updateMetadata(sid, file.key, { bucketLocked: true });
}));
}
isLocked(sid) {
const files = this.get(sid);
if(!files) return false;
// "buckedLocked" is the old (misspelled) name, still found in existing data
return files.some(file => file.metadata.bucketLocked || file.metadata.buckedLocked);
}
/** Remove every file of a bucket. */
async removeBucket(sid) {
const files = this.get(sid);
if(!files) return 0;
const keys = files.map(f => f.key);
await Promise.all(keys.map(key => this.remove(sid, key)));
return keys.length;
}
get(sid) {
return this.db[sid];
}
bucketSize(sid) {
const bucket = this.get(sid);
if(!bucket) return 0;
return bucket.reduce((v, file) => v + +file.metadata.uploadLength, 0);
}
};