🍑 ass 0.14.0 🍑

Merge pull request #182 from tycrek/0.14.0/stage
pull/191/head releases/0.14.0
Josh Moore 2 years ago committed by GitHub
commit 47fa061548
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

77
.github/README.md vendored

@ -199,10 +199,6 @@ For HTTPS support, you must configure a reverse proxy. I recommend Caddy but any
[Caddy]: https://caddyserver.com/
[my tutorial]: https://old.jmoore.dev/tutorials/2021/03/caddy-express-reverse-proxy/
## Generating new tokens
If you need to generate a new token at any time, run `npm run new-token <username>`. This will **automatically** load the new token so there is no need to restart ass. Username field is optional; if left blank, a random username will be created.
## Cloudflare users
In your Cloudflare DNS dashboard, set your domain/subdomain to **DNS Only** if you experience issues with **Proxied**.
@ -247,7 +243,7 @@ If you primarily share media on Discord, you can add these additional (optional)
| Header | Purpose |
| ------ | ------- |
| **`X-Ass-OG-Title`** | Large text shown above your media |
| **`X-Ass-OG-Title`** | Large text shown above your media. Required for embeds to appear on desktop. |
| **`X-Ass-OG-Description`** | Small text shown below the title but above the media (does not show up on videos) |
| **`X-Ass-OG-Author`** | Small text shown above the title |
| **`X-Ass-OG-Author-Url`** | URL to open when the Author is clicked |
@ -265,6 +261,20 @@ You can insert certain metadata into your embeds with these placeholders:
| **`&filename`** | The original filename of the uploaded file |
| **`&timestamp`** | The timestamp of when the file was uploaded (example: `Oct 14, 1983, 1:30 PM`) |
#### Server-side embed configuration
You may also specify a default embed config on the server. Keep in mind that if users specify the `X-Ass-OG-Title` header, the server-side config will be ignored. To configure the server-side embed, create a new file in the `share/` directory named `embed.json`. Available options are:
- **`title`**
- `description`
- `author`
- `authorUrl`
- `provider`
- `providerUrl`
- `color`
Their values are equivalent to the headers listed above.
### Webhooks
You may use Discord webhooks as an easy way to keep track of your uploads. The first step is to [create a new Webhook]. You only need to follow the first section, **Making a Webhook**. Once you are done that, click **Copy Webhook URL**. Finally, add these headers to your custom uploader:
@ -290,6 +300,7 @@ If you want to customize the font or colours of the viewer page, create a file i
| **`bgViewer`** | Background colour for the viewer element |
| **`txtPrimary`** | Primary text colour; this should be your main brand colour. |
| **`txtSecondary`** | Secondary text colour; this is used for the file details. |
| **`linkPrimary`** | Primary link colour |
| **`linkHover`** | Colour of the `hover` effect for links |
| **`linkActive`** | Colour of the `active` effect for links |
| **`borderHover`** | Colour of the `hover` effect for borders; this is used for the underlining links. |
@ -337,10 +348,65 @@ S3 servers are generally very fast & have very good uptime, though this will dep
[Amazon S3]: https://en.wikipedia.org/wiki/Amazon_S3
[Skynet Labs]: https://github.com/SkynetLabs
## New user system (v0.14.0)
The user system was overhauled in v0.14.0 to allow more features and flexibility. New fields on users include `admin`, `passhash`, `unid`, and `meta` (these will be documented more once the system is finalized).
New installs will automatically generate a default user. Check the `auth.json` file for the token. You will use this for API requests and to authenticate within ShareX.
ass will automatically convert your old `auth.json` to the new format. **Always backup your `auth.json` and `data.json` before updating**. By default, the original user (named `ass`) will be marked as an admin.
### Adding users
You may add users via the CLI or the API. I'll document the API further in the future.
#### CLI
```bash
npm run cli-adduser <username> <password> [admin] [meta]
```
| Argument | Purpose |
| -------- | ------- |
| **`username`** `string` | The username of the user. |
| **`password`** `string` | The password of the user. |
| **`admin?`** `boolean` | Whether the user is an admin. Defaults to `false`. |
| **`meta?`** `string` | Any additional metadata to store on the user, as a JSON string. |
**Things still not added:**
- Modifying/deleting users via the API
## Developer API
ass includes an API (v0.14.0) for frontend developers to easily integrate with. Right now the API is pretty limited but I will expand on it in the future, with frontend developer feedback.
Any endpoints requiring authorization will require an `Authorization` header with the value being the user's upload token. Admin users are a new feature introduced in v0.14.0. Admin users can access all endpoints, while non-admin users can only access those relevant to them.
Other things to note:
- **All endpoints are prefixed with `/api/`**.
- All endpoints will return a JSON object unless otherwise specified.
- Successful endpoints *should* return a `200` status code. Any errors will use the corresponding `4xx` or `5xx` status code (such as `401 Unauthorized`).
### API endpoints
| Endpoint | Purpose | Admin? |
| -------- | ------- | ------ |
| **`GET /user/all`** | Returns a list of all users | Yes |
| **`GET /user/self`** | Returns the current user | No |
| **`GET /user/token/:token`** | Returns the user with the given token | No |
| **`POST /user/reset`** | Resets the current user's **password** (token resets coming soon). Request body must be a JSON object including `username` and `password`. | No |
| **`GET /user/:id`** | Returns the user with the given ID | Yes |
| **`POST /user/new`** | Creates a new user. Request body must be a JSON object including `username` and `password`. You may optionally include `admin` (boolean) or `meta` (object). Returns 400 if fails. | Yes |
## Custom frontends - OUTDATED
**Please be aware that this section is outdated (marked as of 2022-04-15). It will be updated when I overhaul the frontend system.**
**Update 2022-12-24: I plan to overhaul this early in 2023.**
ass is intended to provide a strong backend for developers to build their own frontends around. [Git Submodules] make it easy to create custom frontends. Submodules are their own projects, which means you are free to build the router however you wish, as long as it exports the required items. A custom frontend is really just an [Express.js router].
**For a detailed walkthrough on developing your first frontend, [consult the wiki][ctw1].**
@ -395,7 +461,6 @@ ass has a number of pre-made npm scripts for you to use. **All** of these script
| `setup` | Starts the easy setup process. Should be run after any updates that introduce new config options. |
| `metrics` | Runs the metrics script. This is a simple script that outputs basic resource statistics. |
| `purge` | Purges all uploads & data associated with them. This does **not** delete any users, however. |
| `new-token` | Generates a new API token. Accepts one parameter for specifying a username, like `npm run new-token <username>`. ass automatically detects the new token & reloads it, so there's no need to restart the server. |
| `engine-check` | Ensures your environment meets the minimum Node & npm version requirements. |
[`FORCE_COLOR`]: https://nodejs.org/dist/latest-v16.x/docs/api/cli.html#cli_force_color_1_2_3

