diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 083a74d..0000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,72 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '24 21 * * 3' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: [ 'javascript-typescript' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - queries: +security-and-quality - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/merge-conflict-labeler.yml b/.github/workflows/merge-conflict-labeler.yml index df04273..0ae960c 100644 --- a/.github/workflows/merge-conflict-labeler.yml +++ b/.github/workflows/merge-conflict-labeler.yml @@ -14,6 +14,6 @@ jobs: if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' - commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' - commentOnClean: "Conflicts have been resolved. A maintainer will review the pull request shortly." + commentOnDirty: '**[AUTOMATED MESSAGE]** 🔴 This Pull Request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' + commentOnClean: '**[AUTOMATED MESSAGE]** đŸŸĸ Conflicts have been resolved.' repoToken: "${{ secrets.GH_TOKEN }}" diff --git a/backend/UserConfig.ts b/backend/UserConfig.ts index 77bca22..c31b903 100644 --- a/backend/UserConfig.ts +++ b/backend/UserConfig.ts @@ -1,6 +1,7 @@ +import { UserConfiguration, UserConfigTypeChecker } from 'ass'; + import fs from 'fs-extra'; import { path } from '@tycrek/joint'; -import { UserConfiguration, UserConfigTypeChecker } from 'ass'; import { log } from './log'; const FILEPATH = path.join('.ass-data/userconfig.json'); @@ -35,10 +36,7 @@ const Checkers: UserConfigTypeChecker = { return false; } }, - idType: (val) => { - const options = ['random', 'original', 'gfycat', 'timestamp', 'zws']; - return options.includes(val); - }, + idType: (val) => ['random', 'original', 'gfycat', 'timestamp', 'zws'].includes(val), idSize: numChecker, gfySize: numChecker, maximumFileSize: numChecker, @@ -60,8 +58,12 @@ const Checkers: UserConfigTypeChecker = { password: basicStringChecker, database: basicStringChecker } + }, + + rateLimit: { + endpoint: (val) => val == null || (val != null && (numChecker(val.requests) && numChecker(val.duration))) } -} +}; export class UserConfig { private static _config: UserConfiguration; @@ -108,6 +110,13 @@ export class UserConfig { if (!Checkers.sql.mySql.database(config.sql.mySql.database)) throw new Error('Invalid MySql Database'); } + // * optional rate limit config + if (config.rateLimit != null) { + if (!Checkers.rateLimit.endpoint(config.rateLimit.login)) throw new Error('Invalid Login rate limit configuration'); + if (!Checkers.rateLimit.endpoint(config.rateLimit.upload)) throw new Error('Invalid Upload rate limit configuration'); + if (!Checkers.rateLimit.endpoint(config.rateLimit.api)) throw new Error('Invalid API rate limit configuration'); + } + // All is fine, carry on! return config; } diff --git a/backend/app.ts b/backend/app.ts index d1d2a2d..a0b2cc2 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -1,30 +1,68 @@ -import express, { Request, Response, NextFunction, RequestHandler, json as BodyParserJson } from 'express'; +import { AssUser, ServerConfiguration } from 'ass'; + import fs from 'fs-extra'; +import tailwindcss from 'tailwindcss'; +import session from 'express-session'; +import MemoryStore from 'memorystore'; +import express, { Request, Response, NextFunction, RequestHandler, json as BodyParserJson } from 'express'; import { path, isProd } from '@tycrek/joint'; import { epcss } from '@tycrek/express-postcss'; -import tailwindcss from 'tailwindcss'; + import { log } from './log'; -import { ensureFiles } from './data'; +import { ensureFiles, get } from './data'; import { UserConfig } from './UserConfig'; -import { ServerConfiguration } from 'ass'; import { MySql } from './sql/mysql'; +import { buildFrontendRouter } from './routers/_frontend'; /** * Top-level metadata exports */ export const App = { pkgVersion: '' -} +}; /** * Custom middleware to attach the ass object (and construct the `host` property) */ -function assMetaMiddleware(port: number, proxied: boolean): RequestHandler { - return (req: Request, _res: Response, next: NextFunction) => { - req.ass = { host: `${req.protocol}://${req.hostname}${proxied ? '' : `:${port}`}` }; +const assMetaMiddleware = (port: number, proxied: boolean): RequestHandler => + (req: Request, _res: Response, next: NextFunction) => { + req.ass = { + host: `${req.protocol}://${req.hostname}${proxied ? '' : `:${port}`}`, + version: App.pkgVersion + }; + + // Set up Session if required + if (!req.session.ass) + (log.debug('Session missing'), req.session.ass = {}); + next(); - } -} + }; + +/** + * Custom middleware to verify user access + */ +const loginRedirectMiddleware = (requireAdmin = false): RequestHandler => + async (req: Request, res: Response, next: NextFunction) => { + + // If auth doesn't exist yet, make the user login + if (!req.session.ass?.auth) { + log.warn('User not logged in', req.baseUrl); + + // Set pre-login path so user is directed to their requested page + req.session.ass!.preLoginPath = req.baseUrl; + + // Redirect + res.redirect('/login'); + } else { + const user = (await get('users', req.session.ass.auth.uid)) as AssUser; + + // Check if user is admin + if ((requireAdmin || req.baseUrl === '/admin') && !user.admin) { + log.warn('Admin verification failed', user.username, user.id); + res.sendStatus(403); + } else next(); + } + }; /** * Main function. @@ -81,9 +119,22 @@ async function main() { // Set up Express const app = express(); + // Configure sessions + const DAY = 86_400_000; + app.use(session({ + name: 'ass', + resave: true, + saveUninitialized: false, + cookie: { maxAge: DAY, secure: isProd() }, + secret: (Math.random() * 100).toString(), + store: new (MemoryStore(session))({ checkPeriod: DAY }) as any, + })); + + // Configure Express features app.enable('case sensitive routing'); app.disable('x-powered-by'); + // Set Express variables app.set('trust proxy', serverConfig.proxied); app.set('view engine', 'pug'); app.set('views', 'views/'); @@ -108,14 +159,17 @@ async function main() { warn: (warning: Error) => log.warn('PostCSS', warning.toString()) })); - app.get('/.ass.host', (req, res) => res.send(req.ass.host)); + // Metadata routes + app.get('/.ass.host', (req, res) => res.type('text').send(req.ass.host)); + app.get('/.ass.version', (req, res) => res.type('text').send(req.ass.version)); - // ! I did not want to do it like this how tf did I back myself into this shit - app.get('/admin', (req, res) => res.render('admin', { version: App.pkgVersion })) - app.get('/login', (req, res) => res.render('login', { version: App.pkgVersion })) + // Basic page routers + app.use('/setup', buildFrontendRouter('setup', false)); + app.use('/login', buildFrontendRouter('login')); + app.use('/admin', loginRedirectMiddleware(), buildFrontendRouter('admin')); + app.use('/user', loginRedirectMiddleware(), buildFrontendRouter('user')); - // Routing - app.use('/setup', (await import('./routers/setup.js')).router); + // Advanced routers app.use('/api', (await import('./routers/api.js')).router); app.use('/', (await import('./routers/index.js')).router); diff --git a/backend/data.ts b/backend/data.ts index b62a391..85ae645 100644 --- a/backend/data.ts +++ b/backend/data.ts @@ -1,8 +1,10 @@ +import { AssFile, AssUser, NID, FilesSchema, UsersSchema } from 'ass'; + import fs from 'fs-extra'; import { path } from '@tycrek/joint'; -import { nanoid } from './generators'; + import { log } from './log'; -import { AssFile, AssUser, NID, FilesSchema, UsersSchema } from 'ass'; +import { nanoid } from './generators'; import { UserConfig } from './UserConfig'; import { MySql } from './sql/mysql'; @@ -133,7 +135,7 @@ export const put = (sector: DataSector, key: NID, data: AssFile | AssUser): Prom // ? SQL if (!(await MySql.get('assfiles', key))) await MySql.put('assfiles', key, data); - else return reject(new Error(`File key ${key} already exists`)) + else return reject(new Error(`File key ${key} already exists`)); // todo: modify users SQL files property } @@ -178,3 +180,15 @@ export const get = (sector: DataSector, key: NID): Promise => new Promise(async (resolve, reject) => { + try { + const data: { [key: string]: AssFile | AssUser } | undefined = (MySql.ready) + // todo: fix MySQL + ? (await MySql.getAll(sector === 'files' ? 'assfiles' : 'assusers') as /* AssFile[] | AssUser[] | */ undefined) + : (await fs.readJson(PATHS[sector]))[sector]; + (!data) ? resolve(false) : resolve(data); + } catch (err) { + reject(err); + } +}); diff --git a/backend/generators.ts b/backend/generators.ts index 51e144f..dba3999 100644 --- a/backend/generators.ts +++ b/backend/generators.ts @@ -1,6 +1,6 @@ import fs from 'fs-extra'; -import { randomBytes, getRandomValues } from 'crypto'; import cryptoRandomString from 'crypto-random-string'; +import { randomBytes, getRandomValues } from 'crypto'; import { path } from '@tycrek/joint'; type Length = { length: number, gfyLength?: number }; diff --git a/backend/operations.ts b/backend/operations.ts index 3855c94..a33399d 100644 --- a/backend/operations.ts +++ b/backend/operations.ts @@ -3,8 +3,9 @@ import sharp from 'sharp'; import Vibrant from 'node-vibrant'; import ffmpeg from 'ffmpeg-static'; import { exec } from 'child_process'; -import { removeLocation } from '@xoi/gps-metadata-remover'; import { isProd } from '@tycrek/joint'; +import { removeLocation } from '@xoi/gps-metadata-remover'; + //@ts-ignore import shell from 'any-shell-escape'; @@ -13,21 +14,19 @@ type SrcDest = { src: string, dest: string }; /** * Strips GPS EXIF data from a file */ -export const removeGPS = (file: string): Promise => { - return new Promise((resolve, reject) => - fs.open(file, 'r+') - .then((fd) => removeLocation(file, - // Read function - (size: number, offset: number): Promise => - fs.read(fd, Buffer.alloc(size), 0, size, offset) - .then(({ buffer }) => Promise.resolve(buffer)), - // Write function - (val: string, offset: number, enc: BufferEncoding): Promise => - fs.write(fd, Buffer.alloc(val.length, val, enc), 0, val.length, offset) - .then(() => Promise.resolve()))) - .then(resolve) - .catch(reject)); -} +export const removeGPS = (file: string): Promise => new Promise((resolve, reject) => + fs.open(file, 'r+') + .then((fd) => removeLocation(file, + // Read function + (size: number, offset: number): Promise => + fs.read(fd, Buffer.alloc(size), 0, size, offset) + .then(({ buffer }) => Promise.resolve(buffer)), + // Write function + (val: string, offset: number, enc: BufferEncoding): Promise => + fs.write(fd, Buffer.alloc(val.length, val, enc), 0, val.length, offset) + .then(() => Promise.resolve()))) + .then(resolve) + .catch(reject)); const VIBRANT = { COLOURS: 256, QUALITY: 3 }; export const vibrant = (file: string, mimetype: string): Promise => new Promise((resolve, reject) => @@ -39,8 +38,7 @@ export const vibrant = (file: string, mimetype: string): Promise => new .quality(VIBRANT.QUALITY) .getPalette()) .then((palettes) => resolve(palettes[Object.keys(palettes).sort((a, b) => palettes[b]!.population - palettes[a]!.population)[0]]!.hex)) - .catch((err) => reject(err)) -) + .catch((err) => reject(err))); /** * Thumbnail operations @@ -64,7 +62,7 @@ export class Thumbnail { } private static getVideoThumbnail({ src, dest }: SrcDest) { - exec(this.getCommand({ src, dest })) + exec(this.getCommand({ src, dest })); } private static getCommand({ src, dest }: SrcDest) { diff --git a/backend/ratelimit.ts b/backend/ratelimit.ts new file mode 100644 index 0000000..77115f1 --- /dev/null +++ b/backend/ratelimit.ts @@ -0,0 +1,46 @@ +import { EndpointRateLimitConfiguration } from 'ass'; +import { NextFunction, Request, Response } from 'express'; +import { rateLimit } from 'express-rate-limit'; + +/** + * map that contains rate limiter middleware for each group + */ +const rateLimiterGroups = new Map void>(); + +export const setRateLimiter = (group: string, config: EndpointRateLimitConfiguration | undefined): (req: Request, res: Response, next: NextFunction) => void => { + if (config == null) { // config might be null if the user doesnt want a rate limit + rateLimiterGroups.set(group, (req, res, next) => { + next(); + }); + + return rateLimiterGroups.get(group)!; + } else { + rateLimiterGroups.set(group, rateLimit({ + limit: config.requests, + windowMs: config.duration * 1000, + skipFailedRequests: true, + legacyHeaders: false, + standardHeaders: 'draft-7', + keyGenerator: (req, res) => { + return req.ip || 'disconnected'; + }, + handler: (req, res) => { + res.status(429); + res.contentType('json'); + res.send('{"success":false,"message":"Rate limit exceeded, try again later"}'); + } + })); + + return rateLimiterGroups.get(group)!; + } +} +/** + * creates middleware for rate limiting + */ +export const rateLimiterMiddleware = (group: string, config: EndpointRateLimitConfiguration | undefined): (req: Request, res: Response, next: NextFunction) => void => { + if (!rateLimiterGroups.has(group)) setRateLimiter(group, config); + + return (req, res, next) => { + return rateLimiterGroups.get(group)!(req, res, next); + }; +}; \ No newline at end of file diff --git a/backend/routers/_frontend.ts b/backend/routers/_frontend.ts new file mode 100644 index 0000000..9a84c9c --- /dev/null +++ b/backend/routers/_frontend.ts @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import { path } from '@tycrek/joint'; + +import { App } from '../app'; +import { UserConfig } from '../UserConfig'; + +/** + * Builds a basic router for loading a page with frontend JS + */ +export const buildFrontendRouter = (page: string, onConfigReady = true) => { + + // Config readiness checker + const ready = () => (onConfigReady) + ? UserConfig.ready + : !UserConfig.ready; + + // Set up a router + const router = Router({ caseSensitive: true }); + + // Render the page + router.get('/', (_req, res) => ready() + ? res.render(page, { version: App.pkgVersion }) + : res.redirect('/')); + + // Load frontend JS + router.get('/ui.js', (_req, res) => ready() + ? res.type('text/javascript').sendFile(path.join(`dist-frontend/${page}.mjs`)) + : res.sendStatus(403)); + + return router; +}; diff --git a/backend/routers/api.ts b/backend/routers/api.ts index a91da49..2117bcd 100644 --- a/backend/routers/api.ts +++ b/backend/routers/api.ts @@ -1,15 +1,80 @@ -import { Router, json as BodyParserJson } from 'express'; +import { AssUser, AssUserNewReq } from 'ass'; + import * as bcrypt from 'bcrypt' -import { log } from '../log'; -import { UserConfig } from '../UserConfig'; +import { Router, json as BodyParserJson, RequestHandler } from 'express'; + import * as data from '../data'; -import { AssUser, AssUserNewReq } from 'ass'; +import { log } from '../log'; import { nanoid } from '../generators'; +import { UserConfig } from '../UserConfig'; +import { MySql } from '../sql/mysql'; +import { rateLimiterMiddleware, setRateLimiter } from '../ratelimit'; const router = Router({ caseSensitive: true }); +// Setup route +router.post('/setup', BodyParserJson(), async (req, res) => { + if (UserConfig.ready) + return res.status(409).json({ success: false, message: 'User config already exists' }); + + log.info('Setup', 'initiated'); + + try { + // Parse body + new UserConfig(req.body); + + // Save config + await UserConfig.saveConfigFile(); + + // Set data storage (not files) to SQL if required + if (UserConfig.config.sql?.mySql != null) + await Promise.all([MySql.configure(), data.setDataModeToSql()]); + + // set rate limits + if (UserConfig.config.rateLimit?.api) setRateLimiter('api', UserConfig.config.rateLimit.api); + if (UserConfig.config.rateLimit?.login) setRateLimiter('login', UserConfig.config.rateLimit.login); + if (UserConfig.config.rateLimit?.upload) setRateLimiter('upload', UserConfig.config.rateLimit.upload); + + log.success('Setup', 'completed'); + + return res.json({ success: true }); + } catch (err: any) { + return res.status(400).json({ success: false, message: err.message }); + } +}); + +// User login +router.post('/login', rateLimiterMiddleware('login', UserConfig.config?.rateLimit?.login), BodyParserJson(), (req, res) => { + const { username, password } = req.body; + + data.getAll('users') + .then((users) => { + if (!users) throw new Error('Missing users data'); + else return Object.entries(users as { [key: string]: AssUser }) + .filter(([_uid, user]: [string, AssUser]) => user.username === username)[0][1]; // [0] is the first item in the filter results, [1] is is AssUser + }) + .then((user) => Promise.all([bcrypt.compare(password, user.password), user])) + .then(([success, user]) => { + success ? log.success('User logged in', user.username) + : log.warn('User failed to log in', user.username); + + // Set up the session information + if (success) req.session.ass!.auth = { + uid: user.id, + token: '' + }; + + // Respond + res.json({ success, message: `User [${user.username}] ${success ? 'logged' : 'failed to log'} in`, meta: { redirectTo: req.session.ass?.preLoginPath ?? '/user' } }); + + // Delete the pre-login path after successful login + if (success) delete req.session.ass?.preLoginPath; + }) + .catch((err) => res.status(400).json({ success: false, message: err.message })); +}); + // todo: authenticate API endpoints -router.post('/user', BodyParserJson(), async (req, res) => { +router.post('/user', rateLimiterMiddleware('api', UserConfig.config?.rateLimit?.api), BodyParserJson(), async (req, res) => { if (!UserConfig.ready) return res.status(409).json({ success: false, message: 'User config not ready' }); @@ -21,12 +86,12 @@ router.post('/user', BodyParserJson(), async (req, res) => { try { // Username check - if (!newUser.username) issue = 'Missing username' + if (!newUser.username) issue = 'Missing username'; newUser.username.replaceAll(/[^A-z0-9_-]/g, ''); if (newUser.username === '') issue = 'Invalid username'; // Password check - if (!newUser.password) issue = 'Missing password' + if (!newUser.password) issue = 'Missing password'; if (newUser.password === '') issue = 'Invalid password'; newUser.password = newUser.password.substring(0, 128); @@ -48,7 +113,7 @@ router.post('/user', BodyParserJson(), async (req, res) => { // todo: also check duplicate usernames await data.put('users', user.id, user); - } catch (err: any) { issue = `Error: ${err.message}` } + } catch (err: any) { issue = `Error: ${err.message}`; } if (issue) return res.status(400).json({ success: false, messsage: issue }); diff --git a/backend/routers/index.ts b/backend/routers/index.ts index e3bbf2b..eed8d13 100644 --- a/backend/routers/index.ts +++ b/backend/routers/index.ts @@ -1,15 +1,18 @@ +import { BusBoyFile, AssFile } from 'ass'; + import fs from 'fs-extra'; import bb from 'express-busboy'; -import { Router } from 'express'; import crypto from 'crypto'; -import { log } from '../log'; -import { UserConfig } from '../UserConfig'; -import { random } from '../generators'; -import { BusBoyFile, AssFile } from 'ass'; -import { getFileS3, uploadFileS3 } from '../s3'; +import { Router } from 'express'; import { Readable } from 'stream'; + import * as data from '../data'; +import { log } from '../log'; import { App } from '../app'; +import { random } from '../generators'; +import { UserConfig } from '../UserConfig'; +import { getFileS3, uploadFileS3 } from '../s3'; +import { rateLimiterMiddleware } from '../ratelimit'; const router = Router({ caseSensitive: true }); @@ -27,7 +30,7 @@ bb.extend(router, { router.get('/', (req, res) => UserConfig.ready ? res.render('index', { version: App.pkgVersion }) : res.redirect('/setup')); // Upload flow -router.post('/', async (req, res) => { +router.post('/', rateLimiterMiddleware("upload", UserConfig.config?.rateLimit?.upload), async (req, res) => { // Check user config if (!UserConfig.ready) return res.status(500).type('text').send('Configuration missing!'); diff --git a/backend/routers/setup.ts b/backend/routers/setup.ts deleted file mode 100644 index e84c70b..0000000 --- a/backend/routers/setup.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { path } from '@tycrek/joint'; -import { Router, json as BodyParserJson } from 'express'; -import { log } from '../log'; -import { UserConfig } from '../UserConfig'; -import { setDataModeToSql } from '../data'; -import { MySql } from '../sql/mysql'; -import { App } from '../app'; - -const router = Router({ caseSensitive: true }); - -// Static routes -router.get('/', (req, res) => UserConfig.ready ? res.redirect('/') : res.render('setup', { version: App.pkgVersion })); -router.get('/ui.js', (req, res) => UserConfig.ready ? res.send('') : res.type('text/javascript').sendFile(path.join('dist-frontend/setup.mjs'))); - -// Setup route -router.post('/', BodyParserJson(), async (req, res) => { - if (UserConfig.ready) - return res.status(409).json({ success: false, message: 'User config already exists' }); - - log.debug('Setup initiated'); - - try { - // Parse body - new UserConfig(req.body); - - // Save config - await UserConfig.saveConfigFile(); - - // Set data storage (not files) to SQL if required - if (UserConfig.config.sql?.mySql != null) - await Promise.all([MySql.configure(), setDataModeToSql()]); - - return res.json({ success: true }); - } catch (err: any) { - return res.status(400).json({ success: false, message: err.message }); - } -}); - -export { router }; diff --git a/backend/s3.ts b/backend/s3.ts index 97b1866..16c39ef 100644 --- a/backend/s3.ts +++ b/backend/s3.ts @@ -1,5 +1,3 @@ -import { UserConfig } from './UserConfig'; -import { log } from './log'; import { S3Client, S3ClientConfig, @@ -14,6 +12,9 @@ import { AbortMultipartUploadCommand, } from "@aws-sdk/client-s3"; +import { log } from './log'; +import { UserConfig } from './UserConfig'; + const NYR = 'S3 not ready'; /** @@ -48,7 +49,7 @@ const s3 = (): S3Client | null => { } return _s3client; -} +}; /** * Basic single file upload diff --git a/backend/sql/mysql.ts b/backend/sql/mysql.ts index d4e5122..5d25a5a 100644 --- a/backend/sql/mysql.ts +++ b/backend/sql/mysql.ts @@ -1,7 +1,9 @@ +import { AssFile, AssUser, NID, UploadToken } from 'ass'; + import mysql, { Pool } from 'mysql2/promise'; -import { UserConfig } from '../UserConfig'; + import { log } from '../log'; -import { AssFile, AssUser, NID, UploadToken } from 'ass'; +import { UserConfig } from '../UserConfig'; type TableNamesType = 'assfiles' | 'assusers' | 'asstokens'; @@ -87,7 +89,7 @@ export class MySql { if (tablesExist.files && tablesExist.users) log.info('MySQL', 'Tables exist, ready').callback(() => { MySql._ready = true; - resolve(void 0) + resolve(void 0); }); else throw new Error('Table(s) missing!'); } @@ -129,4 +131,24 @@ VALUES ('${key}', '${JSON.stringify(data)}'); } }); } + + // todo: unknown if this works + public static getAll(table: TableNamesType): Promise { + return new Promise(async (resolve, reject) => { + try { + // Run query // ! this may not work as expected + const [rowz, _fields] = await MySql._pool.query(`SELECT Data FROM ${table}`); + + // Interpret results this is pain + const rows = (rowz as unknown as { [key: string]: string }[]); + + // console.log(rows); + + // aaaaaaaaaaaa + resolve(undefined); + } catch (err) { + reject(err); + } + }); + } } diff --git a/backend/utils.ts b/backend/utils.ts index 60a23b0..64df481 100644 --- a/backend/utils.ts +++ b/backend/utils.ts @@ -6,15 +6,14 @@ export const randomHexColour = () => { // From: https://www.geeksforgeeks.org/ja for (let i = 0; i < 6; i++) colour += letters[(Math.floor(Math.random() * letters.length))]; return colour; -} +}; export const formatTimestamp = (timestamp: number, timeoffset: string) => DateTime.fromMillis(timestamp).setZone(timeoffset).toLocaleString(DateTime.DATETIME_MED); - export const formatBytes = (bytes: number, decimals = 2) => { if (bytes === 0) return '0 Bytes'; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return parseFloat((bytes / Math.pow(1024, i)).toFixed(decimals < 0 ? 0 : decimals)).toString().concat(` ${sizes[i]}`); -} +}; diff --git a/common/fix-frontend-js.js b/common/fix-frontend-js.js index 789d45c..4bd00a1 100644 --- a/common/fix-frontend-js.js +++ b/common/fix-frontend-js.js @@ -2,13 +2,26 @@ const fs = require('fs-extra'); const { path } = require('@tycrek/joint'); const log = new (require('@tycrek/log').TLog)(); -log.info('Fixing frontend JS'); +const FILES = { + prefix: 'dist-frontend', + suffix: '.mjs', + pages: [ + 'setup', + 'login', + 'admin', + 'user', + ] +}; -// Read & fix file data -const setupUiFile = path.join('dist-frontend/setup.mjs'); -const setupUiNew = fs.readFileSync(setupUiFile).toString().replace('export {};', ''); +const fixFile = (page) => { + const filePath = path.join(FILES.prefix, `${page}${FILES.suffix}`); + const fixed = fs.readFileSync(filePath).toString().replace('export {};', ''); -// Write new file -fs.writeFileSync(setupUiFile, setupUiNew); + return fs.writeFile(filePath, fixed); +}; + +log.info('Fixing frontend JS', `${FILES.pages.length} files`); +Promise.all(FILES.pages.map(fixFile)) + .then(() => log.success('Fixed.')) + .catch(console.error); -log.success('Fixed.'); diff --git a/common/global.d.ts b/common/global.d.ts index 543d8e7..d7d892e 100644 --- a/common/global.d.ts +++ b/common/global.d.ts @@ -1,6 +1,18 @@ import { BusBoyFile } from 'ass'; import { Request, Response } from 'express'; +declare module 'express-session' { + interface SessionData { + ass: { + auth?: { + uid: string; + token: string; + } + preLoginPath?: string; + } + } +} + declare global { namespace Express { interface Request { @@ -14,6 +26,11 @@ declare global { * Combination of {protocol}://{hostname} */ host: string + + /** + * ass version + */ + version: string } files: { [key: string]: BusBoyFile } diff --git a/common/types.d.ts b/common/types.d.ts index 3f04c21..af05700 100644 --- a/common/types.d.ts +++ b/common/types.d.ts @@ -25,6 +25,8 @@ declare module 'ass' { s3?: S3Configuration; sql?: SqlConfiguration; + + rateLimit?: RateLimitConfiguration; } interface S3Configuration { @@ -58,6 +60,43 @@ declare module 'ass' { } } + /** + * rate limiter configuration + * @since 0.15.0 + */ + interface RateLimitConfiguration { + /** + * rate limit for the login endpoints + */ + login?: EndpointRateLimitConfiguration; + + /** + * rate limit for parts of the api not covered by other rate limits + */ + api?: EndpointRateLimitConfiguration; + + /** + * rate limit for file uploads + */ + upload?: EndpointRateLimitConfiguration; + } + + /** + * rate limiter per-endpoint configuration + * @since 0.15.0 + */ + interface EndpointRateLimitConfiguration { + /** + * maximum number of requests per duration + */ + requests: number; + + /** + * rate limiting window in seconds + */ + duration: number; + } + interface UserConfigTypeChecker { uploadsDir: (val: any) => boolean; idType: (val: any) => boolean; @@ -81,6 +120,9 @@ declare module 'ass' { database: (val: any) => boolean; } } + rateLimit: { + endpoint: (val: any) => boolean; + } } /** diff --git a/frontend/admin.mts b/frontend/admin.mts new file mode 100644 index 0000000..62f94c6 --- /dev/null +++ b/frontend/admin.mts @@ -0,0 +1,4 @@ +import { SlInput, SlButton } from '@shoelace-style/shoelace'; + +// * Wait for the document to be ready +document.addEventListener('DOMContentLoaded', () => console.log('Admin page loaded')); diff --git a/frontend/login.mts b/frontend/login.mts new file mode 100644 index 0000000..7a76a74 --- /dev/null +++ b/frontend/login.mts @@ -0,0 +1,46 @@ +import { SlInput, SlButton } from '@shoelace-style/shoelace'; + +const genericErrorAlert = () => alert('An error occured, please check the console for details'); +const errAlert = (logTitle: string, err: any, stream: 'error' | 'warn' = 'error') => (console[stream](logTitle, err), genericErrorAlert()); +const errReset = (message: string, element: SlButton) => (element.disabled = false, alert(message)); + +// * Wait for the document to be ready +document.addEventListener('DOMContentLoaded', () => { + + const Elements = { + usernameInput: document.querySelector('#login-username') as SlInput, + passwordInput: document.querySelector('#login-password') as SlInput, + submitButton: document.querySelector('#login-submit') as SlButton + }; + + // * Login button click handler + Elements.submitButton.addEventListener('click', async () => { + Elements.submitButton.disabled = true; + + // Make sure fields are filled + if (Elements.usernameInput.value == null || Elements.usernameInput.value === '') + return errReset('Username is required!', Elements.submitButton); + if (Elements.passwordInput.value == null || Elements.passwordInput.value === '') + return errReset('Password is required!', Elements.submitButton); + + fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: Elements.usernameInput.value, + password: Elements.passwordInput.value + }) + }) + .then((res) => res.json()) + .then((data: { + success: boolean, + message: string, + meta: { redirectTo: string } + }) => { + if (!data.success) alert(data.message); + else window.location.href = data.meta.redirectTo; + }) + .catch((err) => errAlert('POST to /api/login failed!', err)) + .finally(() => Elements.submitButton.disabled = false); + }); +}); diff --git a/frontend/setup.mts b/frontend/setup.mts index 0d8b70a..306e608 100644 --- a/frontend/setup.mts +++ b/frontend/setup.mts @@ -3,6 +3,27 @@ import { IdType, UserConfiguration } from 'ass'; const genericErrorAlert = () => alert('An error occured, please check the console for details'); const errAlert = (logTitle: string, err: any, stream: 'error' | 'warn' = 'error') => (console[stream](logTitle, err), genericErrorAlert()); +const errReset = (message: string, element: SlButton) => (element.disabled = false, alert(message)); +const genericRateLimit = (config: object, category: string, submitButton: SlButton, requests: SlInput, time: SlInput) => { + if ((requests.value || time.value) != '') { + if (requests.value == '') { + errReset(`No count for ${category} rate limit`, submitButton); + return true; // this should probably be false but this lets us chain this until we see an error + } + + if (time.value == '') { + errReset(`No time for ${category} rate limit`, submitButton); + return true; + } + + (config as any)[category] = { + requests: parseInt(requests.value), + duration: parseInt(time.value), + }; + } + + return false; +}; // * Wait for the document to be ready document.addEventListener('DOMContentLoaded', () => { @@ -28,6 +49,13 @@ document.addEventListener('DOMContentLoaded', () => { userUsername: document.querySelector('#user-username') as SlInput, userPassword: document.querySelector('#user-password') as SlInput, + ratelimitLoginRequests: document.querySelector('#ratelimit-login-requests') as SlInput, + ratelimitLoginTime: document.querySelector('#ratelimit-login-time') as SlInput, + ratelimitApiRequests: document.querySelector('#ratelimit-api-requests') as SlInput, + ratelimitApiTime: document.querySelector('#ratelimit-api-time') as SlInput, + ratelimitUploadRequests: document.querySelector('#ratelimit-upload-requests') as SlInput, + ratelimitUploadTime: document.querySelector('#ratelimit-upload-time') as SlInput, + submitButton: document.querySelector('#submit') as SlButton, }; @@ -71,15 +99,34 @@ document.addEventListener('DOMContentLoaded', () => { }; } + // append rate limit config, if specified + if (( + Elements.ratelimitLoginRequests.value + || Elements.ratelimitLoginTime.value + || Elements.ratelimitUploadRequests.value + || Elements.ratelimitUploadTime.value + || Elements.ratelimitApiRequests.value + || Elements.ratelimitApiTime.value) != '' + ) { + if (!config.rateLimit) config.rateLimit = {}; + + if ( + genericRateLimit(config.rateLimit, 'login', Elements.submitButton, Elements.ratelimitLoginRequests, Elements.ratelimitLoginTime) + || genericRateLimit(config.rateLimit, 'api', Elements.submitButton, Elements.ratelimitApiRequests, Elements.ratelimitApiTime) + || genericRateLimit(config.rateLimit, 'upload', Elements.submitButton, Elements.ratelimitUploadRequests, Elements.ratelimitUploadTime) + ) { + return; + } + } + // ! Make sure the admin user fields are set - const adminErrReset = (message: string) => (Elements.submitButton.disabled = false, alert(message)); if (Elements.userUsername.value == null || Elements.userUsername.value === '') - return adminErrReset('Admin username is required!'); + return errReset('Admin username is required!', Elements.submitButton); if (Elements.userPassword.value == null || Elements.userPassword.value === '') - return adminErrReset('Admin password is required!'); + return errReset('Admin password is required!', Elements.submitButton); // Do setup - fetch('/setup', { + fetch('/api/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) @@ -105,11 +152,11 @@ document.addEventListener('DOMContentLoaded', () => { success: boolean, message: string }) => { - alert(data.message); if (data.success) window.location.href = '/admin'; + else alert(data.message); }); }) - .catch((err) => errAlert('POST to /setup failed!', err)) + .catch((err) => errAlert('POST to /api/setup failed!', err)) .finally(() => Elements.submitButton.disabled = false); }); }); diff --git a/frontend/user.mts b/frontend/user.mts new file mode 100644 index 0000000..49c9bea --- /dev/null +++ b/frontend/user.mts @@ -0,0 +1,4 @@ +import { SlInput, SlButton } from '@shoelace-style/shoelace'; + +// * Wait for the document to be ready +document.addEventListener('DOMContentLoaded', () => console.log('User page loaded')); diff --git a/install/docker-linux.sh b/install/docker-linux.sh deleted file mode 100755 index 6a4d3a3..0000000 --- a/install/docker-linux.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - -echo "Installing ass-docker for Linux..." - -# Ensure that ./uploads/thumbnails/ exists -mkdir -p ./uploads/thumbnails/ - -# Ensure that ./share/ exists -mkdir -p ./share/ - -# Ensure that files config.json, auth.json, & data.json exist -for value in config.json auth.json data.json -do - if [ ! -f $value ]; then - touch $value - fi -done - -# Wait for user to confirm -echo "Continuing will run docker compose. Continue? (Press Ctrl+C to abort)" -read -n 1 -s -r -p "Press any key to continue..." - -echo Running setup... - -# Bring up the container and run the setup -docker compose up -d && docker compose exec ass npm run setup && docker compose restart - -# Done! -echo "ass-docker for Linux installed!" -echo "Run the following to view commands:" -echo "$ docker compose logs -f --tail=50 --no-log-prefix ass" diff --git a/install/docker-windows.bat b/install/docker-windows.bat deleted file mode 100644 index 34cce34..0000000 --- a/install/docker-windows.bat +++ /dev/null @@ -1,28 +0,0 @@ -@echo off - -ECHO Installing ass-docker for Windows... - -REM Ensure that ./uploads/thumbnails/ exists -if not exist "./uploads/thumbnails/" md "./uploads/thumbnails/" - -REM Ensure that ./share/ exists -if not exist "./share/" md "./share/" - -REM Ensure that files config.json, auth.json, & data.json exist -if not exist "./config.json" echo. >> "./config.json" -if not exist "./auth.json" echo. >> "./auth.json" -if not exist "./data.json" echo. >> "./data.json" - -REM Wait for user to confirm -ECHO Continuing will run docker compose. Continue? (Press Ctrl+C to abort) -PAUSE - -ECHO Running setup... - -REM Bring up the container and run the setup -docker compose up -d && docker compose exec ass npm run setup && docker compose restart - -REM Done! -ECHO ass-docker for Windows installed! -ECHO Run the following to view commands: -ECHO > docker compose logs -f --tail=50 --no-log-prefix ass diff --git a/package-lock.json b/package-lock.json index e8fdacc..943bcdd 100755 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "cssnano": "^6.0.1", "express": "^4.18.2", "express-busboy": "^10.1.0", + "express-rate-limit": "^7.1.2", + "express-session": "^1.17.3", "ffmpeg-static": "^5.2.0", "fs-extra": "^11.1.1", "luxon": "^3.4.3", @@ -3985,6 +3987,56 @@ "uuid": "^8.3.2" } }, + "node_modules/express-rate-limit": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.1.2.tgz", + "integrity": "sha512-uvkFt5JooXDhUhrfgqXLyIsAMRCtU1o8W/p0Q2p5U2ude7fEOfFaP0kSYbHOHmPbA9ZEm1JqrRne3vL9pVCBXA==", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, + "node_modules/express-session": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", + "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", + "dependencies": { + "cookie": "0.4.2", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -5421,6 +5473,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6332,6 +6392,14 @@ "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", diff --git a/package.json b/package.json index 4d7553d..8558bea 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "npm run build && npm start", + "fresh": "rm -dr .ass-data/ & npm run dev", "build": "rm -dr dist-*/ & npm run build:backend && npm run build:frontend", "build:backend": "tsc -p backend/", "build:frontend": "tsc -p frontend/", @@ -47,6 +48,8 @@ "cssnano": "^6.0.1", "express": "^4.18.2", "express-busboy": "^10.1.0", + "express-rate-limit": "^7.1.2", + "express-session": "^1.17.3", "ffmpeg-static": "^5.2.0", "fs-extra": "^11.1.1", "luxon": "^3.4.3", diff --git a/sample_screenshotter.sh b/sample_screenshotter.sh new file mode 100644 index 0000000..d6f3603 --- /dev/null +++ b/sample_screenshotter.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash + +# Script Configuration +# Load configuration file if available +# this is useful if you want to source keys from a secret file +CONFIG_FILE="config.sh" +if [ -f "$CONFIG_FILE" ]; then + # shellcheck disable=1090 + source "${CONFIG_FILE}" +fi + +LOG_DIR=$(pwd) +if [ ! -d "$LOG_DIR" ]; then + echo "The directory you have specified to save the logs does not exist." + echo "Please create the directory with the following command:" + echo "mkdir -p $LOG_DIR" + echo -en "Or specify a different LOG_DIR\n" + exit 1 +fi + +IMAGE_PATH="$HOME/Pictures" +if [ ! -d "$IMAGE_PATH" ]; then + echo "The directory you have specified to save the screenshot does not exist." + echo "Please create the directory with the following command:" + echo "mkdir -p $IMAGE_PATH" + echo -en "Or specify a different IMAGE_PATH\n" + exit 1 +fi + +IMAGE_NAME="ass" +FILE="${IMAGE_PATH}/${IMAGE_NAME}.png" + +# Function to check if a tool is installed +check_tool() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to take Flameshot screenshots +takeFlameshot() { + # check if flameshot tool is installed + REQUIRED_TOOLS=("flameshot") + + for tool in "${REQUIRED_TOOLS[@]}"; do + if ! check_tool "$tool"; then + echo "Error: $tool is not installed. Please install it before using this script." + exit 1 + fi + done + + flameshot config -f "${IMAGE_NAME}" + flameshot gui -r -p "${IMAGE_PATH}" >/dev/null +} + +# Function to take Wayland screenshots using grim + slurp +takeGrimshot() { + # check if grim and slurp are installed + REQUIRED_TOOLS=("grim" "slurp") + for tool in "${REQUIRED_TOOLS[@]}"; do + if ! check_tool "$tool"; then + echo "Error: $tool is not installed. Please install it before using this script." + exit 1 + fi + done + + grim -g "$(slurp)" "${FILE}" >/dev/null +} + +# Function to remove the taken screenshot +removeTargetFile() { + echo -en "Process complete.\nRemoving image.\n" + rm -v "${FILE}" +} + +# Function to upload target image to your ass instance +uploadScreenshot() { + echo -en "KEY & DOMAIN are set. Attempting to upload to your ass instance.\n" + URL=$(curl -X POST \ + -H "Content-Type: multipart/form-data" \ + -H "Accept: application/json" \ + -H "User-Agent: ShareX/13.4.0" \ + -H "Authorization: $KEY" \ + -F "file=@${FILE}" "https://$DOMAIN/" | grep -Po '(?<="resource":")[^"]+') + if [[ "${XDG_SESSION_TYPE}" == x11 ]]; then + printf "%s" "$URL" | xclip -sel clip + elif [[ "${XDG_SESSION_TYPE}" == wayland ]]; then + printf "%s" "$URL" | wl-copy + else + echo -en "Invalid desktop session!\nExiting.\n" + exit 1 + fi +} + +localScreenshot() { + echo -en "KEY & DOMAIN variables are not set. Attempting local screenshot.\n" + if [[ "${XDG_SESSION_TYPE}" == x11 ]]; then + xclip -sel clip -target image/png <"${FILE}" + elif [[ "${XDG_SESSION_TYPE}" == wayland ]]; then + wl-copy <"${FILE}" + else + echo -en "Unknown display backend. Assuming Xorg and using xclip.\n" + xclip -sel clip -target image/png <"${FILE}" + fi +} + +# Check if the screenshot tool based on display backend +if [[ "${XDG_SESSION_TYPE}" == x11 ]]; then + echo -en "Display backend detected as Xorg (x11), using Flameshot\n" + takeFlameshot +elif [[ "${XDG_SESSION_TYPE}" == wayland ]]; then + echo -en "Display backend detected as Wayland, using grim & slurp\n" + takeGrimshot +else + echo -en "Unknown display backend. Assuming Xorg and using Flameshot\n" + takeFlameshot >"${LOG_DIR}/flameshot.log" + echo -en "Done. Make sure you check for any errors and report them.\nLogfile located in '${LOG_DIR}'\n" +fi + +# Check if the screenshot file exists before proceeding +if [[ -f "${FILE}" ]]; then + if [[ -n "$KEY" && -n "$DOMAIN" ]]; then + # Upload the file to the ass instance + uploadImage + + # Remove image + removeTargetFile + else + # Take a screenshot locally + localScreenshot + + # Remove image + removeTargetFile + fi +else + echo -en "Target file ${FILE} was not found. Aborting screenshot.\n" + exit 1 +fi diff --git a/tailwind.css b/tailwind.css index 07a21b6..6e62757 100644 --- a/tailwind.css +++ b/tailwind.css @@ -26,4 +26,8 @@ } } -@layer utilities {} \ No newline at end of file +@layer utilities { + .flex-center { + @apply items-center justify-center; + } +} \ No newline at end of file diff --git a/tailwind.old.css b/tailwind.old.css deleted file mode 100644 index 4a52983..0000000 --- a/tailwind.old.css +++ /dev/null @@ -1,33 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base {} - -@layer components { - .res-media { - @apply border-l-4 rounded max-h-half-port; - } - - .link { - @apply no-underline hover_no-underline active_no-underline visited_no-underline - - /* regular, visited */ - text-link-primary visited_text-link-primary - border-b-2 visited_border-b-2 - border-transparent visited_border-transparent - rounded-sm visited_rounded-sm - - /* hover */ - hover_text-link-hover - hover_border-hover - - /* active */ - active_text-link-active - - /* transitions */ - ease-linear duration-150 transition-all; - } -} - -@layer utilities {} diff --git a/views/admin.pug b/views/admin.pug index 086cff4..65b04af 100644 --- a/views/admin.pug +++ b/views/admin.pug @@ -4,4 +4,6 @@ block title block section span admin block content - h1.text-3xl Coming soon. \ No newline at end of file + h1.text-3xl Coming soon. + + script(src='/admin/ui.js') diff --git a/views/login.pug b/views/login.pug index dbb25c2..4d621a4 100644 --- a/views/login.pug +++ b/views/login.pug @@ -2,5 +2,10 @@ extends _base_ block section span login block content - .flex.flex-col.items-center.justify-center.h-full - h1.text-3xl Login \ No newline at end of file + .flex.flex-col.flex-center.h-full: .setup-panel + h3 Username + sl-input#login-username(type='text' placeholder='username' clearable): sl-icon(slot='prefix' name='fas-user' library='fa') + h3 Password + sl-input#login-password(type='password' placeholder='password' clearable): sl-icon(slot='prefix' name='fas-lock' library='fa') + sl-button.mt-4#login-submit(type='primary' submit) Login + script(src='/login/ui.js') \ No newline at end of file diff --git a/views/setup.pug b/views/setup.pug index acff4db..6446068 100644 --- a/views/setup.pug +++ b/views/setup.pug @@ -55,6 +55,22 @@ block content sl-input#mysql-password(type='password' placeholder='super-secure' clearable): sl-icon(slot='prefix' name='fas-lock' library='fa') h3.setup-text-item-title Database sl-input#mysql-database(type='text' placeholder='assdb' clearable): sl-icon(slot='prefix' name='fas-database' library='fa') + + //- * Rate Limits + h2.setup-text-section-header.mt-4 Rate Limits #[span.setup-text-optional optional] + .setup-panel + h3.setup-text-item-title Generic API - Requests + sl-input#ratelimit-api-requests(type='text' placeholder='120' clearable): sl-icon(slot='prefix' name='fas-hashtag' library='fa') + h3.setup-text-item-title Generic API - Seconds per reset + sl-input#ratelimit-api-time(type='text' placeholder='60' clearable): sl-icon(slot='prefix' name='fas-clock' library='fa') + h3.setup-text-item-title Login - Requests + sl-input#ratelimit-login-requests(type='text' placeholder='5' clearable): sl-icon(slot='prefix' name='fas-hashtag' library='fa') + h3.setup-text-item-title Login - Seconds per reset + sl-input#ratelimit-login-time(type='text' placeholder='30' clearable): sl-icon(slot='prefix' name='fas-clock' library='fa') + h3.setup-text-item-title File upload - Requests + sl-input#ratelimit-upload-requests(type='text' placeholder='120' clearable): sl-icon(slot='prefix' name='fas-hashtag' library='fa') + h3.setup-text-item-title File upload - Seconds per reset + sl-input#ratelimit-upload-time(type='text' placeholder='60' clearable): sl-icon(slot='prefix' name='fas-clock' library='fa') sl-button.w-32.mt-2.self-center#submit(type='primary' submit) Submit script(src='/setup/ui.js') \ No newline at end of file diff --git a/views/user.pug b/views/user.pug new file mode 100644 index 0000000..39fd493 --- /dev/null +++ b/views/user.pug @@ -0,0 +1,9 @@ +extends _base_ +block title + title ass user 🍑 +block section + span user +block content + h1.text-3xl Coming soon. + + script(src='/user/ui.js')