216 lines
5.5 KiB
JavaScript
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);
|
|
}
|
|
|
|
}; |