Merge branch 'patch/docker' of https://github.com/Gauvino/ass into patch/docker

pull/244/head
Uruk 11 months ago
commit a8fda8800d

@ -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

@ -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 }}"

@ -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;
}

@ -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);

@ -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<AssFile | AssUser | f
reject(err);
}
});
export const getAll = (sector: DataSector): Promise<{ [key: string]: AssFile | AssUser } | false> => 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);
}
});

@ -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 };

@ -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<boolean> => {
return new Promise((resolve, reject) =>
fs.open(file, 'r+')
.then((fd) => removeLocation(file,
// Read function
(size: number, offset: number): Promise<Buffer> =>
fs.read(fd, Buffer.alloc(size), 0, size, offset)
.then(({ buffer }) => Promise.resolve(buffer)),
// Write function
(val: string, offset: number, enc: BufferEncoding): Promise<void> =>
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<boolean> => new Promise((resolve, reject) =>
fs.open(file, 'r+')
.then((fd) => removeLocation(file,
// Read function
(size: number, offset: number): Promise<Buffer> =>
fs.read(fd, Buffer.alloc(size), 0, size, offset)
.then(({ buffer }) => Promise.resolve(buffer)),
// Write function
(val: string, offset: number, enc: BufferEncoding): Promise<void> =>
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<string> => new Promise((resolve, reject) =>
@ -39,8 +38,7 @@ export const vibrant = (file: string, mimetype: string): Promise<string> => 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) {

@ -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<string, (req: Request, res: Response, next: NextFunction) => 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);
};
};

@ -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;
};

@ -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 });

@ -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!');

@ -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 };

@ -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

@ -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<UploadToken | AssFile | AssUser | undefined> {
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);
}
});
}
}

@ -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]}`);
}
};

@ -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.');

17
common/global.d.ts vendored

@ -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 }

42
common/types.d.ts vendored

@ -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;
}
}
/**

@ -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'));

@ -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);
});
});

@ -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);
});
});

@ -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'));

@ -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"

@ -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

68
package-lock.json generated

@ -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",

@ -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",

@ -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

@ -26,4 +26,8 @@
}
}
@layer utilities {}
@layer utilities {
.flex-center {
@apply items-center justify-center;
}
}

@ -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 {}

@ -4,4 +4,6 @@ block title
block section
span admin
block content
h1.text-3xl Coming soon.
h1.text-3xl Coming soon.
script(src='/admin/ui.js')

@ -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
.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')

@ -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')

@ -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')
Loading…
Cancel
Save