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

View File

@@ -0,0 +1,43 @@
import type { Request } from 'express';
import Debug from 'debug';
import jwt from 'jsonwebtoken';
import { TwtKprConfiguration } from '../types.js';
const debug = Debug('twtkpr:authCheckJWT');
/**
* Checks for a valid JWT, and returns a boolean indicating the result
*
* @param req
* @returns
*/
export default async function authCheckJWT(
req: Request,
config: TwtKprConfiguration
) {
debug('beginning');
const token = req.header('Authorization')?.split(' ')[1];
if (!token) {
debug('no token');
return false;
}
debug('token present');
try {
const decoded = jwt.verify(token, config.accessSecret) as { id: string };
debug({ decoded });
if (!decoded.id) return false;
req.username = decoded.id;
} catch {
debug('invalid token');
return false;
}
debug('token good');
return true;
}

View File

@@ -0,0 +1,25 @@
/*
import { doubleCsrf } from "csrf-csrf";
const {
invalidCsrfTokenError, // This is just for convenience if you plan on making your own middleware.
generateCsrfToken, // Use this in your routes to provide a CSRF token.
validateRequest, // Also a convenience if you plan on making your own middleware.
doubleCsrfProtection, // This is the default CSRF protection middleware.
} = doubleCsrf({
getSecret: (req) => 'return some cryptographically pseudorandom secret here',
getSessionIdentifier: (req) => req.session.id // return the requests unique identifier
});
const csrfTokenRoute = (req, res) => {
const csrfToken = generateCsrfToken(req, res);
// You could also pass the token into the context of a HTML response.
res.json({ csrfToken });
};
export {
csrfTokenRoute,
doubleCsrfProtection,
}
*/

4
src/middlewares/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { default as authCheck } from './authCheckJWT.js';
export { default as memoryCache } from './postHandler/memoryCache.js';
export { default as postHandler } from './postHandler/index.js';
export { default as queryHandler } from './queryHandler/index.js';

View File

@@ -0,0 +1 @@
export { default } from "./postHandler.js";

View File

@@ -0,0 +1,86 @@
import type { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import Debug from 'debug';
import { env } from '../../lib/env.js';
import refreshTokensDB, { RefreshTokensDB } from '../../lib/refreshTokensDB.js';
import userDB, { UserDB } from '../../lib/userDB.js';
import {
generateAccessToken,
generateEtag,
generateRefreshToken,
} from '../../lib/utils.js';
import { TwtKprConfiguration } from '../../types.js';
const debug = Debug('twtkpr:login');
/**
* Handles login request and (if successful) returns the JWT access token wile setting the refresh n the
*
* @param req
* @param res
* @returns
*/
export default async function loginHandler(
req: Request,
res: Response,
config: TwtKprConfiguration
) {
const { accessSecret, privateDirectory, refreshSecret } = config;
debug('starting');
try {
const tokens = await refreshTokensDB(privateDirectory);
const users = await userDB(privateDirectory);
const { username, password, rememberToggle } = req.body;
if (!username || !password || !users.get(username)) {
debug('no values found', username);
res.status(401).end();
return;
}
const isMatch = await bcrypt.compare(password, users.get(username));
if (!isMatch) {
privateDirectory;
debug('no match');
res.status(401).end();
return;
}
debug('generating tokens');
const accessToken = generateAccessToken(username, accessSecret);
debug(`access token: ${accessToken}`);
const refreshToken = generateRefreshToken(
username,
refreshSecret,
!!rememberToggle
);
debug(`refresh token: ${refreshToken}`);
debug('setting tokens');
tokens.set(username, (tokens.get(username) || []).concat([refreshToken]));
debug('setting refreshToken cookie');
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
// 1 hour or 7 days
maxAge: (rememberToggle ? 1 : 7 * 24) * 60 * 60 * 1000,
});
debug('setting response');
res.set('etag', generateEtag(accessToken)).status(200).send(accessToken);
} catch (err) {
console.error(err);
res.status(500).end();
}
}

View File

