first commit

This commit is contained in:
Hugh Rundle 2023-01-04 11:17:41 +11:00
commit 40f6ade790
15 changed files with 2573 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.env
*.db

40
cli.js Normal file
View file

@ -0,0 +1,40 @@
const { argv } = require('node:process');
const { addUser, resetPassword } = require('./utilities')
function userCreate(username, directory) {
if (!username || !directory) {
console.log(' User-create requires a username and directory.')
console.log(' e.g. `user-create sam gemini.example.com`.\n')
} else {
addUser(username, directory, password => {
console.log(` User ${username} created with password ${password}`)
console.log(' Keep it secret, keep it safe.\n')
})
}
}
function passwordReset(username) {
if (!username) {
console.log(' User-create requires a username.')
console.log(' e.g. `password-reset sam`.\n')
} else {
resetPassword(username, null, password => {
console.log(` Password for ${username} is now ${password}`)
console.log(' Keep it secret, keep it safe.\n')
})
}
}
// TODO: delete user from database, (and user's files?)
switch (argv[2]) {
case 'user-create':
userCreate(argv[3], argv[4]);
break;
case 'password-reset':
passwordReset(argv[3]);
break;
default:
console.log(' Command not recognised.')
console.log(' Possible commands are `user-create` or `password-reset`\n')
}

1858
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "soyuz-web",
"version": "0.1.0",
"description": "Web app for publishing Gemini posts",
"main": "server.js",
"scripts": {
"password-reset": "node cli.js password-reset",
"user-create": "node cli.js user-create"
},
"author": "Hugh Rundle <hugh@hughrundle.net> (https://www.hughrundle.net/)",
"license": "AGPL-3.0-or-later",
"keywords": ["gemini"],
"repository": {
"type": "git",
"url": "https://github.com/hughrun/soyuz-web.git"
},
"dependencies": {
"better-sqlite3": "^8.0.1",
"better-sqlite3-session-store": "^0.1.0",
"body-parser": "^1.20.1",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-session": "^1.17.3",
"sprightly": "^1.0.3"
}
}

136
server.js Normal file
View file

@ -0,0 +1,136 @@
require('dotenv').config()
const express = require('express')
const { getLatestPost, getNow, publishNewPost, requireLoggedIn, resetPassword, saveFile, updatePost, verifyUser, getSavedFile } = require('./utilities')
const bodyParser = require('body-parser')
const Database = require('better-sqlite3');
const session = require('express-session')
const sprightly = require('sprightly');
const SqliteStore = require("better-sqlite3-session-store")(session)
// configure Express
const app = express()
const PORT = process.env.SOYUZ_PORT
app.use(bodyParser.urlencoded({ extended: false }))
app.use(express.static('static'))
// configure session store
db = new Database('soyuz.db', {});
app.use(
session({
store: new SqliteStore({
client: db,
expired: {
clear: true,
intervalMs: 900000 //ms = 15min
}
}),
saveUninitialized: false,
secret: process.env.SOYUZ_SESSION_SECRET,
resave: false,
cookie: {
sameSite: 'strict',
maxAge: 1.21e+9 // 2 weeks
},
name: 'soyuz-web'
})
)
// configure template engine
app.engine('spy', sprightly);
app.set('views', './templates');
app.set('view engine', 'spy');
/**
* ROUTES
*/
// GET
app.get('/', requireLoggedIn, (req, res) => {
let data = {
disabled: '',
message: getSavedFile(req.session.user.username)}
let today = getNow().toISOString().slice(0,10)
let latestPost = req.session.user.latest_post
if (today === latestPost) {
data.disabled = 'disabled'
data.message = `Relax, ${req.session.user.username}, you have already posted today.`
}
res.render('index.spy', data)
})
app.get('/login', (req, res) => {
if (req.session.user) {
res.redirect('/')
} else {
res.render('login.spy')
}
})
app.get('/edit', requireLoggedIn, (req, res) => {
getLatestPost( req.session.user.directory, (data, path) => {
res.render('edit.spy', {data: data, path: path})
})
})
app.get('/settings', requireLoggedIn, (req, res) => {
res.render('settings.spy')
})
app.get('/try-again', requireLoggedIn, (req, res, next) => {
res.render('try-again.spy')
})
// POST
app.post('/login', verifyUser,
function(req, res){
if (req.session.user) {
res.redirect('/')
} else {
res.redirect('/try-again')
}
})
app.post('/logout', function(req, res, next){
req.session.destroy( (err) => {
if (err) {console.error(err)}
res.redirect('/login')
})
})
app.post('/publish', requireLoggedIn, (req, res) => {
publishNewPost(req, () => {
res.redirect('/')
})
})
app.post('/save', requireLoggedIn, (req, res) => {
saveFile(req.session.user, req.body.textarea, () => {
res.redirect('/')
})
})
app.post('/update', requireLoggedIn, (req, res) => {
updatePost(req, () => {
res.redirect('/')
})
})
app.post('/reset-password', requireLoggedIn, (req, res) => {
resetPassword(req.session.user.username, req.body.password, password => {
return req.session.destroy( (err) => {
if (err) {console.error(err)}
res.redirect('/login')
})
})
})
/**
* Let's go!
*/
app.listen(PORT, () => {
console.log(`Soyuz Web listening on port ${PORT}`)
})

