Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-implements as modular server using Express #113

Merged
merged 36 commits into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
af969d4
Splits layout.html into header and footer, so only one call to render…
DougReeder Feb 22, 2024
dc50dc5
Updates dependencies
DougReeder Feb 22, 2024
8083c22
Adds alternate modular server using Express, w/ automated tests
DougReeder Feb 22, 2024
8f77c92
Adds errorPage() and shorten() utilities
DougReeder Feb 22, 2024
3849941
Pulls request listener out of Armadietto so it can share tests w/ mod…
DougReeder Feb 23, 2024
72e8725
modular server: Adds signup route & implements basePath config
DougReeder Feb 22, 2024
e7def12
Modular server: implements Web Finger
DougReeder Feb 26, 2024
c6718d4
modular server: implements OAuth
DougReeder Feb 26, 2024
319e0ff
modular server: implements storage
DougReeder Feb 27, 2024
86cfbd8
Monolithic server removes all listeners on process, when stopped
DougReeder Feb 29, 2024
0d0a798
INCOMPLETE: implements S3 streaming store
DougReeder Feb 29, 2024
ea0c541
WIP: Modular: Re-implements streaming store as handler
DougReeder Mar 10, 2024
d58a95e
removes non-router S3 store
DougReeder Mar 13, 2024
1400b6a
Modular: permissions are stored in JWT
DougReeder Mar 18, 2024
b9363da
Modular: reworks validPathParam to match spec & removes validUserParam
DougReeder Mar 19, 2024
3475290
Modular: Adds hostIdentity as issuer to JWTs
DougReeder Mar 19, 2024
94db9bd
Modular: each streaming store decides what usernames are valid
DougReeder Mar 19, 2024
50d66f1
Modular: Cleans up names to 'storage common' and 'store handler'
DougReeder Mar 20, 2024
196002e
Modular: S3_store_handler implements Optimistic concurrency for writes
DougReeder Mar 20, 2024
626d186
Implements latest version of RS spec - root scope is '*'
DougReeder Apr 1, 2024
a2ad2b9
Fixes bug: GET of non-existent folder now returns empty listing
DougReeder Apr 2, 2024
9b6a50b
S3: old blob versions are deleted after 35 days
DougReeder Apr 2, 2024
166e661
Modular: Username check is now sanity check
DougReeder Apr 2, 2024
d2314fc
Modular: JWTs use HMAC + SHA512 (HS512)
DougReeder Apr 2, 2024
59c1218
Modular: Updates webFinger to match current spec
DougReeder Apr 3, 2024
e960f5d
Extends test timeout in GitHub workflow
DougReeder Apr 3, 2024
1edd821
Modular: removes Morgan in favor of custom logging middleware
DougReeder Apr 3, 2024
641bfe3
Modular: wraps S3 handler factory parameters in object, and fixes wea…
DougReeder Apr 4, 2024
a66cddb
Modular: corrects RS "directory" to "folder" and "file" to "document".
DougReeder Apr 6, 2024
f52db57
Modular: tweaks log levels
DougReeder Apr 7, 2024
8b81bd2
Modular: extracts allocateUserStorage() from createUser() in S3-strea…
DougReeder Apr 8, 2024
b0e971b
Updates dev-conf.json to work with both modular & monolithic servers
DougReeder Apr 8, 2024
3d969b6
S3_store_handler: works w/ S3 implementations w/o versioning
DougReeder Apr 10, 2024
4aeb774
Bugfix: bin/www allows region to be set from environment
DougReeder Apr 12, 2024
d55f8d2
Changes double quotes to single quotes
DougReeder May 2, 2024
081a8ed
URL encoding now allowed to use extended syntax; naming more consistent
DougReeder May 6, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/test-and-lint.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
name: test-and-lint
on:
push:
branches: [ master ]
branches: [ master, modular ]
pull_request:
branches: [ master ]
branches: [ master, modular ]
jobs:
build:
name: node.js
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Support LTS versions based on https://nodejs.org/en/about/releases/
node-version: ['18', '20', '21']
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
run: npm test -- --timeout 10000
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ dev-log
dev-storage
.vscode
.idea

# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

# Data stores
myminio
14 changes: 12 additions & 2 deletions bin/dev-conf.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
{
"host_identity": "example.com",
"basePath": "",
"allow_signup": true,
"storage_path": "./dev-storage",
"lock_timeout_ms": 30000,
"lock_stale_after_ms": 60000,
"cache_views": true,
"http": {
"host": "127.0.0.1",
"host": "0.0.0.0",
"port": 8000
},
"https": {
"enable": true,
"portXXX": 4443,
"key": "../../example.com+5-key.pem",
"cert": "../../example.com+5.pem"
},
"logging": {
"log_dir": "./dev-log",
"stdout": ["debug"],
"stdout": ["info"],
"log_files": ["notice"]
},
"s3": {
"user_name_suffix": null
}
}
221 changes: 221 additions & 0 deletions bin/www
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
#!/usr/bin/env node