@@ -0,0 +1,31 @@
import type { Request, Response } from 'express';
import Debug from 'debug';
import { TwtKprConfiguration } from '../../types.js';
const debug = Debug('twtkpr:logout');
/**
* Handles logout request and clears the token cookies
*
* @param req
* @param res
* @returns
*/
export default async function logoutHandler(
req: Request,
res: Response,
config: TwtKprConfiguration
) {
const { mainRoute } = config;
debug('logging out');
res
.status(200)
.clearCookie('refreshToken')
.clearCookie('accessToken')
.redirect(mainRoute);
return;
}

View File

@@ -0,0 +1,36 @@
import type { NextFunction, Request, Response } from 'express';
import NodeCache from '@cacheable/node-cache';
import Debug from 'debug';
const debug = Debug('twtkpr:memoryCache');
/**
*
* @param req
* @param res
* @param next
* @param cache
* @param reloadCache
* @returns
*/
export default async function memoryCache(
req: Request,
res: Response,
next: NextFunction,
cache: NodeCache<unknown>,
reloadCache: () => Promise<void>
) {
if (cache.keys().length && !['DELETE', 'POST', 'PUT'].includes(req.method)) {
next();
return;
}
reloadCache()
.then(() => {
next();
})
.catch((err) => {
console.error(err);
});
}

View File

@@ -0,0 +1,69 @@
import Debug from 'debug';
import express, { NextFunction, Request, Response } from 'express';
import rateLimit from 'express-rate-limit';
import authCheck from '../../middlewares/authCheckJWT.js';
import { TwtKprConfiguration } from '../../types.js';
import login from './login.js';
import logout from './logout.js';
import refresh from './refresh.js';
import twt from './twt.js';
import editFile from '../putHandler/editFile.js';
const debug = Debug('twtkpr:postHandler');
/**
*
* @param config
* @returns
*/
export default function postHandler(config: TwtKprConfiguration) {
const { postLimiterConfiguration } = config;
const { active: isLimiterActive, ...otherLimiterProps } =
postLimiterConfiguration ?? {};
const postLimiter = isLimiterActive
? rateLimit({
...otherLimiterProps,
})
: (req: Request, res: Response, next: NextFunction) => {
next();
};
const { mainRoute } = config;
const router = express.Router();
router.post('/', postLimiter, async (req, res, next) => {
const { content, type } = req.body ?? {};
debug('post', { type, path: req.path });
if (type === 'logout') {
debug('logging out');
res.clearCookie('refreshToken');
res.clearCookie('accessToken');
res.redirect(mainRoute);
return;
}
if (type === 'login') return login(req, res, config);
if (type === 'logout') return logout(req, res, config);
if (type === 'refresh') return refresh(req, res, config);
debug('checking auth');
const isLoggedIn = await authCheck(req, config);
if (!isLoggedIn) {
debug('auth check failed');
next();
return;
}
debug('auth check succeeded');
if (type === 'twt' || content) return twt(req, res, config);
if (type === 'editFile') return editFile(req, res, config);
next();
});
return router;
}

View File

