From b892eb2fd7d21b4f6014660d176d7478de2c3e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 24 Jul 2020 17:17:15 +0200 Subject: [PATCH 1/8] sketch --- package.json | 1 + src/Uwave.js | 2 ++ src/plugins/assets.js | 74 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 src/plugins/assets.js diff --git a/package.json b/package.json index 26b7081d..1008e5e1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "jsonwebtoken": "^8.5.1", "lodash": "^4.17.15", "make-promises-safe": "^5.1.0", + "mkdirp": "^1.0.4", "minimist": "^1.2.5", "mongoose": "^6.0.0", "ms": "^2.1.2", diff --git a/src/Uwave.js b/src/Uwave.js index 34f0db69..ffb77ec9 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -14,6 +14,7 @@ const { i18n } = require('./locale'); const models = require('./models'); const configStore = require('./plugins/configStore'); +const assets = require('./plugins/assets'); const booth = require('./plugins/booth'); const chat = require('./plugins/chat'); const motd = require('./plugins/motd'); @@ -168,6 +169,7 @@ class UwaveServer extends EventEmitter { boot.use(models); boot.use(migrations); boot.use(configStore); + boot.use(assets); boot.use(passport, { secret: this.options.secret, diff --git a/src/plugins/assets.js b/src/plugins/assets.js new file mode 100644 index 00000000..b7396c6d --- /dev/null +++ b/src/plugins/assets.js @@ -0,0 +1,74 @@ +const path = require('path'); +const fs = require('fs').promises; +const mkdirp = require('mkdirp'); +const serveStatic = require('serve-static'); + +class FSAssets { + constructor(options = {}) { + this.options = { + publicPath: '/assets/', + ...options + }; + + if (!this.options.basedir) { + throw new TypeError('u-wave: fs-assets: missing basedir'); + } + } + + /** + * @type {string} + * @private + */ + get basedir() { + return this.options.basedir; + } + + /** + * @param {string} key + */ + path(key) { + return path.resolve(this.basedir, key); + } + + /** + * @param {string} key + */ + publicPath(key) { + const publicPath = this.options.publicPath.replace(/\/$/, ''); + return `${publicPath}/${key}`; + } + + /** + * @param {string} key + * @param {Buffer|ArrayBuffer|string} content + * @returns {Promise} The actual key used. + */ + async store(key, content) { + const fullPath = this.path(key); + // TODO check if fullPath is "below" basedir + await fs.writeFile(fullPath, content); + return path.relative(this.basedir, fullPath); + } + + /** + * @param {string} key + * @returns {Promise} + */ + async get(key) { + const fullPath = this.path(key); + return fs.readFile(fullPath); + } + + middleware() { + return serveStatic(this.basedir, { + index: false, + redirect: false, + }); + } +} + +module.exports = function assetsPlugin(options) { + return (uw) => { + uw.assets = new FSAssets(options); + }; +} From df2fb677bb6f5ac2026621e9215a45abba6b96fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Fri, 12 Nov 2021 19:24:39 +0100 Subject: [PATCH 2/8] merge --- package.json | 1 - src/Uwave.js | 4 ++++ src/plugins/assets.js | 26 +++++++++++++++++++------- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 1008e5e1..26b7081d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "jsonwebtoken": "^8.5.1", "lodash": "^4.17.15", "make-promises-safe": "^5.1.0", - "mkdirp": "^1.0.4", "minimist": "^1.2.5", "mongoose": "^6.0.0", "ms": "^2.1.2", diff --git a/src/Uwave.js b/src/Uwave.js index ffb77ec9..5d21141b 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -83,6 +83,10 @@ class UwaveServer extends EventEmitter { // @ts-expect-error TS2564 Definitely assigned in a plugin config; + /** @type {import('./plugins/assets').Assets} */ + // @ts-expect-error TS2564 Definitely assigned in a plugin + assets; + /** @type {import('./plugins/history').HistoryRepository} */ // @ts-expect-error TS2564 Definitely assigned in a plugin history; diff --git a/src/plugins/assets.js b/src/plugins/assets.js index b7396c6d..70e34fd0 100644 --- a/src/plugins/assets.js +++ b/src/plugins/assets.js @@ -1,13 +1,19 @@ const path = require('path'); const fs = require('fs').promises; -const mkdirp = require('mkdirp'); const serveStatic = require('serve-static'); class FSAssets { - constructor(options = {}) { + /** + * @typedef {object} FSAssetsOptions + * @prop {string} [publicPath] + * @prop {string} basedir + * + * @param {FSAssetsOptions} options + */ + constructor(options) { this.options = { publicPath: '/assets/', - ...options + ...options, }; if (!this.options.basedir) { @@ -25,6 +31,7 @@ class FSAssets { /** * @param {string} key + * @private */ path(key) { return path.resolve(this.basedir, key); @@ -67,8 +74,13 @@ class FSAssets { } } -module.exports = function assetsPlugin(options) { - return (uw) => { - uw.assets = new FSAssets(options); - }; +/** + * @param {import('../Uwave').Boot} uw + * @param {FSAssetsOptions} options + */ +async function assetsPlugin(uw, options) { + uw.assets = new FSAssets(options); } + +module.exports = assetsPlugin; +module.exports.Assets = FSAssets; From 892debdfd32392bdcd0b7171fd411184bb622882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sat, 5 Sep 2020 17:58:31 +0200 Subject: [PATCH 3/8] Add "basic" runtime configurable options For now, this is just the server name and the canonical public URL. These are important for the announce plugin (and possibly the web client later). --- src/Uwave.js | 8 +++++++- src/schemas/base.json | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/schemas/base.json diff --git a/src/Uwave.js b/src/Uwave.js index 29aaabb6..93239839 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -27,6 +27,8 @@ const waitlist = require('./plugins/waitlist'); const passport = require('./plugins/passport'); const migrations = require('./plugins/migrations'); +const baseSchema = require('./schemas/base.json'); + const DEFAULT_MONGO_URL = 'mongodb://localhost:27017/uwave'; const DEFAULT_REDIS_URL = 'redis://localhost:6379'; @@ -178,9 +180,13 @@ class UwaveServer extends EventEmitter { boot.use(models); boot.use(migrations); + boot.use(configStore); - boot.use(assets); + boot.use(async (uw) => { + uw.config.register(baseSchema['uw:key'], baseSchema); + }); + boot.use(assets); boot.use(passport, { secret: this.options.secret, }); diff --git a/src/schemas/base.json b/src/schemas/base.json new file mode 100644 index 00000000..e302b2d5 --- /dev/null +++ b/src/schemas/base.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://ns.u-wave.net/config/base.json#", + "uw:key": "u-wave:base", + "uw:access": "admin", + "type": "object", + "title": "Basic Settings", + "description": "Server identification et cetera.", + "properties": { + "name": { + "title": "Server Name", + "description": "Your server name, used for public display of the server everywhere.", + "type": "string" + }, + "url": { + "type": "string", + "format": "uri", + "title": "URL", + "description": "Publically accessible URL of this server." + } + }, + "required": ["name", "url"] +} From ae9d0b22400515930a955efdec1e63a478138db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 30 Oct 2022 14:58:29 +0100 Subject: [PATCH 4/8] fix --- src/plugins/assets.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/assets.js b/src/plugins/assets.js index 70e34fd0..19bed9b1 100644 --- a/src/plugins/assets.js +++ b/src/plugins/assets.js @@ -1,3 +1,5 @@ +'use strict'; + const path = require('path'); const fs = require('fs').promises; const serveStatic = require('serve-static'); @@ -47,7 +49,7 @@ class FSAssets { /** * @param {string} key - * @param {Buffer|ArrayBuffer|string} content + * @param {Buffer|string} content * @returns {Promise} The actual key used. */ async store(key, content) { From 19cc2328849b56bf237afa35388a69e7b778aba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 30 Oct 2022 15:32:05 +0100 Subject: [PATCH 5/8] store asset metadata --- package.json | 3 ++ src/Uwave.js | 9 +++- src/models/Asset.js | 41 +++++++++++++++ src/models/index.js | 4 ++ src/plugins/assets.js | 114 ++++++++++++++++++++++++++++++------------ src/schemas/base.json | 6 +++ 6 files changed, 143 insertions(+), 34 deletions(-) create mode 100644 src/models/Asset.js diff --git a/package.json b/package.json index f775a192..6302206b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "escape-string-regexp": "^4.0.0", "explain-error": "^1.0.4", "express": "^4.17.1", + "fs-blob-store": "^6.0.0", "has": "^1.0.3", "helmet": "^6.0.0", "htmlescape": "^1.1.1", @@ -47,6 +48,7 @@ "jsonwebtoken": "^8.5.1", "lodash": "^4.17.15", "make-promises-safe": "^5.1.0", + "mime": "^3.0.0", "minimist": "^1.2.5", "mongoose": "^6.3.8", "ms": "^2.1.2", @@ -85,6 +87,7 @@ "@types/json-merge-patch": "^0.0.8", "@types/jsonwebtoken": "^8.5.1", "@types/lodash": "^4.14.168", + "@types/mime": "^3.0.1", "@types/ms": "^0.7.31", "@types/node": "14", "@types/node-fetch": "^2.5.8", diff --git a/src/Uwave.js b/src/Uwave.js index 93239839..98d2ae04 100644 --- a/src/Uwave.js +++ b/src/Uwave.js @@ -186,7 +186,10 @@ class UwaveServer extends EventEmitter { uw.config.register(baseSchema['uw:key'], baseSchema); }); - boot.use(assets); + boot.use(assets, { + basedir: '/tmp/u-wave-basedir', + }); + boot.use(passport, { secret: this.options.secret, }); @@ -202,6 +205,10 @@ class UwaveServer extends EventEmitter { }); boot.use(SocketServer.plugin); + boot.use(async (uw) => { + uw.express.use('/assets', uw.assets.middleware()); + }); + boot.use(acl); boot.use(chat); boot.use(motd); diff --git a/src/models/Asset.js b/src/models/Asset.js new file mode 100644 index 00000000..5b2db399 --- /dev/null +++ b/src/models/Asset.js @@ -0,0 +1,41 @@ +'use strict'; + +const mongoose = require('mongoose'); + +const { Schema } = mongoose; +const { Types } = mongoose.Schema; + +/** + * @typedef {object} LeanAsset + * @prop {import('mongodb').ObjectId} _id + * @prop {string} name + * @prop {string} path + * @prop {string} category + * @prop {import('mongodb').ObjectId} user + * @prop {Date} createdAt + * @prop {Date} updatedAt + * + * @typedef {mongoose.Document & + * LeanAsset} Asset + */ + +/** + * @type {mongoose.Schema>} + */ +const schema = new Schema({ + name: { type: String, required: true }, + path: { type: String, required: true }, + category: { type: String, required: true }, + user: { + type: Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, +}, { + collection: 'assets', + timestamps: true, + toJSON: { versionKey: false }, +}); + +module.exports = schema; diff --git a/src/models/index.js b/src/models/index.js index a232bfb2..f3f6ea4d 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -9,6 +9,7 @@ const migrationSchema = require('./Migration'); const playlistSchema = require('./Playlist'); const playlistItemSchema = require('./PlaylistItem'); const userSchema = require('./User'); +const assetSchema = require('./Asset'); /** * @typedef {import('./AclRole').AclRole} AclRole @@ -20,6 +21,7 @@ const userSchema = require('./User'); * @typedef {import('./Playlist').Playlist} Playlist * @typedef {import('./PlaylistItem').PlaylistItem} PlaylistItem * @typedef {import('./User').User} User + * @typedef {import('./Asset').Asset} Asset * @typedef {{ * AclRole: import('mongoose').Model, * Authentication: import('mongoose').Model, @@ -30,6 +32,7 @@ const userSchema = require('./User'); * Playlist: import('mongoose').Model, * PlaylistItem: import('mongoose').Model, * User: import('mongoose').Model, + * Asset: import('mongoose').Model, * }} Models */ @@ -47,6 +50,7 @@ async function models(uw) { Playlist: uw.mongo.model('Playlist', playlistSchema), PlaylistItem: uw.mongo.model('PlaylistItem', playlistItemSchema), User: uw.mongo.model('User', userSchema), + Asset: uw.mongo.model('Asset', assetSchema), }; } diff --git a/src/plugins/assets.js b/src/plugins/assets.js index 19bed9b1..c217d57c 100644 --- a/src/plugins/assets.js +++ b/src/plugins/assets.js @@ -1,18 +1,24 @@ 'use strict'; -const path = require('path'); -const fs = require('fs').promises; -const serveStatic = require('serve-static'); +const { finished, pipeline } = require('stream'); +const mime = require('mime'); +const BlobStore = require('fs-blob-store'); class FSAssets { + #uw; + + #store; + /** * @typedef {object} FSAssetsOptions * @prop {string} [publicPath] * @prop {string} basedir * + * @param {import('../Uwave')} uw * @param {FSAssetsOptions} options */ - constructor(options) { + constructor(uw, options) { + this.#uw = uw; this.options = { publicPath: '/assets/', ...options, @@ -21,22 +27,8 @@ class FSAssets { if (!this.options.basedir) { throw new TypeError('u-wave: fs-assets: missing basedir'); } - } - - /** - * @type {string} - * @private - */ - get basedir() { - return this.options.basedir; - } - /** - * @param {string} key - * @private - */ - path(key) { - return path.resolve(this.basedir, key); + this.#store = new BlobStore(this.options.basedir); } /** @@ -48,31 +40,87 @@ class FSAssets { } /** - * @param {string} key + * @typedef {object} StoreOptions + * @prop {string} category + * @prop {import('mongodb').ObjectId} userID + * + * @param {string} name * @param {Buffer|string} content + * @param {StoreOptions} options * @returns {Promise} The actual key used. */ - async store(key, content) { - const fullPath = this.path(key); - // TODO check if fullPath is "below" basedir - await fs.writeFile(fullPath, content); - return path.relative(this.basedir, fullPath); + async store(name, content, { category, userID }) { + const { Asset } = this.#uw.models; + + const key = `${category}/${userID}/${name}`; + const path = await new Promise((resolve, reject) => { + /** @type {import('stream').Writable} */ + const ws = this.#store.createWriteStream({ key }, (err, meta) => { + if (err) { + reject(err); + } else { + resolve(meta.key); + } + }); + ws.end(content); + }); + + try { + await Asset.create({ + name, + path, + category, + user: userID, + }); + } catch (error) { + this.#store.remove({ key: path }, () => { + // ignore + }); + throw error; + } + + return path; } /** * @param {string} key * @returns {Promise} */ - async get(key) { - const fullPath = this.path(key); - return fs.readFile(fullPath); + get(key) { + return new Promise((resolve, reject) => { + /** @type {import('stream').Readable} */ + const rs = this.#store.createReadStream({ key }); + + /** @type {Buffer[]} */ + const chunks = []; + finished(rs, (err) => { + if (err) { + reject(err); + } else { + resolve(Buffer.concat(chunks)); + } + }); + + rs.on('data', (chunk) => { + chunks.push(chunk); + }); + }); } + /** + * @returns {import('express').RequestHandler} + */ middleware() { - return serveStatic(this.basedir, { - index: false, - redirect: false, - }); + // Note this is VERY inefficient! + // Perhaps it will be improved in the future : ) + return (req, res, next) => { + const key = req.url; + const type = mime.getType(key); + if (type) { + res.setHeader('content-type', type); + } + pipeline(this.#store.createReadStream({ key }), res, next); + }; } } @@ -81,7 +129,7 @@ class FSAssets { * @param {FSAssetsOptions} options */ async function assetsPlugin(uw, options) { - uw.assets = new FSAssets(options); + uw.assets = new FSAssets(uw, options); } module.exports = assetsPlugin; diff --git a/src/schemas/base.json b/src/schemas/base.json index e302b2d5..52ab0fde 100644 --- a/src/schemas/base.json +++ b/src/schemas/base.json @@ -17,6 +17,12 @@ "format": "uri", "title": "URL", "description": "Publically accessible URL of this server." + }, + "logo": { + "type": "string", + "format": "image", + "title": "Server logo", + "description": "" } }, "required": ["name", "url"] From 1fc9373a5de8a777d773c343d49c33ec368cd6d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 30 Oct 2022 16:16:17 +0100 Subject: [PATCH 6/8] now: return base config --- src/controllers/now.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/controllers/now.js b/src/controllers/now.js index 6e9eca60..d6ceb497 100644 --- a/src/controllers/now.js +++ b/src/controllers/now.js @@ -104,6 +104,8 @@ async function getState(req) { }); } + const baseConfig = await uw.config.get('u-wave:base'); + const stateShape = { motd, user: user ? serializeUser(user) : null, @@ -138,6 +140,8 @@ async function getState(req) { state.playlists = state.playlists.map(serializePlaylist); } + // TODO dynamically return all the public-facing config. + state.config = { 'u-wave:base': baseConfig }; return state; } From 3f30c15050fb0b9e72116378eba2fc44f5dc11a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 30 Oct 2022 16:16:27 +0100 Subject: [PATCH 7/8] implement file uploads --- package.json | 1 + src/controllers/server.js | 70 +++++++++++++++++++++++++++++++++++++-- src/routes/server.js | 5 +++ src/schemas/base.json | 2 +- 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6302206b..910403aa 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "avvio": "^8.0.0", "bcryptjs": "^2.4.3", "body-parser": "^1.19.0", + "busboy": "^1.6.0", "cookie": "^0.5.0", "cookie-parser": "^1.4.4", "cors": "^2.8.5", diff --git a/src/controllers/server.js b/src/controllers/server.js index 47cb3064..c472acc1 100644 --- a/src/controllers/server.js +++ b/src/controllers/server.js @@ -1,5 +1,8 @@ 'use strict'; +const { finished } = require('stream'); +const { NotFound } = require('http-errors'); +const busboy = require('busboy'); const toItemResponse = require('../utils/toItemResponse'); /** @@ -26,7 +29,7 @@ async function getAllConfig(req) { } /** - * @type {import('../types').AuthenticatedController} + * @type {import('../types').AuthenticatedController<{ key: string }>} */ async function getConfig(req) { const { config } = req.uwave; @@ -44,7 +47,7 @@ async function getConfig(req) { } /** - * @type {import('../types').AuthenticatedController} + * @type {import('../types').AuthenticatedController<{ key: string }>} */ async function updateConfig(req) { const { config } = req.uwave; @@ -58,9 +61,72 @@ async function updateConfig(req) { }); } +function getPath(schema, path) { + const parts = path.split('.'); + let descended = schema; + for (const part of parts) { + descended = descended.properties[part]; + if (!descended) { + return null; + } + } + return descended; +} + +/** + * @type {import('../types').AuthenticatedController<{ key: string }, {}, never>} + */ +async function uploadFile(req) { + const { config, assets } = req.uwave; + const { key } = req.params; + + const combinedSchema = config.getSchema(); + const schema = getPath(combinedSchema, key); + if (!schema) { + throw new NotFound('Config key does not exist'); + } + if (schema.type !== 'string' || schema.format !== 'asset') { + throw new NotFound('Config key is not an asset'); + } + + const [content, meta] = await new Promise((resolve, reject) => { + const bb = busboy({ headers: req.headers }); + bb.on('file', (name, file, info) => { + if (name !== 'file') { + return; + } + + /** @type {Buffer[]} */ + const chunks = []; + file.on('data', (chunk) => { + chunks.push(chunk); + }); + + finished(file, (err) => { + if (err) { + reject(err); + } else { + resolve([Buffer.concat(chunks), info]); + } + }); + }); + req.pipe(bb); + }); + + const path = await assets.store(meta.filename, content, { + category: 'config', + userID: req.user._id, + }); + + return toItemResponse({ path }, { + url: req.fullUrl, + }); +} + module.exports = { getServerTime, getAllConfig, getConfig, updateConfig, + uploadFile, }; diff --git a/src/routes/server.js b/src/routes/server.js index a0ea7230..181e4ba4 100644 --- a/src/routes/server.js +++ b/src/routes/server.js @@ -29,6 +29,11 @@ function serverRoutes() { '/config/:key', protect('admin'), route(controller.updateConfig), + ) + .put( + '/config/asset/:key', + protect('admin'), + route(controller.uploadFile), ); } diff --git a/src/schemas/base.json b/src/schemas/base.json index 52ab0fde..b42112b0 100644 --- a/src/schemas/base.json +++ b/src/schemas/base.json @@ -20,7 +20,7 @@ }, "logo": { "type": "string", - "format": "image", + "format": "asset", "title": "Server logo", "description": "" } From ce412a50a4b4ff2dd90a2983ecc8f3f2c4e7e1f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 30 Oct 2022 16:26:52 +0100 Subject: [PATCH 8/8] types --- package.json | 2 ++ src/controllers/server.js | 5 +++++ src/modules.d.ts | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 src/modules.d.ts diff --git a/package.json b/package.json index 910403aa..bfb35bd7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "node": ">= 14.17.0" }, "dependencies": { + "abstract-blob-store": "^3.3.5", "ajv": "^8.0.5", "ajv-formats": "^2.0.2", "avvio": "^8.0.0", @@ -77,6 +78,7 @@ "devDependencies": { "@tsconfig/node14": "^1.0.1", "@types/bcryptjs": "^2.4.2", + "@types/busboy": "^1.5.0", "@types/cookie": "^0.5.0", "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.10", diff --git a/src/controllers/server.js b/src/controllers/server.js index c472acc1..48edce63 100644 --- a/src/controllers/server.js +++ b/src/controllers/server.js @@ -61,6 +61,11 @@ async function updateConfig(req) { }); } +/** + * @param {import('ajv').SchemaObject} schema + * @param {string} path + * @returns {import('ajv').SchemaObject|null} + */ function getPath(schema, path) { const parts = path.split('.'); let descended = schema; diff --git a/src/modules.d.ts b/src/modules.d.ts new file mode 100644 index 00000000..ad591841 --- /dev/null +++ b/src/modules.d.ts @@ -0,0 +1,20 @@ +declare module 'fs-blob-store' { + import { AbstractBlobStore } from 'abstract-blob-store'; + + type Key = { key: string }; + type CreateCallback = (error: Error | null, metadata: Key) => void; + + class FsBlobStore implements AbstractBlobStore { + constructor(basedir: string) + + createWriteStream(opts: Key, callback: CreateCallback): NodeJS.WriteStream + + createReadStream(opts: Key): NodeJS.ReadStream + + exists(opts: Key, callback: ExistsCallback): void + + remove(opts: Key, callback: RemoveCallback): void + } + + export = FsBlobStore; +}