Compare commits

..

10 Commits

Author SHA1 Message Date
b1ek 6d68a704a3
fix: tsc errors 2024-05-11 21:58:25 +10:00
b1ek c06e655819
feat: add an index page 2024-05-11 21:54:41 +10:00
b1ek 98a53051ee
fix: set TS lib to ES2023 2024-05-11 21:54:28 +10:00
b1ek 6f69779ec3
feat: dockerize 2024-05-11 21:54:13 +10:00
b1ek c9b9fe1eab
fix: throw a nice error if config does not exist 2024-05-11 21:37:27 +10:00
b1ek 7a1433b830
fix: add config.json and store to gitignore 2024-05-11 21:36:19 +10:00
b1ek 71d635fcb1
feat: DELETE /:name endpoint 2024-05-11 21:34:44 +10:00
b1ek 89e7e3ded4
feat: /get/:name endpoint 2024-05-11 21:21:50 +10:00
b1ek 5e1c610614
feat: /list endpoint 2024-05-11 21:06:05 +10:00
b1ek 8c8ac3523b
feat: throw QuotaExceeded error on upload when applicable 2024-05-11 20:33:07 +10:00
17 changed files with 247 additions and 42 deletions

3
.gitignore vendored
View File

@ -1,2 +1,5 @@
node_modules node_modules
dist dist
store
config.json
docker-compose.yml

10
config.example.json Normal file
View File

@ -0,0 +1,10 @@
{
"clients": [
{
"name": "user",
"keySha256Sum": "0000000000000000000000000000000000000000000000000000000000000000",
"quota": 512
}
],
"storePath": "store"
}

View File

@ -1,10 +0,0 @@
{
"clients": [
{
"name": "izz",
"keySha256Sum": "eeb36e726e3ffec16da7798415bb4e531bf8a57fbe276fcc3fc6ea986cb02e9a",
"quota": 512
}
],
"storePath": "store"
}

View File

@ -0,0 +1,11 @@
services:
server:
image: node:18-alpine
volumes:
- '.:/opt/code'
working_dir: '/opt/code'
entrypoint: ['/opt/code/docker-start.sh']
ports:
- 8080:80
environment:
HOST: 0.0.0.0

4
docker-start.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/sh
yarn
yarn start

23
src/client.ts Normal file
View File

@ -0,0 +1,23 @@
import * as crypto from 'node:crypto';
export type Client = {
name: string;
keySha256Sum: string;
/**
* In megabytes
*/
quota?: number;
};
export type NoKeyClient = {
name: string;
quota?: number;
};
export function checkKey(key: string, client: Client): boolean {
const sha = crypto.createHash('sha256');
sha.update(key);
const hashed = sha.digest().toString('hex');
return client.keySha256Sum === hashed;
}

View File

@ -1,5 +1,9 @@
import fsp from 'node:fs/promises'; import { Client, checkKey } from './client.js';
import * as fsp from 'node:fs/promises';
import Ajv from 'ajv'; import Ajv from 'ajv';
import { existsSync } from 'node:fs';
// @ts-expect-error: esnext moment
const ajv = new Ajv(); const ajv = new Ajv();
const config_validate = ajv.compile({ const config_validate = ajv.compile({
@ -23,18 +27,16 @@ const config_validate = ajv.compile({
}); });
export class Config { export class Config {
clients: { clients: Client[];
name: string;
keySha256Sum: string;
/**
* In megabytes
*/
quota?: number;
}[];
storePath: string; storePath: string;
static async load(): Promise<Config> { static async load(): Promise<Config> {
const path = process.env.CONFIG_PATH ?? 'config.json'; const path = process.env.CONFIG_PATH ?? 'config.json';
if (!existsSync(path)) {
throw new Error(`The config file ${path} does not exist`);
}
const raw = JSON.parse( const raw = JSON.parse(
await fsp.readFile(path, { encoding: 'utf8' }), await fsp.readFile(path, { encoding: 'utf8' }),
) as Config; ) as Config;
@ -56,6 +58,10 @@ export class Config {
return raw; return raw;
} }
static getClientByKey(config: Config, key: string): Client | undefined {
return config.clients.find((client) => checkKey(key, client));
}
} }
export const config = await Config.load(); export const config = await Config.load();

View File

@ -26,3 +26,21 @@ export const InvalidAuthorization = () =>
name: 'InvalidAuthorization', name: 'InvalidAuthorization',
message: 'Provided authorization credentials are invalid', message: 'Provided authorization credentials are invalid',
}) as FastifyError; }) as FastifyError;
export const QuotaExceeded = () =>
({
code: '422',
statusCode: 422,
name: 'QuotaExceeded',
message:
'Your quota has exceeded. Please delete some files to proceed.',
}) as FastifyError;
export const NotFoundError = () =>
({
code: '404',
statusCode: 404,
name: 'NotFound',
message:
'The requested resource has never existed, deleted or has a different name or path.',
}) as FastifyError;