@@ -0,0 +1,117 @@
import type { Request, Response } from 'express';
import Debug from 'debug';
import jwt from 'jsonwebtoken';
import { env } from '../../lib/env.js';
import refreshTokensDB from '../../lib/refreshTokensDB.js';
import {
generateAccessToken,
generateEtag,
generateRefreshToken,
} from '../../lib/utils.js';
import { TwtKprConfiguration } from '../../types.js';
const debug = Debug('twtkpr:refresh');
/**
* Issues a new JWT and updates the refresh token in the cookie
*
* @param req
* @param res
*/
export default async function refresh(
req: Request,
res: Response,
config: TwtKprConfiguration
) {
const send401 = (message: string) => {
debug(message);
res
.clearCookie('accessToken')
.clearCookie('refreshToken')
.status(401)
.send(message ?? 'Unauthorized');
return;
};
try {
const tokens = await refreshTokensDB(config.privateDirectory);
const oldToken = req.cookies.refreshToken;
debug(oldToken);
if (!oldToken) return send401('Unauthorized');
let decoded = { id: '' };
try {
decoded = jwt.verify(oldToken, config.refreshSecret) as {
id: string;
};
debug({ decoded });
} catch (err) {
return send401('Refresh token invalid');
}
const username = req.username ?? decoded.id;
if (!username) return send401('Missing username');
const currentTime = Math.floor(Date.now() / 1000);
// cleanup tokens on load
const validTokens = (tokens.get(decoded.id) ?? []).filter((token) => {
const val = jwt.decode(token);
return val && ((val as jwt.JwtPayload).exp ?? 0) >= currentTime;
});
// If token is invalid or not the latest one
if (!validTokens.includes(oldToken)) {
debug('token missing from list');
return send401('Invalid refresh token');
}
debug('generating new tokens');
const newAccessToken = generateAccessToken(
req.username || decoded.id,
config.accessSecret
);
const newRefreshToken = generateRefreshToken(
req.username || decoded.id,
config.refreshSecret
);
debug('updating token list');
tokens.set(
req.username || decoded.id,
validTokens
.filter((token) => token !== oldToken)
.concat([newRefreshToken])
);
debug('setting httpOnly cookie with new refresh token');
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
// 1 hour or 7 days
maxAge: (!!req.query.rememberToggle ? 1 : 7 * 24) * 60 * 60 * 1000,
});
// Return the new access token in body
debug('generating response');
res
.set('etag', generateEtag(newAccessToken))
.status(200)
.send(newAccessToken);
} catch (err) {
console.error(err);
res.status(500).end();
}
}

View File

@@ -0,0 +1,36 @@
import type { Request, Response } from 'express';
import dayjs from 'dayjs';
import fs from 'node:fs';
import { join } from 'node:path';
import { TwtKprConfiguration } from '../../types.js';
/**
* Creates a new twt, appending it to the bottom of the TWTXT file
*
* @param req
* @param res
*/
export default function twt(
req: Request,
res: Response,
config: TwtKprConfiguration
) {
const { content } = req.body ?? {};
const date = dayjs().format();
const twt = `${date}\t${content.trim()}\n`;
const stream = fs.createWriteStream(
join(config.publicDirectory, config.twtxtFilename),
{
flags: 'a',
}
);
stream.write(twt);
stream.end();
res.status(200).send(twt);
}

View File

@@ -0,0 +1,38 @@
import type { Request, Response } from "express";
import fs from "node:fs";
import path from "node:path";
import { TwtKprConfiguration } from "../../types.js";
/**
* Creates a new twt, appending it to the bottom of the TWTXT file
*
* @param req
* @param res
*/
export default function editFile(
req: Request,
res: Response,
config: TwtKprConfiguration,
) {
const { fileContents } = req.body ?? {};
if (!fileContents) {
res.status(400).send("Missing fileContents");
return;
}
const stream = fs.createWriteStream(
path.join(config.publicDirectory, config.twtxtFilename),
{
flags: "w",
start: 0,
},
);
stream.write(fileContents);
stream.end();
res.type("text").status(200).send(fileContents);
}

View File

@@ -0,0 +1 @@
export { default } from "./putHandler.js";

View File

@@ -0,0 +1,34 @@
import Debug from 'debug';
import express from 'express';
import authCheck from '../../middlewares/authCheckJWT.js';
import { TwtKprConfiguration } from '../../types.js';
import editFile from './editFile.js';
const debug = Debug('twtkpr:putHandler');
/**
*
* @param config
* @returns
*/
export default function putHandler(config: TwtKprConfiguration) {
const router = express.Router();
router.put('/', (req, res, next) => {
debug('put', { path: req.path });
debug('checking auth');
if (!authCheck(req, config)) {
debug('auth check failed');
next();
return;
}
debug('auth check succeeded');
return editFile(req, res, config);
});
return router;
}

View File

