initial commit
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
'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 = {};
|
||||
|
||||
this.uploadDir = uploadDir;
|
||||
this.store = store;
|
||||
|
||||
// delete expired files
|
||||
const gc = () => {
|
||||
let sid,f,expires;
|
||||
for (sid of Object.keys(this.db)) {
|
||||
for (f of this.db[sid]) {
|
||||
// expire on maxAge
|
||||
expires = (+f.metadata.createdAt) + (config.maxAge * 1000) - Date.now();
|
||||
|
||||
// respect one-time downloads
|
||||
if(expires > 0 && Number.isInteger(+f.metadata.retention)) {
|
||||
expires = (+f.metadata.createdAt) + (+f.metadata.retention * 1000) - Date.now();
|
||||
}
|
||||
|
||||
if(expires <= 0) {
|
||||
debug(`Expired ${sid}++${f.key}`);
|
||||
this.remove(sid, f.key).catch(e => console.error(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
setInterval(gc, 60*1000);
|
||||
|
||||
}
|
||||
|
||||
|
||||
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) => {
|
||||
this._import(sid);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_import(sid) {
|
||||
const p = path.resolve(this.uploadDir, sid);
|
||||
const stat = fsp.statSync(p);
|
||||
if(!stat.isDirectory()) return;
|
||||
|
||||
fsp.readdirSync(p).forEach(async (key) => {
|
||||
if(path.extname(key) !== '') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
try {
|
||||
let info = await this.store.info(`${sid}++${key}`);
|
||||
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);
|
||||
const i = this.db[sid].findIndex(item => item.key === key);
|
||||
this.get(sid).splice(i, 1);
|
||||
if(this.get(sid).length === 0) {
|
||||
delete this.db[sid];
|
||||
await fsp.rmdir(path.resolve(this.uploadDir, sid));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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, { buckedLocked: true });
|
||||
}));
|
||||
}
|
||||
|
||||
isLocked(sid) {
|
||||
const files = this.get(sid);
|
||||
if(!files) return false;
|
||||
return files.some(file => file.metadata.buckedLocked);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,448 @@
|
||||
const pug = require('pug');
|
||||
const express = require('express');
|
||||
const morgan = require('morgan');
|
||||
const compression = require('compression');
|
||||
const uuid = require('uuid').v4;
|
||||
const path = require('path');
|
||||
const fs = require("fs");
|
||||
const assert = require('assert');
|
||||
const AES = require("crypto-js/aes");
|
||||
const MD5 = require("crypto-js/md5");
|
||||
const archiver = require('archiver');
|
||||
const tar = require('tar-stream');
|
||||
const config = require('../config');
|
||||
const eventBus = require('./eventBus');
|
||||
const tusboy = require('./tusboy');
|
||||
const Store = require('./store');
|
||||
const tusMeta = require('./tusboy/tus-metadata');
|
||||
const debug = require('debug')('psitransfer:main');
|
||||
|
||||
const pugVars = {
|
||||
baseUrl: config.baseUrl
|
||||
};
|
||||
|
||||
const errorPage = pug.compileFile(path.join(__dirname, '../public/pug/error.pug'), { pretty: true });
|
||||
const adminPage = pug.compileFile(path.join(__dirname, '../public/pug/admin.pug'), { pretty: true });
|
||||
const uploadPage = pug.compileFile(path.join(__dirname, '../public/pug/upload.pug'), { pretty: true });
|
||||
const downloadPage = pug.compileFile(path.join(__dirname, '../public/pug/download.pug'), { pretty: true });
|
||||
|
||||
const store = new Store(config.uploadDir);
|
||||
const Db = require('./db');
|
||||
const { createGzip } = require("zlib");
|
||||
const httpErrors = require("http-errors");
|
||||
const db = new Db(config.uploadDir, store);
|
||||
db.init();
|
||||
const app = express();
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
|
||||
if (config.accessLog) {
|
||||
app.use(morgan(config.accessLog));
|
||||
}
|
||||
|
||||
if (config.trustProxy) {
|
||||
app.set('trust proxy', config.trustProxy);
|
||||
}
|
||||
|
||||
if (config.forceHttps) {
|
||||
app.enable('trust proxy');
|
||||
app.use(function(req, res, next) {
|
||||
if (req.secure) return next();
|
||||
const target = config.forceHttps === 'true' ? 'https://' + req.headers.host : config.forceHttps;
|
||||
res.redirect(target + req.url);
|
||||
});
|
||||
}
|
||||
|
||||
// Static files
|
||||
app.use(`${ config.baseUrl }app`, express.static(path.join(__dirname, '../public/app')));
|
||||
app.use(`${ config.baseUrl }assets`, express.static(path.join(__dirname, '../public/assets')));
|
||||
|
||||
// Resolve language
|
||||
app.use((req, res, next) => {
|
||||
const lang = req.acceptsLanguages(...Object.keys(config.languages)) || config.defaultLanguage;
|
||||
req.translations = config.languages[lang];
|
||||
next();
|
||||
});
|
||||
|
||||
// robots.txt
|
||||
app.get(`${ config.baseUrl }robots.txt`, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/robots.txt'));
|
||||
});
|
||||
|
||||
// Init plugins
|
||||
config.plugins.forEach(pluginName => {
|
||||
require('../plugins/' + pluginName)(eventBus, app, config, db);
|
||||
});
|
||||
|
||||
// Upload App
|
||||
app.get(config.uploadAppPath, (req, res) => {
|
||||
res.send(uploadPage({
|
||||
...pugVars,
|
||||
baseUrl: config.baseUrl,
|
||||
uploadAppPath: config.uploadAppPath,
|
||||
lang: req.translations
|
||||
}));
|
||||
});
|
||||
|
||||
// Return translations
|
||||
app.get(`${ config.baseUrl }lang.json`, (req, res) => {
|
||||
eventBus.emit('getLang', req.translations);
|
||||
res.json(req.translations);
|
||||
});
|
||||
|
||||
// Config
|
||||
app.get(`${ config.baseUrl }config.json`, (req, res) => {
|
||||
// Upload password protection
|
||||
if (config.uploadPass) {
|
||||
const bfTimeout = 200;
|
||||
if (!req.get('x-passwd')) {
|
||||
setTimeout(() => res.status(401).send('Unauthorized'), bfTimeout);
|
||||
return;
|
||||
}
|
||||
if (req.get('x-passwd') !== config.uploadPass) {
|
||||
setTimeout(() => res.status(403).send('Forbidden'), bfTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const frontendConfig = {
|
||||
retentions: config.retentions,
|
||||
defaultRetention: config.defaultRetention,
|
||||
mailTemplate: config.mailTemplate,
|
||||
requireBucketPassword: config.requireBucketPassword,
|
||||
maxFileSize: config.maxFileSize,
|
||||
maxBucketSize: config.maxBucketSize,
|
||||
disableQrCode: config.disableQrCode,
|
||||
};
|
||||
|
||||
eventBus.emit('getFrontendConfig', frontendConfig);
|
||||
|
||||
res.json(frontendConfig);
|
||||
});
|
||||
|
||||
app.get(`${ config.baseUrl }admin`, (req, res, next) => {
|
||||
if (!config.adminPass) return next();
|
||||
res.send(adminPage({ ...pugVars, lang: req.translations }));
|
||||
});
|
||||
|
||||
app.get(`${ config.baseUrl }admin/data.json`, (req, res, next) => {
|
||||
if (!config.adminPass) return next();
|
||||
|
||||
const bfTimeout = 500;
|
||||
if (!req.get('x-passwd')) {
|
||||
// delay answer to make brute force attacks more difficult
|
||||
setTimeout(() => res.status(401).send('Unauthorized'), bfTimeout);
|
||||
return;
|
||||
}
|
||||
if (req.get('x-passwd') !== config.adminPass) {
|
||||
setTimeout(() => res.status(403).send('Forbidden'), bfTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
let result = JSON.parse(JSON.stringify(db.db));
|
||||
Object.values(result).forEach(bucket => {
|
||||
bucket.forEach(file => {
|
||||
if (file.metadata.password) {
|
||||
file.metadata._password = true;
|
||||
delete file.metadata.password;
|
||||
delete file.metadata.key;
|
||||
delete file.key;
|
||||
delete file.url;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => res.json(result), bfTimeout);
|
||||
});
|
||||
|
||||
|
||||
// List files / Download App
|
||||
app.get(`${ config.baseUrl }:sid`, (req, res, next) => {
|
||||
if (req.url.endsWith('.json')) {
|
||||
const sid = req.params.sid.substr(0, req.params.sid.length - 5);
|
||||
if (!db.get(sid)) return res.status(404).end();
|
||||
|
||||
const downloadPassword = req.get('x-download-pass');
|
||||
const items = db.get(sid).map(item => ({
|
||||
...item,
|
||||
url: `${ config.baseUrl }files/${ sid }++${ item.key }`
|
||||
}));
|
||||
|
||||
res.header('Cache-control', 'private, max-age=0, no-cache, no-store, must-revalidate');
|
||||
|
||||
// Currently, every item in a bucket must have the same password
|
||||
if(items.some(item => item.metadata.password && item.metadata.password !== downloadPassword)) {
|
||||
setTimeout(() => res.status(401).send('Unauthorized'), 500);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
items,
|
||||
config: {
|
||||
maxPreviewSize: config.maxPreviewSize
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!db.get(req.params.sid)) return next();
|
||||
res.send(downloadPage({ ...pugVars, lang: req.translations }));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Download files
|
||||
app.get(`${ config.baseUrl }files/:fid`, async (req, res, next) => {
|
||||
// let tusboy handle HEAD requests with Tus Header
|
||||
if (req.method === 'HEAD' && req.get('Tus-Resumable')) return next();
|
||||
|
||||
const sid = req.params.fid.split('++')[0];
|
||||
|
||||
// Download all files
|
||||
if (req.params.fid.match(/^[a-z0-9+]+\.(tar\.gz|zip)$/)) {
|
||||
const format = req.params.fid.endsWith('.zip') ? 'zip' : 'tar.gz';
|
||||
const bucket = db.get(sid);
|
||||
|
||||
if (!bucket) return res.status(404).send(errorPage({
|
||||
...pugVars,
|
||||
error: 'Download bucket not found.',
|
||||
lang: req.translations,
|
||||
uploadAppPath: config.uploadAppPath || config.baseUrl,
|
||||
}));
|
||||
|
||||
if (req.params.fid !== sid + '++' + MD5(bucket.map(f => f.key).join()).toString() + '.' + format) {
|
||||
res.status(404).send(errorPage({
|
||||
...pugVars,
|
||||
error: 'Invalid link',
|
||||
uploadAppPath: config.uploadAppPath || config.baseUrl,
|
||||
lang: req.translations,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
debug(`Download Bucket ${ sid }`);
|
||||
|
||||
const filename = `${ sid }.${ format }`;
|
||||
res.header('Content-Disposition', `attachment; filename="${ filename }"`);
|
||||
|
||||
try {
|
||||
res.on('finish', async () => {
|
||||
bucket.forEach(async info => {
|
||||
if (info.metadata.retention === 'one-time') {
|
||||
await db.remove(info.metadata.sid, info.metadata.key);
|
||||
} else {
|
||||
await db.updateLastDownload(info.metadata.sid, info.metadata.key);
|
||||
}
|
||||
});
|
||||
|
||||
eventBus.emit('archiveDownloaded', {
|
||||
sid,
|
||||
file: filename,
|
||||
metadata: bucket[0].metadata,
|
||||
bucket,
|
||||
url: req.protocol + '://' + req.get('host') + req.originalUrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if(format === 'zip') {
|
||||
res.header('ContentType', 'application/zip');
|
||||
const archive = archiver('zip');
|
||||
archive.on('error', function(err) {
|
||||
console.error(err);
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
for (const info of bucket) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const stream = fs.createReadStream(store.getFilename(info.metadata.sid + '++' + info.key));
|
||||
stream.on('end', resolve);
|
||||
archive.append(stream, { name: info.metadata.name });
|
||||
});
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} else {
|
||||
res.header('ContentType', 'application/x-gtar');
|
||||
const pack = tar.pack();
|
||||
pack.pipe(createGzip()).pipe(res);
|
||||
|
||||
for (const info of bucket) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(store.getFilename(info.metadata.sid + '++' + info.key));
|
||||
const entry = pack.entry({ name: info.metadata.name, size: info.size });
|
||||
readStream.on('error', reject);
|
||||
entry.on('error', reject);
|
||||
entry.on('finish',resolve);
|
||||
readStream.pipe(entry);
|
||||
});
|
||||
}
|
||||
pack.finalize();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Download single file
|
||||
debug(`Download ${ req.params.fid }`);
|
||||
try {
|
||||
const info = await store.info(req.params.fid); // throws on 404
|
||||
res.download(store.getFilename(req.params.fid), info.metadata.name);
|
||||
|
||||
// remove one-time files after download
|
||||
res.on('finish', async () => {
|
||||
if (info.metadata.retention === 'one-time') {
|
||||
await db.remove(info.metadata.sid, info.metadata.key);
|
||||
} else {
|
||||
await db.updateLastDownload(info.metadata.sid, info.metadata.key);
|
||||
}
|
||||
|
||||
eventBus.emit('fileDownloaded', {
|
||||
sid,
|
||||
file: info.metadata.name,
|
||||
metadata: info.metadata,
|
||||
url: req.protocol + '://' + req.get('host') + req.originalUrl,
|
||||
});
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
res.status(404).send(errorPage({
|
||||
...pugVars,
|
||||
error: e.message,
|
||||
lang: req.translations,
|
||||
uploadAppPath: config.uploadAppPath || config.baseUrl,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Upload file
|
||||
app.use(`${ config.uploadAppPath }files`,
|
||||
async function(req, res, next) {
|
||||
// Upload password protection
|
||||
if (config.uploadPass) {
|
||||
const bfTimeout = 500;
|
||||
if (!req.get('x-passwd')) {
|
||||
setTimeout(() => res.status(401).send('Unauthorized'), bfTimeout);
|
||||
return;
|
||||
}
|
||||
if (req.get('x-passwd') !== config.uploadPass) {
|
||||
setTimeout(() => res.status(403).send('Forbidden'), bfTimeout);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === 'GET') return res.status(405).end();
|
||||
|
||||
// Lock bucket by PATCH /files/:sid?lock=yes
|
||||
const fid = req.path.substring(1);
|
||||
if(!fid.includes('++') && req.method === 'PATCH' && req.query.lock) {
|
||||
await db.lock(fid);
|
||||
return res.status(204).end('Bucket locked');
|
||||
}
|
||||
|
||||
if(['POST', 'PATCH'].includes(req.method)) {
|
||||
// Restrict upload to the bucket if it is locked
|
||||
if(!fid.includes('++') && db.isLocked(fid)) {
|
||||
return res.status(400).end('Bucket locked');
|
||||
}
|
||||
try {
|
||||
const info = await store.info(fid);
|
||||
// Restrict upload to the bucket if it is locked
|
||||
if(info.metadata.locked) {
|
||||
return res.status(400).end('Bucket locked');
|
||||
}
|
||||
// Restrict upload to a file which upload completed already
|
||||
if(!info.isPartial) {
|
||||
return res.status(400).end('Upload already completed');
|
||||
}
|
||||
} catch(e) {
|
||||
if(! e instanceof httpErrors.NotFound) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
// validate meta-data
|
||||
// !! tusMeta.encode supports only strings !!
|
||||
const meta = tusMeta.decode(req.get('Upload-Metadata'));
|
||||
|
||||
try {
|
||||
assert(meta.name, 'tus meta prop missing: name');
|
||||
assert(meta.sid, 'tus meta prop missing: sid');
|
||||
assert(meta.retention, 'tus meta prop missing: retention');
|
||||
assert(Object.keys(config.retentions).indexOf(meta.retention) >= 0,
|
||||
`invalid tus meta prop retention. Value ${ meta.retention } not in [${ Object.keys(config.retentions).join(',') }]`);
|
||||
|
||||
const uploadLength = req.get('Upload-Length');
|
||||
assert(uploadLength, 'missing Upload-Length header');
|
||||
|
||||
// Restrict creating new files for locked buckets
|
||||
if(db.isLocked(meta.sid)) {
|
||||
return res.status(400).end('Bucket locked');
|
||||
}
|
||||
|
||||
meta.uploadLength = uploadLength;
|
||||
meta.key = uuid();
|
||||
meta.createdAt = Date.now().toString();
|
||||
|
||||
// limit file and bucket size
|
||||
if (config.maxFileSize && config.maxFileSize < +uploadLength) {
|
||||
return res
|
||||
.status(413)
|
||||
.json({ message: `File exceeds maximum upload size ${ config.maxFileSize }.` });
|
||||
} else if (config.maxBucketSize && db.bucketSize(meta.sid) + +uploadLength > config.maxBucketSize) {
|
||||
return res
|
||||
.status(413)
|
||||
.json({ message: `Bucket exceeds maximum upload size ${ config.maxBucketSize }.` });
|
||||
}
|
||||
|
||||
// store changed metadata for tusboy
|
||||
req.headers['upload-metadata'] = tusMeta.encode(meta);
|
||||
// for tusboy getKey()
|
||||
req.FID = meta.sid + '++' + meta.key;
|
||||
|
||||
db.add(meta.sid, meta.key, {
|
||||
"isPartial": true,
|
||||
metadata: meta
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error(e);
|
||||
return res.status(400).end(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
|
||||
// let tusboy handle the upload
|
||||
tusboy(store, {
|
||||
getKey: req => req.FID,
|
||||
maxUploadLength: config.maxFileSize || Infinity,
|
||||
afterComplete: (req, upload, fid) => {
|
||||
db.add(upload.metadata.sid, upload.metadata.key, upload);
|
||||
debug(`Completed upload ${ fid }, size=${ upload.size } name=${ upload.metadata.name }`);
|
||||
eventBus.emit('fileUploaded', upload);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.url === config.baseUrl) {
|
||||
return res.redirect(config.uploadAppPath);
|
||||
}
|
||||
|
||||
res.status(404).send(errorPage({
|
||||
...pugVars,
|
||||
error: 'Download bucket not found.',
|
||||
uploadAppPath: config.uploadAppPath || config.baseUrl,
|
||||
lang: req.translations
|
||||
}));
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,8 @@
|
||||
const EventEmitter = require('events');
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
emitter.on('uncaughtException', function(err) {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
module.exports = emitter;
|
||||
@@ -0,0 +1,123 @@
|
||||
'use strict';
|
||||
const fsp = require('fs-promise');
|
||||
const path = require('path');
|
||||
const Transform = require('stream').Transform;
|
||||
const debug = require('debug')('psitransfer:store');
|
||||
const httpErrors = require('http-errors');
|
||||
|
||||
class StreamLen extends Transform {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.bytes = 0;
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, cb) {
|
||||
this.bytes += chunk.length;
|
||||
this.push(chunk);
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO ???: make tus-store complaint: https://github.com/blockai/abstract-tus-store
|
||||
class Store {
|
||||
|
||||
constructor(targetDir) {
|
||||
this.dir = path.normalize(targetDir);
|
||||
}
|
||||
|
||||
|
||||
getFilename(fid) {
|
||||
let p = path.resolve(this.dir, fid.replace('++', '/'));
|
||||
if(!p.startsWith(this.dir)) {
|
||||
throw new Error('file name not in jail path. aborting');
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
async create(fid, opts = {}) {
|
||||
debug(`New File ${this.getFilename(fid)}`);
|
||||
await fsp.ensureDir(path.dirname(this.getFilename(fid)));
|
||||
await fsp.writeJson(this.getFilename(fid) + '.json', Object.assign(opts, {
|
||||
isPartial: true
|
||||
}));
|
||||
return {uploadId: fid};
|
||||
}
|
||||
|
||||
|
||||
async update(fid, data) {
|
||||
debug(`Update File ${this.getFilename(fid)}`);
|
||||
await fsp.writeJson(this.getFilename(fid) + '.json', data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
async info(fid) {
|
||||
try {
|
||||
const info = await fsp.readJson(this.getFilename(fid) + '.json');
|
||||
const stat = await fsp.stat(this.getFilename(fid));
|
||||
info.size = stat.size;
|
||||
info.offset = stat.size;
|
||||
debug(`Fetched Fileinfo ${this.getFilename(fid)}`);
|
||||
return info;
|
||||
} catch(e) {
|
||||
if(e.code === 'ENOENT') {
|
||||
throw httpErrors.NotFound();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async append(fid, readStream, offset) {
|
||||
debug(`Append Data to ${this.getFilename(fid)}`);
|
||||
const uploadSize = new StreamLen();
|
||||
const ws = fsp.createWriteStream(this.getFilename(fid), {flags: 'a', start: offset});
|
||||
|
||||
const ret = new Promise((resolve, reject) => {
|
||||
ws.on('finish', async() => {
|
||||
const info = await this.info(fid);
|
||||
if(info.size >= info.uploadLength) delete info.isPartial;
|
||||
await fsp.writeJson(this.getFilename(fid) + '.json', info);
|
||||
debug(`Finished appending Data to ${this.getFilename(fid)}`);
|
||||
return resolve({ offset: info.offset, upload: info });
|
||||
});
|
||||
ws.on('error', reject);
|
||||
});
|
||||
|
||||
readStream.pipe(uploadSize).pipe(ws);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
createReadStream(fid, start, end, cb) {
|
||||
debug(`Create ReadStream for ${this.getFilename(fid)}`);
|
||||
this.info(fid).then(info => {
|
||||
let contentLength = info.size;
|
||||
if(start > 0) {
|
||||
if(!end) end = info.size - 1;
|
||||
contentLength = end - start + 1
|
||||
}
|
||||
if(cb) cb({ contentLength, metadata: info.metadata, info });
|
||||
});
|
||||
return fsp.createReadStream(this.getFilename(fid), {start, end});
|
||||
}
|
||||
|
||||
|
||||
async del(fid) {
|
||||
debug(`Delete ${this.getFilename(fid)}`);
|
||||
try {
|
||||
await fsp.unlink(this.getFilename(fid) + '.json');
|
||||
} catch (e) {
|
||||
if(e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
try {
|
||||
await fsp.unlink(this.getFilename(fid));
|
||||
}
|
||||
catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Store;
|
||||
@@ -0,0 +1,35 @@
|
||||
const HEADERS = [
|
||||
'Content-Type',
|
||||
'Location',
|
||||
'Tus-Extension',
|
||||
'Tus-Max-Size',
|
||||
'Tus-Resumable',
|
||||
'Tus-Version',
|
||||
'Upload-Defer-Length',
|
||||
'Upload-Length',
|
||||
'Upload-Metadata',
|
||||
'Upload-Offset',
|
||||
'X-HTTP-Method-Override',
|
||||
'X-Requested-With',
|
||||
];
|
||||
|
||||
const MAX_AGE = 86400;
|
||||
const ALLOWED_HEADERS = HEADERS;
|
||||
const EXPOSED_HEADERS = HEADERS;
|
||||
|
||||
const ALLOWED_METHODS = [
|
||||
'POST',
|
||||
'HEAD',
|
||||
'PATCH',
|
||||
'OPTIONS',
|
||||
];
|
||||
|
||||
const TUS_VERSION = '1.0.0';
|
||||
|
||||
module.exports = {
|
||||
MAX_AGE,
|
||||
ALLOWED_HEADERS,
|
||||
EXPOSED_HEADERS,
|
||||
ALLOWED_METHODS,
|
||||
TUS_VERSION,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const createError = require('http-errors');
|
||||
|
||||
const tusResumableHeaderMissing = () => createError(412, 'Tus-Resumable Required')
|
||||
|
||||
const invalidHeaders = (headers) => (
|
||||
createError(412, 'Precondition Failed', {
|
||||
details: { headers },
|
||||
})
|
||||
)
|
||||
|
||||
const invalidHeader = (header, val) => (
|
||||
createError(412, 'Precondition Failed', {
|
||||
details: { headers: [[header, val]] },
|
||||
})
|
||||
)
|
||||
|
||||
const missingHeader = (header) => invalidHeader(header)
|
||||
|
||||
const entityTooLarge = (msg, props) => createError(413, msg, props)
|
||||
|
||||
const preconditionError = (msg, props) => createError(412, msg, props)
|
||||
|
||||
const offsetMismatch = (actual, expected) => (
|
||||
createError(409, `Offset mismatch, got ${actual} but expected ${expected}`)
|
||||
)
|
||||
|
||||
// For store implementations:
|
||||
const unknownResource = (key) => (
|
||||
createError(404, `Unknown resource: ${key}`)
|
||||
)
|
||||
|
||||
const concurrentWrite = () => (
|
||||
createError(409, 'Concurrent write detected')
|
||||
)
|
||||
|
||||
const uploadLengthAlreadySet = () => (
|
||||
createError(409, 'Upload length is already set')
|
||||
)
|
||||
|
||||
module.exports = {
|
||||
tusResumableHeaderMissing,
|
||||
invalidHeaders,
|
||||
invalidHeader,
|
||||
missingHeader,
|
||||
entityTooLarge,
|
||||
preconditionError,
|
||||
offsetMismatch,
|
||||
unknownResource,
|
||||
concurrentWrite,
|
||||
uploadLengthAlreadySet,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = (err, req, res, next) => {
|
||||
if (res.headersSent) return next(err)
|
||||
if (!err.status) {
|
||||
// console.error(err)
|
||||
return next(err)
|
||||
}
|
||||
if (!err.expose) return next(err)
|
||||
if (!(err instanceof Error)) return next(err)
|
||||
|
||||
res.statusCode = err.status
|
||||
res.json({
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
details: err.details,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// HEAD
|
||||
//
|
||||
// The Server MUST always include the Upload-Offset header in the
|
||||
// response for a HEAD request, even if the offset is 0, or the upload
|
||||
// is already considered completed. If the size of the upload is known,
|
||||
// the Server MUST include the Upload-Length header in the response. If
|
||||
// the resource is not found, the Server SHOULD return either the 404
|
||||
// Not Found, 410 Gone or 403 Forbidden status without the Upload-Offset
|
||||
// header.
|
||||
//
|
||||
// The Server MUST prevent the client and/or proxies from caching the
|
||||
// response by adding the Cache-Control: no-store header to the
|
||||
// response.
|
||||
//
|
||||
|
||||
const encodeMetadata = require('../tus-metadata').encode;
|
||||
|
||||
module.exports = (store) => (
|
||||
async (req, res) => {
|
||||
res.set('Cache-Control', 'no-store')
|
||||
const { uploadId } = req.params
|
||||
const upload = await store.info(uploadId)
|
||||
// The Server MUST always include the Upload-Offset header in the
|
||||
// response for a HEAD request, even if the offset is 0, or the upload
|
||||
// is already considered completed.
|
||||
res.set('Upload-Offset', upload.offset)
|
||||
|
||||
if (!('uploadLength' in upload)) {
|
||||
// As long as the length of the upload is not known, the Server
|
||||
// MUST set Upload-Defer-Length: 1 in all responses to HEAD requests.
|
||||
res.set('Upload-Defer-Length', '1')
|
||||
} else {
|
||||
// If the size of the upload is known, the Server MUST include
|
||||
// the Upload-Length header in the response
|
||||
res.set('Upload-Length', upload.uploadLength)
|
||||
}
|
||||
// If an upload contains additional metadata, responses to HEAD
|
||||
// requests MUST include the Upload-Metadata header and its value as
|
||||
// specified by the Client during the creation.
|
||||
const encodedMetadata = encodeMetadata(upload.metadata)
|
||||
if (encodedMetadata !== '') {
|
||||
res.set('Upload-Metadata', encodedMetadata)
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
const cors = require('cors')
|
||||
const { ALLOWED_HEADERS, ALLOWED_METHODS, MAX_AGE } = require ('../constants');
|
||||
|
||||
const tusExtension = (extensions = []) => {
|
||||
if (!extensions.length) {
|
||||
return (req, res, next) => next()
|
||||
}
|
||||
return (req, res, next) => {
|
||||
res.set('Tus-Extension', extensions.join(','))
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
const corsPreflight = (extraMethods) => cors({
|
||||
methods: [...ALLOWED_METHODS, ...extraMethods],
|
||||
allowedHeaders: ALLOWED_HEADERS,
|
||||
maxAge: MAX_AGE,
|
||||
})
|
||||
|
||||
module.exports = (extensions, extraMethods = []) => ([
|
||||
tusExtension(extensions),
|
||||
corsPreflight(extraMethods),
|
||||
])
|
||||
@@ -0,0 +1,82 @@
|
||||
// PATCH
|
||||
//
|
||||
// The Server SHOULD accept PATCH requests against any upload URL and
|
||||
// apply the bytes contained in the message at the given offset
|
||||
// specified by the Upload-Offset header. All PATCH requests MUST use
|
||||
// Content-Type: application/offset+octet-stream.
|
||||
//
|
||||
// The Upload-Offset header’s value MUST be equal to the current offset
|
||||
// of the resource. In order to achieve parallel upload the
|
||||
// Concatenation extension MAY be used. If the offsets do not match, the
|
||||
// Server MUST respond with the 409 Conflict status without modifying
|
||||
// the upload resource.
|
||||
//
|
||||
// The Client SHOULD send all the remaining bytes of an upload in a
|
||||
// single PATCH request, but MAY also use multiple small requests
|
||||
// successively for scenarios where this is desirable, for example, if
|
||||
// the Checksum extension is used.
|
||||
//
|
||||
// The Server MUST acknowledge successful PATCH requests with the 204 No
|
||||
// Content status. It MUST include the Upload-Offset header containing
|
||||
// the new offset. The new offset MUST be the sum of the offset before
|
||||
// the PATCH request and the number of bytes received and processed or
|
||||
// stored during the current PATCH request.
|
||||
//
|
||||
// Both, Client and Server, SHOULD attempt to detect and handle network
|
||||
// errors predictably. They MAY do so by checking for read/write socket
|
||||
// errors, as well as setting read/write timeouts. A timeout SHOULD be
|
||||
// handled by closing the underlying connection.
|
||||
//
|
||||
// The Server SHOULD always attempt to store as much of the received
|
||||
// data as possible.
|
||||
const storeErrors = require('../store/errors');
|
||||
const errors = require('../errors');
|
||||
|
||||
module.exports = (store, {
|
||||
onComplete,
|
||||
beforeComplete = async () => {},
|
||||
afterComplete = async () => {},
|
||||
}) => (async (req, res) => {
|
||||
const after = onComplete || afterComplete
|
||||
|
||||
// The request MUST include a Upload-Offset header
|
||||
if (!('uploadOffset' in req.tus)) {
|
||||
throw errors.missingHeader('upload-offset')
|
||||
}
|
||||
|
||||
// The request MUST include a Content-Type header
|
||||
if (typeof req.get('content-type') === 'undefined') {
|
||||
throw errors.missingHeader('content-type')
|
||||
}
|
||||
|
||||
// All PATCH requests MUST use Content-Type: application/offset+octet-stream
|
||||
if (req.get('content-type') !== 'application/offset+octet-stream') {
|
||||
throw errors.invalidHeader('content-type', req.get('content-type'))
|
||||
}
|
||||
|
||||
const uploadId = req.params.uploadId
|
||||
|
||||
try {
|
||||
const {
|
||||
offset,
|
||||
upload,
|
||||
} = await store.append(uploadId, req, req.tus.uploadOffset, {
|
||||
beforeComplete: (...args) => beforeComplete(req, ...args),
|
||||
uploadLength: req.tus.uploadLength,
|
||||
})
|
||||
if (upload && upload.uploadLength === offset) {
|
||||
await after(req, upload, uploadId)
|
||||
}
|
||||
// It MUST include the Upload-Offset header containing the new offset.
|
||||
res.set('Upload-Offset', offset)
|
||||
// The Server MUST acknowledge successful PATCH requests
|
||||
// with the 204 No Content status.
|
||||
res.status(204)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
if (err instanceof storeErrors.OffsetMismatch) {
|
||||
throw errors.offsetMismatch()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// Creation
|
||||
//
|
||||
// The Client and the Server SHOULD implement the upload creation
|
||||
// extension. If the Server supports this extension, it MUST add creation
|
||||
// to the Tus-Extension header.
|
||||
//
|
||||
// Headers
|
||||
//
|
||||
// Upload-Defer-Length
|
||||
//
|
||||
// The Upload-Defer-Length request and response header indicates that the
|
||||
// size of the upload is not known currently and will be transferred later.
|
||||
// Its value MUST be 1. If the length of an upload is not deferred, this
|
||||
// header MUST be omitted.
|
||||
//
|
||||
// Upload-Metadata
|
||||
//
|
||||
// The Upload-Metadata request and response header MUST consist of one or
|
||||
// more comma-separated key-value pairs. The key and value MUST be
|
||||
// separated by a space. The key MUST NOT contain spaces and commas and
|
||||
// MUST NOT be empty. The key SHOULD be ASCII encoded and the value MUST be
|
||||
// Base64 encoded. All keys MUST be unique.
|
||||
//
|
||||
// Requests
|
||||
//
|
||||
// POST
|
||||
//
|
||||
// The Client MUST send a POST request against a known upload creation URL
|
||||
// to request a new upload resource. The request MUST include one of the
|
||||
// following headers:
|
||||
//
|
||||
// a) Upload-Length to indicate the size of an entire upload in bytes.
|
||||
//
|
||||
// b) Upload-Defer-Length: 1 if upload size is not known at the time. Once
|
||||
// it is known the Client MUST set the Upload-Length header in the next
|
||||
// PATCH request. Once set the length MUST NOT be changed. As long as the
|
||||
// length of the upload is not known, the Server MUST set
|
||||
// Upload-Defer-Length: 1 in all responses to HEAD requests.
|
||||
//
|
||||
// If the Server supports deferring length, it MUST add
|
||||
// creation-defer-length to the Tus-Extension header.
|
||||
//
|
||||
// The Client MAY supply the Upload-Metadata header to add additional
|
||||
// metadata to the upload creation request. The Server MAY decide to ignore
|
||||
// or use this information to further process the request or to reject it.
|
||||
// If an upload contains additional metadata, responses to HEAD requests
|
||||
// MUST include the Upload-Metadata header and its value as specified by
|
||||
// the Client during the creation.
|
||||
//
|
||||
// If the length of the upload exceeds the maximum, which MAY be specified
|
||||
// using the Tus-Max-Size header, the Server MUST respond with the 413
|
||||
// Request Entity Too Large status.
|
||||
//
|
||||
// The Server MUST acknowledge a successful upload creation with the 201
|
||||
// Created status. The Server MUST set the Location header to the URL of
|
||||
// the created resource. This URL MAY be absolute or relative.
|
||||
//
|
||||
// The Client MUST perform the actual upload using the core protocol.
|
||||
|
||||
const errors = require('../errors');
|
||||
|
||||
module.exports = (store, {
|
||||
getKey = () => {},
|
||||
maxUploadLength = Infinity,
|
||||
}) => async (req, res, next) => {
|
||||
const tus = req.tus
|
||||
const { uploadLength } = tus
|
||||
const defer = !!tus.uploadDeferLength
|
||||
|
||||
if (!defer && !('uploadLength' in tus)) {
|
||||
return next(errors.preconditionError(
|
||||
'Missing Upload-Length header'
|
||||
))
|
||||
}
|
||||
// TODO: make sure store supports defer length
|
||||
if (defer && !store.supportsDeferredLength) {
|
||||
return next(errors.preconditionError(
|
||||
'Store does not support creation-defer-length extension'
|
||||
))
|
||||
}
|
||||
if (defer && ('uploadLength' in tus)) {
|
||||
return next(errors.preconditionError(
|
||||
'Choose one of Upload-Length OR Upload-Defer-Length'
|
||||
))
|
||||
}
|
||||
|
||||
if ('uploadLength' in tus && uploadLength > maxUploadLength) {
|
||||
res.set('Tus-Max-Size', maxUploadLength)
|
||||
return next(errors.entityTooLarge(
|
||||
`Upload-length (${uploadLength}) exceeds max upload size (${maxUploadLength})`
|
||||
), { maxUploadLength, uploadLength })
|
||||
}
|
||||
|
||||
const key = await getKey(req)
|
||||
const metadata = req.tus.uploadMetadata
|
||||
const { uploadId } = await store.create(key, {
|
||||
uploadLength,
|
||||
metadata,
|
||||
})
|
||||
res.status(201)
|
||||
let basePath = req.baseUrl
|
||||
if (basePath[basePath.length - 1] !== '/') basePath += '/'
|
||||
res.set('Location', `${basePath}${uploadId}`)
|
||||
res.end()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const { Router } = require('express');
|
||||
const tusHeaderParser = require('./tus-header-parser');
|
||||
const methodOverride = require('method-override');
|
||||
const cors = require('cors');
|
||||
|
||||
const constants = require('./constants');
|
||||
const errors = require('./errors');
|
||||
|
||||
const errorHandler = require ('./handlers/error');
|
||||
const options = require ('./handlers/options');
|
||||
const head = require ('./handlers/head');
|
||||
const patch = require ('./handlers/patch');
|
||||
const post = require ('./handlers/post');
|
||||
|
||||
/**
|
||||
* Returns a route handler for Express that calls the passed in function
|
||||
* @param {Function} fn The asynchronous the route needs to call
|
||||
* @return {Promise}
|
||||
*/
|
||||
function w(fn) {
|
||||
if (fn.length <= 3) {
|
||||
return function(req, res, next) {
|
||||
return fn(req, res, next).catch(next);
|
||||
};
|
||||
} else {
|
||||
return function(err, req, res, next) {
|
||||
return fn(err, req, res, next).catch(next);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const detectExtensions = (store) => {
|
||||
return [
|
||||
'create',
|
||||
() => { if (store.del) return 'delete' },
|
||||
].filter(ele => typeof ele === 'string')
|
||||
}
|
||||
|
||||
const versionSupported = (/* versionStr */) => true
|
||||
|
||||
const setTusResumableHeader = (req, res, next) => {
|
||||
res.set('Tus-Resumable', constants.TUS_VERSION)
|
||||
next()
|
||||
}
|
||||
|
||||
// The Tus-Resumable header MUST be included in every request and
|
||||
// response except for OPTIONS requests. The value MUST be the version
|
||||
// of the protocol used by the Client or the Server.
|
||||
const assertTusResumableHeader = (req, res, next) => {
|
||||
if (!('tusResumable' in req.tus)) {
|
||||
res.set('Tus-Version', constants.TUS_VERSION)
|
||||
return next(errors.preconditionError('Tus-Resumable header missing'))
|
||||
} else if (!versionSupported(req.tus.tusResumable)) {
|
||||
res.set('Tus-Version', constants.TUS_VERSION)
|
||||
return next(errors.preconditionError('Tus-Resumable version not supported'))
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
const setCorsHeaders = cors({
|
||||
origin: true,
|
||||
exposedHeaders: constants.EXPOSED_HEADERS,
|
||||
})
|
||||
|
||||
module.exports = (store, opts = {}) => {
|
||||
const { handleErrors = true } = opts
|
||||
const extensions = detectExtensions(store)
|
||||
|
||||
const nextRouter = new Router({ mergeParams: true })
|
||||
.use(assertTusResumableHeader)
|
||||
.post('/', w(post(store, opts)))
|
||||
.head('/:uploadId', w(head(store, opts)))
|
||||
.patch('/:uploadId', w(patch(store, opts)))
|
||||
|
||||
if (handleErrors) nextRouter.use(errorHandler)
|
||||
|
||||
const router = new Router({ mergeParams: true })
|
||||
router
|
||||
.use(methodOverride('X-HTTP-Method-Override'))
|
||||
.use(tusHeaderParser())
|
||||
.options('*', options(extensions, opts.extraCorsMethods))
|
||||
.use(setCorsHeaders)
|
||||
.use(setTusResumableHeader)
|
||||
.use((req, res, next) => {
|
||||
if (req.method === 'GET') return next()
|
||||
return nextRouter(req, res, next)
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
class StoreError extends Error {
|
||||
}
|
||||
|
||||
class UploadNotFound extends StoreError {
|
||||
}
|
||||
|
||||
class OffsetMismatch extends StoreError {
|
||||
}
|
||||
|
||||
class KeyNotFound extends StoreError {
|
||||
}
|
||||
|
||||
class UploadLocked extends StoreError {
|
||||
}
|
||||
|
||||
module.exports = StoreError;
|
||||
module.exports.UploadNotFound = UploadNotFound;
|
||||
module.exports.OffsetMismatch = OffsetMismatch;
|
||||
module.exports.KeyNotFound = KeyNotFound;
|
||||
module.exports.UploadLocked = UploadLocked;
|
||||
@@ -0,0 +1,111 @@
|
||||
const toObject = require('to-object-reducer');
|
||||
const decodeMetadata = require('./tus-metadata').decode;
|
||||
|
||||
// TODO: Buffer.from silently ignores invalid bytes :(
|
||||
// https://github.com/nodejs/node/issues/8569
|
||||
const base64decode = str => Buffer.from(str, 'base64')
|
||||
|
||||
const nonNegativeInteger = (v) => {
|
||||
// need this because parseInt parses floats too
|
||||
if (v !== '0' && !v.match(/^[1-9][0-9]*$/)) return
|
||||
const num = parseInt(v, 10)
|
||||
if (isNaN(num)) return
|
||||
if (num >= 0) return num
|
||||
}
|
||||
|
||||
/*
|
||||
const enum = (possibleVals = []) => (v) => {
|
||||
if (!possibleVals.includes(v)) return
|
||||
return v
|
||||
}
|
||||
*/
|
||||
|
||||
const id = v => v
|
||||
const parsers = {
|
||||
'tus-resumable': id,
|
||||
// The Upload-Offset request and response header indicates a byte offset
|
||||
// within a resource. The value MUST be a non-negative integer.
|
||||
'upload-offset': nonNegativeInteger,
|
||||
// The Upload-Defer-Length request and response header indicates that the
|
||||
// size of the upload is not known currently and will be transferred later.
|
||||
// Its value MUST be 1. If the length of an upload is not deferred, this
|
||||
// header MUST be omitted.
|
||||
'upload-defer-length': v => {
|
||||
const num = nonNegativeInteger(v)
|
||||
if (num === 1) return 1
|
||||
},
|
||||
// The Upload-Length request and response header indicates the size of the
|
||||
// entire upload in bytes. The value MUST be a non-negative integer.
|
||||
'upload-length': nonNegativeInteger,
|
||||
'upload-metadata': decodeMetadata,
|
||||
// The Upload-Checksum request header contains information about the checksum
|
||||
// of the current body payload. The header MUST consist of the name of the
|
||||
// used checksum algorithm and the Base64 encoded checksum separated by a
|
||||
// space.
|
||||
'upload-checksum': v => {
|
||||
const parts = v.split(' ')
|
||||
if (parts.length !== 2) return
|
||||
const algorithm = parts[0]
|
||||
const checksum = base64decode(parts[1])
|
||||
if (!checksum.length) return
|
||||
return { algorithm, checksum }
|
||||
},
|
||||
// The Upload-Concat request and response header MUST be set in both partial
|
||||
// and final upload creation requests. It indicates whether the upload is
|
||||
// either a partial or final upload. If the upload is a partial one, the
|
||||
// header value MUST be partial. In the case of a final upload, its value
|
||||
// MUST be final followed by a semicolon and a space-separated list of
|
||||
// partial upload URLs that will be concatenated. The partial uploads URLs
|
||||
// MAY be absolute or relative and MUST NOT contain spaces as defined in RFC
|
||||
// 3986.
|
||||
'upload-concat': v => {
|
||||
if (v === 'partial') return { partial: true }
|
||||
const parts = v.split(';')
|
||||
if (!parts.length) return
|
||||
const firstPart = parts.shift()
|
||||
if (firstPart !== 'final') return
|
||||
const urls = parts.join(';').split(' ')
|
||||
if (urls.some(url => url === '')) return
|
||||
// validate urls? nah...
|
||||
return { final: true, urls }
|
||||
},
|
||||
}
|
||||
|
||||
const knownHeaders = Object.keys(parsers)
|
||||
|
||||
const parseHeader = (tmpHeaderName, str) => {
|
||||
const headerName = tmpHeaderName.toLowerCase()
|
||||
if (!knownHeaders.includes(headerName)) return
|
||||
return parsers[headerName](str)
|
||||
}
|
||||
|
||||
// Pre-computed for speed
|
||||
const camelCase = {
|
||||
'tus-resumable': 'tusResumable',
|
||||
'upload-offset': 'uploadOffset',
|
||||
'upload-defer-length': 'uploadDeferLength',
|
||||
'upload-length': 'uploadLength',
|
||||
'upload-metadata': 'uploadMetadata',
|
||||
'upload-checksum': 'uploadChecksum',
|
||||
'upload-concat': 'uploadConcat',
|
||||
}
|
||||
|
||||
const parseHeaders = (headers) => (
|
||||
Object
|
||||
.keys(headers)
|
||||
.filter(headerName => knownHeaders.includes(headerName))
|
||||
.map(headerName => ([
|
||||
camelCase[headerName],
|
||||
parsers[headerName](headers[headerName]),
|
||||
]))
|
||||
.reduce(toObject, {})
|
||||
)
|
||||
|
||||
module.exports = () => (req, res, next) => {
|
||||
req.tus = parseHeaders(req.headers)
|
||||
next()
|
||||
};
|
||||
|
||||
module.exports.knownHeaders = knownHeaders;
|
||||
module.exports.parseHeader = parseHeader;
|
||||
module.exports.parseHeaders = parseHeaders;
|
||||
@@ -0,0 +1,24 @@
|
||||
const toObject = require('to-object-reducer');
|
||||
|
||||
// return object
|
||||
const base64decode = str => Buffer.from(str, 'base64').toString('utf8')
|
||||
const base64encode = str => Buffer.from(str, 'utf8').toString('base64')
|
||||
|
||||
module.exports.decode = (str = '') => {
|
||||
const keypairs = str
|
||||
.split(',')
|
||||
// .map(s => s.trim())
|
||||
const keyvals = keypairs
|
||||
.map(s => s.split(' '))
|
||||
.filter(arr => arr.length === 2)
|
||||
.filter(([key]) => key !== '')
|
||||
.map(([key, val]) => [key, base64decode(val)])
|
||||
return keyvals.reduce(toObject, {})
|
||||
}
|
||||
|
||||
module.exports.encode = (obj = {}) => Object
|
||||
.keys(obj)
|
||||
.map(key => [key, obj[key]])
|
||||
.map(([key, val]) => [key, base64encode(val)])
|
||||
.map(kvArr => kvArr.join(' '))
|
||||
.join(',')
|
||||
Reference in New Issue
Block a user