const http = require('http');
const fs = require("fs");
const path = require("path");
const {ArgumentParser} = require("argparse");
const appFactory = require('../lib/appFactory');
const {configureLogger, getLogger} = require("../lib/logger");
const S3Handler = require("../lib/routes/S3_store_handler");
const process = require("process");
const https = require("https");

const SSL_CIPHERS = 'ECDHE-RSA-AES256-SHA384:AES256-SHA256:RC4-SHA:RC4:HIGH:!MD5:!aNULL:!EDH:!AESGCM';
const SSL_OPTIONS = require('crypto').constants.SSL_OP_CIPHER_SERVER_PREFERENCE;

/** Command-line arguments and configuration loading */

const args = parseArgs();
let conf = {};

if (args.exampleConf) {
console.info(fs.readFileSync(path.join(__dirname, '/conf.example.json'), 'utf8'));
return -1;
}

try {
conf = JSON.parse(fs.readFileSync(args.conf, 'utf8'));
} catch (err) {
console.error(`Unable to load config file “${args.conf}”:`, err);
return -2;
}

/** Configures application */

configureLogger(conf.logging);

let basePath = conf.basePath || '';
if (basePath && !basePath.startsWith('/')) { basePath = '/' + basePath; }

let jwtSecret = process.env.JWT_SECRET || process.env.S3_SECRET_KEY;
if (!jwtSecret) {
process.env.SECRET = jwtSecret = String(Math.round(Math.random() * Number.MAX_SAFE_INTEGER))
getLogger().warning(`neither JWT_SECRET nor S3_SECRET_KEY were set in the environment. Setting it to “${jwtSecret}”`)
}

const hostIdentity = conf.host_identity?.trim();
if (!hostIdentity) {
getLogger().emerg(`host_identity MUST be set in the configuration file`);
process.exit(1);
}
const userNameSuffix = conf.user_name_suffix ?? '-' + hostIdentity;

if (conf.http?.port) {
start( Object.assign({}, conf.http, process.env.PORT && {port: process.env.PORT}));
}

if (conf.https?.port) {
start(conf.https);
}


function start(network) {
// If the environment variables aren't set, s3handler uses a shared public account on play.min.io,
// to which anyone in the world can read and write!
// It is not entirely compatible with S3Handler.
const s3handler = new S3Handler({endPoint: process.env.S3_ENDPOINT,
accessKey: process.env.S3_ACCESS_KEY, secretKey: process.env.S3_SECRET_KEY, region: process.env.S3_REGION || 'us-east-1',
userNameSuffix});

const app = appFactory({hostIdentity, jwtSecret, account: s3handler, store: s3handler, basePath});

const port = normalizePort( network?.port || '8000');
app.set('port', port);

app.set('forceSSL', Boolean(conf.https?.force));
if (network?.port && conf.https?.port) {
app.set('httpsPort', parseInt(conf.https?.port)); // only for redirecting to HTTPS
}

app.locals.title = "Modular Armadietto";
// Before rendering, `locals.host` should be set to `getHost(req)`
app.locals.host = (network?.host || '0.0.0.0') + (port ? ':' + port : '');
app.locals.signup = conf.allow_signup;

/** Creates HTTP server. */

let server;
if (network.key && network.cert) {
const key = fs.readFileSync(network.key);
const cert = fs.readFileSync(network.cert);
const ca = network.ca ? fs.readFileSync(network.ca) : null;
const sslOptions = {
key,
cert,
ciphers: SSL_CIPHERS,
secureOptions: SSL_OPTIONS,
ca
};

server = https.createServer(sslOptions, app);
} else {
server = http.createServer(app);
}

/** Listens on provided port, on network interfaces specified by 'host'. */

server.listen(port);
server.on('error', onError);
server.on('clientError', clientError);
server.on('listening', onListening);

/** Event listener for HTTP server "error" event. */
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}

const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;

// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
getLogger().crit(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
getLogger().crit(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}

/** Event listener for HTTP server "listening" event. */
function onListening() {
getLogger().notice(`Accepting remoteStorage connections: http${network.key ? 's' : ''}://${app.locals.host}${basePath}/`);
}

/** Adds listeners for shutdown and serious problems */

// These are the happy paths for shutdown.
process.on('SIGINT', stop.bind(this, 'SIGINT'));
process.on('SIGTERM', stop.bind(this, 'SIGTERM'));

function stop(signal) {
getLogger().debug(`${signal} signal received: closing HTTP server`);
server.close(() => {
getLogger().notice(`No longer accepting remoteStorage connections: http${network.key ? 's' : ''}://${app.locals.host}${basePath}/`);
});
}

// Without these listeners, these events would not be logged, only sent to stdout or stderr.
process.on('uncaughtExceptionMonitor', (err, origin) => {
getLogger().crit(`${origin} ${err}`);
});

process.on('warning', (warning) => {
getLogger().warning(`${warning.name} ${warning.message} ${warning.stack}`);
});

process.on('multipleResolves', (type, promise, reason) => {
getLogger().debug(`multipleResolves ${type} “${reason}”`);
});
}

