Implement a command-line interface to the running webserver, and a way to get/set...
authorNick Downing <nick@ndcode.org>
Sat, 22 Jan 2022 06:36:48 +0000 (17:36 +1100)
committerNick Downing <nick@ndcode.org>
Sun, 23 Jan 2022 02:21:55 +0000 (13:21 +1100)
15 files changed:
.gitignore
_config/Problem.mjs [new file with mode: 0644]
_config/Session.mjs [new file with mode: 0644]
_config/get.mjs [new file with mode: 0755]
_config/n.sh [new file with mode: 0755]
_config/set.mjs [new file with mode: 0755]
_config/sign_in.mjs [new file with mode: 0755]
_config/sign_out.mjs [new file with mode: 0755]
_lib/page.jst
_lib/post_request.jst
api/account/change_details/get.json.jst
api/account/change_details/set.json.jst
api/account/change_password.json.jst
api/globals/get.json.jst [new file with mode: 0644]
api/globals/set.json.jst [new file with mode: 0644]

index 66a3e20..a2c50bd 100644 (file)
@@ -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 (file)
index 0000000..58b06e3
--- /dev/null
@@ -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 (file)
index 0000000..b8ef0a3
--- /dev/null
@@ -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 (executable)
index 0000000..1a0e95d
--- /dev/null
@@ -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 (executable)
index 0000000..197f13a
--- /dev/null
@@ -0,0 +1,2 @@
+#!/bin/sh
+./set.mjs /api/globals/set.json <globals.json
diff --git a/_config/set.mjs b/_config/set.mjs
new file mode 100755 (executable)
index 0000000..cf8c4dc
--- /dev/null
@@ -0,0 +1,46 @@
+#!/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 write_stream = new stream_buffers.WritableStreamBuffer()
+let data = new Promise(
+  (resolve, reject) => {
+    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 (executable)
index 0000000..bc1ef48
--- /dev/null
@@ -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 (executable)
index 0000000..ad21215
--- /dev/null
@@ -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()
index 058ae05..fecae38 100644 (file)
@@ -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
index 79a930e..9df0ecd 100644 (file)
@@ -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)
index 0bde260..efc4107 100644 (file)
@@ -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 (
index dac0b61..5e61e5e 100644 (file)
@@ -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 (
index b36bb0d..ce95e13 100644 (file)
@@ -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 (file)
index 0000000..22069a0
--- /dev/null
@@ -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 (file)
index 0000000..5f986aa
--- /dev/null
@@ -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
+      }
+    }
+  )
+}