diff --git a/package.json b/package.json index 44037f19..ea25faab 100644 --- a/package.json +++ b/package.json @@ -26,17 +26,20 @@ "node": ">= 14.17.0" }, "dependencies": { + "abstract-blob-store": "^3.3.5", "ajv": "^8.0.5", "ajv-formats": "^2.0.2", "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", "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 +50,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", @@ -74,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", @@ -85,6 +90,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 8d64bd96..98d2ae04 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'); @@ -26,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'; @@ -83,6 +86,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; @@ -173,7 +180,15 @@ class UwaveServer extends EventEmitter { boot.use(models); boot.use(migrations); + boot.use(configStore); + boot.use(async (uw) => { + uw.config.register(baseSchema['uw:key'], baseSchema); + }); + + boot.use(assets, { + basedir: '/tmp/u-wave-basedir', + }); boot.use(passport, { secret: this.options.secret, @@ -190,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/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; } diff --git a/src/controllers/server.js b/src/controllers/server.js index 47cb3064..48edce63 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,77 @@ 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; + 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/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/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; +} diff --git a/src/plugins/assets.js b/src/plugins/assets.js new file mode 100644 index 00000000..c217d57c --- /dev/null +++ b/src/plugins/assets.js @@ -0,0 +1,136 @@ +'use strict'; + +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(uw, options) { + this.#uw = uw; + this.options = { + publicPath: '/assets/', + ...options, + }; + + if (!this.options.basedir) { + throw new TypeError('u-wave: fs-assets: missing basedir'); + } + + this.#store = new BlobStore(this.options.basedir); + } + + /** + * @param {string} key + */ + publicPath(key) { + const publicPath = this.options.publicPath.replace(/\/$/, ''); + return `${publicPath}/${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(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} + */ + 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() { + // 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); + }; + } +} + +/** + * @param {import('../Uwave').Boot} uw + * @param {FSAssetsOptions} options + */ +async function assetsPlugin(uw, options) { + uw.assets = new FSAssets(uw, options); +} + +module.exports = assetsPlugin; +module.exports.Assets = FSAssets; 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 new file mode 100644 index 00000000..b42112b0 --- /dev/null +++ b/src/schemas/base.json @@ -0,0 +1,29 @@ +{ + "$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." + }, + "logo": { + "type": "string", + "format": "asset", + "title": "Server logo", + "description": "" + } + }, + "required": ["name", "url"] +}