2
static/soyuz.js Normal file
View file

@ -0,0 +1,2 @@
// use this regex to find links that are the wrong way around:
// /=>\s[^:\/]*\s/

94
static/style.css Normal file
View file

@ -0,0 +1,94 @@
html {
margin: 0;
padding: 0;
height: 100%;
}
body {
color: #3d3d3d;
padding: 0;
margin: auto;
min-height: 100%;
display: grid;
grid-template-rows: 2fr auto 1fr;
}
header,
footer {
background-color: #3d3d3d;
color: #ddd;
padding: 1em;
text-align: center;
}
header {
grid-row-start: 1;
grid-row-end: 2;
}
footer {
grid-row-start: 3;
grid-row-end: 4;
}
.login {
margin: 1em auto 0;
min-height: 80vh;
}
.textarea {
text-align: center;
margin: 1em auto 0;
width: 100%;
height: 100%;
}
header form {
display: inline-block;
}
textarea,
main {
outline: none;
width: 90%;
height:80vh;
border: none;
resize: none;
}
main {
padding: 1em;
}
.post-buttons {
background-color: #3d3d3d;
color: #ddd;
padding: 1em;
}
.action-button {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
text-decoration: none;
}
.disabled {
background-color: #999;
color: #000;
}
.password-reset {
width: 100%;
margin-bottom: 1em;
}
#settings section:nth-of-type(even) {
margin-top: 1em;
border-top: 1px solid #999;
padding-top: 1em;
}

18
templates/edit.spy Normal file
View file

@ -0,0 +1,18 @@
<< partials/head >>
<body>
<< partials/header >>
<section class="textarea">
<form method="post">
<textarea name="textarea" autofocus>{{ data }}</textarea>
<input type="text" name="path" value="{{ path }}" hidden>
<section class="post-buttons">
<input class="action-button" type="submit" name="save" value="Save" formaction="/save"> |
<input class="action-button" type="submit" name="publish" value="Update" formaction="/update">
</section>
</form>
</section>
<footer>
</footer>
</body>
</html>

17
templates/index.spy Normal file
View file

@ -0,0 +1,17 @@
<< partials/head >>
<body class="{{ disabled }}">
<< partials/header >>
<section class="textarea">
<form method="post">
<textarea name="textarea" autofocus class="{{ disabled }}" {{ disabled }}>{{ message }}</textarea>
<section class="post-buttons">
<input class="action-button" type="submit" name="save" value="Save" formaction="/save"> |
<input class="action-button" type="submit" name="publish" value="Publish" formaction="/publish">
</section>
</form>
</section>
<footer>
</footer>
</body>
</html>

16
templates/login.spy Normal file
View file

@ -0,0 +1,16 @@
<< partials/head >>
<body>
<header class="welcome">
<h1>Welcome to Soyuz</h1>
</header>
<section class="login">
<form method="POST" action="/login">
<label for="username">username </label><br/>
<input type="text" name="username"><br/>
<label for="password">password </label><br/>
<input type="password" name="password"><br/><br/>
<button type="submit">Log in!</button>
</form>
</section>
</body>
</html>

View file

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="/style.css">
<title>Soyuz</title>
</head>

View file

@ -0,0 +1,5 @@
<header>
<a class="action-button" href="/">New</a> |
<a class="action-button" href="/edit">Edit</a> |
<a class="action-button" href="/settings">Settings</a>
</header>

18
templates/settings.spy Normal file
View file

@ -0,0 +1,18 @@
<< partials/head >>
<body>
<< partials/header >>
<main id="settings">
<form method="post">
<section>
<label for="password">Reset password:</label><br/>
<input class="password-reset" type="password" name="password" id="password">
<input type="submit" name="logout" value="Reset Password" formaction="/reset-password">
</section>
<section>
<input type="submit" name="logout" value="Log Out" formaction="/logout">
</section>
</form>
</main>
<footer></footer>
</body>
</html>

15
templates/try-again.spy Normal file
View file