4
.gitignore vendored

@ -104,11 +104,11 @@ dist
.tern-port
# tokens
auth.json
auth.json*
auth.*.json
# data
data.json
data.json*
# uploads
uploads/

851
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
{
"name": "ass",
"version": "0.13.0",
"version": "0.14.0-alpha.1",
"description": "The superior self-hosted ShareX server",
"main": "ass.js",
"engines": {
@ -9,12 +9,12 @@
},
"scripts": {
"dev": "npm run build && npm start",
"dev-win": "npm run build-skip-options && npm run start",
"build": "NODE_OPTIONS=\"--max-old-space-size=1024\" tsc",
"build-skip-options": "tsc",
"start": "node dist/ass.js",
"setup": "node dist/setup.js",
"metrics": "node dist/metrics.js",
"new-token": "node dist/generators/token.js",
"engine-check": "node dist/checkEngine.js",
"prestart": "npm run engine-check",
"presetup": "npm run engine-check",
@ -23,7 +23,10 @@
"docker-update": "git pull && npm run docker-uplite",
"docker-uplite": "docker-compose up --force-recreate --build -d && docker image prune -f",
"docker-upfull": "npm run docker-update && npm run docker-resetup",
"docker-resetup": "docker-compose exec ass npm run setup && docker-compose restart"
"docker-resetup": "docker-compose exec ass npm run setup && docker-compose restart",
"cli-setpassword": "node dist/tools/script.setpassword.js",
"cli-testpassword": "node dist/tools/script.testpassword.js",
"cli-adduser": "node dist/tools/script.adduser.js"
},
"repository": "github:tycrek/ass",
"keywords": [
@ -41,6 +44,7 @@
"dependencies": {
"@skynetlabs/skynet-nodejs": "^2.3.0",
"@tsconfig/node16": "^1.0.1",
"@tycrek/discord-hookr": "^0.1.0",
"@tycrek/express-nofavicon": "^1.0.3",
"@tycrek/express-postcss": "^0.2.4",
"@tycrek/isprod": "^2.0.2",
@ -50,10 +54,10 @@
"any-shell-escape": "^0.1.1",
"autoprefixer": "^10.4.4",
"aws-sdk": "^2.1115.0",
"bcrypt": "^5.1.0",
"check-node-version": "^4.2.1",
"crypto-random-string": "3.3.1",
"cssnano": "^5.1.7",
"discord-webhook-node": "^1.1.8",
"escape-html": "^1.0.3",
"express": "^4.17.3",
"express-brute": "^1.0.1",
@ -62,6 +66,7 @@
"fs-extra": "^10.0.1",
"helmet": "^4.6.0",
"luxon": "^2.3.1",
"nanoid": "^3.3.4",
"node-fetch": "^2.6.7",
"node-vibrant": "^3.1.6",
"postcss-font-magician": "^3.0.0",
@ -76,6 +81,7 @@
"uuid": "^8.3.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/escape-html": "^1.0.1",
"@types/express": "^4.17.13",
"@types/express-brute": "^1.0.1",
@ -92,4 +98,4 @@
"@types/uuid": "^8.3.1",
"@types/ws": "^7.4.7"
}
}
}

