From 1a9fad707633857cb8f3f28c15d503cc5442e04b Mon Sep 17 00:00:00 2001 From: tycrek Date: Tue, 29 Nov 2022 21:58:28 -0700 Subject: [PATCH] feat: BREAKING: overhaul auth file format and handling (etc, expand) BREAKING CHANGE: any hosts with modified deployments of ass utilizing the auth file in its current state will need to fix their modifications. --- src/ass.ts | 8 +- src/auth.ts | 145 ++++++++++++++++++++++++++++++++++++- src/routers/resource.ts | 4 +- src/routers/upload.ts | 26 ++----- src/types/definitions.d.ts | 5 -- src/utils.ts | 5 -- 6 files changed, 156 insertions(+), 37 deletions(-) diff --git a/src/ass.ts b/src/ass.ts index 445e893..b075ad9 100644 --- a/src/ass.ts +++ b/src/ass.ts @@ -44,7 +44,7 @@ const ROUTERS = { }; // Read users and data -import { users } from './auth'; +import { onStart as AuthOnStart, users } from './auth'; import { data } from './data'; //#endregion @@ -127,10 +127,12 @@ app.use('/:resourceId', (req, _res, next) => (req.resourceId = req.params.resour // Error handler app.use((err: ErrWrap, _req: Request, res: Response) => log.error(err.message).err(err).callback(() => res.sendStatus(CODE_INTERNAL_SERVER_ERROR))); // skipcq: JS-0128 -(function start() { +(async function start() { + await AuthOnStart(); + if (data() == null) setTimeout(start, 100); else log - .info('Users', `${Object.keys(users).length}`) + .info('Users', `${users.size}`) .info('Files', `${data().size}`) .info('Data engine', data().name, data().type) .info('Frontend', ASS_FRONTEND.enabled ? ASS_FRONTEND.brand : 'disabled', `${ASS_FRONTEND.enabled ? `${getTrueHttp()}${getTrueDomain()}${ASS_FRONTEND.endpoint}` : ''}`) diff --git a/src/auth.ts b/src/auth.ts index 304d7ba..607de7d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,9 +4,152 @@ import fs from 'fs-extra'; import { log, path, arrayEquals } from './utils'; +import { nanoid } from 'nanoid'; +import { User, Users, OldUsers } from './types/auth'; +import { Request } from 'express'; -export const users = require('../auth.json').users || {}; +/** + * !!!!! + * Things for tycrek to do: + * - [ ] Add a way to configure passwords + * - [ ] Create new users + * - [ ] Modify user (admin, meta, replace token/token history) + * - [ ] Delete user + * - [x] Get user + * - [x] Get users + * - [x] Get user by token + */ + +/** + * Map of users + */ +export const users = new Map(); + +/** + * Migrates the old auth.json format to the new one + * @since v0.14.0 + */ +const migrate = (): Promise => new Promise(async (resolve, reject) => { + + // Get ready to read the old auth.json file + const authPath = path('auth.json'); + const oldUsers = fs.readJsonSync(authPath).users as OldUsers; + + // Create a new users object + const newUsers: Users = { users: {}, meta: {} }; + newUsers.migrated = true; + + // Loop through each user + Object.entries(oldUsers).forEach(([token, { username }]) => { + + // Create a new user object + const id = nanoid(); + const newUser: User = { + username: username, + passhash: '', // TODO: Figure out how to configure passwords + token, + admin: Object.keys(oldUsers).indexOf(token) === 0, + meta: {} + }; + + newUsers.users[id] = newUser; + }); + + // Save the new users object to auth.json + fs.writeJson(authPath, newUsers, { spaces: '\t' }) + .then(() => resolve(newUsers)) + .catch(reject); +}); + +/** + * This is a WIP + */ +export const createNewUser = (username: string, passhash: string, admin: boolean, meta?: { [key: string]: User }): Promise => new Promise(async (resolve, reject) => { + + // todo: finish this + + // Create a new user object + const id = nanoid(); + const newUser: User = { + username, + passhash, + token: nanoid(32), + admin, + meta: meta || {} + }; + + // Add the user to the users map + users.set(id, newUser); + + // Save the new user to auth.json + const authPath = path('auth.json'); + const auth = fs.readJsonSync(authPath) as Users; + auth.users[id] = newUser; + fs.writeJson(authPath, auth, { spaces: '\t' }); +}); + + +/** + * Called by ass.ts on startup + */ +export const onStart = (authFile = 'auth.json') => new Promise((resolve, reject) => { + const file = path(authFile); + + log.debug('Reading', file); + + // Check if the file exists + fs.stat(file) + + // Create the file if it doesn't exist + .catch((_errStat) => { + log.debug('File does not exist', authFile, 'will be created automatically'); + return fs.writeJson(file, { migrated: true }); + }) + .catch((errWriteJson) => log.error('Failed to create auth.json').callback(reject, errWriteJson)) + + // File exists or was created + .then(() => fs.readJson(file)) + .then((json: Users) => { + + // Check if the file is the old format + if (json.migrated === undefined || !json.migrated) return ( + log.debug('auth.json is in old format, migrating'), + migrate()); + else return json; + }) + .then((json: Users) => { + + // Check if the file is empty + if (Object.keys(json).length === 0) { + log.debug('auth.json is empty, creating default user'); + //return createDefaultUser(); // todo: need to do this + } + + // Add users to the map + Object.entries(json.users).forEach(([uuid, user]) => users.set(uuid, user)); + }) + .catch((errReadJson) => log.error('Failed to read auth.json').callback(reject, errReadJson)) + .then(resolve); +}); + +/** + * Retrieves a user using their upload token. + */ +export const findFromToken = (token: string) => { + for (const [uuid, user] of users) + if (user.token === token) + return { uuid, user }; + return null; +}; + +/** + * Verifies that the upload token in the request exists in the user map + */ +export const verify = (req: Request) => { + return req.headers.authorization && findFromToken(req.headers.authorization); +}; +// todo: This is definitely broken // Monitor auth.json for changes (triggered by running 'npm run new-token') fs.watch(path('auth.json'), { persistent: false }, (eventType: String) => eventType === 'change' && fs.readJson(path('auth.json')) diff --git a/src/routers/resource.ts b/src/routers/resource.ts index 9bcc2d3..068dd87 100644 --- a/src/routers/resource.ts +++ b/src/routers/resource.ts @@ -11,7 +11,7 @@ import { path, log, getTrueHttp, getTrueDomain, formatBytes, formatTimestamp, ge const { diskFilePath, s3enabled, viewDirect, useSia }: Config = fs.readJsonSync(path('config.json')); const { CODE_UNAUTHORIZED, CODE_NOT_FOUND, }: MagicNumbers = fs.readJsonSync(path('MagicNumbers.json')); import { data } from '../data'; -import { users } from '../auth'; +import { findFromToken } from '../auth'; import express from 'express'; const router = express.Router(); @@ -49,7 +49,7 @@ router.get('/', (req: Request, res: Response, next) => data().get(req.ass.resour fileIs: fileData.is, title: escape(fileData.originalname), mimetype: fileData.mimetype, - uploader: users[fileData.token].username, + uploader: findFromToken(fileData.token)?.user.username || 'Unknown', timestamp: formatTimestamp(fileData.timestamp, fileData.timeoffset), size: formatBytes(fileData.size), // todo: figure out how to not ignore this diff --git a/src/routers/upload.ts b/src/routers/upload.ts index bcdfa7d..5295b1e 100644 --- a/src/routers/upload.ts +++ b/src/routers/upload.ts @@ -1,4 +1,4 @@ -import { ErrWrap, User } from '../types/definitions'; +import { ErrWrap } from '../types/definitions'; import { Config, MagicNumbers } from 'ass-json'; import fs from 'fs-extra'; @@ -7,9 +7,9 @@ import bb from 'express-busboy'; import { DateTime } from 'luxon'; import { Webhook, MessageBuilder } from 'discord-webhook-node'; import { processUploaded } from '../storage'; -import { path, log, verify, getTrueHttp, getTrueDomain, generateId, formatBytes } from '../utils'; +import { path, log, getTrueHttp, getTrueDomain, generateId, formatBytes } from '../utils'; import { data } from '../data'; -import { users } from '../auth'; +import { findFromToken, verify } from '../auth'; const { maxUploadSize, resourceIdSize, gfyIdSize, resourceIdType, spaceReplace, adminWebhookEnabled, adminWebhookUrl, adminWebhookUsername, adminWebhookAvatar }: Config = fs.readJsonSync(path('config.json')); const { CODE_UNAUTHORIZED, CODE_PAYLOAD_TOO_LARGE }: MagicNumbers = fs.readJsonSync(path('MagicNumbers.json')); @@ -35,7 +35,7 @@ bb.extend(router, { router.post('/', (req: Request, res: Response, next: Function) => { req.headers.authorization = req.headers.authorization || ''; req.token = req.headers.authorization.replace(/[^\da-z]/gi, ''); // Strip anything that isn't a digit or ASCII letter - !verify(req, users) ? log.warn('Upload blocked', 'Unauthorized').callback(() => res.sendStatus(CODE_UNAUTHORIZED)) : next(); // skipcq: JS-0093 + !verify(req) ? log.warn('Upload blocked', 'Unauthorized').callback(() => res.sendStatus(CODE_UNAUTHORIZED)) : next(); // skipcq: JS-0093 }); // Upload file @@ -110,7 +110,7 @@ router.post('/', (req: Request, res: Response, next: Function) => { .then(() => { // Log the upload const logInfo = `${req.file!.originalname} (${req.file!.mimetype}, ${formatBytes(req.file.size)})`; - const uploader = users[req.token ?? ''] ? users[req.token ?? ''].username : ''; + const uploader = findFromToken(req.token)?.user.username ?? 'Unknown'; log.success('File uploaded', logInfo, `uploaded by ${uploader}`); // Build the URLs @@ -163,22 +163,6 @@ router.post('/', (req: Request, res: Response, next: Function) => { adminWebhookUsername.trim().length === 0 ? 'ass admin logs' : adminWebhookUsername, adminWebhookAvatar.trim().length === 0 ? ASS_LOGO : adminWebhookAvatar, true); - - // Also update the users upload count - if (!users[req.token ?? '']) { - const generateUsername = () => generateId('random', 20, 0, req.file.size.toString()); // skipcq: JS-0074 - let username: string = generateUsername(); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - while (Object.values(users).findIndex((user: User) => user.username === username) !== -1) // skipcq: JS-0073 - username = generateUsername(); - - users[req.token ?? ''] = { username, count: 0 }; - } - users[req.token ?? ''].count += 1; - fs.writeJsonSync(path('auth.json'), { users }, { spaces: 4 }); - log.debug('Upload request flow completed', ''); }); }) diff --git a/src/types/definitions.d.ts b/src/types/definitions.d.ts index 75c5021..5628869 100644 --- a/src/types/definitions.d.ts +++ b/src/types/definitions.d.ts @@ -12,11 +12,6 @@ declare global { } } -export interface User { - token: string - username: string -} - export interface FileData { // Data from request file object uuid?: string diff --git a/src/utils.ts b/src/utils.ts index 0748fb1..934f828 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -70,10 +70,6 @@ export function replaceholder(data: string, size: number, timestamp: number, tim export function arrayEquals(arr1: any[], arr2: any[]) { return arr1.length === arr2.length && arr1.slice().sort().every((value: string, index: number) => value === arr2.slice().sort()[index]) -}; - -export function verify(req: Request, users: JSON) { - return req.headers.authorization && Object.prototype.hasOwnProperty.call(users, req.headers.authorization); } const idModes = { @@ -108,7 +104,6 @@ module.exports = { replaceholder, randomHexColour, sanitize, - verify, renameFile: (req: Request, newName: string) => new Promise((resolve: Function, reject) => { try { const paths = [req.file.destination, newName];