@ -0,0 +1,15 @@
<< partials/head >>
<body>
<p>Welcome to Soyuz</p>
<div>Incorrect username or password, please try again</div>
<section>
<form method="POST" action="/login">
<label for="username">username </label>
<input type="text" name="username">
<label for="password">username </label>
<input type="password" name="password">
<button type="submit">Log in!</button>
</form>
</section>
</body>
</html>

317
utilities.js Normal file
View file

@ -0,0 +1,317 @@
const GEMINI_PATH = process.env.GEMINI_PATH
const Database = require("better-sqlite3")
const {mkdir, readFile, writeFile} = require('node:fs')
const { pbkdf2, randomBytes } = require('node:crypto')
db = new Database('soyuz.db', {})
function getNow() {
// we want to be able to use toISOString but that always returns
// the date in UTC timezone. Here we adjust the UTC date to align
// with the local timezone
let localNow = new Date()
let now = new Date(localNow.getTime() - localNow.getTimezoneOffset()*60000)
return now
}
// DATABASE FUNCTIONS
const addUser = function(username, directory, callback){
let buf = randomBytes(32);
let salt = buf.toString('hex');
let pbuf = randomBytes(16);
let password = pbuf.toString('hex');
directory = directory.toString()
// prepare db table
let createTable = db.prepare(
'CREATE TABLE IF NOT EXISTS users (username TEXT UNIQUE, password TEXT, salt TEXT, directory TEXT UNIQUE, latest_post TEXT, saved_post TEXT)'
);
createTable.run();
// save to db
pbkdf2(password, salt, 310000, 32, 'sha512', (err, derivedKey) => {
if (err) throw err;
let hash = derivedKey.toString('hex');
let stmt = db.prepare(
'INSERT INTO users (username, directory, password, salt, saved_post) VALUES (?, ?, ?, ?, ?)'
);
stmt.run(username, directory, hash, salt, '# Title of my note');
return callback(password)
});
}
const resetPassword = function(username, pass, callback) {
let buf = randomBytes(32)
let salt = buf.toString('hex')
let password
if (pass) {
password = pass
} else {
let pbuf = randomBytes(16)
password = pbuf.toString('hex')
}
pbkdf2(password, salt, 310000, 32, 'sha512', (err, derivedKey) => {
if (err) throw err;
let hash = derivedKey.toString('hex');
let stmt = db.prepare(
'UPDATE users SET password = ?, salt = ? WHERE username = ?'
);
stmt.run(hash, salt, username);
return callback(password)
});
}
// update latest post in db
const updateLatestPostDate = function(username, callback) {
let dateString = getNow().toISOString().slice(0,10)
let stmt = db.prepare(
'UPDATE users SET latest_post = ? WHERE username = ?'
);
stmt.run(dateString, username);
callback(dateString)
}
// AUTHORISATION MIDDLEWARE
const verifyUser = function (req, res, next) {
let username = req.body.username
let password = req.body.password
let stmt = db.prepare(
'SELECT * FROM users WHERE username = ?'
)
user = stmt.get(username)
if (!user) {
return next()
}
pbkdf2(password, user.salt, 310000, 32, 'sha512', function(err, hashedPassword) {
if (err) {
return next()
}
if (user.password !== hashedPassword.toString('hex')) {
return next()
}
req.session.user = {
username: user.username,
directory: user.directory,
latest_post: user.latest_post,
};
next()
});
}
const requireLoggedIn = function(req, res, next) {
if (req.session.user) {
return next()
} else {
return res.redirect('/login')
}
}
// PUBLISHING
const publishNewPost = function(req, cb) {
let post = req.body.textarea
let title = req.body.textarea.split('\n')[0].split('# ')[1].trim()
let year = getNow().toISOString().slice(0,4)
let dateString = getNow().toISOString().slice(0,10)
let yearDir = `${GEMINI_PATH}/${req.session.user.directory}/${year}`
let fileName = `${GEMINI_PATH}/${req.session.user.directory}/${year}/${dateString}.gmi`
function updateArchivePage() {
// update or create year's archive page
let yearIndex = `${GEMINI_PATH}/${req.session.user.directory}/${year}/index.gmi`
let updated = ''
readFile(yearIndex, {encoding: 'utf8'}, (err, data) => {
// if the file doesn't exist, create it
if (err) {
if (err.code == 'ENOENT') {
let string = `# ${year} Notes\n\n=> ${dateString}.gmi ${dateString} (${title})\n`
writeFile(yearIndex, string, (err) => {
if (err) throw err;
})
}
else {
throw err
}
} else {
let lines = data.split('\n')
lines[1] = `\n=> ${dateString}.gmi ${dateString} (${title})`
updated = lines.join('\n')
}
writeFile(yearIndex, updated, (err) => {
if (err) throw err
})
})
// clear any saved post now that it is published
saveFile(req.session.user.username, '# Title of my note', () => {
// delete active page on db and in session
updateLatestPostDate(req.session.user.username, datestring => {
req.session.user.latest_post = datestring
return cb()
})
})
}
function updateIndexListing() {
// update index.gmi listing
let indexFile = `${GEMINI_PATH}/${req.session.user.directory}/index.gmi`
readFile(indexFile, {encoding: 'utf8'}, (err, data) => {
if (err) {
// if the file doesn't exist, create it
if (err.code == 'ENOENT') {
let string = `## Latest notes\n\n=> /${year}/${dateString}.gmi ${dateString} (${title})\n`
writeFile(indexFile, string, (err) => {
if (err) throw err;
})
}
} else {
let links = data.split('## Latest notes')
let lines = links[1].split('\n')
for (let i = 6; i < 2; i--) {
if (lines[i] && lines[i].startsWith('=>')) {
lines[i] = lines[i-1]
}
}
lines[0] = '## Latest notes'
lines[2] = `=> /${year}/${dateString}.gmi ${dateString} (${title})`
updated = links[0] + lines.join('\n')
writeFile(indexFile, updated, (err) => {
if (err) {
// if the directory doesn't exist, create it and try again
if (err.code == 'ENOENT') {
mkdir(yearDir, (err) => {
if (err) throw err;
writeFile(indexFile, updated, (err) => {
if (err) throw err;
})
})
}
}
})
}
})
return updateArchivePage()
}
writeFile(fileName, post, (err) => {
if (err) {
// if the directory doesn't exist, create it and try again
if (err.code == 'ENOENT') {
mkdir(yearDir, (err) => {
if (err) throw err;
writeFile(fileName, post, (err) => {
if (err) throw err;
})
})
}
}
return updateIndexListing()
})
}
let getLatestPost = function(directory, callback) {
// we check the index file because
// a new post could have come from
// somewhere other than the app
// e.g. from a CLI on a laptop etc
let indexFile = `${GEMINI_PATH}/${directory}/index.gmi`
readFile(indexFile, {encoding: 'utf8'}, (err, data) => {
if (err) throw err;
let links = data.split('## Latest notes')
let parts = links[1].split('\n')[2].split(' ')
let filePath = `${GEMINI_PATH}/${directory}/${parts[1]}`
readFile(filePath, {encoding: 'utf8'}, (err, file) => {
if (err) throw err;
return callback(file, filePath)
})
})
}
let updatePost = function(req, callback) {
let contents = req.body.textarea
let path = req.body.path
let title = contents.split('\n')[0].split('# ')[1].trim()
let year = getNow().toISOString().slice(0,4)
let dateString = getNow().toISOString().slice(0,10)
let indexFile = `${GEMINI_PATH}/${req.session.user.directory}/index.gmi`
let yearIndex = `${GEMINI_PATH}/${req.session.user.directory}/${year}/index.gmi`
let updated = ''
// we update the index and archive listings in case the title has changed
readFile(indexFile, {encoding: 'utf8'}, (err, data) => {
if (err) {
throw err;
} else {
let links = data.split('## Latest notes')
let lines = links[1].split('\n')
lines[0] = '## Latest notes'
lines[2] = `=> /${year}/${dateString}.gmi ${dateString} (${title})`
updated = links[0] + lines.join('\n')
// update index on homepage
writeFile(indexFile, updated, (err) => {
if (err) throw err
readFile(yearIndex, {encoding: 'utf8'}, (err, data) => {
if (err) {
throw err
} else {
let lines = data.split('\n')
lines[2] = `=> ${dateString}.gmi ${dateString} (${title})`
updated = lines.join('\n')
// update archive page
writeFile(yearIndex, updated, (err) => {
if (err) throw err
//write out the updated post
writeFile(path, contents, (err) => {
if (err) {
if (err) throw err;
}
return callback()
})
})
}
})
})
}
})
}
let saveFile = function(user, text, callback) {
let stmt = db.prepare(
'UPDATE users SET saved_post = ? WHERE username = ?'
);
stmt.run(text, user.username);
callback()
}
let getSavedFile = function(user) {
let stmt = db.prepare(
'SELECT saved_post FROM users WHERE username = ?'
)
stmt.pluck(true)
let post = stmt.get(user)
return post
}
// TODO:
let savePictures = function(text) {
// we will need to save pictures to the server
// separately when publishing
}
module.exports = {
addUser: addUser,
getLatestPost: getLatestPost,
getNow: getNow,
getSavedFile: getSavedFile,
publishNewPost: publishNewPost,
resetPassword: resetPassword,
requireLoggedIn: requireLoggedIn,
saveFile: saveFile,
updatePost: updatePost,
verifyUser: verifyUser
}