Compare commits

...

190 commits

Author SHA1 Message Date
39be84fcc0 Reordered output cus it was bothering me. 2024-11-21 20:05:56 +00:00
242e70eabf Added option to include e-mail address in user rpc call. 2024-11-21 19:37:04 +00:00
174ceaa4e7 Count loads of old emote endpoint in case I forgot to replace anywhere. 2024-11-14 04:13:57 +00:00
058b409adf Added RPC for emotes list. 2024-11-14 02:44:02 +00:00
8e006c7003 Updated RPC library. 2024-11-13 23:30:34 +00:00
23d47fa6d2 Ensure content passed to the parse_text filter is escaped. 2024-11-07 00:33:42 +00:00
bdad34e065 Updated libraries. 2024-10-28 18:35:19 +00:00
fc6a899f16 Fixed some URLs not getting registered properly. 2024-10-05 15:28:56 +00:00
1f16de2239 Fixed casing oversight. 2024-10-05 14:39:43 +00:00
1550a5da57 Removed all references to the IPAddress class. 2024-10-05 14:22:14 +00:00
7ef1974c88 Fixed undropkicked I. 2024-10-05 03:36:53 +00:00
0f45a5f60f Updated to latest Index version. 2024-10-05 02:40:29 +00:00
324fe21d73 Use attributes for JSON encoding. 2024-09-30 17:38:08 +00:00
153abde3a2 Updated libraries. 2024-09-30 17:37:41 +00:00
f8aaa71260 Added optional string role IDs for the API. 2024-09-16 21:44:37 +00:00
37a3bc1ee6 Updated libraries. 2024-09-16 20:51:46 +00:00
f547812d5a Added RPC endpoint for fetching user info. 2024-09-05 20:08:31 +00:00
8a06836985 Added auth RPC routes. 2024-08-25 23:03:46 +00:00
34528ae413 Fixed return type. 2024-08-18 20:54:39 +00:00
0bf7ca0d52 Replaced internal Flashii ID routes with RPC library. 2024-08-16 19:29:57 +00:00
cc9fccdf18 Updated Index and switched to Carbon for date handling. 2024-08-04 21:37:12 +00:00
ca77b501e7 Removed stray Jeff. 2024-07-27 20:11:06 +00:00
2439f87df9 Added very preliminary support for Bearer tokens to chat authentication. 2024-07-21 01:50:42 +00:00
400253e04b Added interop endpoints for Hanyuu. 2024-07-20 19:35:50 +00:00
01c60e3027 Updated libraries. 2024-07-18 03:42:16 +00:00
37d8413118 Updated build script. 2024-06-11 00:48:44 +00:00
8cfa07bc8c SharpConfig -> FileConfig 2024-06-03 23:04:59 +00:00
a65579bf9d Updated libraries. 2024-06-03 23:04:21 +00:00
44a4bb6e6f Prevent access to private messages when impersonating a user. 2024-06-02 19:57:58 +00:00
ec00cfa176 Base64 encode PM titles and bodies in the database.
To prevent personal discomfort with having to do database messages and seeing people's personal conversations.
I haven't run into it yet, but I'd rather avoid it altogether.
2024-06-02 19:54:33 +00:00
1d295df8da Added broom closet PM stats. 2024-06-02 19:43:57 +00:00
6a88ed8b11 Updated libraries. 2024-05-30 22:02:09 +00:00
36bcf1ab1d Built Playpen icon updating into Misuzu.
Was previously handled by a stinky script.
2024-05-30 22:00:41 +00:00
5d3e1d4960 Fixed wrong HTTP verb. 2024-03-30 15:22:11 +00:00
9bb943bacf Fixed various oversights. 2024-03-30 03:19:08 +00:00
107d16cf46 Updated Misuzu to new HTTP router. 2024-03-30 03:14:03 +00:00
0afc5186a7 Fixed error when trying to access a topic with no posts associated. 2024-02-24 22:03:32 +00:00
0300bae994 hurr 2024-02-21 00:31:25 +00:00
cb0c64f8ed Stinky fix for impersonation in chat auth. 2024-02-20 23:56:43 +00:00
89ef9d9ad1 Fixed bans no longer working. 2024-02-15 22:55:24 +00:00
c02d922dc6 Fixed Forum Activity section always showing up. 2024-02-13 21:22:56 +00:00
80cd6222c4 Fixed profile fields not showing up anymore. 2024-02-11 02:22:22 +00:00
344a3c9160 Missed one! 2024-02-09 16:07:43 +00:00
df5dbdf3ad Fixed forum/topic breadcrumbs. 2024-02-08 15:20:44 +00:00
c0caceed7b Fixed use of wrong BanInfo constructor. 2024-02-08 15:18:57 +00:00
be54ce2c22 Fixed oversights on landing page. 2024-02-08 00:06:23 +00:00
070dc5e782 Added lazy database object creation. 2024-02-07 00:04:45 +00:00
b89621cb1a Added PMs to data export. 2024-02-05 22:56:51 +00:00
760cca0e5d whoops 2024-02-02 21:53:36 +00:00
fe77f1616c Updated to new EEPROM script. 2024-02-02 21:42:40 +00:00
eb81ed7a82 Added notice when recipient is banned. 2024-02-02 02:16:37 +00:00
8ef11afe02 Check if recipient is actually able to receive messages. 2024-02-02 02:07:29 +00:00
cca016ba10 Prevent banned users from sending messages. 2024-02-02 01:59:21 +00:00
b80151583e Added private messages. 2024-01-30 23:47:02 +00:00
d8cc208a85 Use accent-color and color-scheme CSS directives. 2024-01-25 18:17:54 +00:00
4b2f9a2fec Fixed Ctrl+Enter submission not working anymore either. 2024-01-25 00:18:56 +00:00
ddb255bf32 Fixed forum post form throwing up the navigation confirmation when it isn't supposed to. 2024-01-25 00:12:53 +00:00
5a70e3f3f1 Include SameSite attribute on cookies. 2024-01-24 22:14:48 +00:00
bd3e055323 Rewrote Javascript code. 2024-01-24 21:53:26 +00:00
dba5754ccc Fixed error when trying to add a new change. 2024-01-24 18:28:13 +00:00
ec6ba3f781 Imported new asset build script. 2024-01-24 18:24:40 +00:00
70ec285f99 Added links to Amimami repositories. 2024-01-18 20:31:08 +00:00
77eadd5bde Adjusted CORS handling for emoticon endpoint. 2024-01-17 19:57:46 +00:00
f0fc735975 Updated browserlists. 2024-01-08 13:43:34 +00:00
adb80bad9e Added server side image map support. 2024-01-08 13:42:22 +00:00
f30cf41f86 Ported boolean attribute support. 2024-01-08 13:36:47 +00:00
b4f5dd0660 Removed broken CONSTRAINT from perms table creation. 2023-12-16 18:51:17 +00:00
133e2f420c Fixed markdown styling issues. 2023-12-15 12:56:08 +00:00
bf65c95490 Updated highlight.js and created new code theme. 2023-12-15 12:47:01 +00:00
7ef5994da4 Updated Sentry library to 4.0 in Misuzu. 2023-12-15 01:03:57 +00:00
2b34bde413 Fixed error when trying to create a new role. 2023-12-02 02:57:46 +00:00
432615508d Fixed undefined variable. 2023-11-26 22:23:47 +00:00
a4cc14e4c1 Libraries have been updated once more. 2023-11-20 19:10:47 +00:00
65e695e9d9 git.flash.moe -> patchii.net 2023-11-20 19:04:59 +00:00
2e6a84b46d Updated source.md. 2023-11-09 20:58:56 +00:00
8f56174637 Supply super user status in auth data. 2023-11-07 14:38:53 +00:00
19fbe59ddd Return to purple. 2023-11-01 09:36:49 +00:00
f7a571e551 moguu? 2023-10-21 23:54:41 +00:00
5f57e3fdf4 Use SharpConfig format for the pre-database config. 2023-10-21 23:45:40 +00:00
c2836719c7 Updated to use Syokuhou config library. 2023-10-20 22:29:28 +00:00
14c9a1d9f6 Fixed oversight on members list. 2023-10-18 10:16:32 +00:00
4f1e35b566 Fixed overly eager url encoding on the search page. 2023-10-18 10:11:21 +00:00
9aa2a1431e Enable Spookii 2023-10-01 18:44:59 +00:00
4322f2561c Fixed chat routes being broken. 2023-09-11 20:36:20 +00:00
67d9620037 Fixed legacy paths being too / tolerant. 2023-09-11 20:15:48 +00:00
904d220582 Fixed router related explosions. 2023-09-11 20:10:37 +00:00
d9b152fb78 Fixed oversight on memberlist. 2023-09-11 19:19:19 +00:00
a945cc518a Fixed syntax error in post.php. 2023-09-11 19:18:10 +00:00
edc64b45ff Fixed error when trying to view a non-existent topic when logged out. 2023-09-10 21:04:10 +00:00
17e0d1f591 Added Sentry error logging on the server side. 2023-09-10 20:46:58 +00:00
5554c5c28d Removed unused pagination helper function. 2023-09-10 20:12:27 +00:00
55e23c7b5d Fixed CSRF tokens not being added to URLs that need them. 2023-09-10 20:02:11 +00:00
e376671136 Attempt at fixing forum issues. 2023-09-10 19:13:36 +00:00
3e49f6e503 Added URL registry attributes. 2023-09-10 00:04:53 +00:00
7db43a2acd Revert "チルノの日"
This reverts commit 099bd899ed.
2023-09-09 11:54:33 +00:00
099bd899ed チルノの日 2023-09-08 23:07:37 +00:00
1248c0d2f6 Moved various .php file redirects into the LegacyRouter. 2023-09-08 20:47:54 +00:00
c3bed1c0e3 Rewrote URL registry. 2023-09-08 20:40:48 +00:00
163da8b213 Added separate context class for forum stuff and split up handling of each object type. 2023-09-08 13:22:46 +00:00
c68279add9 Cleaned up some things I missed. 2023-09-08 01:05:17 +00:00
737c99280e Make PHPStan happy. 2023-09-08 00:54:19 +00:00
8b0f960c86 Split auth stuff off into own context. 2023-09-08 00:43:00 +00:00
c5a284f360 Route registration with attributes! 2023-09-08 00:13:30 +00:00
506d32d210 Fixed incorrect type on latest forum post fetching result. 2023-09-07 20:53:19 +00:00
498ec0cf9a Merge SharpChat permission set into the Misuzu permission system directly. 2023-09-06 20:44:28 +00:00
15e96684c2 Moved authentication related macros out of MisuzuContext. 2023-09-06 20:06:07 +00:00
73e4597e16 Rewrote Satori recent forum post fetch. 2023-09-06 19:35:50 +00:00
9b2c409a24 Moved user related stuff into its own context object. 2023-09-06 13:50:19 +00:00
7190a5f4df Syntactic sugar for mass route registration. 2023-09-06 11:59:44 +00:00
5c67d49225 Fixed edit display threshold. 2023-09-06 11:32:13 +00:00
69e4d05be6 Pluralise Views. 2023-09-06 11:19:54 +00:00
2d0f083e1a Fixed topic read status check. 2023-09-06 11:19:04 +00:00
1da6470928 Switch to Sasae. 2023-08-31 21:33:34 +00:00
9682fa595a Fixed static analysis detections. 2023-08-31 17:14:41 +00:00
c14195c4c3 Moved render_info and render_error into Template class. 2023-08-31 15:59:53 +00:00
45500ce698 Removed html_colour function, moved renamed DateCheck to Tools and moved the country names function into it and use new callable syntax. 2023-08-31 14:55:39 +00:00
0c9bac473b No longer rely on Referer header for the comments return URL. 2023-08-31 14:39:50 +00:00
061d4c8a8f Fixed leaderboard name not retaining the leading 0. 2023-08-31 00:54:17 +00:00
6fc10984e1 Append total posts count at the end of the leaderboard. 2023-08-31 00:52:14 +00:00
e222009dd0 Fixed oversight. 2023-08-31 00:40:07 +00:00
85b629bc08 Fixed missing use statement. 2023-08-31 00:38:20 +00:00
16ea495c7a Added permission for displaying load timings in the footer. 2023-08-31 00:37:09 +00:00
ad3fe74275 Removed old database backend. 2023-08-31 00:31:11 +00:00
29426fafc1 Count profile stats using Index database backend. 2023-08-31 00:24:59 +00:00
4d6fb64f3a Added shitty search hack to users class. 2023-08-31 00:19:20 +00:00
40558ceb39 Added targeted permission recalculation.
Reduces reliance on full recalculation and actually makes it viable to do from within the browser.
2023-08-30 23:56:33 +00:00
f03c8ebfa5 Moved validation methods into the new Users class. 2023-08-30 23:41:44 +00:00
07a2868159 Rewrote permissions system. 2023-08-30 22:37:21 +00:00
ca23822e40 Fixed errors on profiles. 2023-08-28 14:45:32 +00:00
34bd71600a Removed manage.php. 2023-08-28 13:45:36 +00:00
5bab957a7c Fixed user colours in comments sections. 2023-08-28 13:33:39 +00:00
57b9e82c10 Fixed topic type string usage. 2023-08-28 01:41:13 +00:00
460a0ca57d Fixed user colours not showing on forum posts. 2023-08-28 01:32:05 +00:00
39c6269cf3 Rewrote forum backend. 2023-08-28 01:17:34 +00:00
fb41c71ee9 Fixed emoticon ordering in chat. 2023-08-07 12:59:08 +00:00
2214dffc5b Fixed profile editing failing due to old argument. 2023-08-06 19:09:59 +00:00
bab8b29c5b Fixed error 500 when trying to log in to a non-existing user. 2023-08-06 18:22:39 +00:00
0a11c5525a Fixed oversight regarding RNG ordering of user list. 2023-08-05 13:55:34 +00:00
d4f6990e8a Made data source argument lists for News, Changelog, Comments and Emotes consistent with the rest. 2023-08-05 13:50:15 +00:00
87915b6a25 Fixed forum post deletion and editing. 2023-08-04 22:49:09 +00:00
cf71129153 Converted all Misuzu style route handlers to Index style ones. 2023-08-04 20:51:02 +00:00
6bfa3d7238 Fixed error 500 when viewing profiles as guest. 2023-08-04 17:44:37 +00:00
b7de5acfd8 Fixed search and updated collations of various fields to more appropriate ones. 2023-08-03 12:40:37 +00:00
9dd7156c79 Fixed issue caused by used of dangling variable on sessions page. 2023-08-03 01:43:43 +00:00
00d1d2922d Changed the way msz_auth is handled.
Going forward msz_auth is always assumed to be present, even while the user is not logged in.
If the cookie is not present a default, empty value will be used.
The msz_uid and msz_sid cookies are also still upconverted for some reason but are no longer removed even though there's no active sessions that can possibly have those anymore.
As with the previous change, shit may be broken so report any Anomalies you come across, through flashii-issues@flash.moe if necessary.
2023-08-03 01:35:08 +00:00
383e2ed0e0 Rewrote the user information class.
This one took multiple days and it pretty invasive into the core of Misuzu so issue might (will) arise, there's also some features that have gone temporarily missing in the mean time and some inefficiencies introduced that will be fixed again at a later time.
The old class isn't gone entirely because I still have to figure out what I'm gonna do about validation, but for the most part this knocks out one of the "layers of backwards compatibility", as I've been referring to it, and is moving us closer to a future where Flashii actually gets real updates.
If you run into anything that's broken and you're inhibited from reporting it through the forum, do it through chat or mail me at flashii-issues@flash.moe.
2023-08-02 22:12:47 +00:00
57081d858d Added server side stuff for Satori hooks. 2023-07-29 22:18:20 +00:00
e813f2a90e Some TOTP touch-ups. 2023-07-29 20:18:41 +00:00
0158333c90 Removed permissions stuff from the User object. 2023-07-29 18:15:30 +00:00
a89d8d26f4 Fixed error when news comments category doesn't exist somehow. 2023-07-29 18:01:41 +00:00
e3c0ae662e Removed HasRankInterface. 2023-07-29 17:31:43 +00:00
61daa21d3a Emit audit log upon impersonation. 2023-07-28 23:23:45 +00:00
934b016541 Added counters table for storing numbers of things statically. 2023-07-28 23:17:37 +00:00
8ef113f3a9 Allow non-super users to impersonate select users. 2023-07-28 21:20:19 +00:00
a22433f7dd Don't update last online time and ip address when impersonating. 2023-07-28 20:43:08 +00:00
c5ec94289d Added notice when there's no account logs to display. 2023-07-28 20:36:16 +00:00
8c52fc81e2 Hide roles section from settings if there's only one available. 2023-07-28 20:33:44 +00:00
d2f0eebfb2 Use random alphabetic string instead hex bytes for session tokens. 2023-07-28 20:13:11 +00:00
3148da4403 Rewrote Sessions backend. 2023-07-28 20:06:12 +00:00
5c8ffa09fc Cleaned up User and UserSession queries. 2023-07-28 15:07:30 +00:00
20b309563e Fixed phpstan detections. 2023-07-27 23:49:55 +00:00
461ffbf73b Rewrote user role handling. 2023-07-27 23:26:05 +00:00
26a0e11253 Fixed data export. 2023-07-27 13:14:32 +00:00
70623d3a7c Pluralise user role relations table name. 2023-07-27 13:09:22 +00:00
b4d4e8578c Rewrote TFA session code. 2023-07-27 12:44:50 +00:00
8480d5f043 Fixed the manage index statistics causing a 500 because the old warnings table is Gone. 2023-07-26 22:57:03 +00:00
a30df1b17c Fixed warning deletion. 2023-07-26 22:48:47 +00:00
351043e283 Split Sharp Chat kick and ban permissions based on the Misuzu warnings and bans permissions. 2023-07-26 22:46:35 +00:00
2231cd8124 Rewrote user warnings backend. 2023-07-26 22:43:50 +00:00
86432616c6 Expiration -> Expires 2023-07-26 18:24:49 +00:00
1d552e907b Added new banning system.
it actually works and isn't confusing this time around!
2023-07-26 18:19:48 +00:00
057551edb3 Pluralise. 2023-07-26 11:56:06 +00:00
710049794f Fixed typo that would cause things to fail. 2023-07-26 11:54:49 +00:00
ca1edb4270 Fixed gross misalignment. 2023-07-25 19:03:48 +00:00
f4f465d8d8 Redesigned news post preview information section. 2023-07-25 19:02:00 +00:00
81f4dfce19 Fixed error 500 when trying to view a non-existent profile. 2023-07-25 15:03:25 +00:00
bd683d8404 Allow moderators to view a stripped down version of the user page in the broom closet. 2023-07-25 14:52:51 +00:00
3299d73df2 Added new moderator notes system. 2023-07-25 14:40:31 +00:00
ee304af133 Removed the concept of silencing.
Nothing really implemented it properly or checked for it and the places that did check just handled it as a slightly softer ban.
It's pretty obvious that the existence of this feature was directly taken from osu! where the differentation between a ban and a silence probably makes more sense, though even there Silences are just non-permanent bans, so like why does this exist lol?
Well, it doesn't anymore! Hopefully chat will upgrade successfully because I let it get 18 commits behind :D
2023-07-23 21:47:15 +00:00
3d67b59238 Attempt 2 at fixing the profile fields issue (this one actually fixes the issue!) 2023-07-22 21:25:51 +00:00
dd21fce6e3 Rewrote password recovery token storage using new DB backend. 2023-07-22 21:20:03 +00:00
f6058823f1 Fixed error 500 on profiles when filling certain fields in specific conditions. 2023-07-22 20:54:52 +00:00
392881c0d8 Fixed type on getUserId in LoginAttemptInfo. 2023-07-22 17:27:42 +00:00
6e3023a772 Rewrite login attempts log to use new database backend. 2023-07-22 16:37:57 +00:00
d0e3f6ce65 Normalised custom exception usage in user classes.
Also updated the Index library to include the MediaType fix.
2023-07-22 15:02:45 +00:00
42d893fc18 Use the Index DbStatementCache implementation. 2023-07-22 14:00:51 +00:00
baefea88df Use the Index DbTools version for list prepare thing. 2023-07-22 13:54:42 +00:00
e369038609 Updated Index Serialiser usage. 2023-07-21 21:56:09 +00:00
9962bbc5df Added phpstan as a dev dependency. 2023-07-21 19:38:54 +00:00
761bc94b8e Removed local config plugin and fixed Index info pages. 2023-07-21 19:30:28 +00:00
362 changed files with 24381 additions and 14878 deletions

6
.gitignore vendored
View file

@ -11,6 +11,8 @@
/composer.local.json
# Configuration
/config/config.cfg
/config/github.cfg
/config/config.ini
/config/github.ini
/.debug
@ -43,3 +45,7 @@
# Google
/public/robots.txt
# moguu?
/public/moguu.swf
/public/moguu.html

View file

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

View file

