Compare commits

..

No commits in common. "master" and "20240124.2" have entirely different histories.

199 changed files with 3341 additions and 6742 deletions

View file

@ -1,4 +1,4 @@
Copyright (c) 2017-2024, flashwave <me@flash.moe>
Copyright (c) 2017-2023, flashwave <me@flash.moe>
All rights reserved.
Redistribution and use in source and binary forms, with or without

View file

@ -154,9 +154,6 @@
}
.forum__post__action {
background-color: transparent;
border: 0;
display: block;
padding: 5px 10px;
margin: 1px;
color: inherit;

View file

@ -146,14 +146,9 @@
}
.header__desktop__user__button__count {
position: absolute;
top: -5px;
right: -3px;
z-index: 1;
font-size: .5em;
line-height: 1.4em;
text-align: right;
padding: 2px 2px 0;
border-radius: 4px;
bottom: 1px;
right: 1px;
font-size: 10px;
background-color: var(--header-accent-colour);
opacity: .9;
border-radius: 4px;

View file

@ -3,6 +3,7 @@
padding: 0;
box-sizing: border-box;
position: relative;
outline-style: none;
}
html,
@ -56,8 +57,6 @@ body {
html {
scrollbar-color: var(--accent-colour) var(--background-colour);
accent-color: var(--accent-colour);
color-scheme: dark;
}
.main {
@ -164,8 +163,6 @@ html {
@include manage/_manage.css;
@include messages/messages.css;
@include news/container.css;
@include news/feeds.css;
@include news/list.css;

View file

@ -17,5 +17,4 @@
display: flex;
justify-content: center;
padding: 5px;
gap: 5px;
}

View file

@ -1,37 +0,0 @@
.messages-actions-item {
display: flex;
align-items: center;
height: 30px;
margin: 1px;
font-size: 1.3em;
line-height: 1.4em;
color: #fff;
text-decoration: none;
transition: background-color .1s;
width: 100%;
border: 0;
background-color: inherit;
text-align: left;
}
.messages-actions-item:hover,
.messages-actions-item:focus {
background-color: #444f;
}
.messages-actions-item:active,
.messages-actions-item-current {
background-color: var(--accent-colour) !important;
}
.messages-actions-item[disabled] {
background-color: inherit !important;
opacity: .4;
}
.messages-actions-item-icon {
text-align: center;
width: 30px;
flex-grow: 0;
flex-shrink: 0;
}
.messages-actions-item-label {
flex-grow: 1;
flex-shrink: 1;
}

View file

@ -1,26 +0,0 @@
.messages-columns {
display: flex;
gap: 2px;
}
.messages-columns-sidebar {
width: 200px;
flex-shrink: 0;
flex-grow: 0;
}
.messages-columns-content {
flex-shrink: 1;
flex-grow: 1;
overflow: hidden;
}
@media (max-width: 800px) {
.messages-columns {
flex-direction: column;
}
.messages-columns-sidebar {
width: 100%;
}
}

View file

@ -1,80 +0,0 @@
.messages-entry {
color: inherit;
text-decoration: none;
display: flex;
flex-direction: column;
padding: 2px 4px;
gap: 4px;
overflow: hidden;
cursor: pointer;
}
.messages-entry-header {
display: flex;
font-size: 1.1em;
line-height: 1.6em;
border-bottom: 2px solid #9999;
gap: 2px;
}
.messages-entry-check {
flex-grow: 0;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
}
.messages-entry-check input {
display: block;
}
.messages-entry-unread {
flex-grow: 0;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
}
.messages-entry-unread-orb {
width: 8px;
height: 8px;
background-color: var(--accent-colour);
border-radius: 100%;
}
.messages-entry-author {
font-weight: bold;
border-bottom: 2px solid var(--user-colour, currentColor);
margin: 0 0 -2px;
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
white-space: nowrap;
}
.messages-entry-spacing {
flex-grow: 1;
flex-shrink: 1;
}
.messages-entry-datetime {
flex-grow: 0;
flex-shrink: 0;
color: #aaa;
align-self: flex-end;
}
.messages-entry-subject {
line-height: 1.4em;
color: #fff;
overflow: hidden;
}
.messages-entry-preview {
line-height: 1.4em;
color: #888;
overflow: hidden;
}
.messages-entry-preview .messages-entry-overflow {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.messages-entry-overflow {
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -1,33 +0,0 @@
.messages-folder {
margin: 1px;
display: flex;
flex-direction: column;
gap: 1px;
padding: 1px;
}
.messages-folder-item {
background-color: #161616;
transition: background-color .1s;
}
.messages-folder-item:nth-child(2n) {
background-color: #1f1f1f;
}
.messages-folder-item:hover,
.messages-folder-item:focus {
background-color: #262626;
}
.messages-folder-item:active,
.messages-folder-item-current {
background-color: var(--accent-colour) !important;
}
.messages-folder-notice {
text-align: center;
margin: 10px;
}
.messages-folder-notice-text {
font-size: 1.4em;
line-height: 1.5em;
}
.messages-folder .pagination {
margin-top: 2px;
}

View file

@ -1,135 +0,0 @@
.messages-message {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
}
.messages-message-snippet {
cursor: pointer;
font-size: .9em;
line-height: 1.5em;
color: #888;
gap: 5px;
opacity: .8;
transition: opacity .1s;
}
.messages-message-snippet:hover,
.messages-message-snippet:focus,
.messages-message-snippet:focus-within {
opacity: 1;
}
.messages-message-draft {
border-top: 2px solid var(--accent-colour) !important;
border-left: 2px solid var(--accent-colour) !important;
border-right: 2px solid var(--accent-colour);
border-bottom: 2px solid var(--accent-colour);
}
.messages-message-deleted {
border-top: 2px solid red;
border-left: 2px solid red;
border-right: 2px solid red !important;
border-bottom: 2px solid red !important;
}
.messages-message-overflow {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.messages-message-header {
display: flex;
gap: 10px;
border-bottom: 1px #444 solid;
padding-bottom: 10px;
align-items: center;
}
.messages-message-sender-avatar {
flex-shrink: 0;
flex-grow: 0;
width: 40px;
height: 40px;
}
.messages-message-sender-avatar img {
object-fit: cover;
}
.messages-message-details {
display: flex;
flex-direction: column;
flex-shrink: 1;
flex-grow: 1;
overflow: hidden;
gap: 2px;
}
.messages-message-details-spacing {
flex-grow: 1;
flex-shrink: 1;
}
.messages-message-header-columns {
display: flex;
gap: 2px;
}
.messages-message-sender-name {
flex-grow: 0;
flex-shrink: 1;
overflow: hidden;
white-space: nowrap;
}
.messages-message-sender-name a {
color: inherit;
text-decoration: none;
font-weight: 700;
border-bottom: 2px solid var(--user-colour, currentColor);
}
.messages-message-datetime {
flex-shrink: 0;
flex-grow: 0;
align-self: flex-end;
padding-bottom: 2px;
}
.messages-message-addressee {
display: flex;
gap: 4px;
}
.messages-message-addressee-to {
flex-shrink: 0;
flex-grow: 0;
}
.messages-message-addressee-user {
flex-shrink: 1;
flex-grow: 0;
overflow: hidden;
white-space: nowrap;
}
.messages-message-addressee-user a {
color: inherit;
text-decoration: none;
font-weight: 700;
border-bottom: 2px solid var(--user-colour, currentColor);
}
.messages-message-subject {
line-height: 2em;
}
.messages-message-body {
line-height: 1.4em;
}
.messages-message-body p:first-child {
margin-top: 0 !important;
}
.messages-message-body p:last-child {
margin-bottom: 0 !important;
}
.messages-message-snippet-body {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4em;
}

View file

@ -1,9 +0,0 @@
@include messages/actions.css;
@include messages/columns.css;
@include messages/entry.css;
@include messages/folder.css;
@include messages/message.css;
@include messages/recipient.css;
@include messages/reply.css;
@include messages/sidebar.css;
@include messages/thread.css;

View file

@ -1,17 +0,0 @@
.messages-recipient {
display: flex;
flex-direction: column;
}
.messages-recipient-avatar {
display: flex;
justify-content: center;
padding: 10px;
}
.messages-recipient-name {
padding: 5px;
}
.messages-recipient-name-input {
width: 100%;
}

View file

@ -1,52 +0,0 @@
.messages-reply-form {
display: flex;
flex-direction: column;
width: 100%;
gap: 5px;
padding: 5px;
}
.messages-reply-subject-input {
width: 100%;
}
.messages-reply-body-input {
min-width: 100%;
max-width: 100%;
min-height: 100px;
}
.messages-reply-compose .messages-reply-body-input {
min-height: 300px;
}
.messages-reply-actions {
display: flex;
padding: 1px;
gap: 1px;
}
.messages-reply-action {
background-color: transparent;
border: 0;
display: block;
padding: 5px 10px;
color: inherit;
text-decoration: none;
transition: background-color .2s;
border-radius: 3px;
cursor: pointer;
}
.messages-reply-action:hover,
.messages-reply-action:focus {
background-color: rgba(0, 0, 0, .2);
}
.messages-reply-options {
display: flex;
align-items: center;
justify-content: space-between;
}
.messages-reply-settings {
display: flex;
align-items: center;
gap: 5px;
}

View file

@ -1,11 +0,0 @@
.messages-sidebar {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.messages-sidebar-button {
text-align: center;
padding: 10px;
}

View file

@ -1,5 +0,0 @@
.messages-thread {
display: flex;
flex-direction: column;
gap: 1px;
}

View file

@ -1,6 +1,6 @@
.news__feeds {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: 1fr 1fr;
grid-gap: 2px;
padding: 2px;
}

View file

@ -1,40 +0,0 @@
#include utility.js
const MszCSRFP = (() => {
let elem;
const getElement = () => {
if(elem === undefined)
elem = $q('meta[name="csrfp-token"]');
return elem;
};
const getToken = () => {
const elem = getElement();
return typeof elem.content === 'string' ? elem.content : '';
};
const setToken = token => {
if(typeof token !== 'string')
throw 'token must be a string';
const elem = getElement();
if(typeof elem.content === 'string')
elem.content = token;
};
return {
getToken: getToken,
setToken: setToken,
setFromHeaders: result => {
if(typeof result.headers !== 'function')
throw 'result.headers is not a function';
const headers = result.headers();
if(!(headers instanceof Map))
throw 'result of result.headers does not return a map';
if(headers.has('x-csrfp-token'))
setToken(headers.get('x-csrfp-token'));
},
};
})();

View file

@ -56,7 +56,7 @@ const MszAudioEmbedPlayer = function(metadata, options) {
if(haveNativeControls)
playerAttrs.controls = 'controls';
const watchers = new MszWatchers;
const watchers = new MszWatcherCollection;
watchers.define(MszAudioEmbedPlayerEvents());
const player = $e({

View file

@ -229,7 +229,7 @@ const MszVideoEmbedPlayer = function(metadata, options) {
videoAttrs.style.width = initialSize[0].toString() + 'px';
videoAttrs.style.height = initialSize[1].toString() + 'px';
const watchers = new MszWatchers;
const watchers = new MszWatcherCollection;
watchers.define(MszVideoEmbedPlayerEvents());
const player = $e({
@ -375,7 +375,7 @@ const MszVideoEmbedYouTube = function(metadata, options) {
currentTime = undefined,
isPlaying = undefined;
const watchers = new MszWatchers;
const watchers = new MszWatcherCollection;
watchers.define(MszVideoEmbedPlayerEvents());
const player = $e({
@ -576,7 +576,7 @@ const MszVideoEmbedNicoNico = function(metadata, options) {
currentTime = undefined,
isPlaying = false;
const watchers = new MszWatchers;
const watchers = new MszWatcherCollection;
watchers.define(MszVideoEmbedPlayerEvents());
const player = $e({

View file

@ -19,7 +19,7 @@ const MszEEPROM = (() => {
const scriptElem = $e({
tag: 'script',
attrs: {
src: `${peepPath}/scripts/eepromv1a.js`,
src: `${peepPath}/eeprom.js`,
charset: 'utf-8',
type: 'text/javascript',
onerror: () => reject(),

View file

@ -1,5 +1,4 @@
#include msgbox.jsx
#include parsing.js
#include utility.js
#include ext/eeprom.js
@ -14,7 +13,10 @@ const MszForumEditor = function(form) {
parserElem = form.querySelector('.js-forum-posting-parser'),
previewElem = form.querySelector('.js-forum-posting-preview'),
modeElem = form.querySelector('.js-forum-posting-mode'),
markupActs = form.querySelector('.js-forum-posting-actions');
markupBtns = form.querySelectorAll('.js-forum-posting-markup');
const bbBtns = $q('.forum__post__actions--bbcode'),
mdBtns = $q('.forum__post__actions--markdown');
let lastPostText = '',
lastPostParser;
@ -22,10 +24,10 @@ const MszForumEditor = function(form) {
MszEEPROM.init()
.catch(() => console.error('Failed to initialise EEPROM'))
.then(() => {
const eepromClient = new EEPROM(peepApp, peepPath);
const eepromClient = new EEPROM(peepApp, `${peepPath}/uploads`, '');
const eepromHistory = <div class="eeprom-widget-history-items"/>;
const eepromHandleFileUpload = async file => {
const eepromHandleFileUpload = file => {
const uploadElemNameValue = <div class="eeprom-widget-file-name-value" title={file.name}>{file.name}</div>;
const uploadElemName = <a class="eeprom-widget-file-name" target="_blank">{uploadElemNameValue}</a>;
const uploadElemProgressText = <div class="eeprom-widget-file-progress">Please wait...</div>;
@ -46,21 +48,62 @@ const MszForumEditor = function(form) {
eepromHistory.appendChild(uploadElem);
const explodeUploadElem = () => $r(uploadElem);
const uploadTask = eepromClient.create(file);
const uploadTask = eepromClient.createUpload(file);
uploadTask.onProgress(prog => {
uploadElemProgressBarValue.style.width = `${Math.ceil(prog.progress * 100)}%`;
uploadElemProgressText.textContent = `${prog.progress.toLocaleString(undefined, { style: 'percent' })} (${prog.total - prog.loaded} bytes remaining)`;
});
uploadTask.onProgress = function(progressInfo) {
const progressValue = `${progressInfo.progress}%`;
uploadElemProgressBarValue.style.width = progressValue;
uploadElemProgressText.textContent = `${progressValue} (${progressInfo.total - progressInfo.loaded} bytes remaining)`;
};
try {
const fileInfo = await uploadTask.start();
uploadTask.onFailure = function(errorInfo) {
if(!errorInfo.userAborted) {
let errorText = 'Was unable to upload file.';
switch(errorInfo.error) {
case EEPROM.ERR_INVALID:
errorText = 'Upload request was invalid.';
break;
case EEPROM.ERR_AUTH:
errorText = 'Upload authentication failed, refresh and try again.';
break;
case EEPROM.ERR_ACCESS:
errorText = "You're not allowed to upload files.";
break;
case EEPROM.ERR_GONE:
errorText = 'Upload client has a configuration error or the server is gone.';
break;
case EEPROM.ERR_DMCA:
errorText = 'This file has been uploaded before and was removed for copyright reasons, you cannot upload this file.';
break;
case EEPROM.ERR_SERVER:
errorText = 'Upload server returned a critical error, try again later.';
break;
case EEPROM.ERR_SIZE:
if(errorInfo.maxSize < 1)
errorText = 'Selected file is too large.';
else {
const types = ['bytes', 'KB', 'MB', 'GB', 'TB'],
typeIndex = parseInt(Math.floor(Math.log(errorInfo.maxSize) / Math.log(1024))),
number = Math.round(errorInfo.maxSize / Math.pow(1024, _i), 2);
errorText = `Upload may not be larger than ${number} ${types[typeIndex]}.`;
}
break;
}
uploadElem.classList.add('eeprom-widget-file-fail');
uploadElemProgressText.textContent = errorText;
MszShowMessageBox(errorText, 'Upload Error');
}
};
uploadTask.onComplete = function(fileInfo) {
uploadElem.classList.add('eeprom-widget-file-done');
uploadElemName.href = fileInfo.url;
uploadElemProgressText.textContent = '';
const insertTheLinkIntoTheBoxEx2 = () => {
const insertTheLinkIntoTheBoxEx2 = function() {
const parserMode = parseInt(parserElem.value);
let insertText = location.protocol + fileInfo.url;
@ -83,27 +126,14 @@ const MszForumEditor = function(form) {
uploadElemProgressText.appendChild(<a href="javascript:void(0)" onclick={() => insertTheLinkIntoTheBoxEx2()}>Insert</a>);
uploadElemProgressText.appendChild($t(' '));
uploadElemProgressText.appendChild(<a href="javascript:void(0)" onclick={() => {
eepromClient.delete(fileInfo)
.then(() => explodeUploadElem())
.catch(ex => {
console.error(ex);
MszShowMessageBox(ex, 'Upload Error');
});
eepromClient.deleteUpload(fileInfo).start();
explodeUploadElem();
}}>Delete</a>);
insertTheLinkIntoTheBoxEx2();
} catch(ex) {
let errorText = 'Upload aborted.';
};
if(!ex.aborted) {
console.error(ex);
errorText = ex.toString();
}
uploadElem.classList.add('eeprom-widget-file-fail');
uploadElemProgressText.textContent = errorText;
await MszShowMessageBox(errorText, 'Upload Error');
}
uploadTask.start();
};
const eepromFormInput = <input type="file" multiple={true} class="eeprom-widget-form-input"
@ -174,15 +204,13 @@ const MszForumEditor = function(form) {
}
});
const switchButtons = parser => {
$rc(markupActs);
for(const button of markupBtns)
button.addEventListener('click', () => $insertTags(textElem, button.dataset.tagOpen, button.dataset.tagClose));
const tags = MszParsing.getTagsFor(parser);
for(const tag of tags)
markupActs.appendChild(<button class={['forum__post__action', 'forum__post__action--tag', `forum__post__action--${tag.name}`]}
type="button" title={tag.summary} onclick={() => $insertTags(textElem, tag.open, tag.close)}>
<i class={tag.icon}/>
</button>);
const switchButtons = parser => {
parser = parseInt(parser);
bbBtns.hidden = parser !== 1;
mdBtns.hidden = parser !== 2;
};
const renderPreview = async (parser, text) => {

View file

@ -4,7 +4,6 @@
#include events/events.js
#include ext/sakuya.js
#include forum/editor.jsx
#include messages/messages.js
(async () => {
const initLoginPage = async () => {
@ -81,8 +80,6 @@
await initLoginPage();
MszMessages();
MszEmbed.handle($qa('.js-msz-embed-media'));
} catch(ex) {
console.error(ex);

View file

@ -1,89 +0,0 @@
#include watcher.js
const MszMessagesActionButton = function(button, stateless) {
if(!(button instanceof Element))
throw 'button must be an element';
const stateful = !stateless;
const pub = {};
const icon = button.querySelector('.js-messages-button-icon i');
const label = button.querySelector('.js-messages-button-label');
const update = () => {
if(stateful) {
icon.className = button.dataset[`${button.dataset.state}Ico`];
label.textContent = button.dataset[`${button.dataset.state}Str`];
}
};
pub.update = update;
const stateWatcher = new MszWatcher;
const getState = () => button.dataset.state !== 'inactive';
const setState = state => {
button.dataset.state = state ? 'active' : 'inactive';
update();
stateWatcher.call(getState());
};
if(stateful) {
pub.getState = getState;
pub.setState = setState;
pub.watchState = handler => { stateWatcher.watch(handler, getState()); };
pub.unwatchState = handler => { stateWatcher.unwatch(handler); };
}
let clickAction;
const click = async () => {
if(clickAction !== undefined) {
if(stateful) {
const result = await clickAction(getState());
if(typeof result === 'boolean')
setState(result);
} else
await clickAction();
}
};
pub.click = click;
button.addEventListener('click', () => click());
update();
pub.setAction = action => {
if(typeof action !== 'function')
throw 'action must be a function';
clickAction = action;
};
let preventEnable = false;
pub.getEnabled = () => !button.disabled;
pub.setEnabled = state => {
if(!preventEnable)
button.disabled = !state;
};
pub.disableWith = async callback => {
if(typeof callback !== 'function')
throw 'callback must be a function';
if(preventEnable)
throw 'preventEnable is true';
preventEnable = true;
const wasDisabled = button.disabled;
button.disabled = true;
try {
return await callback();
} finally {
button.disabled = wasDisabled;
preventEnable = false;
}
};
pub.setHidden = state => {
button.hidden = state;
};
return pub;
};

View file

@ -1,167 +0,0 @@
#include utility.js
#include watcher.js
const MsgMessagesList = function(list) {
if(!(list instanceof Element))
throw 'list must be an element';
const watchers = new MszWatchers;
watchers.define(['select']);
let selectedCount = 0;
const items = Array.from(list.querySelectorAll('.js-messages-entry')).map(elem => {
const item = new MsgMessagesEntry(elem);
item.onSelectedChange((state, initial) => {
if(state)
++selectedCount;
else if(!initial)
--selectedCount;
if(!initial)
watchers.call('select', selectedCount, items.length);
});
return item;
});
const recountSelected = () => {
selectedCount = 0;
for(const item of items)
if(item.getSelected())
++selectedCount;
};
const onSelectedChange = handler => {
watchers.watch('select', handler, selectedCount, items.length);
};
onSelectedChange(selectedCount => {
const state = selectedCount > 0;
for(const item of items)
item.setClickIsSelect(state);
});
return {
getItems: () => items,
getItemsCount: () => items.length,
getSelectedItems: () => {
const selected = [];
for(const item of items)
if(item.getSelected())
selected.push(item);
return selected;
},
removeItem: item => {
$ari(items, item);
$r(item.getElement());
recountSelected();
watchers.call('select', selectedCount, items.length);
},
getAllSelected: () => {
if(items.length < 1)
return false;
for(const item of items)
if(!item.getSelected())
return false;
return true;
},
setAllSelected: state => {
for(const item of items)
item.setSelected(state);
selectedCount = state ? items.length : 0;
watchers.call('select', selectedCount, items.length);
},
onSelectedChange: onSelectedChange,
};
};
const MsgMessagesEntry = function(entry) {
if(!(entry instanceof Element))
throw 'entry must be an element';
const msgId = entry.dataset.msgId;
const unreadElem = entry.querySelector('.js-messages-entry-unread');
const isRead = () => entry.dataset.msgRead === 'read';
const setRead = state => {
if(state) {
entry.dataset.msgRead = 'read';
unreadElem.hidden = true;
} else {
entry.dataset.msgRead = 'unread';
unreadElem.hidden = false;
}
};
const isSent = () => entry.dataset.msgSent === 'sent';
const setSent = state => {
entry.dataset.msgRead = state ? 'sent' : 'draft';
};
const checkbox = entry.querySelector('.js-entry-checkbox');
const getSelected = () => checkbox.checked;
const setSelected = state => checkbox.checked = state;
const toggleSelected = () => checkbox.checked = !checkbox.checked;
let clickIsSelect = false;
const watchers = new MszWatchers;
watchers.define(['select']);
checkbox.addEventListener('click', ev => ev.stopPropagation());
checkbox.addEventListener('keydown', ev => ev.stopPropagation());
checkbox.addEventListener('change', () => {
watchers.call('select', getSelected());
});
const navigateToMessage = () => {
const url = entry.dataset.msgUrl;
if(url !== undefined && url.startsWith('/') && !url.startsWith('//'))
location.assign(url);
};
entry.addEventListener('keydown', ev => {
if(ev.key === 'Enter' || ev.key === 'NumpadEnter') {
ev.preventDefault();
entry.click();
}
});
entry.addEventListener('click', ev => {
ev.preventDefault();
if(clickIsSelect)
checkbox.click();
else
navigateToMessage();
});
entry.addEventListener('dblclick', ev => {
ev.preventDefault();
if(clickIsSelect)
navigateToMessage();
});
return {
getId: () => msgId,
getElement: () => entry,
isRead: isRead,
setRead: setRead,
isSent: isSent,
setSent: setSent,
getSelected: getSelected,
setSelected: setSelected,
toggleSelected: toggleSelected,
setClickIsSelect: state => clickIsSelect = state,
onSelectedChange: handler => {
watchers.watch('select', handler, getSelected());
},
};
};

View file

@ -1,386 +0,0 @@
#include csrfp.js
#include msgbox.jsx
#include utility.js
#include messages/actbtn.js
#include messages/list.js
#include messages/recipient.js
#include messages/reply.jsx
#include messages/thread.js
const MszMessages = () => {
const extractMsgIds = msg => {
if(typeof msg.getId === 'function')
return msg.getId();
if(typeof msg.toString === 'function')
return msg.toString();
throw 'unsupported message type';
};
const displayErrorMessage = async error => {
let text;
if(typeof error === 'string')
text = error;
else if(typeof error.text === 'string')
text = error.text;
else if(typeof error.toString === 'function')
text = error.toString();
else
text = 'Something indescribable happened.';
await MszShowMessageBox(text, 'Error');
return false;
};
const msgsCreate = async (title, text, parser, draft, recipient, replyTo) => {
const formData = new FormData;
formData.append('_csrfp', MszCSRFP.getToken());
formData.append('title', title);
formData.append('body', text);
formData.append('parser', parser);
formData.append('draft', draft);
formData.append('recipient', recipient);
formData.append('reply', replyTo);
const result = await $x.post('/messages/create', { type: 'json' }, formData);
MszCSRFP.setFromHeaders(result);
const body = result.body();
if(body.error !== undefined)
throw body.error;
return body;
};
const msgsUpdate = async (messageId, title, text, parser, draft) => {
const formData = new FormData;
formData.append('_csrfp', MszCSRFP.getToken());
formData.append('title', title);
formData.append('body', text);
formData.append('parser', parser);
formData.append('draft', draft);
const result = await $x.post(`/messages/${encodeURIComponent(messageId)}`, { type: 'json' }, formData);
MszCSRFP.setFromHeaders(result);
const body = result.body();
if(body.error !== undefined)
throw body.error;
return body;
};
const msgsMark = async (msgs, state) => {
const result = await $x.post('/messages/mark', { type: 'json' }, {
_csrfp: MszCSRFP.getToken(),
type: state,
messages: msgs.map(extractMsgIds).join(','),
});
MszCSRFP.setFromHeaders(result);
const body = result.body();
if(body.error !== undefined)
throw body.error;
return true;
};
const msgsDelete = async msgs => {
const result = await $x.post('/messages/delete', { type: 'json' }, {
_csrfp: MszCSRFP.getToken(),
messages: msgs.map(extractMsgIds).join(','),
});
MszCSRFP.setFromHeaders(result);
const body = result.body();
if(body.error !== undefined)
throw body.error;
return true;
};
const msgsRestore = async msgs => {
const result = await $x.post('/messages/restore', { type: 'json' }, {
_csrfp: MszCSRFP.getToken(),
messages: msgs.map(extractMsgIds).join(','),
});
MszCSRFP.setFromHeaders(result);
const body = result.body();
if(body.error !== undefined)
throw body.error;
return true;
};
const msgsNuke = async msgs => {
const result = await $x.post('/messages/nuke', { type: 'json' }, {
_csrfp: MszCSRFP.getToken(),
messages: msgs.map(extractMsgIds).join(','),
});
MszCSRFP.setFromHeaders(result);
const body = result.body();
if(body.error !== undefined)
throw body.error;
return true;
};
const msgsUserBtns = Array.from($qa('.js-header-pms-button'));
if(msgsUserBtns.length > 0)
$x.get('/messages/stats', { type: 'json' }).then(result => {
const body = result.body();
if(typeof body === 'object' && typeof body.unread === 'number')
if(body.unread > 0)
for(const msgsUserBtn of msgsUserBtns)
msgsUserBtn.append($e({ child: body.unread.toLocaleString(), attrs: { className: 'header__desktop__user__button__count' } }));
});
const msgsListElem = $q('.js-messages-list');
const msgsList = msgsListElem instanceof Element ? new MsgMessagesList(msgsListElem) : undefined;
const msgsListEmptyNotice = $q('.js-messages-folder-empty');
const msgsThreadElem = $q('.js-messages-thread');
const msgsThread = msgsThreadElem instanceof Element ? new MszMessagesThread(msgsThreadElem) : undefined;
const msgsRecipientElem = $q('.js-messages-recipient');
const msgsRecipient = msgsRecipientElem instanceof Element ? new MszMessagesRecipient(msgsRecipientElem) : undefined;
const msgsReplyElem = $q('.js-messages-reply');
const msgsReply = msgsReplyElem instanceof Element ? new MszMessagesReply(msgsReplyElem) : undefined;
if(msgsReply !== undefined) {
if(msgsRecipient !== undefined)
msgsRecipient.onUpdate(async info => {
msgsReply.setRecipient(typeof info.id === 'string' ? info.id : '');
msgsReply.setWarning(info.ban ? `${(typeof info.name === 'string' ? info.name : 'This user')} has been banned and will be unable to respond to your messages.` : undefined);
});
msgsReply.onSubmit(async form => {
try {
let result;
if(typeof form.message === 'string') {
result = await msgsUpdate(
form.message,
form.title,
form.body,
form.parser,
form.draft
);
} else {
result = await msgsCreate(
form.title,
form.body,
form.parser,
form.draft,
form.recipient,
form.reply || ''
);
}
if(typeof result.url === 'string')
location.assign(result.url);
} catch(ex) {
return await displayErrorMessage(ex);
}
});
}
let actSelectAll, actMarkRead, actMoveTrash, actNuke;
const actSelectAllBtn = $q('.js-messages-actions-select-all');
if(actSelectAllBtn instanceof Element) {
actSelectAll = new MszMessagesActionButton(actSelectAllBtn);
if(msgsList !== undefined) {
actSelectAll.setAction(async state => {
msgsList.setAllSelected(!state);
return !state;
});
msgsList.onSelectedChange((selectedNo, itemNo) => {
actSelectAll.setState(selectedNo >= itemNo);
});
actSelectAll.setState(msgsList.getAllSelected());
}
}
const actMarkReadBtn = $q('.js-messages-actions-mark-read');
if(actMarkReadBtn instanceof Element) {
actMarkRead = new MszMessagesActionButton(actMarkReadBtn);
if(msgsList !== undefined) {
msgsList.onSelectedChange(selectedNo => {
const enabled = selectedNo > 0;
actMarkRead.setEnabled(enabled);
if(enabled) {
const items = msgsList.getSelectedItems();
let readNo = 0, unreadNo = 0;
for(const item of items) {
if(item.isRead())
++readNo;
else
++unreadNo;
}
actMarkRead.setState(readNo > unreadNo);
}
});
actMarkRead.setAction(async state => {
const items = msgsList.getSelectedItems();
const result = await actMarkRead.disableWith(async () => {
try {
return await msgsMark(items, state ? 'unread' : 'read');
} catch(ex) {
return await displayErrorMessage(ex);
}
});
if(result) {
state = !state;
for(const item of items)
item.setRead(state);
return state;
}
});
} else if(msgsThread !== undefined) {
actMarkRead.setAction(async state => {
const items = [msgsThread.getMessage()];
const result = await actMarkRead.disableWith(async () => {
try {
return await msgsMark(items, state ? 'unread' : 'read');
} catch(ex) {
return await displayErrorMessage(ex);
}
});
return result ? !state : state;
});
}
}
const actMoveTrashBtn = $q('.js-messages-actions-move-trash');
if(actMoveTrashBtn instanceof Element) {
actMoveTrash = new MszMessagesActionButton(actMoveTrashBtn);
if(msgsList !== undefined) {
msgsList.onSelectedChange(selectedNo => actMoveTrash.setEnabled(selectedNo > 0));
actMoveTrash.setAction(async state => {
const items = msgsList.getSelectedItems();
if(!state && !await MszShowConfirmBox(`Are you sure you wish to delete ${items.length} item${items.length === 1 ? '' : 's'}?`, 'Confirmation'))
return;
const result = await actMoveTrash.disableWith(async () => {
try {
if(state)
return await msgsRestore(items);
return await msgsDelete(items);
} catch(ex) {
return await displayErrorMessage(ex);
}
});
if(result)
for(const message of items)
msgsList.removeItem(message);
if(msgsListEmptyNotice instanceof Element)
msgsListEmptyNotice.hidden = msgsList.getItemsCount() > 0;
});
} else if(msgsThread !== undefined) {
actMoveTrash.setAction(async state => {
if(!state && !await MszShowConfirmBox('Are you sure you wish to delete this message?', 'Confirmation'))
return;
const items = [msgsThread.getMessage()];
const result = await actMoveTrash.disableWith(async () => {
try {
if(state)
return await msgsRestore(items);
return await msgsDelete(items);
} catch(ex) {
return await displayErrorMessage(ex);
}
});
if(result) {
state = !state;
if(msgsReply !== undefined)
msgsReply.setHidden(state);
const msg = msgsThread.getMessage();
if(msg !== undefined)
msg.setDeleted(state);
return state;
}
});
}
}
const actNukeBtn = $q('.js-messages-actions-nuke');
if(actNukeBtn instanceof Element) {
actNuke = new MszMessagesActionButton(actNukeBtn, true);
if(msgsList !== undefined) {
msgsList.onSelectedChange(selectedNo => actNuke.setEnabled(selectedNo > 0));
actNuke.setAction(async () => {
const items = msgsList.getSelectedItems();
if(!await MszShowConfirmBox(`Are you sure you wish to PERMANENTLY delete ${items.length} item${items.length === 1 ? '' : 's'}?`, 'Confirmation'))
return;
const result = await actNuke.disableWith(async () => {
try {
return await msgsNuke(items);
} catch(ex) {
return await displayErrorMessage(ex);
}
});
if(result)
for(const message of items)
msgsList.removeItem(message);
if(msgsListEmptyNotice instanceof Element)
msgsListEmptyNotice.hidden = msgsList.getItemsCount() > 0;
});
} else if(msgsThread !== undefined) {
actMoveTrash.watchState(state => {
actNuke.setHidden(!state);
});
actNuke.setAction(async () => {
if(!await MszShowConfirmBox('Are you sure you wish to PERMANENTLY delete this message?', 'Confirmation'))
return;
const items = [msgsThread.getMessage()];
const result = await actNuke.disableWith(async () => {
try {
return await msgsNuke(items);
} catch(ex) {
return await displayErrorMessage(ex);
}
});
if(result)
location.assign('/messages');
});
}
}
};

View file

@ -1,56 +0,0 @@
#include csrfp.js
#include utility.js
const MszMessagesRecipient = function(element) {
if(!(element instanceof Element))
throw 'element must be an instance of Element';
const avatarElem = element.querySelector('.js-messages-recipient-avatar img');
const nameInput = element.querySelector('.js-messages-recipient-name');
let updateHandler = undefined;
const update = async () => {
const result = await $x.post(element.dataset.msgLookup, { type: 'json' }, {
_csrfp: MszCSRFP.getToken(),
name: nameInput.value,
});
MszCSRFP.setFromHeaders(result);
const body = result.body();
if(updateHandler !== undefined)
await updateHandler(body);
if(typeof body.avatar === 'string')
avatarElem.src = body.avatar;
if(typeof body.name === 'string')
nameInput.value = body.name;
};
let nameTimeout = null;
nameInput.addEventListener('input', () => {
if(nameTimeout !== undefined)
return;
nameTimeout = setTimeout(() => {
update().finally(() => {
clearTimeout(nameTimeout);
nameTimeout = undefined;
});
}, 750);
});
update().finally(() => nameTimeout = undefined);
return {
getElement: () => element,
onUpdate: handler => {
if(typeof handler !== 'function')
throw 'handler must be a function';
updateHandler = handler;
},
};
};

View file

@ -1,171 +0,0 @@
#include parsing.js
#include ext/eeprom.js
const MszMessagesReply = function(element) {
if(!(element instanceof Element))
throw 'element must be an Element';
const form = element.querySelector('.js-messages-reply-form');
const bodyElem = form.querySelector('.js-messages-reply-body');
const actsElem = form.querySelector('.js-messages-reply-actions');
const parserSelect = form.querySelector('.js-messages-reply-parser');
const saveBtn = form.querySelector('.js-messages-reply-save');
const sendBtn = form.querySelector('.js-messages-reply-send');
const warnElem = form.querySelector('.js-reply-form-warning');
const warnText = warnElem instanceof Element ? warnElem.querySelector('.js-reply-form-warning-text') : undefined;
let submitHandler;
form.addEventListener('submit', ev => {
ev.preventDefault();
if(typeof submitHandler === 'function') {
const fields = Array.from(form.elements);
const result = {};
for(const field of fields) {
if((field instanceof HTMLButtonElement || (field instanceof HTMLInputElement && field.type === 'submit')) && ev.submitter !== field)
continue;
if(typeof field.name === 'string' && field.name.length > 0)
result[field.name] = field.value;
}
submitHandler(result);
}
});
bodyElem.addEventListener('keydown', ev => {
if((ev.code === 'Enter' || ev.code === 'NumpadEnter') && ev.ctrlKey && !ev.altKey && !ev.metaKey) {
ev.preventDefault();
if(ev.shiftKey)
saveBtn.click();
else
sendBtn.click();
}
});
const switchButtons = parser => {
$rc(actsElem);
const tags = MszParsing.getTagsFor(parser);
actsElem.hidden = tags.length < 1;
for(const tag of tags)
actsElem.appendChild(<button class="messages-reply-action" type="button" title={tag.summary} onclick={() => $insertTags(bodyElem, tag.open, tag.close)}>
<i class={tag.icon}/>
</button>);
};
switchButtons(parserSelect.value);
parserSelect.addEventListener('change', () => {
switchButtons(parserSelect.value);
});
// this implementation is godawful but it'll do for now lol
// need to make it easier to share the forum's implementation
MszEEPROM.init()
.catch(() => console.error('Failed to initialise EEPROM'))
.then(() => {
const eepromClient = new EEPROM(peepApp, peepPath);
const eepromHandleFileUpload = async file => {
const uploadTask = eepromClient.create(file);
try {
const fileInfo = await uploadTask.start();
const parserMode = parseInt(parserSelect.value);
let insertText = location.protocol + fileInfo.url;
if(parserMode == 1) { // bbcode
if(fileInfo.isImage())
insertText = `[img]${fileInfo.url}[/img]`;
else if(fileInfo.isAudio())
insertText = `[audio]${fileInfo.url}[/audio]`;
else if(fileInfo.isVideo())
insertText = `[video]${fileInfo.url}[/video]`;
} else if(parserMode == 2) { // markdown
if(fileInfo.isMedia())
insertText = `![](${fileInfo.url})`;
}
$insertTags(bodyElem, insertText, '');
bodyElem.value = bodyElem.value.trim();
} catch(ex) {
let errorText = 'Upload aborted.';
if(!ex.aborted) {
console.error(ex);
errorText = ex.toString();
}
await MszShowMessageBox(errorText, 'Upload Error');
}
};
bodyElem.addEventListener('paste', ev => {
if(ev.clipboardData && ev.clipboardData.files.length > 0) {
ev.preventDefault();
const files = ev.clipboardData.files;
for(const file of files)
eepromHandleFileUpload(file);
}
});
document.body.addEventListener('dragenter', ev => {
ev.preventDefault();
ev.stopPropagation();
});
document.body.addEventListener('dragover', ev => {
ev.preventDefault();
ev.stopPropagation();
});
document.body.addEventListener('dragleave', ev => {
ev.preventDefault();
ev.stopPropagation();
});
document.body.addEventListener('drop', ev => {
ev.preventDefault();
ev.stopPropagation();
if(ev.dataTransfer && ev.dataTransfer.files.length > 0) {
const files = ev.dataTransfer.files;
for(const file of files)
eepromHandleFileUpload(file);
}
});
});
return {
getElement: () => element,
setWarning: text => {
if(warnElem === undefined || warnText === undefined)
return;
if(text === undefined) {
warnElem.hidden = true;
warnText.textContent = '';
} else {
warnElem.hidden = false;
warnText.textContent = text;
}
},
setRecipient: userId => {
for(const field of form.elements)
if(field.name === 'recipient') {
field.value = userId;
break;
}
},
getHidden: () => element.hidden,
setHidden: state => {
element.hidden = state;
},
onSubmit: handler => {
if(typeof handler !== 'function')
throw 'handler must be a function';
submitHandler = handler;
},
};
};

View file

@ -1,78 +0,0 @@
const MszMessagesThread = function(thread) {
if(!(thread instanceof Element))
throw 'thread must be an element';
const messages = Array.from(thread.querySelectorAll('.js-messages-message')).map(elem => new MszMessagesThreadMessage(elem));
const message = messages.find(msg => msg.isFull());
return {
getMessage: () => message,
getMessages: () => messages,
};
};
const MszMessagesThreadMessage = function(message) {
if(!(message instanceof Element))
throw 'message must be an element';
const msgId = message.dataset.msgId;
const type = message.dataset.msgType;
const url = message.dataset.msgUrl;
if(type === 'snip') {
message.addEventListener('click', ev => {
if(typeof url !== 'string')
return;
let target = ev.target;
while(target !== message) {
if(target instanceof HTMLAnchorElement)
return;
target = target.parentNode;
}
ev.preventDefault();
location.assign(url);
});
} else if(type === 'full') {
message.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
const isRead = () => message.dataset.msgRead === 'read';
const setRead = state => {
message.dataset.msgRead = state ? 'read' : 'unread';
};
const isSent = () => message.dataset.msgSent === 'sent';
const setSent = state => {
message.dataset.msgRead = state ? 'sent' : 'draft';
};
const isDeleted = () => message.dataset.msgDeleted === 'yes';
const setDeleted = state => {
if(state) {
message.dataset.msgDeleted = 'yes';
message.classList.add('messages-message-deleted');
} else {
message.dataset.msgDeleted = 'no';
message.classList.remove('messages-message-deleted');
}
};
return {
getId: () => msgId,
getType: () => type,
isFull: () => type === 'full',
isSnippet: () => type === 'snip',
isRead: isRead,
setRead: setRead,
isSent: isSent,
setSent: setSent,
isDeleted: isDeleted,
setDeleted: setDeleted,
};
};

View file

@ -1,73 +1,49 @@
#include utility.js
const MszShowConfirmBox = async (text, title, target) => {
let result = false;
await MszShowMessageBox(text, title, [
{ text: 'Yes', callback: async () => result = true },
{ text: 'No' },
], target);
return result;
};
const MszShowMessageBox = (text, title, buttons, target) => {
if(typeof text !== 'string') {
if(text !== undefined && text !== null && typeof text.toString === 'function')
text = text.toString();
else throw 'text must be a string';
}
const MszShowMessageBox = async (text, title, buttons, target) => {
if(typeof text !== 'string')
throw 'text must be a string';
if(!(target instanceof Element))
target = document.body;
if(target.querySelector('.messagebox'))
return false;
if(typeof title !== 'string')
title = 'Information';
if(!Array.isArray(buttons))
buttons = [];
return new Promise((resolve, reject) => {
if(target.querySelector('.messagebox')) {
reject();
return;
}
let buttonsElem;
const html = <div class="messagebox">
<div class="container messagebox__container">
<div class="container__title">
<div class="container__title__background"/>
<div class="container__title__text">{title}</div>
</div>
<div class="container__content">{text}</div>
{buttonsElem = <div class="messagebox__buttons"/>}
let buttonsElem;
const html = <div class="messagebox">
<div class="container messagebox__container">
<div class="container__title">
<div class="container__title__background"/>
<div class="container__title__text">{title}</div>
</div>
</div>;
<div class="container__content">{text}</div>
{buttonsElem = <div class="messagebox__buttons"/>}
</div>
</div>;
let firstButton;
if(buttons.length < 1) {
firstButton = <button class="input__button" onclick={() => {
html.remove();
resolve();
}}>OK</button>;
buttonsElem.appendChild(firstButton);
} else {
for(const button of buttons) {
const buttonElem = <button class="input__button" onclick={() => {
html.remove();
let firstButton;
if(buttons.length < 1) {
firstButton = <button class="input__button" onclick={() => html.remove()}>OK</button>;
buttonsElem.appendChild(firstButton);
} else {
for(const button of buttons) {
const buttonElem = <button class="input__button" onclick={() => { html.remove(); if(typeof button === 'function') button.callback(); }}>
{button.text}
</button>;
buttonsElem.appendChild(buttonElem);
if(typeof button.callback === 'function')
button.callback().finally(() => resolve());
else
resolve();
}}>{button.text}</button>;
buttonsElem.appendChild(buttonElem);
if(firstButton === undefined)
firstButton = buttonElem;
}
if(firstButton === undefined)
firstButton = buttonElem;
}
}
target.appendChild(html);
firstButton.focus();
});
target.appendChild(html);
firstButton.focus();
return true;
};

View file

@ -1,56 +0,0 @@
// welcome to the shitty temporary file for managing the bbcode/markdown/whatever button
const MszParsing = (() => {
const defineTag = (name, open, close, summary, icon) => {
return {
name: name,
open: open,
close: close,
summary: summary,
icon: icon,
};
};
const bbTags = [
defineTag('bb-bold', '[b]', '[/b]', 'Bold [b]<text>[/b]', 'fas fa-bold fa-fw'),
defineTag('bb-italic', '[i]', '[/i]', 'Italic [i]<text>[/i]', 'fas fa-italic fa-fw'),
defineTag('bb-underline', '[u]', '[/u]', 'Underline [u]<text>[/u]', 'fas fa-underline fa-fw'),
defineTag('bb-strike', '[s]', '[/s]', 'Strikethrough [s]<text>[/s]', 'fas fa-strikethrough fa-fw'),
defineTag('bb-link', '[url=]', '[/url]', 'Link [url]<url>[/url] or [url=<url>]<text>[/url]', 'fas fa-link fa-fw'),
defineTag('bb-image', '[img]', '[/img]', 'Image [img]<url>[/img]', 'fas fa-image fa-fw'),
defineTag('bb-audio', '[audio]', '[/audio]', 'Audio [audio]<url>[/audio]', 'fas fa-music fa-fw'),
defineTag('bb-video', '[video]', '[/video]', 'Video [video]<url>[/video]', 'fas fa-video fa-fw'),
defineTag('bb-code', '[code]', '[/code]', 'Code [code]<code>[/code]', 'fas fa-code fa-fw'),
defineTag('bb-zalgo', '[zalgo]', '[/zalgo]', 'Zalgo [zalgo]<text>[/zalgo]', 'fas fa-frog fa-fw'),
];
const mdTags = [
defineTag('md-bold', '**', '**', 'Bold **<text>**', 'fas fa-bold fa-fw'),
defineTag('md-italic', '*', '*', 'Italic *<text>* or _<text>_', 'fas fa-italic fa-fw'),
defineTag('md-underline', '__', '__', 'Underline __<text>__', 'fas fa-underline fa-fw'),
defineTag('md-strike', '~~', '~~', 'Strikethrough ~~<text>~~', 'fas fa-strikethrough fa-fw'),
defineTag('md-link', '[](', ')', 'Link [<text>](<url>)', 'fas fa-link fa-fw'),
defineTag('md-image', '![](', ')', 'Image ![<alt text>](<url>)', 'fas fa-image fa-fw'),
defineTag('md-audio', '![](', ')', 'Audio ![<alt text>](<url>)', 'fas fa-music fa-fw'),
defineTag('md-video', '![](', ')', 'Video ![<alt text>](<url>)', 'fas fa-video fa-fw'),
defineTag('md-code', '```', '```', 'Code `<code>` or ```<code>```', 'fas fa-code fa-fw'),
];
const getTagsFor = parser => {
if(typeof parser !== 'number')
parser = parseInt(parser);
if(parser === 1)
return bbTags;
if(parser === 2)
return mdTags;
return [];
};
return {
getTagsFor: getTagsFor,
getTagsForPlainText: () => getTagsFor(0),
getTagsForBBcode: () => getTagsFor(1),
getTagsForMarkdown: () => getTagsFor(2),
};
})();

View file

@ -28,7 +28,7 @@ const MszWatcher = function() {
};
};
const MszWatchers = function() {
const MszWatcherCollection = function() {
const watchers = new Map;
const getWatcher = name => {

171
build.js
View file

@ -1,30 +1,157 @@
const assproc = require('@railcomm/assproc');
const { join: pathJoin } = require('path');
// IMPORTS
const fs = require('fs');
const swc = require('@swc/core');
const path = require('path');
const util = require('util');
const postcss = require('postcss');
const utils = require('./assets/utils.js');
const assproc = require('./assets/assproc.js');
(async () => {
const isDebug = fs.existsSync(pathJoin(__dirname, '.debug'));
const env = {
root: __dirname,
source: pathJoin(__dirname, 'assets'),
public: pathJoin(__dirname, 'public'),
debug: isDebug,
swc: {
es: 'es2021',
// CONFIG
const rootDir = __dirname;
const srcDir = path.join(rootDir, 'assets');
const srcCurrentInfo = path.join(srcDir, 'current.json');
const pubDir = path.join(rootDir, 'public');
const pubAssetsDir = path.join(pubDir, 'assets');
const isDebugBuild = fs.existsSync(path.join(rootDir, '.debug'));
const buildTasks = {
js: [
{ source: 'misuzu.js', target: '/assets', name: 'misuzu.{hash}.js', },
],
css: [
{ source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', },
],
};
// PREP
const postcssPlugins = [ require('autoprefixer')({ remove: false }) ];
if(!isDebugBuild)
postcssPlugins.push(require('cssnano')({
preset: [
'cssnano-preset-default',
{
minifyGradients: false,
reduceIdents: false,
zindex: true,
}
],
}));
const swcJscOptions = {
target: 'es2021',
loose: false,
externalHelpers: false,
keepClassNames: true,
preserveAllComments: false,
transform: {},
parser: {
syntax: 'ecmascript',
jsx: true,
dynamicImport: false,
privateMethod: false,
functionBind: false,
exportDefaultFrom: false,
exportNamespaceFrom: false,
decorators: false,
decoratorsBeforeExport: false,
topLevelAwait: true,
importMeta: false,
},
transform: {
react: {
runtime: 'classic',
pragma: '$er',
},
};
},
};
const tasks = {
js: [
{ source: 'misuzu.js', target: '/assets', name: 'misuzu.{hash}.js', },
],
css: [
{ source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', },
],
};
const files = await assproc.process(env, tasks);
// BUILD
(async () => {
const files = {};
fs.writeFileSync(pathJoin(__dirname, 'assets/current.json'), JSON.stringify(files));
console.log('Ensuring assets directory exists...');
fs.mkdirSync(pubAssetsDir, { recursive: true });
console.log();
console.log('JS assets');
for(const info of buildTasks.js) {
console.log(`=> Building ${info.source}...`);
let origTarget = undefined;
if('es' in info) {
origTarget = swcJscOptions.target;
swcJscOptions.target = info.es;
}
const assprocOpts = {
prefix: '#',
entry: info.entry || 'main.js',
};
const swcOpts = {
filename: info.source,
sourceMaps: false,
isModule: false,
minify: !isDebugBuild,
jsc: swcJscOptions,
};
const pubName = await assproc.process(path.join(srcDir, info.source), assprocOpts)
.then(output => swc.transform(output, swcOpts))
.then(output => {
const name = utils.strtr(info.name, { hash: utils.shortHash(output.code) });
const pubName = path.join(info.target || '', name);
console.log(` Saving to ${pubName}...`);
fs.writeFileSync(path.join(pubDir, pubName), output.code);
return pubName;
});
if(origTarget !== undefined)
swcJscOptions.target = origTarget;
files[info.source] = pubName;
}
console.log();
console.log('CSS assets');
for(const info of buildTasks.css) {
console.log(`=> Building ${info.source}...`);
const sourcePath = path.join(srcDir, info.source);
const assprocOpts = {
prefix: '@',
entry: info.entry || 'main.css',
};
const postcssOpts = { from: sourcePath };
files[info.source] = await assproc.process(sourcePath, assprocOpts)
.then(output => postcss(postcssPlugins).process(output, postcssOpts)
.then(output => {
const name = utils.strtr(info.name, { hash: utils.shortHash(output.css) });
const pubName = path.join(info.target || '', name);
console.log(` Saving to ${pubName}...`);
fs.writeFileSync(path.join(pubDir, pubName), output.css);
return pubName;
}));
}
console.log();
console.log('Writing assets info...');
fs.writeFileSync(srcCurrentInfo, JSON.stringify(files));
console.log();
console.log('Cleaning up old builds...');
assproc.housekeep(pubAssetsDir);
})();

View file

@ -1,13 +1,15 @@
{
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"flashwave/index": "^0.2410",
"flashii/rpcii": "^2.0",
"flashwave/index": "dev-master",
"flashwave/sasae": "dev-master",
"erusev/parsedown": "~1.6",
"chillerlan/php-qrcode": "^4.3",
"symfony/mailer": "^6.0",
"matomo/device-detector": "^6.1",
"sentry/sdk": "^4.0",
"nesbot/carbon": "^3.7"
"flashwave/syokuhou": "dev-master"
},
"autoload": {
"classmap": [
@ -32,6 +34,6 @@
}
},
"require-dev": {
"phpstan/phpstan": "^1.11"
"phpstan/phpstan": "^1.10"
}
}

1075
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,11 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
// Switching to the Index migration system!!!!!!
final class InitialStructureNdx_20230107_023235 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class InitialStructureNdx_20230107_023235 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$hasMszTrack = false;
// check if the old migrations table exists

View file

@ -1,9 +1,9 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class CreateTopicRedirsTable_20230430_001226 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class CreateTopicRedirsTable_20230430_001226 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_forum_topics_redirects (
topic_id INT(10) UNSIGNED NOT NULL,

View file

@ -1,10 +1,10 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
use Misuzu\ClientInfo;
final class UpdateUserAgentStorage_20230721_121854 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
// convert user agent fields to BLOB and add field for client info storage
$conn->execute('
ALTER TABLE msz_login_attempts

View file

@ -1,9 +1,9 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class AddModeratorNotesTable_20230724_201010 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class AddModeratorNotesTable_20230724_201010 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_modnotes (
note_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,

View file

@ -1,9 +1,9 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class AddNewBansTable_20230726_175936 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class AddNewBansTable_20230726_175936 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_bans (
ban_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,

View file

@ -1,9 +1,9 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class RedoWarningsTable_20230726_210150 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class RedoWarningsTable_20230726_210150 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_warnings (
warn_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,

View file

@ -1,9 +1,9 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class PluraliseUsersForRoleRelations_20230727_130516 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class PluraliseUsersForRoleRelations_20230727_130516 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('RENAME TABLE msz_user_roles TO msz_users_roles');
}
}

View file

@ -1,9 +1,9 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class CreateCountersTable_20230728_212101 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class CreateCountersTable_20230728_212101 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_counters (
counter_name VARBINARY(64) NOT NULL,

View file

@ -1,9 +1,9 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class UpdateCollationsInVariousTables_20230803_114403 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class UpdateCollationsInVariousTables_20230803_114403 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
ALTER TABLE msz_audit_log
CHANGE COLUMN log_action log_action VARCHAR(50) NOT NULL COLLATE "ascii_general_ci" AFTER user_id,

View file

@ -1,9 +1,9 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class NewPermissionsSystem_20230830_213930 implements DbMigration {
public function migrate(DbConnection $conn): void {
final class NewPermissionsSystem_20230830_213930 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
// make sure cron doesn't fuck us over
$conn->execute('DELETE FROM msz_config WHERE config_name = "perms.needsRecalc"');

View file

@ -1,48 +0,0 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class CreateMessagesTable_20240130_233734 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_messages (
msg_id BINARY(8) NOT NULL,
msg_owner_id INT(10) UNSIGNED NOT NULL,
msg_author_id INT(10) UNSIGNED NULL DEFAULT NULL,
msg_recipient_id INT(10) UNSIGNED NULL DEFAULT NULL,
msg_reply_to BINARY(8) NULL DEFAULT NULL,
msg_title TINYTEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci",
msg_body TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci",
msg_parser TINYINT(3) UNSIGNED NOT NULL,
msg_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
msg_sent TIMESTAMP NULL DEFAULT NULL,
msg_read TIMESTAMP NULL DEFAULT NULL,
msg_deleted TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (msg_id, msg_owner_id),
KEY messages_owner_foreign (msg_owner_id),
KEY messages_author_foreign (msg_author_id),
KEY messages_recipient_foreign (msg_recipient_id),
KEY messages_reply_to_index (msg_reply_to),
KEY messages_created_index (msg_created),
KEY messages_sent_index (msg_sent),
KEY messages_read_index (msg_read),
KEY messages_deleted_index (msg_deleted),
CONSTRAINT messages_owner_foreign
FOREIGN KEY (msg_owner_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT messages_author_foreign
FOREIGN KEY (msg_author_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE SET NULL,
CONSTRAINT messages_recipient_foreign
FOREIGN KEY (msg_recipient_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE SET NULL
) ENGINE=InnoDB COLLATE=utf8mb4_bin;
');
}
}

View file

@ -1,14 +0,0 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class BaseSixtyFourEncodePmsInDb_20240602_194809 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute('UPDATE msz_messages SET msg_title = TO_BASE64(msg_title), msg_body = TO_BASE64(msg_body)');
$conn->execute('
ALTER TABLE `msz_messages`
CHANGE COLUMN `msg_title` `msg_title` TINYBLOB NOT NULL AFTER `msg_reply_to`,
CHANGE COLUMN `msg_body` `msg_body` BLOB NOT NULL AFTER `msg_title`;
');
}
}

View file

@ -1,13 +0,0 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class AddRoleIdString_20240916_205613 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute(<<<SQL
ALTER TABLE msz_roles
ADD COLUMN role_string VARCHAR(20) NULL DEFAULT NULL COLLATE 'ascii_general_ci' AFTER role_id,
ADD UNIQUE INDEX roles_string_unique (role_string);
SQL);
}
}

View file

@ -13,7 +13,6 @@ Below are a number of links to source code repositories related to Flashii.net a
- [Seria](https://patchii.net/flashii/seria): Software used by the downloads tracker.
- [Mince](https://patchii.net/flashii/mince): Source code for the Minecraft servers subwebsite.
- [Awaki](https://patchii.net/flashii/awaki): Redirect service hosted on fii.moe.
- [Aleister](https://patchii.net/flashii/aleister): Public API gateway.
## Tools & Software
- [SoFii](https://patchii.net/flashii/sofii): Launcher for Soldier of Fortune 2
@ -22,7 +21,8 @@ Below are a number of links to source code repositories related to Flashii.net a
## First-Party Libraries
- [Index](https://patchii.net/flash/index): Base library used in almost any component of the website that uses PHP.
- [RPCii](https://patchii.net/flashii/rpcii): Internal RPC extension, mainly used to supply data to the API gateway.
- [Sasae](https://patchii.net/flash/sasae): Extension to the Twig templating library.
- [Syokuhou](https://patchii.net/flash/syokuhou): Configuration library.
## Historical
- [AJAX Chat (fork)](https://patchii.net/flashii/ajax-chat): Old chat software (2013-2015). Still kept on life support for the nostalgia.

View file

@ -1,9 +1,10 @@
<?php
namespace Misuzu;
use Index\Db\DbBackends;
use Index\Config\Db\DbConfig;
use Index\Config\Fs\FsConfig;
use Index\Environment;
use Index\Data\DbTools;
use Syokuhou\DbConfig;
use Syokuhou\SharpConfig;
define('MSZ_STARTUP', microtime(true));
define('MSZ_ROOT', __DIR__);
@ -18,11 +19,11 @@ define('MSZ_ASSETS', MSZ_ROOT . '/assets');
require_once MSZ_ROOT . '/vendor/autoload.php';
error_reporting(MSZ_DEBUG ? -1 : 0);
Environment::setDebug(MSZ_DEBUG);
mb_internal_encoding('UTF-8');
date_default_timezone_set('GMT');
date_default_timezone_set('UTC');
$cfg = FsConfig::fromFile(MSZ_CONFIG . '/config.cfg');
$cfg = SharpConfig::fromFile(MSZ_CONFIG . '/config.cfg');
if($cfg->hasValues('sentry:dsn'))
(function($cfg) {
@ -37,7 +38,7 @@ if($cfg->hasValues('sentry:dsn'))
});
})($cfg->scopeTo('sentry'));
$db = DbBackends::create($cfg->getString('database:dsn', 'null:'));
$db = DbTools::create($cfg->getString('database:dsn', 'null:'));
$db->execute('SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\';');
$cfg = new DbConfig($db, 'msz_config');

938
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,8 @@
{
"dependencies": {
"@railcomm/assproc": "^1.0.0"
"@swc/core": "^1.3.69",
"autoprefixer": "^10.4.14",
"cssnano": "^6.0.1",
"postcss": "^8.4.26"
}
}

View file

@ -4,6 +4,3 @@ parameters:
- src
bootstrapFiles:
- misuzu.php
dynamicConstantNames:
- MSZ_CLI
- MSZ_DEBUG

View file

@ -4,7 +4,7 @@ namespace Misuzu;
use Exception;
use Misuzu\Auth\AuthTokenCookie;
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
Tools::redirect($urls->format('index'));

View file

@ -24,4 +24,4 @@ if($authInfo->isLoggedIn()) {
AuthTokenCookie::apply($tokenPacker->pack($tokenInfo));
}
Tools::redirect($msz->getUrls()->format('index'));;
Tools::redirect($msz->getURLs()->format('index'));;

View file

@ -4,7 +4,7 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
Tools::redirect($urls->format('settings-account'));

View file

@ -4,7 +4,7 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
Tools::redirect($urls->format('index'));

View file

@ -3,7 +3,7 @@ namespace Misuzu;
use Misuzu\Auth\AuthTokenCookie;
$urls = $msz->getUrls();
$urls = $msz->getURLs();
if(CSRF::validateRequest()) {
$tokenInfo = $msz->getAuthInfo()->getTokenInfo();

View file

@ -5,7 +5,7 @@ use RuntimeException;
use Misuzu\TOTPGenerator;
use Misuzu\Auth\AuthTokenCookie;
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
Tools::redirect($urls->format('index'));

View file

@ -4,7 +4,7 @@ namespace Misuzu;
use RuntimeException;
$usersCtx = $msz->getUsersContext();
$redirect = filter_input(INPUT_GET, 'return') ?? $_SERVER['HTTP_REFERER'] ?? $msz->getUrls()->format('index');
$redirect = filter_input(INPUT_GET, 'return') ?? $_SERVER['HTTP_REFERER'] ?? $msz->getURLs()->format('index');
if(!Tools::isLocalURL($redirect))
Template::displayInfo('Possible request forgery detected.', 403);

View file

@ -168,7 +168,7 @@ $perms = $perms->checkMany([
]);
Template::render('forum.forum', [
'forum_breadcrumbs' => iterator_to_array($forumCategories->getCategoryAncestry($categoryInfo)),
'forum_breadcrumbs' => $forumCategories->getCategoryAncestry($categoryInfo),
'global_accent_colour' => $forumCategories->getCategoryColour($categoryInfo),
'forum_info' => $categoryInfo,
'forum_children' => $children,

View file

@ -32,14 +32,14 @@ if($mode === 'mark') {
$forumCategories->updateUserReadCategory($userInfo, $categoryInfo);
}
Tools::redirect($msz->getUrls()->format($categoryId ? 'forum-category' : 'forum-index', ['forum' => $categoryId]));
Tools::redirect($msz->getURLs()->format($categoryId ? 'forum-category' : 'forum-index', ['forum' => $categoryId]));
return;
}
Template::render('confirm', [
'title' => 'Mark forum as read',
'message' => 'Are you sure you want to mark ' . ($categoryId < 1 ? 'the entire' : 'this') . ' forum as read?',
'return' => $msz->getUrls()->format($categoryId ? 'forum-category' : 'forum-index', ['forum' => $categoryId]),
'return' => $msz->getURLs()->format($categoryId ? 'forum-category' : 'forum-index', ['forum' => $categoryId]),
'params' => [
'forum' => $categoryId,
]

View file

@ -94,7 +94,7 @@ MD;
$markdown .= sprintf("| %s | [%s](%s%s) | %s |\r\n", $ranking->position,
$ranking->user?->getName() ?? 'Deleted User',
$msz->getSiteInfo()->getURL(),
$msz->getUrls()->format('user-profile', ['user' => $ranking->userId]),
$msz->getURLs()->format('user-profile', ['user' => $ranking->userId]),
number_format($ranking->postsCount));
}

View file

@ -3,7 +3,7 @@ namespace Misuzu;
use RuntimeException;
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$forumCtx = $msz->getForumContext();
$forumPosts = $forumCtx->getPosts();
$usersCtx = $msz->getUsersContext();

View file

@ -3,10 +3,9 @@ namespace Misuzu;
use stdClass;
use RuntimeException;
use Index\DateTime;
use Misuzu\Forum\ForumTopicInfo;
use Misuzu\Parsers\Parser;
use Index\XDateTime;
use Carbon\CarbonImmutable;
$authInfo = $msz->getAuthInfo();
if(!$authInfo->isLoggedIn())
@ -153,11 +152,11 @@ if(!empty($_POST)) {
if($mode === 'create') {
$postTimeout = $cfg->getInteger('forum.posting.timeout', 5);
if($postTimeout > 0) {
$postTimeoutThreshold = new CarbonImmutable(sprintf('-%d seconds', $postTimeout));
$postTimeoutThreshold = DateTime::now()->modify(sprintf('-%d seconds', $postTimeout));
$lastPostCreatedAt = $forumPosts->getUserLastPostCreatedAt($currentUser);
if(XDateTime::compare($lastPostCreatedAt, $postTimeoutThreshold) > 0) {
$waitSeconds = $postTimeout + ((int)$lastPostCreatedAt->format('U') - time());
if($lastPostCreatedAt->isMoreThan($postTimeoutThreshold)) {
$waitSeconds = $postTimeout + ($lastPostCreatedAt->getUnixTimeSeconds() - time());
$notices[] = sprintf("You're posting too quickly! Please wait %s seconds before posting again.", number_format($waitSeconds));
$notices[] = "It's possible that your post went through successfully and you pressed the submit button twice by accident.";
@ -258,7 +257,7 @@ if(!empty($_POST)) {
if(empty($notices)) {
// does this ternary ever return forum-topic?
$redirect = $msz->getUrls()->format(empty($topicInfo) ? 'forum-topic' : 'forum-post', [
$redirect = $msz->getURLs()->format(empty($topicInfo) ? 'forum-topic' : 'forum-post', [
'topic' => $topicId ?? 0,
'post' => $postId ?? 0,
]);
@ -297,7 +296,7 @@ try {
}
Template::render('forum.posting', [
'posting_breadcrumbs' => iterator_to_array($forumCategories->getCategoryAncestry($categoryInfo)),
'posting_breadcrumbs' => $forumCategories->getCategoryAncestry($categoryInfo),
'global_accent_colour' => $forumCategories->getCategoryColour($categoryInfo),
'posting_user' => $currentUser,
'posting_user_colour' => $usersCtx->getUserColour($currentUser),

View file

@ -4,7 +4,7 @@ namespace Misuzu;
use stdClass;
use RuntimeException;
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$forumCtx = $msz->getForumContext();
$forumCategories = $forumCtx->getCategories();
$forumTopics = $forumCtx->getTopics();
@ -291,11 +291,7 @@ $postInfos = $forumPosts->getPosts(
if(empty($postInfos))
Template::throwError(404);
try {
$originalPostInfo = $forumPosts->getPost(topicInfo: $topicInfo);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
$originalPostInfo = $forumPosts->getPost(topicInfo: $topicInfo);
$posts = [];
@ -330,7 +326,7 @@ $perms = $perms->checkMany([
]);
Template::render('forum.topic', [
'topic_breadcrumbs' => iterator_to_array($forumCategories->getCategoryAncestry($topicInfo)),
'topic_breadcrumbs' => $forumCategories->getCategoryAncestry($topicInfo),
'global_accent_colour' => $forumCategories->getCategoryColour($topicInfo),
'topic_info' => $topicInfo,
'category_info' => $categoryInfo,

View file

@ -3,9 +3,9 @@ namespace Misuzu;
use DateTimeInterface;
use RuntimeException;
use Index\DateTime;
use Index\XArray;
use Misuzu\Changelog\Changelog;
use Carbon\CarbonImmutable;
use Index\{XArray,XDateTime};
$authInfo = $msz->getAuthInfo();
if(!$authInfo->getPerms('global')->check(Perm::G_CL_CHANGES_MANAGE))
@ -15,7 +15,7 @@ $changeActions = [];
foreach(Changelog::ACTIONS as $action)
$changeActions[$action] = Changelog::actionText($action);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$changelog = $msz->getChangelog();
$changeId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
$changeInfo = null;
@ -58,8 +58,8 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
if(empty($createdAt))
$createdAt = null;
else {
$createdAt = CarbonImmutable::createFromFormat(DateTimeInterface::ATOM, $createdAt . ':00Z');
if((int)$createdAt->format('U') < 0)
$createdAt = DateTime::createFromFormat(DateTimeInterface::ATOM, $createdAt . ':00Z');
if($createdAt->getUnixTimeSeconds() < 0)
$createdAt = null;
}
@ -72,7 +72,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$summary = null;
if($body === $changeInfo->getBody())
$body = null;
if($createdAt !== null && XDateTime::compare($createdAt, $changeInfo->getCreatedAt()) === 0)
if($createdAt !== null && $createdAt->equals($changeInfo->getCreatedAt()))
$createdAt = null;
$updateUserInfo = $userId !== $changeInfo->getUserId();

View file

@ -6,7 +6,7 @@ use RuntimeException;
if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CL_TAGS_MANAGE))
Template::throwError(403);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$changelog = $msz->getChangelog();
$tagId = (string)filter_input(INPUT_GET, 't', FILTER_SANITIZE_NUMBER_INT);
$loadTagInfo = fn() => $changelog->getTag($tagId);

View file

@ -5,7 +5,7 @@ $authInfo = $msz->getAuthInfo();
if(!$authInfo->getPerms('global')->check(Perm::G_FORUM_TOPIC_REDIRS_MANAGE))
Template::throwError(403);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$forumCtx = $msz->getForumContext();
$forumTopicRedirects = $forumCtx->getTopicRedirects();

View file

@ -18,7 +18,7 @@ else
try {
$isNew = false;
$emoteInfo = $emotes->getEmote($emoteId);
$emoteStrings = iterator_to_array($emotes->getEmoteStrings($emoteInfo));
$emoteStrings = $emotes->getEmoteStrings($emoteInfo);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
@ -97,7 +97,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
[$emoteInfo->getId()]
);
Tools::redirect($msz->getUrls()->format('manage-general-emoticon', ['emote' => $emoteInfo->getId()]));
Tools::redirect($msz->getURLs()->format('manage-general-emoticon', ['emote' => $emoteInfo->getId()]));
return;
}

View file

@ -37,7 +37,7 @@ if(CSRF::validateRequest() && !empty($_GET['emote'])) {
}
}
Tools::redirect($msz->getUrls()->format('manage-general-emoticons'));
Tools::redirect($msz->getURLs()->format('manage-general-emoticons'));
return;
}

View file

@ -13,7 +13,7 @@ $pagination = new Pagination($auditLog->countLogs(), 50);
if(!$pagination->hasValidOffset())
Template::throwError(404);
$logs = iterator_to_array($auditLog->getLogs(pagination: $pagination));
$logs = $auditLog->getLogs(pagination: $pagination);
$userInfos = [];
$userColours = [];

View file

@ -13,7 +13,7 @@ if($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$valueName = $valueInfo->getName();
$msz->createAuditLog('CONFIG_DELETE', [$valueName]);
$cfg->removeValues($valueName);
Tools::redirect($msz->getUrls()->format('manage-general-settings'));
Tools::redirect($msz->getURLs()->format('manage-general-settings'));
return;
}

View file

@ -1,7 +1,7 @@
<?php
namespace Misuzu;
use Index\Config\Db\DbConfig;
use Syokuhou\DbConfig;
if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CONFIG_MANAGE))
Template::throwError(403);
@ -73,7 +73,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$msz->createAuditLog($isNew ? 'CONFIG_CREATE' : 'CONFIG_UPDATE', [$sName]);
$applyFunc($sName, $sValue);
Tools::redirect($msz->getUrls()->format('manage-general-settings'));
Tools::redirect($msz->getURLs()->format('manage-general-settings'));
return;
}

View file

@ -6,7 +6,7 @@ use RuntimeException;
if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_NEWS_CATEGORIES_MANAGE))
Template::throwError(403);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$news = $msz->getNews();
$categoryId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
$loadCategoryInfo = fn() => $news->getCategory(categoryId: $categoryId);

View file

@ -7,7 +7,7 @@ $authInfo = $msz->getAuthInfo();
if(!$authInfo->getPerms('global')->check(Perm::G_NEWS_POSTS_MANAGE))
Template::throwError(403);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$news = $msz->getNews();
$postId = (string)filter_input(INPUT_GET, 'p', FILTER_SANITIZE_NUMBER_INT);
$loadPostInfo = fn() => $news->getPost($postId);

View file

@ -3,13 +3,13 @@ namespace Misuzu;
use DateTimeInterface;
use RuntimeException;
use Carbon\CarbonImmutable;
use Index\DateTime;
$authInfo = $msz->getAuthInfo();
if(!$authInfo->getPerms('user')->check(Perm::U_BANS_MANAGE))
Template::throwError(403);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$usersCtx = $msz->getUsersContext();
$bans = $usersCtx->getBans();
@ -56,7 +56,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
if($expires === -1) {
$expires = null;
} elseif($expires === -2) {
$expires = CarbonImmutable::createFromFormat(DateTimeInterface::ATOM, $expiresCustom . ':00Z');
$expires = DateTime::createFromFormat(DateTimeInterface::ATOM, $expiresCustom . ':00Z');
} else {
echo 'Invalid duration specified.';
break;

View file

@ -13,7 +13,7 @@ $hasUserId = filter_has_var(INPUT_GET, 'u');
if((!$hasNoteId && !$hasUserId) || ($hasNoteId && $hasUserId))
Template::throwError(400);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$usersCtx = $msz->getUsersContext();
$modNotes = $usersCtx->getModNotes();

View file

@ -3,7 +3,7 @@ namespace Misuzu;
use RuntimeException;
use Index\Colour\Colour;
use Index\Colour\ColourRgb;
use Index\Colour\ColourRGB;
use Misuzu\Perm;
$authInfo = $msz->getAuthInfo();
@ -42,7 +42,6 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
break;
}
$roleString = (string)filter_input(INPUT_POST, 'ur_string');
$roleName = (string)filter_input(INPUT_POST, 'ur_name');
$roleHide = !empty($_POST['ur_hidden']);
$roleLeavable = !empty($_POST['ur_leavable']);
@ -55,7 +54,6 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$colourBlue = (int)filter_input(INPUT_POST, 'ur_col_blue', FILTER_SANITIZE_NUMBER_INT);
Template::set([
'role_ur_string' => $roleString,
'role_ur_name' => $roleName,
'role_ur_hidden' => $roleHide,
'role_ur_leavable' => $roleLeavable,
@ -86,7 +84,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$roleColour = $colourInherit
? Colour::none()
: new ColourRgb($colourRed, $colourGreen, $colourBlue);
: new ColourRGB($colourRed, $colourGreen, $colourBlue);
if(mb_strlen($roleDesc) > 1000) {
echo 'Description may not be longer than 1000 characters.';
@ -98,31 +96,11 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
break;
}
if(strlen($roleString) > 20) {
echo 'Role string may not be longer than 20 characters.';
break;
}
if(strlen($roleString) > 1 && !ctype_alpha($roleString[0])) {
echo 'Role string most start with an alphabetical character.';
break;
}
if($isNew) {
$roleInfo = $roles->createRole(
$roleName,
$roleRank,
$roleColour,
string: $roleString,
title: $roleTitle,
description: $roleDesc,
hidden: $roleHide,
leavable: $roleLeavable
);
$roleInfo = $roles->createRole($roleName, $roleRank, $roleColour, $roleTitle, $roleDesc, $roleHide, $roleLeavable);
} else {
if($roleName === $roleInfo->getName())
$roleName = null;
if($roleString === $roleInfo->getString())
$roleString = null;
if($roleHide === $roleInfo->isHidden())
$roleHide = null;
if($roleLeavable === $roleInfo->isLeavable())
@ -137,17 +115,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
if((string)$roleColour === (string)$roleInfo->getColour())
$roleColour = null;
$roles->updateRole(
$roleInfo,
string: $roleString,
name: $roleName,
rank: $roleRank,
colour: $roleColour,
title: $roleTitle,
description: $roleDesc,
hidden: $roleHide,
leavable: $roleLeavable
);
$roles->updateRole($roleInfo, $roleName, $roleRank, $roleColour, $roleTitle, $roleDesc, $roleHide, $roleLeavable);
}
$msz->createAuditLog(
@ -168,7 +136,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$msz->getConfig()->setBoolean('perms.needsRecalc', true);
}
Tools::redirect($msz->getUrls()->format('manage-role', ['role' => $roleInfo->getId()]));
Tools::redirect($msz->getURLs()->format('manage-role', ['role' => $roleInfo->getId()]));
return;
}

View file

@ -12,7 +12,7 @@ $viewerPerms = $authInfo->getPerms('user');
if(!$authInfo->isLoggedIn())
Template::throwError(403);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$usersCtx = $msz->getUsersContext();
$users = $usersCtx->getUsers();
$roles = $usersCtx->getRoles();
@ -106,7 +106,7 @@ if(CSRF::validateRequest() && $canEdit) {
}
$existingRoles = [];
foreach(iterator_to_array($roles->getRoles(userInfo: $userInfo)) as $roleInfo)
foreach($roles->getRoles(userInfo: $userInfo) as $roleInfo)
$existingRoles[$roleInfo->getId()] = $roleInfo;
$removeRoles = [];
@ -226,7 +226,7 @@ if(CSRF::validateRequest() && $canEdit) {
return;
}
$rolesAll = iterator_to_array($roles->getRoles());
$rolesAll = $roles->getRoles();
$userRoleIds = $users->hasRoles($userInfo, $rolesAll);
Template::render('manage.users.user', [

View file

@ -7,7 +7,7 @@ $authInfo = $msz->getAuthInfo();
if(!$authInfo->getPerms('user')->check(Perm::U_WARNINGS_MANAGE))
Template::throwError(403);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$usersCtx = $msz->getUsersContext();
$users = $usersCtx->getUsers();
$warns = $usersCtx->getWarnings();

View file

@ -5,6 +5,7 @@ use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Index\ByteFormat;
use Index\DateTime;
use Misuzu\Parsers\Parser;
use Misuzu\Users\User;
use Misuzu\Users\Assets\UserAvatarAsset;
@ -14,7 +15,7 @@ $userId = !empty($_GET['u']) && is_string($_GET['u']) ? trim($_GET['u']) : 0;
$profileMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
$isEditing = !empty($_GET['edit']) && is_string($_GET['edit']) ? (bool)$_GET['edit'] : !empty($_POST) && is_array($_POST);
$urls = $msz->getUrls();
$urls = $msz->getURLs();
$usersCtx = $msz->getUsersContext();
$users = $usersCtx->getUsers();
$forumCtx = $msz->getForumContext();
@ -70,16 +71,15 @@ $notices = [];
$userRank = $usersCtx->getUserRank($userInfo);
$viewerRank = $usersCtx->getUserRank($viewerInfo);
$viewerPermsGlobal = $authInfo->getPerms('global');
$viewerPermsUser = $authInfo->getPerms('user');
$viewerPerms = $authInfo->getPerms('user');
$activeBanInfo = $usersCtx->tryGetActiveBan($userInfo);
$isBanned = $activeBanInfo !== null;
$profileFields = $msz->getProfileFields();
$viewingOwnProfile = (string)$viewerId === $userInfo->getId();
$canManageWarnings = $viewerPermsUser->check(Perm::U_WARNINGS_MANAGE);
$canManageWarnings = $viewerPerms->check(Perm::U_WARNINGS_MANAGE);
$canEdit = !$viewingAsGuest && ((!$isBanned && $viewingOwnProfile) || $viewerInfo->isSuperUser() || (
$viewerPermsUser->check(Perm::U_USERS_MANAGE) && ($viewingOwnProfile || $viewerRank > $userRank)
$viewerPerms->check(Perm::U_USERS_MANAGE) && ($viewingOwnProfile || $viewerRank > $userRank)
));
$avatarInfo = new UserAvatarAsset($userInfo);
$backgroundInfo = new UserBackgroundAsset($userInfo);
@ -88,7 +88,7 @@ if($isEditing) {
if(!$canEdit)
Template::throwError(403);
$perms = $viewerPermsUser->checkMany([
$perms = $viewerPerms->checkMany([
'edit_profile' => Perm::U_PROFILE_EDIT,
'edit_avatar' => Perm::U_AVATAR_CHANGE,
'edit_background' => PERM::U_PROFILE_BACKGROUND_CHANGE,
@ -112,7 +112,7 @@ if($isEditing) {
if(!$perms->edit_profile) {
$notices[] = 'You\'re not allowed to edit your profile';
} else {
$profileFieldInfos = iterator_to_array($profileFields->getFields());
$profileFieldInfos = $profileFields->getFields();
$profileFieldsSetInfos = [];
$profileFieldsSetValues = [];
$profileFieldsRemove = [];
@ -298,7 +298,7 @@ $profileStats->forum_post_count = $forumCtx->countTotalUserPosts($userInfo);
$profileStats->comments_count = $msz->getComments()->countPosts(userInfo: $userInfo, deleted: false);
if(!$viewingAsGuest) {
Template::set('profile_warnings', iterator_to_array($usersCtx->getWarnings()->getWarningsWithDefaultBacklog($userInfo)));
Template::set('profile_warnings', $usersCtx->getWarnings()->getWarningsWithDefaultBacklog($userInfo));
if((!$isBanned || $canEdit)) {
$unranked = $cfg->getValues([
@ -322,9 +322,9 @@ if(!$viewingAsGuest) {
);
$activeTopicInfo = $activeTopicStats->success ? $forumTopics->getTopic(topicId: $activeTopicStats->topicId) : null;
$profileFieldValues = iterator_to_array($profileFields->getFieldValues($userInfo));
$profileFieldInfos = $profileFieldInfos ?? iterator_to_array($profileFields->getFields(fieldValueInfos: $isEditing ? null : $profileFieldValues));
$profileFieldFormats = iterator_to_array($profileFields->getFieldFormats(fieldValueInfos: $profileFieldValues));
$profileFieldValues = $profileFields->getFieldValues($userInfo);
$profileFieldInfos = $profileFieldInfos ?? $profileFields->getFields(fieldValueInfos: $isEditing ? null : $profileFieldValues);
$profileFieldFormats = $profileFields->getFieldFormats(fieldValueInfos: $profileFieldValues);
$profileFieldRawValues = [];
$profileFieldLinkValues = [];
@ -384,5 +384,4 @@ Template::render('profile.index', [
'profile_ban_info' => $activeBanInfo,
'profile_avatar_info' => $avatarInfo,
'profile_background_info' => $backgroundInfo,
'profile_can_send_messages' => $viewerPermsGlobal->check(Perm::G_MESSAGES_SEND),
]);

View file

@ -116,7 +116,7 @@ if($isVerifiedRequest && !empty($_POST['current_password'])) {
if($_SERVER['REQUEST_METHOD'] === 'POST' && $isVerifiedRequest)
$userInfo = $users->getUser($userInfo->getId(), 'id');
$userRoles = iterator_to_array($roles->getRoles(userInfo: $userInfo));
$userRoles = $roles->getRoles(userInfo: $userInfo);
Template::render('settings.account', [
'errors' => $errors,

View file

@ -3,6 +3,7 @@ namespace Misuzu;
use ZipArchive;
use Index\XString;
use Index\IO\FileStream;
use Misuzu\Users\UserInfo;
$authInfo = $msz->getAuthInfo();
@ -41,7 +42,7 @@ function db_to_zip(ZipArchive $archive, UserInfo $userInfo, string $baseName, ar
}
$tmpName = sys_get_temp_dir() . DIRECTORY_SEPARATOR . sprintf('msz-user-data-%s-%s-%s.tmp', $userId, $baseName, XString::random(8));
$tmpHandle = fopen($tmpName, 'wb');
$tmpStream = FileStream::newWrite($tmpName);
try {
$stmt = $dbConn->prepare(sprintf('SELECT %s FROM msz_%s WHERE %s = ?', implode(', ', $fields), $baseName, $userIdField));
@ -84,12 +85,12 @@ function db_to_zip(ZipArchive $archive, UserInfo $userInfo, string $baseName, ar
$row[$fieldInfo['name']] = $fieldValue;
}
fwrite($tmpHandle, json_encode($row, JSON_INVALID_UTF8_SUBSTITUTE));
fwrite($tmpHandle, "\n");
$tmpStream->write(json_encode($row, JSON_INVALID_UTF8_SUBSTITUTE));
$tmpStream->write("\n");
}
} finally {
fflush($tmpHandle);
fclose($tmpHandle);
$tmpStream->flush();
$tmpStream->close();
}
$archive->addFile($tmpName, $baseName . '.jsonl');
@ -128,7 +129,6 @@ if(isset($_POST['action']) && is_string($_POST['action'])) {
$tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics_redirects', ['topic_id:s', 'user_id:s:n', 'topic_redir_url:s', 'topic_redir_created:t']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'forum_topics_track', ['user_id:s', 'topic_id:s', 'forum_id:s', 'track_last_read:t']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'login_attempts', ['user_id:s:n', 'attempt_success:b', 'attempt_ip:a', 'attempt_country:s', 'attempt_created:t', 'attempt_user_agent:s']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'messages', ['msg_id:s', 'msg_owner_id:s', 'msg_author_id:s:n', 'msg_recipient_id:s:n', 'msg_reply_to:s:n', 'msg_title:s', 'msg_body:s', 'msg_parser:i', 'msg_created:t', 'msg_sent:t:n', 'msg_read:t:n', 'msg_deleted:t:n'], 'msg_owner_id');
$tmpFiles[] = db_to_zip($archive, $userInfo, 'news_posts', ['post_id:s', 'category_id:s', 'user_id:s:n', 'comment_section_id:s:n', 'post_is_featured:b', 'post_title:s', 'post_text:s', 'post_scheduled:t', 'post_created:t', 'post_updated:t', 'post_deleted:t:n']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'perms', ['user_id:s:n', 'role_id:s:n', 'forum_id:s:n', 'perms_category:s', 'perms_allow:i', 'perms_deny:i']);
$tmpFiles[] = db_to_zip($archive, $userInfo, 'perms_calculated', ['user_id:s:n', 'forum_id:s:n', 'perms_category:s', 'perms_calculated:i']);

View file

@ -15,8 +15,8 @@ $auditLog = $msz->getAuditLog();
$loginHistoryPagination = new Pagination($loginAttempts->countAttempts(userInfo: $currentUser), 5, 'hp');
$accountLogPagination = new Pagination($auditLog->countLogs(userInfo: $currentUser), 10, 'ap');
$loginHistory = iterator_to_array($loginAttempts->getAttempts(userInfo: $currentUser, pagination: $loginHistoryPagination));
$auditLogs = iterator_to_array($auditLog->getLogs(userInfo: $currentUser, pagination: $accountLogPagination));
$loginHistory = $loginAttempts->getAttempts(userInfo: $currentUser, pagination: $loginHistoryPagination);
$auditLogs = $auditLog->getLogs(userInfo: $currentUser, pagination: $accountLogPagination);
Template::render('settings.logs', [
'login_history_list' => $loginHistory,

View file

@ -37,7 +37,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
}
if($activeSessionKilled) {
Tools::redirect($msz->getUrls()->format('index'));
Tools::redirect($msz->getURLs()->format('index'));
return;
} else break;
}

View file

@ -2,7 +2,10 @@
namespace Misuzu;
use RuntimeException;
use Misuzu\Auth\{AuthTokenBuilder,AuthTokenCookie,AuthTokenInfo};
use Misuzu\Auth\AuthTokenBuilder;
use Misuzu\Auth\AuthTokenCookie;
use Misuzu\Auth\AuthTokenInfo;
use Sasae\SasaeEnvironment;
require_once __DIR__ . '/../misuzu.php';

View file

@ -2,23 +2,28 @@
namespace Misuzu\AuditLog;
use InvalidArgumentException;
use Index\Db\{DbConnection,DbStatementCache};
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
use Misuzu\Pagination;
use Misuzu\Users\UserInfo;
class AuditLog {
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function countLogs(
UserInfo|string|null $userInfo = null,
?string $remoteAddr = null
IPAddress|string|null $remoteAddr = null
): int {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$hasUserInfo = $userInfo !== null;
$hasRemoteAddr = $remoteAddr !== null;
@ -54,11 +59,13 @@ class AuditLog {
public function getLogs(
UserInfo|string|null $userInfo = null,
?string $remoteAddr = null,
IPAddress|string|null $remoteAddr = null,
?Pagination $pagination = null
): iterable {
): array {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$hasUserInfo = $userInfo !== null;
$hasRemoteAddr = $remoteAddr !== null;
@ -91,19 +98,26 @@ class AuditLog {
}
$stmt->execute();
$result = $stmt->getResult();
$logs = [];
return $stmt->getResult()->getIterator(AuditLogInfo::fromResult(...));
while($result->next())
$logs[] = new AuditLogInfo($result);
return $logs;
}
public function createLog(
UserInfo|string|null $userInfo,
string $action,
array $params = [],
string $remoteAddr = '::1',
IPAddress|string $remoteAddr = '::1',
string $countryCode = 'XX'
): void {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
// action names should have stricter validation,
// i do want to switch to a lowercase colon separated format later but i'll save that for the unified log in Hanyuu

View file

@ -2,28 +2,25 @@
namespace Misuzu\AuditLog;
use ValueError;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
use Index\DateTime;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
class AuditLogInfo {
public function __construct(
private ?string $userId,
private string $action,
private array $params,
private int $created,
private string $address,
private string $country,
) {}
private ?string $userId;
private string $action;
private array $params;
private int $created;
private string $address;
private string $country;
public static function fromResult(DbResult $result): AuditLogInfo {
return new AuditLogInfo(
userId: $result->getStringOrNull(0),
action: $result->getString(1),
params: json_decode($result->getString(2)),
created: $result->getInteger(3),
address: $result->isNull(4) ? '::1' : $result->getString(4), // apparently this being NULL is possible?
country: $result->getString(5),
);
public function __construct(IDbResult $result) {
$this->userId = $result->isNull(0) ? null : (string)$result->getInteger(0);
$this->action = $result->getString(1);
$this->params = json_decode($result->getString(2));
$this->created = $result->getInteger(3);
$this->address = $result->isNull(4) ? '::1' : $result->getString(4); // apparently this being NULL is possible?
$this->country = $result->getString(5);
}
public function hasUserId(): bool {
@ -46,14 +43,18 @@ class AuditLogInfo {
return $this->created;
}
public function getCreatedAt(): CarbonImmutable {
return CarbonImmutable::createFromTimestampUTC($this->created);
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
public function getRemoteAddress(): string {
public function getRemoteAddressRaw(): string {
return $this->address;
}
public function getRemoteAddress(): IPAddress {
return IPAddress::parse($this->address);
}
public function getCountryCode(): string {
return $this->country;
}

View file

@ -1,8 +1,8 @@
<?php
namespace Misuzu\Auth;
use Index\Config\Config;
use Index\Db\DbConnection;
use Index\Data\IDbConnection;
use Syokuhou\IConfig;
class AuthContext {
private Sessions $sessions;
@ -10,9 +10,9 @@ class AuthContext {
private RecoveryTokens $recoveryTokens;
private TwoFactorAuthSessions $tfaSessions;
private Config $config;
private IConfig $config;
public function __construct(DbConnection $dbConn, Config $config) {
public function __construct(IDbConnection $dbConn, IConfig $config) {
$this->config = $config;
$this->sessions = new Sessions($dbConn);
$this->loginAttempts = new LoginAttempts($dbConn);

View file

@ -1,6 +1,7 @@
<?php
namespace Misuzu\Auth;
use Index\XArray;
use Misuzu\Auth\SessionInfo;
use Misuzu\Forum\ForumCategoryInfo;
use Misuzu\Perms\IPermissionResult;

View file

@ -1,65 +0,0 @@
<?php
namespace Misuzu\Auth;
use RuntimeException;
use Misuzu\Users\{UsersContext,UserInfo};
use RPCii\Server\{RpcHandler,RpcHandlerCommon,RpcAction};
use Index\Config\Config;
final class AuthRpcHandler implements RpcHandler {
use RpcHandlerCommon;
public function __construct(
private Config $impersonateConfig,
private UsersContext $usersCtx,
private AuthContext $authCtx
) {}
private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool {
if($impersonator->isSuperUser())
return true;
$whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->getId()));
return in_array($targetId, $whitelist, true);
}
#[RpcAction('misuzu:auth:attemptMisuzuAuth')]
public function procAttemptMisuzuAuth(string $remoteAddr, string $token): array {
$tokenInfo = $this->authCtx->createAuthTokenPacker()->unpack($token);
if(!$tokenInfo->isEmpty())
$token = $tokenInfo->getSessionToken();
$sessions = $this->authCtx->getSessions();
try {
$sessionInfo = $sessions->getSession(sessionToken: $token);
} catch(RuntimeException $ex) {
return ['method' => 'misuzu', 'error' => 'token'];
}
if($sessionInfo->hasExpired()) {
$sessions->deleteSessions(sessionInfos: $sessionInfo);
return ['method' => 'misuzu', 'error' => 'expired'];
}
$sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $remoteAddr);
$users = $this->usersCtx->getUsers();
$userInfo = $users->getUser($sessionInfo->getUserId(), 'id');
if($tokenInfo->hasImpersonatedUserId() && $this->canImpersonateUserId($userInfo, $tokenInfo->getImpersonatedUserId())) {
$userInfoReal = $userInfo;
try {
$userInfo = $users->getUser($tokenInfo->getImpersonatedUserId(), 'id');
} catch(RuntimeException $ex) {
$userInfo = $userInfoReal;
}
}
return [
'method' => 'misuzu',
'type' => 'user',
'user' => $userInfo->getId(),
'expires' => $sessionInfo->getExpiresTime(),
];
}
}

View file

@ -2,7 +2,8 @@
namespace Misuzu\Auth;
use RuntimeException;
use Index\UriBase64;
use Index\IO\MemoryStream;
use Index\Serialisation\UriBase64;
class AuthTokenPacker {
private const EPOCH_V2 = 1682985600;
@ -63,40 +64,32 @@ class AuthTokenPacker {
$unpackTime = unpack('Nts', $timestamp);
if($unpackTime === false)
return AuthTokenInfo::empty();
throw new RuntimeException('$token does not contain a valid timestamp.');
$timestamp = $unpackTime['ts'] + self::EPOCH_V2;
$handle = fopen('php://memory', 'rb+');
if($handle === false)
return AuthTokenInfo::empty();
$stream = MemoryStream::fromString($data);
$stream->seek(0);
try {
fwrite($handle, $data);
fseek($handle, 0);
for(;;) {
$length = $stream->readChar();
if($length === null)
break;
for(;;) {
$length = fgetc($handle);
if($length === false)
break;
$length = ord($length);
if($length < 1)
break;
$name = $stream->read($length);
$value = null;
$length = $stream->readChar();
if($length !== null) {
$length = ord($length);
if($length < 1)
break;
$name = fread($handle, $length);
$value = null;
$length = fgetc($handle);
if($length !== false) {
$length = ord($length);
if($length > 0)
$value = fread($handle, $length);
}
$builder->setProperty($name, $value);
if($length > 0)
$value = $stream->read($length);
}
} finally {
fclose($handle);
$builder->setProperty($name, $value);
}
} else
return AuthTokenInfo::empty();

View file

@ -1,31 +1,28 @@
<?php
namespace Misuzu\Auth;
use Index\DateTime;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
use Misuzu\ClientInfo;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class LoginAttemptInfo {
public function __construct(
private ?string $userId,
private bool $success,
private string $remoteAddr,
private string $countryCode,
private int $created,
private string $userAgent,
private string $clientInfo,
) {}
private ?string $userId;
private bool $success;
private string $remoteAddr;
private string $countryCode;
private int $created;
private string $userAgent;
private string $clientInfo;
public static function fromResult(DbResult $result): LoginAttemptInfo {
return new LoginAttemptInfo(
userId: $result->getStringOrNull(0),
success: $result->getBoolean(1),
remoteAddr: $result->getString(2),
countryCode: $result->getString(3),
created: $result->getInteger(4),
userAgent: $result->getString(5),
clientInfo: $result->getString(6),
);
public function __construct(IDbResult $result) {
$this->userId = $result->isNull(0) ? null : (string)$result->getInteger(0);
$this->success = $result->getInteger(1) !== 0;
$this->remoteAddr = $result->getString(2);
$this->countryCode = $result->getString(3);
$this->created = $result->getInteger(4);
$this->userAgent = $result->getString(5);
$this->clientInfo = $result->getString(6);
}
public function hasUserId(): bool {
@ -40,10 +37,14 @@ class LoginAttemptInfo {
return $this->success;
}
public function getRemoteAddress(): string {
public function getRemoteAddressRaw(): string {
return $this->remoteAddr;
}
public function getRemoteAddress(): IPAddress {
return IPAddress::parse($this->remoteAddr);
}
public function getCountryCode(): string {
return $this->countryCode;
}
@ -52,8 +53,8 @@ class LoginAttemptInfo {
return $this->created;
}
public function getCreatedAt(): CarbonImmutable {
return CarbonImmutable::createFromTimestampUTC($this->created);
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
public function getUserAgentString(): string {

View file

@ -1,7 +1,10 @@
<?php
namespace Misuzu\Auth;
use Index\Db\{DbConnection,DbStatementCache};
use Index\TimeSpan;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Index\Net\IPAddress;
use Misuzu\ClientInfo;
use Misuzu\Pagination;
use Misuzu\Users\UserInfo;
@ -12,18 +15,22 @@ class LoginAttempts {
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function countAttempts(
?bool $success = null,
UserInfo|string|null $userInfo = null,
?string $remoteAddr = null,
int|null $timeRange = null
IPAddress|string|null $remoteAddr = null,
TimeSpan|int|null $timeRange = null
): int {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
if($timeRange instanceof TimeSpan)
$timeRange = (int)$timeRange->totalSeconds();
$hasSuccess = $success !== null;
$hasUserInfo = $userInfo !== null;
@ -62,7 +69,7 @@ class LoginAttempts {
return $count;
}
public function countRemainingAttempts(string $remoteAddr): int {
public function countRemainingAttempts(IPAddress|string $remoteAddr): int {
return self::REMAINING_MAX - $this->countAttempts(
success: false,
timeRange: self::REMAINING_WINDOW,
@ -73,12 +80,16 @@ class LoginAttempts {
public function getAttempts(
?bool $success = null,
UserInfo|string|null $userInfo = null,
?string $remoteAddr = null,
?int $timeRange = null,
IPAddress|string|null $remoteAddr = null,
TimeSpan|int|null $timeRange = null,
?Pagination $pagination = null
): iterable {
): array {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
if($timeRange instanceof TimeSpan)
$timeRange = (int)$timeRange->totalSeconds();
$hasSuccess = $success !== null;
$hasUserInfo = $userInfo !== null;
@ -116,17 +127,25 @@ class LoginAttempts {
}
$stmt->execute();
return $stmt->getResult()->getIterator(LoginAttemptInfo::fromResult(...));
$result = $stmt->getResult();
$attempts = [];
while($result->next())
$attempts[] = new LoginAttemptInfo($result);
return $attempts;
}
public function recordAttempt(
bool $success,
string $remoteAddr,
IPAddress|string $remoteAddr,
string $countryCode,
string $userAgentString,
?ClientInfo $clientInfo = null,
UserInfo|string|null $userInfo = null
): void {
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();

View file

@ -1,8 +1,9 @@
<?php
namespace Misuzu\Auth;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
use Index\DateTime;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
class RecoveryTokenInfo {
public const LIFETIME = 60 * 60;
@ -12,7 +13,7 @@ class RecoveryTokenInfo {
private int $created;
private ?string $code;
public function __construct(DbResult $result) {
public function __construct(IDbResult $result) {
$this->userId = (string)$result->getInteger(0);
$this->remoteAddr = $result->getString(1);
$this->created = $result->getInteger(2);
@ -23,24 +24,28 @@ class RecoveryTokenInfo {
return $this->userId;
}
public function getRemoteAddress(): string {
public function getRemoteAddressRaw(): string {
return $this->remoteAddr;
}
public function getRemoteAddress(): IPAddress {
return IPAddress::parse($this->remoteAddr);
}
public function getCreatedTime(): int {
return $this->created;
}
public function getCreatedAt(): CarbonImmutable {
return CarbonImmutable::createFromTimestampUTC($this->created);
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
public function getExpiresTime(): int {
return $this->created + self::LIFETIME;
}
public function getExpiresAt(): CarbonImmutable {
return CarbonImmutable::createFromTimestampUTC($this->getExpiresTime());
public function getExpiresAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created + self::LIFETIME);
}
public function hasExpired(): bool {

View file

@ -3,8 +3,10 @@ namespace Misuzu\Auth;
use InvalidArgumentException;
use RuntimeException;
use Index\Base32;
use Index\Db\{DbConnection,DbStatementCache};
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Index\Net\IPAddress;
use Index\Serialisation\Base32;
use Misuzu\ClientInfo;
use Misuzu\Pagination;
use Misuzu\Users\UserInfo;
@ -12,7 +14,7 @@ use Misuzu\Users\UserInfo;
class RecoveryTokens {
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
@ -23,12 +25,14 @@ class RecoveryTokens {
public function getToken(
UserInfo|string|null $userInfo = null,
?string $remoteAddr = null,
IPAddress|string|null $remoteAddr = null,
?string $verifyCode = null,
?bool $isUnused = null
): RecoveryTokenInfo {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$hasUserInfo = $userInfo !== null;
$hasRemoteAddr = $remoteAddr !== null;
@ -72,11 +76,12 @@ class RecoveryTokens {
public function createToken(
UserInfo|string $userInfo,
string $remoteAddr
IPAddress|string $remoteAddr
): RecoveryTokenInfo {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$verifyCode = self::generateCode();
$stmt = $this->cache->get('INSERT INTO msz_users_password_resets (user_id, reset_ip, verification_code) VALUES (?, INET6_ATON(?), ?)');

View file

@ -1,41 +1,38 @@
<?php
namespace Misuzu\Auth;
use Index\DateTime;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
use Misuzu\ClientInfo;
use Carbon\CarbonImmutable;
use Index\Db\DbResult;
class SessionInfo {
public function __construct(
private string $id,
private string $userId,
private string $token,
private string $firstRemoteAddr,
private ?string $lastRemoteAddr,
private string $userAgent,
private string $clientInfo,
private string $countryCode,
private int $expires,
private bool $bumpExpires,
private int $created,
private ?int $lastActive,
) {}
private string $id;
private string $userId;
private string $token;
private string $firstRemoteAddr;
private ?string $lastRemoteAddr;
private string $userAgent;
private string $clientInfo;
private string $countryCode;
private int $expires;
private bool $bumpExpires;
private int $created;
private ?int $lastActive;
public static function fromResult(DbResult $result): SessionInfo {
return new SessionInfo(
id: $result->getString(0),
userId: $result->getString(1),
token: $result->getString(2),
firstRemoteAddr: $result->getString(3),
lastRemoteAddr: $result->getStringOrNull(4),
userAgent: $result->getString(5),
clientInfo: $result->getString(6),
countryCode: $result->getString(7),
expires: $result->getInteger(8),
bumpExpires: $result->getBoolean(9),
created: $result->getInteger(10),
lastActive: $result->getIntegerOrNull(11),
);
public function __construct(IDbResult $result) {
$this->id = (string)$result->getInteger(0);
$this->userId = (string)$result->getInteger(1);
$this->token = $result->getString(2);
$this->firstRemoteAddr = $result->getString(3);
$this->lastRemoteAddr = $result->isNull(4) ? null : $result->getString(4);
$this->userAgent = $result->getString(5);
$this->clientInfo = $result->getString(6);
$this->countryCode = $result->getString(7);
$this->expires = $result->getInteger(8);
$this->bumpExpires = $result->getInteger(9) !== 0;
$this->created = $result->getInteger(10);
$this->lastActive = $result->isNull(11) ? null : $result->getInteger(11);
}
public function getId(): string {
@ -50,18 +47,26 @@ class SessionInfo {
return $this->token;
}
public function getFirstRemoteAddress(): string {
public function getFirstRemoteAddressRaw(): string {
return $this->firstRemoteAddr;
}
public function getFirstRemoteAddress(): IPAddress {
return IPAddress::parse($this->firstRemoteAddr);
}
public function hasLastRemoteAddress(): bool {
return $this->lastRemoteAddr !== null;
}
public function getLastRemoteAddress(): ?string {
public function getLastRemoteAddressRaw(): string {
return $this->lastRemoteAddr;
}
public function getLastRemoteAddress(): ?IPAddress {
return $this->lastRemoteAddr === null ? null : IPAddress::parse($this->lastRemoteAddr);
}
public function getUserAgentString(): string {
return $this->userAgent;
}
@ -82,8 +87,8 @@ class SessionInfo {
return $this->expires;
}
public function getExpiresAt(): CarbonImmutable {
return CarbonImmutable::createFromTimestampUTC($this->expires);
public function getExpiresAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->expires);
}
public function shouldBumpExpires(): bool {
@ -98,8 +103,8 @@ class SessionInfo {
return $this->created;
}
public function getCreatedAt(): CarbonImmutable {
return CarbonImmutable::createFromTimestampUTC($this->created);
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
public function hasLastActive(): bool {
@ -110,7 +115,7 @@ class SessionInfo {
return $this->lastActive;
}
public function getLastActiveAt(): ?CarbonImmutable {
return $this->lastActive === null ? null : CarbonImmutable::createFromTimestampUTC($this->lastActive);
public function getLastActiveAt(): ?DateTime {
return $this->lastActive === null ? null : DateTime::fromUnixTimeSeconds($this->lastActive);
}
}

View file

@ -4,16 +4,19 @@ namespace Misuzu\Auth;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Db\{DbConnection,DbStatementCache,DbTools};
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
use Index\Net\IPAddress;
use Misuzu\ClientInfo;
use Misuzu\Pagination;
use Misuzu\Users\UserInfo;
class Sessions {
private DbConnection $dbConn;
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
$this->cache = new DbStatementCache($dbConn);
}
@ -55,7 +58,7 @@ class Sessions {
public function getSessions(
UserInfo|string|null $userInfo = null,
?Pagination $pagination = null
): iterable {
): array {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
@ -82,7 +85,13 @@ class Sessions {
}
$stmt->execute();
return $stmt->getResult()->getIterator(SessionInfo::fromResult(...));
$result = $stmt->getResult();
$sessions = [];
while($result->next())
$sessions[] = new SessionInfo($result);
return $sessions;
}
public function getSession(
@ -115,18 +124,20 @@ class Sessions {
if(!$result->next())
throw new RuntimeException('Session not found.');
return SessionInfo::fromResult($result);
return new SessionInfo($result);
}
public function createSession(
UserInfo|string $userInfo,
string $remoteAddr,
IPAddress|string $remoteAddr,
string $countryCode,
string $userAgentString,
?ClientInfo $clientInfo = null
): SessionInfo {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$sessionToken = self::generateToken();
$clientInfo = json_encode($clientInfo ?? ClientInfo::parse($userAgentString));
@ -238,7 +249,7 @@ class Sessions {
public function recordSessionActivity(
SessionInfo|string|null $sessionInfo = null,
?string $sessionToken = null,
?string $remoteAddr = null
IPAddress|string|null $remoteAddr = null
): void {
if($sessionInfo === null && $sessionToken === null)
throw new InvalidArgumentException('Either $sessionInfo or $sessionToken needs to be set.');
@ -246,6 +257,8 @@ class Sessions {
throw new InvalidArgumentException('Only one of $sessionInfo and $sessionToken may be set at once.');
if($sessionInfo instanceof SessionInfo)
$sessionInfo = $sessionInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$hasSessionInfo = $sessionInfo !== null;
$hasSessionToken = $sessionToken !== null;

View file

@ -2,13 +2,14 @@
namespace Misuzu\Auth;
use Index\XString;
use Index\Db\{DbConnection,DbStatementCache};
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Misuzu\Users\UserInfo;
class TwoFactorAuthSessions {
private DbStatementCache $cache;
public function __construct(DbConnection $dbConn) {
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}

View file

@ -1,23 +1,13 @@
<?php
namespace Misuzu;
use Index\CsrfToken;
use Index\Security\CSRFP;
final class CSRF {
private static CsrfToken $instance;
private static string $secretKey = '';
public static function create(string $identity, ?string $secretKey = null): CsrfToken {
if($secretKey === null)
$secretKey = self::$secretKey;
else
self::$secretKey = $secretKey;
return new CsrfToken($secretKey, $identity);
}
private static CSRFP $instance;
public static function init(string $secretKey, string $identity): void {
self::$instance = self::create($identity, $secretKey);
self::$instance = new CSRFP($secretKey, $identity);
}
public static function validate(string $token, int $tolerance = -1): bool {

Some files were not shown because too many files have changed in this diff Show more