Compare commits

...

167 commits

Author SHA1 Message Date
flash 44a4bb6e6f Prevent access to private messages when impersonating a user. 2024-06-02 19:57:58 +00:00
flash 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
flash 1d295df8da Added broom closet PM stats. 2024-06-02 19:43:57 +00:00
flash 6a88ed8b11 Updated libraries. 2024-05-30 22:02:09 +00:00
flash 36bcf1ab1d Built Playpen icon updating into Misuzu.
Was previously handled by a stinky script.
2024-05-30 22:00:41 +00:00
flash 5d3e1d4960 Fixed wrong HTTP verb. 2024-03-30 15:22:11 +00:00
flash 9bb943bacf Fixed various oversights. 2024-03-30 03:19:08 +00:00
flash 107d16cf46 Updated Misuzu to new HTTP router. 2024-03-30 03:14:03 +00:00
flash 0afc5186a7 Fixed error when trying to access a topic with no posts associated. 2024-02-24 22:03:32 +00:00
flash 0300bae994 hurr 2024-02-21 00:31:25 +00:00
flash cb0c64f8ed Stinky fix for impersonation in chat auth. 2024-02-20 23:56:43 +00:00
flash 89ef9d9ad1 Fixed bans no longer working. 2024-02-15 22:55:24 +00:00
flash c02d922dc6 Fixed Forum Activity section always showing up. 2024-02-13 21:22:56 +00:00
flash 80cd6222c4 Fixed profile fields not showing up anymore. 2024-02-11 02:22:22 +00:00
flash 344a3c9160 Missed one! 2024-02-09 16:07:43 +00:00
flash df5dbdf3ad Fixed forum/topic breadcrumbs. 2024-02-08 15:20:44 +00:00
flash c0caceed7b Fixed use of wrong BanInfo constructor. 2024-02-08 15:18:57 +00:00
flash be54ce2c22 Fixed oversights on landing page. 2024-02-08 00:06:23 +00:00
flash 070dc5e782 Added lazy database object creation. 2024-02-07 00:04:45 +00:00
flash b89621cb1a Added PMs to data export. 2024-02-05 22:56:51 +00:00
flash 760cca0e5d whoops 2024-02-02 21:53:36 +00:00
flash fe77f1616c Updated to new EEPROM script. 2024-02-02 21:42:40 +00:00
flash eb81ed7a82 Added notice when recipient is banned. 2024-02-02 02:16:37 +00:00
flash 8ef11afe02 Check if recipient is actually able to receive messages. 2024-02-02 02:07:29 +00:00
flash cca016ba10 Prevent banned users from sending messages. 2024-02-02 01:59:21 +00:00
flash b80151583e Added private messages. 2024-01-30 23:47:02 +00:00
flash d8cc208a85 Use accent-color and color-scheme CSS directives. 2024-01-25 18:17:54 +00:00
flash 4b2f9a2fec Fixed Ctrl+Enter submission not working anymore either. 2024-01-25 00:18:56 +00:00
flash ddb255bf32 Fixed forum post form throwing up the navigation confirmation when it isn't supposed to. 2024-01-25 00:12:53 +00:00
flash 5a70e3f3f1 Include SameSite attribute on cookies. 2024-01-24 22:14:48 +00:00
flash bd3e055323 Rewrote Javascript code. 2024-01-24 21:53:26 +00:00
flash dba5754ccc Fixed error when trying to add a new change. 2024-01-24 18:28:13 +00:00
flash ec6ba3f781 Imported new asset build script. 2024-01-24 18:24:40 +00:00
flash 70ec285f99 Added links to Amimami repositories. 2024-01-18 20:31:08 +00:00
flash 77eadd5bde Adjusted CORS handling for emoticon endpoint. 2024-01-17 19:57:46 +00:00
flash f0fc735975 Updated browserlists. 2024-01-08 13:43:34 +00:00
flash adb80bad9e Added server side image map support. 2024-01-08 13:42:22 +00:00
flash f30cf41f86 Ported boolean attribute support. 2024-01-08 13:36:47 +00:00
flash b4f5dd0660 Removed broken CONSTRAINT from perms table creation. 2023-12-16 18:51:17 +00:00
flash 133e2f420c Fixed markdown styling issues. 2023-12-15 12:56:08 +00:00
flash bf65c95490 Updated highlight.js and created new code theme. 2023-12-15 12:47:01 +00:00
flash 7ef5994da4 Updated Sentry library to 4.0 in Misuzu. 2023-12-15 01:03:57 +00:00
flash 2b34bde413 Fixed error when trying to create a new role. 2023-12-02 02:57:46 +00:00
flash 432615508d Fixed undefined variable. 2023-11-26 22:23:47 +00:00
flash a4cc14e4c1 Libraries have been updated once more. 2023-11-20 19:10:47 +00:00
flash 65e695e9d9 git.flash.moe -> patchii.net 2023-11-20 19:04:59 +00:00
flash 2e6a84b46d Updated source.md. 2023-11-09 20:58:56 +00:00
flash 8f56174637 Supply super user status in auth data. 2023-11-07 14:38:53 +00:00
flash 19fbe59ddd Return to purple. 2023-11-01 09:36:49 +00:00
flash f7a571e551 moguu? 2023-10-21 23:54:41 +00:00
flash 5f57e3fdf4 Use SharpConfig format for the pre-database config. 2023-10-21 23:45:40 +00:00
flash c2836719c7 Updated to use Syokuhou config library. 2023-10-20 22:29:28 +00:00
flash 14c9a1d9f6 Fixed oversight on members list. 2023-10-18 10:16:32 +00:00
flash 4f1e35b566 Fixed overly eager url encoding on the search page. 2023-10-18 10:11:21 +00:00
flash 9aa2a1431e Enable Spookii 2023-10-01 18:44:59 +00:00
flash 4322f2561c Fixed chat routes being broken. 2023-09-11 20:36:20 +00:00
flash 67d9620037 Fixed legacy paths being too / tolerant. 2023-09-11 20:15:48 +00:00
flash 904d220582 Fixed router related explosions. 2023-09-11 20:10:37 +00:00
flash d9b152fb78 Fixed oversight on memberlist. 2023-09-11 19:19:19 +00:00
flash a945cc518a Fixed syntax error in post.php. 2023-09-11 19:18:10 +00:00
flash edc64b45ff Fixed error when trying to view a non-existent topic when logged out. 2023-09-10 21:04:10 +00:00
flash 17e0d1f591 Added Sentry error logging on the server side. 2023-09-10 20:46:58 +00:00
flash 5554c5c28d Removed unused pagination helper function. 2023-09-10 20:12:27 +00:00
flash 55e23c7b5d Fixed CSRF tokens not being added to URLs that need them. 2023-09-10 20:02:11 +00:00
flash e376671136 Attempt at fixing forum issues. 2023-09-10 19:13:36 +00:00
flash 3e49f6e503 Added URL registry attributes. 2023-09-10 00:04:53 +00:00
flash 7db43a2acd Revert "チルノの日"
This reverts commit 099bd899ed.
2023-09-09 11:54:33 +00:00
flash 099bd899ed チルノの日 2023-09-08 23:07:37 +00:00
flash 1248c0d2f6 Moved various .php file redirects into the LegacyRouter. 2023-09-08 20:47:54 +00:00
flash c3bed1c0e3 Rewrote URL registry. 2023-09-08 20:40:48 +00:00
flash 163da8b213 Added separate context class for forum stuff and split up handling of each object type. 2023-09-08 13:22:46 +00:00
flash c68279add9 Cleaned up some things I missed. 2023-09-08 01:05:17 +00:00
flash 737c99280e Make PHPStan happy. 2023-09-08 00:54:19 +00:00
flash 8b0f960c86 Split auth stuff off into own context. 2023-09-08 00:43:00 +00:00
flash c5a284f360 Route registration with attributes! 2023-09-08 00:13:30 +00:00
flash 506d32d210 Fixed incorrect type on latest forum post fetching result. 2023-09-07 20:53:19 +00:00
flash 498ec0cf9a Merge SharpChat permission set into the Misuzu permission system directly. 2023-09-06 20:44:28 +00:00
flash 15e96684c2 Moved authentication related macros out of MisuzuContext. 2023-09-06 20:06:07 +00:00
flash 73e4597e16 Rewrote Satori recent forum post fetch. 2023-09-06 19:35:50 +00:00
flash 9b2c409a24 Moved user related stuff into its own context object. 2023-09-06 13:50:19 +00:00
flash 7190a5f4df Syntactic sugar for mass route registration. 2023-09-06 11:59:44 +00:00
flash 5c67d49225 Fixed edit display threshold. 2023-09-06 11:32:13 +00:00
flash 69e4d05be6 Pluralise Views. 2023-09-06 11:19:54 +00:00
flash 2d0f083e1a Fixed topic read status check. 2023-09-06 11:19:04 +00:00
flash 1da6470928 Switch to Sasae. 2023-08-31 21:33:34 +00:00
flash 9682fa595a Fixed static analysis detections. 2023-08-31 17:14:41 +00:00
flash c14195c4c3 Moved render_info and render_error into Template class. 2023-08-31 15:59:53 +00:00
flash 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
flash 0c9bac473b No longer rely on Referer header for the comments return URL. 2023-08-31 14:39:50 +00:00
flash 061d4c8a8f Fixed leaderboard name not retaining the leading 0. 2023-08-31 00:54:17 +00:00
flash 6fc10984e1 Append total posts count at the end of the leaderboard. 2023-08-31 00:52:14 +00:00
flash e222009dd0 Fixed oversight. 2023-08-31 00:40:07 +00:00
flash 85b629bc08 Fixed missing use statement. 2023-08-31 00:38:20 +00:00
flash 16ea495c7a Added permission for displaying load timings in the footer. 2023-08-31 00:37:09 +00:00
flash ad3fe74275 Removed old database backend. 2023-08-31 00:31:11 +00:00
flash 29426fafc1 Count profile stats using Index database backend. 2023-08-31 00:24:59 +00:00
flash 4d6fb64f3a Added shitty search hack to users class. 2023-08-31 00:19:20 +00:00
flash 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
flash f03c8ebfa5 Moved validation methods into the new Users class. 2023-08-30 23:41:44 +00:00
flash 07a2868159 Rewrote permissions system. 2023-08-30 22:37:21 +00:00
flash ca23822e40 Fixed errors on profiles. 2023-08-28 14:45:32 +00:00
flash 34bd71600a Removed manage.php. 2023-08-28 13:45:36 +00:00
flash 5bab957a7c Fixed user colours in comments sections. 2023-08-28 13:33:39 +00:00
flash 57b9e82c10 Fixed topic type string usage. 2023-08-28 01:41:13 +00:00
flash 460a0ca57d Fixed user colours not showing on forum posts. 2023-08-28 01:32:05 +00:00
flash 39c6269cf3 Rewrote forum backend. 2023-08-28 01:17:34 +00:00
flash fb41c71ee9 Fixed emoticon ordering in chat. 2023-08-07 12:59:08 +00:00
flash 2214dffc5b Fixed profile editing failing due to old argument. 2023-08-06 19:09:59 +00:00
flash bab8b29c5b Fixed error 500 when trying to log in to a non-existing user. 2023-08-06 18:22:39 +00:00
flash 0a11c5525a Fixed oversight regarding RNG ordering of user list. 2023-08-05 13:55:34 +00:00
flash d4f6990e8a Made data source argument lists for News, Changelog, Comments and Emotes consistent with the rest. 2023-08-05 13:50:15 +00:00
flash 87915b6a25 Fixed forum post deletion and editing. 2023-08-04 22:49:09 +00:00
flash cf71129153 Converted all Misuzu style route handlers to Index style ones. 2023-08-04 20:51:02 +00:00
flash 6bfa3d7238 Fixed error 500 when viewing profiles as guest. 2023-08-04 17:44:37 +00:00
flash b7de5acfd8 Fixed search and updated collations of various fields to more appropriate ones. 2023-08-03 12:40:37 +00:00
flash 9dd7156c79 Fixed issue caused by used of dangling variable on sessions page. 2023-08-03 01:43:43 +00:00
flash 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
flash 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
flash 57081d858d Added server side stuff for Satori hooks. 2023-07-29 22:18:20 +00:00
flash e813f2a90e Some TOTP touch-ups. 2023-07-29 20:18:41 +00:00
flash 0158333c90 Removed permissions stuff from the User object. 2023-07-29 18:15:30 +00:00
flash a89d8d26f4 Fixed error when news comments category doesn't exist somehow. 2023-07-29 18:01:41 +00:00
flash e3c0ae662e Removed HasRankInterface. 2023-07-29 17:31:43 +00:00
flash 61daa21d3a Emit audit log upon impersonation. 2023-07-28 23:23:45 +00:00
flash 934b016541 Added counters table for storing numbers of things statically. 2023-07-28 23:17:37 +00:00
flash 8ef113f3a9 Allow non-super users to impersonate select users. 2023-07-28 21:20:19 +00:00
flash a22433f7dd Don't update last online time and ip address when impersonating. 2023-07-28 20:43:08 +00:00
flash c5ec94289d Added notice when there's no account logs to display. 2023-07-28 20:36:16 +00:00
flash 8c52fc81e2 Hide roles section from settings if there's only one available. 2023-07-28 20:33:44 +00:00
flash d2f0eebfb2 Use random alphabetic string instead hex bytes for session tokens. 2023-07-28 20:13:11 +00:00
flash 3148da4403 Rewrote Sessions backend. 2023-07-28 20:06:12 +00:00
flash 5c8ffa09fc Cleaned up User and UserSession queries. 2023-07-28 15:07:30 +00:00
flash 20b309563e Fixed phpstan detections. 2023-07-27 23:49:55 +00:00
flash 461ffbf73b Rewrote user role handling. 2023-07-27 23:26:05 +00:00
flash 26a0e11253 Fixed data export. 2023-07-27 13:14:32 +00:00
flash 70623d3a7c Pluralise user role relations table name. 2023-07-27 13:09:22 +00:00
flash b4d4e8578c Rewrote TFA session code. 2023-07-27 12:44:50 +00:00
flash 8480d5f043 Fixed the manage index statistics causing a 500 because the old warnings table is Gone. 2023-07-26 22:57:03 +00:00
flash a30df1b17c Fixed warning deletion. 2023-07-26 22:48:47 +00:00
flash 351043e283 Split Sharp Chat kick and ban permissions based on the Misuzu warnings and bans permissions. 2023-07-26 22:46:35 +00:00
flash 2231cd8124 Rewrote user warnings backend. 2023-07-26 22:43:50 +00:00
flash 86432616c6 Expiration -> Expires 2023-07-26 18:24:49 +00:00
flash 1d552e907b Added new banning system.
it actually works and isn't confusing this time around!
2023-07-26 18:19:48 +00:00
flash 057551edb3 Pluralise. 2023-07-26 11:56:06 +00:00
flash 710049794f Fixed typo that would cause things to fail. 2023-07-26 11:54:49 +00:00
flash ca1edb4270 Fixed gross misalignment. 2023-07-25 19:03:48 +00:00
flash f4f465d8d8 Redesigned news post preview information section. 2023-07-25 19:02:00 +00:00
flash 81f4dfce19 Fixed error 500 when trying to view a non-existent profile. 2023-07-25 15:03:25 +00:00
flash 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
flash 3299d73df2 Added new moderator notes system. 2023-07-25 14:40:31 +00:00
flash 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
flash 3d67b59238 Attempt 2 at fixing the profile fields issue (this one actually fixes the issue!) 2023-07-22 21:25:51 +00:00
flash dd21fce6e3 Rewrote password recovery token storage using new DB backend. 2023-07-22 21:20:03 +00:00
flash f6058823f1 Fixed error 500 on profiles when filling certain fields in specific conditions. 2023-07-22 20:54:52 +00:00
flash 392881c0d8 Fixed type on getUserId in LoginAttemptInfo. 2023-07-22 17:27:42 +00:00
flash 6e3023a772 Rewrite login attempts log to use new database backend. 2023-07-22 16:37:57 +00:00
flash 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
flash 42d893fc18 Use the Index DbStatementCache implementation. 2023-07-22 14:00:51 +00:00
flash baefea88df Use the Index DbTools version for list prepare thing. 2023-07-22 13:54:42 +00:00
flash e369038609 Updated Index Serialiser usage. 2023-07-21 21:56:09 +00:00
flash 9962bbc5df Added phpstan as a dev dependency. 2023-07-21 19:38:54 +00:00
flash 761bc94b8e Removed local config plugin and fixed Index info pages. 2023-07-21 19:30:28 +00:00
flash ffbe25d0b5 Oops it's all white! 2023-07-21 19:17:57 +00:00
flash e4b647f2c6 Updated project licence to BSD3CC and import Index through Composer. 2023-07-21 18:58:37 +00:00
flash 683462ef71 Just use Composer for DeviceDetector.
It was initially my goal to lessen use of Composer, but given I just switched to using NPM packages for asset compilation, it's a little bit silly to try and keep that going.
2023-07-21 16:12:08 +00:00
flash 14c5635b4f Determine client info on insert rather than on retrieve for speed improvements.
i suppose device detect only ever expects to analyse a single string at once given its made for matomo so it on the slower side for multiple dingusses
2023-07-21 12:47:59 +00:00
flash ebac064c59 Fixed error 500 on logs and sessions pages because of dead library. 2023-07-21 11:33:27 +00:00
348 changed files with 23645 additions and 14334 deletions

