alpha release
update v0.8.0
This commit is contained in:
506
src/client/script.js
Normal file
506
src/client/script.js
Normal file
@@ -0,0 +1,506 @@
|
||||
const DEBUG_ON = true;
|
||||
|
||||
// served from same path as TWTXT file
|
||||
const TWTXT_FILE_URL = window.location.pathname;
|
||||
const REMEMBER_LOGIN_STORAGE_KEY = 'rememberLogin';
|
||||
const ACCESS_TOKEN_COOKIE_KEY = 'accessToken';
|
||||
|
||||
const debug = (...vals) => {
|
||||
if (DEBUG_ON) console.log(...vals);
|
||||
};
|
||||
|
||||
export default (async () => {
|
||||
/* DOM Elements */
|
||||
const twtForm = document.getElementById('twtForm'),
|
||||
loginForm = document.getElementById('loginControls-form'),
|
||||
fileBox = document.getElementById('fileBox'),
|
||||
fileContentsSection = document.getElementById('fileContentsSection'),
|
||||
toastContainer = document.getElementById('toast-container'),
|
||||
twtControlsContentInput = document.getElementById(
|
||||
'twtControlsContentInput'
|
||||
),
|
||||
twtSubmitButton = document.querySelector('.twtControls-submitButton'),
|
||||
twtLogoutButton = document.getElementById('twtControlsLogoutButton'),
|
||||
twtFileEditButton = document.getElementById('twtControlsEditButton'),
|
||||
menuCheckbox = document.getElementById('hamburgerToggleCheckbox'),
|
||||
twtxtEditFormText = document.getElementById('twtxtEditFormText'),
|
||||
uploadInputs = document.querySelectorAll('.twtControls-uploadInput'),
|
||||
rememberToggle = document.getElementById('loginControls-rememberToggle');
|
||||
|
||||
const lastModifiedDates = {};
|
||||
let isEditing = false,
|
||||
cookie,
|
||||
fileText,
|
||||
token;
|
||||
|
||||
const showToast = (message, type = 'success') => {
|
||||
const toast = document.createElement('div');
|
||||
toast.classList.add('toast');
|
||||
if (type === 'error') toast.classList.add('error');
|
||||
toast.textContent = message;
|
||||
|
||||
toastContainer.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOut 0.5s forwards';
|
||||
setTimeout(() => toast.remove(), 500);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const beginEditMode = () => {
|
||||
isEditing = true;
|
||||
|
||||
if (menuCheckbox) menuCheckbox.checked = false;
|
||||
if (twtSubmitButton) twtSubmitButton.setAttribute('disabled', 'disabled');
|
||||
document.body.classList.add('js-editMode');
|
||||
};
|
||||
|
||||
const endEditMode = () => {
|
||||
isEditing = false;
|
||||
|
||||
if (twtSubmitButton) twtSubmitButton.removeAttribute('disabled');
|
||||
document.body.classList.remove('js-editMode');
|
||||
};
|
||||
|
||||
const getCookie = (name) => {
|
||||
const cookies = document.cookie.split('; ');
|
||||
for (const cookie of cookies) {
|
||||
const [key, value] = cookie.split('=');
|
||||
if (key === name) return decodeURIComponent(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const setCookie = (name, value, expireDays) => {
|
||||
const isSecure = window.location.protocol === 'https';
|
||||
const expireDate = new Date(); // current date
|
||||
expireDate.setTime(
|
||||
expireDate.getTime() + (expireDays ?? 0) * 24 * 60 * 60 * 1000
|
||||
);
|
||||
let expires =
|
||||
expireDays !== undefined ? `expires=${expireDate.toUTCString()}; ` : '';
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; ${expires}${isSecure ? 'Secure; ' : ''}SameSite=Strict; Path=/`;
|
||||
};
|
||||
|
||||
const loadTwtxtFile = async (filePath = TWTXT_FILE_URL) => {
|
||||
debug('loadTwtxtFile start');
|
||||
let response;
|
||||
const fileURL = `${filePath.charAt(0) !== '/' ? '/' : ''}${filePath}`;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set(
|
||||
'Accept',
|
||||
'application/twtxt+txt,text/twtxt,text/plain;q=0.9,*/*;q=0.8'
|
||||
);
|
||||
|
||||
if (lastModifiedDates[fileURL]) {
|
||||
headers['If-Modified-Since'] = lastModifiedDates[fileURL];
|
||||
}
|
||||
|
||||
try {
|
||||
response = await fetch(fileURL, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
mode: 'same-origin',
|
||||
});
|
||||
|
||||
if (response?.body) fileText = await new Response(response.body).text();
|
||||
|
||||
if (response?.headers.get('Last-Modified')) {
|
||||
lastModifiedDates[fileURL] = response?.headers.get('Last-Modified');
|
||||
}
|
||||
|
||||
if (fileText && fileBox) {
|
||||
fileBox.textContent = fileText;
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Unable to load file, please try again later.', 'error');
|
||||
console.error('Error loading file', err);
|
||||
}
|
||||
debug('loadTwtxtFile end');
|
||||
};
|
||||
|
||||
const refreshToken = async (hideToast = false) => {
|
||||
debug('refreshToken start', hideToast);
|
||||
const rememberToggleVal =
|
||||
localStorage.getItem(REMEMBER_LOGIN_STORAGE_KEY) === 'true';
|
||||
|
||||
const res = await fetch(`${TWTXT_FILE_URL}`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
rememberToggle: rememberToggleVal,
|
||||
type: 'refresh',
|
||||
}),
|
||||
credentials: 'include', // Include cookies
|
||||
});
|
||||
|
||||
if (res.ok && res?.body) {
|
||||
token = await new Response(res.body).text();
|
||||
if (rememberToggleVal) {
|
||||
debug('refreshToken set new accessToken cookie');
|
||||
setCookie(ACCESS_TOKEN_COOKIE_KEY, token);
|
||||
}
|
||||
debug('refreshToken end OK');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle refresh failure
|
||||
if (!hideToast)
|
||||
showToast('Unable to refresh token, please try again later.', 'error');
|
||||
token = undefined;
|
||||
document.body.classList.remove('js-authorized');
|
||||
debug('refreshToken end error');
|
||||
throw new Error('Failed to refresh token');
|
||||
};
|
||||
|
||||
const uploadFiles = async (files, uploadRoute, secondAttempt = false) => {
|
||||
if (!uploadRoute) return;
|
||||
|
||||
debug('uploadFiles', token, files, uploadRoute, secondAttempt);
|
||||
|
||||
const formData = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files', files[i]);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(uploadRoute, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`File${files.length !== 1 ? 's' : ''} uploaded`);
|
||||
|
||||
const filePath = await res.text();
|
||||
twtControlsContentInput.value += filePath
|
||||
.split('\n')
|
||||
.map((currFilePath) =>
|
||||
[
|
||||
' ',
|
||||
location.protocol,
|
||||
'//',
|
||||
location.hostname,
|
||||
location.protocol !== 'https' && location.port !== 80
|
||||
? ':' + location.port
|
||||
: '',
|
||||
currFilePath,
|
||||
].join('')
|
||||
)
|
||||
.join('');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!secondAttempt) {
|
||||
await refreshToken();
|
||||
return uploadFiles(files, uploadRoute, true);
|
||||
}
|
||||
|
||||
showToast(
|
||||
`Unable to upload image${files.length !== 1 ? 's' : ''} refresh token, please try again later.`,
|
||||
'error'
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
/* Handlers */
|
||||
|
||||
const dragOverHandler = (ev) => {
|
||||
const files = [...ev.dataTransfer.items].filter(
|
||||
(item) => item.kind === 'file'
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const dragOverWindowHandler = (ev) => {
|
||||
const files = [...ev.dataTransfer.items].filter(
|
||||
(item) => item.kind === 'file'
|
||||
);
|
||||
|
||||
if (files.length > 0) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!twtControlsContentInput.contains(ev.target)) {
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dropHandler = (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!uploadInputs.length) return;
|
||||
|
||||
const files = [...ev.dataTransfer.items]
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file) => file);
|
||||
|
||||
debug('dropHandler', files);
|
||||
uploadFiles(files, uploadInputs[0].getAttribute('data-route'));
|
||||
};
|
||||
|
||||
const dropWindowHandler = (ev) => {
|
||||
if ([...ev.dataTransfer.items].some((item) => item.kind === 'file')) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const editClickHandler = () => {
|
||||
if (isEditing) return;
|
||||
|
||||
if (twtxtEditFormText) twtxtEditFormText.value = fileText;
|
||||
|
||||
beginEditMode();
|
||||
};
|
||||
|
||||
const editResetHandler = (ev) => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!isEditing && !confirm('Do you want to quit editing?')) return;
|
||||
|
||||
if (fileText && fileBox) {
|
||||
fileBox.textContent = fileText;
|
||||
}
|
||||
|
||||
endEditMode();
|
||||
};
|
||||
|
||||
const editSubmitHandler = async (ev, secondAttempt = false) => {
|
||||
ev?.preventDefault();
|
||||
|
||||
try {
|
||||
const newFileText = twtxtEditFormText.value;
|
||||
|
||||
debug('edit file submit', { newFileText });
|
||||
if (!newFileText) return;
|
||||
|
||||
const res = await fetch(`${TWTXT_FILE_URL}`, {
|
||||
method: 'PUT',
|
||||
body: new URLSearchParams({
|
||||
fileContents: newFileText,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok && !secondAttempt) {
|
||||
await refreshToken();
|
||||
return editSubmitHandler(ev, true);
|
||||
}
|
||||
|
||||
showToast('File updated');
|
||||
|
||||
if (newFileText && fileBox) {
|
||||
fileText = newFileText;
|
||||
fileBox.textContent = newFileText;
|
||||
}
|
||||
|
||||
endEditMode();
|
||||
|
||||
await loadTwtxtFile();
|
||||
} catch (err) {
|
||||
if (!secondAttempt) {
|
||||
await refreshToken();
|
||||
return twtFormSubmitHandler(ev, true);
|
||||
}
|
||||
|
||||
showToast('Unable to update file, please try again later.', 'error');
|
||||
console.error('Error updating file', err);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const loginFormSubmitHandler = async (ev) => {
|
||||
ev?.preventDefault();
|
||||
let response;
|
||||
|
||||
try {
|
||||
const loginData = new URLSearchParams(new FormData(loginForm));
|
||||
debug('loginForm submit', { loginData });
|
||||
|
||||
response = await fetch(TWTXT_FILE_URL, {
|
||||
method: 'POST',
|
||||
body: loginData,
|
||||
mode: 'same-origin',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
debug('loginForm submit', { response });
|
||||
|
||||
if (!response.ok) throw new Error(response.statusText);
|
||||
showToast('Login complete');
|
||||
} catch (err) {
|
||||
showToast('Unable to login, please try again later.', 'error');
|
||||
console.error('Error logging in', err);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (response?.body) token = await new Response(response.body).text();
|
||||
|
||||
if (token) document.body.classList.add('js-authorized');
|
||||
|
||||
if (token && rememberToggle.checked)
|
||||
setCookie(ACCESS_TOKEN_COOKIE_KEY, token, 7);
|
||||
|
||||
debug('loginForm submit', { cookie, token, response });
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const logoutHandler = async () => {
|
||||
try {
|
||||
const res = await fetch(`${TWTXT_FILE_URL}`, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
type: 'logout',
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
document.cookie = `${ACCESS_TOKEN_COOKIE_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||
|
||||
if (menuCheckbox) menuCheckbox.checked = false;
|
||||
|
||||
window.location.reload();
|
||||
} catch {
|
||||
showToast('Unable to logout, please try again later.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const rememberToggleHandler = (ev) => {
|
||||
if (ev.target.checked) {
|
||||
localStorage.setItem(REMEMBER_LOGIN_STORAGE_KEY, 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(REMEMBER_LOGIN_STORAGE_KEY);
|
||||
};
|
||||
|
||||
const twtContentKeyupHandler = (ev) => {
|
||||
const hasContent =
|
||||
(twtControlsContentInput.value?.trim() ?? '').length !== 0;
|
||||
|
||||
if (twtControlsContentInput) {
|
||||
requestAnimationFrame(() => {
|
||||
// Sync with browser repaint
|
||||
twtControlsContentInput.style.height = 'auto';
|
||||
twtControlsContentInput.style.height = `${Math.max(twtControlsContentInput.scrollHeight, 80)}px`;
|
||||
});
|
||||
}
|
||||
|
||||
if (isEditing || !hasContent)
|
||||
twtSubmitButton.setAttribute('disabled', 'disabled');
|
||||
else twtSubmitButton.removeAttribute('disabled');
|
||||
|
||||
if (hasContent && ev?.key === 'Enter' && ev?.ctrlKey)
|
||||
twtFormSubmitHandler();
|
||||
};
|
||||
|
||||
const twtFormSubmitHandler = async (ev, secondAttempt = false) => {
|
||||
ev?.preventDefault();
|
||||
|
||||
try {
|
||||
const twtData = new FormData(twtForm);
|
||||
const twtContent = twtData.get('content').trim();
|
||||
|
||||
debug('twtForm submit data', { twtData });
|
||||
if (!twtContent) return;
|
||||
|
||||
twtData.set('content', twtContent.replaceAll('\n', '\u2028'));
|
||||
const twtBody = new URLSearchParams(twtData);
|
||||
debug('twtForm submit body', { twtBody });
|
||||
|
||||
const res = await fetch(TWTXT_FILE_URL, {
|
||||
method: 'POST',
|
||||
body: twtBody,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok && !secondAttempt) {
|
||||
debug('twtForm submit - not OK, trying refresh');
|
||||
await refreshToken();
|
||||
return twtFormSubmitHandler(ev, true);
|
||||
}
|
||||
|
||||
showToast('Twt sent');
|
||||
|
||||
twtForm.reset();
|
||||
await loadTwtxtFile();
|
||||
|
||||
// scroll to bottom of file to show update
|
||||
fileContentsSection.scrollTop = fileBox.scrollHeight;
|
||||
} catch (err) {
|
||||
debug('twtForm submit - error, trying refresh', err, !secondAttempt);
|
||||
if (!secondAttempt) {
|
||||
await refreshToken();
|
||||
return twtFormSubmitHandler(ev, true);
|
||||
}
|
||||
|
||||
showToast('Unable to twt, please try again later.', 'error');
|
||||
console.error('Error POSTing twt', err);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const uploadChangeHandler = (ev) => {
|
||||
uploadFiles(ev.target.files, ev.target.getAttribute('data-route'));
|
||||
};
|
||||
|
||||
/* Attach Handlers to Listeners */
|
||||
|
||||
Array.from(uploadInputs).forEach((uploadInput) => {
|
||||
uploadInput.addEventListener('change', uploadChangeHandler);
|
||||
});
|
||||
|
||||
loginForm.addEventListener('submit', loginFormSubmitHandler);
|
||||
|
||||
twtForm.addEventListener('submit', twtFormSubmitHandler);
|
||||
|
||||
twtForm.addEventListener('keyup', twtContentKeyupHandler);
|
||||
|
||||
twtControlsContentInput.addEventListener('drop', dropHandler);
|
||||
|
||||
twtControlsContentInput.addEventListener('dragover', dragOverHandler);
|
||||
|
||||
twtLogoutButton.addEventListener('click', logoutHandler);
|
||||
|
||||
twtFileEditButton.addEventListener('click', editClickHandler);
|
||||
|
||||
twtxtEditForm.addEventListener('reset', editResetHandler);
|
||||
|
||||
twtxtEditForm.addEventListener('submit', editSubmitHandler);
|
||||
|
||||
window.addEventListener('dragover', dragOverWindowHandler);
|
||||
|
||||
window.addEventListener('drop', dropWindowHandler);
|
||||
|
||||
rememberToggle.addEventListener('change', rememberToggleHandler);
|
||||
|
||||
/* Start App*/
|
||||
|
||||
loadTwtxtFile().catch(() => {});
|
||||
|
||||
token = getCookie(ACCESS_TOKEN_COOKIE_KEY);
|
||||
if (token) document.body.classList.add('js-authorized');
|
||||
|
||||
debug('client loaded');
|
||||
})();
|
||||
717
src/client/styles.css
Normal file
717
src/client/styles.css
Normal file
@@ -0,0 +1,717 @@
|
||||
/**
|
||||
* Taken from Normalize.css v12.1.1 / https://csstools.github.io/normalize.css/
|
||||
*/
|
||||
:where(html) {
|
||||
line-height: 1.15;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
:where(b, strong) {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(code, kbd, pre, samp) {
|
||||
font-family: monospace, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
:where(button, input, select) {
|
||||
margin: 0;
|
||||
}
|
||||
:where(button) {
|
||||
text-transform: none;
|
||||
}
|
||||
:where(
|
||||
button,
|
||||
input:is([type='button' i], [type='reset' i], [type='submit' i])
|
||||
) {
|
||||
-webkit-appearance: button;
|
||||
appearance: button;
|
||||
}
|
||||
:where(select) {
|
||||
text-transform: none;
|
||||
}
|
||||
:where(textarea) {
|
||||
margin: 0;
|
||||
}
|
||||
:where(input[type='search' i]) {
|
||||
-webkit-appearance: textfield;
|
||||
appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
::-webkit-input-placeholder {
|
||||
color: inherit;
|
||||
opacity: 0.54;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
:where(
|
||||
button,
|
||||
input:is(
|
||||
[type='button' i],
|
||||
[type='color' i],
|
||||
[type='reset' i],
|
||||
[type='submit' i]
|
||||
)
|
||||
)::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
:where(
|
||||
button,
|
||||
input:is(
|
||||
[type='button' i],
|
||||
[type='color' i],
|
||||
[type='reset' i],
|
||||
[type='submit' i]
|
||||
)
|
||||
)::-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local styles
|
||||
*/
|
||||
:root {
|
||||
/* #0a0a14 / Vulcan*/
|
||||
--bg: rgb(10, 10, 20);
|
||||
--bg-al: rgb(10, 10, 20, 0.6);
|
||||
|
||||
/* #1b1b27 / Steel Gray */
|
||||
--bg-hl: rgb(27, 27, 39);
|
||||
|
||||
/* #6e6e81 / Storm Gray */
|
||||
--fg: rgb(110, 110, 129);
|
||||
|
||||
--fg-hl: #ccc;
|
||||
|
||||
/* #9f9fc1 / Logan */
|
||||
--link: rgb(159, 159, 193);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
a {
|
||||
border-radius: 0.5rem;
|
||||
color: var(--link);
|
||||
padding: 0 0.25rem;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--fg-hl);
|
||||
background-color: var(--bg-hl);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--fg-hl);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background-color: var(--bg-hl);
|
||||
border-color: var(--fg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
button,
|
||||
input[type='reset'],
|
||||
input[type='submit'] {
|
||||
background-color: var(--bg-hl);
|
||||
border-color: var(--link);
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
input:disabled {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.appInfo {
|
||||
font-size: smaller;
|
||||
font-style: italic;
|
||||
margin: 0.5rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: var(--bg-hl);
|
||||
border: 1px solid var(--link);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--link);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.fileContentsSection {
|
||||
margin-bottom: 7rem;
|
||||
max-width: 100vw;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fileContentsSection-fileBox {
|
||||
margin: 0;
|
||||
max-height: 1000rem;
|
||||
overflow-y: hidden;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.hamburgerToggle {
|
||||
grid-area: menuButton;
|
||||
max-width: 3rem;
|
||||
}
|
||||
|
||||
.hamburgerToggle-icon,
|
||||
.hamburgerToggle-icon:after,
|
||||
.hamburgerToggle-icon:before {
|
||||
background-color: var(--fg);
|
||||
height: 0.25rem;
|
||||
position: absolute;
|
||||
transition-duration: 0.5s;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
.hamburgerToggle-icon {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.hamburgerToggle-icon:before {
|
||||
content: '';
|
||||
top: -12px;
|
||||
}
|
||||
|
||||
.hamburgerToggle-icon:after {
|
||||
content: '';
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.hamburgerToggle-label {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
height: 1.75rem;
|
||||
left: 0;
|
||||
position: relative;
|
||||
top: 0;
|
||||
transition-duration: 0.5s;
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
#hamburgerToggleCheckbox {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#hamburgerToggleCheckbox:checked
|
||||
+ .hamburgerToggle-label
|
||||
.hamburgerToggle-icon {
|
||||
transition-duration: 0.5s;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#hamburgerToggleCheckbox:checked
|
||||
+ .hamburgerToggle-label
|
||||
.hamburgerToggle-icon:before {
|
||||
transform: rotateZ(45deg) scaleX(1.25) translate(8px, 8px);
|
||||
}
|
||||
|
||||
#hamburgerToggleCheckbox:checked
|
||||
+ .hamburgerToggle-label
|
||||
.hamburgerToggle-icon:after {
|
||||
transform: rotateZ(-45deg) scaleX(1.25) translate(8px, -8px);
|
||||
}
|
||||
|
||||
#hamburgerToggleCheckbox:checked ~ .popupMenu {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.loginControls {
|
||||
max-height: 100rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loginControls-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.loginControls-fields-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.loginControls-input {
|
||||
max-width: 8rem;
|
||||
}
|
||||
|
||||
.loginControls input:focus {
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
|
||||
.loginControls-label {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.loginControls-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.loginControls-toggle {
|
||||
align-items: center;
|
||||
border-radius: 8rm;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.loginControls-toggle-checkbox {
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.loginControls-toggle-checkbox:not([disabled]):active
|
||||
+ .loginControls-toggle-track,
|
||||
.loginControls-toggle-checkbox:not([disabled]):focus
|
||||
+ .loginControls-toggle-track {
|
||||
/* border: 1px solid transparent; */
|
||||
box-shadow: 0px 0px 0px 2px #333;
|
||||
}
|
||||
.loginControls-toggle-checkbox:disabled + .loginControls-toggle-track {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.loginControls-toggle-track {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--fg);
|
||||
border-radius: 100px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 1.5rem;
|
||||
margin-left: 0.5rem;
|
||||
position: relative;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.loginControls-toggle-indicator {
|
||||
align-items: center;
|
||||
background: var(--bg-hl);
|
||||
border: 1px solid var(--fg);
|
||||
border-radius: 1rem;
|
||||
bottom: 0.1rem;
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
justify-content: center;
|
||||
left: 0.1rem;
|
||||
outline: solid 2px transparent;
|
||||
position: absolute;
|
||||
transition: 0.25s;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.loginControls-toggle-checkMark {
|
||||
fill: var(--fg);
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
.loginControls-toggle-checkbox:checked
|
||||
+ .loginControls-toggle-track
|
||||
.loginControls-toggle-indicator {
|
||||
transform: translateX(1.4rem);
|
||||
}
|
||||
.loginControls-toggle-checkbox:checked
|
||||
+ .loginControls-toggle-track
|
||||
.loginControls-toggle-indicator
|
||||
.loginControls-toggle-checkMark {
|
||||
opacity: 1;
|
||||
transition: opacity 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
.loginControls-submitButton {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.menu {
|
||||
background-color: var(--bg-al);
|
||||
backdrop-filter: blur(5px) saturate(70%);
|
||||
left: 0;
|
||||
padding: 0.5rem;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupMenu {
|
||||
background-color: var(--bg-hl);
|
||||
border: 1px solid var(--fg);
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
right: -10rem;
|
||||
padding: 0.5rem;
|
||||
position: fixed;
|
||||
top: -14rem;
|
||||
transition: all 0.5s;
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
.popupMenu > * {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.popupMenu > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popupMenu-appInfo {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
background-color: #4caf50;
|
||||
color: #ffffff;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
animation:
|
||||
slideIn 0.5s,
|
||||
fadeOut 0.5s 3s;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.toastContainer {
|
||||
position: fixed;
|
||||
bottom: 8rem;
|
||||
right: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.twtControls {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.twtControls-appAuthor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twtControls-appInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twtControls-contentInput {
|
||||
width: 100%;
|
||||
min-height: 5rem;
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
transition: max-height 0.2s ease;
|
||||
}
|
||||
|
||||
.twtControls-contentLabel {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
grid-area: textarea;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.twtControls-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.twtControls-formRow {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-gap: 0.5rem;
|
||||
grid-template-areas:
|
||||
'textarea menuButton'
|
||||
'textarea postButton';
|
||||
grid-template-columns: 1fr 3rem;
|
||||
grid-template-rows: auto;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.twtControls-gitLink {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel-normal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel-small {
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel:hover {
|
||||
background-color: var(--bg-hl);
|
||||
color: var(--fg-hl);
|
||||
}
|
||||
|
||||
.twtControls-uploadInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twtControls-submitButton {
|
||||
background-color: var(--bg-hl);
|
||||
border: 1px solid var(--link);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--link);
|
||||
grid-area: postButton;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.twtxtEditForm {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.5s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.twtxtEditForm-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.twtxtEditForm-textarea {
|
||||
font-size: large;
|
||||
height: 80vh;
|
||||
min-height: 6rem;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Media-based Overrides */
|
||||
|
||||
@media (min-width: 24rem) {
|
||||
.loginControls-input {
|
||||
max-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 28rem) {
|
||||
.loginControls-input {
|
||||
max-width: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
#hamburgerToggleCheckbox:checked ~ .popupMenu {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.popupMenu {
|
||||
left: -10rem;
|
||||
top: -6.5rem;
|
||||
}
|
||||
|
||||
.twtControls {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.twtControls-appInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: appInfo;
|
||||
}
|
||||
|
||||
.twtControls-submitButton {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.twtControls-formRow {
|
||||
grid-template-areas: 'menuButton appInfo textarea postButton';
|
||||
grid-template-columns: 3.5rem 3.5rem 1fr 3.5rem;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.popupMenu-appInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twtControls-appAuthor {
|
||||
display: inline-block;
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.twtControls-appInfo {
|
||||
display: block;
|
||||
grid-area: appInfo;
|
||||
}
|
||||
|
||||
.twtControls-contentInput {
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.twtControls-formRow {
|
||||
grid-template-areas: 'menuButton appInfo uploadButton textarea postButton';
|
||||
grid-template-columns: 3.5rem 1fr 5rem 1fr 3.5rem;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.twtControls-gitLink {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel-normal {
|
||||
display: block;
|
||||
grid-area: uploadButton;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.twtControls-uploadInputLabel-small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.fileContentsSection {
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 6rem;
|
||||
}
|
||||
|
||||
.menu {
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.popupMenu {
|
||||
left: -10rem;
|
||||
top: 5rem;
|
||||
}
|
||||
|
||||
.toastContainer {
|
||||
bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (-ms-high-contrast: active) {
|
||||
.loginControls-toggle-track {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* State-Based Overrides */
|
||||
|
||||
.js-authorized .loginControls {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.js-authorized .twtControls {
|
||||
max-height: 100rem;
|
||||
}
|
||||
|
||||
.js-editMode .fileContentsSection-fileBox {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.js-editMode .fileContentsSection-twtxtEditForm {
|
||||
max-height: 100rem;
|
||||
}
|
||||
|
||||
/* TODO: Fix */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* #6e6e81 / Storm Gray */
|
||||
--bg: #ccc;
|
||||
|
||||
--bg-al: rgb(204, 204, 204, 0.6);
|
||||
|
||||
--bg-hl: #b6b6c0;
|
||||
|
||||
/* #0a0a14 / Vulcan*/
|
||||
--fg: rgb(10, 10, 20);
|
||||
|
||||
/* #1b1b27 / Steel Gray */
|
||||
--fg-hl: rgb(27, 27, 39);
|
||||
|
||||
--link: #35353e;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
1
src/index.ts
Normal file
1
src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./plugin.js";
|
||||
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',
|
||||
});
|
||||
};
|
||||
43
src/middlewares/authCheckJWT.ts
Normal file
43
src/middlewares/authCheckJWT.ts
Normal 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;
|
||||
}
|
||||
25
src/middlewares/csrfProtection.ts
Normal file
25
src/middlewares/csrfProtection.ts
Normal 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
4
src/middlewares/index.ts
Normal 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';
|
||||
1
src/middlewares/postHandler/index.ts
Normal file
1
src/middlewares/postHandler/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./postHandler.js";
|
||||
86
src/middlewares/postHandler/login.ts
Normal file
86
src/middlewares/postHandler/login.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
31
src/middlewares/postHandler/logout.ts
Normal file
31
src/middlewares/postHandler/logout.ts
Normal 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;
|
||||
}
|
||||
36
src/middlewares/postHandler/memoryCache.ts
Normal file
36
src/middlewares/postHandler/memoryCache.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
69
src/middlewares/postHandler/postHandler.ts
Normal file
69
src/middlewares/postHandler/postHandler.ts
Normal 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;
|
||||
}
|
||||
117
src/middlewares/postHandler/refresh.ts
Normal file
117
src/middlewares/postHandler/refresh.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
36
src/middlewares/postHandler/twt.ts
Normal file
36
src/middlewares/postHandler/twt.ts
Normal 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);
|
||||
}
|
||||
38
src/middlewares/putHandler/editFile.ts
Normal file
38
src/middlewares/putHandler/editFile.ts
Normal 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);
|
||||
}
|
||||
1
src/middlewares/putHandler/index.ts
Normal file
1
src/middlewares/putHandler/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./putHandler.js";
|
||||
34
src/middlewares/putHandler/putHandler.ts
Normal file
34
src/middlewares/putHandler/putHandler.ts
Normal 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;
|
||||
}
|
||||
65
src/middlewares/queryHandler/followingHandler.ts
Normal file
65
src/middlewares/queryHandler/followingHandler.ts
Normal 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);
|
||||
}
|
||||
1
src/middlewares/queryHandler/index.ts
Normal file
1
src/middlewares/queryHandler/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./queryHandler.js";
|
||||
92
src/middlewares/queryHandler/metadataHandler.ts
Normal file
92
src/middlewares/queryHandler/metadataHandler.ts
Normal 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);
|
||||
}
|
||||
88
src/middlewares/queryHandler/queryHandler.ts
Normal file
88
src/middlewares/queryHandler/queryHandler.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
101
src/middlewares/queryHandler/twtHandler.ts
Normal file
101
src/middlewares/queryHandler/twtHandler.ts
Normal 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);
|
||||
}
|
||||
1
src/middlewares/renderApp/index.ts
Normal file
1
src/middlewares/renderApp/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./renderApp.js";
|
||||
148
src/middlewares/renderApp/renderApp.ts
Normal file
148
src/middlewares/renderApp/renderApp.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
29
src/middlewares/renderApp/renderUploadButton.ts
Normal file
29
src/middlewares/renderApp/renderUploadButton.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
174
src/middlewares/uploadHandler.ts
Normal file
174
src/middlewares/uploadHandler.ts
Normal 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'));
|
||||
});
|
||||
};
|
||||
}
|
||||
5
src/packageInfo.ts
Normal file
5
src/packageInfo.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import pkgInfo from "../package.json" with { type: "json" };
|
||||
|
||||
const { author, name, version } = pkgInfo;
|
||||
|
||||
export { author, name, version };
|
||||
60
src/plugin.ts
Normal file
60
src/plugin.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import cookieParser from 'cookie-parser';
|
||||
import Debug from 'debug';
|
||||
import express, { Request, Router } from 'express';
|
||||
import { TwtKprPluginConfiguration } from './types.js';
|
||||
|
||||
import authCheck from './middlewares/authCheckJWT.js';
|
||||
import twtxtCache from './lib/twtxtCache.js';
|
||||
import getConfiguration from './lib/getConfiguration.js';
|
||||
import queryHandler from './middlewares/queryHandler/index.js';
|
||||
import uploadHandler from './middlewares/uploadHandler.js';
|
||||
import postHandler from './middlewares/postHandler/index.js';
|
||||
import memoryCache from './middlewares/postHandler/memoryCache.js';
|
||||
import putHandler from './middlewares/putHandler/index.js';
|
||||
|
||||
export default function plugin(initialConfig?: TwtKprPluginConfiguration) {
|
||||
const debug = Debug('twtkpr:plugin');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const config = getConfiguration(initialConfig ?? {});
|
||||
const { publicDirectory, twtxtFilename } = config;
|
||||
|
||||
const verifyAuthRequest = (req: Request) => authCheck(req, config);
|
||||
|
||||
debug('initializing cache');
|
||||
const { cache, reloadCache } = twtxtCache({ publicDirectory, twtxtFilename });
|
||||
|
||||
debug('adding URL encoder');
|
||||
router.use(express.urlencoded({ extended: true }));
|
||||
|
||||
debug('adding cookieParser');
|
||||
router.use(cookieParser());
|
||||
|
||||
debug('adding queryRouter');
|
||||
router.use(config.mainRoute, queryHandler(config, cache, verifyAuthRequest));
|
||||
|
||||
debug(`adding uploadHandler at /${config.uploadConfiguration.route}`);
|
||||
router.post(
|
||||
`/${config.uploadConfiguration.route}`,
|
||||
uploadHandler(config, verifyAuthRequest)
|
||||
);
|
||||
|
||||
debug('adding postHandler and putHandler');
|
||||
router.use(config.mainRoute, postHandler(config), putHandler(config));
|
||||
|
||||
debug('adding static');
|
||||
router.use(express.static(config.publicDirectory));
|
||||
|
||||
debug('adding default redirect');
|
||||
router.get('/', (_, res) => {
|
||||
res.redirect(config.mainRoute);
|
||||
});
|
||||
|
||||
debug('adding memoryCache');
|
||||
router.use((req, res, next) =>
|
||||
memoryCache(req, res, next, cache, reloadCache)
|
||||
);
|
||||
|
||||
return router as Router;
|
||||
}
|
||||
58
src/types.ts
Normal file
58
src/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Options } from 'express-rate-limit';
|
||||
import formidable from 'formidable';
|
||||
|
||||
export interface MimeOptions {
|
||||
directory?: string;
|
||||
rename?: boolean;
|
||||
}
|
||||
|
||||
export interface MimeImageOptions extends MimeOptions {
|
||||
compression?: string;
|
||||
maxHeight?: string;
|
||||
maxWidth?: string;
|
||||
}
|
||||
|
||||
export interface UploadConfiguration extends Partial<
|
||||
Omit<formidable.Options, 'uploadDir'>
|
||||
> {
|
||||
active: boolean;
|
||||
directory: string;
|
||||
allowedMimeTypes: string | string[] | Record<string, MimeOptions>;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export interface QueryParameters {
|
||||
app: string;
|
||||
css: string;
|
||||
following: string;
|
||||
js: string;
|
||||
logout: string;
|
||||
metadata: string;
|
||||
twt: string;
|
||||
twts: string;
|
||||
}
|
||||
|
||||
export interface PostLimiterConfiguration extends Partial<Options> {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface TwtKprConfiguration {
|
||||
accessSecret: string;
|
||||
mainRoute: string;
|
||||
privateDirectory: string;
|
||||
publicDirectory: string;
|
||||
refreshSecret: string;
|
||||
twtxtFilename: string;
|
||||
postLimiterConfiguration?: PostLimiterConfiguration;
|
||||
queryParameters: QueryParameters;
|
||||
uploadConfiguration: UploadConfiguration;
|
||||
}
|
||||
|
||||
export interface TwtKprPluginConfiguration extends Omit<
|
||||
Partial<TwtKprConfiguration>,
|
||||
'postLimiterConfiguration' | 'queryParameters' | 'uploadConfiguration'
|
||||
> {
|
||||
postLimiterConfiguration?: Partial<PostLimiterConfiguration>;
|
||||
queryParameters?: Partial<QueryParameters>;
|
||||
uploadConfiguration?: Partial<UploadConfiguration>;
|
||||
}
|
||||
Reference in New Issue
Block a user