From 3f0994be9d56f5e4c1fb0e3f2b4886a55197e05b Mon Sep 17 00:00:00 2001 From: Nick Downing Date: Sat, 22 Jan 2022 17:36:48 +1100 Subject: [PATCH] Implement a command-line interface to the running webserver, and a way to get/set the globals object (so it will be in the database, not /_config/globals.json) --- .gitignore | 1 + _config/Problem.mjs | 17 +++ _config/Session.mjs | 185 ++++++++++++++++++++++++ _config/get.mjs | 37 +++++ _config/n.sh | 2 + _config/set.mjs | 46 ++++++ _config/sign_in.mjs | 70 +++++++++ _config/sign_out.mjs | 23 +++ _lib/page.jst | 1 - _lib/post_request.jst | 2 +- api/account/change_details/get.json.jst | 4 +- api/account/change_details/set.json.jst | 4 +- api/account/change_password.json.jst | 2 + api/globals/get.json.jst | 44 ++++++ api/globals/set.json.jst | 54 +++++++ 15 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 _config/Problem.mjs create mode 100644 _config/Session.mjs create mode 100755 _config/get.mjs create mode 100755 _config/n.sh create mode 100755 _config/set.mjs create mode 100755 _config/sign_in.mjs create mode 100755 _config/sign_out.mjs create mode 100644 api/globals/get.json.jst create mode 100644 api/globals/set.json.jst diff --git a/.gitignore b/.gitignore index 66a3e20..a2c50bd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .*.sass .*.scss .*.svg +/_config/session.json /_zet/site.* /database.logjson /database.logjson.* diff --git a/_config/Problem.mjs b/_config/Problem.mjs new file mode 100644 index 0000000..58b06e3 --- /dev/null +++ b/_config/Problem.mjs @@ -0,0 +1,17 @@ +let Problem = class { + constructor(title, detail, status) { + this.title = title + this.detail = detail + this.status = status + } + + static from(error) { + return ( + error instanceof Problem ? + error : + new Problem('Bad request', error.message, 400) + ) + } +} + +export default Problem diff --git a/_config/Session.mjs b/_config/Session.mjs new file mode 100644 index 0000000..b8ef0a3 --- /dev/null +++ b/_config/Session.mjs @@ -0,0 +1,185 @@ +import assert from 'assert' +import fsPromises from 'fs/promises' +import http from 'http' +import https from 'https' +import stream_buffers from 'stream-buffers' +import Problem from './Problem.mjs' +import XDate from 'xdate' + +class Session { + constructor(path) { + this.path = path + this.persistent = {url: 'http://localhost:8080', cookies: {}} + } + + async load() { + let buffer + try { + buffer = await fsPromises.readFile(this.path) + } + catch (error) { + if (error.code !== 'ENOENT') + throw error + + try { + await fsPromises.rename(this.path + '.new', this.path) + } + catch (error1) { + throw error // ENOENT will make more sense to the user + } + + buffer = await fsPromises.readFile(this.path) + } + this.persistent = JSON.parse(buffer.toString('utf-8')) + } + + async save() { + await fsPromises.writeFile( + this.path + '.new', + Buffer.from(JSON.stringify(this.persistent, null, 2) + '\n', 'utf-8') + ) + try { + await fsPromises.unlink(this.path) + } + catch (error) { + if (error.code !== 'ENOENT') + throw error + } + await fsPromises.rename(this.path + '.new', this.path) + } + + async api_call(endpoint, ...args) { + let url = new URL(this.persistent.url + endpoint) + + let http_or_https, default_port + if (url.protocol === 'https:') { + http_or_https = https + default_port = 443 + } + else { + http_or_https = http + default_port = 80 + } + + let buffer = Buffer.from( + JSON.stringify(args) + '\n', + 'utf-8' + ) + + let headers = { + 'Content-Type': 'application/json', + 'Content-Length': buffer.length + } + + let cookies = [] + let now = XDate.now() + for (let i in this.persistent.cookies) { + let cookie = this.persistent.cookies[i] + + let expires = cookie.expires + if (expires !== undefined && now >= new XDate(expires).getTime()) + continue + + let path = cookie.path + if (path !== undefined) { + if (path.slice(-1) != '/') + path += '/' + if (url.pathname.slice(0, path.length) !== path) + continue + } + + cookies.push(`${i}=${cookie.value}`) + } + if (cookies.length) + headers.Cookie = cookies.join(', ') + + let response = await new Promise( + (resolve, reject) => { + let request = http_or_https.request( + { + hostname: url.hostname, + port: url.port.length === 0 ? default_port : parseInt(url.port), + path: url.pathname, + method: 'POST', + headers: headers + }, + response => {resolve(response)} + ) + request.on('error', error => {reject(error)}) + request.write(buffer) + request.end() + } + ) + + let response_cookies = response.headers['set-cookie'] || [] + for (let i = 0; i < response_cookies.length; ++i) { + let fields = response_cookies[i].split(';') + assert(fields.length >= 1) + + let j = fields[0].indexOf('=') + assert(j >= 0) + let name = fields[0].slice(0, j).trim() + let value = fields[0].slice(j + 1).trim() + if ( + value.length >= 2 && + value.charAt(0) === 0x22 && + value.charAt(value.length - 1) === 0x22 + ) + value = value.slice(1, -1) + let attrs = {value} + + for (/*let*/ j = 1; j < fields.length; ++j) { + let k = fields[j].indexOf('=') + let attr/*, value*/ + if (k >= 0) { + attr = fields[j].slice(0, k).trim() + value = fields[j].slice(k + 1).trim() + } + else { + attr = fields[j].trim() + if (attr.length === 0) + continue + value = null + } + attr = attr.toLowerCase() + assert( + attr === 'expires' || + attr === 'max-age' || + attr === 'domain' || + attr === 'path' || + attr === 'secure' || + attr === 'httponly' || + attr === 'samesite' + ) // cannot be 'value' + attrs[attr] = value + } + + let max_age = attrs['max-age'] + if (max_age !== undefined) { + let expires = new XDate() + expires.addSeconds(parseInt(max_age)) + attrs['expires'] = expires.toUTCString() + delete attrs['max-age'] + } + + this.persistent.cookies[name] = attrs + } + + let write_stream = new stream_buffers.WritableStreamBuffer() + let data = new Promise( + (resolve, reject) => { + write_stream. + on('finish', () => {resolve(write_stream.getContents())}). + on('error', () => {reject()}) + } + ) + response.pipe(write_stream) + + let result = JSON.parse((await data).toString('utf-8')) + if (response.statusCode < 200 || response.statusCode >= 300) + throw new Problem(result.title, result.detail, result.status) + return result + } +} + +export default Session diff --git a/_config/get.mjs b/_config/get.mjs new file mode 100755 index 0000000..1a0e95d --- /dev/null +++ b/_config/get.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import stream_buffers from 'stream-buffers' +import Problem from './Problem.mjs' +import Session from './Session.mjs' + +if (process.argv.length < 3) { + console.log( + `usage: ${process.argv[0]} ${process.argv[1]} endpoint [args]` + ) + process.exit(1) +} +let endpoint = process.argv[2] +let args = [] +for (let i = 3; i < process.argv.length; ++i) + args.push(JSON.parse(process.argv[i])) + +let session = new Session('session.json') +await session.load() + +let result +try { + result = await session.api_call(endpoint, ...args) +} +catch (error) { + let problem = Problem.from(error) + console.error('problem:') + console.error(' title:', problem.title) + console.error(' detail:', problem.detail) + console.error(' status:', problem.status) + + await session.save() + process.exit(1) +} +process.stdout.write(JSON.stringify(result, null, 2) + '\n') + +await session.save() diff --git a/_config/n.sh b/_config/n.sh new file mode 100755 index 0000000..197f13a --- /dev/null +++ b/_config/n.sh @@ -0,0 +1,2 @@ +#!/bin/sh +./set.mjs /api/globals/set.json { + write_stream. + on('finish', () => {resolve(write_stream.getContents())}). + on('error', () => {reject()}) + } +) +process.stdin.pipe(write_stream) +args.push(JSON.parse((await data).toString('utf-8'))) + +let session = new Session('session.json') +await session.load() + +try { + await session.api_call(endpoint, ...args) +} +catch (error) { + let problem = Problem.from(error) + console.error('problem:') + console.error(' title:', problem.title) + console.error(' detail:', problem.detail) + console.error(' status:', problem.status) + + await session.save() + process.exit(1) +} + +await session.save() diff --git a/_config/sign_in.mjs b/_config/sign_in.mjs new file mode 100755 index 0000000..bc1ef48 --- /dev/null +++ b/_config/sign_in.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +import readline from 'readline' +import util from 'util' +import Problem from './Problem.mjs' +import Session from './Session.mjs' + +let session = new Session('session.json') +try { + await session.load() +} +catch (error) { + if (error.code !== 'ENOENT') + throw error + + console.log('warning: session.json not found, using defaults') +} + +let rl = readline.createInterface( + { + input: process.stdin, + output: process.stdout, + terminal: false + } +) +let rl_question = util.promisify(rl.question).bind(rl) + +let url = await rl_question(`url: [${session.persistent.url || ''}] `) +if (url.length) + session.persistent.url = url +else if (session.persistent.url === undefined) { + console.log('url is required') + process.exit(1) +} + +let username = await rl_question(`username: [${session.persistent.username || ''}] `) +if (username.length) + session.persistent.username = username +else if (session.persistent.username === undefined) { + console.log('username is required') + process.exit(1) +} + +let password = await rl_question('password: ') +if (password.length < 8) { + console.log('password is minimum 8 characters') + process.exit(1) +} + +rl.close() + +try { + await session.api_call( + '/api/account/sign_in.json', + session.persistent.username, + password + ) +} +catch (error) { + let problem = Problem.from(error) + console.error('problem:') + console.error(' title:', problem.title) + console.error(' detail:', problem.detail) + console.error(' status:', problem.status) + + await session.save() + process.exit(1) +} + +await session.save() diff --git a/_config/sign_out.mjs b/_config/sign_out.mjs new file mode 100755 index 0000000..ad21215 --- /dev/null +++ b/_config/sign_out.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import Problem from './Problem.mjs' +import Session from './Session.mjs' + +let session = new Session('session.json') +await session.load() + +try { + await session.api_call('/api/account/sign_out.json') +} +catch (error) { + let problem = Problem.from(error) + console.error('problem:') + console.error(' title:', problem.title) + console.error(' detail:', problem.detail) + console.error(' status:', problem.status) + + await session.save() + process.exit(1) +} + +await session.save() diff --git a/_lib/page.jst b/_lib/page.jst index 058ae05..fecae38 100644 --- a/_lib/page.jst +++ b/_lib/page.jst @@ -4,7 +4,6 @@ let crypto = require('crypto') return async (env, head, body, scripts) => { let favicons = await env.site.get_min_html('/_favicon/favicons.html') - let globals = await env.site.get_json('/_config/globals.json') let session_cookie = await _require('/_lib/session_cookie.jst') // initialize env.session_key, set cookie in env.response diff --git a/_lib/post_request.jst b/_lib/post_request.jst index 79a930e..9df0ecd 100644 --- a/_lib/post_request.jst +++ b/_lib/post_request.jst @@ -23,7 +23,7 @@ return async (env, handler) => { } ) env.request.pipe(write_stream) - let args = JSON.parse((await data).toString()) + let args = JSON.parse((await data).toString('utf-8')) console.log('endpoint', env.parsed_url.path, 'args', JSON.stringify(args)) result = await handler(...args) diff --git a/api/account/change_details/get.json.jst b/api/account/change_details/get.json.jst index 0bde260..efc4107 100644 --- a/api/account/change_details/get.json.jst +++ b/api/account/change_details/get.json.jst @@ -14,7 +14,9 @@ return async env => { let transaction = await env.site.database.Transaction() try { // initialize env.session_key, set cookie in env.response - let session = await session_cookie(env, transaction) + await session_cookie(env, transaction) + if (env.signed_in_as === null) + throw new Problem('Unauthorized', 'Please sign in first.', 401) let account = await ( await ( diff --git a/api/account/change_details/set.json.jst b/api/account/change_details/set.json.jst index dac0b61..5e61e5e 100644 --- a/api/account/change_details/set.json.jst +++ b/api/account/change_details/set.json.jst @@ -26,7 +26,9 @@ return async env => { let transaction = await env.site.database.Transaction() try { // initialize env.session_key, set cookie in env.response - let session = await session_cookie(env, transaction) + await session_cookie(env, transaction) + if (env.signed_in_as === null) + throw new Problem('Unauthorized', 'Please sign in first.', 401) let account = await ( await ( diff --git a/api/account/change_password.json.jst b/api/account/change_password.json.jst index b36bb0d..ce95e13 100644 --- a/api/account/change_password.json.jst +++ b/api/account/change_password.json.jst @@ -26,6 +26,8 @@ return async env => { try { // initialize env.session_key, set cookie in env.response await session_cookie(env, transaction) + if (env.signed_in_as === null) + throw new Problem('Unauthorized', 'Please sign in first.', 401) let account = await ( await ( diff --git a/api/globals/get.json.jst b/api/globals/get.json.jst new file mode 100644 index 0000000..22069a0 --- /dev/null +++ b/api/globals/get.json.jst @@ -0,0 +1,44 @@ +let logjson = (await import('@ndcode/logjson')).default +let XDate = require('xdate') + +return async env => { + let post_request = await _require('/_lib/post_request.jst') + let session_cookie = await _require('/_lib/session_cookie.jst') + let Problem = await _require('/_lib/Problem.jst') + + await post_request( + // env + env, + // handler + async () => { + let transaction = await env.site.database.Transaction() + try { + // initialize env.session_key, set cookie in env.response + await session_cookie(env, transaction) + if (env.signed_in_as === null) + throw new Problem('Unauthorized', 'Please sign in first.', 401) + + let root = await transaction.get({}) + let account = await ( + await root.get('accounts', {}) + ).get(env.signed_in_as) + if ( + !await logjson.logjson_to_json( + await account.get('administrator') + ) + ) + throw new Problem('Unauthorized', 'Not administrator.', 401) + + globals = await logjson.logjson_to_json( + await root.get('globals', {}) + ) + await transaction.commit() + return globals + } + catch (error) { + transaction.rollback() + throw error + } + } + ) +} diff --git a/api/globals/set.json.jst b/api/globals/set.json.jst new file mode 100644 index 0000000..5f986aa --- /dev/null +++ b/api/globals/set.json.jst @@ -0,0 +1,54 @@ +let logjson = (await import('@ndcode/logjson')).default +let XDate = require('xdate') + +return async env => { + let post_request = await _require('/_lib/post_request.jst') + let session_cookie = await _require('/_lib/session_cookie.jst') + let Problem = await _require('/_lib/Problem.jst') + + await post_request( + // env + env, + // handler + async globals => { + // coerce and/or validate + globals = { + site_url: globals.site_url.slice(0, 1024), + site_title: globals.site_title.slice(0, 1024), + contact_from: globals.contact_from.slice(0, 1024), + contact_to: globals.contact_to.slice(0, 1024), + feedback_from: globals.feedback_from.slice(0, 1024), + feedback_to: globals.feedback_to.slice(0, 1024), + noreply_from: globals.noreply_from.slice(0, 1024), + noreply_signature: globals.noreply_signature.slice(0, 1024), + copyright: globals.copyright.slice(0, 1024) + } + + let transaction = await env.site.database.Transaction() + try { + // initialize env.session_key, set cookie in env.response + await session_cookie(env, transaction) + if (env.signed_in_as === null) + throw new Problem('Unauthorized', 'Please sign in first.', 401) + + let root = await transaction.get({}) + let account = await ( + await root.get('accounts', {}) + ).get(env.signed_in_as) + if ( + !await logjson.logjson_to_json( + await account.get('administrator') + ) + ) + throw new Problem('Unauthorized', 'Not administrator.', 401) + + root.set('globals', transaction.json_to_logjson(globals)) + await transaction.commit() + } + catch (error) { + transaction.rollback() + throw error + } + } + ) +} -- 2.34.1