7
.gitignore vendored
View file

@ -8,8 +8,11 @@
/npm-debug.log
/yarn-error.log
/lib/index-dev
/composer.local.json
# Configuration
/config/config.cfg
/config/github.cfg
/config/config.ini
/config/github.ini
/.debug
@ -42,3 +45,7 @@
# Google
/public/robots.txt
# moguu?
/public/moguu.swf
/public/moguu.html

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "lib/index"]
path = lib/index
url = https://git.flash.moe/flash/index.git

221
LICENSE
View file

@ -1,201 +1,30 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright (c) 2017-2024, flashwave <me@flash.moe>
All rights reserved.
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Redistribution and use in source and binary forms, with or without
modification, are permitted (subject to the limitations in the disclaimer
below) provided that the following conditions are met:
1. Definitions.
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2017-2023 flashwave <me@flash.moe>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.

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,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.js
#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";

146
build.js
View file

@ -1,3 +1,4 @@
// IMPORTS
const fs = require('fs');
const swc = require('@swc/core');
const path = require('path');
@ -6,25 +7,42 @@ const postcss = require('postcss');
const utils = require('./assets/utils.js');
const assproc = require('./assets/assproc.js');
// CONFIG
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 srcDir = path.join(rootDir, 'assets');
const srcCurrentInfo = path.join(srcDir, '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 pubAssetsDir = path.join(pubDir, 'assets');
const isDebugBuild = fs.existsSync(path.join(rootDir, '.debug'));
const buildTasks = {
js: [
{ source: 'misuzu.js', target: '/assets', name: 'misuzu.{hash}.js', },
],
css: [
{ source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', },
],
};
// PREP
const postcssPlugins = [ require('autoprefixer')({ remove: false }) ];
if(!isDebugBuild)
postcssPlugins.push(require('cssnano')({
preset: [
'cssnano-preset-default',
{
minifyGradients: false,
reduceIdents: false,
zindex: true,
}
],
}));
const swcJscOptions = {
target: 'es2016',
target: 'es2021',
loose: false,
externalHelpers: false,
keepClassNames: true,
@ -51,39 +69,89 @@ const swcJscOptions = {
},
};
const postcssPlugins = [];
if(!isDebugBuild) postcssPlugins.push(require('cssnano'));
postcssPlugins.push(require('autoprefixer')({
remove: false,
}));
fs.mkdirSync(pubAssetsFull, { recursive: true });
// BUILD
(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 files = {};
const mszJsName = await assproc.process(assetsJS, { 'prefix': '#', 'entry': 'main.js' })
.then(output => swc.transform(output, {
filename: 'misuzu.js',
console.log('Ensuring assets directory exists...');
fs.mkdirSync(pubAssetsDir, { recursive: true });
console.log();
console.log('JS assets');
for(const info of buildTasks.js) {
console.log(`=> Building ${info.source}...`);
let origTarget = undefined;
if('es' in info) {
origTarget = swcJscOptions.target;
swcJscOptions.target = info.es;
}
const assprocOpts = {
prefix: '#',
entry: info.entry || 'main.js',
};
const swcOpts = {
filename: info.source,
sourceMaps: false,
isModule: false,
minify: !isDebugBuild,
jsc: swcJscOptions,
}).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;
}));
};
fs.writeFileSync(assetsInfo, JSON.stringify({
mszjs: mszJsName,
mszcss: mszCssName,
}));
const pubName = await assproc.process(path.join(srcDir, info.source), assprocOpts)
.then(output => swc.transform(output, swcOpts))
.then(output => {
const name = utils.strtr(info.name, { hash: utils.shortHash(output.code) });
const pubName = path.join(info.target || '', name);
assproc.housekeep(pubAssetsFull);
console.log(` Saving to ${pubName}...`);
fs.writeFileSync(path.join(pubDir, pubName), output.code);
return pubName;
});
if(origTarget !== undefined)
swcJscOptions.target = origTarget;
files[info.source] = pubName;
}
console.log();
console.log('CSS assets');
for(const info of buildTasks.css) {
console.log(`=> Building ${info.source}...`);
const sourcePath = path.join(srcDir, info.source);
const assprocOpts = {
prefix: '@',
entry: info.entry || 'main.css',
};
const postcssOpts = { from: sourcePath };
files[info.source] = await assproc.process(sourcePath, assprocOpts)
.then(output => postcss(postcssPlugins).process(output, postcssOpts)
.then(output => {
const name = utils.strtr(info.name, { hash: utils.shortHash(output.css) });
const pubName = path.join(info.target || '', name);
console.log(` Saving to ${pubName}...`);
fs.writeFileSync(path.join(pubDir, pubName), output.css);
return pubName;
}));
}
console.log();
console.log('Writing assets info...');
fs.writeFileSync(srcCurrentInfo, JSON.stringify(files));
console.log();
console.log('Cleaning up old builds...');
assproc.housekeep(pubAssetsDir);
})();