@@ -0,0 +1,65 @@
import type { Request, Response } from 'express';
import type { Twttr } from 'twtxt-lib';
import {
generateEtag,
getQueryParameterArray,
getValueOrFirstEntry,
} from '../../lib/utils.js';
import { QueryParameters } from '../../types.js';
import NodeCache from '@cacheable/node-cache';
/**
*
* @param req
* @param res
* @param cache
* @param followingParameter
*/
export default function followingHandler(
req: Request,
res: Response,
cache: NodeCache<unknown>,
followingParameter: QueryParameters['following']
) {
const followingsToMatch = getQueryParameterArray(
req.query[followingParameter]
);
const nicksToMatch = getQueryParameterArray(req.query.nick);
const urlsToMatch = getQueryParameterArray(req.query.url);
const searchTermsToMatch = [
...getQueryParameterArray(req.query.search),
...getQueryParameterArray(req.query.s),
];
const wantsJson =
req.is('json') ||
getValueOrFirstEntry(getQueryParameterArray(req.query.format)) === 'json';
if (wantsJson) res.set('content-type', 'application/json');
else res.set('content-type', 'text/plain');
const matchedFollowing = (cache.get('following') as Twttr[]).filter(
({ nick, url }) =>
(!followingsToMatch.length ||
(followingsToMatch.length === 1 && followingsToMatch[0] === '') ||
followingsToMatch.includes(nick) ||
followingsToMatch.includes(`@${nick}`) ||
followingsToMatch.includes(url)) &&
(!nicksToMatch.length ||
nicksToMatch.includes(nick) ||
nicksToMatch.includes(`@${nick}`)) &&
(!urlsToMatch.length || urlsToMatch.includes(url)) &&
(!searchTermsToMatch.length ||
searchTermsToMatch.some(
(term) => nick.includes(term) || url.includes(term)
))
);
const result = wantsJson
? JSON.stringify(matchedFollowing)
: matchedFollowing.map(({ nick, url }) => `@${nick} ${url}`).join('\n');
res.set('etag', generateEtag(result)).send(result);
}

View File

@@ -0,0 +1 @@
export { default } from "./queryHandler.js";

View File

@@ -0,0 +1,92 @@
import type { Request, Response } from 'express';
import type { Metadata } from 'twtxt-lib';
import { env } from '../../lib/env.js';
import twtxtCache from '../../lib/twtxtCache.js';
import {
generateEtag,
getQueryParameterArray,
getValueOrFirstEntry,
} from '../../lib/utils.js';
import NodeCache from '@cacheable/node-cache';
import { QueryParameters } from '../../types.js';
export interface MetadataHandler {
cache: NodeCache<unknown>;
metadataParameter: QueryParameters['metadata'];
req: Request;
res: Response;
}
/**
*
* @param req
* @param res
* @param cache
* @param metadataParameter
*/
export default function metadataHandler(
req: Request,
res: Response,
cache: NodeCache<unknown>,
metadataParameter: QueryParameters['metadata']
) {
const metadataToMatch = getQueryParameterArray(req.query[metadataParameter]);
const searchTermsToMatch = [
...getQueryParameterArray(req.query.search),
...getQueryParameterArray(req.query.s),
];
const metadata = (cache.get('metadata') as Metadata) ?? {};
const wantsJson =
req.is('json') ||
getValueOrFirstEntry(getQueryParameterArray(req.query.format)) === 'json';
if (wantsJson) res.set('content-type', 'application/json');
else res.set('content-type', 'text/plain');
const matchedMetadata = Object.keys(metadata)
.filter(
(key) =>
(!metadataToMatch.length ||
(metadataToMatch.length === 1 && metadataToMatch[0] === '') ||
metadataToMatch.includes(key)) &&
(!searchTermsToMatch.length ||
searchTermsToMatch.some((term) =>
key.includes(term) || Array.isArray(metadata[key])
? (metadata[key] as string[]).some((val) => val.includes(term))
: metadata[key].includes(term)
))
)
.reduce(
(acc, key) => {
const value = metadata[key as keyof typeof metadata];
acc[key] = Array.isArray(value)
? value.filter(
(value) =>
!searchTermsToMatch.length ||
searchTermsToMatch.some(
(term) => key.includes(term) || value.includes(term)
)
)
: value;
return acc;
},
{} as Record<string, string | string[]>
);
const result = wantsJson
? JSON.stringify(matchedMetadata)
: Object.keys(matchedMetadata)
.map((key) => {
const value = matchedMetadata[key as keyof typeof matchedMetadata];
return Array.isArray(value)
? value.map((rowVal) => `${key}: ${rowVal}`).join('\n')
: `${key}: ${value}`;
})
.join('\n');
res.set('etag', generateEtag(result)).send(result);
}

