alpha release
update v0.8.0
This commit is contained in:
89
src/lib/arrayDB.ts
Normal file
89
src/lib/arrayDB.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import Debug from 'debug';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { loadObjectFromJson, saveToJson } from './utils.js';
|
||||
|
||||
const debug = Debug('twtkpr:arrayDB');
|
||||
|
||||
/**
|
||||
* File-backed, in-memory database consisting of arrays of strings (ex: tokens) indexed by other
|
||||
* strings (ex: usernames).
|
||||
*
|
||||
* @param name
|
||||
* @param directory
|
||||
* @returns
|
||||
*/
|
||||
export default async function arrayDB(name: string, directory: string) {
|
||||
let theName: string;
|
||||
let dataObject: Record<string, string[]>;
|
||||
|
||||
const get = (key = '') => {
|
||||
debug('get', { key });
|
||||
|
||||
if (!theName || !dataObject)
|
||||
throw new Error('DB must be initialized first');
|
||||
|
||||
key = key?.trim();
|
||||
if (!key) throw new Error('a valid key must be provided');
|
||||
|
||||
return dataObject[key];
|
||||
};
|
||||
|
||||
const getObject = () => dataObject;
|
||||
|
||||
const initialize = async (dbName = '') => {
|
||||
debug('initialize starting', { dbName });
|
||||
|
||||
dbName = dbName?.trim();
|
||||
if (!dbName) throw new Error('a valid name must be provided');
|
||||
|
||||
try {
|
||||
dataObject = await loadObjectFromJson(join(directory, `${dbName}.json`));
|
||||
} catch (err: unknown) {
|
||||
debug('initialize read error', { err });
|
||||
|
||||
if ((err as { code: string }).code === 'ENOENT') dataObject = {};
|
||||
else throw err;
|
||||
}
|
||||
|
||||
// only initialize (and set name) if everything passes
|
||||
theName = dbName;
|
||||
debug('initialize complete', { dataObject, name: theName });
|
||||
};
|
||||
|
||||
const remove = (key = '') => {
|
||||
debug('remove', { key });
|
||||
|
||||
if (!theName || !dataObject)
|
||||
throw new Error('DB must be initialized first');
|
||||
|
||||
key = key?.trim();
|
||||
if (!key) throw new Error('a valid key must be provided');
|
||||
|
||||
delete dataObject[key];
|
||||
};
|
||||
|
||||
const set = (key = '', value: string[] = []) => {
|
||||
debug('set', { key });
|
||||
|
||||
if (!theName || !dataObject)
|
||||
throw new Error('DB must be initialized first');
|
||||
|
||||
key = key?.trim();
|
||||
if (!key) throw new Error('a valid key must be provided');
|
||||
|
||||
dataObject[key] = value;
|
||||
saveToJson(dataObject, join(directory, `${name}.json`));
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
await initialize(name);
|
||||
|
||||
return {
|
||||
get,
|
||||
getObject,
|
||||
remove,
|
||||
set,
|
||||
};
|
||||
}
|
||||
29
src/lib/constants.ts
Normal file
29
src/lib/constants.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const DEFAULT_PRIVATE_DIRECTORY = '.data';
|
||||
export const DEFAULT_PUBLIC_DIRECTORY = 'public';
|
||||
export const DEFAULT_TWTXT_FILENAME = 'twtxt.txt';
|
||||
export const DEFAULT_ROUTE = `/${DEFAULT_TWTXT_FILENAME}`;
|
||||
|
||||
export const DEFAULT_POST_LIMITER_ACTIVE = true;
|
||||
|
||||
export const DEFAULT_QUERY_PARAMETER_APP = 'app';
|
||||
export const DEFAULT_QUERY_PARAMETER_CSS = 'css';
|
||||
export const DEFAULT_QUERY_PARAMETER_FOLLOWING = 'following';
|
||||
export const DEFAULT_QUERY_PARAMETER_JS = 'js';
|
||||
export const DEFAULT_QUERY_PARAMETER_LOGOUT = 'logout';
|
||||
export const DEFAULT_QUERY_PARAMETER_METADATA = 'metadata';
|
||||
export const DEFAULT_QUERY_PARAMETER_TWT = 'twt';
|
||||
export const DEFAULT_QUERY_PARAMETER_TWTS = 'twts';
|
||||
|
||||
export const DEFAULT_UPLOAD_ACTIVE = true;
|
||||
export const DEFAULT_UPLOAD_ALLOWED_MIME_TYPES = '';
|
||||
export const DEFAULT_UPLOAD_ROUTE = 'files';
|
||||
export const DEFAULT_UPLOAD_ENCODING = 'utf-8';
|
||||
|
||||
// optional in zod
|
||||
export const DEFAULT_UPLOAD_HASH_ALGORITHM = 'sha256';
|
||||
export const DEFAULT_UPLOAD_KEEP_EXTENSIONS = true;
|
||||
|
||||
export const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
307
src/lib/env.ts
Normal file
307
src/lib/env.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import {
|
||||
DEFAULT_POST_LIMITER_ACTIVE,
|
||||
DEFAULT_PRIVATE_DIRECTORY,
|
||||
DEFAULT_PUBLIC_DIRECTORY,
|
||||
DEFAULT_QUERY_PARAMETER_APP,
|
||||
DEFAULT_QUERY_PARAMETER_CSS,
|
||||
DEFAULT_QUERY_PARAMETER_FOLLOWING,
|
||||
DEFAULT_QUERY_PARAMETER_JS,
|
||||
DEFAULT_QUERY_PARAMETER_LOGOUT,
|
||||
DEFAULT_QUERY_PARAMETER_METADATA,
|
||||
DEFAULT_QUERY_PARAMETER_TWT,
|
||||
DEFAULT_QUERY_PARAMETER_TWTS,
|
||||
DEFAULT_ROUTE,
|
||||
DEFAULT_TWTXT_FILENAME,
|
||||
DEFAULT_UPLOAD_ACTIVE,
|
||||
DEFAULT_UPLOAD_ALLOWED_MIME_TYPES,
|
||||
DEFAULT_UPLOAD_ENCODING,
|
||||
DEFAULT_UPLOAD_HASH_ALGORITHM,
|
||||
DEFAULT_UPLOAD_KEEP_EXTENSIONS,
|
||||
DEFAULT_UPLOAD_ROUTE,
|
||||
} from './constants.js';
|
||||
|
||||
/*
|
||||
The following keys are expected to exist in `process.env`, either as listed, or without the
|
||||
`TWTKPR_` prefix
|
||||
|
||||
We only have listed default values for our keys, anything for other plugins (like formidable or
|
||||
express-rate-limit) fall back to their own defaults (and thus are optional).
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'production', 'test'])
|
||||
.default('development'),
|
||||
|
||||
// required vars - MUST be passed via ENV
|
||||
TWTKPR_REFRESH_SECRET: z.string().default(''),
|
||||
TWTKPR_ACCESS_SECRET: z.string().default(''),
|
||||
|
||||
// vars with default values
|
||||
TWTKPR_DEFAULT_ROUTE: z.string().default(DEFAULT_ROUTE),
|
||||
TWTKPR_PRIVATE_DIRECTORY: z.string().default(DEFAULT_PRIVATE_DIRECTORY),
|
||||
TWTKPR_PUBLIC_DIRECTORY: z.string().default(DEFAULT_PUBLIC_DIRECTORY),
|
||||
TWTKPR_QUERY_PARAMETER_APP: z.string().default(DEFAULT_QUERY_PARAMETER_APP),
|
||||
TWTKPR_QUERY_PARAMETER_CSS: z.string().default(DEFAULT_QUERY_PARAMETER_CSS),
|
||||
TWTKPR_QUERY_PARAMETER_FOLLOWING: z
|
||||
.string()
|
||||
.default(DEFAULT_QUERY_PARAMETER_FOLLOWING),
|
||||
TWTKPR_QUERY_PARAMETER_JS: z.string().default(DEFAULT_QUERY_PARAMETER_JS),
|
||||
TWTKPR_QUERY_PARAMETER_LOGOUT: z
|
||||
.string()
|
||||
.default(DEFAULT_QUERY_PARAMETER_LOGOUT),
|
||||
TWTKPR_QUERY_PARAMETER_METADATA: z
|
||||
.string()
|
||||
.default(DEFAULT_QUERY_PARAMETER_METADATA),
|
||||
TWTKPR_QUERY_PARAMETER_TWT: z.string().default(DEFAULT_QUERY_PARAMETER_TWT),
|
||||
TWTKPR_QUERY_PARAMETER_TWTS: z.string().default(DEFAULT_QUERY_PARAMETER_TWTS),
|
||||
TWTKPR_TWTXT_FILENAME: z.string().default(DEFAULT_TWTXT_FILENAME),
|
||||
|
||||
/**
|
||||
* Post limiter plugin
|
||||
*/
|
||||
|
||||
// var with default value
|
||||
TWTKPR_POST_LIMITER_ACTIVE: z.boolean().default(DEFAULT_POST_LIMITER_ACTIVE),
|
||||
|
||||
// optional vars
|
||||
TWTKPR_POST_LIMITER_WINDOW_MS: z.optional(z.number()),
|
||||
TWTKPR_POST_LIMITER_LIMIT: z.optional(z.union([z.number(), z.function()])),
|
||||
TWTKPR_POST_LIMITER_MESSAGE: z.optional(z.any()),
|
||||
TWTKPR_POST_LIMITER_STATUS_CODE: z.optional(z.number()),
|
||||
TWTKPR_POST_LIMITER_HANDLER: z.optional(z.function()),
|
||||
TWTKPR_POST_LIMITER_LEGACY_HEADERS: z.optional(z.boolean()),
|
||||
TWTKPR_POST_LIMITER_STANDARD_HEADERS: z.optional(
|
||||
z.union([z.boolean(), z.string()])
|
||||
),
|
||||
TWTKPR_POST_LIMITER_IDENTIFIER: z.optional(
|
||||
z.union([z.string(), z.function()])
|
||||
),
|
||||
TWTKPR_POST_LIMITER_STORE: z.optional(z.any()),
|
||||
TWTKPR_POST_LIMITER_PASS_ON_STORE_ERROR: z.optional(z.boolean()),
|
||||
TWTKPR_POST_LIMITER_KEY_GENERATOR: z.optional(z.function()),
|
||||
TWTKPR_POST_LIMITER_IPV6_SUBNET: z.optional(
|
||||
z.union([z.number(), z.function(), z.boolean()])
|
||||
),
|
||||
TWTKPR_POST_LIMITER_REQUEST_PROPERTY_NAME: z.optional(z.string()),
|
||||
TWTKPR_POST_LIMITER_SKIP: z.optional(z.function()),
|
||||
TWTKPR_POST_LIMITER_SKIP_SUCCESSFUL_REQUESTS: z.optional(z.boolean()),
|
||||
TWTKPR_POST_LIMITER_SKIP_FAILED_REQUESTS: z.optional(z.boolean()),
|
||||
TWTKPR_POST_LIMITER_REQUEST_WAS_SUCCESSFUL: z.optional(z.function()),
|
||||
TWTKPR_POST_LIMITER_VALIDATE: z.optional(z.union([z.boolean(), z.object()])),
|
||||
|
||||
/**
|
||||
* Upload plugin
|
||||
*/
|
||||
|
||||
// vars with default values
|
||||
TWTKPR_UPLOAD_ACTIVE: z.boolean().default(DEFAULT_UPLOAD_ACTIVE),
|
||||
TWTKPR_UPLOAD_ALLOWED_MIME_TYPES: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.default(DEFAULT_UPLOAD_ALLOWED_MIME_TYPES),
|
||||
TWTKPR_UPLOAD_ROUTE: z.string().default(DEFAULT_UPLOAD_ROUTE),
|
||||
|
||||
// optional vars
|
||||
TWTKPR_UPLOAD_ALLOW_EMPTY_FILES: z.optional(z.boolean()),
|
||||
TWTKPR_UPLOAD_CREATE_DIRS_FROM_UPLOADS: z.optional(z.boolean()),
|
||||
TWTKPR_UPLOAD_DIRECTORY: z.optional(z.string()),
|
||||
TWTKPR_UPLOAD_ENCODING: z.string().default(DEFAULT_UPLOAD_ENCODING),
|
||||
TWTKPR_UPLOAD_FILE_WRITE_STREAM_HANDLER: z.optional(z.function()),
|
||||
TWTKPR_UPLOAD_FILENAME: z.optional(z.function()),
|
||||
TWTKPR_UPLOAD_FILTER: z.optional(z.function()),
|
||||
TWTKPR_UPLOAD_HASH_ALGORITHM: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.default(DEFAULT_UPLOAD_HASH_ALGORITHM),
|
||||
TWTKPR_UPLOAD_KEEP_EXTENSIONS: z
|
||||
.boolean()
|
||||
.default(DEFAULT_UPLOAD_KEEP_EXTENSIONS),
|
||||
TWTKPR_UPLOAD_MAX_FIELDS: z.optional(z.number()),
|
||||
TWTKPR_UPLOAD_MAX_FIELDS_SIZE: z.optional(z.number()),
|
||||
TWTKPR_UPLOAD_MAX_FILE_SIZE: z.optional(z.number()),
|
||||
TWTKPR_UPLOAD_MAX_FILES: z.optional(z.number()),
|
||||
TWTKPR_UPLOAD_MAX_TOTAL_FILE_SIZE: z.optional(z.number()),
|
||||
TWTKPR_UPLOAD_MIN_FILE_SIZE: z.optional(z.number()),
|
||||
});
|
||||
|
||||
const parseEnv = () => {
|
||||
try {
|
||||
/**
|
||||
* there's probably an easier way to do this, spreading the keys from above and accepting either
|
||||
* the app (bare) or library (`TWTKPR_`-prefixed) version of said key.
|
||||
* But this should work for now.
|
||||
*/
|
||||
const parsedEnv = envSchema.parse({
|
||||
NODE_ENV: process.env.TWTKPR_NODE_ENV || process.env.NODE_ENV,
|
||||
TWTKPR_ACCESS_SECRET:
|
||||
process.env.TWTKPR_ACCESS_SECRET || process.env.ACCESS_SECRET,
|
||||
TWTKPR_DEFAULT_ROUTE:
|
||||
process.env.TWTKPR_DEFAULT_ROUTE || process.env.DEFAULT_ROUTE,
|
||||
TWTKPR_PRIVATE_DIRECTORY:
|
||||
process.env.TWTKPR_PRIVATE_DIRECTORY || process.env.PRIVATE_DIRECTORY,
|
||||
TWTKPR_PUBLIC_DIRECTORY:
|
||||
process.env.TWTKPR_PUBLIC_DIRECTORY || process.env.PUBLIC_DIRECTORY,
|
||||
TWTKPR_REFRESH_SECRET:
|
||||
process.env.TWTKPR_REFRESH_SECRET || process.env.REFRESH_SECRET,
|
||||
TWTKPR_TWTXT_FILENAME:
|
||||
process.env.TWTKPR_TWTXT_FILENAME || process.env.TWTXT_FILENAME,
|
||||
|
||||
TWTKPR_POST_LIMITER_ACTIVE:
|
||||
process.env.TWTKPR_POST_LIMITER_ACTIVE ||
|
||||
process.env.POST_LIMITER_ACTIVE,
|
||||
TWTKPR_POST_LIMITER_WINDOW_MS:
|
||||
process.env.TWTKPR_POST_LIMITER_WINDOW_MS ||
|
||||
process.env.POST_LIMITER_WINDOW_MS,
|
||||
TWTKPR_POST_LIMITER_LIMIT:
|
||||
process.env.TWTKPR_POST_LIMITER_LIMIT || process.env.POST_LIMITER_LIMIT,
|
||||
TWTKPR_POST_LIMITER_MESSAGE:
|
||||
process.env.TWTKPR_POST_LIMITER_MESSAGE ||
|
||||
process.env.POST_LIMITER_MESSAGE,
|
||||
TWTKPR_POST_LIMITER_STATUS_CODE:
|
||||
process.env.TWTKPR_POST_LIMITER_STATUS_CODE ||
|
||||
process.env.POST_LIMITER_STATUS_CODE,
|
||||
TWTKPR_POST_LIMITER_HANDLER:
|
||||
process.env.TWTKPR_POST_LIMITER_HANDLER ||
|
||||
process.env.POST_LIMITER_HANDLER,
|
||||
TWTKPR_POST_LIMITER_LEGACY_HEADERS:
|
||||
process.env.TWTKPR_POST_LIMITER_LEGACY_HEADERS ||
|
||||
process.env.POST_LIMITER_LEGACY_HEADERS,
|
||||
TWTKPR_POST_LIMITER_STANDARD_HEADERS:
|
||||
process.env.TWTKPR_POST_LIMITER_STANDARD_HEADERS ||
|
||||
process.env.POST_LIMITER_STANDARD_HEADERS,
|
||||
TWTKPR_POST_LIMITER_IDENTIFIER:
|
||||
process.env.TWTKPR_POST_LIMITER_IDENTIFIER ||
|
||||
process.env.POST_LIMITER_IDENTIFIER,
|
||||
TWTKPR_POST_LIMITER_STORE:
|
||||
process.env.TWTKPR_POST_LIMITER_STORE || process.env.POST_LIMITER_STORE,
|
||||
TWTKPR_POST_LIMITER_PASS_ON_STORE_ERROR:
|
||||
process.env.TWTKPR_POST_LIMITER_PASS_ON_STORE_ERROR ||
|
||||
process.env.POST_LIMITER_PASS_ON_STORE_ERROR,
|
||||
TWTKPR_POST_LIMITER_KEY_GENERATOR:
|
||||
process.env.TWTKPR_POST_LIMITER_KEY_GENERATOR ||
|
||||
process.env.POST_LIMITER_KEY_GENERATOR,
|
||||
TWTKPR_POST_LIMITER_IPV6_SUBNET:
|
||||
process.env.TWTKPR_POST_LIMITER_IPV6_SUBNET ||
|
||||
process.env.POST_LIMITER_IPV6_SUBNET,
|
||||
TWTKPR_POST_LIMITER_REQUEST_PROPERTY_NAME:
|
||||
process.env.TWTKPR_POST_LIMITER_REQUEST_PROPERTY_NAME ||
|
||||
process.env.POST_LIMITER_REQUEST_PROPERTY_NAME,
|
||||
TWTKPR_POST_LIMITER_SKIP:
|
||||
process.env.TWTKPR_POST_LIMITER_SKIP || process.env.POST_LIMITER_SKIP,
|
||||
TWTKPR_POST_LIMITER_SKIP_SUCCESSFUL_REQUESTS:
|
||||
process.env.TWTKPR_POST_LIMITER_SKIP_SUCCESSFUL_REQUESTS ||
|
||||
process.env.POST_LIMITER_SKIP_SUCCESSFUL_REQUESTS,
|
||||
TWTKPR_POST_LIMITER_SKIP_FAILED_REQUESTS:
|
||||
process.env.TWTKPR_POST_LIMITER_SKIP_FAILED_REQUESTS ||
|
||||
process.env.POST_LIMITER_SKIP_FAILED_REQUESTS,
|
||||
TWTKPR_POST_LIMITER_REQUEST_WAS_SUCCESSFUL:
|
||||
process.env.TWTKPR_POST_LIMITER_REQUEST_WAS_SUCCESSFUL ||
|
||||
process.env.POST_LIMITER_REQUEST_WAS_SUCCESSFUL,
|
||||
TWTKPR_POST_LIMITER_VALIDATE:
|
||||
process.env.TWTKPR_POST_LIMITER_VALIDATE ||
|
||||
process.env.POST_LIMITER_VALIDATE,
|
||||
|
||||
TWTKPR_QUERY_PARAMETER_APP:
|
||||
process.env.TWTKPR_QUERY_PARAMETER_APP ||
|
||||
process.env.QUERY_PARAMETER_APP,
|
||||
TWTKPR_QUERY_PARAMETER_CSS:
|
||||
process.env.TWTKPR_QUERY_PARAMETER_CSS ||
|
||||
process.env.QUERY_PARAMETER_CSS,
|
||||
TWTKPR_QUERY_PARAMETER_FOLLOWING:
|
||||
process.env.TWTKPR_QUERY_PARAMETER_FOLLOWING ||
|
||||
process.env.QUERY_PARAMETER_FOLLOWING,
|
||||
TWTKPR_QUERY_PARAMETER_JS:
|
||||
process.env.TWTKPR_QUERY_PARAMETER_JS || process.env.QUERY_PARAMETER_JS,
|
||||
TWTKPR_QUERY_PARAMETER_LOGOUT:
|
||||
process.env.TWTKPR_QUERY_PARAMETER_LOGOUT ||
|
||||
process.env.QUERY_PARAMETER_LOGOUT,
|
||||
TWTKPR_QUERY_PARAMETER_METADATA:
|
||||
process.env.TWTKPR_QUERY_PARAMETER_METADATA ||
|
||||
process.env.QUERY_PARAMETER_METADATA,
|
||||
TWTKPR_QUERY_PARAMETER_TWT:
|
||||
process.env.TWTKPR_QUERY_PARAMETER_TWT ||
|
||||
process.env.QUERY_PARAMETER_TWT,
|
||||
TWTKPR_QUERY_PARAMETER_TWTS:
|
||||
process.env.TWTKPR_QUERY_PARAMETER_TWTS ||
|
||||
process.env.QUERY_PARAMETER_TWTS,
|
||||
|
||||
TWTKPR_UPLOAD_ACTIVE:
|
||||
process.env.TWTKPR_UPLOAD_ACTIVE || process.env.UPLOAD_ACTIVE,
|
||||
TWTKPR_UPLOAD_ALLOW_EMPTY_FILES:
|
||||
process.env.TWTKPR_UPLOAD_ALLOW_EMPTY_FILES ||
|
||||
process.env.UPLOAD_ALLOW_EMPTY_FILES,
|
||||
TWTKPR_UPLOAD_ALLOWED_MIME_TYPES:
|
||||
process.env.TWTKPR_UPLOAD_ALLOWED_MIME_TYPES ||
|
||||
process.env.UPLOAD_ALLOWED_MIME_TYPES,
|
||||
TWTKPR_UPLOAD_CREATE_DIRS_FROM_UPLOADS:
|
||||
process.env.TWTKPR_UPLOAD_CREATE_DIRS_FROM_UPLOADS ||
|
||||
process.env.UPLOAD_CREATE_DIRS_FROM_UPLOADS,
|
||||
TWTKPR_UPLOAD_DIRECTORY:
|
||||
process.env.TWTKPR_UPLOAD_DIRECTORY ||
|
||||
process.env.UPLOAD_DIRECTORY ||
|
||||
process.env.UPLOAD_DIR,
|
||||
TWTKPR_UPLOAD_ENCODING:
|
||||
process.env.TWTKPR_UPLOAD_ENCODING || process.env.UPLOAD_ENCODING,
|
||||
TWTKPR_UPLOAD_FILE_WRITE_STREAM_HANDLER:
|
||||
process.env.TWTKPR_UPLOAD_FILE_WRITE_STREAM_HANDLER ||
|
||||
process.env.UPLOAD_FILE_WRITE_STREAM_HANDLER,
|
||||
TWTKPR_UPLOAD_FILENAME:
|
||||
process.env.TWTKPR_UPLOAD_FILENAME || process.env.UPLOAD_FILENAME,
|
||||
TWTKPR_UPLOAD_FILTER:
|
||||
process.env.TWTKPR_UPLOAD_FILTER || process.env.UPLOAD_FILTER,
|
||||
TWTKPR_UPLOAD_HASH_ALGORITHM:
|
||||
process.env.TWTKPR_UPLOAD_HASH_ALGORITHM ||
|
||||
process.env.UPLOAD_HASH_ALGORITHM,
|
||||
TWTKPR_UPLOAD_KEEP_EXTENSIONS:
|
||||
process.env.TWTKPR_UPLOAD_KEEP_EXTENSIONS ||
|
||||
process.env.UPLOAD_KEEP_EXTENSIONS,
|
||||
TWTKPR_UPLOAD_MAX_FIELDS:
|
||||
process.env.TWTKPR_UPLOAD_MAX_FIELDS || process.env.UPLOAD_MAX_FIELDS,
|
||||
TWTKPR_UPLOAD_MAX_FIELDS_SIZE:
|
||||
process.env.TWTKPR_UPLOAD_MAX_FIELDS_SIZE ||
|
||||
process.env.UPLOAD_MAX_FIELDS_SIZE,
|
||||
TWTKPR_UPLOAD_MAX_FILE_SIZE:
|
||||
process.env.TWTKPR_UPLOAD_MAX_FILE_SIZE ||
|
||||
process.env.UPLOAD_MAX_FILE_SIZE,
|
||||
TWTKPR_UPLOAD_MAX_FILES:
|
||||
process.env.TWTKPR_UPLOAD_MAX_FILES || process.env.UPLOAD_MAX_FILES,
|
||||
TWTKPR_UPLOAD_MAX_TOTAL_FILE_SIZE:
|
||||
process.env.TWTKPR_UPLOAD_MAX_TOTAL_FILE_SIZE ||
|
||||
process.env.UPLOAD_MAX_TOTAL_FILE_SIZE,
|
||||
TWTKPR_UPLOAD_MIN_FILE_SIZE:
|
||||
process.env.TWTKPR_UPLOAD_MIN_FILE_SIZE ||
|
||||
process.env.UPLOAD_MIN_FILE_SIZE,
|
||||
TWTKPR_UPLOAD_ROUTE:
|
||||
process.env.TWTKPR_UPLOAD_ROUTE || process.env.UPLOAD_ROUTE,
|
||||
});
|
||||
|
||||
if (!parsedEnv.TWTKPR_ACCESS_SECRET)
|
||||
throw new Error(
|
||||
'Either ACCESS_SECRET or TWTKPR_ACCESS_SECRET must be provided'
|
||||
);
|
||||
|
||||
if (!parsedEnv.TWTKPR_REFRESH_SECRET)
|
||||
throw new Error(
|
||||
'Either REFRESH_SECRET or TWTKPR_REFRESH_SECRET must be provided'
|
||||
);
|
||||
|
||||
return parsedEnv;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(
|
||||
'Missing environment variables:',
|
||||
error.issues.flatMap((issue) => `${issue.path} or TWTKPR_${issue.path}`)
|
||||
);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
export const env = parseEnv();
|
||||
|
||||
export const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
161
src/lib/getConfiguration.ts
Normal file
161
src/lib/getConfiguration.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
MimeOptions,
|
||||
TwtKprConfiguration,
|
||||
TwtKprPluginConfiguration,
|
||||
} from '../types.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param allowedMimeTypes
|
||||
* @returns
|
||||
*/
|
||||
const getDestinationByMimeTypeConfiguration = (
|
||||
allowedMimeTypes?: string | string[] | Record<string, MimeOptions>
|
||||
) => {
|
||||
const fallback: Record<string, MimeOptions> = {
|
||||
audio: {
|
||||
directory: 'audio',
|
||||
rename: false,
|
||||
},
|
||||
image: {
|
||||
directory: 'images',
|
||||
rename: true,
|
||||
},
|
||||
video: {
|
||||
directory: 'videos',
|
||||
rename: true,
|
||||
},
|
||||
'*': {
|
||||
directory: 'files',
|
||||
rename: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mimeTypeArrayReducer = (
|
||||
acc: Record<string, MimeOptions>,
|
||||
curr: string
|
||||
) => {
|
||||
if (fallback[curr]) acc[curr] = fallback[curr];
|
||||
else
|
||||
acc[curr] = {
|
||||
directory: `${curr}s`,
|
||||
rename: false,
|
||||
};
|
||||
|
||||
return acc;
|
||||
};
|
||||
|
||||
if (!allowedMimeTypes) return fallback;
|
||||
|
||||
if (typeof allowedMimeTypes === 'string')
|
||||
return allowedMimeTypes
|
||||
.split(',')
|
||||
.map((val) => val.trim())
|
||||
.reduce(mimeTypeArrayReducer, {});
|
||||
|
||||
if (Array.isArray(allowedMimeTypes))
|
||||
return (allowedMimeTypes as string[]).reduce(mimeTypeArrayReducer, {});
|
||||
|
||||
if (typeof allowedMimeTypes === 'object') return allowedMimeTypes;
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param initialConfiguration
|
||||
* @returns
|
||||
*/
|
||||
export default function getConfiguration(
|
||||
initialConfiguration: TwtKprPluginConfiguration
|
||||
) {
|
||||
const {
|
||||
mainRoute = env.TWTKPR_DEFAULT_ROUTE,
|
||||
privateDirectory = env.TWTKPR_PRIVATE_DIRECTORY,
|
||||
publicDirectory = env.TWTKPR_PUBLIC_DIRECTORY,
|
||||
twtxtFilename = env.TWTKPR_TWTXT_FILENAME,
|
||||
postLimiterConfiguration,
|
||||
queryParameters,
|
||||
uploadConfiguration,
|
||||
} = initialConfiguration ?? {};
|
||||
|
||||
const {
|
||||
active: postLimiterActive = env.TWTKPR_POST_LIMITER_ACTIVE,
|
||||
...otherPostLimiterProps
|
||||
} = postLimiterConfiguration ?? {};
|
||||
|
||||
const {
|
||||
app = env.TWTKPR_QUERY_PARAMETER_APP,
|
||||
css = env.TWTKPR_QUERY_PARAMETER_CSS,
|
||||
following = env.TWTKPR_QUERY_PARAMETER_FOLLOWING,
|
||||
js = env.TWTKPR_QUERY_PARAMETER_JS,
|
||||
logout = env.TWTKPR_QUERY_PARAMETER_LOGOUT,
|
||||
metadata = env.TWTKPR_QUERY_PARAMETER_METADATA,
|
||||
twt = env.TWTKPR_QUERY_PARAMETER_TWT,
|
||||
twts = env.TWTKPR_QUERY_PARAMETER_TWTS,
|
||||
} = queryParameters ?? {};
|
||||
|
||||
const {
|
||||
active: uploadActive = env.TWTKPR_UPLOAD_ACTIVE,
|
||||
allowEmptyFiles = env.TWTKPR_UPLOAD_ALLOW_EMPTY_FILES,
|
||||
allowedMimeTypes = env.TWTKPR_UPLOAD_ALLOWED_MIME_TYPES,
|
||||
createDirsFromUploads = env.TWTKPR_UPLOAD_CREATE_DIRS_FROM_UPLOADS,
|
||||
directory = env.TWTKPR_UPLOAD_DIRECTORY,
|
||||
encoding = env.TWTKPR_UPLOAD_ENCODING,
|
||||
fileWriteStreamHandler,
|
||||
filter = () => true,
|
||||
hashAlgorithm = env.TWTKPR_UPLOAD_HASH_ALGORITHM,
|
||||
keepExtensions = env.TWTKPR_UPLOAD_KEEP_EXTENSIONS,
|
||||
maxFields = env.TWTKPR_UPLOAD_MAX_FIELDS,
|
||||
maxFileSize = env.TWTKPR_UPLOAD_MAX_FIELDS_SIZE,
|
||||
maxFiles = env.TWTKPR_UPLOAD_MAX_FILES,
|
||||
maxTotalFileSize = env.TWTKPR_UPLOAD_MAX_TOTAL_FILE_SIZE,
|
||||
minFileSize = env.TWTKPR_UPLOAD_MIN_FILE_SIZE,
|
||||
route = env.TWTKPR_UPLOAD_ROUTE,
|
||||
} = uploadConfiguration ?? {};
|
||||
|
||||
return {
|
||||
// secrets cannot be provided through configuration file, must use ENV / .env
|
||||
accessSecret: env.TWTKPR_ACCESS_SECRET,
|
||||
refreshSecret: env.TWTKPR_REFRESH_SECRET,
|
||||
mainRoute,
|
||||
privateDirectory,
|
||||
publicDirectory,
|
||||
twtxtFilename,
|
||||
postLimiterConfiguration: {
|
||||
active: postLimiterActive,
|
||||
...(otherPostLimiterProps ?? {}),
|
||||
},
|
||||
queryParameters: {
|
||||
...queryParameters,
|
||||
app,
|
||||
css,
|
||||
following,
|
||||
js,
|
||||
logout,
|
||||
metadata,
|
||||
twt,
|
||||
twts,
|
||||
},
|
||||
uploadConfiguration: {
|
||||
...uploadConfiguration,
|
||||
active: uploadActive,
|
||||
allowEmptyFiles,
|
||||
allowedMimeTypes: getDestinationByMimeTypeConfiguration(allowedMimeTypes),
|
||||
createDirsFromUploads,
|
||||
directory,
|
||||
encoding,
|
||||
fileWriteStreamHandler,
|
||||
filter,
|
||||
hashAlgorithm: hashAlgorithm as string | false | undefined,
|
||||
keepExtensions,
|
||||
maxFields,
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
maxTotalFileSize,
|
||||
minFileSize,
|
||||
route,
|
||||
},
|
||||
} as TwtKprConfiguration;
|
||||
}
|
||||
58
src/lib/refreshTokensDB.ts
Normal file
58
src/lib/refreshTokensDB.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import arrayDB from './arrayDB.js';
|
||||
import Debug from 'debug';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface RefreshTokensDB {
|
||||
cleanUp: () => void;
|
||||
get: (key: string) => string[];
|
||||
getObject: () => Record<string, string[]>;
|
||||
remove: (key?: string) => void;
|
||||
set: (key?: string, value?: string[]) => string[];
|
||||
}
|
||||
|
||||
const debug = Debug('twtkpr:simpleDB');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param directory
|
||||
* @returns
|
||||
*/
|
||||
export default async function refreshTokensDB(directory: string) {
|
||||
const refreshTokensDB = await arrayDB('refreshTokens', directory);
|
||||
|
||||
const get = (key: string) => {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
debug('get', key, currentTime);
|
||||
|
||||
return (refreshTokensDB.get(key) ?? []).filter((token) => {
|
||||
const val = jwt.decode(token);
|
||||
return val && ((val as jwt.JwtPayload).exp ?? 0) >= currentTime;
|
||||
});
|
||||
};
|
||||
|
||||
const cleanUp = () => {
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const tokenListByUserId = refreshTokensDB.getObject();
|
||||
|
||||
debug('cleanup', currentTime);
|
||||
|
||||
Object.keys(tokenListByUserId).forEach((userId) => {
|
||||
const tokens = refreshTokensDB.get(userId).filter((token) => {
|
||||
const val = jwt.decode(token);
|
||||
return val && ((val as jwt.JwtPayload).exp ?? 0) >= currentTime;
|
||||
});
|
||||
|
||||
debug(`setting tokens for ${userId}`, tokens);
|
||||
|
||||
refreshTokensDB.set(userId, tokens);
|
||||
});
|
||||
};
|
||||
|
||||
cleanUp();
|
||||
|
||||
return {
|
||||
...refreshTokensDB,
|
||||
cleanUp,
|
||||
get,
|
||||
} as RefreshTokensDB;
|
||||
}
|
||||
89
src/lib/simpleDB.ts
Normal file
89
src/lib/simpleDB.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import Debug from 'debug';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadObjectFromJson, saveToJson } from './utils.js';
|
||||
|
||||
const debug = Debug('twtkpr:simpleDB');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param name
|
||||
* @param directory
|
||||
* @returns
|
||||
*/
|
||||
export default async function simpleDB(name: string, directory: string) {
|
||||
let theName: string;
|
||||
let dataObject: Record<string, string>;
|
||||
|
||||
const get = (key = '') => {
|
||||
debug('get', { key });
|
||||
|
||||
if (!theName || !dataObject)
|
||||
throw new Error('DB must be initialized first');
|
||||
|
||||
key = key?.trim();
|
||||
if (!key) throw new Error('a valid key must be provided');
|
||||
|
||||
return dataObject[key];
|
||||
};
|
||||
|
||||
const getObject = () => dataObject;
|
||||
|
||||
const initialize = async (dbName = '') => {
|
||||
debug('initialize starting', { dbName });
|
||||
|
||||
dbName = dbName?.trim();
|
||||
if (!dbName) throw new Error('a valid name must be provided');
|
||||
|
||||
try {
|
||||
dataObject = await loadObjectFromJson(
|
||||
path.join(directory, `${dbName}.json`)
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
debug('initialize read error', { err });
|
||||
|
||||
if ((err as { code: string }).code === 'ENOENT') dataObject = {};
|
||||
else throw err;
|
||||
}
|
||||
|
||||
// only initialize (and set name) if everything passes
|
||||
theName = dbName;
|
||||
debug('initialize complete', { dataObject, name: theName });
|
||||
};
|
||||
|
||||
const remove = (key = '') => {
|
||||
debug('remove', { key });
|
||||
|
||||
if (!theName || !dataObject)
|
||||
throw new Error('DB must be initialized first');
|
||||
|
||||
key = key?.trim();
|
||||
if (!key) throw new Error('a valid key must be provided');
|
||||
|
||||
delete dataObject[key];
|
||||
};
|
||||
|
||||
const set = (key = '', value = '') => {
|
||||
debug('set', { key });
|
||||
|
||||
if (!theName || !dataObject)
|
||||
throw new Error('DB must be initialized first');
|
||||
|
||||
key = key?.trim();
|
||||
if (!key) throw new Error('a valid key must be provided');
|
||||
|
||||
dataObject[key] = value;
|
||||
saveToJson(dataObject, path.join(directory, `${name}.json`));
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
await initialize(name);
|
||||
|
||||
return {
|
||||
get,
|
||||
getObject,
|
||||
remove,
|
||||
set,
|
||||
};
|
||||
}
|
||||
48
src/lib/twtxtCache.ts
Normal file
48
src/lib/twtxtCache.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { NodeCache } from '@cacheable/node-cache';
|
||||
import Debug from 'debug';
|
||||
|
||||
import { parseTwtxt } from 'twtxt-lib';
|
||||
import { TwtKprConfiguration } from '../types.js';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export default function twtxtCache({
|
||||
publicDirectory,
|
||||
twtxtFilename,
|
||||
}: Pick<TwtKprConfiguration, 'publicDirectory' | 'twtxtFilename'>) {
|
||||
let isLoaded = false;
|
||||
|
||||
const debug = Debug('twtkpr:twtxtCache');
|
||||
|
||||
const cache = new NodeCache();
|
||||
|
||||
const reloadCache = async () => {
|
||||
const fileText = await fsp.readFile(
|
||||
path.join(publicDirectory, twtxtFilename),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const parsedFile = parseTwtxt(fileText);
|
||||
Object.keys(parsedFile).forEach((key) => {
|
||||
cache.set(key, parsedFile[key as keyof typeof parsedFile]); // 10 seconds
|
||||
});
|
||||
|
||||
cache.set('source', fileText);
|
||||
debug(`cache ${isLoaded ? 're' : ''}loaded`);
|
||||
|
||||
isLoaded = true;
|
||||
};
|
||||
|
||||
reloadCache();
|
||||
|
||||
return {
|
||||
cache,
|
||||
reloadCache,
|
||||
};
|
||||
}
|
||||
17
src/lib/userDB.ts
Normal file
17
src/lib/userDB.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import simpleDB from './simpleDB.js';
|
||||
|
||||
export interface UserDB {
|
||||
get: (key?: string) => string;
|
||||
getObject: () => Record<string, string>;
|
||||
remove: (key?: string) => void;
|
||||
set: (key?: string, value?: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param directory
|
||||
* @returns
|
||||
*/
|
||||
export default function userDB(directory: string) {
|
||||
return simpleDB('user', directory) as Promise<UserDB>;
|
||||
}
|
||||
88
src/lib/utils.ts
Normal file
88
src/lib/utils.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
* @param secret
|
||||
* @returns
|
||||
*/
|
||||
export const generateAccessToken = (userId: string, secret = '') =>
|
||||
jwt.sign({ id: userId }, secret, { expiresIn: '10m' });
|
||||
|
||||
/**
|
||||
*
|
||||
* @param val
|
||||
* @returns
|
||||
*/
|
||||
export const generateEtag = (val: string) =>
|
||||
crypto.createHash('sha256').update(val).digest('hex');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param userId
|
||||
* @param secret
|
||||
* @param extendRefresh
|
||||
* @returns
|
||||
*/
|
||||
export const generateRefreshToken = (
|
||||
userId: string,
|
||||
secret = '',
|
||||
extendRefresh = false
|
||||
) => {
|
||||
const tokenId = uuidv4(); // unique ID for the refresh token
|
||||
|
||||
const token = jwt.sign({ id: userId, tokenId }, secret, {
|
||||
expiresIn: extendRefresh ? '7d' : '1h',
|
||||
});
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
export const getQueryParameterArray = (value: unknown | unknown[] = []) =>
|
||||
Array.isArray(value)
|
||||
? value.map((val) => `${val}`.trim())
|
||||
: [`${value}`.trim()];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
export const getValueOrFirstEntry = (value: string | string[]) =>
|
||||
Array.isArray(value) && value.length ? value[0] : value;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param filePath
|
||||
* @returns
|
||||
*/
|
||||
export const loadObjectFromJson = async (filePath: string) => {
|
||||
const contents = await readFile(filePath, { encoding: 'utf8' });
|
||||
return JSON.parse(contents);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param contents
|
||||
* @param filePath
|
||||
*/
|
||||
export const saveToJson = async (
|
||||
contents: object | string,
|
||||
filePath: string
|
||||
) => {
|
||||
const stringContents =
|
||||
typeof contents === 'string' ? contents : JSON.stringify(contents, null, 2);
|
||||
|
||||
await writeFile(filePath, stringContents, {
|
||||
encoding: 'utf8',
|
||||
flag: 'w',
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user