/** parses command-line arguments */
function parseArgs () {
const version = require(path.join(__dirname, '/../package.json')).version;
const parser = new ArgumentParser({
add_help: true,
description: 'NodeJS remoteStorage server / ' + version
});

parser.add_argument('-c', '--conf', {
help: 'Path to configuration'
});

parser.add_argument('-e', '--exampleConf', {
help: 'Print configuration example',
action: 'store_true'
});

return parser.parse_args();
}

/** Normalizes a port into a number, string, or false. */
function normalizePort(val) {
const port = parseInt(val, 10);

if (isNaN(port)) {
// named pipe
return val;
}

if (port >= 0) {
// port number
return port;
}

return false;
}

function clientError (err, socket) {
let status, message;
if (err.code === 'HPE_HEADER_OVERFLOW') {
status = 431;
message = 'Request Header Fields Too Large';
} else {
status = 400;
message = 'Bad Request';
}
getLogger().warning(`${socket.address().address} n/a n/a ${status} ${message} ${err.toString()}`);

if (err.code !== 'ECONNRESET' && socket.writable) {
socket.end(`HTTP/1.1 ${status} ${message}\r\n\r\n`);
}
socket.destroy(err);
}
85 changes: 85 additions & 0 deletions lib/appFactory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
const express = require('express');
const path = require('path');
const { loggingMiddleware } = require('./logger');
const indexRouter = require('./routes/index');
const signupRouter = require('./routes/signup');
const webFingerRouter = require('./routes/webfinger');
const oAuthRouter = require('./routes/oauth');
const storageCommon = require('./routes/storage_common');
const errorPage = require('./util/errorPage');
const helmet = require('helmet');
const shorten = require('./util/shorten');
DougReeder marked this conversation as resolved.
Show resolved Hide resolved

module.exports = function ({ hostIdentity, jwtSecret, account, store, basePath = '' }) {
if (basePath && !basePath.startsWith('/')) { basePath = '/' + basePath; }

const app = express();
app.locals.basePath = basePath;

// view engine setup
app.engine('.html', require('ejs').__express);
app.engine('.xml', require('ejs').__express);
app.set('view engine', 'html');
app.set('views', path.join(__dirname, 'views'));
DougReeder marked this conversation as resolved.
Show resolved Hide resolved

express.static.mime.define({ 'text/javascript': ['js'] });

app.set('account', account);

app.use(loggingMiddleware);
app.use(helmet({
contentSecurityPolicy: {
directives: {
sandbox: ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-same-origin'],
defaultSrc: ['\'self\''],
scriptSrc: ['\'self\''],
scriptSrcAttr: ['\'none\''],
styleSrc: ['\'self\''],
imgSrc: ['\'self\''],
fontSrc: ['\'self\''],
objectSrc: ['\'none\''],
childSrc: ['\'none\''],
connectSrc: ['\'none\''],
baseUri: ['\'self\''],
frameAncestors: ['\'none\''],
formAction: (process.env.NODE_ENV === 'production' ? ['https:'] : ['https:', 'http:']), // allows redirect to any RS app
upgradeInsecureRequests: []
}
}
}));
app.use(express.urlencoded({ extended: false }));
DougReeder marked this conversation as resolved.
Show resolved Hide resolved
app.use(`${basePath}/assets`, express.static(path.join(__dirname, 'assets')));

app.use(`${basePath}/`, indexRouter);

app.use(`${basePath}/signup`, signupRouter);

app.use([`${basePath}/.well-known`, `${basePath}/webfinger`], webFingerRouter);

app.use(`${basePath}/oauth`, oAuthRouter(hostIdentity, jwtSecret));
DougReeder marked this conversation as resolved.
Show resolved Hide resolved
app.use(`${basePath}/storage`, storageCommon(hostIdentity, jwtSecret));
app.use(`${basePath}/storage`, store);
DougReeder marked this conversation as resolved.
Show resolved Hide resolved

// catches paths not handled and returns Not Found
app.use(basePath, function (req, res, next) {
const name = req.path.slice(1);
errorPage(req, res, 404, { title: 'Not Found', message: `“${name}” doesn't exist` });
});
DougReeder marked this conversation as resolved.
Show resolved Hide resolved

// redirect for paths outside the app
app.use(function (req, res, next) {
res.status(308).set('Location', basePath).end();
});

// error handler
app.use(function (err, req, res, _next) {
const message = err?.message || err?.errors?.find(e => e.message).message || err?.cause?.message || 'indescribable error';
errorPage(req, res, err.status || 500, {
title: shorten(message, 30),
message,
error: req.app.get('env') === 'development' ? err : {}
});
});

return app;
};
Loading