View File

@@ -0,0 +1,88 @@
import path from 'node:path';
import NodeCache from '@cacheable/node-cache';
import Debug from 'debug';
import type { NextFunction, Request, Response } from 'express';
import { __dirname } from '../../lib/constants.js';
import { generateEtag } from '../../lib/utils.js';
import { TwtKprConfiguration } from '../../types.js';
import renderApp from '../renderApp/index.js';
import followingHandler from './followingHandler.js';
import metadataHandler from './metadataHandler.js';
import twtHandler from './twtHandler.js';
import { Twt } from 'twtxt-lib';
const debug = Debug('twtkpr:queryHandler');
/**
*
* @param config
* @param cache
* @param verifyAuthRequest
* @returns
*/
export default function queryHandler(
config: TwtKprConfiguration,
cache: NodeCache<unknown>,
verifyAuthRequest: (r: Request) => Promise<boolean>
) {
const { mainRoute, queryParameters, uploadConfiguration } = config;
return async (req: Request, res: Response, next: NextFunction) => {
debug({ query: JSON.stringify(req.query) });
if (!Object.keys(req.query).length) {
next();
return;
}
if (req.query[queryParameters.app] !== undefined) {
const appContent = renderApp({ mainRoute, uploadConfiguration });
res.set('etag', generateEtag(appContent)).send(appContent);
return;
}
if (req.query[queryParameters.css] !== undefined) {
res.sendFile('styles.css', {
root: path.resolve(__dirname, 'client'),
});
return;
}
if (req.query[queryParameters.js] !== undefined) {
res.sendFile('script.js', {
root: path.resolve(__dirname, 'client'),
});
return;
}
if (
req.query[queryParameters.following] !== undefined &&
cache.get('following')
) {
return followingHandler(req, res, cache, queryParameters.following);
}
if (
req.query[queryParameters.metadata] !== undefined &&
cache.get('metadata')
) {
return metadataHandler(req, res, cache, queryParameters.metadata);
}
if (
(req.query[queryParameters.twt] !== undefined ||
req.query[queryParameters.twts] !== undefined) &&
cache.get('twts')
) {
return twtHandler(
req,
res,
cache.get('twts') as Twt[],
queryParameters.twt,
queryParameters.twts
);
}
next();
};
}

View File

