Skip to content

Commit

Permalink
more WIP: modular server: implements storage
Browse files Browse the repository at this point in the history
  • Loading branch information
DougReeder committed Feb 29, 2024
1 parent 0b39569 commit bc0ab92
Show file tree
Hide file tree
Showing 12 changed files with 573 additions and 83 deletions.
2 changes: 2 additions & 0 deletions lib/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const signupRouter = require('./routes/signup');
const wellKnownRouter = require('./routes/well_known');
const webFingerRouter = require('./routes/webfinger');
const oAuthRouter = require('./routes/oauth');
const streamingStorageRouter = require('./routes/streaming_storage');
const errorPage = require('./util/errorPage');
const helmet = require('helmet');
const shorten = require('./util/shorten');
Expand Down Expand Up @@ -56,6 +57,7 @@ app.use(`${basePath}/.well-known`, wellKnownRouter);
app.use(`${basePath}/webfinger`, webFingerRouter);

app.use(`${basePath}/oauth`, oAuthRouter);
app.use(`${basePath}/storage`, streamingStorageRouter);

// catches 404 and forwards to error handler
app.use(basePath, function (req, res, next) {
Expand Down
2 changes: 1 addition & 1 deletion lib/middleware/redirectToSSL.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module.exports = function redirectToSSL (req, res, next) {
return next();
}

const host = getHost().split(':')[0] + (req.app.get('httpsPort') ? ':' + req.app.get('httpsPort') : '');
const host = getHost(req).split(':')[0] + (req.app.get('httpsPort') ? ':' + req.app.get('httpsPort') : '');
const newUrl = 'https://' + host + req.url;

res.redirect(302, newUrl);
Expand Down
2 changes: 1 addition & 1 deletion lib/middleware/validUser.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const core = require('../stores/core');
const { logRequest } = require('../logger');

/** fails request if data.username doesn't pass core.isValidUsername
/** fails request if req.data.username doesn't pass core.isValidUsername
* TODO: have store validate username
* */
module.exports = function validUser (req, res, next) {
Expand Down
167 changes: 167 additions & 0 deletions lib/routes/streaming_storage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/* eslint-env node */
/* eslint-disable camelcase */
const express = require('express');
const router = express.Router();
const cors = require('cors');
const ourCors = cors({ exposedHeaders: 'ETag', maxAge: 7200 });
const isSecureRequest = require('../util/isSecureRequest');
const { logRequest } = require('../logger');
const { getHost } = require('../util/getHost');
const core = require('../stores/core');
const { pipeline } = require('node:stream/promises');

// const accessStrings = { r: 'Read', rw: 'Read/write' };

router.options('/:username/*',
ourCors
);

/** Express uses GET to generate head */
router.get('/:username/*',
ourCors,
cacheControl,
validUserParam,
validPathParam,
checkToken.bind(null, 'r'),
async function (req, res, next) {
try {
let condition;
if (req.get('If-Match')) {
condition = { name: 'If-Match', ETag: req.get('If-Match') };
} else if (req.get('If-None-Match')) {
condition = { name: 'If-None-Match', ETag: req.get('If-None-Match') };
}
const { readStream, contentLength, contentType, ETag } = await req.app.get('streaming store').get(req.params.username, req.blobPath, condition);
if (!ETag) {
return next(Object.assign(new Error(`No file exists at path “${req.blobPath}”`), { status: 404 }));
}
res.set('Content-Length', contentLength).set('Content-Type', contentType).set('ETag', ETag);
if (readStream) {
res.status(200);
return pipeline(readStream, res);
} else {
res.status(304).end();
}
} catch (err) { // Express v5 will propagate rejected promises automatically.
next(err);
}
}
);

router.put('/:username/*',
ourCors,
noRanges,
validUserParam,
validPathParam,
checkToken.bind(null, 'w'),
async function (req, res, next) {
const type = req.get('Content-Type') || 'application/binary';
let condition;
if (req.get('If-Match')) {
condition = { name: 'If-Match', ETag: req.get('If-Match') };
} else if (req.get('If-None-Match')) {
condition = { name: 'If-None-Match', ETag: req.get('If-None-Match') };
}
const result = await req.app.get('streaming store').put(req.params.username, req.blobPath, type, req, condition);
switch (result) {
case 'UPDATED':
res.status(204).end();
break;
case 'CREATED':
res.status(201).end();
break;
case 'CONFLICT':
res.status(412).end();
break;
default:
next(new Error('result of store is unknown'));
}
}
);

function cacheControl (req, res, next) {
res.set('Cache-Control', 'no-cache');
next();
}

function validUserParam (req, res, next) {
if (core.isValidUsername(req.params.username)) { return next(); }

res.status(400).type('text/plain').end();

logRequest(req, req.params.username, 400, 0, 'invalid user');
}

function validPathParam (req, res, next) {
req.blobPath = req.url.slice(1 + req.params.username.length);
if (core.VALID_PATH.test(req.blobPath)) {
return next();
}

res.status(400).type('text/plain').end();

logRequest(req, req.params.username, 400, 0, 'invalid user');
}

async function checkToken (permission, req, res, next) {
req.token = decodeURIComponent(req.get('Authorization')).split(/\s+/)[1];
// req.token = req.get('Authorization')
// ? decodeURIComponent(req.get('Authorization')).split(/\s+/)[1]
// : req.data.access_token || req.data.oauth_token;

if (req.app.get('forceSSL') && !isSecureRequest(req)) {
await req.app.get('streaming store').revokeAccess(req.params.username, req.token);
return unauthorized(req, res, 400, 'invalid_request', 'HTTPS required');
}

const isDir = /\/$/.test(req.blobPath);
const isPublic = req.blobPath?.startsWith('/public/');
// const isPublic = /^\/public\//.test(req.blobPath);

if (permission === 'r' && isPublic && !isDir) { return next(); }

const permissions = await req.app.get('streaming store').permissions(req.params.username, req.token);
if (!permissions) {
return unauthorized(req, res, 401, 'invalid_token');
}

const blobPathComponents = req.blobPath?.split('/');
const scopeName = blobPathComponents[1] === 'public' ? blobPathComponents[2] : blobPathComponents[1];
const scope = '/' + (scopeName ? scopeName + '/' : '');
const scopePermissions = permissions[scope] || [];
if (scopePermissions.indexOf(permission) < 0) {
return unauthorized(req, res, 403, 'insufficient_scope', `user has permissions '${JSON.stringify(scopePermissions)}' but lacks '${permission}'`);
}
if (permission === 'w' && isDir) {
res.status(400).end();
return logRequest(req, req.params.username, 400, 0, 'can\'t write to directory');
}
next();
}

function noRanges (req, res, next) {
if (req.get('Content-Range')) {
const msg = 'Content-Range not allowed in PUT';
res.status(400).type('text/plain').send(msg);
logRequest(req, req.params.username, 400, msg.length, msg);
} else {
next();
}
}

/**
* Renders error response
* @param {http.ClientRequest} req
* @param {http.ServerResponse} res
* @param {number} status - HTTP status
* @param {string} errMsg - OAUTH code: invalid_request, access_denied, invalid_scope, etc.
* @param {string|Error} [logMsg] - should concisely give details & be distinct from other calls
*/
function unauthorized (req, res, status, errMsg, logMsg) {
const realm = getHost(req);
res.set('WWW-Authenticate', `Bearer realm="${realm}" error="${errMsg}"`);
res.status(status).end();
logRequest(req, req.params.username, status, 0, logMsg || errMsg || '');
}

module.exports = router;
6 changes: 3 additions & 3 deletions lib/routes/webfinger.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
const express = require('express');
const router = express.Router();
const cors = require('cors');
const { getHostBaseUrl } = require('../util/getHost');

router.get('/jrd', handler);
router.get('/xrd', handler);
router.get('/jrd', cors(), handler);
router.get('/xrd', cors(), handler);

function handler (req, res) {
const resource = req.query.resource;
Expand All @@ -19,7 +20,6 @@ function handler (req, res) {
}]
};

res.set('Access-Control-Allow-Origin', '*');
if (req.path.startsWith('/xrd')) {
res.type('application/xrd+xml').render('account.xml', content);
} else {
Expand Down
7 changes: 3 additions & 4 deletions lib/routes/well_known.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
const express = require('express');
const router = express.Router();
const cors = require('cors');
const { getHostBaseUrl } = require('../util/getHost');
const WebFinger = require('../controllers/web_finger');

router.get('/webfinger', handler);
router.get('/host-meta(.json)?', handler);
router.get('/webfinger', cors(), handler);
router.get('/host-meta(.json)?', cors(), handler);

function handler (req, res) {
const resource = req.query.resource;
Expand All @@ -13,8 +14,6 @@ function handler (req, res) {
const useJRD = req.url.startsWith('/webfinger');
const useJSON = useJRD || (req.url.startsWith('/host-meta') && jsonRequested);

res.set('Access-Control-Allow-Origin', '*');

let content;
if (!resource) {
content = {
Expand Down
22 changes: 22 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"main": "./lib/armadietto.js",
"dependencies": {
"argparse": "^2.0.1",
"cors": "^2.8.5",
"ejs": "^3.1.9",
"express": "^4.18.2",
"helmet": "^7.1.0",
Expand Down
Loading

0 comments on commit bc0ab92

Please sign in to comment.