28
src/routes/delete.ts Normal file
View File

@ -0,0 +1,28 @@
import { type FastifyPluginAsync } from 'fastify';
import * as fsp from 'node:fs/promises';
import { Config, config } from '../config.js';
import { InvalidAuthorization } from '../errors.js';
import { getFilesFor, getPathFor } from '../store.js';
export default (async function (fastify) {
fastify.delete('/:name', async (req) => {
const key = req.headers.authorization?.replace('Bearer ', '') ?? 'none';
const client = Config.getClientByKey(config, key);
if (!client) {
throw InvalidAuthorization();
}
const params = req.params as { name: string };
const files = await getFilesFor(client.name);
if (!files.find((x) => x === client.name + '-' + params.name)) {
return { status: 'ok' };
}
await fsp.rm(getPathFor(params.name, client.name));
return { status: 'ok' };
});
} as FastifyPluginAsync);

30
src/routes/get.ts Normal file
View File

@ -0,0 +1,30 @@
import { FastifyPluginAsync } from 'fastify';
import * as fsp from 'node:fs/promises';
import { Config, config } from '../config.js';
import { InvalidAuthorization, NotFoundError } from '../errors.js';
import { getFilesFor, getPathFor } from '../store.js';
export default (async function (fastify) {
fastify.get('/get/:name', async (req, rep) => {
const key = req.headers.authorization?.replace('Bearer ', '') ?? 'none';
const client = Config.getClientByKey(config, key);
if (!client) {
throw InvalidAuthorization();
}
const files = (await getFilesFor(client.name)).map((x) =>
x.replace(client.name + '-', ''),
);
const params = req.params as { name: string };
if (!files.find((x) => x === params.name)) {
throw NotFoundError();
}
rep.type('application/octet-stream');
return await fsp.readFile(getPathFor(params.name, client.name));
});
} as FastifyPluginAsync);

View File

@ -1,6 +1,14 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import deleter from './delete.js';
import upload from './upload.js'; import upload from './upload.js';
import index from './indexpage.js';
import list from './list.js';
import get from './get.js';
export default (async function (fastify) { export default (async function (fastify) {
await fastify.register(deleter);
await fastify.register(upload); await fastify.register(upload);
await fastify.register(index);
await fastify.register(list);
await fastify.register(get);
} as FastifyPluginAsync); } as FastifyPluginAsync);

29
src/routes/indexpage.ts Normal file
View File

@ -0,0 +1,29 @@
import { FastifyPluginAsync } from 'fastify';
const page = `<!DOCTYPE html>
<html>
<head>
<title>Backup server</title>
<style>*{background:#111;color:white}a{color:#abf}</style>
</head>
<body>
<h1>You have reached the backup server!</h1>
<p>This is the backup server! If you are a regular user, you wouldn't find this place very interesting and might as well close this page now.</p>
<p>If you are a sysadmin, please refer to <a href='https://git.blek.codes/blek/backups.git'>API docs</a> for more info</p>
</body>
</html>
`
.replaceAll('\n', '')
.replaceAll(/ +/gm, ' ')
.replaceAll(' <', '<');
export default (async function (fastify) {
fastify.get('/', async (_req, rep) => {
rep.type('text/html; charset=utf8');
return page;
});
fastify.get('/favicon.ico', async (req, rep) => {
rep.status(404);
return '';
});
} as FastifyPluginAsync);

41
src/routes/list.ts Normal file
View File