@@ -0,0 +1,101 @@
import type { Request, Response } from 'express';
import type { Metadata, Twt } from 'twtxt-lib';
import {
generateEtag,
getQueryParameterArray,
getValueOrFirstEntry,
} from '../../lib/utils.js';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import NodeCache from '@cacheable/node-cache';
import { QueryParameters } from '../../types.js';
dayjs.extend(utc);
/**
*
* @param req
* @param res
* @param twts
* @param twtParameter
* @param twtsParameter
* @returns
*/
export default function twtHandler(
req: Request,
res: Response,
twts: Twt[] = [],
twtParameter: QueryParameters['twt'],
twtsParameter: QueryParameters['twts']
) {
const twtsToMatch = getQueryParameterArray(req.query[twtsParameter]);
const showLastTwt = getQueryParameterArray(req.query[twtParameter]);
const hashesToMatch = getQueryParameterArray(req.query.hash);
const searchTermsToMatch = [
...getQueryParameterArray(req.query.search),
...getQueryParameterArray(req.query.s),
];
const createdDatesToMatch = getQueryParameterArray(req.query.created_date);
const createdUTCStartDatesToMatch = getQueryParameterArray(
req.query.created_date_start
).map((val) => dayjs.utc(val));
const createdUTCEndDatesToMatch = getQueryParameterArray(
req.query.created_date_end
).map((val) => dayjs.utc(val));
const wantsJson =
req.is('json') ||
getValueOrFirstEntry(getQueryParameterArray(req.query.format)) === 'json';
if (wantsJson) res.set('content-type', 'application/json');
else res.set('content-type', 'text/plain');
if (showLastTwt.length === 1 && showLastTwt[0] === '') {
const lastTwt = twts.reduce((matched, curr) =>
matched?.createdUTC > curr.createdUTC ? matched : curr
);
let result = 'No results';
if (lastTwt) {
result = wantsJson
? JSON.stringify(lastTwt)
: `${lastTwt?.created || ''}\t${lastTwt?.content || ''}\n`;
}
res.set('etag', generateEtag(result)).send(result);
return;
}
const matchedTwts = twts.filter(({ content, created, createdUTC, hash }) => {
return (
(!twtsToMatch.length ||
(twtsToMatch.length === 1 && twtsToMatch[0] === '') ||
twtsToMatch.includes(created) ||
(hash &&
(twtsToMatch.includes(hash) || twtsToMatch.includes(`#${hash}`)))) &&
(!hashesToMatch.length ||
(hash &&
(hashesToMatch.includes(hash) ||
hashesToMatch.includes(`#${hash}`)))) &&
(!createdDatesToMatch.length ||
createdDatesToMatch.some((date) => created.includes(date))) &&
(!createdUTCStartDatesToMatch.length ||
createdUTCStartDatesToMatch.some(
(date) => date.diff(createdUTC) < 0
)) &&
(!createdUTCEndDatesToMatch.length ||
createdUTCEndDatesToMatch.some((date) => date.diff(createdUTC) > 0)) &&
(!searchTermsToMatch.length ||
searchTermsToMatch.some((term) => content.includes(term)))
);
});
const result = wantsJson
? JSON.stringify(matchedTwts)
: matchedTwts
.map(({ content, created }) => `${created}\t${content}`)
.join('\n');
res.set('etag', generateEtag(result)).send(result);
}

View File

@@ -0,0 +1 @@
export { default } from "./renderApp.js";

View File

