mirror of
https://github.com/Terreii/andromeda-viewer.git
synced 2026-08-14 00:57:49 +00:00
361 lines
9.3 KiB
JavaScript
361 lines
9.3 KiB
JavaScript
'use strict'
|
|
|
|
const basicAuth = require('basic-auth')
|
|
const express = require('express')
|
|
const { body, header, validationResult } = require('express-validator')
|
|
const fetch = require('node-fetch')
|
|
const pouchErrors = require('pouchdb-errors')
|
|
const { v4: uuid } = require('uuid')
|
|
|
|
const { nano, usersDB, getUserDbName } = require('./db')
|
|
|
|
const api = express.Router()
|
|
api.use(express.json())
|
|
|
|
module.exports = api
|
|
|
|
const minPasswordLength = 64 // 32 bytes as hex -> 64 chars
|
|
|
|
/**
|
|
* This is the server side implementation of the account and session API.
|
|
* It is inspired by Hoodies API. https://github.com/hoodiehq/account-json-api
|
|
* But it should be minimal and mostly use CouchDB's API.
|
|
*/
|
|
|
|
/**
|
|
* An email is used for the user to sign in. But the CouchDB-Username is a UUID.
|
|
* This is done to be able to change the login-name (email) without having to create a new database
|
|
* and sync the old data to it, when to user changes their email-address.
|
|
*
|
|
* Most of those routes only exist as a bridge between username and email.
|
|
*/
|
|
|
|
// Create the email to user index.
|
|
const mainIndexCreatedKey = 'didCreateUsersByEMailIndex'
|
|
api.use((req, _res, next) => {
|
|
if (req.app.get(mainIndexCreatedKey)) {
|
|
next()
|
|
return
|
|
}
|
|
|
|
req.app.set(mainIndexCreatedKey, true)
|
|
usersDB.createIndex({
|
|
index: {
|
|
fields: ['email']
|
|
},
|
|
ddoc: 'users-by-email',
|
|
name: 'users-by-email'
|
|
})
|
|
.catch(err => {
|
|
console.error("couldn't create the email index\nlogins will be slower!", err)
|
|
})
|
|
.then(() => {
|
|
next()
|
|
})
|
|
})
|
|
|
|
// sign up
|
|
api.put(
|
|
'/',
|
|
body('data.type', 'body must be of type account').equals('account'),
|
|
body('data.id', 'id must be an UUID').optional().isUUID(),
|
|
body('data.attributes.username', 'username must be an email-address').isEmail(),
|
|
body('data.attributes.password', 'password must be the right size').isLength({ min: minPasswordLength }),
|
|
async (req, res, next) => {
|
|
try {
|
|
// handle the validation errors
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
throw errors.array().map(error => ({
|
|
...error,
|
|
status: 400,
|
|
title: 'Bad Request',
|
|
detail: error.msg
|
|
}))
|
|
}
|
|
|
|
const { attributes: { username, password }, id } = req.body.data
|
|
// check if an user exist with that login-username/email
|
|
const { docs } = await usersDB.find({
|
|
selector: {
|
|
email: username
|
|
},
|
|
fields: ['_id', 'email']
|
|
})
|
|
|
|
if (docs.length > 0) {
|
|
throw pouchErrors.createError(
|
|
pouchErrors.REV_CONFLICT,
|
|
'An account with that username already exists'
|
|
)
|
|
}
|
|
|
|
// If an ID was send, check if an user exists with that id.
|
|
if (id) {
|
|
const exists = await usersDB.head('org.couchdb.user:' + id)
|
|
.then(() => true)
|
|
.catch(error => {
|
|
if (error.statusCode === 404) {
|
|
return false
|
|
}
|
|
throw error
|
|
})
|
|
|
|
if (exists) {
|
|
throw pouchErrors.createError(
|
|
pouchErrors.REV_CONFLICT,
|
|
'An account with that id already exists'
|
|
)
|
|
}
|
|
}
|
|
|
|
// Create the user
|
|
const userID = id || uuid()
|
|
// Add an email validation key
|
|
const emailValidation = uuid()
|
|
await usersDB.insert({
|
|
_id: 'org.couchdb.user:' + userID,
|
|
type: 'user',
|
|
name: userID,
|
|
password,
|
|
roles: [],
|
|
email: username,
|
|
email_validation: emailValidation // if validated, this field will be deleted
|
|
})
|
|
|
|
// TODO: send email
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
await nano.db.create(getUserDbName(userID))
|
|
}
|
|
|
|
res.type('application/vnd.api+json')
|
|
res.status(200).json({
|
|
links: {
|
|
self: req.app.get('host') + req.originalUrl
|
|
},
|
|
data: {
|
|
id: userID,
|
|
type: 'account',
|
|
attributes: {
|
|
username
|
|
},
|
|
relationships: {}
|
|
}
|
|
})
|
|
} catch (error) {
|
|
next(error)
|
|
}
|
|
}
|
|
)
|
|
|
|
// get user data
|
|
api.get('/', ...createAuthValidator(), async (req, res) => {
|
|
res.type('application/vnd.api+json')
|
|
|
|
// Send the user infos
|
|
res.status(200).json({
|
|
links: {
|
|
self: req.app.get('host') + req.originalUrl
|
|
},
|
|
data: {
|
|
// id will later be used for syncing.
|
|
id: req.user.name,
|
|
type: 'account',
|
|
attributes: {
|
|
username: req.user.email
|
|
},
|
|
relationships: {}
|
|
}
|
|
})
|
|
})
|
|
|
|
// update account
|
|
api.patch(
|
|
'/',
|
|
...createAuthValidator(),
|
|
body('data.type', 'body must be of type account').equals('account'),
|
|
body('data.attributes.username', 'username must be an email-address').optional().isEmail(),
|
|
body('data.attributes.password', 'password must be the right size')
|
|
.optional()
|
|
.isLength({ min: minPasswordLength }),
|
|
async (req, res, next) => {
|
|
try {
|
|
// handle the validation errors
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
throw errors.array().map(error => ({
|
|
...error,
|
|
status: 400,
|
|
title: 'Bad Request',
|
|
detail: error.msg
|
|
}))
|
|
}
|
|
|
|
let didChange = false
|
|
const { attributes: { username, password } } = req.body.data
|
|
|
|
if (password) {
|
|
req.user.password = password
|
|
didChange = true
|
|
}
|
|
|
|
if (username && username !== req.user.email) {
|
|
const { docs } = await usersDB.find({
|
|
selector: {
|
|
email: username
|
|
},
|
|
fields: ['_id', 'email']
|
|
})
|
|
if (docs.length > 0) {
|
|
throw pouchErrors.createError(
|
|
pouchErrors.REV_CONFLICT,
|
|
'An account with that username already exists'
|
|
)
|
|
}
|
|
|
|
didChange = true
|
|
req.user.email = username
|
|
req.user.email_validation = uuid()
|
|
// todo send validation email and set email_validation to an UUID
|
|
}
|
|
|
|
// only update user-doc if something did change.
|
|
if (didChange) {
|
|
await usersDB.insert(req.user)
|
|
}
|
|
res.status(204).send('')
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
}
|
|
)
|
|
|
|
// delete account
|
|
api.delete('/', ...createAuthValidator(), async (req, res, next) => {
|
|
try {
|
|
await usersDB.destroy(req.user._id, req.user._rev)
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
await nano.db.destroy(getUserDbName(req.user.name))
|
|
}
|
|
|
|
res.status(204).send('')
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
// Error handler
|
|
// This transforms the different error styles into application/vnd.api+json errors.
|
|
api.use((err, req, res, next) => {
|
|
if (!err) {
|
|
next()
|
|
return
|
|
}
|
|
res.type('application/vnd.api+json')
|
|
|
|
const getStatus = anError => Number(anError.status || anError.statusCode) || 500
|
|
const format = anError => ({
|
|
status: getStatus(anError),
|
|
title: anError.title || anError.name,
|
|
detail: anError.reason || anError.detail || anError.message
|
|
})
|
|
|
|
if (Array.isArray(err)) {
|
|
res.status(getStatus(err[0]))
|
|
res.json({
|
|
errors: err.map(format)
|
|
})
|
|
} else {
|
|
res.status(getStatus(err))
|
|
res.json({
|
|
errors: [format(err)]
|
|
})
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Create an Authorization validator.
|
|
* Is validates that there is a Basic Authorization header is there and uses it.
|
|
*/
|
|
function createAuthValidator () {
|
|
return [
|
|
header('Authorization').exists(),
|
|
(req, res, next) => {
|
|
// handle the validation errors
|
|
const errors = validationResult(req)
|
|
if (!errors.isEmpty()) {
|
|
next(pouchErrors.createError(
|
|
pouchErrors.UNAUTHORIZED,
|
|
'Authorization header missing'
|
|
))
|
|
} else {
|
|
next()
|
|
}
|
|
},
|
|
authentication
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Handle Basic Authentication.
|
|
* @param {express.Request} req Express' request object.
|
|
* @param {express.Response} res Express' response object.
|
|
* @param {express.NextFunction} next Call the next middleware.
|
|
*/
|
|
async function authentication (req, res, next) {
|
|
try {
|
|
const { name: username, pass: password } = basicAuth(req) || {}
|
|
|
|
// check if the username and password matches the requirements
|
|
if (
|
|
username == null ||
|
|
username.length === 0 ||
|
|
password == null ||
|
|
password.length < minPasswordLength
|
|
) {
|
|
throw pouchErrors.UNAUTHORIZED
|
|
}
|
|
|
|
// Find the document of the user.
|
|
const { docs } = await usersDB.find({
|
|
selector: {
|
|
email: username
|
|
}
|
|
})
|
|
|
|
// If the user doesn't exist
|
|
if (docs.length === 0) {
|
|
throw pouchErrors.UNAUTHORIZED
|
|
}
|
|
const user = docs[0]
|
|
|
|
// Use the CouchDB session API for checking the password.
|
|
// This works on CouchDB and PouchDB-Server and CouchDB with per-document-access.
|
|
// Also if the settings and/or hashing algorithm changes, then it would still work!
|
|
const sessionUrl = new URL('/_session', process.env.COUCH_URL || 'http://localhost:5984')
|
|
sessionUrl.username = ''
|
|
sessionUrl.password = ''
|
|
const response = await fetch(sessionUrl.href, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
name: user.name,
|
|
password
|
|
})
|
|
})
|
|
if (response.status === 401 || !(await response.json()).ok) {
|
|
throw pouchErrors.UNAUTHORIZED
|
|
} else {
|
|
req.user = user
|
|
next()
|
|
}
|
|
} catch (error) {
|
|
next(error)
|
|
}
|
|
}
|