@ -3,13 +3,14 @@ import { Config, MagicNumbers, Package } from 'ass-json';
//#region Imports
import fs from 'fs-extra';
import express, { Request, Response } from 'express';
import express, { Request, Response, json as BodyParserJson } from 'express';
import nofavicon from '@tycrek/express-nofavicon';
import { epcss } from '@tycrek/express-postcss';
import tailwindcss from 'tailwindcss';
import helmet from 'helmet';
import { path, log, getTrueHttp, getTrueDomain } from './utils';
import { onStart as ApiOnStart } from './routers/api';
//#endregion
//#region Setup - Run first time setup if using Docker (pseudo-process, setup will be run with docker exec)
@ -43,7 +44,7 @@ const ROUTERS = {
};
// Read users and data
import { users } from './auth';
import { onStart as AuthOnStart, users } from './auth';
import { data } from './data';
//#endregion
@ -79,6 +80,10 @@ app.get(['/'], bruteforce.prevent, (_req, _res, next) => next());
// Express logger middleware
app.use(log.middleware());
// Body parser for API POST requests
// (I really don't like this being top level but it does not work inside the API Router as of 2022-12-24)
app.use(BodyParserJson());
// Helmet security middleware
app.use(helmet.noSniff());
app.use(helmet.ieNoOpen());
@ -105,6 +110,9 @@ ASS_FRONTEND.enabled && app.use(ASS_FRONTEND.endpoint, ASS_FRONTEND.router); //
// Upload router (has to come after custom frontends as express-busboy interferes with all POST calls)
app.use('/', ROUTERS.upload);
// API
app.use('/api', ApiOnStart());
// CSS
app.use('/css', epcss({
cssPath: path('tailwind.css'),
@ -123,10 +131,12 @@ app.use('/:resourceId', (req, _res, next) => (req.resourceId = req.params.resour
// Error handler
app.use((err: ErrWrap, _req: Request, res: Response) => log.error(err.message).err(err).callback(() => res.sendStatus(CODE_INTERNAL_SERVER_ERROR))); // skipcq: JS-0128
(function start() {
(async function start() {
await AuthOnStart();
if (data() == null) setTimeout(start, 100);
else log
.info('Users', `${Object.keys(users).length}`)
.info('Users', `${users.length}`)
.info('Files', `${data().size}`)
.info('Data engine', data().name, data().type)
.info('Frontend', ASS_FRONTEND.enabled ? ASS_FRONTEND.brand : 'disabled', `${ASS_FRONTEND.enabled ? `${getTrueHttp()}${getTrueDomain()}${ASS_FRONTEND.endpoint}` : ''}`)

@ -3,18 +3,238 @@
*/
import fs from 'fs-extra';
import { log, path, arrayEquals } from './utils';
export const users = require('../auth.json').users || {};
// Monitor auth.json for changes (triggered by running 'npm run new-token')
fs.watch(path('auth.json'), { persistent: false },
(eventType: String) => eventType === 'change' && fs.readJson(path('auth.json'))
.then((json: { users: JSON[] }) => {
if (!(arrayEquals(Object.keys(users), Object.keys(json.users)))) {
// @ts-ignore
Object.keys(json.users).forEach((token) => (!Object.prototype.hasOwnProperty.call(users, token)) && (users[token] = json.users[token]));
log.info('New token added', Object.keys(users)[Object.keys(users).length - 1] || 'No new token');
import { nanoid } from 'nanoid';
import { Request } from 'express';
import bcrypt from 'bcrypt';
import { log, path } from './utils';
import { data } from './data';
import { User, Users, OldUsers } from './types/auth';
import { FileData } from './types/definitions';
const SALT_ROUNDS = 10;
/**
* !!!!!
* Things for tycrek to do:
* - [x] Add a way to configure passwords
* - [x] Create new users
* - [ ] Modify user (admin, meta, replace token/token history)
* - [ ] Delete user
* - [x] Get user
* - [x] Get users
* - [x] Get user by token
*/
/**
* Map of users
*/
export const users = [] as User[];
/**
* Migrates the old auth.json format to the new one
* @since v0.14.0
*/
const migrate = (authFileName = 'auth.json'): Promise<Users> => new Promise(async (resolve, reject) => {
// Get ready to read the old auth.json file
const authPath = path(authFileName);
const oldUsers = fs.readJsonSync(authPath).users as OldUsers;
// Create a new users object
const newUsers: Users = { users: [], meta: {} };
newUsers.migrated = true;
// Loop through each user
await Promise.all(Object.entries(oldUsers).map(async ([token, { username }]) => {
// Determine if this user is the admin
const admin = Object.keys(oldUsers).indexOf(token) === 0;
const passhash = admin ? await bcrypt.hash(nanoid(32), SALT_ROUNDS) : '';
// Create a new user object
const newUser: User = {
unid: nanoid(),
username,
passhash,
token,
admin,
meta: {}
};
newUsers.users.push(newUser);
}));
// Save the new users object to auth.json
fs.writeJson(authPath, newUsers, { spaces: '\t' })
.catch(reject)
// Migrate the datafile (token => uploader)
.then(() => data().get())
.then((fileData: [string, FileData][]) =>
// ! A note about this block.
// I know it's gross. But using Promise.all crashes low-spec servers, so I had to do it this way. Sorry.
// Thanks to CoPilot for writing `runQueue` :D
// Wait for all the deletions and puts to finish
new Promise((resolve, reject) => {
// Create a queue of functions to run
const queue = fileData.map(([key, file]) => async () => {
// We need to use `newUsers` because `users` hasn't been re-assigned yet
const user = newUsers.users.find((user) => user.token === file.token!)?.unid ?? ''; // ? This is probably fine
// Because of the stupid way I wrote papito, we need to DEL before we can PUT
await data().del(key);
// PUT the new data
return data().put(key, { ...file, uploader: user });
});
// Recursively run the queue, hopefully sequentially without running out of memory
const runQueue = (index: number) => {
if (index >= queue.length) return resolve(void 0);
queue[index]().then(() => runQueue(index + 1)).catch(reject);
};
runQueue(0);
}))
// We did it hoofuckingray
.then(() => log.success('Migrated all auth & file data to new auth system'))
.then(() => resolve(newUsers))
.catch(reject);
});
/**
* Creates a new user account
* @since v0.14.0
*/
export const createNewUser = (username: string, password: string, admin: boolean, meta?: { [key: string]: any }): Promise<User> => new Promise(async (resolve, reject) => {
// Create a new user object
const newUser: User = {
unid: nanoid(),
username,
passhash: await bcrypt.hash(password, SALT_ROUNDS),
token: nanoid(32),
admin,
meta: meta || {}
};
// Add the user to the users map
users.push(newUser);
// Save the new user to auth.json
const authPath = path('auth.json');
const authData = fs.readJsonSync(authPath) as Users;
if (!authData.users) authData.users = [];
authData.users.push(newUser);
if (!authData.meta) authData.meta = {};
fs.writeJson(authPath, authData, { spaces: '\t' })
.then(() => log.info('Created new user', newUser.username, newUser.unid))
.then(() => resolve(newUser))
.catch(reject);
});
/**
* Sets the password for a user
* @since v0.14.0
*/
export const setUserPassword = (unid: string, password: string): Promise<User> => new Promise(async (resolve, reject) => {
// Find the user
const user = users.find((user) => user.unid === unid);
if (!user) return reject(new Error('User not found'));
// Set the password
user.passhash = await bcrypt.hash(password, SALT_ROUNDS);
// Save the new user to auth.json
const authPath = path('auth.json');
const authData = fs.readJsonSync(authPath) as Users;
const userIndex = authData.users.findIndex((user) => user.unid === unid);
authData.users[userIndex] = user;
fs.writeJson(authPath, authData, { spaces: '\t' })
.then(() => resolve(user))
.catch(reject);
});
/**
* Called by ass.ts on startup
* @since v0.14.0
*/
export const onStart = (authFile = 'auth.json') => new Promise((resolve, reject) => {
// Reset user array (https://stackoverflow.com/questions/1232040/how-do-i-empty-an-array-in-javascript#1232046)
// ! I don't think this works properly..?
users.splice(0, users.length);
const file = path(authFile);
log.debug('Reading', file);
// Check if the file exists
fs.stat(file)
// Create the file if it doesn't exist
.catch((_errStat) => {
log.debug('File does not exist', authFile, 'will be created automatically');
return fs.writeJson(file, { migrated: true });
})
.catch((errWriteJson) => log.error('Failed to create auth.json').callback(reject, errWriteJson))
// File exists or was created
.then(() => fs.readJson(file))
.then((json: Users) => {
// Check if the file is the old format
if (json.migrated === undefined || !json.migrated) return (
log.debug('auth.json is in old format, migrating'),
migrate(authFile));
else return json;
})
.then(async (json) => {
// Check if the file is empty
if (!json.users || json.users.length === 0) {
log.debug('auth.json is empty, creating default user');
return await createNewUser('ass', nanoid(), true);
}
// Check if the CLI key is set
if (!json.cliKey || json.cliKey.length === 0) {
log.debug('CLI key is not set, generating new key');
json.cliKey = nanoid(32);
fs.writeJsonSync(file, json, { spaces: '\t' });
}
// Add users to the map
return json.users.forEach((user) => users.push(user));
})
.catch(console.error));
.catch((errReadJson) => log.error('Failed to read auth.json').callback(reject, errReadJson))
.then(resolve);
});
/**
* Retrieves a user using their upload token. Returns `null` if the user does not exist.
* @since v0.14.0
*/
export const findFromToken = (token: string) => users.find((user) => user.token === token) || null;
/**
* Verifies that the upload token in the request exists in the user map
* @since v0.14.0
*/
export const verifyValidToken = (req: Request) => req.headers.authorization && findFromToken(req.headers.authorization);
/**
* Verifies that the CLI key in the request matches the one in auth.json
* @since v0.14.0
*/
export const verifyCliKey = (req: Request) => {
const cliKey: string = fs.readJsonSync(path('auth.json')).cliKey;
return req.headers.authorization != null && req.headers.authorization === cliKey;
};

@ -0,0 +1,2 @@
import { nanoid } from 'nanoid';
export default ({ length }: { length?: number }) => nanoid(length);

@ -1,3 +1,4 @@
import lengthGen from './lengthGen';
const zeroWidthChars = ['\u200B', '\u200C', '\u200D', '\u2060'];
export default ({ length }: { length: number }) => lengthGen(length, zeroWidthChars);
export const checkIfZws = (str: string) => str.split('').every(char => zeroWidthChars.includes(char));

@ -0,0 +1,98 @@
/**
* Developer API
* - Users
* - Resources
*/
import { Router, Request, Response, NextFunction } from 'express';
import { findFromToken, setUserPassword, users, createNewUser, verifyCliKey } from '../auth';
import { log } from '../utils';
import { data } from '../data';
import { User } from '../types/auth';
/**
* The primary API router
*/
const RouterApi = Router();
/**
* Token authentication middleware for Admins
* @since v0.14.0
*/
const adminAuthMiddleware = (req: Request, res: Response, next: NextFunction) => {
const user = findFromToken(req.headers.authorization ?? '');
(verifyCliKey(req) || (user && user.admin)) ? next() : res.sendStatus(401);
};
/**
* Simple function to either return JSON or a 404, so I don't have to write it 40 times.
* @since v0.14.0
*/
const userFinder = (res: Response, user: User | undefined) => user ? res.json(user) : res.sendStatus(404);
function buildUserRouter() {
const userRouter = Router();
// Index
userRouter.get('/', (_req: Request, res: Response) => res.sendStatus(200));
// Get all users
// Admin only
userRouter.get('/all', adminAuthMiddleware, (req: Request, res: Response) => res.json(users));
// Get self
userRouter.get('/self', (req: Request, res: Response) =>
userFinder(res, findFromToken(req.headers['authorization'] ?? '') ?? undefined));
// Get user by token
userRouter.get('/token/:token', (req: Request, res: Response) =>
userFinder(res, users.find(user => user.token === req.params.token)));
// Reset password (new plaintext password in form data; HOST SHOULD BE USING HTTPS)
// Admin only
userRouter.post('/reset', adminAuthMiddleware, (req: Request, res: Response) => {
const id = req.body.id;
const newPassword = req.body.password;
setUserPassword(id, newPassword)
.then(() => res.sendStatus(200))
.catch((err) => (log.error(err), res.sendStatus(500)));
});
// Create a new user
// Admin only
userRouter.post('/new', adminAuthMiddleware, (req: Request, res: Response) => {
const username: string | undefined = req.body.username;
const password: string | undefined = req.body.password;
const admin = req.body.admin ?? false;
const meta: any = req.body.meta ?? {};
// Block if username or password is empty, or if username is already taken
if (username == null || username.length === 0 || password == null || password.length == 0 || users.find(user => user.username === username))
return res.sendStatus(400);
createNewUser(username, password, admin, meta)
.then((user) => res.send(user))
.catch((err) => (log.error(err), res.sendStatus(500)));
});
// Get a user (must be last as it's a catch-all)
// Admin only
userRouter.get('/:id', adminAuthMiddleware, (req: Request, res: Response) =>
userFinder(res, users.find(user => user.unid === req.params.id || user.username === req.params.id)));
return userRouter;
}
function buildResourceRouter() {
const resourceRouter = Router();
return resourceRouter;
}
export const onStart = () => {
RouterApi.use('/user', buildUserRouter());
RouterApi.use('/resource', buildResourceRouter());
return RouterApi;
};

@ -7,8 +7,9 @@ import fetch, { Response as FetchResponse } from 'node-fetch';
import { Request, Response } from 'express';
import { deleteS3 } from '../storage';
import { SkynetDelete, SkynetDownload } from '../skynet';
import { checkIfZws } from '../generators/zws';
import { path, log, getTrueHttp, getTrueDomain, formatBytes, formatTimestamp, getS3url, getDirectUrl, getResourceColor, replaceholder } from '../utils';
const { diskFilePath, s3enabled, viewDirect, useSia }: Config = fs.readJsonSync(path('config.json'));
const { diskFilePath, s3enabled, viewDirect, useIdInViewer, idInViewerExtension, useSia }: Config = fs.readJsonSync(path('config.json'));
const { CODE_UNAUTHORIZED, CODE_NOT_FOUND, }: MagicNumbers = fs.readJsonSync(path('MagicNumbers.json'));
import { data } from '../data';
import { users } from '../auth';
@ -47,9 +48,9 @@ router.get('/', (req: Request, res: Response, next) => data().get(req.ass.resour
// Send the view to the client
res.render('view', {
fileIs: fileData.is,
title: escape(fileData.originalname),
title: useIdInViewer && !checkIfZws(resourceId) ? `${resourceId}${idInViewerExtension ? `${fileData.ext}` : ''}` : escape(fileData.originalname),
mimetype: fileData.mimetype,
uploader: users[fileData.token].username,
uploader: users.find(user => user.unid === fileData.uploader)?.username || 'Unknown',
timestamp: formatTimestamp(fileData.timestamp, fileData.timeoffset),
size: formatBytes(fileData.size),
// todo: figure out how to not ignore this

@ -1,17 +1,20 @@
import { ErrWrap, User } from '../types/definitions';
import { Config, MagicNumbers } from 'ass-json';
import { ErrWrap } from '../types/definitions';
import { Config, MagicNumbers, Package, ServerSideEmbed } from 'ass-json';
import fs from 'fs-extra';
import bb from 'express-busboy';
//const rateLimit = require('express-rate-limit');
import { DateTime } from 'luxon';
import { Webhook, MessageBuilder } from 'discord-webhook-node';
import { Webhook, EmbedBuilder } from '@tycrek/discord-hookr';
import { processUploaded } from '../storage';
import { path, log, verify, getTrueHttp, getTrueDomain, generateId, formatBytes } from '../utils';
import { path, log, getTrueHttp, getTrueDomain, generateId, formatBytes } from '../utils';
import { data } from '../data';
import { users } from '../auth';
import { findFromToken, verifyValidToken } from '../auth';
const { maxUploadSize, resourceIdSize, gfyIdSize, resourceIdType, spaceReplace, adminWebhookEnabled, adminWebhookUrl, adminWebhookUsername, adminWebhookAvatar }: Config = fs.readJsonSync(path('config.json'));
const { CODE_UNAUTHORIZED, CODE_PAYLOAD_TOO_LARGE }: MagicNumbers = fs.readJsonSync(path('MagicNumbers.json'));
const { name, version, homepage }: Package = fs.readJsonSync(path('package.json'));
const ASS_LOGO = 'https://cdn.discordapp.com/icons/848274994375294986/8d339d4a2f3f54b2295e5e0ff62bd9e6.png?size=1024';
import express, { Request, Response } from 'express';
@ -34,8 +37,8 @@ bb.extend(router, {
// Block unauthorized requests and attempt token sanitization
router.post('/', (req: Request, res: Response, next: Function) => {
req.headers.authorization = req.headers.authorization || '';
req.token = req.headers.authorization.replace(/[^\da-z]/gi, ''); // Strip anything that isn't a digit or ASCII letter
!verify(req, users) ? log.warn('Upload blocked', 'Unauthorized').callback(() => res.sendStatus(CODE_UNAUTHORIZED)) : next(); // skipcq: JS-0093
req.token = req.headers.authorization.replace(/[^\da-z_-]/gi, ''); // Strip anything that isn't a digit, ASCII letter, or underscore/hyphen
!verifyValidToken(req) ? log.warn('Upload blocked', 'Unauthorized').callback(() => res.sendStatus(CODE_UNAUTHORIZED)) : next(); // skipcq: JS-0093
});
// Upload file
@ -60,17 +63,22 @@ router.post('/', (req: Request, res: Response, next: Function) => {
req.file!.timeoffset = req.headers['x-ass-timeoffset']?.toString() || 'UTC+0';
// Keep track of the token that uploaded the resource
req.file.token = req.token ?? '';
req.file.uploader = findFromToken(req.token)?.unid ?? '';
// Load server-side embed config, if it exists
const ssePath = path('share/embed.json');
const sse: ServerSideEmbed | undefined = fs.existsSync(ssePath) ? fs.readJsonSync(path('share/embed.json')) : undefined;
const useSse = sse && sse.title != undefined && sse.title != '';
// Attach any embed overrides, if necessary
req.file.opengraph = {
title: req.headers['x-ass-og-title'],
description: req.headers['x-ass-og-description'],
author: req.headers['x-ass-og-author'],
authorUrl: req.headers['x-ass-og-author-url'],
provider: req.headers['x-ass-og-provider'],
providerUrl: req.headers['x-ass-og-provider-url'],
color: req.headers['x-ass-og-color']
title: useSse ? sse.title : req.headers['x-ass-og-title'],
description: useSse ? sse.description : req.headers['x-ass-og-description'],
author: useSse ? sse.author : req.headers['x-ass-og-author'],
authorUrl: useSse ? sse.authorUrl : req.headers['x-ass-og-author-url'],
provider: useSse ? sse.provider : req.headers['x-ass-og-provider'],
providerUrl: useSse ? sse.providerUrl : req.headers['x-ass-og-provider-url'],
color: useSse ? sse.color : req.headers['x-ass-og-color']
};
// Fix spaces in originalname
@ -110,7 +118,7 @@ router.post('/', (req: Request, res: Response, next: Function) => {
.then(() => {
// Log the upload
const logInfo = `${req.file!.originalname} (${req.file!.mimetype}, ${formatBytes(req.file.size)})`;
const uploader = users[req.token ?? ''] ? users[req.token ?? ''].username : '<token-only>';
const uploader = findFromToken(req.token)?.username ?? 'Unknown';
log.success('File uploaded', logInfo, `uploaded by ${uploader}`);
// Build the URLs
@ -127,19 +135,18 @@ router.post('/', (req: Request, res: Response, next: Function) => {
hook.setAvatar(avatar);
// Build the embed
const embed = new MessageBuilder()
const embed = new EmbedBuilder()
.setTitle(logInfo)
// @ts-ignore
.setUrl(resourceUrl)
.setURL(resourceUrl)
.setAuthor({ name: `${name} ${version}`, url: homepage, icon_url: ASS_LOGO })
.setDescription(`${admin ? `**User:** \`${uploader}\`\n` : ''}**Size:** \`${formatBytes(req.file.size)}\`\n**[Delete](${deleteUrl})**`)
.setThumbnail(thumbnailUrl)
// @ts-ignore
.setThumbnail({ url: thumbnailUrl })
.setColor(req.file.vibrant)
.setTimestamp();
// Send the embed to the webhook, then delete the client after to free resources
log.debug(`Sending${admin ? ' admin' : ''} embed to webhook`);
hook.send(embed)
hook.addEmbed(embed).send()
.then(() => log.debug(`Webhook${admin ? ' admin' : ''} sent`))
.catch((err) => log.error('Webhook error').err(err));
}
@ -163,22 +170,6 @@ router.post('/', (req: Request, res: Response, next: Function) => {
adminWebhookUsername.trim().length === 0 ? 'ass admin logs' : adminWebhookUsername,
adminWebhookAvatar.trim().length === 0 ? ASS_LOGO : adminWebhookAvatar,
true);
// Also update the users upload count
if (!users[req.token ?? '']) {
const generateUsername = () => generateId('random', 20, 0, req.file.size.toString()); // skipcq: JS-0074
let username: string = generateUsername();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
while (Object.values(users).findIndex((user: User) => user.username === username) !== -1) // skipcq: JS-0073
username = generateUsername();
users[req.token ?? ''] = { username, count: 0 };
}
users[req.token ?? ''].count += 1;
fs.writeJsonSync(path('auth.json'), { users }, { spaces: 4 });
log.debug('Upload request flow completed', '');
});
})

@ -12,6 +12,8 @@ const config = {
spaceReplace: '_',
mediaStrict: false,
viewDirect: false,
useIdInViewer: false,
idInViewerExtension: false,
dataEngine: '@tycrek/papito',
frontendName: 'ass-x',
useSia: false,
@ -168,6 +170,18 @@ function doSetup() {
default: config.viewDirect,
required: false
},
useIdInViewer: {
description: 'Use the ID in the web viewer instead of the filename',
type: 'boolean',
default: config.useIdInViewer,
required: false
},
idInViewerExtension: {
description: '(Only applies if "useIdInViewer" is true) Include the file extension in the ID in the web viewer',
type: 'boolean',
default: config.idInViewerExtension,
required: false
},
dataEngine: {
description: 'Data engine to use (must match an npm package name. If unsure, leave blank)',
type: 'string',

@ -0,0 +1,29 @@
import path from 'path';
import fs from 'fs-extra';
import axios from 'axios';
import logger from '../logger';
import { User } from '../types/auth';
// Port from config.json
const { port } = fs.readJsonSync(path.join(process.cwd(), 'config.json'));
// CLI key from auth.json
const { cliKey } = fs.readJsonSync(path.join(process.cwd(), 'auth.json'));
if (process.argv.length < 4) {
logger.error('Missing username or password');
logger.error('Usage: node script.adduser.js <username> <password> [admin] [meta]');
process.exit(1);
} else {
const username = process.argv[2];
const password = process.argv[3];
const admin = process.argv[4] ? process.argv[4].toLowerCase() === 'true' : false;
const meta = process.argv[5] ? JSON.parse(process.argv[5]) : {};
axios.post(`http://localhost:${port}/api/user/new`, { username, password, admin, meta }, { headers: { 'Authorization': cliKey } })
.then((response) => {
const user = response.data as User;
logger.info('User created', username, user.unid).callback(() => process.exit(0))
})
.catch((err) => logger.error(err).callback(() => process.exit(1)));
}

@ -0,0 +1,19 @@
import logger from '../logger';
import { onStart, users, setUserPassword } from '../auth';
if (process.argv.length < 4) {
logger.error('Missing username/unid or password');
process.exit(1);
} else {
const id = process.argv[2];
const password = process.argv[3];
onStart(process.argv[4] || 'auth.json')
.then(() => {
const user = users.find((user) => user.unid === id || user.username === id);
if (!user) throw new Error('User not found');
else return setUserPassword(user.unid, password);
})
.then(() => logger.info('Password changed successfully').callback(() => process.exit(0)))
.catch((err) => logger.error(err).callback(() => process.exit(1)));
}

@ -0,0 +1,20 @@
import logger from '../logger';
import { onStart, users } from '../auth';
import { compare } from 'bcrypt';
if (process.argv.length < 4) {
logger.error('Missing username/unid or password');
process.exit(1);
} else {
const id = process.argv[2];
const password = process.argv[3];
onStart(process.argv[4] || 'auth.json')
.then(() => {
const user = users.find((user) => user.unid === id || user.username === id);
if (!user) throw new Error('User not found');
else return compare(password, user.passhash);
})
.then((result) => logger.info('Matches', `${result}`).callback(() => process.exit(0)))
.catch((err) => logger.error(err).callback(() => process.exit(1)));
}

@ -0,0 +1,72 @@
/**
* Defines the structure of a user
*/
export interface User {
/**
* Unique ID, provided by Nano ID
*/
unid: string
/**
* Name of the user
*/
username: string
/**
* Hashed password. Passwords are hashed using bcrypt.
*/
passhash: string
/**
* Token used for upload authentication
*/
token: string
/**
* Indicates whether the user is an admin
*/
admin: boolean
/**
* Extra metadata. Frontends can use this to store extra data.
*/
meta: {
[key: string]: any
}
}
/**
* Defines the structure of the users.json file
*/
export interface Users {
/**
* List of users. The key is the user's unique ID.
*/
users: User[]
/**
* Indicates whether auth.json has been migrated
*/
migrated?: boolean
/**
* Access key for the CLI
*/
cliKey?: string
/**
* Extra metadata. Frontends can use this to store extra data.
*/
meta: {
[key: string]: any
}
}
export interface OldUser {
username: string
count: number
}
export interface OldUsers {
[key: string]: OldUser
}

@ -12,11 +12,6 @@ declare global {
}
}
export interface User {
token: string
username: string
}
export interface FileData {
// Data from request file object
uuid?: string
@ -43,7 +38,11 @@ export interface FileData {
domain: string
timestamp: number
timeoffset: string
token: string
/**
* @deprecated
*/
token?: string
uploader: string
opengraph: OpenGraphData
// I found this in utils and idk where it comes from

@ -12,6 +12,8 @@ declare module 'ass-json' {
gfyIdSize: number
mediaStrict: boolean
viewDirect: boolean
useIdInViewer: boolean
idInViewerExtension: boolean
dataEngine: string
frontendName: string
indexFile: string
@ -50,4 +52,14 @@ declare module 'ass-json' {
version: string
homepage: string
}
interface ServerSideEmbed {
title: string
description?: string
author?: string
authorUrl?: string
provider?: string
providerUrl?: string
color?: string
}
}

@ -68,14 +68,6 @@ export function replaceholder(data: string, size: number, timestamp: number, tim
.replace(/&timestamp/g, formatTimestamp(timestamp, timeoffset));
}
export function arrayEquals(arr1: any[], arr2: any[]) {
return arr1.length === arr2.length && arr1.slice().sort().every((value: string, index: number) => value === arr2.slice().sort()[index])
};
export function verify(req: Request, users: JSON) {
return req.headers.authorization && Object.prototype.hasOwnProperty.call(users, req.headers.authorization);
}
const idModes = {
zws: 'zws', // Zero-width spaces (see: https://zws.im/)
og: 'original', // Use original uploaded filename
@ -108,7 +100,6 @@ module.exports = {
replaceholder,
randomHexColour,
sanitize,
verify,
renameFile: (req: Request, newName: string) => new Promise((resolve: Function, reject) => {
try {
const paths = [req.file.destination, newName];
@ -121,7 +112,6 @@ module.exports = {
}),
generateToken: () => token(),
generateId,
arrayEquals,
downloadTempS3: (file: FileData) => new Promise((resolve: Function, reject) =>
fetch(getS3url(file.randomId, file.ext))
.then((f2) => f2.body!.pipe(fs.createWriteStream(Path.join(__dirname, diskFilePath, sanitize(file.originalname))).on('close', () => resolve())))

@ -18,10 +18,11 @@ const defaults = {
bgViewer: '#151515',
// Text colours
txtPrimary: '#FD842D',
txtSecondary: '#BDBDBD',
txtPrimary: '#BDBDBD',
txtSecondary: '#8D8D8D',
// Links
linkPrimary: '#FD842D',
linkHover: '#FD710D',
linkActive: '#DE5E02',
@ -52,6 +53,7 @@ module.exports = {
colors: {
'primary': theme.txtPrimary || defaults.txtPrimary,
'secondary': theme.txtSecondary || defaults.txtSecondary,
'link-primary': theme.linkPrimary || defaults.linkPrimary,
'link-hover': theme.linkHover || defaults.linkHover,
'link-active': theme.linkActive || defaults.linkActive,
},

@ -13,7 +13,7 @@
@apply no-underline hover_no-underline active_no-underline visited_no-underline
/* regular, visited */
text-primary visited_text-primary
text-link-primary visited_text-link-primary
border-b-2 visited_border-b-2
border-transparent visited_border-transparent
rounded-sm visited_rounded-sm

@ -2,9 +2,9 @@
"extends": "@tsconfig/node16/tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"target": "ES2021",
"target": "ES2022",
"lib": [
"ES2021",
"ES2022",
"DOM"
],
"allowJs": true,

@ -21,25 +21,26 @@ html
* { display: none !important; }
meta(http-equiv='refresh' content=`0; url='${resourceAttr.src}'`)
body.font-main.text-secondary.bg-page
body.font-main.bg-page
.w-full.h-full.flex.justify-center.items-center.text-center
.bg-viewer.rounded-24
h4.mx-4.mt-6.mb-4.text-3xl.font-main!=title
figure.block.mx-10.my-4.flex.flex-col.align-items-center
if fileIs.video
video.res-media(controls loop muted playsinline preload='metadata')&attributes(resourceAttr)
else if fileIs.image
img.res-media(decoding='async')&attributes(resourceAttr)
else if fileIs.audio
audio.res-media(controls loop preload='metadata')&attributes(resourceAttr)
else
code!=mimetype
h4.mx-4.mt-6.mb-4.text-3xl.font-main.text-primary!=title
figure.mx-10.my-4.flex.flex-col.align-items-center.justify-center
.flex.justify-center
if fileIs.video
video.res-media(controls loop muted playsinline preload='metadata')&attributes(resourceAttr)
else if fileIs.image
img.res-media(decoding='async')&attributes(resourceAttr)
else if fileIs.audio
audio.res-media(controls loop preload='metadata')&attributes(resourceAttr)
else
code!=mimetype
figcaption
br
span.text-2xl Uploaded by #[strong!=uploader]
span.text-2xl.text-primary Uploaded by #[strong!=uploader]
br
span #{timestamp} (#{size})
span.text-secondary #{timestamp} (#{size})
br
span: a.link(href='#' onclick=`window.location = '${resourceAttr.src}?download=yes'; return false;` download=title) Download
if showAd
.mx-4.mb-8.text-footer: p Image hosted by #[a.link(href='https://github.com/tycrek/ass' target='_blank'): strong ass], the superior self-hosted ShareX server
.mx-4.mb-8.text-footer.text-secondary: p Image hosted by #[a.link(href='https://github.com/tycrek/ass' target='_blank'): strong ass], the superior self-hosted ShareX server

Loading…
Cancel
Save