@@ -0,0 +1,148 @@
import { version } from '../../packageInfo.js';
import { TwtKprConfiguration } from '../../types.js';
import renderUploadButton from './renderUploadButton.js';
/**
*
* @param param0
* @returns
*/
export default function renderApp({
mainRoute,
uploadConfiguration,
}: Pick<TwtKprConfiguration, 'mainRoute' | 'uploadConfiguration'>) {
return `<!doctype html>
<html class="no-js" lang="en" xmlns:fb="http://ogp.me/ns/fb#">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- HTML head reference: https://github.com/joshbuchea/HEAD#recommended-minimum -->
<meta name="application-name" content="Application Name">
<meta name="theme-color" content="#6e6e81">
<title>TwtKpr</title>
<link rel="icon" href="favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="${mainRoute}?css" />
</head>
<body>
<div class="toastContainer" id="toast-container"></div>
<div class="app" id="app">
<div class="menu" id="menu">
<div class="loginControls" id="loginControls">
<form method="POST" action="${mainRoute}" id="loginControls-form">
<input type="hidden" name="type" value="login" />
<div class="loginControls-row">
<div class="loginControls-fields">
<div class="loginControls-fields-row">
<label class="loginControls-label" for="loginControls-username">
Username
<input type="text" class="loginControls-input"
id="loginControls-username" name="username" value="" />
</label>
<label class="loginControls-label" for="loginControls-password">
Password
<input type="password" class="loginControls-input"
id="loginControls-password" name="password" value="" />
</label>
<label class="loginControls-toggle" for="loginControls-rememberToggle">
<input type="checkbox" class="loginControls-toggle-checkbox"
id="loginControls-rememberToggle" />
Stay Logged In
<span class="loginControls-toggle-track">
<span class="loginControls-toggle-indicator">
<span class="loginControls-toggle-checkMark">
<svg viewBox="0 0 24 24" id="loginControls-svg-check"
role="presentation" aria-hidden="true">
<path d="M9.86 18a1 1 0 01-.73-.32l-4.86-5.17a1.001 1.001 0
011.46-1.37l4.12 4.39 8.41-9.2a1 1 0 111.48 1.34l-9.14 10a1 1 0
01-.73.33h-.01z"></path>
</svg>
</span>
</span>
</span>
</label>
</div>
</div>
<input class="loginControls-submitButton button" type="submit" value="Login" />
</div>
</form>
</div>
<div class="twtControls" id="twtControls">
<form class="twtControls-form" method="POST" action="${mainRoute}" id="twtForm">
<input type="hidden" name="type" value="twt" />
<div class="twtControls-formRow">
<div class="twtControls-appInfo appInfo">
TwtKpr v${version ?? 'Unknown'}
<div class="twtControls-appAuthor">
by Eric Woodward (<a href="https://itsericwoodward.com/twtxt.txt"
rel="noopener noreferrer" target="_blank">@itsericwoodward</a>)
</div>
<div class="twtControls-gitLink">
<a href="https://git.itsericwoodward.com/eric/express-twtkpr"
rel="noopener noreferrer">
https://git.itsericwoodward.com/eric/express-twtkpr
</a>
</div>
</div>
${renderUploadButton(uploadConfiguration)}
<label class="twtControls-contentLabel" for="twtControlsContentInput">
<textarea class="twtControls-contentInput"
id="twtControlsContentInput" name="content"
placeholder="What do you want to say?"></textarea>
</label>
<div class="button hamburgerToggle">
<input type="checkbox" name="hamburgerToggleCheckbox"
id="hamburgerToggleCheckbox" aria-label="Toggle Navigation" />
<label class="hamburgerToggle-label" for="hamburgerToggleCheckbox">
<div class="hamburgerToggle-icon"></div>
</label>
<div class="popupMenu">
<div class="popupMenu-appInfo appInfo">
TwtKpr v${version ?? 'Unknown'}
</div>
${renderUploadButton(uploadConfiguration, 'small')}
<button class="twtControls-editButton button" id="twtControlsEditButton">
Edit File
</button>
<button class="twtControls-logoutButton button" id="twtControlsLogoutButton">
Logout
</button>
</div>
</div>
<input class="twtControls-submitButton" disabled="disabled"
id="twtControlsSubmitButton" type="submit" value="Post\nMsg" />
</div>
</form>
</div>
</div>
<main class="fileContentsSection" id="fileContentsSection">
<pre class="fileContentsSection-fileBox" id="fileBox"></pre>
<form
action="/"
class="fileContentsSection-twtxtEditForm twtxtEditForm"
id="twtxtEditForm"
method="PUT"
>
<textarea class="twtxtEditForm-textarea" id="twtxtEditFormText"></textarea>
<div class="twtxtEditForm-controls">
<input class="button twtxtEditForm-button" type="reset" value="Cancel" />
<input class="button twtxtEditForm-button" type="submit" value="Update" />
<div>
</form>
</main>
</div>
<script type="module" src="${mainRoute}?js"></script>
</body>
</html>
`;
}

View File

@@ -0,0 +1,29 @@
import { TwtKprConfiguration } from '../../types.js';
/**
*
* @param uploadConfiguration
* @param variant
* @returns
*/
export default function renderUploadButton(
uploadConfiguration: TwtKprConfiguration['uploadConfiguration'],
variant: 'normal' | 'small' = 'normal'
) {
const { active, allowedMimeTypes, route } = uploadConfiguration ?? {};
if (!active) return '';
// determine accept from allowed mime types - may need to rebuild value based on fallback n getConfiguration, rather than at the end.
return `
<label class="button twtControls-uploadInputLabel twtControls-uploadInputLabel-${variant}"
for="twtControlsUploadInput-${variant}"
>
Upload${variant === 'normal' ? '<br />' : ' '}Files
<input accept="*" class="twtControls-uploadInput" data-route="${route}"
id="twtControlsUploadInput-${variant}"
multiple type="file" />
</label>
`;
}