@ -0,0 +1,41 @@
import { type FastifyPluginAsync } from 'fastify';
import * as fsp from 'node:fs/promises';
import * as path from 'node:path';
import { Stats } from 'node:fs';
import { Config, config } from '../config.js';
import { InvalidAuthorization } from '../errors.js';
type ListFilesResponse = {
name: string;
created: Date;
};
export default (async function (fastify) {
fastify.get('/list', async (req) => {
const key = req.headers.authorization?.replace('Bearer ', '') ?? 'none';
const client = Config.getClientByKey(config, key);
if (!client) {
throw InvalidAuthorization();
}
const rawfl = await fsp.readdir(config.storePath);
const files: [Stats, string][] = await Promise.all(
rawfl
.filter((x) => x.startsWith(client.name + '-'))
.map(async (x) => [
await fsp.stat(path.join(config.storePath, x)),
x,
]),
);
return files.map(
(x) =>
({
name: x[1].replace(client.name + '-', ''),
created: x[0].birthtime,
}) as ListFilesResponse,
);
});
} as FastifyPluginAsync);

View File

@ -1,19 +1,20 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import Ajv, { JSONSchemaType, type Schema } from 'ajv'; import Ajv, { JSONSchemaType, type Schema } from 'ajv';
import crypto from 'node:crypto';
import { import {
InvalidAuthorization, InvalidAuthorization,
InvalidPayload, InvalidPayload,
QuotaExceeded,
ValidationError, ValidationError,
} from '../errors.js'; } from '../errors.js';
import { config } from '../config.js'; import { Config, config } from '../config.js';
import { getTakenQuota, save } from '../store.js'; import { save } from '../store.js';
type UploadPayload = { type UploadPayload = {
data: string; // base64-encoded data: string; // base64-encoded
name: string; name: string;
}; };
// @ts-expect-error: esnext moment
const ajv = new Ajv(); const ajv = new Ajv();
const upload_schema = { const upload_schema = {
type: 'object', type: 'object',
@ -26,11 +27,9 @@ const upload_schema = {
export default (async function (fastify) { export default (async function (fastify) {
fastify.post('/upload', async (req) => { fastify.post('/upload', async (req) => {
const sha = crypto.createHash('sha256'); const key = req.headers.authorization?.replace('Bearer ', '') ?? 'none';
sha.update(req.headers.authorization?.replace('Bearer ', '') ?? 'none');
const key = sha.digest().toString('hex');
const client = config.clients.find((x) => x.keySha256Sum === key); const client = Config.getClientByKey(config, key);
if (!client) { if (!client) {
throw InvalidAuthorization(); throw InvalidAuthorization();
@ -54,7 +53,10 @@ export default (async function (fastify) {
const data = Buffer.from(body.data, 'base64'); const data = Buffer.from(body.data, 'base64');
const name = body.name; const name = body.name;
await save(data, name, client.name); const status = await save(data, name, client.name);
if (status === 'Quota exceeded') {
throw QuotaExceeded();
}
return { return {
status: 'ok', status: 'ok',

View File

@ -1,11 +1,10 @@
import { config } from './config.js'; import { config } from './config.js';
import path from 'path'; import * as path from 'path';
import fsp from 'node:fs/promises'; import * as fsp from 'node:fs/promises';
import { NoKeyClient } from './client.js';
function getClient( function getClient(clientName: string): NoKeyClient | undefined {
clientName: string,
): { name: string; quota: number } | undefined {
const client = config.clients.find((x) => x.name === clientName) as const client = config.clients.find((x) => x.name === clientName) as
| { name: string; quota: number; keySha256Sum?: string } | { name: string; quota: number; keySha256Sum?: string }
| undefined; | undefined;
@ -14,13 +13,9 @@ function getClient(
return client; return client;
} }
function getClientOrThrowErr( function getClientOrThrowErr(clientName: string, error?: string): NoKeyClient {
clientName: string,
error?: string,
): { name: string; quota: number } {
const client = getClient(clientName); const client = getClient(clientName);
if (typeof client === 'undefined') if (!client) throw Error(error ?? 'Client does not exist.');
throw Error(error ?? 'Client does not exist.');
return client; return client;
} }
@ -56,6 +51,7 @@ export async function save(
const client = getClientOrThrowErr(clientName); const client = getClientOrThrowErr(clientName);
const quota = await getTakenQuota(clientName); const quota = await getTakenQuota(clientName);
if (client.quota)
if (data.length + quota > client.quota) { if (data.length + quota > client.quota) {
return 'Quota exceeded'; return 'Quota exceeded';
} }

View File

@ -1 +0,0 @@
uwu

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
}