View file

@ -1,15 +1,23 @@
{
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"twig/twig": "^3.0",
"flashwave/index": "dev-master",
"flashwave/sasae": "dev-master",
"erusev/parsedown": "~1.6",
"chillerlan/php-qrcode": "^4.3",
"whichbrowser/parser": "^2.0",
"symfony/mailer": "^6.0"
"symfony/mailer": "^6.0",
"matomo/device-detector": "^6.1",
"sentry/sdk": "^4.0",
"flashwave/syokuhou": "dev-master"
},
"autoload": {
"classmap": [
"database"
]
],
"psr-4": {
"Misuzu\\": "src"
}
},
"scripts": {
"post-install-cmd": [
@ -20,7 +28,12 @@
"config": {
"preferred-install": "dist",
"allow-plugins": {
"composer/installers": true
"composer/installers": true,
"wikimedia/composer-merge-plugin": false,
"php-http/discovery": true
}
},
"require-dev": {
"phpstan/phpstan": "^1.10"
}
}

1397
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

@ -0,0 +1,51 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
use Misuzu\ClientInfo;
final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
// convert user agent fields to BLOB and add field for client info storage
$conn->execute('
ALTER TABLE msz_login_attempts
CHANGE COLUMN attempt_user_agent attempt_user_agent TEXT NOT NULL COLLATE "utf8mb4_bin" AFTER attempt_created,
ADD COLUMN attempt_client_info TEXT NULL DEFAULT NULL AFTER attempt_user_agent
');
$conn->execute('
ALTER TABLE msz_sessions
CHANGE column session_user_agent session_user_agent TEXT NOT NULL COLLATE "utf8mb4_bin" AFTER session_ip_last,
ADD COLUMN session_client_info TEXT NULL DEFAULT NULL AFTER session_user_agent
');
// make sure all existing fields have client info fields filled
$updateLoginAttempts = $conn->prepare('UPDATE msz_login_attempts SET attempt_client_info = ? WHERE attempt_user_agent = ?');
$selectLoginAttempts = $conn->query('SELECT DISTINCT attempt_user_agent FROM msz_login_attempts');
while($selectLoginAttempts->next()) {
$updateLoginAttempts->reset();
$userAgent = $selectLoginAttempts->getString(0);
$updateLoginAttempts->addParameter(1, json_encode(ClientInfo::parse($userAgent)));
$updateLoginAttempts->addParameter(2, $userAgent);
$updateLoginAttempts->execute();
}
$updateSessions = $conn->prepare('UPDATE msz_sessions SET session_client_info = ? WHERE session_user_agent = ?');
$selectSessions = $conn->query('SELECT DISTINCT session_user_agent FROM msz_sessions');
while($selectSessions->next()) {
$updateSessions->reset();
$userAgent = $selectSessions->getString(0);
$updateSessions->addParameter(1, json_encode(ClientInfo::parse($userAgent)));
$updateSessions->addParameter(2, $userAgent);
$updateSessions->execute();
}
// make client info fields NOT NULL
$conn->execute('
ALTER TABLE msz_login_attempts
CHANGE COLUMN attempt_client_info attempt_client_info TEXT NOT NULL COLLATE "utf8mb4_bin" AFTER attempt_user_agent
');
$conn->execute('
ALTER TABLE msz_sessions
CHANGE COLUMN session_client_info session_client_info TEXT NOT NULL COLLATE "utf8mb4_bin" AFTER session_user_agent
');
}
}

View file

@ -0,0 +1,46 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class AddModeratorNotesTable_20230724_201010 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_modnotes (
note_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
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\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class AddNewBansTable_20230726_175936 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_bans (
ban_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
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\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class RedoWarningsTable_20230726_210150 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_users_warnings (
warn_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
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\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class PluraliseUsersForRoleRelations_20230727_130516 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('RENAME TABLE msz_user_roles TO msz_users_roles');
}
}

View file

@ -0,0 +1,16 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class CreateCountersTable_20230728_212101 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE msz_counters (
counter_name VARBINARY(64) NOT NULL,
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\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class UpdateCollationsInVariousTables_20230803_114403 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
ALTER TABLE msz_audit_log
CHANGE COLUMN log_action log_action VARCHAR(50) NOT NULL COLLATE "ascii_general_ci" AFTER user_id,
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\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class NewPermissionsSystem_20230830_213930 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
// make sure cron doesn't fuck us over
$conn->execute('DELETE FROM msz_config WHERE config_name = "perms.needsRecalc"');
$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\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class CreateMessagesTable_20240130_233734 implements IDbMigration {
public function migrate(IDbConnection $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\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class BaseSixtyFourEncodePmsInDb_20240602_194809 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('UPDATE msz_messages SET msg_title = TO_BASE64(msg_title), msg_body = TO_BASE64(msg_body)');
$conn->execute('
ALTER TABLE `msz_messages`
CHANGE COLUMN `msg_title` `msg_title` TINYBLOB NOT NULL AFTER `msg_reply_to`,
CHANGE COLUMN `msg_body` `msg_body` BLOB NOT NULL AFTER `msg_title`;
');
}
}

View file

@ -1,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.
## 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.
- [Sasae](https://patchii.net/flash/sasae): Extension to the Twig templating library.
- [Syokuhou](https://patchii.net/flash/syokuhou): Configuration library.
## Historical
- [AJAX Chat (fork)](https://patchii.net/flashii/ajax-chat): Old chat software (2013-2015). Still kept on life support for the nostalgia.
- [Hajime](https://patchii.net/flash/hajime): Cleaned up source of an older version of the website (2014-2015).

@ -1 +0,0 @@
Subproject commit ccf75ede4ae02aa184a51f565ee0b4f0ace29c6f

View file

@ -1,10 +1,10 @@
<?php
namespace Misuzu;
use Index\Autoloader;
use Index\Environment;
use Index\Data\DbTools;
use Misuzu\Config\DbConfig;
use Syokuhou\DbConfig;
use Syokuhou\SharpConfig;
define('MSZ_STARTUP', microtime(true));
define('MSZ_ROOT', __DIR__);
@ -12,51 +12,36 @@ define('MSZ_CLI', PHP_SAPI === 'cli');
define('MSZ_DEBUG', is_file(MSZ_ROOT . '/.debug'));
define('MSZ_PUBLIC', MSZ_ROOT . '/public');
define('MSZ_SOURCE', MSZ_ROOT . '/src');
define('MSZ_LIBRARIES', MSZ_ROOT . '/lib');
define('MSZ_CONFIG', MSZ_ROOT . '/config');
define('MSZ_TEMPLATES', MSZ_ROOT . '/templates');
define('MSZ_MIGRATIONS', MSZ_ROOT . '/database');
define('MSZ_ASSETS', MSZ_ROOT . '/assets');
define('MSZ_NDX_PATH', MSZ_LIBRARIES . '/index');
define('MSZ_NDX_PATH_DEV', MSZ_LIBRARIES . '/index-dev');
require_once (MSZ_DEBUG && is_dir(MSZ_NDX_PATH_DEV) ? MSZ_NDX_PATH_DEV : MSZ_NDX_PATH) . '/index.php';
Autoloader::addNamespace(__NAMESPACE__, MSZ_SOURCE);
Environment::setDebug(MSZ_DEBUG);
mb_internal_encoding('utf-8');
date_default_timezone_set('utc');
require_once MSZ_ROOT . '/vendor/autoload.php';
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';
$dbConfig = parse_ini_file(MSZ_CONFIG . '/config.ini', true, INI_SCANNER_TYPED);
Environment::setDebug(MSZ_DEBUG);
mb_internal_encoding('UTF-8');
date_default_timezone_set('UTC');
if(empty($dbConfig)) {
echo 'Database config is missing.';
exit;
}
$cfg = SharpConfig::fromFile(MSZ_CONFIG . '/config.cfg');
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\';');
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),
]);
$db = DbTools::create($dbConfig['dsn']);
$db->execute(MSZ_DB_INIT);
set_exception_handler(function(\Throwable $ex) {
\Sentry\captureException($ex);
});
})($cfg->scopeTo('sentry'));
DB::init(DbTools::parse($dbConfig['dsn']));
DB::exec(MSZ_DB_INIT);
$db = DbTools::create($cfg->getString('database:dsn', 'null:'));
$db->execute('SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\';');
$cfg = new DbConfig($db);
$cfg = new DbConfig($db, 'msz_config');
Mailer::init($cfg->scopeTo('mail'));

692
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"dependencies": {
"@swc/core": "^1.3.69",
"autoprefixer": "^10.4.14",
"cssnano": "^6.0.1",
"postcss": "^8.4.26"
"@swc/core": "^1.5.24",
"autoprefixer": "^10.4.19",
"cssnano": "^6.1.2",
"postcss": "^8.4.38"
}
}

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,26 @@
<?php
namespace Misuzu;
use stdClass;
use RuntimeException;
use Index\DateTime;
use Misuzu\Forum\ForumTopicInfo;
use Misuzu\Parsers\Parser;
use Misuzu\Users\User;
$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 +38,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 +57,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 +141,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 = DateTime::now()->modify(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($lastPostCreatedAt->isMoreThan($postTimeoutThreshold)) {
$waitSeconds = $postTimeout + ($lastPostCreatedAt->getUnixTimeSeconds() - time());
$notices[] = sprintf("You're posting too quickly! Please wait %s seconds before posting again.", number_format($waitSeconds));
$notices[] = "It's possible that your post went through successfully and you pressed the submit button twice by accident.";
}
}
}
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 +188,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,

View file

@ -1,69 +1,107 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use Misuzu\Users\UserSession;
use stdClass;
use RuntimeException;
$urls = $msz->getURLs();
$forumCtx = $msz->getForumContext();
$forumCategories = $forumCtx->getCategories();
$forumTopics = $forumCtx->getTopics();
$forumTopicRedirects = $forumCtx->getTopicRedirects();
$forumPosts = $forumCtx->getPosts();
$usersCtx = $msz->getUsersContext();
$postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0;
$topicId = !empty($_GET['t']) && is_string($_GET['t']) ? (int)$_GET['t'] : 0;
$categoryId = null;
$moderationMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : '';
$submissionConfirmed = !empty($_GET['confirm']) && is_string($_GET['confirm']) && $_GET['confirm'] === '1';
$topicUser = User::getCurrent();
$topicUserId = $topicUser === null ? 0 : $topicUser->getId();
$authInfo = $msz->getAuthInfo();
$currentUser = $authInfo->getUserInfo();
$currentUserId = $currentUser === null ? '0' : $currentUser->getId();
if($topicId < 1 && $postId > 0) {
$postInfo = forum_post_find($postId, $topicUserId);
if(!empty($postInfo['topic_id'])) {
$topicId = (int)$postInfo['topic_id'];
try {
$postInfo = $forumPosts->getPost(postId: $postId);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
$categoryId = $postInfo->getCategoryId();
$perms = $authInfo->getPerms('forum', $postInfo->getCategoryId());
$canDeleteAny = $perms->check(Perm::F_POST_DELETE_ANY);
if($postInfo->isDeleted() && !$canDeleteAny)
Template::throwError(404);
$topicId = $postInfo->getTopicId();
$preceedingPostCount = $forumPosts->countPosts(
topicInfo: $topicId,
upToPostInfo: $postInfo,
deleted: $canDeleteAny ? null : false
);
}
$topic = forum_topic_get($topicId, true);
$perms = $topic
? forum_perms_get_user($topic['forum_id'], $topicUserId)[MSZ_FORUM_PERMS_GENERAL]
: 0;
try {
$topicIsNuked = $topicIsDeleted = $canDeleteAny = false;
$topicInfo = $forumTopics->getTopic(topicId: $topicId);
} catch(RuntimeException $ex) {
$topicIsNuked = true;
}
if(isset($topicUser) && $topicUser->hasActiveWarning())
$perms &= ~MSZ_FORUM_PERM_SET_WRITE;
if(!$topicIsNuked) {
$topicIsDeleted = $topicInfo->isDeleted();
$topicIsNuked = empty($topic['topic_id']);
$topicIsDeleted = !empty($topic['topic_deleted']);
$canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
if($categoryId !== (int)$topicInfo->getCategoryId()) {
$categoryId = (int)$topicInfo->getCategoryId();
$perms = $authInfo->getPerms('forum', $topicInfo->getCategoryId());
}
if($usersCtx->hasActiveBan($currentUser))
$perms = $perms->apply(fn($calc) => $calc & (Perm::F_CATEGORY_LIST | Perm::F_CATEGORY_VIEW));
$canDeleteAny = $perms->check(Perm::F_POST_DELETE_ANY);
}
if($topicIsNuked || $topicIsDeleted) {
$topicRedirectInfo = forum_topic_redir_info($topicId);
Template::set('topic_redir_info', $topicRedirectInfo);
if($forumTopicRedirects->hasTopicRedirect($topicId)) {
$topicRedirectInfo = $forumTopicRedirects->getTopicRedirect($topicId);
Template::set('topic_redir_info', $topicRedirectInfo);
if($topicIsNuked || !$canDeleteAny) {
if(empty($topicRedirectInfo))
echo render_error(404);
else
header('Location: ' . $topicRedirectInfo->topic_redir_url);
return;
if($topicIsNuked || !$canDeleteAny) {
header('Location: ' . $topicRedirectInfo->getLinkTarget());
return;
}
}
if(empty($topicRedirectInfo))
Template::throwError(404);
}
if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) {
echo render_error(403);
return;
}
if(!$perms->check(Perm::F_CATEGORY_VIEW))
Template::throwError(403);
$topicIsLocked = !empty($topic['topic_locked']);
$topicIsArchived = !empty($topic['topic_archived']);
$topicPostsTotal = (int)($topic['topic_count_posts'] + $topic['topic_count_posts_deleted']);
// Maximum amount of posts a topic may contain to still be deletable by the author
// this should be in the config
$deletePostThreshold = 1;
$categoryInfo = $forumCategories->getCategory(topicInfo: $topicInfo);
$topicIsLocked = $topicInfo->isLocked();
$topicIsArchived = $categoryInfo->isArchived();
$topicPostsTotal = $topicInfo->getTotalPostsCount();
$topicIsFrozen = $topicIsArchived || $topicIsDeleted;
$canDeleteOwn = !$topicIsFrozen && !$topicIsLocked && perms_check($perms, MSZ_FORUM_PERM_DELETE_POST);
$canBumpTopic = !$topicIsFrozen && perms_check($perms, MSZ_FORUM_PERM_BUMP_TOPIC);
$canLockTopic = !$topicIsFrozen && perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC);
$canDeleteOwn = !$topicIsFrozen && !$topicIsLocked && $perms->check(Perm::F_POST_DELETE_OWN);
$canBumpTopic = !$topicIsFrozen && $perms->check(Perm::F_TOPIC_BUMP);
$canLockTopic = !$topicIsFrozen && $perms->check(Perm::F_TOPIC_LOCK);
$canNukeOrRestore = $canDeleteAny && $topicIsDeleted;
$canDelete = !$topicIsDeleted && (
$canDeleteAny || (
$topicPostsTotal > 0
&& $topicPostsTotal <= MSZ_FORUM_TOPIC_DELETE_POST_LIMIT
&& $topicPostsTotal <= $deletePostThreshold
&& $canDeleteOwn
&& $topic['author_user_id'] === $topicUserId
&& $topicInfo->getUserId() === (string)$currentUserId
)
);
@ -73,260 +111,229 @@ $validModerationModes = [
];
if(in_array($moderationMode, $validModerationModes, true)) {
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);
if(!UserSession::hasCurrent()) {
echo render_info('You must be logged in to manage posts.', 401);
return;
}
$authInfo = $authInfo;
if(!$authInfo->isLoggedIn())
Template::displayInfo('You must be logged in to manage posts.', 401);
if($topicUser->isBanned()) {
echo render_info('You have been banned, check your profile for more information.', 403);
return;
}
if($topicUser->isSilenced()) {
echo render_info('You have been silenced, check your profile for more information.', 403);
return;
}
if($usersCtx->hasActiveBan($currentUser))
Template::displayInfo('You have been banned, check your profile for more information.', 403);
switch($moderationMode) {
case 'delete':
$canDeleteCode = forum_topic_can_delete($topic, $topicUserId);
$canDeleteMsg = '';
$responseCode = 200;
if($canDeleteAny) {
if($topicInfo->isDeleted())
Template::displayInfo('This topic has already been marked as deleted.', 404);
} else {
if($topicInfo->isDeleted())
Template::throwError(404);
switch($canDeleteCode) {
case MSZ_E_FORUM_TOPIC_DELETE_USER:
$responseCode = 401;
$canDeleteMsg = 'You must be logged in to delete topics.';
break;
case MSZ_E_FORUM_TOPIC_DELETE_TOPIC:
$responseCode = 404;
$canDeleteMsg = "This topic doesn't exist.";
break;
case MSZ_E_FORUM_TOPIC_DELETE_DELETED:
$responseCode = 404;
$canDeleteMsg = 'This topic has already been marked as deleted.';
break;
case MSZ_E_FORUM_TOPIC_DELETE_OWNER:
$responseCode = 403;
$canDeleteMsg = 'You can only delete your own topics.';
break;
case MSZ_E_FORUM_TOPIC_DELETE_OLD:
$responseCode = 401;
$canDeleteMsg = 'This topic has existed for too long. Ask a moderator to remove if it absolutely necessary.';
break;
case MSZ_E_FORUM_TOPIC_DELETE_PERM:
$responseCode = 401;
$canDeleteMsg = 'You are not allowed to delete topics.';
break;
case MSZ_E_FORUM_TOPIC_DELETE_POSTS:
$responseCode = 403;
$canDeleteMsg = 'This topic already has replies, you may no longer delete it. Ask a moderator to remove if it absolutely necessary.';
break;
case MSZ_E_FORUM_TOPIC_DELETE_OK:
break;
default:
$responseCode = 500;
$canDeleteMsg = sprintf('Unknown error \'%d\'', $canDelete);
}
if(!$canDeleteOwn)
Template::displayInfo("You aren't allowed to delete topics.", 403);
if($canDeleteCode !== MSZ_E_FORUM_TOPIC_DELETE_OK) {
echo render_info($canDeleteMsg, $responseCode);
break;
if($topicInfo->getUserId() !== $currentUser->getId())
Template::displayInfo('You can only delete your own topics.', 403);
// topics may only be deleted within a day of creation, this should be a config value
$deleteTimeFrame = 60 * 60 * 24;
if($topicInfo->getCreatedTime() < time() - $deleteTimeFrame)
Template::displayInfo('This topic has existed for too long. Ask a moderator to remove if it absolutely necessary.', 403);
// deleted posts are intentionally included
$topicPostCount = $forumPosts->countPosts(topicInfo: $topicInfo);
if($topicPostCount > $deletePostThreshold)
Template::displayInfo('This topic already has replies, you may no longer delete it. Ask a moderator to remove if it absolutely necessary.', 403);
}
if(!isset($_GET['confirm'])) {
Template::render('forum.confirm', [
'title' => 'Confirm topic deletion',
'class' => 'far fa-trash-alt',
'message' => sprintf('You are about to delete topic #%d. Are you sure about that?', $topic['topic_id']),
'message' => sprintf('You are about to delete topic #%d. Are you sure about that?', $topicInfo->getId()),
'params' => [
't' => $topic['topic_id'],
't' => $topicInfo->getId(),
'm' => 'delete',
],
]);
break;
} elseif(!$submissionConfirmed) {
url_redirect(
Tools::redirect($urls->format(
'forum-topic',
['topic' => $topic['topic_id']]
);
['topic' => $topicInfo->getId()]
));
break;
}
$deleteTopic = forum_topic_delete($topic['topic_id']);
$forumTopics->deleteTopic($topicInfo->getId());
$msz->createAuditLog('FORUM_TOPIC_DELETE', [$topicInfo->getId()]);
if($deleteTopic)
$msz->createAuditLog('FORUM_TOPIC_DELETE', [$topic['topic_id']]);
if(!$deleteTopic) {
echo render_error(500);
break;
}
url_redirect('forum-category', [
'forum' => $topic['forum_id'],
]);
Tools::redirect($urls->format('forum-category', [
'forum' => $categoryInfo->getId(),
]));
break;
case 'restore':
if(!$canNukeOrRestore) {
echo render_error(403);
break;
}
if(!$canNukeOrRestore)
Template::throwError(403);
if(!isset($_GET['confirm'])) {
Template::render('forum.confirm', [
'title' => 'Confirm topic restore',
'class' => 'fas fa-magic',
'message' => sprintf('You are about to restore topic #%d. Are you sure about that?', $topic['topic_id']),
'message' => sprintf('You are about to restore topic #%d. Are you sure about that?', $topicInfo->getId()),
'params' => [
't' => $topic['topic_id'],
't' => $topicInfo->getId(),
'm' => 'restore',
],
]);
break;
} elseif(!$submissionConfirmed) {
url_redirect('forum-topic', [
'topic' => $topic['topic_id'],
]);
Tools::redirect($urls->format('forum-topic', [
'topic' => $topicInfo->getId(),
]));
break;
}
$restoreTopic = forum_topic_restore($topic['topic_id']);
$forumTopics->restoreTopic($topicInfo->getId());
$msz->createAuditLog('FORUM_TOPIC_RESTORE', [$topicInfo->getId()]);
if(!$restoreTopic) {
echo render_error(500);
break;
}
$msz->createAuditLog('FORUM_TOPIC_RESTORE', [$topic['topic_id']]);
url_redirect('forum-category', [
'forum' => $topic['forum_id'],
]);
Tools::redirect($urls->format('forum-category', [
'forum' => $categoryInfo->getId(),
]));
break;
case 'nuke':
if(!$canNukeOrRestore) {
echo render_error(403);
break;
}
if(!$canNukeOrRestore)
Template::throwError(403);
if(!isset($_GET['confirm'])) {
Template::render('forum.confirm', [
'title' => 'Confirm topic nuke',
'class' => 'fas fa-radiation',
'message' => sprintf('You are about to PERMANENTLY DELETE topic #%d. Are you sure about that?', $topic['topic_id']),
'message' => sprintf('You are about to PERMANENTLY DELETE topic #%d. Are you sure about that?', $topicInfo->getId()),
'params' => [
't' => $topic['topic_id'],
't' => $topicInfo->getId(),
'm' => 'nuke',
],
]);
break;
} elseif(!$submissionConfirmed) {
url_redirect('forum-topic', [
'topic' => $topic['topic_id'],
]);
Tools::redirect($urls->format('forum-topic', [
'topic' => $topicInfo->getId(),
]));
break;
}
$nukeTopic = forum_topic_nuke($topic['topic_id']);
$forumTopics->nukeTopic($topicInfo->getId());
$msz->createAuditLog('FORUM_TOPIC_NUKE', [$topicInfo->getId()]);
if(!$nukeTopic) {
echo render_error(500);
break;
}
$msz->createAuditLog('FORUM_TOPIC_NUKE', [$topic['topic_id']]);
url_redirect('forum-category', [
'forum' => $topic['forum_id'],
]);
Tools::redirect($urls->format('forum-category', [
'forum' => $categoryInfo->getId(),
]));
break;
case 'bump':
if($canBumpTopic && forum_topic_bump($topic['topic_id'])) {
$msz->createAuditLog('FORUM_TOPIC_BUMP', [$topic['topic_id']]);
if($canBumpTopic) {
$forumTopics->bumpTopic($topicInfo->getId());
$msz->createAuditLog('FORUM_TOPIC_BUMP', [$topicInfo->getId()]);
}
url_redirect('forum-topic', [
'topic' => $topic['topic_id'],
]);
Tools::redirect($urls->format('forum-topic', [
'topic' => $topicInfo->getId(),
]));
break;
case 'lock':
if($canLockTopic && !$topicIsLocked && forum_topic_lock($topic['topic_id'])) {
$msz->createAuditLog('FORUM_TOPIC_LOCK', [$topic['topic_id']]);
if($canLockTopic && !$topicIsLocked) {
$forumTopics->lockTopic($topicInfo->getId());
$msz->createAuditLog('FORUM_TOPIC_LOCK', [$topicInfo->getId()]);
}
url_redirect('forum-topic', [
'topic' => $topic['topic_id'],
]);
Tools::redirect($urls->format('forum-topic', [
'topic' => $topicInfo->getId(),
]));
break;
case 'unlock':
if($canLockTopic && $topicIsLocked && forum_topic_unlock($topic['topic_id'])) {
$msz->createAuditLog('FORUM_TOPIC_UNLOCK', [$topic['topic_id']]);
if($canLockTopic && $topicIsLocked) {
$forumTopics->unlockTopic($topicInfo->getId());
$msz->createAuditLog('FORUM_TOPIC_UNLOCK', [$topicInfo->getId()]);
}
url_redirect('forum-topic', [
'topic' => $topic['topic_id'],
]);
Tools::redirect($urls->format('forum-topic', [
'topic' => $topicInfo->getId(),
]));
break;
}
return;
}
$topicPosts = $topic['topic_count_posts'];
$topicPosts = $topicInfo->getPostsCount();
if($canDeleteAny)
$topicPosts += $topicInfo->getDeletedPostsCount();
if($canDeleteAny) {
$topicPosts += $topic['topic_count_posts_deleted'];
}
$topicPagination = new Pagination($topicPosts, 10, 'page');
$topicPagination = new Pagination($topicPosts, MSZ_FORUM_POSTS_PER_PAGE, 'page');
if(isset($preceedingPostCount))
$topicPagination->setPage(floor($preceedingPostCount / $topicPagination->getRange()), true);
if(isset($postInfo['preceeding_post_count'])) {
$preceedingPosts = $postInfo['preceeding_post_count'];
if(!$topicPagination->hasValidOffset())
Template::throwError(404);
if($canDeleteAny) {
$preceedingPosts += $postInfo['preceeding_post_deleted_count'];
}
$topicPagination->setPage(floor($preceedingPosts / $topicPagination->getRange()), true);
}
if(!$topicPagination->hasValidOffset()) {
echo render_error(404);
return;
}
Template::set('topic_perms', $perms);
$posts = forum_post_listing(
$topic['topic_id'],
$topicPagination->getOffset(),
$topicPagination->getRange(),
perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST)
$postInfos = $forumPosts->getPosts(
topicInfo: $topicInfo,
deleted: $perms->check(Perm::F_POST_DELETE_ANY) ? null : false,
pagination: $topicPagination,
);
if(!$posts) {
echo render_error(404);
return;
if(empty($postInfos))
Template::throwError(404);
try {
$originalPostInfo = $forumPosts->getPost(topicInfo: $topicInfo);
} catch(RuntimeException $ex) {
Template::throwError(404);
}
$canReply = !$topicIsArchived && !$topicIsLocked && !$topicIsDeleted && perms_check($perms, MSZ_FORUM_PERM_CREATE_POST);
$posts = [];
forum_topic_mark_read($topicUserId, $topic['topic_id'], $topic['forum_id']);
foreach($postInfos as $postInfo) {
$posts[] = $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();
}
$canReply = !$topicIsArchived && !$topicIsLocked && !$topicIsDeleted && $perms->check(Perm::F_POST_CREATE);
if(!$forumTopics->checkUserHasReadTopic($currentUser, $topicInfo))
$forumTopics->incrementTopicViews($topicInfo);
$forumTopics->updateUserReadTopic($currentUser, $topicInfo);
$perms = $perms->checkMany([
'can_create_post' => Perm::F_POST_CREATE,
'can_edit_post' => Perm::F_POST_EDIT_OWN,
'can_edit_any_post' => Perm::F_POST_EDIT_ANY,
'can_delete_post' => Perm::F_POST_DELETE_OWN,
'can_delete_any_post' => Perm::F_POST_DELETE_ANY,
]);
Template::render('forum.topic', [
'topic_breadcrumbs' => forum_get_breadcrumbs($topic['forum_id']),
'global_accent_colour' => forum_get_colour($topic['forum_id']),
'topic_info' => $topic,
'topic_breadcrumbs' => iterator_to_array($forumCategories->getCategoryAncestry($topicInfo)),
'global_accent_colour' => $forumCategories->getCategoryColour($topicInfo),
'topic_info' => $topicInfo,
'category_info' => $categoryInfo,
'topic_posts' => $posts,
'can_reply' => $canReply,
'topic_pagination' => $topicPagination,
@ -334,4 +341,6 @@ Template::render('forum.topic', [
'topic_can_nuke_or_restore' => $canNukeOrRestore,
'topic_can_bump' => $canBumpTopic,
'topic_can_lock' => $canLockTopic,
'topic_user_id' => $currentUserId,
'topic_perms' => $perms,
]);

View file

@ -4,41 +4,42 @@ namespace Misuzu;
use DateTimeInterface;
use RuntimeException;
use Index\DateTime;
use Index\XArray;
use Misuzu\Changelog\Changelog;
use Misuzu\Users\User;
use Misuzu\Users\UserNotFoundException;
if(!User::hasCurrent() || !perms_check_user(MSZ_PERMS_CHANGELOG, User::getCurrent()->getId(), MSZ_PERM_CHANGELOG_MANAGE_CHANGES)) {
echo render_error(403);
return;
}
$authInfo = $msz->getAuthInfo();
if(!$authInfo->getPerms('global')->check(Perm::G_CL_CHANGES_MANAGE))
Template::throwError(403);
$changeActions = [];
foreach(Changelog::ACTIONS as $action)
$changeActions[$action] = Changelog::actionText($action);
$urls = $msz->getURLs();
$changelog = $msz->getChangelog();
$changeId = (string)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT);
$loadChangeInfo = fn() => $changelog->getChangeById($changeId, withTags: true);
$changeTags = $changelog->getAllTags();
$changeInfo = null;
$changeTagIds = [];
$tagInfos = $changelog->getTags();
if(empty($changeId))
$isNew = true;
else
try {
$isNew = false;
$changeInfo = $loadChangeInfo();
$changeInfo = $changelog->getChange($changeId);
$changeTagIds = XArray::select($changelog->getTags(changeInfo: $changeInfo), fn($tagInfo) => $tagInfo->getId());
} catch(RuntimeException $ex) {
echo render_error(404);
return;
Template::throwError(404);
}
if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
if(CSRF::validateRequest()) {
$changelog->deleteChange($changeInfo);
$msz->createAuditLog('CHANGELOG_ENTRY_DELETE', [$changeInfo->getId()]);
url_redirect('manage-changelog-changes');
} else render_error(403);
if(!CSRF::validateRequest())
Template::throwError(403);
$changelog->deleteChange($changeInfo);
$msz->createAuditLog('CHANGELOG_ENTRY_DELETE', [$changeInfo->getId()]);
Tools::redirect($urls->format('manage-changelog-changes'));
return;
}
@ -80,7 +81,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
}
if(!empty($tags)) {
$tCurrent = $changeInfo->getTagIds();
$tCurrent = $changeTagIds;
$tApply = $tags;
$tRemove = [];
@ -104,16 +105,15 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
[$changeInfo->getId()]
);
if($isNew) {
url_redirect('manage-changelog-change', ['change' => $changeInfo->getId()]);
return;
} else $changeInfo = $loadChangeInfo();
break;
Tools::redirect($urls->format('manage-changelog-change', ['change' => $changeInfo->getId()]));
return;
}
Template::render('manage.changelog.change', [
'change_new' => $isNew,
'change_info' => $changeInfo ?? null,
'change_tags' => $changeTags,
'change_info' => $changeInfo,
'change_info_tags' => $changeTagIds,
'change_tags' => $tagInfos,
'change_actions' => $changeActions,
'change_author_id' => $authInfo->getUserInfo()->getId(),
]);

View file

@ -1,43 +1,30 @@
<?php
namespace Misuzu;
use Misuzu\Users\User;
use RuntimeException;
if(!User::hasCurrent() || !perms_check_user(MSZ_PERMS_CHANGELOG, User::getCurrent()->getId(), MSZ_PERM_CHANGELOG_MANAGE_CHANGES)) {
echo render_error(403);
return;
}
if(!$msz->getAuthInfo()->getPerms('global')->check(Perm::G_CL_CHANGES_MANAGE))
Template::throwError(403);
$changelog = $msz->getChangelog();
$changelogPagination = new Pagination($changelog->countAllChanges(), 30);
$usersCtx = $msz->getUsersContext();
if(!$changelogPagination->hasValidOffset()) {
echo render_error(404);
return;
}
$changelogPagination = new Pagination($changelog->countChanges(), 30);
$changeInfos = $changelog->getAllChanges(withTags: true, pagination: $changelogPagination);
if(!$changelogPagination->hasValidOffset())
Template::throwError(404);
$changeInfos = $changelog->getChanges(pagination: $changelogPagination);
$changes = [];
$userInfos = [];
foreach($changeInfos as $changeInfo) {
$userId = $changeInfo->getUserId();
if(array_key_exists($userId, $userInfos)) {
$userInfo = $userInfos[$userId];
} else {
try {
$userInfo = User::byId($userId);
} catch(UserNotFoundException $ex) {
$userInfo = null;
}
$userInfos[$userId] = $userInfo;
}
$userInfo = $changeInfo->hasUserId() ? $usersCtx->getUserInfo($changeInfo->getUserId()) : null;
$changes[] = [
'change' => $changeInfo,
'tags' => $changelog->getTags(changeInfo: $changeInfo),
'user' => $userInfo,
'user_colour' => $usersCtx->getUserColour($userInfo),
];
}

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