View File

@@ -0,0 +1,174 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import formidable from 'formidable';
import type { NextFunction, Request, Response } from 'express';
import Debug from 'debug';
import { __dirname } from '../lib/env.js';
import { MimeOptions, TwtKprConfiguration } from '../types.js';
const debug = Debug('twtkpr:uploadHandler');
/**
*
* @param allowedMimeTypes
* @returns
*/
const getDestinationByMimeTypeConfiguration = (
allowedMimeTypes?: string | string[] | Record<string, string>
) => {
const fallback: Record<string, string> = {
audio: 'audio',
image: 'images',
text: 'texts',
video: 'videos',
'*': 'files',
};
const mimeTypeArrayReducer = (acc: Record<string, string>, curr: string) => {
if (fallback[curr]) acc[curr] = fallback[curr];
else acc[curr] = `${curr}s`;
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 config
* @param verifyAuthRequest
* @returns
*/
export default function uploadHandler(
config: TwtKprConfiguration,
verifyAuthRequest: (r: Request) => Promise<boolean>
) {
return async (req: Request, res: Response, next: NextFunction) => {
debug('checking auth');
if (!(await verifyAuthRequest(req))) {
debug('auth check failed');
res.status(401).send('Unauthorized');
return;
}
debug('auth check succeeded');
const { active, allowedMimeTypes, directory, route, ...otherProps } =
config.uploadConfiguration;
if (
!active ||
(Array.isArray(allowedMimeTypes) && !allowedMimeTypes.length)
) {
next();
return;
}
debug('using configuration: ', {
uploadConfiguration: config.uploadConfiguration,
});
const form = formidable({
uploadDir: directory,
...otherProps,
});
form.parse(req, async (err, fields, files) => {
if (err) {
next(err);
return;
}
const uploadsDir = (route ?? '').replaceAll('/', '');
let hadFileError = false;
const processedFiles: string[] = [];
const destinationByMimeType = allowedMimeTypes;
debug(`processing ${(files?.files ?? []).length} files`);
for (const file of files?.files ?? []) {
const { filepath, hash, mimetype, newFilename, originalFilename } =
file ?? {};
if (!(filepath && newFilename && originalFilename)) return;
console.log({ file });
let ext = path.extname(originalFilename).toLocaleLowerCase();
if (ext === '.jpeg') ext = '.jpg';
const finalFilename = (
hash && (mimetype?.includes('image') || mimetype?.includes('video'))
? `${hash}${ext}`
: originalFilename
)
.replace(/\s+/g, '-')
.toLocaleLowerCase();
let destinationDir = '';
Object.keys(destinationByMimeType).forEach((mimeType) => {
if (file.mimetype?.split('/')?.[0] === mimeType.toLocaleLowerCase())
destinationDir =
(
destinationByMimeType[
mimeType as keyof typeof destinationByMimeType
] as MimeOptions
).directory ?? '';
});
if (destinationDir === '')
destinationDir =
(
destinationByMimeType[
'*' as keyof typeof destinationByMimeType
] as MimeOptions
).directory ?? uploadsDir;
const finalPath = path.join(process.cwd(), 'public', destinationDir);
debug(`creating '${finalPath}'`);
fsp.mkdir(finalPath, { recursive: true });
debug(`copying '${filepath}' to '/${destinationDir}/${finalFilename}'`);
try {
await fsp.copyFile(filepath, path.join(finalPath, finalFilename));
debug(`cleaning up '${filepath}'`);
await fsp.rm(filepath);
debug(`processed successfully`);
processedFiles.push(`/${destinationDir}/${finalFilename}`);
} catch (err) {
debug(`error!`);
hadFileError = true;
console.error(err);
}
}
debug('generating reply...');
if (hadFileError && processedFiles.length) {
res.type('text/plain').status(206).send(processedFiles.join('\n'));
return;
}
if (!processedFiles.length) {
res.type('text/plain').status(500).send('No files processed');
return;
}
res.type('text/plain').status(201).send(processedFiles.join('\n'));
});
};
}