@ -2,6 +2,6 @@
> Misuzu can and will steal your lunch money.
## Requirements
- PHP 8.2
- PHP 8.2 (64-bit)
- MariaDB 10.6
- [Composer](https://getcomposer.org/)

View file

@ -17,15 +17,10 @@ exports.process = async function(root, options) {
return '';
included.push(fullPath);
if(!fullPath.startsWith(root)) {
console.error('INVALID PATH: ' + fullPath);
if(!fullPath.startsWith(root))
return '/* *** INVALID PATH: ' + fullPath + ' */';
}
if(!fs.existsSync(fullPath)) {
console.error('FILE NOT FOUND: ' + fullPath);
if(!fs.existsSync(fullPath))
return '/* *** FILE NOT FOUND: ' + fullPath + ' */';
}
const lines = readline.createInterface({
input: fs.createReadStream(fullPath),
@ -58,6 +53,19 @@ exports.process = async function(root, options) {
break;
}
case 'buildvars':
if(typeof options.buildVars === 'object') {
const bvTarget = options.buildVarsTarget || 'window';
const bvProps = [];
for(const bvName in options.buildVars)
bvProps.push(`${bvName}: { value: ${JSON.stringify(options.buildVars[bvName])}, writable: false }`);
if(Object.keys(bvProps).length > 0)
output += `Object.defineProperties(${bvTarget}, { ${bvProps.join(', ')} });\n`;
}
break;
default:
output += line;
output += "\n";
@ -84,7 +92,7 @@ exports.housekeep = function(assetsPath) {
};
}).sort((a, b) => b.lastMod - a.lastMod).map(info => info.name);
const regex = /^(.+)-([a-f0-9]+)\.(.+)$/i;
const regex = /^(.+)[\-\.]([a-f0-9]+)\.(.+)$/i;
const counts = {};
for(const fileName of files) {

View file

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

View file

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

View file

@ -0,0 +1,92 @@
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
font-size: 1.2em;
font-family: var(--font-monospace);
}
code.hljs {
padding: 2px 5px;
}
.hljs {
color: #eee;
background: #121212;
}
.hljs-strong,
.hljs-emphasis,
.hljs-section {
font-weight: 700;
}
.hljs-bullet,
.hljs-quote,
.hljs-number,
.hljs-regexp,
.hljs-literal {
color: #b2b376;
}
.hljs-code {
background-color: #242424;
}
.hljs-comment,
.hljs-meta,
.hljs-emphasis,
.hljs-stronge,
.hljs-type,
.hljs-attribute,
.hljs-params {
font-style: italic;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-section,
.hljs-symbol,
.hljs-name {
color: #9475b2;
}
.hljs-built_in,
.hljs-subst,
.hljs-tag,
.hljs-title,
.hljs-selector-attr {
color: #c8b9d7;
}
.hljs-variable,
.hljs-class .hljs-title,
.hljs-selector-class,
.hljs-selector-id,
.hljs-selector-pseudo {
color: #b37fae;
}
.hljs-string {
color: #76b38a;
}
.hljs-type,
.hljs-template-tag,
.hljs-template-variable,
.hljs-link {
color: #b39a76;
}
.hljs-comment,
.hljs-meta {
color: #70647b;
}
.hljs-addition {
background: #0e4d0e;
}
.hljs-deletion {
background: #4d0e0e;
}

View file

@ -3,7 +3,6 @@
padding: 0;
box-sizing: border-box;
position: relative;
outline-style: none;
}
html,
@ -57,6 +56,8 @@ body {
html {
scrollbar-color: var(--accent-colour) var(--background-colour);
accent-color: var(--accent-colour);
color-scheme: dark;
}
.main {
@ -117,6 +118,8 @@ html {
@include permissions.css;
@include warning.css;
@include hljs.css;
@include _input/button.css;
@include _input/checkbox.css;
@include _input/colour.css;
@ -160,21 +163,8 @@ html {
@include home/landingv2.css;
@include manage/_manage.css;
@include manage/blacklist.css;
@include manage/changelog-actions-tags.css;
@include manage/emote.css;
@include manage/emotes.css;
@include manage/navigation.css;
@include manage/role-item.css;
@include manage/roles.css;
@include manage/settings.css;
@include manage/statistic.css;
@include manage/statistics.css;
@include manage/tag.css;
@include manage/tags.css;
@include manage/user-item.css;
@include manage/user.css;
@include manage/users.css;
@include messages/messages.css;
@include news/container.css;
@include news/feeds.css;
@ -191,7 +181,7 @@ html {
@include profile/header.css;
@include profile/profile.css;
@include profile/signature.css;
@include profile/warning.css;
@include profile/warnings.css;
@include search/anchor.css;
@include search/categories.css;

View file

@ -23,3 +23,25 @@
width: 100%;
}
}
@include manage/ban.css;
@include manage/bans.css;
@include manage/blacklist.css;
@include manage/changelog-actions-tags.css;
@include manage/emote.css;
@include manage/emotes.css;
@include manage/navigation.css;
@include manage/note.css;
@include manage/notes.css;
@include manage/role-item.css;
@include manage/roles.css;
@include manage/settings.css;
@include manage/statistic.css;
@include manage/statistics.css;
@include manage/tag.css;
@include manage/tags.css;
@include manage/user-item.css;
@include manage/user.css;
@include manage/users.css;
@include manage/warning.css;
@include manage/warnings.css;

View file

@ -0,0 +1,73 @@
.manage__ban__field {
margin: 2px;
margin-bottom: 8px;
}
.manage__ban__title {
font-size: 1.4em;
line-height: 1.5em;
padding: 0 4px;
}
.manage__ban__desc {
font-size: .9em;
line-height: 1.5em;
font-style: italic;
border-bottom: 1px solid var(--accent-colour);
padding: 2px 4px;
margin-bottom: 1px;
}
.manage__ban__duration {
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
gap: 5px;
}
.manage__ban__duration__value__custom--hidden {
display: none;
visibility: hidden;
}
.manage__ban__severity {
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
gap: 5px;
}
.manage__ban__severity__slider {
max-width: 200px;
width: 100%;
}
.manage__ban__severity__slider input {
width: 100%;
margin-top: 2px;
}
.manage__ban__severity__display {
max-width: 80px;
width: 100%;
}
.manage__ban__severity__display input {
width: 100%;
margin-bottom: 2px;
}
.manage__ban__reason {
padding: 2px;
width: 100%;
}
.manage__ban__reason textarea {
min-width: 100%;
max-width: 100%;
width: 100%;
min-height: 100px;
}
.manage__ban__actions {
display: flex;
justify-content: center;
padding: 10px;
padding-top: 0;
}

View file

@ -0,0 +1,122 @@
.manage__bans__pagination {
margin: 2px;
}
.manage__bans__actions {
display: flex;
gap: 2px;
margin: 2px;
}
.manage__bans__item {
padding: 0 2px;
margin: 2px;
border-top: 1px solid var(--accent-colour);
}
.manage__bans__item:not(:last-child) {
border-bottom: 1px solid var(--accent-colour);
}
.manage__bans__item__header {
display: flex;
overflow: hidden;
align-items: center;
}
.manage__bans__item__attributes {
flex-grow: 1;
flex-shrink: 1;
display: flex;
gap: 12px;
margin: 0 4px;
flex-wrap: wrap;
}
.manage__bans__item__attribute {
display: flex;
gap: 4px;
align-items: center;
}
.manage__bans__item__created__icon,
.manage__bans__item__expires__icon,
.manage__bans__item__permanent__icon {
font-size: 16px;
}
.manage__bans__item__expires__status span {
padding: 2px 4px;
border-radius: 2px;
}
.manage__bans__item__expires__status--active span {
background: rgba(255, 100, 100, 0.2);
font-weight: 700;
}
.manage__bans__item__expires__status--expired span {
background: rgba(100, 255, 100, 0.2);
}
.manage__bans__item__permanent {
background: rgba(255, 200, 100, 0.2);
border-radius: 2px;
padding: 0 4px;
}
.manage__bans__item__permanent__time {
font-weight: 700;
}
.manage__bans__item__actions {
display: flex;
flex-grow: 0;
flex-shrink: 0;
gap: 1px;
padding: 1px;
margin: 1px;
}
.manage__bans__item__action {
width: 36px;
height: 36px;
}
.manage__bans__item__author a,
.manage__bans__item__user a {
color: inherit;
text-decoration: none;
}
.manage__bans__item__author__name a,
.manage__bans__item__user__name a {
font-weight: bold;
display: inline-block;
padding-top: 2px;
border-bottom: 2px solid var(--user-colour, #fff);
}
.manage__bans__item__user__filter a {
padding: 2px 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
transition: background .2s;
}
.manage__bans__item__user__filter a:hover,
.manage__bans__item__user__filter a:focus {
background: rgba(255, 255, 255, 0.4);
}
.manage__bans__item__user__filter a:active {
background: rgba(255, 255, 255, 0.1);
}
.manage__bans__item__reason {
margin: 1px 4px;
padding: 2px 4px;
border-top: 1px solid var(--accent-colour);
}
.manage__bans__item__reason__title {
font-size: .9em;
line-height: 1.5em;
font-style: italic;
}
.manage__bans__item__reason__body {
padding-left: 4px;
border-left: 2px solid var(--accent-colour);
}
.manage__bans__item__noreason {
font-size: .9em;
font-style: italic;
}

View file

@ -0,0 +1,88 @@
.manage__note {
margin: 2px;
}
.manage__note--view .manage__note--editing,
.manage__note--edit .manage__note--viewing {
display: none !important;
visibility: hidden !important;
}
.manage__note__header {
display: flex;
overflow: hidden;
align-items: center;
}
.manage__note__title {
flex-grow: 1;
flex-shrink: 1;
font-size: 1.4em;
line-height: 1.3em;
}
.manage__note__title__text {
padding: 2px 5px;
}
.manage__note__title input {
width: 100%;
}
.manage__note__actions {
display: flex;
flex-grow: 0;
flex-shrink: 0;
gap: 1px;
padding: 1px;
margin: 1px;
}
.manage__note__action {
width: 36px;
height: 36px;
}
.manage__note__attributes {
display: flex;
gap: 12px;
margin: 0 4px;
}
.manage__note__attribute {
display: flex;
gap: 4px;
align-items: center;
}
.manage__note__created__icon {
font-size: 16px;
}
.manage__note__author a,
.manage__note__user a {
color: inherit;
text-decoration: none;
}
.manage__note__author__name a,
.manage__note__user__name a {
font-weight: bold;
display: inline-block;
padding-top: 2px;
border-bottom: 2px solid var(--user-colour, #fff);
}
.manage__note__body {
margin: 2px;
}
.manage__note__nobody {
text-align: center;
font-size: .9em;
font-style: italic;
}
.manage__note__editor {
width: 100%;
}
.manage__note__editor textarea {
width: 100%;
min-width: 100%;
max-width: 100%;
min-height: 300px;
}

View file

@ -0,0 +1,122 @@
.manage__notes__pagination {
margin: 2px;
}
.manage__notes__actions {
display: flex;
gap: 2px;
margin: 2px;
}
.manage__notes__item {
padding: 2px;
margin: 2px;
}
.manage__notes__item:not(:last-child) {
border-bottom: 1px solid var(--accent-colour);
}
.manage__notes__item__header {
display: flex;
overflow: hidden;
align-items: center;
}
.manage__notes__item__title {
flex-grow: 1;
flex-shrink: 1;
font-size: 1.4em;
line-height: 1.3em;
padding: 2px 5px;
}
.manage__notes__item__title a {
color: inherit;
text-decoration: none;
}
.manage__notes__item__title a:hover,
.manage__notes__item__title a:focus {
text-decoration: underline;
}
.manage__notes__item__actions {
display: flex;
flex-grow: 0;
flex-shrink: 0;
gap: 1px;
padding: 1px;
margin: 1px;
}
.manage__notes__item__action {
width: 36px;
height: 36px;
}
.manage__notes__item__attributes {
display: flex;
gap: 12px;
margin: 0 4px;
}
.manage__notes__item__attribute {
display: flex;
gap: 4px;
align-items: center;
}
.manage__notes__item__created__icon {
font-size: 16px;
}
.manage__notes__item__author a,
.manage__notes__item__user a {
color: inherit;
text-decoration: none;
}
.manage__notes__item__author__name a,
.manage__notes__item__user__name a {
font-weight: bold;
display: inline-block;
padding-top: 2px;
border-bottom: 2px solid var(--user-colour, #fff);
}
.manage__notes__item__user__filter a {
padding: 2px 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
transition: background .2s;
}
.manage__notes__item__user__filter a:hover,
.manage__notes__item__user__filter a:focus {
background: rgba(255, 255, 255, 0.4);
}
.manage__notes__item__user__filter a:active {
background: rgba(255, 255, 255, 0.1);
}
.manage__notes__item__body {
margin: 2px;
}
.manage__notes__item__nobody {
text-align: center;
font-size: .9em;
font-style: italic;
}
.manage__notes__item__continue {
text-align: center;
}
.manage__notes__item__continue a {
display: inline-block;
padding: 2px 5px;
color: inherit;
text-decoration: none;
border-radius: 5px;
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.2);
transition: background .2s;
}
.manage__notes__item__continue a:hover,
.manage__notes__item__continue a:focus {
background: rgba(255, 255, 255, 0.4);
}
.manage__notes__item__continue a:active {
background: rgba(255, 255, 255, 0.1);
}

View file

@ -9,6 +9,12 @@
}
.manage__statistic__value {
text-align: right;
font-size: 1.5em;
line-height: 2em;
font-size: 1.4em;
line-height: 1.5em;
}
.manage__statistic__updated {
text-align: right;
font-size: .9em;
font-style: italic;
line-height: 1.5em;
}

View file

@ -1,9 +1,14 @@
.manage__statistics {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr 1fr 1fr;
padding: 5px;
grid-gap: 5px;
}
@media (max-width: 1100px) {
.manage__statistics {
grid-template-columns: 1fr 1fr 1fr;
}
}
@media (max-width: 900px) {
.manage__statistics {
grid-template-columns: 1fr 1fr;

View file

@ -0,0 +1,37 @@
.manage__warning__field {
margin: 2px;
margin-bottom: 8px;
}
.manage__warning__title {
font-size: 1.4em;
line-height: 1.5em;
padding: 0 4px;
}
.manage__warning__desc {
font-size: .9em;
line-height: 1.5em;
font-style: italic;
border-bottom: 1px solid var(--accent-colour);
padding: 2px 4px;
margin-bottom: 1px;
}
.manage__warning__body {
padding: 2px;
width: 100%;
}
.manage__warning__body textarea {
min-width: 100%;
max-width: 100%;
width: 100%;
min-height: 100px;
}
.manage__warning__actions {
display: flex;
justify-content: center;
padding: 10px;
padding-top: 0;
}

View file

@ -0,0 +1,91 @@
.manage__warnings__pagination {
margin: 2px;
}
.manage__warnings__actions {
display: flex;
gap: 2px;
margin: 2px;
}
.manage__warnings__item {
padding: 0 2px;
margin: 2px;
border-top: 1px solid var(--accent-colour);
}
.manage__warnings__item:not(:last-child) {
border-bottom: 1px solid var(--accent-colour);
}
.manage__warnings__item__header {
display: flex;
overflow: hidden;
align-items: center;
}
.manage__warnings__item__attributes {
flex-grow: 1;
flex-shrink: 1;
display: flex;
gap: 12px;
margin: 0 4px;
flex-wrap: wrap;
}
.manage__warnings__item__attribute {
display: flex;
gap: 4px;
align-items: center;
}
.manage__warnings__item__created__icon {
font-size: 16px;
}
.manage__warnings__item__actions {
display: flex;
flex-grow: 0;
flex-shrink: 0;
gap: 1px;
padding: 1px;
margin: 1px;
}
.manage__warnings__item__action {
width: 36px;
height: 36px;
}
.manage__warnings__item__author a,
.manage__warnings__item__user a {
color: inherit;
text-decoration: none;
}
.manage__warnings__item__author__name a,
.manage__warnings__item__user__name a {
font-weight: bold;
display: inline-block;
padding-top: 2px;
border-bottom: 2px solid var(--user-colour, #fff);
}
.manage__warnings__item__user__filter a {
padding: 2px 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
transition: background .2s;
}
.manage__warnings__item__user__filter a:hover,
.manage__warnings__item__user__filter a:focus {
background: rgba(255, 255, 255, 0.4);
}
.manage__warnings__item__user__filter a:active {
background: rgba(255, 255, 255, 0.1);
}
.manage__warnings__item__reason {
margin: 1px 4px;
padding: 2px 4px;
border-top: 1px solid var(--accent-colour);
}
.manage__warnings__item__reason p {
padding-left: 4px;
border-left: 2px solid var(--accent-colour);
}

View file

@ -53,7 +53,6 @@
.markdown code {
padding: .2em .4em;
margin: 0;
background-color: rgba(0, 0, 0, .7);
border-radius: 2px;
}
.markdown del code { text-decoration: inherit; }
@ -65,7 +64,6 @@
overflow: hidden;
line-height: inherit;
word-wrap: break-word;
background: transparent;
border: 0;
}

View file

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

View file

@ -0,0 +1,37 @@
.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

@ -0,0 +1,26 @@
.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

@ -0,0 +1,80 @@
.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

@ -0,0 +1,33 @@
.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

@ -0,0 +1,135 @@
.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

@ -0,0 +1,9 @@
@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

@ -0,0 +1,17 @@
.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

@ -0,0 +1,52 @@
.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

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

View file

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

View file

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

View file

@ -1,31 +1,6 @@
.news__preview {
display: flex;
margin: 2px 0;
flex-direction: row-reverse;
--user-colour: var(--accent-colour);
}
.news__preview__info__content {
width: 200px;
text-align: center;
display: flex;
flex-direction: column;
padding: 15px;
flex: 0 0 auto;
margin-right: 4px;
}
.news__preview__info__background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
mask-image: linear-gradient(90deg, transparent 10%, var(--background-colour) 100%);
-webkit-mask-image: linear-gradient(90deg, transparent 10%, var(--background-colour) 100%);
background: var(--background-pattern);
background-color: var(--user-colour);
background-blend-mode: multiply;
padding: 10px 12px;
}
.news__preview__listing {
@ -33,68 +8,63 @@
flex-shrink: 1;
}
.news__preview__container {
.news__preview__header {
border-bottom: 1px solid var(--accent-colour);
display: flex;
margin: 1px;
flex-direction: column;
}
.news__preview__user {
display: flex;
text-align: left;
align-items: center;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 10px;
padding-bottom: 2px;
}
.news__preview__user__details {
.news__preview__title {
flex-grow: 1;
flex-shrink: 1;
}
.news__preview__title h1 {
font-size: 2em;
line-height: 1.5em;
}
.news__preview__attrs {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: .9em;
}
.news__preview__avatar {
width: 60px;
height: 60px;
margin-right: 10px;
.news__preview__attr {
display: flex;
gap: 4px;
align-items: center;
}
.news__preview__username {
color: inherit;
font-size: 1.4em;
line-height: 1.5em;
text-decoration: none;
}
.news__preview__username[href]:hover {
text-decoration: underline;
}
.news__preview__date {
font-size: 1.1em;
line-height: 1.5em;
}
.news__preview__category {
.news__preview__author a {
color: inherit;
text-decoration: none;
font-size: 1.1em;
line-height: 1.5em;
margin: 6px 0;
}
.news__preview__category:hover {
.news__preview__author__name a {
font-weight: bold;
display: inline-block;
padding-top: 2px;
border-bottom: 2px solid var(--user-colour, #fff);
}
.news__preview__category a {
color: inherit;
text-decoration: none;
}
.news__preview__category a:hover,
.news__preview__category a:focus {
text-decoration: underline;
}
.news__preview__content {
display: flex;
flex-direction: column;
line-height: 1.2em;
flex: 1 1 auto;
line-height: 1.4em;
word-wrap: break-word;
overflow: hidden;
margin: 2px;
padding: 0 10px 10px 10px;
}
.news__preview__text {
flex: 1 1 auto;
}
.news__preview__links {
@ -105,25 +75,3 @@
.news__preview__link {
font-size: .9em;
}
@media (max-width: 800px) {
.news__preview { flex-direction: column-reverse; }
.news__preview__info { display: none; }
.news__preview__info__content {
width: 100%;
flex-wrap: wrap;
text-align: left;
}
.news__preview__info__background {
mask-image: linear-gradient(180deg, transparent 10%, var(--background-colour) 100%);
-webkit-mask-image: linear-gradient(180deg, transparent 10%, var(--background-colour) 100%);
}
.news__preview__user {
margin-bottom: 0;
margin-right: 10px;
}
.news__preview__avatar {
width: 50px;
height: 50px;
}
}

View file

@ -1,139 +0,0 @@
.profile__warning {
margin: 2px;
border-radius: 2px;
border: 1px solid var(--accent-colour);
}
.profile__warning__container {
margin: 2px 0;
}
.profile__warning--warning {
--accent-colour: #666;
}
.profile__warning--silence {
--accent-colour: #f70;
}
.profile__warning--ban {
--accent-colour: #c33;
}
.profile__warning--extendo {
margin: 4px;
}
.profile__warning__background {
background-color: var(--accent-colour);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.profile__warning__content {
background-color: var(--background-colour-translucent-9);
display: flex;
padding: 1px;
}
.profile__warning__type,
.profile__warning__created,
.profile__warning__duration {
display: inline-flex;
align-items: center;
justify-content: center;
}
.profile__warning__type {
min-width: 80px;
background-color: var(--accent-colour);
border-radius: 1px;
padding: 0 4px;
}
.profile__warning__created,
.profile__warning__duration {
min-width: 100px;
padding: 0 4px;
}
.profile__warning__note {
padding: 1px 4px;
flex: 1 1 auto;
}
.profile__warning__private {
border-top: 1px solid var(--accent-colour);
margin-top: 1px;
width: 100%;
opacity: .5;
transition: opacity .2s;
}
.profile__warning__private:hover,
.profile__warning__private:active,
.profile__warning__private:focus {
opacity: 1;
}
.profile__warning__tools {
display: flex;
padding-bottom: 1px;
}
.profile__warning__options {
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.profile__warning__option {
padding: 2px 5px;
color: inherit;
text-decoration: none;
}
.profile__warning__user {
display: flex;
padding: 2px;
min-width: 300px;
}
.profile__warning__user__avatar {
width: 20px;
height: 20px;
}
.profile__warning__user__username {
padding: 0 5px;
min-width: 60px;
color: inherit;
text-decoration: none;
}
.profile__warning__user__username:hover,
.profile__warning__user__username:focus,
.profile__warning__user__username:active {
text-decoration: underline;
}
.profile__warning__user__ip {
display: inline-flex;
padding: 0 5px;
}
.profile__warning__user__ip:before { content: "("; }
.profile__warning__user__ip:after { content: ")"; }
@media (max-width: 800px) {
.profile__warning__content {
flex-wrap: wrap;
}
.profile__warning__tools {
flex-direction: column;
}
.profile__warning__options {
justify-content: flex-start;
}
}

View file

@ -0,0 +1,26 @@
.profile__warnings {
display: flex;
flex-direction: column;
padding: 2px 5px;
}
.profile__warnings__item {
padding-bottom: 5px;
}
.profile__warnings__item:not(:last-child) {
border-bottom: 1px solid #222;
}
.profile__warnings__datetime {
font-size: .9em;
line-height: 1.5em;
font-style: italic;
padding-top: 2px;
}
.profile__warnings__body {
padding: 0 5px;
}
.profile__warnings__body p {
line-height: 1.4em;
}

View file

@ -1,6 +1,10 @@
.settings__account-logs__pagination {
margin: 4px;
}
.settings__account-logs__none {
padding: 2px 5px;
text-align: center;
}
.settings__account-log {
border: 1px solid var(--accent-colour);

View file

@ -9,10 +9,21 @@
color: #fff;
text-align: center;
}
.warning--red {
--start-colour: #ff3d3d;
--end-colour: #f00;
}
.warning--bigger {
font-size: 1.4em;
line-height: 1.5em;
}
.warning__content {
background-color: rgba(17, 17, 17, .9);
padding: 2px 5px;
}
.warning--bigger .warning__content {
padding: 8px 20px;
}
.warning__link {
color: inherit;
text-decoration: underline dotted;

40
assets/misuzu.js/csrfp.js Normal file
View file

@ -0,0 +1,40 @@
#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

@ -1,128 +0,0 @@
#include utils.js
#include uiharu.js
#include aembed.js
#include iembed.js
#include vembed.js
var MszEmbed = (function() {
let uiharu = undefined;
return {
init: function(endPoint) {
uiharu = new Uiharu(endPoint);
},
handle: function(targets) {
if(!Array.isArray(targets))
targets = Array.from(targets);
const filtered = new Map;
for(const target of targets) {
if(!(target instanceof HTMLElement)
|| !('dataset' in target)
|| !('mszEmbedUrl' in target.dataset))
continue;
const cleanUrl = target.dataset.mszEmbedUrl.replace(/ /, '%20');
if(cleanUrl.indexOf('https://') !== 0
&& cleanUrl.indexOf('http://') !== 0
&& cleanUrl.indexOf('//') !== 0) {
target.textContent = target.dataset.mszEmbedUrl;
continue;
}
$rc(target);
target.appendChild($e({
tag: 'i',
attrs: {
className: 'fas fa-2x fa-spinner fa-pulse',
style: {
width: '32px',
height: '32px',
lineHeight: '32px',
textAlign: 'center',
},
},
}));
if(filtered.has(cleanUrl))
filtered.get(cleanUrl).push(target);
else
filtered.set(cleanUrl, [target]);
}
const replaceWithUrl = function(targets, url) {
for(const target of targets) {
let body = $e({
tag: 'a',
attrs: {
className: 'link',
href: url,
target: '_blank',
rel: 'noopener noreferrer',
},
child: url
});
$ib(target, body);
$r(target);
}
};
filtered.forEach(function(targets, url) {
uiharu.lookupOne(url, function(metadata) {
if(metadata.error) {
replaceWithUrl(targets, url);
console.error(metadata.error);
return;
}
if(metadata.title === undefined) {
replaceWithUrl(targets, url);
console.warn('Media is no longer available.');
return;
}
let phc = undefined,
options = {
onembed: console.log,
};
if(metadata.type === 'youtube:video') {
phc = MszVideoEmbedPlaceholder;
options.type = 'youtube';
options.player = MszVideoEmbedYouTube;
} else if(metadata.type === 'niconico:video') {
phc = MszVideoEmbedPlaceholder;
options.type = 'nicovideo';
options.player = MszVideoEmbedNicoNico;
} else if(metadata.is_video) {
phc = MszVideoEmbedPlaceholder;
options.type = 'external';
options.player = MszVideoEmbedPlayer;
//options.frame = MszVideoEmbedFrame;
options.nativeControls = true;
options.autosize = false;
options.maxWidth = 640;
options.maxHeight = 360;
} else if(metadata.is_audio) {
phc = MszAudioEmbedPlaceholder;
options.type = 'external';
options.player = MszAudioEmbedPlayer;
options.nativeControls = true;
} else if(metadata.is_image) {
phc = MszImageEmbed;
options.type = 'external';
}
if(phc === undefined)
return;
for(const target of targets) {
const placeholder = new phc(metadata, options, target);
if(placeholder !== undefined)
placeholder.replaceElement(target);
}
});
});
},
};
})();

View file

@ -1,4 +1,4 @@
#include utils.js
#include utility.js
#include watcher.js
const MszAudioEmbedPlayerEvents = function() {
@ -56,7 +56,7 @@ const MszAudioEmbedPlayer = function(metadata, options) {
if(haveNativeControls)
playerAttrs.controls = 'controls';
const watchers = new MszWatcherCollection;
const watchers = new MszWatchers;
watchers.define(MszAudioEmbedPlayerEvents());
const player = $e({
@ -84,7 +84,8 @@ const MszAudioEmbedPlayer = function(metadata, options) {
getType: function() { return 'external'; },
};
watchers.proxy(pub);
pub.watch = (name, handler) => watchers.watch(name, handler);
pub.unwatch = (name, handler) => watchers.unwatch(name, handler);
player.addEventListener('play', function() { watchers.call('play', pub); });

View file

@ -0,0 +1,135 @@
#include utility.js
#include embed/audio.js
#include embed/image.js
#include embed/video.js
#include ext/uiharu.js
const MszEmbed = (function() {
let uiharu = undefined;
return {
init: function(endPoint) {
uiharu = new MszUiharu(endPoint);
},
handle: function(targets) {
if(!Array.isArray(targets))
targets = Array.from(targets);
const filtered = new Map;
for(const target of targets) {
if(!(target instanceof HTMLElement)
|| !('dataset' in target)
|| !('mszEmbedUrl' in target.dataset))
continue;
const cleanUrl = target.dataset.mszEmbedUrl.replace(/ /, '%20');
if(cleanUrl.indexOf('https://') !== 0
&& cleanUrl.indexOf('http://') !== 0
&& cleanUrl.indexOf('//') !== 0) {
target.textContent = target.dataset.mszEmbedUrl;
continue;
}
$rc(target);
target.appendChild($e({
tag: 'i',
attrs: {
className: 'fas fa-2x fa-spinner fa-pulse',
style: {
width: '32px',
height: '32px',
lineHeight: '32px',
textAlign: 'center',
},
},
}));
if(filtered.has(cleanUrl))
filtered.get(cleanUrl).push(target);
else
filtered.set(cleanUrl, [target]);
}
const replaceWithUrl = function(targets, url) {
for(const target of targets) {
let body = $e({
tag: 'a',
attrs: {
className: 'link',
href: url,
target: '_blank',
rel: 'noopener noreferrer',
},
child: url
});
$ib(target, body);
$r(target);
}
};
filtered.forEach(function(targets, url) {
uiharu.lookupOne(url)
.catch(ex => {
replaceWithUrl(targets, url);
console.error(ex);
})
.then(result => {
const metadata = result.body();
if(metadata.error) {
replaceWithUrl(targets, url);
console.error(metadata.error);
return;
}
if(metadata.title === undefined) {
replaceWithUrl(targets, url);
console.warn('Media is no longer available.');
return;
}
let phc = undefined,
options = {
onembed: console.log,
};
if(metadata.type === 'youtube:video') {
phc = MszVideoEmbedPlaceholder;
options.type = 'youtube';
options.player = MszVideoEmbedYouTube;
} else if(metadata.type === 'niconico:video') {
phc = MszVideoEmbedPlaceholder;
options.type = 'nicovideo';
options.player = MszVideoEmbedNicoNico;
} else if(metadata.is_video) {
phc = MszVideoEmbedPlaceholder;
options.type = 'external';
options.player = MszVideoEmbedPlayer;
//options.frame = MszVideoEmbedFrame;
options.nativeControls = true;
options.autosize = false;
options.maxWidth = 640;
options.maxHeight = 360;
} else if(metadata.is_audio) {
phc = MszAudioEmbedPlaceholder;
options.type = 'external';
options.player = MszAudioEmbedPlayer;
options.nativeControls = true;
} else if(metadata.is_image) {
phc = MszImageEmbed;
options.type = 'external';
}
if(phc === undefined)
return;
for(const target of targets) {
const placeholder = new phc(metadata, options, target);
if(placeholder !== undefined)
placeholder.replaceElement(target);
}
});
});
},
};
})();

View file

@ -1,4 +1,4 @@
#include utils.js
#include utility.js
const MszImageEmbed = function(metadata, options, target) {
options = options || {};

View file

@ -1,5 +1,5 @@
#include utils.js
#include rng.js
#include utility.js
#include uniqstr.js
#include watcher.js
const MszVideoEmbedPlayerEvents = function() {
@ -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 MszWatcherCollection;
const watchers = new MszWatchers;
watchers.define(MszVideoEmbedPlayerEvents());
const player = $e({
@ -265,12 +265,13 @@ const MszVideoEmbedPlayer = function(metadata, options) {
getHeight: function() { return height; },
};
watchers.proxy(pub);
pub.watch = (name, handler) => watchers.watch(name, handler);
pub.unwatch = (name, handler) => watchers.unwatch(name, handler);
if(shouldObserveResize)
player.addEventListener('resize', function() { setSize(player.videoWidth, player.videoHeight); });
player.addEventListener('play', function() { watchers.call('play', pub); });
player.addEventListener('play', function() { watchers.call('play'); });
const pPlay = function() { player.play(); };
pub.play = pPlay;
@ -280,7 +281,7 @@ const MszVideoEmbedPlayer = function(metadata, options) {
let stopCalled = false;
player.addEventListener('pause', function() {
watchers.call(stopCalled ? 'stop' : 'pause', pub);
watchers.call(stopCalled ? 'stop' : 'pause');
stopCalled = false;
});
@ -301,9 +302,9 @@ const MszVideoEmbedPlayer = function(metadata, options) {
player.addEventListener('volumechange', function() {
if(lastMuteState !== player.muted) {
lastMuteState = player.muted;
watchers.call('mute', pub, [lastMuteState]);
watchers.call('mute', lastMuteState);
} else
watchers.call('volume', pub, [player.volume]);
watchers.call('volume', player.volume);
});
const pSetMuted = function(state) { player.muted = state; };
@ -319,21 +320,21 @@ const MszVideoEmbedPlayer = function(metadata, options) {
pub.getPlaybackRate = pGetPlaybackRate;
player.addEventListener('ratechange', function() {
watchers.call('rate', pub, [player.playbackRate]);
watchers.call('rate', player.playbackRate);
});
const pSetPlaybackRate = function(rate) { player.playbackRate = rate; };
pub.setPlaybackRate = pSetPlaybackRate;
window.addEventListener('durationchange', function() {
watchers.call('duration', pub, [player.duration]);
watchers.call('duration', player.duration);
});
const pGetDuration = function() { return player.duration; };
pub.getDuration = pGetDuration;
window.addEventListener('timeupdate', function() {
watchers.call('time', pub, [player.currentTime]);
watchers.call('time', player.currentTime);
});
const pGetTime = function() { return player.currentTime; };
@ -374,7 +375,7 @@ const MszVideoEmbedYouTube = function(metadata, options) {
currentTime = undefined,
isPlaying = undefined;
const watchers = new MszWatcherCollection;
const watchers = new MszWatchers;
watchers.define(MszVideoEmbedPlayerEvents());
const player = $e({
@ -410,7 +411,8 @@ const MszVideoEmbedYouTube = function(metadata, options) {
getPlayerId: function() { return playerId; },
};
watchers.proxy(pub);
pub.watch = (name, handler) => watchers.watch(name, handler);
pub.unwatch = (name, handler) => watchers.unwatch(name, handler);
const postMessage = function(data) {
player.contentWindow.postMessage(JSON.stringify(data), ytOrigin);
@ -463,33 +465,33 @@ const MszVideoEmbedYouTube = function(metadata, options) {
lastPlayerState = state;
if(eventName !== undefined && eventName !== lastPlayerStateEvent) {
lastPlayerStateEvent = eventName;
watchers.call(eventName, pub);
watchers.call(eventName);
}
};
const handleMuted = function(muted) {
isMuted = muted;
watchers.call('mute', pub, [isMuted]);
watchers.call('mute', isMuted);
};
const handleVolume = function(value) {
volume = value / 100;
watchers.call('volume', pub, [volume]);
watchers.call('volume', volume);
};
const handleRate = function(rate) {
playbackRate = rate;
watchers.call('rate', pub, [playbackRate]);
watchers.call('rate', playbackRate);
};
const handleDuration = function(time) {
duration = time;
watchers.call('duration', pub, [duration]);
watchers.call('duration', duration);
};
const handleTime = function(time) {
currentTime = time;
watchers.call('time', pub, [currentTime]);
watchers.call('time', currentTime);
};
const handlePresetRates = function(rates) {
@ -574,7 +576,7 @@ const MszVideoEmbedNicoNico = function(metadata, options) {
currentTime = undefined,
isPlaying = false;
const watchers = new MszWatcherCollection;
const watchers = new MszWatchers;
watchers.define(MszVideoEmbedPlayerEvents());
const player = $e({
@ -610,7 +612,8 @@ const MszVideoEmbedNicoNico = function(metadata, options) {
getPlayerId: function() { return playerId; },
};
watchers.proxy(pub);
pub.watch = (name, handler) => watchers.watch(name, handler);
pub.unwatch = (name, handler) => watchers.unwatch(name, handler);
const postMessage = function(name, data) {
if(name === undefined)
@ -660,28 +663,28 @@ const MszVideoEmbedNicoNico = function(metadata, options) {
if(eventName !== undefined && eventName !== lastPlayerStateEvent) {
lastPlayerStateEvent = eventName;
watchers.call(eventName, pub);
watchers.call(eventName);
}
};
const handleMuted = function(muted) {
isMuted = muted;
watchers.call('mute', pub, [isMuted]);
watchers.call('mute', isMuted);
};
const handleVolume = function(value) {
volume = value;
watchers.call('volume', pub, [volume]);
watchers.call('volume', volume);
};
const handleDuration = function(time) {
duration = time / 1000;
watchers.call('duration', pub, [duration]);
watchers.call('duration', duration);
};
const handleTime = function(time) {
currentTime = time / 1000;
watchers.call('time', pub, [currentTime]);
watchers.call('time', currentTime);
};
const metadataHanders = {

View file

@ -1,35 +1,49 @@
#include utils.js
#include utility.js
Misuzu.Events.Christmas2019 = function() {
this.propName = propName = 'msz-christmas-' + (new Date).getFullYear().toString();
const MszChristmas2019EventInfo = function() {
return {
isActive: () => {
const d = new Date;
return d.getMonth() === 11 && d.getDate() > 5 && d.getDate() < 27;
},
dispatch: () => {
const impl = new MszChristmas2019Event;
impl.dispatch();
return impl;
},
};
};
Misuzu.Events.Christmas2019.prototype.changeColour = function() {
var count = parseInt(localStorage.getItem(this.propName));
document.body.style.setProperty('--header-accent-colour', (count++ % 2) ? 'green' : 'red');
localStorage.setItem(this.propName, count.toString());
};
Misuzu.Events.Christmas2019.prototype.isActive = function() {
var d = new Date;
return d.getMonth() === 11 && d.getDate() > 5 && d.getDate() < 27;
};
Misuzu.Events.Christmas2019.prototype.dispatch = function() {
var headerBg = $q('.header__background'),
menuBgs = $qa('.header__desktop__submenu__background');
if(!localStorage.getItem(this.propName))
localStorage.setItem(this.propName, '0');
if(headerBg)
headerBg.style.transition = 'background-color .4s';
setTimeout(function() {
if(headerBg)
headerBg.style.transition = 'background-color 1s';
for(var i = 0; i < menuBgs.length; i++)
menuBgs[i].style.transition = 'background-color 1s';
}, 1000);
this.changeColour();
setInterval(this.changeColour, 10000);
const MszChristmas2019Event = function() {
const propName = 'msz-christmas-' + (new Date).getFullYear().toString();
const headerBg = $q('.header__background');
const menuBgs = Array.from($qa('.header__desktop__submenu__background'));
if(!localStorage.getItem(propName))
localStorage.setItem(propName, '0');
const changeColour = () => {
let count = parseInt(localStorage.getItem(propName));
document.body.style.setProperty('--header-accent-colour', (count++ % 2) ? 'green' : 'red');
localStorage.setItem(propName, count.toString());
};
return {
changeColour: changeColour,
dispatch: () => {
if(headerBg)
headerBg.style.transition = 'background-color .4s';
setTimeout(() => {
if(headerBg)
headerBg.style.transition = 'background-color 1s';
for(const menuBg of menuBgs)
menuBg.style.transition = 'background-color 1s';
}, 1000);
changeColour();
setInterval(changeColour, 10000);
},
};
};

View file

@ -1,15 +1,15 @@
Misuzu.Events = {};
const MszSeasonalEvents = function() {
const events = [];
#include events/christmas2019.js
Misuzu.Events.getList = function() {
return [
new Misuzu.Events.Christmas2019,
];
};
Misuzu.Events.dispatch = function() {
var list = Misuzu.Events.getList();
for(var i = 0; i < list.length; ++i)
if(list[i].isActive())
list[i].dispatch();
return {
add: eventInfo => {
if(!events.includes(eventInfo))
events.push(eventInfo);
},
dispatch: () => {
for(const info of events)
if(info.isActive())
info.dispatch();
},
};
};

View file

@ -0,0 +1,37 @@
#include utility.js
const MszEEPROM = (() => {
let eepromScript;
return {
init: () => {
return new Promise((resolve, reject) => {
if(eepromScript !== undefined) {
resolve(false);
return;
}
if(typeof peepPath !== 'string') {
reject();
return;
}
const scriptElem = $e({
tag: 'script',
attrs: {
src: `${peepPath}/scripts/eepromv1a.js`,
charset: 'utf-8',
type: 'text/javascript',
onerror: () => reject(),
onload: () => {
eepromScript = scriptElem;
resolve(true);
},
},
});
document.body.appendChild(scriptElem);
});
},
};
})();

View file

@ -1,4 +1,4 @@
const Sakuya = (function() {
const MszSakuya = (function() {
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const divisions = [
{ amount: 60, name: 'seconds' },

View file

@ -0,0 +1,15 @@
#include utility.js
const MszUiharu = function(apiUrl) {
const maxBatchSize = 4;
const lookupOneUrl = apiUrl + '/metadata';
return {
lookupOne: async targetUrl => {
if(typeof targetUrl !== 'string')
throw 'targetUrl must be a string';
return $x.post(lookupOneUrl, { type: 'json' }, targetUrl);
},
};
};

View file

@ -1,437 +0,0 @@
#include forum/forum.js
Misuzu.Forum.Editor = {};
Misuzu.Forum.Editor.allowWindowClose = false;
Misuzu.Forum.Editor.init = function() {
const postingForm = $q('.js-forum-posting');
if(!postingForm)
return;
const postingButtons = postingForm.querySelector('.js-forum-posting-buttons'),
postingText = postingForm.querySelector('.js-forum-posting-text'),
postingParser = postingForm.querySelector('.js-forum-posting-parser'),
postingPreview = postingForm.querySelector('.js-forum-posting-preview'),
postingMode = postingForm.querySelector('.js-forum-posting-mode'),
previewButton = document.createElement('button'),
bbcodeButtons = $q('.forum__post__actions--bbcode'),
markdownButtons = $q('.forum__post__actions--markdown'),
markupButtons = $qa('.forum__post__action--tag');
// Initialise EEPROM, code sucks ass but it's getting nuked soon again anyway
if(typeof peepPath === 'string')
document.body.appendChild($e({
tag: 'script',
attrs: {
src: peepPath + '/eeprom.js',
charset: 'utf-8',
type: 'text/javascript',
onload: function() {
const eepromClient = new EEPROM(peepApp, peepPath + '/uploads', '');
const eepromHistory = $e({
attrs: {
className: 'eeprom-widget-history-items',
},
});
const eepromHandleFileUpload = function(file) {
const uploadElemNameValue = $e({
attrs: {
className: 'eeprom-widget-file-name-value',
title: file.name,
},
child: file.name,
});
const uploadElemName = $e({
tag: 'a',
attrs: {
className: 'eeprom-widget-file-name',
target: '_blank',
},
child: uploadElemNameValue,
});
const uploadElemProgressText = $e({
attrs: {
className: 'eeprom-widget-file-progress',
},
child: 'Please wait...',
});
const uploadElemProgressBarValue = $e({
attrs: {
className: 'eeprom-widget-file-bar-fill',
style: {
width: '0%',
},
},
});
const uploadElem = $e({
attrs: {
className: 'eeprom-widget-file',
},
child: [
{
attrs: {
className: 'eeprom-widget-file-info',
},
child: [
uploadElemName,
uploadElemProgressText,
],
},
{
attrs: {
className: 'eeprom-widget-file-bar',
},
child: uploadElemProgressBarValue,
},
],
});
if(eepromHistory.children.length > 0)
$ib(eepromHistory.firstChild, uploadElem);
else
eepromHistory.appendChild(uploadElem);
const explodeUploadElem = function() {
$r(uploadElem);
};
const uploadTask = eepromClient.createUpload(file);
uploadTask.onProgress = function(progressInfo) {
const progressValue = progressInfo.progress.toString() + '%';
uploadElemProgressBarValue.style.width = progressValue;
uploadElemProgressText.textContent = progressValue + ' (' + (progressInfo.total - progressInfo.loaded).toString() + ' bytes remaining)';
};
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 _t = ['bytes', 'KB', 'MB', 'GB', 'TB'],
_i = parseInt(Math.floor(Math.log(errorInfo.maxSize) / Math.log(1024))),
_s = Math.round(errorInfo.maxSize / Math.pow(1024, _i), 2);
errorText = 'Upload may not be larger than %1 %2.'.replace('%1', _s).replace('%2', _t[_i]);
}
break;
}
uploadElem.classList.add('eeprom-widget-file-fail');
uploadElemProgressText.textContent = errorText;
Misuzu.showMessageBox(errorText, 'Upload Error');
}
};
uploadTask.onComplete = function(fileInfo) {
uploadElem.classList.add('eeprom-widget-file-done');
uploadElemName.href = fileInfo.url;
uploadElemProgressText.textContent = '';
const insertTheLinkIntoTheBoxEx2 = function() {
const parserMode = parseInt(postingParser.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(postingText, insertText, '');
postingText.value = postingText.value.trim();
};
uploadElemProgressText.appendChild($e({
tag: 'a',
attrs: {
href: 'javascript:void(0);',
onclick: function() { insertTheLinkIntoTheBoxEx2(); },
},
child: 'Insert',
}));
uploadElemProgressText.appendChild($t(' '));
uploadElemProgressText.appendChild($e({
tag: 'a',
attrs: {
href: 'javascript:void(0);',
onclick: function() {
eepromClient.deleteUpload(fileInfo).start();
explodeUploadElem();
},
},
child: 'Delete',
}));
insertTheLinkIntoTheBoxEx2();
};
uploadTask.start();
};
const eepromFormInput = $e({
tag: 'input',
attrs: {
type: 'file',
multiple: 'multiple',
className: 'eeprom-widget-form-input',
onchange: function(ev) {
const files = this.files;
for(const file of files)
eepromHandleFileUpload(file);
this.value = '';
},
},
});
const eepromForm = $e({
tag: 'label',
attrs: {
className: 'eeprom-widget-form',
},
child: [
eepromFormInput,
{
attrs: {
className: 'eeprom-widget-form-text',
},
child: 'Select Files...',
}
],
});
const eepromWidget = $e({
attrs: {
className: 'eeprom-widget',
},
child: [
eepromForm,
{
attrs: {
className: 'eeprom-widget-history',
},
child: eepromHistory,
},
],
});
postingForm.appendChild(eepromWidget);
postingText.addEventListener('paste', function(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', function(ev) {
ev.preventDefault();
ev.stopPropagation();
});
document.body.addEventListener('dragover', function(ev) {
ev.preventDefault();
ev.stopPropagation();
});
document.body.addEventListener('dragleave', function(ev) {
ev.preventDefault();
ev.stopPropagation();
});
document.body.addEventListener('drop', function(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);
}
});
},
onerror: function(ev) {
console.error('Failed to initialise EEPROM: ', ev);
},
},
}));
// hack: don't prompt user when hitting submit, really need to make this not stupid.
postingButtons.firstElementChild.addEventListener('click', function() {
Misuzu.Forum.Editor.allowWindowClose = true;
});
window.addEventListener('beforeunload', function(ev) {
if(!Misuzu.Forum.Editor.allowWindowClose && postingText.value.length > 0) {
ev.preventDefault();
ev.returnValue = '';
}
});
for(var i = 0; i < markupButtons.length; ++i)
(function(currentBtn) {
currentBtn.addEventListener('click', function(ev) {
$insertTags(postingText, currentBtn.dataset.tagOpen, currentBtn.dataset.tagClose);
});
})(markupButtons[i]);
Misuzu.Forum.Editor.switchButtons(parseInt(postingParser.value));
var lastPostText = '',
lastPostParser = null;
postingParser.addEventListener('change', function() {
var postParser = parseInt(postingParser.value);
Misuzu.Forum.Editor.switchButtons(postParser);
if(postingPreview.hasAttribute('hidden'))
return;
// dunno if this would even be possible, but ech
if(postParser === lastPostParser)
return;
postingParser.setAttribute('disabled', 'disabled');
previewButton.setAttribute('disabled', 'disabled');
previewButton.classList.add('input__button--busy');
Misuzu.Forum.Editor.renderPreview(postParser, lastPostText, function(success, text) {
if(!success) {
Misuzu.showMessageBox(text);
return;
}
postingPreview.classList[postParser == 2 ? 'add' : 'remove']('markdown');
lastPostParser = postParser;
postingPreview.innerHTML = text;
MszEmbed.handle($qa('.js-msz-embed-media'));
previewButton.removeAttribute('disabled');
postingParser.removeAttribute('disabled');
previewButton.classList.remove('input__button--busy');
});
});
previewButton.className = 'input__button';
previewButton.textContent = 'Preview';
previewButton.type = 'button';
previewButton.value = 'preview';
previewButton.addEventListener('click', function() {
if(previewButton.value === 'back') {
postingPreview.setAttribute('hidden', 'hidden');
postingText.removeAttribute('hidden');
previewButton.value = 'preview';
previewButton.textContent = 'Preview';
postingMode.textContent = postingMode.dataset.original;
postingMode.dataset.original = null;
} else {
var postText = postingText.value,
postParser = parseInt(postingParser.value);
if(lastPostText === postText && lastPostParser === postParser) {
postingPreview.removeAttribute('hidden');
postingText.setAttribute('hidden', 'hidden');
previewButton.value = 'back';
previewButton.textContent = 'Edit';
postingMode.dataset.original = postingMode.textContent;
postingMode.textContent = 'Previewing';
return;
}
postingParser.setAttribute('disabled', 'disabled');
previewButton.setAttribute('disabled', 'disabled');
previewButton.classList.add('input__button--busy');
Misuzu.Forum.Editor.renderPreview(postParser, postText, function(success, text) {
if(!success) {
Misuzu.showMessageBox(text);
return;
}
postingPreview.classList[postParser == 2 ? 'add' : 'remove']('markdown');
lastPostText = postText;
lastPostParser = postParser;
postingPreview.innerHTML = text;
MszEmbed.handle($qa('.js-msz-embed-media'));
postingPreview.removeAttribute('hidden');
postingText.setAttribute('hidden', 'hidden');
previewButton.value = 'back';
previewButton.textContent = 'Back';
previewButton.removeAttribute('disabled');
postingParser.removeAttribute('disabled');
previewButton.classList.remove('input__button--busy');
postingMode.dataset.original = postingMode.textContent;
postingMode.textContent = 'Previewing';
});
}
});
postingButtons.insertBefore(previewButton, postingButtons.firstChild);
};
Misuzu.Forum.Editor.switchButtons = function(parser) {
var bbcodeButtons = $q('.forum__post__actions--bbcode'),
markdownButtons = $q('.forum__post__actions--markdown');
bbcodeButtons.hidden = parser != 1;
markdownButtons.hidden = parser != 2;
};
Misuzu.Forum.Editor.renderPreview = function(parser, text, callback) {
if(!callback)
return;
parser = parseInt(parser);
text = text || '';
var xhr = new XMLHttpRequest,
formData = new FormData;
formData.append('post[mode]', 'preview');
formData.append('post[text]', text);
formData.append('post[parser]', parser.toString());
xhr.addEventListener('readystatechange', function() {
if(xhr.readyState !== XMLHttpRequest.DONE)
return;
if(xhr.status === 200)
callback(true, xhr.response);
else
callback(false, 'Failed to render preview.');
});
// need to figure out a url registry system again, current one is too much overhead so lets just do this for now
xhr.open('POST', '/forum/posting.php');
xhr.withCredentials = true;
xhr.send(formData);
};

View file

@ -0,0 +1,291 @@
#include msgbox.jsx
#include parsing.js
#include utility.js
#include ext/eeprom.js
let MszForumEditorAllowClose = false;
const MszForumEditor = function(form) {
if(!(form instanceof Element))
throw 'form must be an instance of element';
const buttonsElem = form.querySelector('.js-forum-posting-buttons'),
textElem = form.querySelector('.js-forum-posting-text'),
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');
let lastPostText = '',
lastPostParser;
MszEEPROM.init()
.catch(() => console.error('Failed to initialise EEPROM'))
.then(() => {
const eepromClient = new EEPROM(peepApp, peepPath);
const eepromHistory = <div class="eeprom-widget-history-items"/>;
const eepromHandleFileUpload = async 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>;
const uploadElemProgressBarValue = <div class="eeprom-widget-file-bar-fill" style={{ width: '0%' }}/>;
const uploadElem = <div class="eeprom-widget-file">
<div class="eeprom-widget-file-info">
{uploadElemName}
{uploadElemProgressText}
</div>
<div class="eeprom-widget-file-bar">
{uploadElemProgressBarValue}
</div>
</div>;
if(eepromHistory.children.length > 0)
$ib(eepromHistory.firstChild, uploadElem);
else
eepromHistory.appendChild(uploadElem);
const explodeUploadElem = () => $r(uploadElem);
const uploadTask = eepromClient.create(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)`;
});
try {
const fileInfo = await uploadTask.start();
uploadElem.classList.add('eeprom-widget-file-done');
uploadElemName.href = fileInfo.url;
uploadElemProgressText.textContent = '';
const insertTheLinkIntoTheBoxEx2 = () => {
const parserMode = parseInt(parserElem.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(textElem, insertText, '');
textElem.value = textElem.value.trim();
};
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');
});
}}>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');
}
};
const eepromFormInput = <input type="file" multiple={true} class="eeprom-widget-form-input"
onchange={() => {
const files = eepromFormInput.files;
for(const file of files)
eepromHandleFileUpload(file);
eepromFormInput.value = '';
}}/>;
const eepromForm = <label class="eeprom-widget-form">
{eepromFormInput}
<div class="eeprom-widget-form-text">
Select Files...
</div>
</label>;
const eepromWidget = <div class="eeprom-widget">
{eepromForm}
<div class="eeprom-widget-history">
{eepromHistory}
</div>
</div>;
form.appendChild(eepromWidget);
textElem.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);
}
});
});
// hack: don't prompt user when hitting submit, really need to make this not stupid.
buttonsElem.firstChild.addEventListener('click', () => MszForumEditorAllowClose = true);
window.addEventListener('beforeunload', function(ev) {
if(!MszForumEditorAllowClose && textElem.value.length > 0) {
ev.preventDefault();
ev.returnValue = '';
}
});
const switchButtons = parser => {
$rc(markupActs);
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 renderPreview = async (parser, text) => {
if(typeof text !== 'string')
return '';
const formData = new FormData;
formData.append('post[mode]', 'preview');
formData.append('post[text]', text);
formData.append('post[parser]', parseInt(parser));
const result = await $x.post('/forum/posting.php', { authed: true }, formData);
return result.body();
};
const previewBtn = <button class="input__button" type="button" value="preview">Preview</button>;
previewBtn.addEventListener('click', function() {
if(previewBtn.value === 'back') {
previewElem.setAttribute('hidden', 'hidden');
textElem.removeAttribute('hidden');
previewBtn.value = 'preview';
previewBtn.textContent = 'Preview';
modeElem.textContent = modeElem.dataset.original;
modeElem.dataset.original = null;
} else {
const postText = textElem.value,
postParser = parseInt(parserElem.value);
if(lastPostText === postText && lastPostParser === postParser) {
previewElem.removeAttribute('hidden');
textElem.setAttribute('hidden', 'hidden');
previewBtn.value = 'back';
previewBtn.textContent = 'Edit';
modeElem.dataset.original = modeElem.textContent;
modeElem.textContent = 'Previewing';
return;
}
parserElem.setAttribute('disabled', 'disabled');
previewBtn.setAttribute('disabled', 'disabled');
previewBtn.classList.add('input__button--busy');
renderPreview(postParser, postText)
.catch(() => {
previewElem.innerHTML = '';
MszShowMessageBox('Failed to render preview.');
})
.then(body => {
previewElem.classList.toggle('markdown', postParser === 2);
lastPostText = postText;
lastPostParser = postParser;
previewElem.innerHTML = body;
MszEmbed.handle($qa('.js-msz-embed-media'));
previewElem.removeAttribute('hidden');
textElem.setAttribute('hidden', 'hidden');
previewBtn.value = 'back';
previewBtn.textContent = 'Back';
previewBtn.removeAttribute('disabled');
parserElem.removeAttribute('disabled');
previewBtn.classList.remove('input__button--busy');
modeElem.dataset.original = modeElem.textContent;
modeElem.textContent = 'Previewing';
});
}
});
buttonsElem.insertBefore(previewBtn, buttonsElem.firstChild);
switchButtons(parserElem.value);
parserElem.addEventListener('change', () => {
const postParser = parseInt(parserElem.value);
switchButtons(postParser);
if(previewElem.hasAttribute('hidden'))
return;
// dunno if this would even be possible, but ech
if(postParser === lastPostParser)
return;
parserElem.setAttribute('disabled', 'disabled');
previewBtn.setAttribute('disabled', 'disabled');
previewBtn.classList.add('input__button--busy');
renderPreview(postParser, lastPostText)
.catch(() => {
previewElem.innerHTML = '';
MszShowMessageBox('Failed to render preview.');
})
.then(body => {
previewElem.classList.add('markdown', postParser === 2);
lastPostParser = postParser;
previewElem.innerHTML = body;
MszEmbed.handle($qa('.js-msz-embed-media'));
previewBtn.removeAttribute('disabled');
parserElem.removeAttribute('disabled');
previewBtn.classList.remove('input__button--busy');
});
});
};

View file

@ -1 +0,0 @@
Misuzu.Forum = {};

View file

@ -1,135 +1,90 @@
#include sakuya.js
var Misuzu = function() {
Sakuya.trackElements($qa('time'));
hljs.initHighlighting();
MszEmbed.init(location.protocol + '//uiharu.' + location.host);
Misuzu.initQuickSubmit(); // only used by the forum posting form
Misuzu.Forum.Editor.init();
Misuzu.Events.dispatch();
Misuzu.initLoginPage();
MszEmbed.handle($qa('.js-msz-embed-media'));
};
#include utils.js
#include embed.js
#include forum/editor.js
#include utility.js
#include embed/embed.js
#include events/christmas2019.js
#include events/events.js
#include ext/sakuya.js
#include forum/editor.jsx
#include messages/messages.js
Misuzu.showMessageBox = function(text, title, buttons) {
if($q('.messagebox'))
return false;
(async () => {
const initLoginPage = async () => {
const forms = Array.from($qa('.js-login-form'));
if(forms.length < 1)
return;
text = text || '';
title = title || '';
buttons = buttons || [];
var element = document.createElement('div');
element.className = 'messagebox';
var container = element.appendChild(document.createElement('div'));
container.className = 'container messagebox__container';
var titleElement = container.appendChild(document.createElement('div')),
titleBackground = titleElement.appendChild(document.createElement('div')),
titleText = titleElement.appendChild(document.createElement('div'));
titleElement.className = 'container__title';
titleBackground.className = 'container__title__background';
titleText.className = 'container__title__text';
titleText.textContent = title || 'Information';
var textElement = container.appendChild(document.createElement('div'));
textElement.className = 'container__content';
textElement.textContent = text;
var buttonsContainer = container.appendChild(document.createElement('div'));
buttonsContainer.className = 'messagebox__buttons';
var firstButton = null;
if(buttons.length < 1) {
firstButton = buttonsContainer.appendChild(document.createElement('button'));
firstButton.className = 'input__button';
firstButton.textContent = 'OK';
firstButton.addEventListener('click', function() { element.remove(); });
} else {
for(var i = 0; i < buttons.length; i++) {
var button = buttonsContainer.appendChild(document.createElement('button'));
button.className = 'input__button';
button.textContent = buttons[i].text;
button.addEventListener('click', function() {
element.remove();
buttons[i].callback();
});
if(firstButton === null)
firstButton = button;
}
}
document.body.appendChild(element);
firstButton.focus();
return true;
};
Misuzu.initLoginPage = function() {
var updateForm = function(avatarElem, usernameElem) {
var xhr = new XMLHttpRequest;
xhr.addEventListener('readystatechange', function() {
if(xhr.readyState !== 4)
const updateForm = async (avatar, userName) => {
if(!(avatar instanceof Element) || !(userName instanceof Element))
return;
var json = JSON.parse(xhr.responseText);
if(!json)
return;
const result = (await $x.get(`/auth/login.php?resolve=1&name=${encodeURIComponent(userName.value)}`, { type: 'json' })).body();
if(json.name)
usernameElem.value = json.name;
avatarElem.src = json.avatar;
});
// need to figure out a url registry system again, current one is too much overhead so lets just do this for now
xhr.open('GET', '/auth/login.php?resolve=1&name=' + encodeURIComponent(usernameElem.value));
xhr.send();
};
avatar.src = result.avatar;
if(result.name.length > 0)
userName.value = result.name;
};
var loginForms = $c('js-login-form');
for(const form of forms) {
const avatar = form.querySelector('.js-login-avatar');
const userName = form.querySelector('.js-login-username');
let timeOut;
for(var i = 0; i < loginForms.length; ++i)
(function(form) {
var loginTimeOut = 0,
loginAvatar = form.querySelector('.js-login-avatar'),
loginUsername = form.querySelector('.js-login-username');
await updateForm(avatar, userName);
updateForm(loginAvatar, loginUsername);
loginUsername.addEventListener('keyup', function() {
if(loginTimeOut)
userName.addEventListener('input', function() {
if(timeOut !== undefined)
return;
loginTimeOut = setTimeout(function() {
updateForm(loginAvatar, loginUsername);
clearTimeout(loginTimeOut);
loginTimeOut = 0;
timeOut = setTimeout(() => {
updateForm(avatar, userName)
.finally(() => {
clearTimeout(timeOut);
timeOut = undefined;
});
}, 750);
});
})(loginForms[i]);
};
Misuzu.initQuickSubmit = function() {
var ctrlSubmit = Array.from($qa('.js-quick-submit, .js-ctrl-enter-submit'));
if(!ctrlSubmit)
return;
}
};
for(var i = 0; i < ctrlSubmit.length; ++i)
ctrlSubmit[i].addEventListener('keydown', function(ev) {
if((ev.code === 'Enter' || ev.code === 'NumpadEnter') // i hate this fucking language so much
&& ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
// hack: prevent forum editor from screaming when using this keycombo
// can probably be done in a less stupid manner
Misuzu.Forum.Editor.allowWindowClose = true;
const initQuickSubmit = () => {
const elems = Array.from($qa('.js-quick-submit, .js-ctrl-enter-submit'));
if(elems.length < 1)
return;
this.form.submit();
ev.preventDefault();
}
});
};
for(const elem of elems)
elem.addEventListener('keydown', ev => {
if((ev.code === 'Enter' || ev.code === 'NumpadEnter') && ev.ctrlKey && !ev.altKey && !ev.shiftKey && !ev.metaKey) {
// hack: prevent forum editor from screaming when using this keycombo
// can probably be done in a less stupid manner
MszForumEditorAllowClose = true;
elem.form.submit();
ev.preventDefault();
}
});
};
try {
MszSakuya.trackElements($qa('time'));
hljs.highlightAll();
MszEmbed.init(`${location.protocol}//uiharu.${location.host}`);
// only used by the forum posting form
initQuickSubmit();
const forumPostingForm = $q('.js-forum-posting');
if(forumPostingForm !== null)
MszForumEditor(forumPostingForm);
const events = new MszSeasonalEvents;
events.add(new MszChristmas2019EventInfo);
events.dispatch();
await initLoginPage();
MszMessages();
MszEmbed.handle($qa('.js-msz-embed-media'));
} catch(ex) {
console.error(ex);
}
})();

View file

@ -0,0 +1,89 @@
#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

@ -0,0 +1,167 @@
#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

@ -0,0 +1,386 @@
#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

@ -0,0 +1,56 @@
#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

@ -0,0 +1,171 @@
#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

@ -0,0 +1,78 @@
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

@ -0,0 +1,73 @@
#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';
}
if(!(target instanceof Element))
target = document.body;
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"/>}
</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();
if(typeof button.callback === 'function')
button.callback().finally(() => resolve());
else
resolve();
}}>{button.text}</button>;
buttonsElem.appendChild(buttonElem);
if(firstButton === undefined)
firstButton = buttonElem;
}
}
target.appendChild(html);
firstButton.focus();
});
};

View file

@ -0,0 +1,56 @@
// 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

@ -1,58 +0,0 @@
const Uiharu = function(apiUrl) {
const maxBatchSize = 4;
const lookupOneUrl = apiUrl + '/metadata',
lookupManyUrl = apiUrl + '/metadata/batch';
const lookupManyInternal = function(targetUrls, callback) {
const formData = new FormData;
for(const url of targetUrls)
formData.append('url[]', url);
const xhr = new XMLHttpRequest;
xhr.addEventListener('load', function() {
callback(JSON.parse(xhr.responseText));
});
xhr.addEventListener('error', function(ev) {
callback({ status: xhr.status, error: 'xhr', details: ev });
});
xhr.open('POST', lookupManyUrl);
xhr.send(formData);
};
return {
lookupOne: function(targetUrl, callback) {
if(typeof callback !== 'function')
throw 'callback is missing';
targetUrl = (targetUrl || '').toString();
if(targetUrl.length < 1)
return;
const xhr = new XMLHttpRequest;
xhr.addEventListener('load', function() {
callback(JSON.parse(xhr.responseText));
});
xhr.addEventListener('error', function() {
callback({ status: xhr.status, error: 'xhr', details: ex });
});
xhr.open('POST', lookupOneUrl);
xhr.send(targetUrl);
},
lookupMany: function(targetUrls, callback) {
if(!Array.isArray(targetUrls))
throw 'targetUrls must be an array of urls';
if(typeof callback !== 'function')
throw 'callback is missing';
if(targetUrls < 1)
return;
if(targetUrls.length <= maxBatchSize) {
lookupManyInternal(targetUrls, callback);
return;
}
for(let i = 0; i < targetUrls.length; i += maxBatchSize)
lookupManyInternal(targetUrls.slice(i, i + maxBatchSize), callback);
},
};
};

View file

@ -13,6 +13,10 @@ const $ri = function(name) {
$r($i(name));
};
const $rq = function(query) {
$r($q(query));
};
const $ib = function(ref, elem) {
ref.parentNode.insertBefore(elem, ref);
};
@ -79,6 +83,11 @@ const $e = function(info, attrs, child, created) {
}
break;
case 'boolean':
if(attr)
elem.setAttribute(key, '');
break;
default:
if(key === 'className')
key = 'class';
@ -153,17 +162,125 @@ const $as = function(array) {
}
};
var $insertTags = function(target, tagOpen, tagClose) {
const $x = (function() {
const send = function(method, url, options, body) {
if(options === undefined)
options = {};
else if(typeof options !== 'object')
throw 'options must be undefined or an object';
const xhr = new XMLHttpRequest;
const requestHeaders = new Map;
if('headers' in options && typeof options.headers === 'object')
for(const name in options.headers)
if(options.headers.hasOwnProperty(name))
requestHeaders.set(name.toLowerCase(), options.headers[name]);
if(typeof options.download === 'function') {
xhr.onloadstart = ev => options.download(ev);
xhr.onprogress = ev => options.download(ev);
xhr.onloadend = ev => options.download(ev);
}
if(typeof options.upload === 'function') {
xhr.upload.onloadstart = ev => options.upload(ev);
xhr.upload.onprogress = ev => options.upload(ev);
xhr.upload.onloadend = ev => options.upload(ev);
}
if(options.authed)
xhr.withCredentials = true;
if(typeof options.timeout === 'number')
xhr.timeout = options.timeout;
if(typeof options.type === 'string')
xhr.responseType = options.type;
if(typeof options.abort === 'function')
options.abort(() => xhr.abort());
if(typeof options.xhr === 'function')
options.xhr(() => xhr);
if(typeof body === 'object') {
if(body instanceof URLSearchParams) {
requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
} else if(body instanceof FormData) {
// content-type is implicitly set
} else if(body instanceof Blob || body instanceof ArrayBuffer || body instanceof DataView) {
if(!requestHeaders.has('content-type'))
requestHeaders.set('content-type', 'application/octet-stream');
} else if(!requestHeaders.has('content-type')) {
const bodyParts = [];
for(const name in body)
if(body.hasOwnProperty(name))
bodyParts.push(encodeURIComponent(name) + '=' + encodeURIComponent(body[name]));
body = bodyParts.join('&');
requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
}
}
return new Promise((resolve, reject) => {
let responseHeaders = undefined;
xhr.onload = ev => resolve({
status: xhr.status,
body: () => xhr.response,
text: () => xhr.responseText,
headers: () => {
if(responseHeaders !== undefined)
return responseHeaders;
responseHeaders = new Map;
const raw = xhr.getAllResponseHeaders().trim().split(/[\r\n]+/);
for(const name in raw)
if(raw.hasOwnProperty(name)) {
const parts = raw[name].split(': ');
responseHeaders.set(parts.shift(), parts.join(': '));
}
return responseHeaders;
},
xhr: xhr,
ev: ev,
});
xhr.onerror = ev => reject({
xhr: xhr,
ev: ev,
});
xhr.open(method, url);
for(const [name, value] of requestHeaders)
xhr.setRequestHeader(name, value);
xhr.send(body);
});
};
return {
send: send,
get: (url, options, body) => send('GET', url, options, body),
post: (url, options, body) => send('POST', url, options, body),
delete: (url, options, body) => send('DELETE', url, options, body),
patch: (url, options, body) => send('PATCH', url, options, body),
put: (url, options, body) => send('PUT', url, options, body),
};
})();
const $insertTags = function(target, tagOpen, tagClose) {
tagOpen = tagOpen || '';
tagClose = tagClose || '';
if(document.selection) {
target.focus();
var selected = document.selection.createRange();
const selected = document.selection.createRange();
selected.text = tagOpen + selected.text + tagClose;
target.focus();
} else if(target.selectionStart || target.selectionStart === 0) {
var startPos = target.selectionStart,
const startPos = target.selectionStart,
endPos = target.selectionEnd,
scrollTop = target.scrollTop;
@ -176,7 +293,7 @@ var $insertTags = function(target, tagOpen, tagClose) {
target.focus();
target.selectionStart = startPos + tagOpen.length;
target.selectionEnd = endPos + tagOpen.length;
target.scrollTop + scrollTop;
target.scrollTop = scrollTop;
} else {
target.value += tagOpen + tagClose;
target.focus();

View file

@ -1,83 +1,65 @@
const MszWatcher = function() {
let watchers = [];
const handlers = [];
return {
watch: function(watcher, thisArg, args) {
if(typeof watcher !== 'function')
throw 'watcher must be a function';
if(watchers.indexOf(watcher) >= 0)
return;
const watch = (handler, ...args) => {
if(typeof handler !== 'function')
throw 'handler must be a function';
if(handlers.includes(handler))
throw 'handler already registered';
watchers.push(watcher);
if(thisArg !== undefined) {
if(!Array.isArray(args)) {
if(args !== undefined)
args = [args];
else args = [];
}
// initial call
args.push(true);
watcher.apply(thisArg, args);
}
},
unwatch: function(watcher) {
$ari(watchers, watcher);
},
call: function(thisArg, args) {
if(!Array.isArray(args)) {
if(args !== undefined)
args = [args];
else args = [];
}
args.push(false);
for(const watcher of watchers)
watcher.apply(thisArg, args);
},
};
};
const MszWatcherCollection = function() {
const collection = new Map;
const watch = function(name, watcher, thisArg, args) {
const watchers = collection.get(name);
if(watchers === undefined)
throw 'undefined watcher name';
watchers.watch(watcher, thisArg, args);
handlers.push(handler);
args.push(true);
handler(...args);
};
const unwatch = function(name, watcher) {
const watchers = collection.get(name);
if(watchers === undefined)
throw 'undefined watcher name';
watchers.unwatch(watcher);
const unwatch = handler => {
$ari(handlers, handler);
};
return {
define: function(names) {
if(!Array.isArray(names))
names = [names];
for(const name of names)
collection.set(name, new MszWatcher);
},
call: function(name, thisArg, args) {
const watchers = collection.get(name);
if(watchers === undefined)
throw 'undefined watcher name';
watchers.call(thisArg, args);
},
watch: watch,
unwatch: unwatch,
proxy: function(obj) {
obj.watch = function(name, watcher) {
watch(name, watcher);
};
obj.unwatch = unwatch;
call: (...args) => {
args.push(false);
for(const handler of handlers)
handler(...args);
},
};
};
const MszWatchers = function() {
const watchers = new Map;
const getWatcher = name => {
const watcher = watchers.get(name);
if(watcher === undefined)
throw 'undefined watcher name';
return watcher;
};
const watch = (name, handler, ...args) => {
getWatcher(name).watch(handler, ...args);
};
const unwatch = (name, handler) => {
getWatcher(name).unwatch(handler);
};
return {
watch: watch,
unwatch: unwatch,
define: names => {
if(typeof names === 'string')
watchers.set(names, new MszWatcher);
else if(Array.isArray(names))
for(const name of names)
watchers.set(name, new MszWatcher);
else
throw 'names must be an array of names or a single name';
},
call: (name, ...args) => {
getWatcher(name).call(...args);
},
};
};

View file

@ -1,5 +1,9 @@
const crypto = require('crypto');
exports.strtr = (str, replacements) => str.toString().replace(
/{([^}]+)}/g, (match, key) => replacements[key] || match
);
const trim = function(str, chars, flags) {
if(chars === undefined)
chars = " \n\r\t\v\0";

105
build.js
View file

@ -1,89 +1,30 @@
const assproc = require('@railcomm/assproc');
const { join: pathJoin } = require('path');
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');
const rootDir = __dirname;
const modulesDir = path.join(rootDir, 'node_modules');
const assetsDir = path.join(rootDir, 'assets');
const assetsCSS = path.join(assetsDir, 'misuzu.css');
const assetsJS = path.join(assetsDir, 'misuzu.js');
const assetsInfo = path.join(assetsDir, 'current.json');
const pubDir = path.join(rootDir, 'public');
const pubIndex = path.join(pubDir, 'index.html');
const pubAssets = '/assets';
const pubAssetsFull = path.join(pubDir, pubAssets);
const pubAssetCSSFormat = '%s-%s.css';
const pubAssetJSFormat = '%s-%s.js';
const isDebugBuild = fs.existsSync(path.join(rootDir, '.debug'));
const swcJscOptions = {
target: 'es2016',
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 postcssPlugins = [];
if(!isDebugBuild) postcssPlugins.push(require('cssnano'));
postcssPlugins.push(require('autoprefixer')({
remove: false,
}));
fs.mkdirSync(pubAssetsFull, { recursive: true });
(async () => {
const mszCssName = await assproc.process(assetsCSS, { 'prefix': '@', 'entry': 'main.css' })
.then(output => postcss(postcssPlugins).process(output, { from: assetsCSS }).then(output => {
const mszCssName = path.join(pubAssets, util.format(pubAssetCSSFormat, 'misuzu', utils.shortHash(output.css)));
fs.writeFileSync(path.join(pubDir, mszCssName), output.css);
return mszCssName;
}));
const isDebug = fs.existsSync(pathJoin(__dirname, '.debug'));
const mszJsName = await assproc.process(assetsJS, { 'prefix': '#', 'entry': 'main.js' })
.then(output => swc.transform(output, {
filename: 'misuzu.js',
sourceMaps: false,
isModule: false,
minify: !isDebugBuild,
jsc: swcJscOptions,
}).then(async output => {
const mszJsName = path.join(pubAssets, util.format(pubAssetJSFormat, 'misuzu', utils.shortHash(output.code)));
fs.writeFileSync(path.join(pubDir, mszJsName), output.code);
return mszJsName;
}));
const env = {
root: __dirname,
source: pathJoin(__dirname, 'assets'),
public: pathJoin(__dirname, 'public'),
debug: isDebug,
swc: {
es: 'es2021',
},
};
fs.writeFileSync(assetsInfo, JSON.stringify({
mszjs: mszJsName,
mszcss: mszCssName,
}));
const tasks = {
js: [
{ source: 'misuzu.js', target: '/assets', name: 'misuzu.{hash}.js', },
],
css: [
{ source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', },
],
};
assproc.housekeep(pubAssetsFull);
const files = await assproc.process(env, tasks);
fs.writeFileSync(pathJoin(__dirname, 'assets/current.json'), JSON.stringify(files));
})();

View file

@ -1,14 +1,13 @@
{
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"flashwave/index": "dev-master",
"twig/twig": "^3.0",
"flashwave/index": "^0.2410",
"flashii/rpcii": "^2.0",
"erusev/parsedown": "~1.6",
"chillerlan/php-qrcode": "^4.3",
"symfony/mailer": "^6.0",
"matomo/device-detector": "^6.1",
"wikimedia/composer-merge-plugin": "^2.1"
"sentry/sdk": "^4.0",
"nesbot/carbon": "^3.7"
},
"autoload": {
"classmap": [
@ -28,16 +27,11 @@
"preferred-install": "dist",
"allow-plugins": {
"composer/installers": true,
"wikimedia/composer-merge-plugin": true
"wikimedia/composer-merge-plugin": false,
"php-http/discovery": true
}
},
"extra": {
"merge-plugin": {
"include": [
"composer.local.json"
],
"recurse": true,
"replace": true
}
"require-dev": {
"phpstan/phpstan": "^1.11"
}
}

1767
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
# Example configuration for Misuzu
# and ; can be used at the start of a line for comments.
database:dsn mariadb://<user>:<pass>@<host>/<name>?charset=utf8mb4
;sentry:dsn https://sentry dsn here
;sentry:tracesRate 1.0
;sentry:profilesRate 1.0

View file

@ -1,10 +0,0 @@
; Example configuration for Misuzu
[Database]
driver = mysql
host = localhost
port = 3306
username = username
password = password
dbname = database
charset = utf8mb4

View file

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

View file

@ -1,9 +1,9 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class CreateTopicRedirsTable_20230430_001226 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
final class CreateTopicRedirsTable_20230430_001226 implements DbMigration {
public function migrate(DbConnection $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\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
use Misuzu\ClientInfo;
final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
final class UpdateUserAgentStorage_20230721_121854 implements DbMigration {
public function migrate(DbConnection $conn): void {
// convert user agent fields to BLOB and add field for client info storage
$conn->execute('
ALTER TABLE msz_login_attempts
@ -23,7 +23,7 @@ final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
while($selectLoginAttempts->next()) {
$updateLoginAttempts->reset();
$userAgent = $selectLoginAttempts->getString(0);
$updateLoginAttempts->addParameter(1, ClientInfo::parse($userAgent)->encode());
$updateLoginAttempts->addParameter(1, json_encode(ClientInfo::parse($userAgent)));
$updateLoginAttempts->addParameter(2, $userAgent);
$updateLoginAttempts->execute();
}
@ -33,7 +33,7 @@ final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
while($selectSessions->next()) {
$updateSessions->reset();
$userAgent = $selectSessions->getString(0);
$updateSessions->addParameter(1, ClientInfo::parse($userAgent)->encode());
$updateSessions->addParameter(1, json_encode(ClientInfo::parse($userAgent)));
$updateSessions->addParameter(2, $userAgent);
$updateSessions->execute();
}

View file

@ -0,0 +1,46 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class AddModeratorNotesTable_20230724_201010 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_modnotes (
note_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT(10) UNSIGNED NOT NULL,
author_id INT(10) UNSIGNED NULL DEFAULT NULL,
note_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
note_title VARCHAR(255) NOT NULL,
note_body TEXT NOT NULL,
PRIMARY KEY (note_id),
KEY users_modnotes_user_foreign (user_id),
KEY users_modnotes_author_foreign (author_id),
KEY users_modnotes_created_index (note_created),
CONSTRAINT users_modnotes_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT users_modnotes_author_foreign
FOREIGN KEY (author_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE SET NULL
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
// migrate existing notes
$conn->execute('
INSERT INTO msz_users_modnotes (user_id, author_id, note_created, note_title, note_body)
SELECT user_id, issuer_id, warning_created, warning_note, COALESCE(warning_note_private, "")
FROM msz_user_warnings
WHERE warning_type = 0
');
// delete notes from the warnings table
$conn->execute('DELETE FROM msz_user_warnings WHERE warning_type = 0');
// for good measure update silences to bans since i forgot to do that as a migration
$conn->execute('UPDATE msz_user_warnings SET warning_type = 3 WHERE warning_type = 2');
}
}

View file

@ -0,0 +1,45 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class AddNewBansTable_20230726_175936 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_bans (
ban_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT(10) UNSIGNED NOT NULL,
mod_id INT(10) UNSIGNED NULL DEFAULT NULL,
ban_severity TINYINT(4) NOT NULL,
ban_reason_public TEXT NOT NULL,
ban_reason_private TEXT NOT NULL,
ban_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
ban_expires TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (ban_id),
KEY users_bans_user_foreign (user_id),
KEY users_bans_mod_foreign (mod_id),
KEY users_bans_created_index (ban_created),
KEY users_bans_expires_index (ban_expires),
KEY users_bans_severity_index (ban_severity),
CONSTRAINT users_bans_users_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT users_bans_mod_foreign
FOREIGN KEY (mod_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE SET NULL
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
INSERT INTO msz_users_bans (user_id, mod_id, ban_severity, ban_reason_public, ban_reason_private, ban_created, ban_expires)
SELECT user_id, issuer_id, 0, warning_note, COALESCE(warning_note_private, ""), warning_created, warning_duration
FROM msz_user_warnings
WHERE warning_type = 3
');
$conn->execute('DELETE FROM msz_user_warnings WHERE warning_type = 3');
}
}

View file

@ -0,0 +1,43 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class RedoWarningsTable_20230726_210150 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_warnings (
warn_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT(10) UNSIGNED NOT NULL,
mod_id INT(10) UNSIGNED NULL DEFAULT NULL,
warn_body TEXT NOT NULL,
warn_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (warn_id),
KEY users_warnings_user_foreign (user_id),
KEY users_warnings_mod_foreign (mod_id),
KEY users_warnings_created_index (warn_created),
CONSTRAINT users_warnings_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT users_warnings_mod_foreign
FOREIGN KEY (mod_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE SET NULL
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
// migrate existing warnings, public and private note have been merged but that's fine in prod
// still specifying type = 1 as well even though that should be the only type remaining
$conn->execute('
INSERT INTO msz_users_warnings (user_id, mod_id, warn_body, warn_created)
SELECT user_id, issuer_id, TRIM(CONCAT(COALESCE(warning_note, ""), "\n", COALESCE(warning_note_private, ""))), warning_created
FROM msz_user_warnings
WHERE warning_type = 1
');
// drop the old table with non-plural "user"
$conn->execute('DROP TABLE msz_user_warnings');
}
}

View file

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

View file

@ -0,0 +1,16 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class CreateCountersTable_20230728_212101 implements DbMigration {
public function migrate(DbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_counters (
counter_name VARBINARY(64) NOT NULL,
counter_value BIGINT(20) NOT NULL DEFAULT "0",
counter_updated TIMESTAMP NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (counter_name)
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
}
}

View file

@ -0,0 +1,162 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class UpdateCollationsInVariousTables_20230803_114403 implements DbMigration {
public function migrate(DbConnection $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,
CHANGE COLUMN log_country log_country CHAR(2) NOT NULL DEFAULT "XX" COLLATE "ascii_general_ci" AFTER log_ip;
');
$conn->execute('
ALTER TABLE msz_auth_tfa
CHANGE COLUMN tfa_token tfa_token CHAR(32) NOT NULL COLLATE "ascii_bin" AFTER user_id;
');
$conn->execute('
ALTER TABLE msz_changelog_changes
CHANGE COLUMN change_log change_log VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER change_created,
CHANGE COLUMN change_text change_text TEXT NULL DEFAULT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER change_log;
');
$conn->execute('
ALTER TABLE msz_changelog_tags
CHANGE COLUMN tag_name tag_name VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER tag_id,
CHANGE COLUMN tag_description tag_description TEXT NULL DEFAULT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER tag_name;
');
$conn->execute('
ALTER TABLE msz_comments_categories
CHANGE COLUMN category_name category_name VARCHAR(255) NOT NULL COLLATE "ascii_bin" AFTER category_id;
');
$conn->execute('
ALTER TABLE msz_comments_posts
CHANGE COLUMN comment_text comment_text TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER comment_reply_to;
');
$conn->execute('
ALTER TABLE msz_config
CHANGE COLUMN config_name config_name VARCHAR(100) NOT NULL COLLATE "ascii_general_ci" FIRST;
');
$conn->execute('
ALTER TABLE msz_emoticons
CHANGE COLUMN emote_url emote_url VARCHAR(255) NOT NULL COLLATE "ascii_bin" AFTER emote_hierarchy;
');
$conn->execute('
ALTER TABLE msz_emoticons_strings
CHANGE COLUMN emote_string emote_string VARCHAR(50) NOT NULL COLLATE "ascii_general_ci" AFTER emote_string_order;
');
$conn->execute('
ALTER TABLE msz_forum_categories
CHANGE COLUMN forum_name forum_name VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER forum_parent,
CHANGE COLUMN forum_description forum_description TEXT NULL DEFAULT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER forum_type,
CHANGE COLUMN forum_icon forum_icon VARCHAR(50) NULL DEFAULT NULL COLLATE "ascii_bin" AFTER forum_description,
CHANGE COLUMN forum_link forum_link VARCHAR(255) NULL DEFAULT NULL COLLATE "ascii_bin" AFTER forum_colour;
');
$conn->execute('
ALTER TABLE msz_forum_posts
CHANGE COLUMN post_text post_text MEDIUMTEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER post_ip;
');
$conn->execute('
ALTER TABLE msz_forum_topics
CHANGE COLUMN topic_title topic_title VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER topic_type;
');
$conn->execute('
ALTER TABLE msz_forum_topics_redirects
CHANGE COLUMN topic_redir_url topic_redir_url VARCHAR(255) NOT NULL COLLATE "ascii_bin" AFTER user_id;
');
$conn->execute('
ALTER TABLE msz_login_attempts
CHANGE COLUMN attempt_country attempt_country CHAR(2) NOT NULL DEFAULT "XX" COLLATE "ascii_general_ci" AFTER attempt_ip,
CHANGE COLUMN attempt_user_agent attempt_user_agent TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER attempt_created;
');
$conn->execute('
ALTER TABLE msz_news_categories
CHANGE COLUMN category_name category_name VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER category_id,
CHANGE COLUMN category_description category_description TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER category_name;
');
$conn->execute('
ALTER TABLE msz_news_posts
CHANGE COLUMN post_title post_title VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER post_is_featured,
CHANGE COLUMN post_text post_text TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER post_title;
');
$conn->execute('
ALTER TABLE msz_profile_fields
CHANGE COLUMN field_key field_key VARCHAR(50) NOT NULL COLLATE "ascii_general_ci" AFTER field_order,
CHANGE COLUMN field_title field_title VARCHAR(50) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER field_key,
CHANGE COLUMN field_regex field_regex VARCHAR(255) NOT NULL COLLATE "ascii_bin" AFTER field_title;
');
$conn->execute('
ALTER TABLE msz_profile_fields_formats
CHANGE COLUMN format_regex format_regex VARCHAR(255) NULL DEFAULT NULL COLLATE "ascii_bin" AFTER field_id,
CHANGE COLUMN format_link format_link VARCHAR(255) NULL DEFAULT NULL COLLATE "ascii_bin" AFTER format_regex,
CHANGE COLUMN format_display format_display VARCHAR(255) NOT NULL DEFAULT "%s" COLLATE "utf8mb4_unicode_520_ci" AFTER format_link;
');
$conn->execute('
ALTER TABLE msz_profile_fields_values
CHANGE COLUMN field_value field_value VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER format_id;
');
$conn->execute('
ALTER TABLE msz_roles
CHANGE COLUMN role_name role_name VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER role_hierarchy,
CHANGE COLUMN role_title role_title VARCHAR(64) NULL DEFAULT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER role_name,
CHANGE COLUMN role_description role_description TEXT NULL DEFAULT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER role_title;
');
$conn->execute('
ALTER TABLE msz_sessions
CHANGE COLUMN session_user_agent session_user_agent TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER session_ip_last,
CHANGE COLUMN session_country session_country CHAR(2) NOT NULL DEFAULT "XX" COLLATE "ascii_general_ci" AFTER session_client_info;
');
$conn->execute('
ALTER TABLE msz_users
CHANGE COLUMN username username VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER user_id,
CHANGE COLUMN password password VARCHAR(255) NULL DEFAULT NULL COLLATE "ascii_bin" AFTER username,
CHANGE COLUMN email email VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER password,
CHANGE COLUMN user_country user_country CHAR(2) NOT NULL DEFAULT "XX" COLLATE "ascii_general_ci" AFTER user_super,
CHANGE COLUMN user_totp_key user_totp_key CHAR(26) NULL DEFAULT NULL COLLATE "ascii_bin" AFTER display_role,
CHANGE COLUMN user_about_content user_about_content TEXT NULL DEFAULT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER user_totp_key,
CHANGE COLUMN user_signature_content user_signature_content TEXT NULL DEFAULT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER user_about_parser,
CHANGE COLUMN user_title user_title VARCHAR(64) NULL DEFAULT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER user_background_settings;
');
$conn->execute('
ALTER TABLE msz_users_bans
CHANGE COLUMN ban_reason_public ban_reason_public TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER ban_severity,
CHANGE COLUMN ban_reason_private ban_reason_private TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER ban_reason_public;
');
$conn->execute('
ALTER TABLE msz_users_modnotes
CHANGE COLUMN note_title note_title VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER note_created,
CHANGE COLUMN note_body note_body TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER note_title;
');
$conn->execute('
ALTER TABLE msz_users_password_resets
CHANGE COLUMN verification_code verification_code CHAR(12) NULL DEFAULT NULL COLLATE "ascii_general_ci" AFTER reset_requested;
');
$conn->execute('
ALTER TABLE msz_users_warnings
CHANGE COLUMN warn_body warn_body TEXT NOT NULL COLLATE "utf8mb4_unicode_520_ci" AFTER mod_id;
');
}
}

View file

@ -0,0 +1,123 @@
<?php
use Index\Db\DbConnection;
use Index\Db\Migration\DbMigration;
final class NewPermissionsSystem_20230830_213930 implements DbMigration {
public function migrate(DbConnection $conn): void {
// make sure cron doesn't fuck us over
$conn->execute('DELETE FROM msz_config WHERE config_name = "perms.needsRecalc"');
$conn->execute('
CREATE TABLE msz_perms (
user_id INT(10) UNSIGNED NULL DEFAULT NULL,
role_id INT(10) UNSIGNED NULL DEFAULT NULL,
forum_id INT(10) UNSIGNED NULL DEFAULT NULL,
perms_category VARBINARY(64) NOT NULL,
perms_allow BIGINT(20) UNSIGNED NOT NULL,
perms_deny BIGINT(20) UNSIGNED NOT NULL,
UNIQUE KEY perms_unique (user_id, role_id, forum_id, perms_category),
KEY perms_user_foreign (user_id),
KEY perms_role_foreign (role_id),
KEY perms_forum_foreign (forum_id),
KEY perms_category_index (perms_category),
CONSTRAINT perms_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT perms_role_foreign
FOREIGN KEY (role_id)
REFERENCES msz_roles (role_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT perms_forum_foreign
FOREIGN KEY (forum_id)
REFERENCES msz_forum_categories (forum_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
ALTER TABLE msz_perms
ADD CONSTRAINT perms_53bit
CHECK (perms_allow >= 0 AND perms_deny >= 0 AND perms_allow <= 9007199254740991 AND perms_deny <= 9007199254740991)
');
$conn->execute('
CREATE TABLE msz_perms_calculated (
user_id INT(10) UNSIGNED NULL DEFAULT NULL,
forum_id INT(10) UNSIGNED NULL DEFAULT NULL,
perms_category VARBINARY(64) NOT NULL,
perms_calculated BIGINT(20) UNSIGNED NOT NULL,
UNIQUE KEY perms_calculated_unique (user_id, forum_id, perms_category),
KEY perms_calculated_user_foreign (user_id),
KEY perms_calculated_forum_foreign (forum_id),
KEY perms_calculated_category_index (perms_category),
CONSTRAINT perms_calculated_user_foreign
FOREIGN KEY (user_id)
REFERENCES msz_users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT perms_calculated_forum_foreign
FOREIGN KEY (forum_id)
REFERENCES msz_forum_categories (forum_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
ALTER TABLE msz_perms_calculated
ADD CONSTRAINT perms_calculated_53bit
CHECK (perms_calculated >= 0 AND perms_calculated <= 9007199254740991)
');
$insert = $conn->prepare('INSERT INTO msz_perms (user_id, role_id, forum_id, perms_category, perms_allow, perms_deny) VALUES (?, ?, ?, ?, ?, ?)');
$result = $conn->query('SELECT user_id, role_id, general_perms_allow, general_perms_deny, user_perms_allow, user_perms_deny, changelog_perms_allow, changelog_perms_deny, news_perms_allow, news_perms_deny, forum_perms_allow, forum_perms_deny, comments_perms_allow, comments_perms_deny FROM msz_permissions');
while($result->next()) {
$insert->addParameter(1, $result->isNull(0) ? null : $result->getString(0));
$insert->addParameter(2, $result->isNull(1) ? null : $result->getString(1));
$insert->addParameter(3, null);
$insert->addParameter(4, 'user');
$insert->addParameter(5, $result->getInteger(4));
$insert->addParameter(6, $result->getInteger(5));
$insert->execute();
$allow = $result->getInteger(2);
$allow |= $result->getInteger(6) << 8;
$allow |= $result->getInteger(8) << 16;
$allow |= $result->getInteger(10) << 24;
$allow |= $result->getInteger(12) << 32;
$deny = $result->getInteger(3);
$deny |= $result->getInteger(7) << 8;
$deny |= $result->getInteger(9) << 16;
$deny |= $result->getInteger(11) << 24;
$deny |= $result->getInteger(13) << 32;
$insert->addParameter(4, 'global');
$insert->addParameter(5, $allow);
$insert->addParameter(6, $deny);
$insert->execute();
}
$result = $conn->query('SELECT user_id, role_id, forum_id, forum_perms_allow, forum_perms_deny FROM msz_forum_permissions');
while($result->next()) {
$insert->addParameter(1, $result->isNull(0) ? null : $result->getString(0));
$insert->addParameter(2, $result->isNull(1) ? null : $result->getString(1));
$insert->addParameter(3, $result->getString(2));
$insert->addParameter(4, 'forum');
$insert->addParameter(5, $result->getInteger(3));
$insert->addParameter(6, $result->getInteger(4));
$insert->execute();
}
$conn->execute('DROP TABLE msz_forum_permissions');
$conn->execute('DROP TABLE msz_permissions');
// schedule recalc
$conn->execute('INSERT INTO msz_config (config_name, config_value) VALUES ("perms.needsRecalc", "b:1;")');
}
}

View file

@ -0,0 +1,48 @@
<?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

@ -0,0 +1,14 @@
<?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

@ -0,0 +1,13 @@
<?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

@ -1,12 +1,29 @@
# Source Code Repositories
- [Misuzu](https://git.flash.moe/flashii/misuzu): Backend of the main website.
- [Sharp Chat](https://git.flash.moe/flashii/sharp-chat): Chat Server software.
- [Backup Manager](https://git.flash.moe/flashii/backup-manager): Program that runs every day at 12:00am UTC to back up any user generated content.
- [Index](https://git.flash.moe/flash/index): Base library used in almost any component of the website that uses PHP.
- [Uiharu](https://git.flash.moe/flashii/uiharu): Service for looking up URL metadata.
- [Futami](https://git.flash.moe/flashii/futami): Common data shared between the chat clients.
- [AJAX Chat (fork)](https://git.flash.moe/flashii/ajax-chat): Old chat software (2013-2015).
- [Seria](https://git.flash.moe/flashii/seria): Software used by the torrent tracker.
- [Mince](https://git.flash.moe/flashii/mince): Source code for the Minecraft servers subwebsite.
- [Awaki](https://git.flash.moe/flashii/awaki): Redirect service hosted on fii.moe.
Below are a number of links to source code repositories related to Flashii.net and its services.
## Websites & Services
- [Misuzu](https://patchii.net/flashii/misuzu): Backend of the main website.
- [Sharp Chat](https://patchii.net/flashii/sharp-chat): Chat Server software.
- [Futami](https://patchii.net/flashii/futami): Common data shared between the chat clients.
- [Mami](https://patchii.net/flashii/mami): Web client for chat.
- [Ami](https://patchii.net/flashii/ami): Web client for chat for older browsers.
- [EEPROM](https://patchii.net/flashii/eeprom): Service for file uploading.
- [Uiharu](https://patchii.net/flashii/uiharu): Service for looking up URL metadata.
- [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
- [MCExts](https://patchii.net/flashii/mcexts): Minecraft Client and Server extensions.
- [Backup Tools](https://patchii.net/flashii/backup-tools): Scripts that run every day at 12:00am UTC to back up any user generated content.
## 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.
## Historical
- [AJAX Chat (fork)](https://patchii.net/flashii/ajax-chat): Old chat software (2013-2015). Still kept on life support for the nostalgia.
- [Hajime](https://patchii.net/flash/hajime): Cleaned up source of an older version of the website (2014-2015).

View file

@ -1,10 +1,9 @@
<?php
namespace Misuzu;
use Index\Autoloader;
use Index\Environment;
use Index\Data\DbTools;
use Misuzu\Config\DbConfig;
use Index\Db\DbBackends;
use Index\Config\Db\DbConfig;
use Index\Config\Fs\FsConfig;
define('MSZ_STARTUP', microtime(true));
define('MSZ_ROOT', __DIR__);
@ -19,37 +18,29 @@ define('MSZ_ASSETS', MSZ_ROOT . '/assets');
require_once MSZ_ROOT . '/vendor/autoload.php';
Environment::setDebug(MSZ_DEBUG);
mb_internal_encoding('utf-8');
date_default_timezone_set('utc');
error_reporting(MSZ_DEBUG ? -1 : 0);
mb_internal_encoding('UTF-8');
date_default_timezone_set('GMT');
require_once MSZ_ROOT . '/utility.php';
require_once MSZ_SOURCE . '/perms.php';
require_once MSZ_SOURCE . '/manage.php';
require_once MSZ_SOURCE . '/url.php';
require_once MSZ_SOURCE . '/Forum/perms.php';
require_once MSZ_SOURCE . '/Forum/forum.php';
require_once MSZ_SOURCE . '/Forum/leaderboard.php';
require_once MSZ_SOURCE . '/Forum/post.php';
require_once MSZ_SOURCE . '/Forum/topic.php';
require_once MSZ_SOURCE . '/Forum/validate.php';
$cfg = FsConfig::fromFile(MSZ_CONFIG . '/config.cfg');
$dbConfig = parse_ini_file(MSZ_CONFIG . '/config.ini', true, INI_SCANNER_TYPED);
if($cfg->hasValues('sentry:dsn'))
(function($cfg) {
\Sentry\init([
'dsn' => $cfg->getString('dsn'),
'traces_sample_rate' => $cfg->getFloat('tracesRate', 0.2),
'profiles_sample_rate' => $cfg->getFloat('profilesRate', 0.2),
]);
if(empty($dbConfig)) {
echo 'Database config is missing.';
exit;
}
set_exception_handler(function(\Throwable $ex) {
\Sentry\captureException($ex);
});
})($cfg->scopeTo('sentry'));
define('MSZ_DB_INIT', '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\';');
$db = DbBackends::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\';');
$db = DbTools::create($dbConfig['dsn']);
$db->execute(MSZ_DB_INIT);
DB::init(DbTools::parse($dbConfig['dsn']));
DB::exec(MSZ_DB_INIT);
$cfg = new DbConfig($db);
$cfg = new DbConfig($db, 'msz_config');
Mailer::init($cfg->scopeTo('mail'));

1029
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -43,11 +43,11 @@ header('Content-Type: text/plain; charset=utf-8');
if($_SERVER['REQUEST_METHOD'] !== 'POST')
die('no');
$config = MSZ_ROOT . '/config/github.ini';
$config = MSZ_CONFIG . '/github.ini';
if(!is_file($config))
die('config missing');
$config = parse_ini_file(MSZ_ROOT . '/config/github.ini', true);
$config = parse_ini_file($config, true);
if(empty($config['tokens']['token']))
die('config invalid');

View file

@ -1,4 +0,0 @@
<?php
namespace Misuzu;
url_redirect('auth-login');

View file

@ -1,38 +1,40 @@
<?php
namespace Misuzu;
use Misuzu\AuthToken;
use Misuzu\Users\User;
use Misuzu\Users\UserNotFoundException;
use Misuzu\Users\UserAuthSession;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserSessionCreationFailedException;
use Exception;
use Misuzu\Auth\AuthTokenCookie;
if(UserSession::hasCurrent()) {
url_redirect('index');
$urls = $msz->getUrls();
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
Tools::redirect($urls->format('index'));
return;
}
$authCtx = $msz->getAuthContext();
$users = $msz->getUsersContext()->getUsers();
$sessions = $authCtx->getSessions();
$loginAttempts = $authCtx->getLoginAttempts();
if(!empty($_GET['resolve'])) {
header('Content-Type: application/json; charset=utf-8');
try {
// Only works for usernames, this is by design
$userInfo = User::byUsername((string)filter_input(INPUT_GET, 'name'));
} catch(UserNotFoundException $ex) {
$userInfo = $users->getUser((string)filter_input(INPUT_GET, 'name'), 'name');
} catch(Exception $ex) {
echo json_encode([
'id' => 0,
'name' => '',
'avatar' => url('user-avatar', ['res' => 200, 'user' => 0]),
'avatar' => $urls->format('user-avatar', ['res' => 200, 'user' => 0]),
]);
return;
}
echo json_encode([
'id' => $userInfo->getId(),
'name' => $userInfo->getUsername(),
'avatar' => url('user-avatar', ['user' => $userInfo->getId(), 'res' => 200]),
'id' => (int)$userInfo->getId(),
'name' => $userInfo->getName(),
'avatar' => $urls->format('user-avatar', ['user' => $userInfo->getId(), 'res' => 200]),
]);
return;
}
@ -40,7 +42,9 @@ if(!empty($_GET['resolve'])) {
$notices = [];
$ipAddress = $_SERVER['REMOTE_ADDR'];
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
$siteIsPrivate = $cfg->getBoolean('private.enable');
if($siteIsPrivate) {
@ -81,6 +85,8 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
break;
}
$clientInfo = ClientInfo::fromRequest();
$attemptsRemainingError = sprintf(
"%d attempt%s remaining",
$remainingAttempts - 1,
@ -89,57 +95,60 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
$loginFailedError = "Invalid username or password, {$attemptsRemainingError}.";
try {
$userInfo = User::byUsernameOrEMailAddress($_POST['login']['username']);
} catch(UserNotFoundException $ex) {
UserLoginAttempt::create($ipAddress, $countryCode, false);
$userInfo = $users->getUser($_POST['login']['username'], 'login');
} catch(Exception $ex) {
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo);
$notices[] = $loginFailedError;
break;
}
if(!$userInfo->hasPassword()) {
if(!$userInfo->hasPasswordHash()) {
$notices[] = 'Your password has been invalidated, please reset it.';
break;
}
if($userInfo->isDeleted() || !$userInfo->checkPassword($_POST['login']['password'])) {
UserLoginAttempt::create($ipAddress, $countryCode, false, $userInfo);
if($userInfo->isDeleted() || !$userInfo->verifyPassword($_POST['login']['password'])) {
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
$notices[] = $loginFailedError;
break;
}
if($userInfo->passwordNeedsRehash())
$userInfo->setPassword($_POST['login']['password'])->save();
$users->updateUser($userInfo, password: $_POST['login']['password']);
if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userInfo->getId(), $loginPermVal)) {
if(!empty($loginPermCat) && $loginPermVal > 0 && !$msz->getPerms()->checkPermissions($loginPermCat, $loginPermVal, $userInfo)) {
$notices[] = "Login succeeded, but you're not allowed to browse the site right now.";
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
break;
}
if($userInfo->hasTOTP()) {
url_redirect('auth-two-factor', [
'token' => UserAuthSession::create($userInfo)->getToken(),
]);
if($userInfo->hasTOTPKey()) {
$tfaToken = $authCtx->getTwoFactorAuthSessions()->createToken($userInfo);
Tools::redirect($urls->format('auth-two-factor', ['token' => $tfaToken]));
return;
}
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
try {
$sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode);
$sessionInfo->setCurrent();
} catch(UserSessionCreationFailedException $ex) {
$sessionInfo = $sessions->createSession($userInfo, $ipAddress, $countryCode, $userAgent, $clientInfo);
} catch(Exception $ex) {
$notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!";
break;
}
$authToken = AuthToken::create($userInfo, $sessionInfo);
$authToken->applyCookie($sessionInfo->getExpiresTime());
$tokenBuilder = $authInfo->getTokenInfo()->toBuilder();
$tokenBuilder->setUserId($userInfo);
$tokenBuilder->setSessionToken($sessionInfo);
$tokenBuilder->removeImpersonatedUserId();
$tokenInfo = $tokenBuilder->toInfo();
if(!is_local_url($loginRedirect))
$loginRedirect = url('index');
AuthTokenCookie::apply($tokenPacker->pack($tokenInfo));
redirect($loginRedirect);
if(!Tools::isLocalURL($loginRedirect))
$loginRedirect = $urls->format('index');
Tools::redirect($loginRedirect);
return;
}
@ -147,7 +156,7 @@ $welcomeMode = !empty($_GET['welcome']);
$loginUsername = !empty($_POST['login']['username']) && is_string($_POST['login']['username']) ? $_POST['login']['username'] : (
!empty($_GET['username']) && is_string($_GET['username']) ? $_GET['username'] : ''
);
$loginRedirect = $welcomeMode ? url('index') : (!empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : null) ?? $_SERVER['HTTP_REFERER'] ?? url('index');
$loginRedirect = $welcomeMode ? $urls->format('index') : (!empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : null) ?? $_SERVER['HTTP_REFERER'] ?? $urls->format('index');
$canRegisterAccount = !$siteIsPrivate;
Template::render('auth.login', [

View file

@ -1,21 +1,27 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
use Misuzu\Auth\AuthTokenCookie;
if(!UserSession::hasCurrent()) {
url_redirect('index');
return;
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
if(!CSRF::validateRequest()) {
Template::render('auth.logout');
return;
}
$tokenInfo = $authInfo->getTokenInfo();
$authCtx = $msz->getAuthContext();
$authCtx->getSessions()->deleteSessions(sessionTokens: $tokenInfo->getSessionToken());
$tokenBuilder = $tokenInfo->toBuilder();
$tokenBuilder->removeUserId();
$tokenBuilder->removeSessionToken();
$tokenBuilder->removeImpersonatedUserId();
$tokenInfo = $tokenBuilder->toInfo();
AuthTokenCookie::apply($tokenPacker->pack($tokenInfo));
}
if(CSRF::validateRequest()) {
AuthToken::nukeCookie();
UserSession::getCurrent()->delete();
UserSession::unsetCurrent();
User::unsetCurrent();
url_redirect('index');
return;
}
Template::render('auth.logout');
Tools::redirect($msz->getUrls()->format('index'));;

View file

@ -1,19 +1,21 @@
<?php
namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserNotFoundException;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserRecoveryToken;
use Misuzu\Users\UserRecoveryTokenNotFoundException;
use Misuzu\Users\UserRecoveryTokenCreationFailedException;
use Misuzu\Users\UserSession;
if(UserSession::hasCurrent()) {
url_redirect('settings-account');
$urls = $msz->getUrls();
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
Tools::redirect($urls->format('settings-account'));
return;
}
$authCtx = $msz->getAuthContext();
$users = $msz->getUsersContext()->getUsers();
$recoveryTokens = $authCtx->getRecoveryTokens();
$loginAttempts = $authCtx->getLoginAttempts();
$reset = !empty($_POST['reset']) && is_array($_POST['reset']) ? $_POST['reset'] : [];
$forgot = !empty($_POST['forgot']) && is_array($_POST['forgot']) ? $_POST['forgot'] : [];
$userId = !empty($reset['user']) ? (int)$reset['user'] : (
@ -22,9 +24,9 @@ $userId = !empty($reset['user']) ? (int)$reset['user'] : (
if($userId > 0)
try {
$userInfo = User::byId($userId);
} catch(UserNotFoundException $ex) {
url_redirect('auth-forgot');
$userInfo = $users->getUser((string)$userId, 'id');
} catch(RuntimeException $ex) {
Tools::redirect($urls->format('auth-forgot'));
return;
}
@ -32,7 +34,8 @@ $notices = [];
$ipAddress = $_SERVER['REMOTE_ADDR'];
$siteIsPrivate = $cfg->getBoolean('private.enable');
$canResetPassword = $siteIsPrivate ? $cfg->getBoolean('private.allow_password_reset', true) : true;
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
while($canResetPassword) {
if(!empty($reset) && $userId > 0) {
@ -41,15 +44,15 @@ while($canResetPassword) {
break;
}
$verificationCode = !empty($reset['verification']) && is_string($reset['verification']) ? $reset['verification'] : '';
$verifyCode = !empty($reset['verification']) && is_string($reset['verification']) ? $reset['verification'] : '';
try {
$tokenInfo = UserRecoveryToken::byToken($verificationCode);
} catch(UserRecoveryTokenNotFoundException $ex) {
$tokenInfo = $recoveryTokens->getToken(verifyCode: $verifyCode);
} catch(RuntimeException $ex) {
unset($tokenInfo);
}
if(empty($tokenInfo) || !$tokenInfo->isValid() || $tokenInfo->getUserId() !== $userInfo->getId()) {
if(empty($tokenInfo) || !$tokenInfo->isValid() || $tokenInfo->getUserId() !== (string)$userInfo->getId()) {
$notices[] = 'Invalid verification code!';
break;
}
@ -64,22 +67,21 @@ while($canResetPassword) {
break;
}
if(User::validatePassword($passwordNew) !== '') {
$notices[] = 'Your password is too weak!';
$passwordValidation = $users->validatePassword($passwordNew);
if($passwordValidation !== '') {
$notices[] = $users->validatePasswordText($passwordValidation);
break;
}
// also disables two factor auth to prevent getting locked out of account entirely
// this behaviour should really be replaced with recovery keys...
$userInfo->setPassword($passwordNew)
->removeTOTPKey()
->save();
$users->updateUser($userInfo, password: $passwordNew, totpKey: '');
$msz->createAuditLog('PASSWORD_RESET', [], $userInfo);
$tokenInfo->invalidate();
$recoveryTokens->invalidateToken($tokenInfo);
url_redirect('auth-login', ['redirect' => '/']);
Tools::redirect($urls->format('auth-login', ['redirect' => '/']));
return;
}
@ -100,8 +102,8 @@ while($canResetPassword) {
}
try {
$forgotUser = User::byEMailAddress($forgot['email']);
} catch(UserNotFoundException $ex) {
$forgotUser = $users->getUser($forgot['email'], 'email');
} catch(RuntimeException $ex) {
unset($forgotUser);
}
@ -111,28 +113,28 @@ while($canResetPassword) {
}
try {
$tokenInfo = UserRecoveryToken::byUserAndRemoteAddress($forgotUser, $ipAddress);
} catch(UserRecoveryTokenNotFoundException $ex) {
$tokenInfo = UserRecoveryToken::create($forgotUser, $ipAddress);
$tokenInfo = $recoveryTokens->getToken(userInfo: $forgotUser, remoteAddr: $ipAddress);
} catch(RuntimeException $ex) {
$tokenInfo = $recoveryTokens->createToken($forgotUser, $ipAddress);
$recoveryMessage = Mailer::template('password-recovery', [
'username' => $forgotUser->getUsername(),
'token' => $tokenInfo->getToken(),
'username' => $forgotUser->getName(),
'token' => $tokenInfo->getCode(),
]);
$recoveryMail = Mailer::sendMessage(
[$forgotUser->getEMailAddress() => $forgotUser->getUsername()],
[$forgotUser->getEMailAddress() => $forgotUser->getName()],
$recoveryMessage['subject'], $recoveryMessage['message']
);
if(!$recoveryMail) {
$notices[] = "Failed to send reset email, please contact the administrator.";
$tokenInfo->invalidate();
$recoveryTokens->invalidateToken($tokenInfo);
break;
}
}
url_redirect('auth-reset', ['user' => $forgotUser->getId()]);
Tools::redirect($urls->format('auth-reset', ['user' => $forgotUser->getId()]));
return;
}
@ -144,5 +146,5 @@ Template::render(isset($userInfo) ? 'auth.password_reset' : 'auth.password_forgo
'password_email' => !empty($forget['email']) && is_string($forget['email']) ? $forget['email'] : '',
'password_attempts_remaining' => $remainingAttempts,
'password_user' => $userInfo ?? null,
'password_verification' => $verificationCode ?? '',
'password_verification' => $verifyCode ?? '',
]);

View file

@ -1,24 +1,40 @@
<?php
namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserCreationFailedException;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserRole;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserWarning;
if(UserSession::hasCurrent()) {
url_redirect('index');
$urls = $msz->getUrls();
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
Tools::redirect($urls->format('index'));
return;
}
$authCtx = $msz->getAuthContext();
$usersCtx = $msz->getUsersContext();
$users = $usersCtx->getUsers();
$roles = $usersCtx->getRoles();
$config = $msz->getConfig();
$register = !empty($_POST['register']) && is_array($_POST['register']) ? $_POST['register'] : [];
$notices = [];
$ipAddress = $_SERVER['REMOTE_ADDR'];
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
$remainingAttempts = UserLoginAttempt::remaining($_SERVER['REMOTE_ADDR']);
$restricted = UserWarning::countByRemoteAddress($ipAddress) > 0 ? 'ban' : '';
// there is currently no ip banning system.
// because people can have a wide variety of ip address
// it doesn't make sense to include a single row for it
// in the user bans table
// add better ip tracking and reintroduce the blacklist
// was thinking of having both a storage table and an expanded table
// with the storage table contains range syntaxes and whatnot
// and the expanded table just having seas of raw ips in it with a primary key
// for fast matching
$restricted = '';
$loginAttempts = $authCtx->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
while(!$restricted && !empty($register)) {
if(!CSRF::validateRequest()) {
@ -53,41 +69,48 @@ while(!$restricted && !empty($register)) {
break;
}
$usernameValidation = User::validateUsername($register['username']);
$usernameValidation = $users->validateName($register['username']);
if($usernameValidation !== '')
$notices[] = User::usernameValidationErrorString($usernameValidation);
$notices[] = $users->validateNameText($usernameValidation);
$emailValidation = User::validateEMailAddress($register['email']);
$emailValidation = $users->validateEMailAddress($register['email']);
if($emailValidation !== '')
$notices[] = $emailValidation === 'in-use'
? 'This e-mail address has already been used!'
: 'The e-mail address you entered is invalid!';
$notices[] = $users->validateEMailAddressText($emailValidation);
if($register['password_confirm'] !== $register['password'])
$notices[] = 'The given passwords don\'t match.';
if(User::validatePassword($register['password']) !== '')
$notices[] = 'Your password is too weak!';
$passwordValidation = $users->validatePassword($register['password']);
if($passwordValidation !== '')
$notices[] = $users->validatePasswordText($passwordValidation);
if(!empty($notices))
break;
$defaultRoleInfo = $roles->getDefaultRole();
try {
$createUser = User::create(
$userInfo = $users->createUser(
$register['username'],
$register['password'],
$register['email'],
$ipAddress,
$countryCode
$countryCode,
$defaultRoleInfo
);
} catch(UserCreationFailedException $ex) {
} catch(RuntimeException $ex) {
$notices[] = 'Something went wrong while creating your account, please alert an administrator or a developer about this!';
break;
}
$createUser->addRole(UserRole::byDefault());
$users->addRoles($userInfo, $defaultRoleInfo);
$config->setString('users.newest', $userInfo->getId());
$msz->getPerms()->precalculatePermissions(
$msz->getForumContext()->getCategories(),
[$userInfo->getId()]
);
url_redirect('auth-login-welcome', ['username' => $createUser->getUsername()]);
Tools::redirect($urls->format('auth-login-welcome', ['username' => $userInfo->getName()]));
return;
}

View file

@ -1,19 +1,23 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Auth\AuthTokenCookie;
if(!isset($userInfoReal) || !$authToken->hasImpersonatedUserId() || !CSRF::validateRequest()) {
url_redirect('index');
return;
$urls = $msz->getUrls();
if(CSRF::validateRequest()) {
$tokenInfo = $msz->getAuthInfo()->getTokenInfo();
if($tokenInfo->hasImpersonatedUserId()) {
$impUserId = $tokenInfo->getImpersonatedUserId();
$tokenBuilder = $tokenInfo->toBuilder();
$tokenBuilder->removeImpersonatedUserId();
$tokenInfo = $tokenBuilder->toInfo();
AuthTokenCookie::apply($tokenPacker->pack($tokenInfo));
Tools::redirect($urls->format('manage-user', ['user' => $impUserId]));
return;
}
}
$authToken->removeImpersonatedUserId();
$authToken->applyCookie();
$impUserId = User::hasCurrent() ? User::getCurrent()->getId() : 0;
url_redirect(
$impUserId > 0 ? 'manage-user' : 'index',
['user' => $impUserId]
);
Tools::redirect($urls->format('index'));

View file

@ -1,43 +1,47 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserSessionCreationFailedException;
use Misuzu\Users\UserAuthSession;
use Misuzu\Users\UserAuthSessionNotFoundException;
use RuntimeException;
use Misuzu\TOTPGenerator;
use Misuzu\Auth\AuthTokenCookie;
if(UserSession::hasCurrent()) {
url_redirect('index');
$urls = $msz->getUrls();
$authInfo = $msz->getAuthInfo();
if($authInfo->isLoggedIn()) {
Tools::redirect($urls->format('index'));
return;
}
$authCtx = $msz->getAuthContext();
$users = $msz->getUsersContext()->getUsers();
$sessions = $authCtx->getSessions();
$tfaSessions = $authCtx->getTwoFactorAuthSessions();
$loginAttempts = $authCtx->getLoginAttempts();
$ipAddress = $_SERVER['REMOTE_ADDR'];
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$twofactor = !empty($_POST['twofactor']) && is_array($_POST['twofactor']) ? $_POST['twofactor'] : [];
$notices = [];
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
try {
$tokenInfo = UserAuthSession::byToken(
!empty($_GET['token']) && is_string($_GET['token']) ? $_GET['token'] : (
!empty($twofactor['token']) && is_string($twofactor['token']) ? $twofactor['token'] : ''
)
);
} catch(UserAuthSessionNotFoundException $ex) {}
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
if(empty($tokenInfo) || $tokenInfo->hasExpired()) {
url_redirect('auth-login');
$tokenString = !empty($_GET['token']) && is_string($_GET['token']) ? $_GET['token'] : (
!empty($twofactor['token']) && is_string($twofactor['token']) ? $twofactor['token'] : ''
);
$tokenUserId = $tfaSessions->getTokenUserId($tokenString);
if(empty($tokenUserId)) {
Tools::redirect($urls->format('auth-login'));
return;
}
$userInfo = $tokenInfo->getUser();
$userInfo = $users->getUser($tokenUserId, 'id');
// checking user_totp_key specifically because there's a fringe chance that
// there's a token present, but totp is actually disabled
if(!$userInfo->hasTOTP()) {
url_redirect('auth-login');
if(!$userInfo->hasTOTPKey()) {
Tools::redirect($urls->format('auth-login'));
return;
}
@ -60,41 +64,47 @@ while(!empty($twofactor)) {
break;
}
if(!in_array($twofactor['code'], $userInfo->getValidTOTPTokens())) {
$clientInfo = ClientInfo::fromRequest();
$totp = new TOTPGenerator($userInfo->getTOTPKey());
if(!in_array($twofactor['code'], $totp->generateRange())) {
$notices[] = sprintf(
"Invalid two factor code, %d attempt%s remaining",
$remainingAttempts - 1,
$remainingAttempts === 2 ? '' : 's'
);
UserLoginAttempt::create($ipAddress, $countryCode, false, $userInfo);
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
break;
}
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
$tokenInfo->delete();
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo);
$tfaSessions->deleteToken($tokenString);
try {
$sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode);
$sessionInfo->setCurrent();
} catch(UserSessionCreationFailedException $ex) {
$sessionInfo = $sessions->createSession($userInfo, $ipAddress, $countryCode, $userAgent, $clientInfo);
} catch(RuntimeException $ex) {
$notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!";
break;
}
$authToken = AuthToken::create($userInfo, $sessionInfo);
$authToken->applyCookie($sessionInfo->getExpiresTime());
$tokenBuilder = $authInfo->getTokenInfo()->toBuilder();
$tokenBuilder->setUserId($userInfo);
$tokenBuilder->setSessionToken($sessionInfo);
$tokenBuilder->removeImpersonatedUserId();
$tokenInfo = $tokenBuilder->toInfo();
if(!is_local_url($redirect)) {
$redirect = url('index');
}
AuthTokenCookie::apply($tokenPacker->pack($tokenInfo));
redirect($redirect);
if(!Tools::isLocalURL($redirect))
$redirect = $urls->format('index');
Tools::redirect($redirect);
return;
}
Template::render('auth.twofactor', [
'twofactor_notices' => $notices,
'twofactor_redirect' => !empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : url('index'),
'twofactor_redirect' => !empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : $urls->format('index'),
'twofactor_attempts_remaining' => $remainingAttempts,
'twofactor_token' => $tokenInfo->getToken(),
'twofactor_token' => $tokenString,
]);

View file

@ -2,40 +2,27 @@
namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
// basing whether or not this is an xhr request on whether a referrer header is present
// this page is never directy accessed, under normal circumstances
$redirect = !empty($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : url('index');
$usersCtx = $msz->getUsersContext();
$redirect = filter_input(INPUT_GET, 'return') ?? $_SERVER['HTTP_REFERER'] ?? $msz->getUrls()->format('index');
if(!is_local_url($redirect)) {
echo render_info('Possible request forgery detected.', 403);
return;
}
if(!Tools::isLocalURL($redirect))
Template::displayInfo('Possible request forgery detected.', 403);
if(!CSRF::validateRequest()) {
echo render_info("Couldn't verify this request, please refresh the page and try again.", 403);
return;
}
if(!CSRF::validateRequest())
Template::displayInfo("Couldn't verify this request, please refresh the page and try again.", 403);
$currentUserInfo = User::getCurrent();
if($currentUserInfo === null) {
echo render_info('You must be logged in to manage comments.', 401);
return;
}
$authInfo = $msz->getAuthInfo();
if(!$authInfo->isLoggedIn())
Template::displayInfo('You must be logged in to manage comments.', 403);
if($currentUserInfo->isBanned()) {
echo render_info('You have been banned, check your profile for more information.', 403);
return;
}
if($currentUserInfo->isSilenced()) {
echo render_info('You have been silenced, check your profile for more information.', 403);
return;
}
$currentUserInfo = $authInfo->getUserInfo();
if($usersCtx->hasActiveBan($currentUserInfo))
Template::displayInfo('You have been banned, check your profile for more information.', 403);
$comments = $msz->getComments();
$commentPerms = $currentUserInfo->commentPerms();
$perms = $authInfo->getPerms('global');
$commentId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
$commentMode = (string)filter_input(INPUT_GET, 'm');
@ -43,69 +30,52 @@ $commentVote = (int)filter_input(INPUT_GET, 'v', FILTER_SANITIZE_NUMBER_INT);
if(!empty($commentId)) {
try {
$commentInfo = $comments->getPostById($commentId);
$commentInfo = $comments->getPost($commentId);
} catch(RuntimeException $ex) {
echo render_info('Post not found.', 404);
return;
Template::displayInfo('Post not found.', 404);
}
$categoryInfo = $comments->getCategoryByPost($commentInfo);
$categoryInfo = $comments->getCategory(postInfo: $commentInfo);
}
if($commentMode !== 'create' && empty($commentInfo)) {
echo render_error(400);
return;
}
if($commentMode !== 'create' && empty($commentInfo))
Template::throwError(400);
switch($commentMode) {
case 'pin':
case 'unpin':
if(!$commentPerms['can_pin'] && !$categoryInfo->isOwner($currentUserInfo)) {
echo render_info("You're not allowed to pin comments.", 403);
break;
}
if(!$perms->check(Perm::G_COMMENTS_PIN) && !$categoryInfo->isOwner($currentUserInfo))
Template::displayInfo("You're not allowed to pin comments.", 403);
if($commentInfo->isDeleted()) {
echo render_info("This comment doesn't exist!", 400);
break;
}
if($commentInfo->isDeleted())
Template::displayInfo("This comment doesn't exist!", 400);
if($commentInfo->isReply()) {
echo render_info("You can't pin replies!", 400);
break;
}
if($commentInfo->isReply())
Template::displayInfo("You can't pin replies!", 400);
$isPinning = $commentMode === 'pin';
if($isPinning) {
if($commentInfo->isPinned()) {
echo render_info('This comment is already pinned.', 400);
break;
}
if($commentInfo->isPinned())
Template::displayInfo('This comment is already pinned.', 400);
$comments->pinPost($commentInfo);
} else {
if(!$commentInfo->isPinned()) {
echo render_info("This comment isn't pinned yet.", 400);
break;
}
if(!$commentInfo->isPinned())
Template::displayInfo("This comment isn't pinned yet.", 400);
$comments->unpinPost($commentInfo);
}
redirect($redirect . '#comment-' . $commentInfo->getId());
Tools::redirect($redirect . '#comment-' . $commentInfo->getId());
break;
case 'vote':
if(!$commentPerms['can_vote'] && !$categoryInfo->isOwner($currentUserInfo)) {
echo render_info("You're not allowed to vote on comments.", 403);
break;
}
if(!$perms->check(Perm::G_COMMENTS_VOTE) && !$categoryInfo->isOwner($currentUserInfo))
Template::displayInfo("You're not allowed to vote on comments.", 403);
if($commentInfo->isDeleted()) {
echo render_info("This comment doesn't exist!", 400);
break;
}
if($commentInfo->isDeleted())
Template::displayInfo("This comment doesn't exist!", 400);
if($commentVote > 0)
$comments->addPostPositiveVote($commentInfo, $currentUserInfo);
@ -114,30 +84,26 @@ switch($commentMode) {
else
$comments->removePostVote($commentInfo, $currentUserInfo);
redirect($redirect . '#comment-' . $commentInfo->getId());
Tools::redirect($redirect . '#comment-' . $commentInfo->getId());
break;
case 'delete':
if(!$commentPerms['can_delete'] && !$categoryInfo->isOwner($currentUserInfo)) {
echo render_info("You're not allowed to delete comments.", 403);
break;
}
$canDelete = $perms->check(Perm::G_COMMENTS_DELETE_OWN | Perm::G_COMMENTS_DELETE_ANY);
if(!$canDelete && !$categoryInfo->isOwner($currentUserInfo))
Template::displayInfo("You're not allowed to delete comments.", 403);
if($commentInfo->isDeleted()) {
echo render_info(
$commentPerms['can_delete_any'] ? 'This comment is already marked for deletion.' : "This comment doesn't exist.",
$canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
if($commentInfo->isDeleted())
Template::displayInfo(
$canDeleteAny ? 'This comment is already marked for deletion.' : "This comment doesn't exist.",
400
);
break;
}
$isOwnComment = $commentInfo->getUserId() === (string)$currentUserInfo->getId();
$isModAction = $commentPerms['can_delete_any'] && !$isOwnComment;
$isOwnComment = $commentInfo->getUserId() === $currentUserInfo->getId();
$isModAction = $canDeleteAny && !$isOwnComment;
if(!$isModAction && !$isOwnComment) {
echo render_info("You're not allowed to delete comments made by others.", 403);
break;
}
if(!$isModAction && !$isOwnComment)
Template::displayInfo("You're not allowed to delete comments made by others.", 403);
$comments->deletePost($commentInfo);
@ -151,19 +117,15 @@ switch($commentMode) {
$msz->createAuditLog('COMMENT_ENTRY_DELETE', [$commentInfo->getId()]);
}
redirect($redirect);
Tools::redirect($redirect);
break;
case 'restore':
if(!$commentPerms['can_delete_any']) {
echo render_info("You're not allowed to restore deleted comments.", 403);
break;
}
if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY))
Template::displayInfo("You're not allowed to restore deleted comments.", 403);
if(!$commentInfo->isDeleted()) {
echo render_info("This comment isn't in a deleted state.", 400);
break;
}
if(!$commentInfo->isDeleted())
Template::displayInfo("This comment isn't in a deleted state.", 400);
$comments->restorePost($commentInfo);
@ -173,39 +135,33 @@ switch($commentMode) {
'<username>',
]);
redirect($redirect . '#comment-' . $commentInfo->getId());
Tools::redirect($redirect . '#comment-' . $commentInfo->getId());
break;
case 'create':
if(!$commentPerms['can_comment'] && !$categoryInfo->isOwner($currentUserInfo)) {
echo render_info("You're not allowed to post comments.", 403);
break;
}
if(!$perms->check(Perm::G_COMMENTS_CREATE) && !$categoryInfo->isOwner($currentUserInfo))
Template::displayInfo("You're not allowed to post comments.", 403);
if(empty($_POST['comment']) || !is_array($_POST['comment'])) {
echo render_info('Missing data.', 400);
break;
}
if(empty($_POST['comment']) || !is_array($_POST['comment']))
Template::displayInfo('Missing data.', 400);
try {
$categoryId = isset($_POST['comment']['category']) && is_string($_POST['comment']['category'])
? (int)$_POST['comment']['category']
: 0;
$categoryInfo = $comments->getCategoryById($categoryId);
$categoryInfo = $comments->getCategory(categoryId: $categoryId);
} catch(RuntimeException $ex) {
echo render_info('This comment category doesn\'t exist.', 404);
break;
Template::displayInfo('This comment category doesn\'t exist.', 404);
}
if($categoryInfo->isLocked() && !$commentPerms['can_lock']) {
echo render_info('This comment category has been locked.', 403);
break;
}
$canLock = $perms->check(Perm::G_COMMENTS_LOCK);
if($categoryInfo->isLocked() && !$canLock)
Template::displayInfo('This comment category has been locked.', 403);
$commentText = !empty($_POST['comment']['text']) && is_string($_POST['comment']['text']) ? $_POST['comment']['text'] : '';
$commentReply = (string)(!empty($_POST['comment']['reply']) && is_string($_POST['comment']['reply']) ? (int)$_POST['comment']['reply'] : 0);
$commentLock = !empty($_POST['comment']['lock']) && $commentPerms['can_lock'];
$commentPin = !empty($_POST['comment']['pin']) && $commentPerms['can_pin'];
$commentLock = !empty($_POST['comment']['lock']) && $canLock;
$commentPin = !empty($_POST['comment']['pin']) && $perms->check(Perm::G_COMMENTS_PIN);
if($commentLock) {
if($categoryInfo->isLocked())
@ -217,28 +173,24 @@ switch($commentMode) {
if(strlen($commentText) > 0) {
$commentText = preg_replace("/[\r\n]{2,}/", "\n", $commentText);
} else {
if($commentPerms['can_lock']) {
echo render_info('The action has been processed.', 400);
if($canLock) {
Template::displayInfo('The action has been processed.', 400);
} else {
echo render_info('Your comment is too short.', 400);
Template::displayInfo('Your comment is too short.', 400);
}
break;
}
if(mb_strlen($commentText) > 5000) {
echo render_info('Your comment is too long.', 400);
break;
}
if(mb_strlen($commentText) > 5000)
Template::displayInfo('Your comment is too long.', 400);
if($commentReply > 0) {
try {
$parentInfo = $comments->getPostById($commentReply);
$parentInfo = $comments->getPost($commentReply);
} catch(RuntimeException $ex) {}
if(!isset($parentInfo) || $parentInfo->isDeleted()) {
echo render_info('The comment you tried to reply to does not exist.', 404);
break;
}
if(!isset($parentInfo) || $parentInfo->isDeleted())
Template::displayInfo('The comment you tried to reply to does not exist.', 404);
}
$commentInfo = $comments->createPost(
@ -249,9 +201,9 @@ switch($commentMode) {
$commentPin
);
redirect($redirect . '#comment-' . $commentInfo->getId());
Tools::redirect($redirect . '#comment-' . $commentInfo->getId());
break;
default:
echo render_info('Not found.', 404);
Template::displayInfo('Not found.', 404);
}

View file

@ -1,78 +1,179 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use stdClass;
use RuntimeException;
$forumId = !empty($_GET['f']) && is_string($_GET['f']) ? (int)$_GET['f'] : 0;
$forumId = max($forumId, 0);
$forumCtx = $msz->getForumContext();
$forumCategories = $forumCtx->getCategories();
$forumTopics = $forumCtx->getTopics();
$forumPosts = $forumCtx->getPosts();
$usersCtx = $msz->getUsersContext();
if($forumId === 0) {
url_redirect('forum-index');
exit;
$categoryId = (int)filter_input(INPUT_GET, 'f', FILTER_SANITIZE_NUMBER_INT);
try {
$categoryInfo = $forumCategories->getCategory(categoryId: $categoryId);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
$forum = forum_get($forumId);
$forumUser = User::getCurrent();
$forumUserId = $forumUser === null ? 0 : $forumUser->getId();
$authInfo = $msz->getAuthInfo();
$perms = $authInfo->getPerms('forum', $categoryInfo);
if(empty($forum) || ($forum['forum_type'] == MSZ_FORUM_TYPE_LINK && empty($forum['forum_link']))) {
echo render_error(404);
return;
$currentUser = $authInfo->getUserInfo();
$currentUserId = $currentUser === null ? '0' : $currentUser->getId();
if(!$perms->check(Perm::F_CATEGORY_VIEW))
Template::throwError(403);
if($usersCtx->hasActiveBan($currentUser))
$perms = $perms->apply(fn($calc) => $calc & (Perm::F_CATEGORY_LIST | Perm::F_CATEGORY_VIEW));
if($categoryInfo->isLink()) {
if($categoryInfo->hasLinkTarget()) {
$forumCategories->incrementCategoryClicks($categoryInfo);
Tools::redirect($categoryInfo->getLinkTarget());
return;
}
Template::throwError(404);
}
$perms = forum_perms_get_user($forum['forum_id'], $forumUserId)[MSZ_FORUM_PERMS_GENERAL];
$forumPagination = new Pagination($forumTopics->countTopics(
categoryInfo: $categoryInfo,
global: true,
deleted: $perms->check(Perm::F_POST_DELETE_ANY) ? null : false
), 20);
if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) {
echo render_error(403);
return;
}
if(!$forumPagination->hasValidOffset())
Template::throwError(404);
if(isset($forumUser) && $forumUser->hasActiveWarning())
$perms &= ~MSZ_FORUM_PERM_SET_WRITE;
$children = [];
$topics = [];
Template::set('forum_perms', $perms);
if($categoryInfo->mayHaveChildren()) {
$children = $forumCategories->getCategoryChildren($categoryInfo, hidden: false, asTree: true);
if($forum['forum_type'] == MSZ_FORUM_TYPE_LINK) {
forum_increment_clicks($forum['forum_id']);
redirect($forum['forum_link']);
return;
}
foreach($children as $childId => $child) {
$childPerms = $authInfo->getPerms('forum', $child->info);
if(!$childPerms->check(Perm::F_CATEGORY_LIST)) {
unset($category->children[$childId]);
continue;
}
$forumPagination = new Pagination($forum['forum_topic_count'], 20);
$childUnread = false;
if(!$forumPagination->hasValidOffset() && $forum['forum_topic_count'] > 0) {
echo render_error(404);
return;
}
if($child->info->mayHaveChildren()) {
foreach($child->children as $grandChildId => $grandChild) {
$grandChildPerms = $authInfo->getPerms('forum', $grandChild->info);
if(!$grandChildPerms->check(Perm::F_CATEGORY_LIST)) {
unset($child->children[$grandChildId]);
continue;
}
$forumMayHaveTopics = forum_may_have_topics($forum['forum_type']);
$topics = $forumMayHaveTopics
? forum_topic_listing(
$forum['forum_id'],
$forumUserId,
$forumPagination->getOffset(),
$forumPagination->getRange(),
perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST)
)
: [];
$grandChildUnread = false;
$forumMayHaveChildren = forum_may_have_children($forum['forum_type']);
if($grandChild->info->mayHaveTopics()) {
$catIds = [$grandChild->info->getId()];
foreach($grandChild->childIds as $greatGrandChildId) {
$greatGrandChildPerms = $authInfo->getPerms('forum', $greatGrandChildId);
if(!$greatGrandChildPerms->check(Perm::F_CATEGORY_LIST))
$catIds[] = $greatGrandChildId;
}
if($forumMayHaveChildren) {
$forum['forum_subforums'] = forum_get_children($forum['forum_id'], $forumUserId);
$grandChildUnread = $forumCategories->checkCategoryUnread($catIds, $currentUser);
if($grandChildUnread)
$childUnread = true;
}
foreach($forum['forum_subforums'] as $skey => $subforum) {
$forum['forum_subforums'][$skey]['forum_subforums']
= forum_get_children($subforum['forum_id'], $forumUserId);
$grandChild->perms = $grandChildPerms;
$grandChild->unread = $grandChildUnread;
}
}
if($child->info->mayHaveChildren() || $child->info->mayHaveTopics()) {
$catIds = [$child->info->getId()];
foreach($child->childIds as $grandChildId) {
$grandChildPerms = $authInfo->getPerms('forum', $grandChildId);
if($grandChildPerms->check(Perm::F_CATEGORY_LIST))
$catIds[] = $grandChildId;
}
try {
$lastPostInfo = $forumPosts->getPost(categoryInfos: $catIds, getLast: true, deleted: false);
} catch(RuntimeException $ex) {
$lastPostInfo = null;
}
if($lastPostInfo !== null) {
$child->lastPost = new stdClass;
$child->lastPost->info = $lastPostInfo;
$child->lastPost->topicInfo = $forumTopics->getTopic(postInfo: $lastPostInfo);
if($lastPostInfo->hasUserId()) {
$child->lastPost->user = $usersCtx->getUserInfo($lastPostInfo->getUserId());
$child->lastPost->colour = $usersCtx->getUserColour($child->lastPost->user);
}
}
}
if($child->info->mayHaveTopics() && !$childUnread)
$childUnread = $forumCategories->checkCategoryUnread($child->info, $currentUser);
$child->perms = $childPerms;
$child->unread = $childUnread;
}
}
if($categoryInfo->mayHaveTopics()) {
$topicInfos = $forumTopics->getTopics(
categoryInfo: $categoryInfo,
global: true,
deleted: $perms->check(Perm::F_POST_DELETE_ANY) ? null : false,
pagination: $forumPagination,
);
foreach($topicInfos as $topicInfo) {
$topics[] = $topic = new stdClass;
$topic->info = $topicInfo;
$topic->unread = $forumTopics->checkTopicUnread($topicInfo, $currentUser);
$topic->participated = $forumTopics->checkTopicParticipated($topicInfo, $currentUser);
if($topicInfo->hasUserId()) {
$topic->user = $usersCtx->getUserInfo($topicInfo->getUserId());
$topic->colour = $usersCtx->getUserColour($topic->user);
}
try {
$topic->lastPost = new stdClass;
$topic->lastPost->info = $lastPostInfo = $forumPosts->getPost(
topicInfo: $topicInfo,
getLast: true,
deleted: $topicInfo->isDeleted() ? null : false,
);
if($lastPostInfo->hasUserId()) {
$topic->lastPost->user = $usersCtx->getUserInfo($lastPostInfo->getUserId());
$topic->lastPost->colour = $usersCtx->getUserColour($topic->lastPost->user);
}
} catch(RuntimeException $ex) {
$topic->lastPost = null;
}
}
}
$perms = $perms->checkMany([
'can_create_topic' => Perm::F_TOPIC_CREATE,
]);
Template::render('forum.forum', [
'forum_breadcrumbs' => forum_get_breadcrumbs($forum['forum_id']),
'global_accent_colour' => forum_get_colour($forum['forum_id']),
'forum_may_have_topics' => $forumMayHaveTopics,
'forum_may_have_children' => $forumMayHaveChildren,
'forum_info' => $forum,
'forum_breadcrumbs' => iterator_to_array($forumCategories->getCategoryAncestry($categoryInfo)),
'global_accent_colour' => $forumCategories->getCategoryColour($categoryInfo),
'forum_info' => $categoryInfo,
'forum_children' => $children,
'forum_topics' => $topics,
'forum_pagination' => $forumPagination,
'forum_show_mark_as_read' => $currentUser !== null,
'forum_perms' => $perms,
]);

View file

@ -1,39 +1,191 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use stdClass;
use RuntimeException;
$indexMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
$forumId = !empty($_GET['f']) && is_string($_GET['f']) ? (int)$_GET['f'] : 0;
$forumCtx = $msz->getForumContext();
$forumCategories = $forumCtx->getCategories();
$forumTopics = $forumCtx->getTopics();
$forumPosts = $forumCtx->getPosts();
$usersCtx = $msz->getUsersContext();
$mode = (string)filter_input(INPUT_GET, 'm');
$currentUser = User::getCurrent();
$currentUserId = $currentUser === null ? 0 : $currentUser->getId();
$authInfo = $msz->getAuthInfo();
$currentUser = $authInfo->getUserInfo();
$currentUserId = $currentUser === null ? '0' : $currentUser->getId();
switch($indexMode) {
case 'mark':
url_redirect($forumId < 1 ? 'forum-mark-global' : 'forum-mark-single', ['forum' => $forumId]);
break;
if($mode === 'mark') {
if(!$authInfo->isLoggedIn())
Template::throwError(403);
default:
$categories = forum_get_root_categories($currentUserId);
$blankForum = count($categories) < 1;
$categoryId = filter_input(INPUT_GET, 'f', FILTER_SANITIZE_NUMBER_INT);
foreach($categories as $key => $category) {
$categories[$key]['forum_subforums'] = forum_get_children($category['forum_id'], $currentUserId);
if($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
$categoryInfos = $categoryId === null
? $forumCategories->getCategories()
: $forumCategories->getCategoryChildren(parentInfo: $categoryId, includeSelf: true);
foreach($categories[$key]['forum_subforums'] as $skey => $sub) {
if(!forum_may_have_children($sub['forum_type'])) {
continue;
}
$categories[$key]['forum_subforums'][$skey]['forum_subforums']
= forum_get_children($sub['forum_id'], $currentUserId);
}
foreach($categoryInfos as $categoryInfo) {
$perms = $authInfo->getPerms('forum', $categoryInfo);
if($perms->check(Perm::F_CATEGORY_LIST))
$forumCategories->updateUserReadCategory($userInfo, $categoryInfo);
}
Template::render('forum.index', [
'forum_categories' => $categories,
'forum_empty' => $blankForum,
]);
break;
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]),
'params' => [
'forum' => $categoryId,
]
]);
return;
}
if($mode !== '')
Template::throwError(404);
$categories = $forumCategories->getCategories(hidden: false, asTree: true);
foreach($categories as $categoryId => $category) {
$perms = $authInfo->getPerms('forum', $category->info);
if(!$perms->check(Perm::F_CATEGORY_LIST)) {
unset($categories[$categoryId]);
continue;
}
$unread = false;
if($category->info->mayHaveChildren())
foreach($category->children as $childId => $child) {
$childPerms = $authInfo->getPerms('forum', $child->info);
if(!$childPerms->check(Perm::F_CATEGORY_LIST)) {
unset($category->children[$childId]);
continue;
}
$childUnread = false;
if($category->info->isListing()) {
if($child->info->mayHaveChildren()) {
foreach($child->children as $grandChildId => $grandChild) {
$grandChildPerms = $authInfo->getPerms('forum', $grandChild->info);
if(!$grandChildPerms->check(Perm::F_CATEGORY_LIST)) {
unset($child->children[$grandChildId]);
continue;
}
$grandChildUnread = false;
if($grandChild->info->mayHaveTopics()) {
$catIds = [$grandChild->info->getId()];
foreach($grandChild->childIds as $greatGrandChildId) {
$greatGrandChildPerms = $authInfo->getPerms('forum', $greatGrandChildId);
if($greatGrandChildPerms->check(Perm::F_CATEGORY_LIST))
$catIds[] = $greatGrandChildId;
}
$grandChildUnread = $forumCategories->checkCategoryUnread($catIds, $currentUser);
if($grandChildUnread)
$childUnread = true;
}
$grandChild->perms = $grandChildPerms;
$grandChild->unread = $grandChildUnread;
}
}
if($child->info->mayHaveChildren() || $child->info->mayHaveTopics()) {
$catIds = [$child->info->getId()];
foreach($child->childIds as $grandChildId) {
$grandChildPerms = $authInfo->getPerms('forum', $grandChildId);
if($grandChildPerms->check(Perm::F_CATEGORY_LIST))
$catIds[] = $grandChildId;
}
try {
$lastPostInfo = $forumPosts->getPost(categoryInfos: $catIds, getLast: true, deleted: false);
} catch(RuntimeException $ex) {
$lastPostInfo = null;
}
if($lastPostInfo !== null) {
$child->lastPost = new stdClass;
$child->lastPost->info = $lastPostInfo;
$child->lastPost->topicInfo = $forumTopics->getTopic(postInfo: $lastPostInfo);
if($lastPostInfo->hasUserId()) {
$child->lastPost->user = $usersCtx->getUserInfo($lastPostInfo->getUserId());
$child->lastPost->colour = $usersCtx->getUserColour($child->lastPost->user);
}
}
}
}
if($child->info->mayHaveTopics() && !$childUnread) {
$childUnread = $forumCategories->checkCategoryUnread($child->info, $currentUser);
if($childUnread)
$unread = true;
}
$child->perms = $childPerms;
$child->unread = $childUnread;
}
if($category->info->mayHaveTopics() && !$unread)
$unread = $forumCategories->checkCategoryUnread($category->info, $currentUser);
if(!$category->info->isListing()) {
if(!array_key_exists('0', $categories)) {
$categories['0'] = $root = new stdClass;
$root->info = null;
$root->perms = 0;
$root->unread = false;
$root->colour = null;
$root->children = [];
}
$categories['0']->children[$categoryId] = $category;
unset($categories[$categoryId]);
if($category->info->mayHaveChildren() || $category->info->mayHaveTopics()) {
$catIds = [$category->info->getId()];
foreach($category->childIds as $childId) {
$childPerms = $authInfo->getPerms('forum', $childId);
if($childPerms->check(Perm::F_CATEGORY_LIST))
$catIds[] = $childId;
}
try {
$lastPostInfo = $forumPosts->getPost(categoryInfos: $catIds, getLast: true, deleted: false);
} catch(RuntimeException $ex) {
$lastPostInfo = null;
}
if($lastPostInfo !== null) {
$category->lastPost = new stdClass;
$category->lastPost->info = $lastPostInfo;
$category->lastPost->topicInfo = $forumTopics->getTopic(postInfo: $lastPostInfo);
if($lastPostInfo->hasUserId()) {
$category->lastPost->user = $usersCtx->getUserInfo($lastPostInfo->getUserId());
$category->lastPost->colour = $usersCtx->getUserColour($category->lastPost->user);
}
}
}
}
$category->perms = $perms;
$category->unread = $unread;
}
Template::render('forum.index', [
'forum_categories' => $categories,
'forum_empty' => empty($categories),
'forum_show_mark_as_read' => $currentUser !== null,
]);

View file

@ -1,65 +1,113 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use RuntimeException;
if(!User::hasCurrent() || !perms_check_user(MSZ_PERMS_FORUM, User::getCurrent()->getId(), MSZ_PERM_FORUM_VIEW_LEADERBOARD)) {
echo render_error(403);
return;
if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_FORUM_LEADERBOARD_VIEW))
Template::throwError(403);
$forumCtx = $msz->getForumContext();
$usersCtx = $msz->getUsersContext();
$config = $cfg->getValues([
['forum_leader.first_year:i', 2018],
['forum_leader.first_month:i', 12],
'forum_leader.unranked.forum:a',
'forum_leader.unranked.topic:a',
]);
$mode = (string)filter_input(INPUT_GET, 'mode');
$yearMonth = (string)filter_input(INPUT_GET, 'id');
$year = $month = 0;
$currentYear = (int)date('Y');
$currentMonth = (int)date('m');
if(!empty($yearMonth)) {
$yearMonthLength = strlen($yearMonth);
if(($yearMonthLength !== 4 && $yearMonthLength !== 6) || !ctype_digit($yearMonth))
Template::throwError(404);
$year = (int)substr($yearMonth, 0, 4);
if($year < $config['forum_leader.first_year'] || $year > $currentYear)
Template::throwError(404);
if($yearMonthLength === 6) {
$month = (int)substr($yearMonth, 4, 2);
if($month < 1 || $month > 12 || ($year === $config['forum_leader.first_year'] && $month < $config['forum_leader.first_month']))
Template::throwError(404);
}
}
$leaderboardMode = !empty($_GET['mode']) && is_string($_GET['mode']) && ctype_lower($_GET['mode']) ? $_GET['mode'] : '';
$leaderboardId = !empty($_GET['id']) && is_string($_GET['id'])
&& ctype_digit($_GET['id'])
? $_GET['id']
: MSZ_FORUM_LEADERBOARD_CATEGORY_ALL;
$leaderboardIdLength = strlen($leaderboardId);
$leaderboardYear = $leaderboardIdLength === 4 || $leaderboardIdLength === 6 ? substr($leaderboardId, 0, 4) : null;
$leaderboardMonth = $leaderboardIdLength === 6 ? substr($leaderboardId, 4, 2) : null;
if(empty($_GET['allow_unranked'])) {
[
'forum_leader.unranked.forum' => $unrankedForums,
'forum_leader.unranked.topic' => $unrankedTopics,
] = $cfg->getValues([
'forum_leader.unranked.forum:a',
'forum_leader.unranked.topic:a',
]);
} else $unrankedForums = $unrankedTopics = [];
$leaderboards = forum_leaderboard_categories();
$leaderboard = forum_leaderboard_listing($leaderboardYear, $leaderboardMonth, $unrankedForums, $unrankedTopics);
$leaderboardName = 'All Time';
if($leaderboardYear) {
$leaderboardName = "Leaderboard {$leaderboardYear}";
if($leaderboardMonth)
$leaderboardName .= "-{$leaderboardMonth}";
if(filter_has_var(INPUT_GET, 'allow_unranked')) {
$unrankedForums = $unrankedTopics = [];
} else {
$unrankedForums = $config['forum_leader.unranked.forum'];
$unrankedTopics = $config['forum_leader.unranked.topic'];
}
if($leaderboardMode === 'markdown') {
$years = $months = [];
for($i = $currentYear; $i >= $config['forum_leader.first_year']; $i--)
$years[(string)$i] = sprintf('Leaderboard %d', $i);
for($i = $currentYear, $j = $currentMonth;;) {
$months[sprintf('%d%02d', $i, $j)] = sprintf('Leaderboard %d-%02d', $i, $j);
if($j <= 1) {
$i--; $j = 12;
} else $j--;
if($i <= $config['forum_leader.first_year'] && $j < $config['forum_leader.first_month'])
break;
}
$rankings = $forumCtx->getPosts()->generatePostRankings($year, $month, $unrankedForums, $unrankedTopics);
foreach($rankings as $ranking) {
$ranking->user = $ranking->colour = null;
if($ranking->userId !== '')
$ranking->user = $usersCtx->getUserInfo($ranking->userId);
}
$name = 'All Time';
if($year > 0) {
$name = sprintf("Leaderboard %04d", $year);
if($month > 0)
$name .= sprintf("-%02d", $month);
}
if($mode === 'markdown') {
$markdown = <<<MD
# {$leaderboardName}
# {$name}
| Rank | Usename | Post count |
| ----:|:------- | ----------:|
MD;
foreach($leaderboard as $user) {
$markdown .= sprintf("| %s | [%s](%s%s) | %s |\r\n", $user['rank'], $user['username'], url_prefix(false), url('user-profile', ['user' => $user['user_id']]), $user['posts']);
$totalPostsCount = 0;
foreach($rankings as $ranking) {
$totalPostsCount += $ranking->postsCount;
$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]),
number_format($ranking->postsCount));
}
$markdown .= sprintf("\r\nIn total %s posts were made!\r\n", number_format($totalPostsCount));
Template::set('leaderboard_markdown', $markdown);
}
Template::render('forum.leaderboard', [
'leaderboard_id' => $leaderboardId,
'leaderboard_name' => $leaderboardName,
'leaderboard_categories' => $leaderboards,
'leaderboard_data' => $leaderboard,
'leaderboard_mode' => $leaderboardMode,
'leaderboard_id' => $yearMonth,
'leaderboard_name' => $name,
'leaderboard_years' => $years,
'leaderboard_months' => $months,
'leaderboard_data' => $rankings,
'leaderboard_mode' => $mode,
]);

View file

@ -1,8 +1,12 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
use RuntimeException;
$urls = $msz->getUrls();
$forumCtx = $msz->getForumContext();
$forumPosts = $forumCtx->getPosts();
$usersCtx = $msz->getUsersContext();
$postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0;
$postMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
@ -10,206 +14,129 @@ $submissionConfirmed = !empty($_GET['confirm']) && is_string($_GET['confirm']) &
$postRequestVerified = CSRF::validateRequest();
if(!empty($postMode) && !UserSession::hasCurrent()) {
echo render_info('You must be logged in to manage posts.', 401);
return;
$authInfo = $msz->getAuthInfo();
if(!empty($postMode) && !$authInfo->isLoggedIn())
Template::displayInfo('You must be logged in to manage posts.', 401);
$currentUser = $authInfo->getUserInfo();
$currentUserId = $currentUser === null ? '0' : $currentUser->getId();
if($postMode !== '' && $usersCtx->hasActiveBan($currentUser))
Template::displayInfo('You have been banned, check your profile for more information.', 403);
try {
$postInfo = $forumPosts->getPost(postId: $postId);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
$currentUser = User::getCurrent();
$currentUserId = $currentUser === null ? 0 : $currentUser->getId();
$perms = $authInfo->getPerms('forum', $postInfo->getCategoryId());
if(isset($currentUser) && $currentUser->isBanned()) {
echo render_info('You have been banned, check your profile for more information.', 403);
return;
}
if(isset($currentUser) && $currentUser->isSilenced()) {
echo render_info('You have been silenced, check your profile for more information.', 403);
return;
}
if(!$perms->check(Perm::F_CATEGORY_VIEW))
Template::throwError(403);
$postInfo = forum_post_get($postId, true);
$perms = empty($postInfo)
? 0
: forum_perms_get_user($postInfo['forum_id'], $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
$canDeleteAny = $perms->check(Perm::F_POST_DELETE_ANY);
switch($postMode) {
case 'delete':
$canDelete = forum_post_can_delete($postInfo, $currentUserId);
$canDeleteMsg = '';
$responseCode = 200;
if($canDeleteAny) {
if($postInfo->isDeleted())
Template::displayInfo('This post has already been marked as deleted.', 404);
} else {
if($postInfo->isDeleted())
Template::throwError(404);
switch($canDelete) {
case MSZ_E_FORUM_POST_DELETE_USER: // i don't think this is ever reached but we may as well have it
$responseCode = 401;
$canDeleteMsg = 'You must be logged in to delete posts.';
break;
case MSZ_E_FORUM_POST_DELETE_POST:
$responseCode = 404;
$canDeleteMsg = "This post doesn't exist.";
break;
case MSZ_E_FORUM_POST_DELETE_DELETED:
$responseCode = 404;
$canDeleteMsg = 'This post has already been marked as deleted.';
break;
case MSZ_E_FORUM_POST_DELETE_OWNER:
$responseCode = 403;
$canDeleteMsg = 'You can only delete your own posts.';
break;
case MSZ_E_FORUM_POST_DELETE_OLD:
$responseCode = 401;
$canDeleteMsg = 'This post has existed for too long. Ask a moderator to remove if it absolutely necessary.';
break;
case MSZ_E_FORUM_POST_DELETE_PERM:
$responseCode = 401;
$canDeleteMsg = 'You are not allowed to delete posts.';
break;
case MSZ_E_FORUM_POST_DELETE_OP:
$responseCode = 403;
$canDeleteMsg = 'This is the opening post of a topic, it may not be deleted without deleting the entire topic as well.';
break;
case MSZ_E_FORUM_POST_DELETE_OK:
break;
default:
$responseCode = 500;
$canDeleteMsg = sprintf('Unknown error \'%d\'', $canDelete);
if(!$perms->check(Perm::F_POST_DELETE_OWN))
Template::displayInfo('You are not allowed to delete posts.', 403);
if($postInfo->getUserId() !== $currentUser->getId())
Template::displayInfo('You can only delete your own posts.', 403);
// posts may only be deleted within a week of creation, this should be a config value
$deleteTimeFrame = 60 * 60 * 24 * 7;
if($postInfo->getCreatedTime() < time() - $deleteTimeFrame)
Template::displayInfo('This post has existed for too long. Ask a moderator to remove if it absolutely necessary.', 403);
}
if($canDelete !== MSZ_E_FORUM_POST_DELETE_OK) {
echo render_info($canDeleteMsg, $responseCode);
break;
}
$originalPostInfo = $forumPosts->getPost(topicInfo: $postInfo->getTopicId());
if($originalPostInfo->getId() === $postInfo->getId())
Template::displayInfo('This is the opening post of the topic it belongs to, it may not be deleted without deleting the entire topic as well.', 403);
if($postRequestVerified && !$submissionConfirmed) {
url_redirect('forum-post', [
'post' => $postInfo['post_id'],
'post_fragment' => 'p' . $postInfo['post_id'],
]);
Tools::redirect($urls->format('forum-post', ['post' => $postInfo->getId()]));
break;
} elseif(!$postRequestVerified) {
Template::render('forum.confirm', [
'title' => 'Confirm post deletion',
'class' => 'far fa-trash-alt',
'message' => sprintf('You are about to delete post #%d. Are you sure about that?', $postInfo['post_id']),
'message' => sprintf('You are about to delete post #%d. Are you sure about that?', $postInfo->getId()),
'params' => [
'p' => $postInfo['post_id'],
'p' => $postInfo->getId(),
'm' => 'delete',
],
]);
break;
}
$deletePost = forum_post_delete($postInfo['post_id']);
$forumPosts->deletePost($postInfo);
$msz->createAuditLog('FORUM_POST_DELETE', [$postInfo->getId()]);
if($deletePost) {
$msz->createAuditLog('FORUM_POST_DELETE', [$postInfo['post_id']]);
}
if(!$deletePost) {
echo render_error(500);
break;
}
url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
Tools::redirect($urls->format('forum-topic', ['topic' => $postInfo->getTopicId()]));
break;
case 'nuke':
if(!perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST)) {
echo render_error(403);
break;
}
if(!$canDeleteAny)
Template::throwError(403);
if($postRequestVerified && !$submissionConfirmed) {
url_redirect('forum-post', [
'post' => $postInfo['post_id'],
'post_fragment' => 'p' . $postInfo['post_id'],
]);
Tools::redirect($urls->format('forum-post', ['post' => $postInfo->getId()]));
break;
} elseif(!$postRequestVerified) {
Template::render('forum.confirm', [
'title' => 'Confirm post nuke',
'class' => 'fas fa-radiation',
'message' => sprintf('You are about to PERMANENTLY DELETE post #%d. Are you sure about that?', $postInfo['post_id']),
'message' => sprintf('You are about to PERMANENTLY DELETE post #%d. Are you sure about that?', $postInfo->getId()),
'params' => [
'p' => $postInfo['post_id'],
'p' => $postInfo->getId(),
'm' => 'nuke',
],
]);
break;
}
$nukePost = forum_post_nuke($postInfo['post_id']);
$forumPosts->nukePost($postInfo->getId());
$msz->createAuditLog('FORUM_POST_NUKE', [$postInfo->getId()]);
if(!$nukePost) {
echo render_error(500);
break;
}
$msz->createAuditLog('FORUM_POST_NUKE', [$postInfo['post_id']]);
url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
Tools::redirect($urls->format('forum-topic', ['topic' => $postInfo->getTopicId()]));
break;
case 'restore':
if(!perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST)) {
echo render_error(403);
break;
}
if(!$canDeleteAny)
Template::throwError(403);
if($postRequestVerified && !$submissionConfirmed) {
url_redirect('forum-post', [
'post' => $postInfo['post_id'],
'post_fragment' => 'p' . $postInfo['post_id'],
]);
Tools::redirect($urls->format('forum-post', ['post' => $postInfo->getId()]));
break;
} elseif(!$postRequestVerified) {
Template::render('forum.confirm', [
'title' => 'Confirm post restore',
'class' => 'fas fa-magic',
'message' => sprintf('You are about to restore post #%d. Are you sure about that?', $postInfo['post_id']),
'message' => sprintf('You are about to restore post #%d. Are you sure about that?', $postInfo->getId()),
'params' => [
'p' => $postInfo['post_id'],
'p' => $postInfo->getId(),
'm' => 'restore',
],
]);
break;
}
$restorePost = forum_post_restore($postInfo['post_id']);
$forumPosts->restorePost($postInfo->getId());
$msz->createAuditLog('FORUM_POST_RESTORE', [$postInfo->getId()]);
if(!$restorePost) {
echo render_error(500);
break;
}
$msz->createAuditLog('FORUM_POST_RESTORE', [$postInfo['post_id']]);
url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
Tools::redirect($urls->format('forum-topic', ['topic' => $postInfo->getTopicId()]));
break;
default: // function as an alt for topic.php?p= by default
$canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
if(!empty($postInfo['post_deleted']) && !$canDeleteAny) {
echo render_error(404);
break;
}
$postFind = forum_post_find($postInfo['post_id'], $currentUserId);
if(empty($postFind)) {
echo render_error(404);
break;
}
if($canDeleteAny) {
$postInfo['preceeding_post_count'] += $postInfo['preceeding_post_deleted_count'];
}
unset($postInfo['preceeding_post_deleted_count']);
url_redirect('forum-topic', [
'topic' => $postFind['topic_id'],
'page' => floor($postFind['preceeding_post_count'] / MSZ_FORUM_POSTS_PER_PAGE) + 1,
]);
Tools::redirect($urls->format('forum-post', ['post' => $postInfo->getId()]));
break;
}

View file

@ -1,22 +1,27 @@
<?php
namespace Misuzu;
use stdClass;
use RuntimeException;
use Misuzu\Forum\ForumTopicInfo;
use Misuzu\Parsers\Parser;
use Misuzu\Users\User;
use Index\XDateTime;
use Carbon\CarbonImmutable;
$currentUser = User::getCurrent();
$authInfo = $msz->getAuthInfo();
if(!$authInfo->isLoggedIn())
Template::throwError(401);
if($currentUser === null) {
echo render_error(401);
return;
}
$forumCtx = $msz->getForumContext();
$forumCategories = $forumCtx->getCategories();
$forumTopics = $forumCtx->getTopics();
$forumPosts = $forumCtx->getPosts();
$usersCtx = $msz->getUsersContext();
$currentUser = $authInfo->getUserInfo();
$currentUserId = $currentUser->getId();
if($currentUser->hasActiveWarning()) {
echo render_error(403);
return;
}
if($usersCtx->hasActiveBan($currentUser))
Template::throwError(403);
$forumPostingModes = [
'create', 'edit', 'quote', 'preview',
@ -34,10 +39,8 @@ if(!empty($_POST)) {
$forumId = !empty($_GET['f']) && is_string($_GET['f']) ? (int)$_GET['f'] : 0;
}
if(!in_array($mode, $forumPostingModes, true)) {
echo render_error(400);
return;
}
if(!in_array($mode, $forumPostingModes, true))
Template::throwError(400);
if($mode === 'preview') {
header('Content-Type: text/plain; charset=utf-8');
@ -55,79 +58,83 @@ if($mode === 'preview') {
return;
}
if(empty($postId) && empty($topicId) && empty($forumId)) {
echo render_error(404);
return;
}
if(empty($postId) && empty($topicId) && empty($forumId))
Template::throwError(404);
if(!empty($postId)) {
$post = forum_post_get($postId);
if(isset($post['topic_id'])) { // should automatic cross-quoting be a thing? if so, check if $topicId is < 1 first
$topicId = (int)$post['topic_id'];
if(empty($postId)) {
$hasPostInfo = false;
} else {
try {
$postInfo = $forumPosts->getPost(postId: $postId);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
if($postInfo->isDeleted())
Template::throwError(404);
// should automatic cross-quoting be a thing? if so, check if $topicId is < 1 first <-- what did i mean by this?
$topicId = $postInfo->getTopicId();
$hasPostInfo = true;
}
if(!empty($topicId)) {
$topic = forum_topic_get($topicId);
if(isset($topic['forum_id'])) {
$forumId = (int)$topic['forum_id'];
if(empty($topicId)) {
$hasTopicInfo = false;
} else {
try {
$topicInfo = $forumTopics->getTopic(topicId: $topicId);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
if($topicInfo->isDeleted())
Template::throwError(404);
$forumId = $topicInfo->getCategoryId();
$originalPostInfo = $forumPosts->getPost(topicInfo: $topicInfo);
$hasTopicInfo = true;
}
if(!empty($forumId)) {
$forum = forum_get($forumId);
if(empty($forumId)) {
$hasCategoryInfo = false;
} else {
try {
$categoryInfo = $forumCategories->getCategory(categoryId: $forumId);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
$hasCategoryInfo = true;
}
if(empty($forum)) {
echo render_error(404);
return;
}
$perms = $authInfo->getPerms('forum', $categoryInfo);
$perms = forum_perms_get_user($forum['forum_id'], $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
if($categoryInfo->isArchived()
|| (isset($topicInfo) && $topicInfo->isLocked() && !$perms->check(Perm::F_TOPIC_LOCK))
|| !$perms->check(Perm::F_CATEGORY_VIEW)
|| !$perms->check(Perm::F_POST_CREATE)
|| (!isset($topicInfo) && !$perms->check(Perm::F_TOPIC_CREATE)))
Template::throwError(403);
if($forum['forum_archived']
|| (!empty($topic['topic_locked']) && !perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC))
|| !perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM | MSZ_FORUM_PERM_CREATE_POST)
|| (empty($topic) && !perms_check($perms, MSZ_FORUM_PERM_CREATE_TOPIC))) {
echo render_error(403);
return;
}
if(!forum_may_have_topics($forum['forum_type'])) {
echo render_error(400);
return;
}
if(!$categoryInfo->mayHaveTopics())
Template::throwError(400);
$topicTypes = [];
if($mode === 'create' || $mode === 'edit') {
$topicTypes[MSZ_TOPIC_TYPE_DISCUSSION] = 'Normal discussion';
$topicTypes['discussion'] = 'Normal discussion';
if(perms_check($perms, MSZ_FORUM_PERM_STICKY_TOPIC)) {
$topicTypes[MSZ_TOPIC_TYPE_STICKY] = 'Sticky topic';
}
if(perms_check($perms, MSZ_FORUM_PERM_ANNOUNCE_TOPIC)) {
$topicTypes[MSZ_TOPIC_TYPE_ANNOUNCEMENT] = 'Announcement';
}
if(perms_check($perms, MSZ_FORUM_PERM_GLOBAL_ANNOUNCE_TOPIC)) {
$topicTypes[MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT] = 'Global Announcement';
}
if($perms->check(Perm::F_TOPIC_STICKY))
$topicTypes['sticky'] = 'Sticky topic';
if($perms->check(Perm::F_TOPIC_ANNOUNCE_LOCAL))
$topicTypes['announce'] = 'Announcement';
if($perms->check(Perm::F_TOPIC_ANNOUNCE_GLOBAL))
$topicTypes['global'] = 'Global Announcement';
}
// edit mode stuff
if($mode === 'edit') {
if(empty($post)) {
echo render_error(404);
return;
}
if(!perms_check($perms, $post['poster_id'] === $currentUserId ? MSZ_FORUM_PERM_EDIT_POST : MSZ_FORUM_PERM_EDIT_ANY_POST)) {
echo render_error(403);
return;
}
}
if($mode === 'edit' && !$perms->check($postInfo->getUserId() === $currentUserId ? Perm::F_POST_EDIT_OWN : Perm::F_POST_EDIT_ANY))
Template::throwError(403);
$notices = [];
@ -135,38 +142,45 @@ if(!empty($_POST)) {
$topicTitle = $_POST['post']['title'] ?? '';
$postText = $_POST['post']['text'] ?? '';
$postParser = (int)($_POST['post']['parser'] ?? Parser::BBCODE);
$topicType = isset($_POST['post']['type']) ? (int)$_POST['post']['type'] : null;
$topicType = isset($_POST['post']['type']) ? $_POST['post']['type'] : null;
$postSignature = isset($_POST['post']['signature']);
if(!CSRF::validateRequest()) {
$notices[] = 'Could not verify request.';
} else {
$isEditingTopic = empty($topic) || ($mode === 'edit' && $post['is_opening_post']);
$isEditingTopic = empty($topicInfo) || ($mode === 'edit' && $originalPostInfo->getId() == $postInfo->getId());
if($mode === 'create') {
$timeoutCheck = max(1, forum_timeout($forumId, $currentUserId));
$postTimeout = $cfg->getInteger('forum.posting.timeout', 5);
if($postTimeout > 0) {
$postTimeoutThreshold = new CarbonImmutable(sprintf('-%d seconds', $postTimeout));
$lastPostCreatedAt = $forumPosts->getUserLastPostCreatedAt($currentUser);
if($timeoutCheck < 5) {
$notices[] = sprintf("You're posting too quickly! Please wait %s seconds before posting again.", number_format($timeoutCheck));
$notices[] = "It's possible that your post went through successfully and you pressed the submit button twice by accident.";
if(XDateTime::compare($lastPostCreatedAt, $postTimeoutThreshold) > 0) {
$waitSeconds = $postTimeout + ((int)$lastPostCreatedAt->format('U') - 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.";
}
}
}
if($isEditingTopic) {
$originalTopicTitle = $topic['topic_title'] ?? null;
$originalTopicTitle = $topicInfo?->getTitle() ?? null;
$topicTitleChanged = $topicTitle !== $originalTopicTitle;
$originalTopicType = (int)($topic['topic_type'] ?? MSZ_TOPIC_TYPE_DISCUSSION);
$originalTopicType = $topicInfo?->getTypeString() ?? 'discussion';
$topicTypeChanged = $topicType !== null && $topicType !== $originalTopicType;
switch(forum_validate_title($topicTitle)) {
case 'too-short':
$notices[] = 'Topic title was too short.';
break;
$topicTitleLengths = $cfg->getValues([
['forum.topic.minLength:i', 3],
['forum.topic.maxLength:i', 100],
]);
case 'too-long':
$notices[] = 'Topic title was too long.';
break;
}
$topicTitleLength = mb_strlen(trim($topicTitle));
if($topicTitleLength < $topicTitleLengths['forum.topic.minLength'])
$notices[] = 'Topic title was too short.';
elseif($topicTitleLength > $topicTitleLengths['forum.topic.maxLength'])
$notices[] = 'Topic title was too long.';
if($mode === 'create' && $topicType === null) {
$topicType = array_key_first($topicTypes);
@ -175,92 +189,121 @@ if(!empty($_POST)) {
}
}
if(!Parser::isValid($postParser)) {
if(!Parser::isValid($postParser))
$notices[] = 'Invalid parser selected.';
}
switch(forum_validate_post($postText)) {
case 'too-short':
$notices[] = 'Post content was too short.';
break;
$postTextLengths = $cfg->getValues([
['forum.post.minLength:i', 1],
['forum.post.maxLength:i', 60000],
]);
case 'too-long':
$notices[] = 'Post content was too long.';
break;
}
$postTextLength = mb_strlen(trim($postText));
if($postTextLength < $postTextLengths['forum.post.minLength'])
$notices[] = 'Post content was too short.';
elseif($postTextLength > $postTextLengths['forum.post.maxLength'])
$notices[] = 'Post content was too long.';
if(empty($notices)) {
switch($mode) {
case 'create':
if(!empty($topic)) {
forum_topic_bump($topic['topic_id']);
} else {
$topicId = forum_topic_create(
$forum['forum_id'],
$currentUserId,
if(empty($topicInfo)) {
$topicInfo = $forumTopics->createTopic(
$categoryInfo,
$currentUser,
$topicTitle,
$topicType
);
}
$postId = forum_post_create(
$topicId = $topicInfo->getId();
$forumCategories->incrementCategoryTopics($categoryInfo);
} else
$forumTopics->bumpTopic($topicInfo);
$postInfo = $forumPosts->createPost(
$topicId,
$forum['forum_id'],
$currentUserId,
$currentUser,
$_SERVER['REMOTE_ADDR'],
$postText,
$postParser,
$postSignature
$postSignature,
$categoryInfo
);
forum_topic_mark_read($currentUserId, $topicId, $forum['forum_id']);
forum_count_increase($forum['forum_id'], empty($topic));
$postId = $postInfo->getId();
$forumCategories->incrementCategoryPosts($categoryInfo);
break;
case 'edit':
$markUpdated = $post['poster_id'] === $currentUserId
&& $post['post_created_unix'] < strtotime('-1 minutes')
&& $postText !== $post['post_text'];
$markUpdated = $postInfo->getUserId() === $currentUserId
&& $postInfo->shouldMarkAsEdited()
&& $postText !== $postInfo->getBody();
if(!forum_post_update($postId, $_SERVER['REMOTE_ADDR'], $postText, $postParser, $postSignature, $markUpdated)) {
$notices[] = 'Post edit failed.';
}
$forumPosts->updatePost(
$postId,
remoteAddr: $_SERVER['REMOTE_ADDR'],
body: $postText,
bodyParser: $postParser,
displaySignature: $postSignature,
bumpEdited: $markUpdated
);
if($isEditingTopic && ($topicTitleChanged || $topicTypeChanged)) {
if(!forum_topic_update($topicId, $topicTitle, $topicType)) {
$notices[] = 'Topic update failed.';
}
}
if($isEditingTopic && ($topicTitleChanged || $topicTypeChanged))
$forumTopics->updateTopic(
$topicId,
title: $topicTitle,
type: $topicType
);
break;
}
if(empty($notices)) {
$redirect = url(empty($topic) ? 'forum-topic' : 'forum-post', [
// does this ternary ever return forum-topic?
$redirect = $msz->getUrls()->format(empty($topicInfo) ? 'forum-topic' : 'forum-post', [
'topic' => $topicId ?? 0,
'post' => $postId ?? 0,
'post_fragment' => 'p' . ($postId ?? 0),
]);
redirect($redirect);
Tools::redirect($redirect);
return;
}
}
}
}
if(!empty($topic)) {
Template::set('posting_topic', $topic);
}
if(!empty($topicInfo))
Template::set('posting_topic', $topicInfo);
if($mode === 'edit') { // $post is pretty much sure to be populated at this point
$post = new stdClass;
$post->info = $postInfo;
if($postInfo->hasUserId()) {
$post->user = $usersCtx->getUserInfo($postInfo->getUserId());
$post->colour = $usersCtx->getUserColour($post->user);
$post->postsCount = $forumCtx->countTotalUserPosts($post->user);
}
$post->isOriginalPost = $originalPostInfo->getId() == $postInfo->getId();
$post->isOriginalPoster = $originalPostInfo->hasUserId() && $postInfo->hasUserId()
&& $originalPostInfo->getUserId() === $postInfo->getUserId();
Template::set('posting_post', $post);
}
$displayInfo = forum_posting_info($currentUserId);
try {
$lastPostInfo = $forumPosts->getPost(userInfo: $currentUser, getLast: true, deleted: false);
$selectedParser = $lastPostInfo->getParser();
} catch(RuntimeException $ex) {
$selectedParser = Parser::BBCODE;
}
Template::render('forum.posting', [
'posting_breadcrumbs' => forum_get_breadcrumbs($forumId),
'global_accent_colour' => forum_get_colour($forumId),
'posting_forum' => $forum,
'posting_info' => $displayInfo,
'posting_breadcrumbs' => iterator_to_array($forumCategories->getCategoryAncestry($categoryInfo)),
'global_accent_colour' => $forumCategories->getCategoryColour($categoryInfo),
'posting_user' => $currentUser,
'posting_user_colour' => $usersCtx->getUserColour($currentUser),
'posting_user_posts_count' => $forumCtx->countTotalUserPosts($currentUser),
'posting_user_preferred_parser' => $selectedParser,
'posting_forum' => $categoryInfo,
'posting_notices' => $notices,
'posting_mode' => $mode,
'posting_types' => $topicTypes,

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