alpha release

update v0.8.0
This commit is contained in:
2026-03-17 22:49:38 -04:00
commit 63a91931da
157 changed files with 10951 additions and 0 deletions

89
src/lib/arrayDB.ts Normal file
View 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
View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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',
});
};