From 266f6b1a59d54d29b4efb697f053a936e61df867 Mon Sep 17 00:00:00 2001 From: "E. S" Date: Mon, 28 Apr 2025 14:47:25 +0300 Subject: [PATCH] upgrade engine --- .gitignore | 3 +- README.md | 2 +- composer.json | 16 +- cron/sitemap.php | 16 +- deploy/gen_static_config.php | 19 +- engine/logging.php | 318 ---------- engine/request.php | 238 -------- engine/skin.php | 566 ------------------ engine/strings.php | 138 ----- handlers/FilesHandler.php | 220 ------- handlers/ServicesHandler.php | 25 - htdocs/img/eagle.jpg | Bin 0 -> 17996 bytes htdocs/img/simurgh-big.jpg | Bin 0 -> 64937 bytes htdocs/img/simurgh.jpg | Bin 0 -> 14597 bytes htdocs/index.php | 4 +- htdocs/js.php | 2 +- htdocs/sass.php | 2 +- lib/BaconianaCollectionItem.php | 62 -- lib/BookCategory.php | 6 - lib/BookFileType.php | 7 - lib/BookItem.php | 87 --- lib/CollectionItem.php | 21 - lib/FilesCollection.php | 7 - lib/FilesItemSizeTrait.php | 6 - lib/FilesItemType.php | 6 - lib/FilesItemTypeTrait.php | 19 - lib/Page.php | 47 -- lib/Post.php | 129 ---- lib/PreviousText.php | 16 - lib/Upload.php | 170 ------ lib/WFFCollectionItem.php | 53 -- lib/files.php | 390 ------------ lib/pages.php | 39 -- lib/posts.php | 153 ----- lib/uploads.php | 161 ----- skin/error.twig | 9 - src/engine/GlobalContext.php | 56 ++ engine/model.php => src/engine/Model.php | 125 +--- src/engine/ModelFieldType.php | 16 + src/engine/ModelProperty.php | 76 +++ src/engine/ModelSpec.php | 27 + engine/mysql.php => src/engine/MySQL.php | 103 +--- src/engine/MySQLBitField.php | 65 ++ engine/router.php => src/engine/Router.php | 49 +- lib/sphinx.php => src/engine/SphinxUtil.php | 14 +- .../exceptions/InvalidDomainException.php | 5 + .../exceptions/NotImplementedException.php | 5 + src/engine/exceptions/ParseFormException.php | 5 + src/engine/http/AjaxError.php | 11 + src/engine/http/AjaxOk.php | 11 + src/engine/http/AjaxResponse.php | 15 + src/engine/http/HTTPCode.php | 24 + src/engine/http/HTTPMethod.php | 9 + src/engine/http/HtmlResponse.php | 22 + src/engine/http/InputVarType.php | 11 + src/engine/http/JSONResponse.php | 20 + src/engine/http/PlainTextResponse.php | 20 + src/engine/http/RequestHandler.php | 203 +++++++ src/engine/http/Response.php | 25 + src/engine/http/errors/BaseRedirect.php | 55 ++ src/engine/http/errors/Forbidden.php | 13 + src/engine/http/errors/HTTPError.php | 29 + .../http/errors/InternalServerError.php | 13 + src/engine/http/errors/InvalidRequest.php | 13 + src/engine/http/errors/NotFound.php | 13 + src/engine/http/errors/NotImplemented.php | 13 + src/engine/http/errors/PermanentRedirect.php | 11 + src/engine/http/errors/Redirect.php | 11 + src/engine/http/errors/Unauthorized.php | 13 + .../errors/UnavailableForLegalReasons.php | 13 + src/engine/lang/Strings.php | 55 ++ src/engine/lang/StringsBase.php | 105 ++++ src/engine/lang/StringsPack.php | 12 + src/engine/logging/AnsiColor.php | 15 + src/engine/logging/DatabaseLogger.php | 47 ++ src/engine/logging/FileLogger.php | 87 +++ src/engine/logging/LogLevel.php | 11 + src/engine/logging/Logger.php | 101 ++++ src/engine/logging/Util.php | 68 +++ src/engine/skin/BaseSkin.php | 96 +++ src/engine/skin/ErrorSkin.php | 35 ++ src/engine/skin/FeaturedSkin.php | 296 +++++++++ src/engine/skin/Meta.php | 67 +++ src/engine/skin/Options.php | 15 + src/engine/skin/ServiceSkin.php | 14 + .../engine/skin}/TwigAddons/JsTagNode.php | 9 +- .../skin}/TwigAddons/JsTagParamsNode.php | 2 +- src/engine/skin/TwigAddons/JsTagRuntime.php | 18 + .../skin}/TwigAddons/JsTagTokenParser.php | 7 +- .../skin/TwigAddons/JsTwigExtension.php | 16 + .../skin/TwigAddons/SkinTwigExtension.php | 43 +- src/engine_functions.php | 81 +++ functions.php => src/functions.php | 60 +- .../handlers/foreignone}/AdminHandler.php | 370 ++++++------ src/handlers/foreignone/BaseHandler.php | 25 + src/handlers/foreignone/FilesHandler.php | 216 +++++++ .../handlers/foreignone}/MainHandler.php | 130 ++-- src/handlers/foreignone/ServicesHandler.php | 28 + src/handlers/ic/BaseHandler.php | 25 + src/handlers/ic/MainHandler.php | 13 + init.php => src/init.php | 61 +- lib/admin.php => src/lib/Admin.php | 39 +- {lib => src/lib}/AdminActions/BaseAction.php | 6 +- {lib => src/lib}/AdminActions/PageCreate.php | 8 +- {lib => src/lib}/AdminActions/PageDelete.php | 8 +- {lib => src/lib}/AdminActions/PageEdit.php | 8 +- {lib => src/lib}/AdminActions/PostCreate.php | 8 +- {lib => src/lib}/AdminActions/PostDelete.php | 8 +- {lib => src/lib}/AdminActions/PostEdit.php | 8 +- .../lib}/AdminActions/PostTextCreate.php | 8 +- {lib => src/lib}/AdminActions/UploadsAdd.php | 8 +- .../lib}/AdminActions/UploadsDelete.php | 8 +- .../lib}/AdminActions/UploadsEditNote.php | 8 +- {lib => src/lib}/AdminActions/Util/Logger.php | 30 +- lib/cli.php => src/lib/CliUtil.php | 9 +- lib/markup.php => src/lib/MarkupUtil.php | 35 +- {lib => src/lib}/MyParsedown.php | 31 +- lib/themes.php => src/lib/ThemesUtil.php | 5 +- src/lib/foreignone/ForeignOneSkin.php | 103 ++++ src/lib/foreignone/ForeignOneSkinOptions.php | 15 + src/lib/foreignone/Page.php | 91 +++ src/lib/foreignone/Post.php | 217 +++++++ {lib => src/lib/foreignone}/PostLanguage.php | 5 +- {lib => src/lib/foreignone}/PostText.php | 87 ++- .../lib/foreignone/PreviousText.php | 30 +- src/lib/foreignone/Upload.php | 329 ++++++++++ src/lib/foreignone/files/Archive.php | 47 ++ src/lib/foreignone/files/ArchiveType.php | 49 ++ src/lib/foreignone/files/BaconianaIssue.php | 142 +++++ src/lib/foreignone/files/Book.php | 139 +++++ .../lib/foreignone/files/FileInterface.php | 13 +- .../lib/foreignone/files/MDFIssue.php | 68 ++- src/lib/foreignone/files/SectionType.php | 10 + src/lib/foreignone/files/Util.php | 170 ++++++ src/lib/foreignone/files/WFFArchiveFile.php | 165 +++++ src/lib/ic/InvisibleCollegeSkin.php | 90 +++ routes.php => src/routes.php | 22 +- src/skins/error/error.twig | 13 + src/skins/error/notfound.twig | 62 ++ src/skins/error/notfound_ic.twig | 64 ++ .../skins/foreignone}/admin_actions_log.twig | 0 .../skins/foreignone}/admin_auth_log.twig | 0 .../skins/foreignone}/admin_errors.twig | 0 .../skins/foreignone}/admin_index.twig | 0 .../skins/foreignone}/admin_login.twig | 0 .../skins/foreignone}/admin_page_form.twig | 0 .../skins/foreignone}/admin_page_new.twig | 0 .../skins/foreignone}/admin_post_form.twig | 0 .../skins/foreignone}/admin_uploads.twig | 0 {skin => src/skins/foreignone}/articles.twig | 0 .../foreignone}/articles_right_links.twig | 0 .../skins/foreignone}/files_collection.twig | 0 .../skins/foreignone}/files_file.twig | 28 +- .../skins/foreignone}/files_folder.twig | 0 .../skins/foreignone}/files_index.twig | 0 .../skins/foreignone}/files_list.twig | 0 {skin => src/skins/foreignone}/footer.twig | 0 {skin => src/skins/foreignone}/header.twig | 0 {skin => src/skins/foreignone}/index.twig | 0 .../foreignone}/markdown_fileupload.twig | 0 .../skins/foreignone}/markdown_image.twig | 0 .../skins/foreignone}/markdown_preview.twig | 0 .../skins/foreignone}/markdown_video.twig | 0 {skin => src/skins/foreignone}/page.twig | 0 {skin => src/skins/foreignone}/post.twig | 0 {skin => src/skins/foreignone}/rss.twig | 0 {skin => src/skins/foreignone}/spinner.twig | 0 src/skins/ic/soon.twig | 52 ++ .../arrow_up_right_out_square_outline_12.svg | 0 {skin => src/skins}/svg/book_20.svg | 0 {skin => src/skins}/svg/clear_16.svg | 0 {skin => src/skins}/svg/clear_20.svg | 0 {skin => src/skins}/svg/college_20.svg | 0 {skin => src/skins}/svg/file_20.svg | 0 {skin => src/skins}/svg/folder_20.svg | 0 {skin => src/skins}/svg/moon_auto_18.svg | 0 {skin => src/skins}/svg/moon_dark_18.svg | 0 {skin => src/skins}/svg/moon_light_18.svg | 0 {skin => src/skins}/svg/search_20.svg | 0 {skin => src/skins}/svg/settings_28.svg | 0 {strings => src/strings}/main.yaml | 0 tools/cli_util.php | 67 ++- tools/import_article.php | 23 +- 183 files changed, 4804 insertions(+), 3731 deletions(-) delete mode 100644 engine/logging.php delete mode 100644 engine/request.php delete mode 100644 engine/skin.php delete mode 100644 engine/strings.php delete mode 100644 handlers/FilesHandler.php delete mode 100644 handlers/ServicesHandler.php create mode 100644 htdocs/img/eagle.jpg create mode 100644 htdocs/img/simurgh-big.jpg create mode 100644 htdocs/img/simurgh.jpg delete mode 100644 lib/BaconianaCollectionItem.php delete mode 100644 lib/BookCategory.php delete mode 100644 lib/BookFileType.php delete mode 100644 lib/BookItem.php delete mode 100644 lib/CollectionItem.php delete mode 100644 lib/FilesCollection.php delete mode 100644 lib/FilesItemSizeTrait.php delete mode 100644 lib/FilesItemType.php delete mode 100644 lib/FilesItemTypeTrait.php delete mode 100644 lib/Page.php delete mode 100644 lib/Post.php delete mode 100644 lib/PreviousText.php delete mode 100644 lib/Upload.php delete mode 100644 lib/WFFCollectionItem.php delete mode 100644 lib/files.php delete mode 100644 lib/pages.php delete mode 100644 lib/posts.php delete mode 100644 lib/uploads.php delete mode 100644 skin/error.twig create mode 100644 src/engine/GlobalContext.php rename engine/model.php => src/engine/Model.php (72%) create mode 100644 src/engine/ModelFieldType.php create mode 100644 src/engine/ModelProperty.php create mode 100644 src/engine/ModelSpec.php rename engine/mysql.php => src/engine/MySQL.php (70%) create mode 100644 src/engine/MySQLBitField.php rename engine/router.php => src/engine/Router.php (83%) rename lib/sphinx.php => src/engine/SphinxUtil.php (95%) create mode 100644 src/engine/exceptions/InvalidDomainException.php create mode 100644 src/engine/exceptions/NotImplementedException.php create mode 100644 src/engine/exceptions/ParseFormException.php create mode 100644 src/engine/http/AjaxError.php create mode 100644 src/engine/http/AjaxOk.php create mode 100644 src/engine/http/AjaxResponse.php create mode 100644 src/engine/http/HTTPCode.php create mode 100644 src/engine/http/HTTPMethod.php create mode 100644 src/engine/http/HtmlResponse.php create mode 100644 src/engine/http/InputVarType.php create mode 100644 src/engine/http/JSONResponse.php create mode 100644 src/engine/http/PlainTextResponse.php create mode 100644 src/engine/http/RequestHandler.php create mode 100644 src/engine/http/Response.php create mode 100644 src/engine/http/errors/BaseRedirect.php create mode 100644 src/engine/http/errors/Forbidden.php create mode 100644 src/engine/http/errors/HTTPError.php create mode 100644 src/engine/http/errors/InternalServerError.php create mode 100644 src/engine/http/errors/InvalidRequest.php create mode 100644 src/engine/http/errors/NotFound.php create mode 100644 src/engine/http/errors/NotImplemented.php create mode 100644 src/engine/http/errors/PermanentRedirect.php create mode 100644 src/engine/http/errors/Redirect.php create mode 100644 src/engine/http/errors/Unauthorized.php create mode 100644 src/engine/http/errors/UnavailableForLegalReasons.php create mode 100644 src/engine/lang/Strings.php create mode 100644 src/engine/lang/StringsBase.php create mode 100644 src/engine/lang/StringsPack.php create mode 100644 src/engine/logging/AnsiColor.php create mode 100644 src/engine/logging/DatabaseLogger.php create mode 100644 src/engine/logging/FileLogger.php create mode 100644 src/engine/logging/LogLevel.php create mode 100644 src/engine/logging/Logger.php create mode 100644 src/engine/logging/Util.php create mode 100644 src/engine/skin/BaseSkin.php create mode 100644 src/engine/skin/ErrorSkin.php create mode 100644 src/engine/skin/FeaturedSkin.php create mode 100644 src/engine/skin/Meta.php create mode 100644 src/engine/skin/Options.php create mode 100644 src/engine/skin/ServiceSkin.php rename {lib => src/engine/skin}/TwigAddons/JsTagNode.php (82%) rename {lib => src/engine/skin}/TwigAddons/JsTagParamsNode.php (71%) create mode 100644 src/engine/skin/TwigAddons/JsTagRuntime.php rename {lib => src/engine/skin}/TwigAddons/JsTagTokenParser.php (98%) create mode 100644 src/engine/skin/TwigAddons/JsTwigExtension.php rename lib/TwigAddons/MyExtension.php => src/engine/skin/TwigAddons/SkinTwigExtension.php (51%) create mode 100644 src/engine_functions.php rename functions.php => src/functions.php (85%) rename {handlers => src/handlers/foreignone}/AdminHandler.php (65%) create mode 100644 src/handlers/foreignone/BaseHandler.php create mode 100644 src/handlers/foreignone/FilesHandler.php rename {handlers => src/handlers/foreignone}/MainHandler.php (52%) create mode 100644 src/handlers/foreignone/ServicesHandler.php create mode 100644 src/handlers/ic/BaseHandler.php create mode 100644 src/handlers/ic/MainHandler.php rename init.php => src/init.php (53%) rename lib/admin.php => src/lib/Admin.php (89%) rename {lib => src/lib}/AdminActions/BaseAction.php (97%) rename {lib => src/lib}/AdminActions/PageCreate.php (53%) rename {lib => src/lib}/AdminActions/PageDelete.php (53%) rename {lib => src/lib}/AdminActions/PageEdit.php (62%) rename {lib => src/lib}/AdminActions/PostCreate.php (51%) rename {lib => src/lib}/AdminActions/PostDelete.php (51%) rename {lib => src/lib}/AdminActions/PostEdit.php (52%) rename {lib => src/lib}/AdminActions/PostTextCreate.php (63%) rename {lib => src/lib}/AdminActions/UploadsAdd.php (71%) rename {lib => src/lib}/AdminActions/UploadsDelete.php (52%) rename {lib => src/lib}/AdminActions/UploadsEditNote.php (64%) rename {lib => src/lib}/AdminActions/Util/Logger.php (95%) rename lib/cli.php => src/lib/CliUtil.php (92%) rename lib/markup.php => src/lib/MarkupUtil.php (67%) rename {lib => src/lib}/MyParsedown.php (87%) rename lib/themes.php => src/lib/ThemesUtil.php (97%) create mode 100644 src/lib/foreignone/ForeignOneSkin.php create mode 100644 src/lib/foreignone/ForeignOneSkinOptions.php create mode 100644 src/lib/foreignone/Page.php create mode 100644 src/lib/foreignone/Post.php rename {lib => src/lib/foreignone}/PostLanguage.php (87%) rename {lib => src/lib/foreignone}/PostText.php (51%) rename lib/previous_texts.php => src/lib/foreignone/PreviousText.php (59%) create mode 100644 src/lib/foreignone/Upload.php create mode 100644 src/lib/foreignone/files/Archive.php create mode 100644 src/lib/foreignone/files/ArchiveType.php create mode 100644 src/lib/foreignone/files/BaconianaIssue.php create mode 100644 src/lib/foreignone/files/Book.php rename lib/FilesItemInterface.php => src/lib/foreignone/files/FileInterface.php (70%) rename lib/MDFCollectionItem.php => src/lib/foreignone/files/MDFIssue.php (56%) create mode 100644 src/lib/foreignone/files/SectionType.php create mode 100644 src/lib/foreignone/files/Util.php create mode 100644 src/lib/foreignone/files/WFFArchiveFile.php create mode 100644 src/lib/ic/InvisibleCollegeSkin.php rename routes.php => src/routes.php (86%) create mode 100644 src/skins/error/error.twig create mode 100644 src/skins/error/notfound.twig create mode 100644 src/skins/error/notfound_ic.twig rename {skin => src/skins/foreignone}/admin_actions_log.twig (100%) rename {skin => src/skins/foreignone}/admin_auth_log.twig (100%) rename {skin => src/skins/foreignone}/admin_errors.twig (100%) rename {skin => src/skins/foreignone}/admin_index.twig (100%) rename {skin => src/skins/foreignone}/admin_login.twig (100%) rename {skin => src/skins/foreignone}/admin_page_form.twig (100%) rename {skin => src/skins/foreignone}/admin_page_new.twig (100%) rename {skin => src/skins/foreignone}/admin_post_form.twig (100%) rename {skin => src/skins/foreignone}/admin_uploads.twig (100%) rename {skin => src/skins/foreignone}/articles.twig (100%) rename {skin => src/skins/foreignone}/articles_right_links.twig (100%) rename {skin => src/skins/foreignone}/files_collection.twig (100%) rename {skin => src/skins/foreignone}/files_file.twig (68%) rename {skin => src/skins/foreignone}/files_folder.twig (100%) rename {skin => src/skins/foreignone}/files_index.twig (100%) rename {skin => src/skins/foreignone}/files_list.twig (100%) rename {skin => src/skins/foreignone}/footer.twig (100%) rename {skin => src/skins/foreignone}/header.twig (100%) rename {skin => src/skins/foreignone}/index.twig (100%) rename {skin => src/skins/foreignone}/markdown_fileupload.twig (100%) rename {skin => src/skins/foreignone}/markdown_image.twig (100%) rename {skin => src/skins/foreignone}/markdown_preview.twig (100%) rename {skin => src/skins/foreignone}/markdown_video.twig (100%) rename {skin => src/skins/foreignone}/page.twig (100%) rename {skin => src/skins/foreignone}/post.twig (100%) rename {skin => src/skins/foreignone}/rss.twig (100%) rename {skin => src/skins/foreignone}/spinner.twig (100%) create mode 100644 src/skins/ic/soon.twig rename {skin => src/skins}/svg/arrow_up_right_out_square_outline_12.svg (100%) rename {skin => src/skins}/svg/book_20.svg (100%) rename {skin => src/skins}/svg/clear_16.svg (100%) rename {skin => src/skins}/svg/clear_20.svg (100%) rename {skin => src/skins}/svg/college_20.svg (100%) rename {skin => src/skins}/svg/file_20.svg (100%) rename {skin => src/skins}/svg/folder_20.svg (100%) rename {skin => src/skins}/svg/moon_auto_18.svg (100%) rename {skin => src/skins}/svg/moon_dark_18.svg (100%) rename {skin => src/skins}/svg/moon_light_18.svg (100%) rename {skin => src/skins}/svg/search_20.svg (100%) rename {skin => src/skins}/svg/settings_28.svg (100%) rename {strings => src/strings}/main.yaml (100%) diff --git a/.gitignore b/.gitignore index 8001eab..33136ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /debug.log /log -test.php +src/test.php /.git /node_modules/ /vendor/ @@ -8,7 +8,6 @@ test.php ._.DS_Store .sass-cache/ config-static.php -/config-local.php /config.yaml /.idea /htdocs/dist-css diff --git a/README.md b/README.md index bd1dbd3..091c273 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This is a source code of 4in1.ws web site. ## Configuration -Should be done by copying config.php to config-local.php and modifying config-local.php. +Should be done by copying config.yaml.example to config.yaml and customizing it. ## Installation diff --git a/composer.json b/composer.json index 2378f70..981ce1e 100644 --- a/composer.json +++ b/composer.json @@ -26,5 +26,19 @@ ], "minimum-stability": "dev", "prefer-stable": true, - "preferred-install": "dist" + "preferred-install": "dist", + "autoload": { + "psr-4": { + "engine\\": "src/engine", + "app\\": "src/lib", + "app\\foreignone\\": [ + "src/lib/foreignone", + "src/handlers/foreignone" + ], + "app\\ic\\": [ + "src/lib/ic", + "src/handlers/ic" + ] + } + } } diff --git a/cron/sitemap.php b/cron/sitemap.php index 4c5e0d0..d51a992 100644 --- a/cron/sitemap.php +++ b/cron/sitemap.php @@ -1,13 +1,15 @@ fetch($q)) { // files $sitemap->addItem("{$addr}/files/", changeFrequency: Sitemap::WEEKLY); -foreach (FilesCollection::cases() as $fc) { +foreach (ArchiveType::cases() as $fc) { $sitemap->addItem("{$addr}/files/".$fc->value.'/', changeFrequency: Sitemap::MONTHLY); } -foreach ([FilesCollection::WilliamFriedman, FilesCollection::Baconiana] as $fc) { - $q = $db->query("SELECT id FROM {$fc->value}_collection WHERE type=?", FilesItemType::FOLDER); +foreach ([ArchiveType::WilliamFriedman, ArchiveType::Baconiana] as $fc) { + $q = $db->query("SELECT id FROM {$fc->value}_collection WHERE type='folder'"); while ($row = $db->fetch($q)) { $sitemap->addItem("{$addr}/files/".$fc->value.'/'.$row['id'].'/', changeFrequency: Sitemap::MONTHLY); } } -$q = $db->query("SELECT id FROM books WHERE type=? AND external=0", FilesItemType::FOLDER); +$q = $db->query("SELECT id FROM books WHERE type='folder' AND external=0"); while ($row = $db->fetch($q)) { $sitemap->addItem("{$addr}/files/".$row['id'].'/', changeFrequency: Sitemap::MONTHLY); diff --git a/deploy/gen_static_config.php b/deploy/gen_static_config.php index f475279..084195d 100755 --- a/deploy/gen_static_config.php +++ b/deploy/gen_static_config.php @@ -1,8 +1,9 @@ #!/usr/bin/env php 0) { break; default: - cli::die('unsupported argument: '.$argv[0]); + CliUtil::die('unsupported argument: '.$argv[0]); } } if (is_null($input_dir)) - cli::die("input directory has not been specified"); + CliUtil::die("input directory has not been specified"); $hashes = []; foreach (['css', 'js'] as $type) { $entries = glob_recursive($input_dir.'/dist-'.$type.'/*.'.$type); if (empty($entries)) { - cli::error("warning: no files found in $input_dir/dist-$type"); + CliUtil::error("warning: no files found in $input_dir/dist-$type"); continue; } @@ -40,7 +41,7 @@ foreach (['css', 'js'] as $type) { 'version' => get_hash($file), 'integrity' => [] ]; - foreach (RESOURCE_INTEGRITY_HASHES as $hash_type) + foreach (\engine\skin\FeaturedSkin::RESOURCE_INTEGRITY_HASHES as $hash_type) $hashes[$type.'/'.basename($file)]['integrity'][$hash_type] = base64_encode(hash_file($hash_type, $file, true)); } } @@ -66,9 +67,9 @@ function get_hash(string $path): string { function glob_escape(string $pattern): string { if (str_contains($pattern, '[') || str_contains($pattern, ']')) { $placeholder = uniqid(); - $replaces = array( $placeholder.'[', $placeholder.']', ); - $pattern = str_replace( array('[', ']', ), $replaces, $pattern); - $pattern = str_replace( $replaces, array('[[]', '[]]', ), $pattern); + $replaces = [$placeholder.'[', $placeholder.']', ]; + $pattern = str_replace( ['[', ']'], $replaces, $pattern); + $pattern = str_replace( $replaces, ['[[]', '[]]'], $pattern); } return $pattern; } diff --git a/engine/logging.php b/engine/logging.php deleted file mode 100644 index 375922b..0000000 --- a/engine/logging.php +++ /dev/null @@ -1,318 +0,0 @@ -value + ($fg_bright ? 90 : 30); - if (!is_null($bg)) - $codes[] = $bg->value + ($bg_bright ? 100 : 40); - if ($bold) - $codes[] = 1; - - if (empty($codes)) - return $text; - - return "\033[".implode(';', $codes)."m".$text."\033[0m"; -} - -enum LogLevel: int { - case ERROR = 10; - case WARNING = 5; - case INFO = 3; - case DEBUG = 2; -} - -function logDebug(...$args): void { global $__logger; $__logger?->log(LogLevel::DEBUG, null, ...$args); } -function logInfo(...$args): void { global $__logger; $__logger?->log(LogLevel::INFO, null, ...$args); } -function logWarning(...$args): void { global $__logger; $__logger?->log(LogLevel::WARNING, null, ...$args); } -function logError(...$args): void { - global $__logger; - if (array_key_exists('stacktrace', $args)) { - $st = $args['stacktrace']; - unset($args['stacktrace']); - } else { - $st = null; - } - if ($__logger?->canReport()) - $__logger?->log(LogLevel::ERROR, $st, ...$args); -} - -abstract class Logger { - protected bool $enabled = false; - protected int $counter = 0; - protected int $recursionLevel = 0; - - /** @var ?callable $filter */ - protected $filter = null; - - public function setErrorFilter(callable $filter): void { - $this->filter = $filter; - } - - public function disable(): void { - $this->enabled = false; - } - - public function enable(): void { - static $error_handler_set = false; - $this->enabled = true; - - if ($error_handler_set) - return; - - $self = $this; - - set_error_handler(function($no, $str, $file, $line) use ($self) { - if (!$self->enabled) - return; - - if (is_callable($self->filter) && !($self->filter)($no, $file, $line, $str)) - return; - - static::write(LogLevel::ERROR, $str, - errno: $no, - errfile: $file, - errline: $line); - }); - - set_exception_handler(function(\Throwable $e): void { - static::write(LogLevel::ERROR, get_class($e).': '.$e->getMessage(), - errfile: $e->getFile() ?: '?', - errline: $e->getLine() ?: 0, - stacktrace: $e->getTraceAsString()); - }); - - register_shutdown_function(function () use ($self) { - if (!$self->enabled || !($error = error_get_last())) - return; - - if (is_callable($self->filter) - && !($self->filter)($error['type'], $error['file'], $error['line'], $error['message'])) { - return; - } - - static::write(LogLevel::ERROR, $error['message'], - errno: $error['type'], - errfile: $error['file'], - errline: $error['line']); - }); - - $error_handler_set = true; - } - - public function log(LogLevel $level, ?string $stacktrace = null, ...$args): void { - if (!isDev() && $level == LogLevel::DEBUG) - return; - $this->write($level, strVars($args), - stacktrace: $stacktrace); - } - - public function canReport(): bool { - return $this->recursionLevel < 3; - } - - protected function write(LogLevel $level, - string $message, - ?int $errno = null, - ?string $errfile = null, - ?string $errline = null, - ?string $stacktrace = null): void { - $this->recursionLevel++; - - if ($this->canReport()) - $this->writer($level, $this->counter++, $message, $errno, $errfile, $errline, $stacktrace); - - $this->recursionLevel--; - } - - abstract protected function writer(LogLevel $level, - int $num, - string $message, - ?int $errno = null, - ?string $errfile = null, - ?string $errline = null, - ?string $stacktrace = null): void; -} - -class FileLogger extends Logger { - - public function __construct(protected string $logFile) {} - - protected function writer(LogLevel $level, - int $num, - string $message, - ?int $errno = null, - ?string $errfile = null, - ?string $errline = null, - ?string $stacktrace = null): void - { - if (is_null($this->logFile)) { - fprintf(STDERR, __METHOD__.': logfile is not set'); - return; - } - - $time = time(); - - // TODO rewrite using sprintf - $exec_time = strval(exectime()); - if (strlen($exec_time) < 6) - $exec_time .= str_repeat('0', 6 - strlen($exec_time)); - - $title = isCli() ? 'cli' : $_SERVER['REQUEST_URI']; - $date = date('d/m/y H:i:s', $time); - - $buf = ''; - if ($num == 0) { - $buf .= ansi(" $title ", - fg: AnsiColor::WHITE, - bg: AnsiColor::MAGENTA, - bold: true, - fg_bright: true); - $buf .= ansi(" $date ", fg: AnsiColor::WHITE, bg: AnsiColor::BLUE, fg_bright: true); - $buf .= "\n"; - } - - $letter = strtoupper($level->name[0]); - $color = match ($level) { - LogLevel::ERROR => AnsiColor::RED, - LogLevel::INFO => AnsiColor::GREEN, - LogLevel::DEBUG => AnsiColor::WHITE, - LogLevel::WARNING => AnsiColor::YELLOW - }; - - $buf .= ansi($letter.ansi('='.ansi($num, bold: true)), fg: $color).' '; - $buf .= ansi($exec_time, fg: AnsiColor::CYAN).' '; - if (!is_null($errno)) { - $buf .= ansi($errfile, fg: AnsiColor::GREEN); - $buf .= ansi(':', fg: AnsiColor::WHITE); - $buf .= ansi($errline, fg: AnsiColor::GREEN, fg_bright: true); - $buf .= ' ('.getPHPErrorName($errno).') '; - } - - $buf .= $message."\n"; - if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING])) - $buf .= ($stacktrace ?: backtraceAsString(2))."\n"; - - $set_perm = false; - if (!file_exists($this->logFile)) { - $set_perm = true; - $dir = dirname($this->logFile); - echo "dir: $dir\n"; - - if (!file_exists($dir)) { - mkdir($dir); - setperm($dir); - } - } - - $f = fopen($this->logFile, 'a'); - if (!$f) { - fprintf(STDERR, __METHOD__.': failed to open file \''.$this->logFile.'\' for writing'); - return; - } - - fwrite($f, $buf); - fclose($f); - - if ($set_perm) - setperm($this->logFile); - } - -} - -class DatabaseLogger extends Logger { - protected function writer(LogLevel $level, - int $num, - string $message, - ?int $errno = null, - ?string $errfile = null, - ?string $errline = null, - ?string $stacktrace = null): void - { - $db = DB(); - - $data = [ - 'ts' => time(), - 'num' => $num, - 'time' => exectime(), - 'errno' => $errno ?: 0, - 'file' => $errfile ?: '?', - 'line' => $errline ?: 0, - 'text' => $message, - 'level' => $level->value, - 'stacktrace' => $stacktrace ?: backtraceAsString(2), - 'is_cli' => intval(isCli()), - 'admin_id' => isAdmin() ? admin::getId() : 0, - ]; - - if (isCli()) { - $data += [ - 'ip' => '', - 'ua' => '', - 'url' => '', - ]; - } else { - $data += [ - 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), - 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', - 'url' => $_SERVER['HTTP_HOST'].$_SERVER['REQUEST_URI'] - ]; - } - - $db->insert('backend_errors', $data); - } -} - -function getPHPErrorName(int $errno): ?string { - static $errors = null; - if (is_null($errors)) - $errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true)); - return $errors[$errno] ?? null; -} - -function strVarDump($var, bool $print_r = false): string { - ob_start(); - $print_r ? print_r($var) : var_dump($var); - return trim(ob_get_clean()); -} - -function strVars(array $args): string { - $args = array_map(fn($a) => match (gettype($a)) { - 'string' => $a, - 'array', 'object' => strVarDump($a, true), - default => strVarDump($a) - }, $args); - return implode(' ', $args); -} - -function backtraceAsString(int $shift = 0): string { - $bt = debug_backtrace(); - $lines = []; - foreach ($bt as $i => $t) { - if ($i < $shift) - continue; - - if (!isset($t['file'])) { - $lines[] = 'from ?'; - } else { - $lines[] = 'from '.$t['file'].':'.$t['line']; - } - } - return implode("\n", $lines); -} \ No newline at end of file diff --git a/engine/request.php b/engine/request.php deleted file mode 100644 index 1646293..0000000 --- a/engine/request.php +++ /dev/null @@ -1,238 +0,0 @@ -skin->setRenderOptions(['inside_admin_interface' => true]); -//} - -abstract class request_handler { - - protected array $routerInput = []; - protected skin $skin; - - public static function resolveAndDispatch() { - if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET'])) - self::httpError(HTTPCode::NotImplemented, 'Method '.$_SERVER['REQUEST_METHOD'].' not implemented'); - - $uri = $_SERVER['REQUEST_URI']; - if (($pos = strpos($uri, '?')) !== false) - $uri = substr($uri, 0, $pos); - - $router = router::getInstance(); - $route = $router->find($uri); - if ($route === null) - self::httpError(HTTPCode::NotFound, 'Route not found'); - - $route = preg_split('/ +/', $route); - $handler_class = $route[0].'Handler'; - if (!class_exists($handler_class)) - self::httpError(HTTPCode::NotFound, isDev() ? 'Handler class "'.$handler_class.'" not found' : ''); - - $action = $route[1]; - $input = []; - if (count($route) > 2) { - for ($i = 2; $i < count($route); $i++) { - $var = $route[$i]; - list($k, $v) = explode('=', $var); - $input[trim($k)] = trim($v); - } - } - - /** @var request_handler $handler */ - $handler = new $handler_class(); - $handler->callAct($_SERVER['REQUEST_METHOD'], $action, $input); - } - - public function __construct() { - $this->skin = skin::getInstance(); - $this->skin->addStatic( - 'css/common.css', - 'js/common.js' - ); - $this->skin->setGlobal([ - 'is_admin' => isAdmin(), - 'is_dev' => isDev() - ]); - } - - public function beforeDispatch(string $http_method, string $action) {} - - public function callAct(string $http_method, string $action, array $input = []) { - $handler_method = $_SERVER['REQUEST_METHOD'].'_'.$action; - if (!method_exists($this, $handler_method)) - $this->notFound(static::class.'::'.$handler_method.' is not defined'); - - if (!((new ReflectionMethod($this, $handler_method))->isPublic())) - $this->notFound(static::class.'::'.$handler_method.' is not public'); - - if (!empty($input)) - $this->routerInput += $input; - - $args = $this->beforeDispatch($http_method, $action); - return call_user_func_array([$this, $handler_method], is_array($args) ? [$args] : []); - } - - public function input(string $input, array $options = []): array { - $options = array_merge(['trim' => false], $options); - $strval = fn(mixed $val): string => $options['trim'] ? trim((string)$val) : (string)$val; - - $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); - $ret = []; - foreach ($input as $var) { - $enum_values = null; - $enum_default = null; - - $pos = strpos($var, ':'); - if ($pos === 1) { // only one-character type specifiers are supported - $type = substr($var, 0, $pos); - $rest = substr($var, $pos + 1); - - $vartype = InputVarType::tryFrom($type); - if (is_null($vartype)) - self::internalServerError('invalid input type '.$type); - - if ($vartype == InputVarType::ENUM) { - $br_from = strpos($rest, '('); - $br_to = strpos($rest, ')'); - - if ($br_from === false || $br_to === false) - self::internalServerError('failed to parse enum values: '.$rest); - - $enum_values = array_map('trim', explode('|', trim(substr($rest, $br_from + 1, $br_to - $br_from - 1)))); - $name = trim(substr($rest, 0, $br_from)); - - if (!empty($enum_values)) { - foreach ($enum_values as $key => $val) { - if (str_starts_with($val, '=')) { - $enum_values[$key] = substr($val, 1); - $enum_default = $enum_values[$key]; - } - } - } - } else { - $name = trim($rest); - } - - } else { - $vartype = InputVarType::STRING; - $name = trim($var); - } - - $val = null; - if (isset($this->routerInput[$name])) { - $val = $this->routerInput[$name]; - } else if (isset($_POST[$name])) { - $val = $_POST[$name]; - } else if (isset($_GET[$name])) { - $val = $_GET[$name]; - } - if (is_array($val)) - $val = $strval(implode($val)); - - $ret[] = match($vartype) { - InputVarType::INTEGER => (int)$val, - InputVarType::FLOAT => (float)$val, - InputVarType::BOOLEAN => (bool)$val, - InputVarType::ENUM => !in_array($val, $enum_values) ? $enum_default ?? '' : $strval($val), - default => $strval($val) - }; - } - return $ret; - } - - public function getPage(int $per_page, ?int $count = null): array { - list($page) = $this->input('i:page'); - $pages = $count !== null ? ceil($count / $per_page) : null; - if ($pages !== null && $page > $pages) - $page = $pages; - if ($page < 1) - $page = 1; - $offset = $per_page * ($page-1); - return [$page, $pages, $offset]; - } - - protected static function ensureXhr(): void { - if (!self::isXhrRequest()) - self::invalidRequest(); - } - - public static function getCSRF(string $key): string { - global $config; - $user_key = isAdmin() ? admin::getCSRFSalt() : $_SERVER['REMOTE_ADDR']; - return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20); - } - - protected static function checkCSRF(string $key): void { - if (self::getCSRF($key) != ($_REQUEST['token'] ?? '')) - self::forbidden('invalid token'); - } - - public static function httpError(HTTPCode $http_code, string $message = ''): void { - if (self::isXhrRequest()) { - $data = []; - if ($message != '') - $data['message'] = $message; - self::ajaxError((object)$data, $http_code->value); - } else { - $http_message = preg_replace('/(?name); - $html = skin::getInstance()->render('error.twig', [ - 'code' => $http_code->value, - 'title' => $http_message, - 'message' => $message - ]); - http_response_code($http_code->value); - echo $html; - exit; - } - } - - protected static function redirect(string $url, HTTPCode $code = HTTPCode::MovedPermanently): never { - if (!in_array($code, [HTTPCode::MovedPermanently, HTTPCode::Found])) - self::internalServerError('invalid http code'); - if (self::isXhrRequest()) - self::ajaxOk(['redirect' => $url]); - http_response_code($code->value); - header('Location: '.$url); - exit; - } - - protected static function invalidRequest(string $message = '') { self::httpError(HTTPCode::InvalidRequest, $message); } - protected static function internalServerError(string $message = '') { self::httpError(HTTPCode::InternalServerError, $message); } - protected static function notFound(string $message = '') { self::httpError(HTTPCode::NotFound, $message); } - protected static function forbidden(string $message = '') { self::httpError(HTTPCode::Forbidden, $message); } - protected static function isXhrRequest(): bool { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; } - protected static function ajaxOk(mixed $data): void { self::ajaxResponse(['response' => $data]); } - protected static function ajaxError(mixed $error, int $code = 200): void { self::ajaxResponse(['error' => $error], $code); } - - protected static function ajaxResponse(mixed $data, int $code = 200): never { - header('Cache-Control: no-cache, must-revalidate'); - header('Pragma: no-cache'); - header('Content-Type: application/json; charset=utf-8'); - http_response_code($code); - echo jsonEncode($data); - exit; - } - -} diff --git a/engine/skin.php b/engine/skin.php deleted file mode 100644 index e75ce4e..0000000 --- a/engine/skin.php +++ /dev/null @@ -1,566 +0,0 @@ - false, - 'wide' => false, - 'logo_path_map' => [], - 'logo_link_map' => [], - 'is_index' => false, - 'head_section' => null, - 'articles_lang' => null, - 'inside_admin_interface' => false, - ]; - public array $static = []; - protected array $styleNames = []; - protected array $svgDefs = []; - - public \Twig\Environment $twig; - - protected static ?skin $instance = null; - - public static function getInstance(): skin { - if (self::$instance === null) - self::$instance = new skin(); - return self::$instance; - } - - /** - * @throws LoaderError - */ - protected function __construct() { - global $config; - $cache_dir = $config['skin_cache_'.(isDev() ? 'dev' : 'prod').'_dir']; - if (!file_exists($cache_dir)) { - if (mkdir($cache_dir, $config['dirs_mode'], true)) - setperm($cache_dir); - } - - // must specify a second argument ($rootPath) here - // otherwise it will be getcwd() and it's www-prod/htdocs/ for apache and www-prod/ for cli code - // this is bad for templates rebuilding - $twig_loader = new \Twig\Loader\FilesystemLoader(APP_ROOT.'/skin', APP_ROOT); - // $twig_loader->addPath(APP_ROOT.'/htdocs/svg', 'svg'); - - $env_options = []; - if (!is_null($cache_dir)) { - $env_options += [ - 'cache' => $cache_dir, - 'auto_reload' => isDev() - ]; - } - $twig = new \Twig\Environment($twig_loader, $env_options); - $twig->addExtension(new \TwigAddons\MyExtension()); - - $this->twig = $twig; - } - - public function addMeta(array $data) { - static $twitter_limits = [ - 'title' => 70, - 'description' => 200 - ]; - $real_meta = []; - $add_og_twitter = function($key, $value) use (&$real_meta, $twitter_limits) { - foreach (['og', 'twitter'] as $social) { - if ($social == 'twitter' && isset($twitter_limits[$key])) { - if (mb_strlen($value) > $twitter_limits[$key]) - $value = mb_substr($value, 0, $twitter_limits[$key]-3).'...'; - } - $real_meta[] = [ - $social == 'twitter' ? 'name' : 'property' => $social.':'.$key, - 'content' => $value - ]; - } - }; - foreach ($data as $key => $value) { - if (str_starts_with($value, '@')) - $value = lang(substr($value, 1)); - switch ($key) { - case '@url': - case '@title': - case '@image': - $add_og_twitter(substr($key, 1), $value); - break; - - case '@description': - case '@keywords': - $real_name = substr($key, 1); - $add_og_twitter($real_name, $value); - $real_meta[] = ['name' => $real_name, 'content' => $value]; - break; - - default: - if (str_starts_with($key, 'og:')) { - $real_meta[] = ['property' => $key, 'content' => $value]; - } else { - logWarning("unsupported meta: $key => $value"); - } - break; - } - } - $this->meta = array_merge($this->meta, $real_meta); - } - - public function exportStrings(array|string $keys): void { - global $__lang; - $this->lang = array_merge($this->lang, is_string($keys) ? $__lang->search($keys) : $keys); - } - - public function setTitle(string $title): void { - if (str_starts_with($title, '$')) - $title = lang(substr($title, 1)); - else if (str_starts_with($title, '\\$')) - $title = substr($title, 1); - $this->title = $title; - } - - public function addPageTitleModifier(callable $callable): void { - if (!is_callable($callable)) { - trigger_error(__METHOD__.': argument is not callable'); - } else { - $this->titleModifiers[] = $callable; - } - } - - protected function getTitle(): string { - $title = $this->title != '' ? $this->title : lang('site_title'); - if (!empty($this->titleModifiers)) { - foreach ($this->titleModifiers as $modifier) - $title = $modifier($title); - } - return $title; - } - - public function set($arg1, $arg2 = null) { - if (is_array($arg1)) { - foreach ($arg1 as $key => $value) - $this->vars[$key] = $value; - } elseif ($arg2 !== null) { - $this->vars[$arg1] = $arg2; - } - } - - public function isSet($key): bool { - return isset($this->vars[$key]); - } - - public function setGlobal($arg1, $arg2 = null): void { - if ($this->globalsApplied) - logError(__METHOD__.': WARNING: globals were already applied, your change will not be visible'); - - if (is_array($arg1)) { - foreach ($arg1 as $key => $value) - $this->globalVars[$key] = $value; - } elseif ($arg2 !== null) { - $this->globalVars[$arg1] = $arg2; - } - } - - public function isGlobalSet($key): bool { - return isset($this->globalVars[$key]); - } - - public function getGlobal($key) { - return $this->isGlobalSet($key) ? $this->globalVars[$key] : null; - } - - public function applyGlobals(): void { - if (!empty($this->globalVars) && !$this->globalsApplied) { - foreach ($this->globalVars as $key => $value) - $this->twig->addGlobal($key, $value); - $this->globalsApplied = true; - } - } - - public function addStatic(string ...$files): void { - foreach ($files as $file) - $this->static[] = $file; - } - - public function addJS(string $js): void { - if ($js != '') - $this->js[] = $js; - } - - protected function getJS(): string { - if (empty($this->js)) - return ''; - return implode("\n", $this->js); - } - - public function preloadSVG(string $name): void { - if (isset($this->svgDefs[$name])) - return; - - if (!preg_match_all('/\d+/', $name, $matches)) - throw new InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]'); - - $size = array_slice($matches[0], -2); - $this->svgDefs[$name] = [ - 'width' => $size[0], - 'height' => $size[1] ?? $size[0] - ]; - } - - public function getSVG(string $name, bool $in_place = false): ?string { - $this->preloadSVG($name); - $w = $this->svgDefs[$name]['width']; - $h = $this->svgDefs[$name]['height']; - if ($in_place) { - $svg = ''; - $svg .= file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg'); - $svg .= ''; - return $svg; - } else { - return ''; - } - } - - public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string { - static $chevron = ''; - $buf = implode(array_map(function(array $i) use ($chevron): string { - $buf = ''; - $has_url = array_key_exists('url', $i); - - if ($has_url) - $buf .= ''; - else - $buf .= ''; - $buf .= htmlescape($i['text']); - - if ($has_url) - $buf .= ' '.$chevron.''; - else - $buf .= ''; - - return $buf; - }, $items)); - $class = 'bc'; - if ($mt) - $class .= ' mt'; - return '
'.$buf.'
'; - } - - public function renderPageNav(int $page, int $pages, string $link_template, ?array $opts = null): string { - if ($opts === null) { - $count = 0; - } else { - $opts = array_merge(['count' => 0], $opts); - $count = $opts['count']; - } - - $min_page = max(1, $page-2); - $max_page = min($pages, $page+2); - - $pages_html = ''; - $base_class = 'pn-button no-hover no-select no-drag is-page'; - for ($p = $min_page; $p <= $max_page; $p++) { - $class = $base_class; - if ($p == $page) - $class .= ' is-page-cur'; - $pages_html .= ''.$p.''; - } - - if ($min_page > 2) { - $pages_html = '
 
'.$pages_html; - } - if ($min_page > 1) { - $pages_html = '1'.$pages_html; - } - - if ($max_page < $pages-1) { - $pages_html .= '
 
'; - } - if ($max_page < $pages) { - $pages_html .= ''.$pages.''; - } - - $pn_class = 'pn'; - if ($pages < 2) { - $pn_class .= ' no-nav'; - if (!$count) { - $pn_class .= ' no-results'; - } - } - - $html = << -
- {$pages_html} -
- -HTML; - - return $html; - } - - protected static function pageNavGetLink($page, $link_template) { - return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template); - } - - protected function getSVGTags(): string { - $buf = ''; - foreach ($this->svgDefs as $name => $icon) { - $content = file_get_contents(APP_ROOT.'/skin/svg/'.$name.'.svg'); - $buf .= "$content"; - } - $buf .= ''; - return $buf; - } - - public function setRenderOptions(array $options): void { - $this->options = array_merge($this->options, $options); - } - - public function render($template, array $vars = []): string { - $this->applyGlobals(); - return $this->doRender($template, $this->vars + $vars); - } - - public function renderPage(string $template, array $vars = []): never { - $this->exportStrings(['4in1']); - $this->applyGlobals(); - - // render body first - $b = $this->renderBody($template, $vars); - - // then everything else - $h = $this->renderHeader(); - $f = $this->renderFooter(); - - echo $h; - echo $b; - echo $f; - - exit; - } - - protected function renderHeader(): string { - global $config; - - $body_class = []; - if ($this->options['full_width']) - $body_class[] = 'full-width'; - else if ($this->options['wide']) - $body_class[] = 'wide'; - - $title = $this->getTitle(); - if (!$this->options['is_index']) - $title = lang('4in1').' - '.$title; - - $vars = [ - 'title' => $title, - 'meta_html' => $this->getMetaTags(), - 'static_html' => $this->getHeaderStaticTags(), - 'svg_html' => $this->getSVGTags(), - 'render_options' => $this->options, - 'app_config' => [ - 'domain' => $config['domain'], - 'devMode' => $config['is_dev'], - 'cookieHost' => $config['cookie_host'], - ], - 'body_class' => $body_class, - 'theme' => themes::getUserTheme(), - ]; - - return $this->doRender('header.twig', $vars); - } - - protected function renderBody(string $template, array $vars): string { - return $this->doRender($template, $this->vars + $vars); - } - - protected function renderFooter(): string { - global $config; - - $exec_time = microtime(true) - START_TIME; - $exec_time = round($exec_time, 4); - - $footer_vars = [ - 'exec_time' => $exec_time, - 'render_options' => $this->options, - 'admin_email' => $config['admin_email'], - // 'lang_json' => json_encode($this->getLangKeys(), JSON_UNESCAPED_UNICODE), - // 'static_config' => $this->getStaticConfig(), - 'script_html' => $this->getFooterScriptTags(), - 'this_page_url' => $_SERVER['REQUEST_URI'], - 'theme' => themes::getUserTheme(), - ]; - return $this->doRender('footer.twig', $footer_vars); - } - - protected function doRender(string $template, array $vars = []): string { - $s = ''; - try { - $s = $this->twig->render($template, $vars); - } catch (\Twig\Error\Error $e) { - $error = get_class($e).": failed to render"; - $source_ctx = $e->getSourceContext(); - if ($source_ctx) { - $path = $source_ctx->getPath(); - if (str_starts_with($path, APP_ROOT)) - $path = substr($path, strlen(APP_ROOT)+1); - $error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine(); - } - $error .= ": "; - $error .= $e->getMessage(); - logError($error); - if (isDev()) - $s = $error."\n"; - } - return $s; - } - - protected function getMetaTags(): string { - if (empty($this->meta)) - return ''; - return implode('', array_map(function(array $item): string { - $s = ' $v) - $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"'; - $s .= '/>'; - $s .= "\n"; - return $s; - }, $this->meta)); - } - - protected function getHeaderStaticTags(): string { - $html = []; - $theme = themes::getUserTheme(); - $dark = $theme == 'dark' || ($theme == 'auto' && themes::isUserSystemThemeDark()); - $this->styleNames = []; - foreach ($this->static as $name) { - // javascript - if (str_starts_with($name, 'js/')) - $html[] = $this->jsLink($name); - - // css - else if (str_starts_with($name, 'css/')) { - $html[] = $this->cssLink($name, 'light', $style_name_ptr); - $this->styleNames[] = $style_name_ptr; - - if ($dark) - $html[] = $this->cssLink($name, 'dark', $style_name_ptr); - else if (!isDev()) - $html[] = $this->cssPrefetchLink($style_name_ptr.'_dark'); - } - else - logError(__FUNCTION__.': unexpected static entry: '.$name); - } - return implode("\n", $html); - } - - protected function getFooterScriptTags(): string { - global $config; - - $html = ''; - return $html; - } - - protected function jsLink(string $name): string { - list (, $bname) = $this->getStaticNameParts($name); - if (isDev()) { - $href = '/js.php?name='.urlencode($bname).'&v='.time(); - } else { - $href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name); - } - return ''; - } - - protected function cssLink(string $name, string $theme, &$bname = null): string { - list(, $bname) = $this->getStaticNameParts($name); - - $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css'; - - if (isDev()) { - $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time(); - } else { - $version = $this->getStaticVersion($config_name); - $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version; - } - - $id = 'style_'.$bname; - if ($theme == 'dark') - $id .= '_dark'; - - return 'getStaticIntegrityAttribute($config_name).'>'; - } - - protected function cssPrefetchLink(string $name): string { - $url = '/dist-css/'.$name.'.css?v='.$this->getStaticVersion('css/'.$name.'.css'); - $integrity = $this->getStaticIntegrityAttribute('css/'.$name.'.css'); - return ''; - } - - protected function getStaticNameParts(string $name): array { - $dname = dirname($name); - $bname = basename($name); - if (($pos = strrpos($bname, '.'))) { - $ext = substr($bname, $pos+1); - $bname = substr($bname, 0, $pos); - } else { - $ext = ''; - } - return [$dname, $bname, $ext]; - } - - protected function getStaticVersion(string $name): string { - global $config; - if (isDev()) - return time(); - if (str_starts_with($name, '/')) { - logWarning(__FUNCTION__.': '.$name.' starts with /'); - $name = substr($name, 1); - } - return $config['static'][$name]['version'] ?? 'notfound'; - } - - protected function getStaticIntegrityAttribute(string $name): string { - if (isDev()) - return ''; - global $config; - return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], RESOURCE_INTEGRITY_HASHES)).'"'; - } - -} diff --git a/engine/strings.php b/engine/strings.php deleted file mode 100644 index b2b289f..0000000 --- a/engine/strings.php +++ /dev/null @@ -1,138 +0,0 @@ -data[$offset]); - } - - public function offsetUnset(mixed $offset): void { - throw new RuntimeException('Not implemented'); - } - - public function offsetGet(mixed $offset): mixed { - if (!isset($this->data[$offset])) { - logError(__METHOD__.': '.$offset.' not found'); - return '{'.$offset.'}'; - } - return $this->data[$offset]; - } - - public function get(string $key, mixed ...$sprintf_args): string|array { - $val = $this[$key]; - if (!empty($sprintf_args)) { - array_unshift($sprintf_args, $val); - return call_user_func_array('sprintf', $sprintf_args); - } else { - return $val; - } - } - - public function num(string $key, int $num, array$opts = []) { - $s = $this[$key]; - - $default_opts = [ - 'format' => true, - 'format_delim' => ' ', - 'lang' => 'ru', - ]; - $opts = array_merge($default_opts, $opts); - - switch ($opts['lang']) { - case 'ru': - $n = $num % 100; - if ($n > 19) - $n %= 10; - - if ($n == 1) { - $word = 0; - } elseif ($n >= 2 && $n <= 4) { - $word = 1; - } elseif ($num == 0 && count($s) == 4) { - $word = 3; - } else { - $word = 2; - } - break; - - default: - if ($num == 0 && count($s) == 4) { - $word = 3; - } else { - $word = $num == 1 ? 0 : 1; - } - break; - } - - // if zero - if ($word == 3) { - return $s[3]; - } - - if (is_callable($opts['format'])) { - $num = $opts['format']($num); - } else if ($opts['format'] === true) { - $num = formatNumber($num, $opts['format_delim']); - } - - return sprintf($s[$word], $num); - } -} - -class Strings extends StringsBase { - private static ?Strings $instance = null; - protected array $loadedPackages = []; - - private function __construct() {} - protected function __clone() {} - - public static function getInstance(): self { - if (is_null(self::$instance)) - self::$instance = new self(); - return self::$instance; - } - - public function load(string ...$pkgs): array { - $keys = []; - foreach ($pkgs as $name) { - $raw = yaml_parse_file(APP_ROOT.'/strings/'.$name.'.yaml'); - $this->data = array_merge($this->data, $raw); - $keys = array_merge($keys, array_keys($raw)); - $this->loadedPackages[$name] = true; - } - return $keys; - } - - public function flex(string $s, DeclensionCase $case, NameSex $sex, NameType $type): string { - $s = iconv('utf-8', 'cp1251', $s); - $s = vkflex($s, $case->value, $sex->value, 0, $type->value); - return iconv('cp1251', 'utf-8', $s); - } - - public function search(string $regexp): array { - return preg_grep($regexp, array_keys($this->data)); - } -} \ No newline at end of file diff --git a/handlers/FilesHandler.php b/handlers/FilesHandler.php deleted file mode 100644 index c68d178..0000000 --- a/handlers/FilesHandler.php +++ /dev/null @@ -1,220 +0,0 @@ - new CollectionItem($c), FilesCollection::cases()); - $books = files::books_get(); - $misc = files::books_get(category: BookCategory::MISC); - $this->skin->addMeta([ - '@title' => '$meta_files_title', - '@description' => '$meta_files_description' - ]); - $this->skin->setTitle('$files'); - $this->skin->setRenderOptions(['head_section' => 'files']); - $this->skin->renderPage('files_index.twig', [ - 'collections' => $collections, - 'books' => $books, - 'misc' => $misc - ]); - } - - public function GET_folder() { - list($folder_id) = $this->input('i:folder_id'); - - $parents = files::books_get_folder($folder_id, true); - if (!$parents) - self::notFound(); - - if (count($parents) > 1) - $parents = array_reverse($parents); - - $folder = $parents[count($parents)-1]; - $files = files::books_get($folder_id, category: $folder->category); - - $bc = [ - ['text' => lang('files'), 'url' => '/files/'], - ]; - if ($parents) { - for ($i = 0; $i < count($parents)-1; $i++) { - $parent = $parents[$i]; - $bc_item = ['text' => $parent->getTitle()]; - if ($i < count($parents)-1) - $bc_item['url'] = $parent->getUrl(); - $bc[] = $bc_item; - } - } - $bc[] = ['text' => $folder->title]; - - $this->skin->addMeta([ - '@title' => lang('meta_files_book_folder_title', $folder->getTitle()), - '@description' => lang('meta_files_book_folder_description', $folder->getTitle()) - ]); - $this->skin->setTitle(lang('files').' - '.$folder->title); - $this->skin->renderPage('files_folder.twig', [ - 'folder' => $folder, - 'bc' => $bc, - 'files' => $files - ]); - } - - public function GET_collection() { - list($collection, $folder_id, $query, $offset) = $this->input('collection, i:folder_id, q, i:offset'); - $collection = FilesCollection::from($collection); - $parents = null; - - $query = trim($query); - if (!$query) - $query = null; - - $this->skin->exportStrings('/^files_(.*?)_collection$/'); - $this->skin->exportStrings([ - 'files_search_results_count' - ]); - - $vars = []; - $text_excerpts = null; - $func_prefix = $collection->value; - - if ($query !== null) { - $files = call_user_func("files::{$func_prefix}_search", $query, $offset, self::SEARCH_RESULTS_PER_PAGE); - $vars += [ - 'search_count' => $files['count'], - 'search_query' => $query - ]; - - /** @var WFFCollectionItem[]|MDFCollectionItem[]|BaconianaCollectionItem[] $files */ - $files = $files['items']; - - $query_words = array_map('mb_strtolower', preg_split('/\s+/', $query)); - $found = []; - $result_ids = []; - foreach ($files as $file) { - if ($file->isFolder()) - continue; - $result_ids[] = $file->id; - - switch ($collection) { - case FilesCollection::MercureDeFrance: - $candidates = [ - $file->date, - (string)$file->issue - ]; - break; - case FilesCollection::WilliamFriedman: - $candidates = [ - mb_strtolower($file->getTitle()), - strtolower($file->documentId) - ]; - break; - case FilesCollection::Baconiana: - $candidates = [ - // TODO - ]; - break; - } - - foreach ($candidates as $haystack) { - foreach ($query_words as $qw) { - if (mb_strpos($haystack, $qw) !== false) { - $found[$file->id] = true; - continue 2; - } - } - } - } - - $found = array_map('intval', array_keys($found)); - $not_found = array_diff($result_ids, $found); - if (!empty($not_found)) - $text_excerpts = call_user_func("files::{$func_prefix}_get_text_excerpts", $not_found, $query_words); - - if (self::isXhrRequest()) { - self::ajaxOk([ - ...$vars, - 'new_offset' => $offset + count($files), - 'html' => skin::getInstance()->render('files_list.twig', [ - 'files' => $files, - 'query' => $query, - 'text_excerpts' => $text_excerpts - ]) - ]); - } - } else { - if (in_array($collection, [FilesCollection::WilliamFriedman, FilesCollection::Baconiana]) && $folder_id) { - $parents = call_user_func("files::{$func_prefix}_get_folder", $folder_id, true); - if (!$parents) - self::notFound(); - if (count($parents) > 1) - $parents = array_reverse($parents); - } - $files = call_user_func("files::{$func_prefix}_get", $folder_id); - } - - $title = lang('files_'.$collection->value.'_collection'); - if ($folder_id && $parents) - $title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle()); - if ($query) - $title .= ' - '.htmlescape($query); - $this->skin->setTitle($title); - - if (!$folder_id && !$query) { - $this->skin->addMeta([ - '@title' => lang('4in1').' - '.lang('meta_files_collection_title', lang('files_'.$collection->value.'_collection')), - '@description' => lang('meta_files_'.$collection->value.'_description') - ]); - } else if ($query || $parents) { - $this->skin->addMeta([ - '@title' => lang('4in1').' - '.$title, - '@description' => lang('meta_files_'.($query ? 'search' : 'folder').'_description', - $query ?: $parents[count($parents)-1]->getTitle(), - lang('files_'.$collection->value.'_collection')) - ]); - } - - $bc = [ - ['text' => lang('files'), 'url' => '/files/'], - ]; - if ($parents) { - $bc[] = ['text' => lang('files_'.$collection->value.'_collection_short'), 'url' => "/files/{$collection->value}/"]; - for ($i = 0; $i < count($parents); $i++) { - $parent = $parents[$i]; - $bc_item = ['text' => $parent->getTitle()]; - if ($i < count($parents)-1) - $bc_item['url'] = $parent->getUrl(); - $bc[] = $bc_item; - } - } else { - $bc[] = ['text' => lang('files_'.$collection->value.'_collection')]; - } - - $js_params = [ - 'container' => 'files_list', - 'per_page' => self::SEARCH_RESULTS_PER_PAGE, - 'min_query_length' => self::SEARCH_MIN_QUERY_LENGTH, - 'base_url' => "/files/{$collection->value}/", - 'query' => $vars['search_query'], - 'count' => $vars['search_count'], - 'collection_name' => $collection->value, - 'inited_with_search' => !!($vars['search_query'] ?? "") - ]; - - $this->skin->set($vars); - $this->skin->set([ - 'collection' => $collection->value, - 'files' => $files, - 'bc' => $bc, - 'do_show_search' => empty($parents), - 'do_show_more' => $vars['search_count'] > 0 && count($files) < $vars['search_count'], - // 'search_results_per_page' => self::SEARCH_RESULTS_PER_PAGE, - // 'search_min_query_length' => self::SEARCH_MIN_QUERY_LENGTH, - 'text_excerpts' => $text_excerpts, - 'js_params' => $js_params, - ]); - $this->skin->renderPage('files_collection.twig'); - } - -} \ No newline at end of file diff --git a/handlers/ServicesHandler.php b/handlers/ServicesHandler.php deleted file mode 100644 index 57b7c1f..0000000 --- a/handlers/ServicesHandler.php +++ /dev/null @@ -1,25 +0,0 @@ -input('lang'); - if (!isset($config['book_versions'][$lang])) - self::notFound(); - self::redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}", - code: HTTPCode::Found); - } - -} \ No newline at end of file diff --git a/htdocs/img/eagle.jpg b/htdocs/img/eagle.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2da4444bf2f8aa6b9bdaee1b90f849497a5220f4 GIT binary patch literal 17996 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 literal 0 HcmV?d00001 diff --git a/htdocs/img/simurgh.jpg b/htdocs/img/simurgh.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0ac1bec35bf51539e9e8fd2a25fbc4cdc236d9a3 GIT binary patch literal 14597 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 !== '') - return $this->title; - - return ($this->jobc ? lang('baconiana_old_name') : lang('baconiana')).' №'.$this->issues; - } - - public function isTargetBlank(): bool { return $this->isFile(); } - public function getId(): string { return $this->id; } - - public function getUrl(): string { - if ($this->isFolder()) { - return '/files/'.FilesCollection::Baconiana->value.'/'.$this->id.'/'; - } - global $config; - return 'https://'.$config['files_domain'].'/'.$this->path; - } - - public function getMeta(?string $hl_matched = null): array { - $items = []; - if ($this->isFolder()) - return $items; - - if ($this->year >= 2007) - $items = array_merge($items, ['Online Edition']); - - $items = array_merge($items, [ - sizeString($this->size), - 'PDF' - ]); - - return [ - 'inline' => false, - 'items' => $items - ]; - } - - public function getSubtitle(): ?string { - return $this->year > 0 ? '('.$this->year.')' : null; - } -} diff --git a/lib/BookCategory.php b/lib/BookCategory.php deleted file mode 100644 index 7fc1915..0000000 --- a/lib/BookCategory.php +++ /dev/null @@ -1,6 +0,0 @@ -id; - } - - public function getUrl(): string { - if ($this->isFolder() && !$this->external) - return '/files/'.$this->id.'/'; - global $config; - $buf = 'https://'.$config['files_domain']; - if (!str_starts_with($this->path, '/')) - $buf .= '/'; - $buf .= $this->path; - return $buf; - } - - public function getTitleHtml(): ?string { - if ($this->isFolder() || !$this->author) - return null; - $buf = ''.htmlescape($this->author).''; - if (!str_ends_with($this->author, '.')) - $buf .= '.'; - $buf .= ' '.htmlescape($this->title).''; - return $buf; - } - - public function getTitle(): string { - return $this->title; - } - - public function getMeta(?string $hl_matched = null): array { - if ($this->isFolder()) - return []; - - $items = [ - sizeString($this->size), - strtoupper($this->getExtension()) - ]; - - return [ - 'inline' => false, - 'items' => $items - ]; - } - - protected function getExtension(): string { - return extension(basename($this->path)); - } - - public function isAvailable(): bool { - return true; - } - - public function isTargetBlank(): bool { - return $this->isFile() || $this->external; - } - - public function getSubtitle(): ?string { - if (!$this->year && !$this->subtitle) - return null; - $buf = '('; - $buf .= $this->subtitle ?: $this->year; - $buf .= ')'; - return $buf; - } -} diff --git a/lib/CollectionItem.php b/lib/CollectionItem.php deleted file mode 100644 index fa47bf2..0000000 --- a/lib/CollectionItem.php +++ /dev/null @@ -1,21 +0,0 @@ -collection->value; } - public function isFolder(): bool { return true; } - public function isFile(): bool { return false; } - public function isAvailable(): bool { return true; } - public function getUrl(): string { - return '/files/'.$this->collection->value.'/'; - } - public function getSize(): ?int { return null; } - public function getTitle(): string { return lang("files_{$this->collection->value}_collection"); } - public function getMeta(?string $hl_matched = null): array { return []; } - public function isTargetBlank(): bool { return false; } - public function getSubtitle(): ?string { return null; } -} \ No newline at end of file diff --git a/lib/FilesCollection.php b/lib/FilesCollection.php deleted file mode 100644 index 2532902..0000000 --- a/lib/FilesCollection.php +++ /dev/null @@ -1,7 +0,0 @@ -isFile() ? $this->size : null; } -} \ No newline at end of file diff --git a/lib/FilesItemType.php b/lib/FilesItemType.php deleted file mode 100644 index 90357e6..0000000 --- a/lib/FilesItemType.php +++ /dev/null @@ -1,6 +0,0 @@ -type == FilesItemType::FOLDER; - } - - public function isFile(): bool { - return $this->type == FilesItemType::FILE; - } - - public function isBook(): bool { - return $this instanceof BookItem && $this->fileType == BookFileType::BOOK; - } - -} \ No newline at end of file diff --git a/lib/Page.php b/lib/Page.php deleted file mode 100644 index 6d5067e..0000000 --- a/lib/Page.php +++ /dev/null @@ -1,47 +0,0 @@ -md || $fields['render_title'] != $this->renderTitle || $fields['title'] != $this->title) { - $md = $fields['md']; - if ($fields['render_title']) - $md = '# '.$fields['title']."\n\n".$md; - $fields['html'] = markup::markdownToHtml($md); - } - parent::edit($fields); - } - - public function isUpdated(): bool { - return $this->updateTs && $this->updateTs != $this->ts; - } - - public function getHtml(bool $is_retina, string $user_theme): string { - return markup::htmlImagesFix($this->html, $is_retina, $user_theme); - } - - public function getUrl(): string { - return "/{$this->shortName}/"; - } - - public function updateHtml(): void { - $html = markup::markdownToHtml($this->md); - $this->html = $html; - DB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName); - } - -} diff --git a/lib/Post.php b/lib/Post.php deleted file mode 100644 index 5304ec8..0000000 --- a/lib/Post.php +++ /dev/null @@ -1,129 +0,0 @@ - $title, - 'lang' => $lang->value, - 'post_id' => $this->id, - 'html' => $html, - 'text' => $text, - 'md' => $md, - 'toc' => $toc, - 'keywords' => $keywords, - ]; - - $db = DB(); - if (!$db->insert('posts_texts', $data)) - return null; - - $id = $db->insertId(); - - $post_text = posts::getText($id); - $post_text->updateImagePreviews(); - - return $post_text; - } - - public function registerText(PostText $postText): void { - if (array_key_exists($postText->lang->value, $this->texts)) - throw new Exception("text for language {$postText->lang->value} has already been registered"); - $this->texts[$postText->lang->value] = $postText; - } - - public function loadTexts() { - if (!empty($this->texts)) - return; - $db = DB(); - $q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id); - while ($row = $db->fetch($q)) { - $text = new PostText($row); - $this->registerText($text); - } - } - - /** - * @return PostText[] - */ - public function getTexts(): array { - $this->loadTexts(); - return $this->texts; - } - - public function getText(PostLanguage|string $lang): ?PostText { - if (is_string($lang)) - $lang = PostLanguage::from($lang); - $this->loadTexts(); - return $this->texts[$lang->value] ?? null; - } - - public function hasLang(PostLanguage $lang) { - $this->loadTexts(); - foreach ($this->texts as $text) { - if ($text->lang == $lang) - return true; - } - return false; - } - - public function hasSourceUrl(): bool { - return $this->sourceUrl != ''; - } - - public function getUrl(PostLanguage|string|null $lang = null): string { - $buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/"; - if ($lang) { - if (is_string($lang)) - $lang = PostLanguage::from($lang); - if ($lang != PostLanguage::English) - $buf .= '?lang=' . $lang->value; - } - return $buf; - } - - public function getTimestamp(): int { - return (new DateTime($this->date))->getTimestamp(); - } - - public function getUpdateTimestamp(): ?int { - if (!$this->updateTime) - return null; - return (new DateTime($this->updateTime))->getTimestamp(); - } - - public function getDate(): string { - return date('j M', $this->getTimestamp()); - } - - public function getYear(): int { - return (int)date('Y', $this->getTimestamp()); - } - - public function getFullDate(): string { - return date('j F Y', $this->getTimestamp()); - } - - public function getDateForInputField(): string { - return date('Y-m-d', $this->getTimestamp()); - } -} \ No newline at end of file diff --git a/lib/PreviousText.php b/lib/PreviousText.php deleted file mode 100644 index 1bb47aa..0000000 --- a/lib/PreviousText.php +++ /dev/null @@ -1,16 +0,0 @@ -randomId; - } - - public function getDirectUrl(): string { - global $config; - return $config['uploads_path'].'/'.$this->randomId.'/'.$this->name; - } - - public function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string { - global $config; - if ($w == $this->imageW && $this->imageH == $h) - return $this->getDirectUrl(); - - if ($retina) { - $w *= 2; - $h *= 2; - } - - $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p'; - return $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg'; - } - - // TODO remove? - public function incrementDownloads() { - $db = DB(); - $db->query("UPDATE uploads SET downloads=downloads+1 WHERE id=?", $this->id); - $this->downloads++; - } - - public function getSize(): string { - return sizeString($this->size); - } - - public function getMarkdown(?string $options = null): string { - if ($this->isImage()) { - $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}'; - } else if ($this->isVideo()) { - $md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}'; - } else { - $md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}'; - } - $md .= ' '; - return $md; - } - - public function setNote(PostLanguage $lang, string $note) { - $db = DB(); - $db->query("UPDATE uploads SET note_{$lang->value}=? WHERE id=?", $note, $this->id); - } - - public function isImage(): bool { - return in_array(extension($this->name), self::$ImageExtensions); - } - - // assume all png images have alpha channel - // i know this is wrong, but anyway - public function imageMayHaveAlphaChannel(): bool { - return strtolower(extension($this->name)) == 'png'; - } - - public function isVideo(): bool { - return in_array(extension($this->name), self::$VideoExtensions); - } - - public function getImageRatio(): float { - return $this->imageW / $this->imageH; - } - - public function getImagePreviewSize(?int $w = null, ?int $h = null): array { - if (is_null($w) && is_null($h)) - throw new Exception(__METHOD__.': both width and height can\'t be null'); - - if (is_null($h)) - $h = round($w / $this->getImageRatio()); - - if (is_null($w)) - $w = round($h * $this->getImageRatio()); - - return [$w, $h]; - } - - public function createImagePreview(?int $w = null, - ?int $h = null, - bool $force_update = false, - bool $may_have_alpha = false): bool { - global $config; - - $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name; - $updated = false; - - foreach (themes::getThemes() as $theme) { - if (!$may_have_alpha && $theme == 'dark') - continue; - - for ($mult = 1; $mult <= 2; $mult++) { - $dw = $w * $mult; - $dh = $h * $mult; - - $prefix = $may_have_alpha ? 'a' : 'p'; - $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg'; - - if (file_exists($dst)) { - if (!$force_update) - continue; - unlink($dst); - } - - $img = imageopen($orig); - imageresize($img, $dw, $dh, themes::getThemeAlphaColorAsRGB($theme)); - imagejpeg($img, $dst, $mult == 1 ? 93 : 67); - imagedestroy($img); - - setperm($dst); - $updated = true; - } - } - - return $updated; - } - - /** - * @return int Number of deleted files - */ - public function deleteAllImagePreviews(): int { - global $config; - $dir = $config['uploads_dir'].'/'.$this->randomId; - $files = scandir($dir); - $deleted = 0; - foreach ($files as $f) { - if (preg_match('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) { - if (is_file($dir.'/'.$f)) - unlink($dir.'/'.$f); - else - logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!'); - $deleted++; - } - } - return $deleted; - } - - public function getJSONEncodedHtmlSafeNote(string $lang): string { - $value = $lang == 'en' ? $this->noteEn : $this->noteRu; - return jsonEncode(preg_replace('/(\r)?\n/', '\n', addslashes($value))); - } - -} \ No newline at end of file diff --git a/lib/WFFCollectionItem.php b/lib/WFFCollectionItem.php deleted file mode 100644 index 6a14f71..0000000 --- a/lib/WFFCollectionItem.php +++ /dev/null @@ -1,53 +0,0 @@ -id; } - public function isAvailable(): bool { return true; } - public function getTitle(): string { return $this->title; } - public function getDocumentId(): string { return $this->isFolder() ? str_replace('_', ' ', basename($this->path)) : $this->documentId; } - public function isTargetBlank(): bool { return $this->isFile(); } - public function getSubtitle(): ?string { return null; } - - public function getUrl(): string { - global $config; - return $this->isFolder() - ? "/files/wff/{$this->id}/" - : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}"; - } - - public function getMeta(?string $hl_matched = null): array { - if ($this->isFolder()) { - if (!$this->parentId) - return []; - return [ - 'items' => [ - highlightSubstring($this->getDocumentId(), $hl_matched), - langNum('files_count', $this->filesCount) - ] - ]; - } - return [ - 'inline' => false, - 'items' => [ - highlightSubstring('Document '.$this->documentId), - sizeString($this->size), - 'PDF' - ] - ]; - } - -} diff --git a/lib/files.php b/lib/files.php deleted file mode 100644 index eb477c7..0000000 --- a/lib/files.php +++ /dev/null @@ -1,390 +0,0 @@ -escape($keyword)."', text)"; - $dynamic_sql_parts[] = $part; - } - if (count($dynamic_sql_parts) > 1) { - foreach ($dynamic_sql_parts as $part) - $combined_parts[] = "IF({$part} > 0, {$part}, CHAR_LENGTH(text) + 1)"; - $combined_parts = implode(', ', $combined_parts); - $combined_parts = 'LEAST('.$combined_parts.')'; - } else { - $combined_parts = "IF({$dynamic_sql_parts[0]} > 0, {$dynamic_sql_parts[0]}, CHAR_LENGTH(text) + 1)"; - } - - $total = $before + $after; - $sql = "SELECT - {$field_id} AS id, - GREATEST( - 1, - {$combined_parts} - {$before} - ) AS excerpt_start_index, - SUBSTRING( - text, - GREATEST( - 1, - {$combined_parts} - {$before} - ), - LEAST( - CHAR_LENGTH(text), - {$total} + {$combined_parts} - GREATEST(1, {$combined_parts} - {$before}) - ) - ) AS excerpt - FROM - {$table} - WHERE - {$field_id} IN (".implode(',', $ids).")"; - - $q = $db->query($sql); - while ($row = $db->fetch($q)) { - $results[$row['id']] = [ - 'excerpt' => preg_replace('/\s+/', ' ', $row['excerpt']), - 'index' => (int)$row['excerpt_start_index'] - ]; - } - - return $results; - } - - public static function _search(string $index, - string $q, - int $offset, - int $count, - callable $items_getter, - ?callable $sphinx_client_setup = null): array { - $query_filtered = sphinx::mkquery($q); - - $cl = sphinx::getClient(); - $cl->setLimits($offset, $count); - - $cl->setMatchMode(Sphinx\SphinxClient::SPH_MATCH_EXTENDED); - - if (is_callable($sphinx_client_setup)) - $sphinx_client_setup($cl); - else { - $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); - $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE); - } - - // run search - $final_query = "$query_filtered"; - $result = $cl->query($final_query, $index); - $error = $cl->getLastError(); - $warning = $cl->getLastWarning(); - if ($error) - logError(__FUNCTION__, $error); - if ($warning) - logWarning(__FUNCTION__, $warning); - if ($result === false) - return ['count' => 0, 'items' => []]; - - $total_found = (int)$result['total_found']; - - $items = []; - if (!empty($result['matches'])) - $items = $items_getter($result['matches']); - - return ['count' => $total_found, 'items' => $items]; - } - - - /** - * @param int $folder_id - * @param bool $with_parents - * @return WFFCollectionItem|WFFCollectionItem[]|null - */ - public static function wff_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null { - $db = DB(); - $q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id); - if (!$db->numRows($q)) - return null; - $item = new WFFCollectionItem($db->fetch($q)); - if (!$item->isFolder()) - return null; - if ($with_parents) { - $items = [$item]; - if ($item->parentId) { - $parents = self::wff_get_folder($item->parentId, true); - if ($parents !== null) - $items = array_merge($items, $parents); - } - return $items; - } - return $item; - } - - /** - * @param int|int[]|null $parent_id - * @return array - */ - public static function wff_get(int|array|null $parent_id = null) { - $db = DB(); - - $where = []; - $args = []; - - if (!is_null($parent_id)) { - if (is_int($parent_id)) { - $where[] = "parent_id=?"; - $args[] = $parent_id; - } else { - $where[] = "parent_id IN (".implode(", ", $parent_id).")"; - } - } - $sql = "SELECT * FROM wff_collection"; - if (!empty($where)) - $sql .= " WHERE ".implode(" AND ", $where); - $sql .= " ORDER BY title"; - $q = $db->query($sql, ...$args); - - return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q)); - } - - /** - * @param int[] $ids - * @return WFFCollectionItem[] - */ - public static function wff_get_by_id(array $ids): array { - $db = DB(); - $q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")"); - return array_map('WFFCollectionItem::create_instance', $db->fetchAll($q)); - } - - public static function wff_search(string $q, int $offset = 0, int $count = 0): array { - return self::_search(self::WFF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, - items_getter: function($matches) { - return self::wff_get_by_id(array_keys($matches)); - }, - sphinx_client_setup: function(SphinxClient $cl) { - $cl->setFieldWeights([ - 'title' => 50, - 'document_id' => 60, - ]); - $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); - $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC'); - } - ); - } - - public static function wff_reindex(): void { - sphinx::execute("TRUNCATE RTINDEX ".self::WFF_ARCHIVE_SPHINX_RTINDEX); - $db = DB(); - $q = $db->query("SELECT * FROM wff_collection"); - while ($row = $db->fetch($q)) { - $item = new WFFCollectionItem($row); - $text = ''; - if ($item->isFile()) { - $text_q = $db->query("SELECT text FROM wff_texts WHERE wff_id=?", $item->id); - if ($db->numRows($text_q)) - $text = $db->result($text_q); - } - sphinx::execute("INSERT INTO ".self::WFF_ARCHIVE_SPHINX_RTINDEX." (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)", - $item->id, $item->getDocumentId(), $item->title, $text, (int)$item->isFolder(), $item->parentId); - } - } - - public static function wff_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { - return self::_get_text_excerpts('wff_texts', 'wff_id', $ids, $keywords, $before, $after); - } - - /** - * @return MDFCollectionItem[] - */ - public static function mdf_get(): array { - $db = DB(); - $q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`"); - return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q)); - } - - /** - * @param int[] $ids - * @return MDFCollectionItem[] - */ - public static function mdf_get_by_id(array $ids): array { - $db = DB(); - $q = $db->query("SELECT * FROM mdf_collection WHERE id IN (".implode(',', $ids).")"); - return array_map('MDFCollectionItem::create_instance', $db->fetchAll($q)); - } - - public static function mdf_search(string $q, int $offset = 0, int $count = 0): array { - return self::_search(self::MDF_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, - items_getter: function($matches) { - return self::mdf_get_by_id(array_keys($matches)); - }, - sphinx_client_setup: function(SphinxClient $cl) { - $cl->setFieldWeights([ - 'date' => 10, - 'issue' => 9, - 'text' => 8 - ]); - $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); - $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE); - } - ); - } - - public static function mdf_reindex(): void { - sphinx::execute("TRUNCATE RTINDEX ".self::MDF_ARCHIVE_SPHINX_RTINDEX); - $db = DB(); - $mdf = self::mdf_get(); - foreach ($mdf as $item) { - $text = $db->result($db->query("SELECT text FROM mdf_texts WHERE mdf_id=?", $item->id)); - sphinx::execute("INSERT INTO ".self::MDF_ARCHIVE_SPHINX_RTINDEX." (id, volume, issue, date, text) VALUES (?, ?, ?, ?, ?)", - $item->id, $item->volume, (string)$item->issue, $item->getHumanFriendlyDate(), $text); - } - } - - public static function mdf_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { - return self::_get_text_excerpts('mdf_texts', 'mdf_id', $ids, $keywords, $before, $after); - } - - - /** - * @return BaconianaCollectionItem[] - */ - public static function baconiana_get(?int $parent_id = 0): array { - $db = DB(); - $sql = "SELECT * FROM baconiana_collection"; - if ($parent_id !== null) - $sql .= " WHERE parent_id='".$db->escape($parent_id)."'"; - $sql .= " ORDER BY type, year, id"; - $q = $db->query($sql); - return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q)); - } - - /** - * @param int[] $ids - * @return BaconianaCollectionItem[] - */ - public static function baconiana_get_by_id(array $ids): array { - $db = DB(); - $q = $db->query("SELECT * FROM baconiana_collection WHERE id IN (".implode(',', $ids).")"); - return array_map('BaconianaCollectionItem::create_instance', $db->fetchAll($q)); - } - - /** - * @param int $folder_id - * @param bool $with_parents - * @return BaconianaCollectionItem|BaconianaCollectionItem[]|null - */ - public static function baconiana_get_folder(int $folder_id, bool $with_parents = false): WFFCollectionItem|array|null { - $db = DB(); - $q = $db->query("SELECT * FROM baconiana_collection WHERE id=?", $folder_id); - if (!$db->numRows($q)) - return null; - $item = new BaconianaCollectionItem($db->fetch($q)); - if (!$item->isFolder()) - return null; - if ($with_parents) { - $items = [$item]; - if ($item->parentId) { - $parents = self::baconiana_get_folder($item->parentId, true); - if ($parents !== null) - $items = array_merge($items, $parents); - } - return $items; - } - return $item; - } - - public static function baconiana_search(string $q, int $offset = 0, int $count = 0): array { - return self::_search(self::BACONIANA_ARCHIVE_SPHINX_RTINDEX, $q, $offset, $count, - items_getter: function($matches) { - return self::baconiana_get_by_id(array_keys($matches)); - }, - sphinx_client_setup: function(SphinxClient $cl) { - $cl->setFieldWeights([ - 'year' => 10, - 'issues' => 9, - 'text' => 8 - ]); - $cl->setRankingMode(Sphinx\SphinxClient::SPH_RANK_PROXIMITY_BM25); - $cl->setSortMode(Sphinx\SphinxClient::SPH_SORT_RELEVANCE); - } - ); - } - - public static function baconiana_reindex(): void { - sphinx::execute("TRUNCATE RTINDEX ".self::BACONIANA_ARCHIVE_SPHINX_RTINDEX); - $db = DB(); - $baconiana = self::baconiana_get(null); - foreach ($baconiana as $item) { - $text_q = $db->query("SELECT text FROM baconiana_texts WHERE bcn_id=?", $item->id); - if (!$db->numRows($text_q)) - continue; - $text = $db->result($text_q); - sphinx::execute("INSERT INTO ".self::BACONIANA_ARCHIVE_SPHINX_RTINDEX." (id, title, year, text) VALUES (?, ?, ?, ?)", - $item->id, "$item->year ($item->issues)", $item->year, $text); - } - } - - public static function baconiana_get_text_excerpts(array $ids, array $keywords, int $before = 50, int $after = 40): array { - return self::_get_text_excerpts('baconiana_texts', 'bcn_id', $ids, $keywords, $before, $after); - } - - - /** - * @return BookItem[] - */ - public static function books_get(int $parent_id = 0, BookCategory $category = BookCategory::BOOKS): array { - $db = DB(); - - if ($category == BookCategory::BOOKS) { - $order_by = "type, ".($parent_id != 0 ? 'year, ': '')."author, title"; - } - else - $order_by = "type, title"; - - $q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by", - $category->value, $parent_id); - return array_map('BookItem::create_instance', $db->fetchAll($q)); - } - - public static function books_get_folder(int $id, bool $with_parents = false): BookItem|array|null { - $db = DB(); - $q = $db->query("SELECT * FROM books WHERE id=?", $id); - if (!$db->numRows($q)) - return null; - $item = new BookItem($db->fetch($q)); - if (!$item->isFolder()) - return null; - if ($with_parents) { - $items = [$item]; - if ($item->parentId) { - $parents = self::books_get_folder($item->parentId, true); - if ($parents !== null) - $items = array_merge($items, $parents); - } - return $items; - } - return $item; - } - -} diff --git a/lib/pages.php b/lib/pages.php deleted file mode 100644 index 1ea6be1..0000000 --- a/lib/pages.php +++ /dev/null @@ -1,39 +0,0 @@ -insert('pages', $data)) - return false; - return true; - } - - public static function delete(Page $page): void { - DB()->query("DELETE FROM pages WHERE short_name=?", $page->shortName); - previous_texts::delete(PreviousText::TYPE_PAGE, $page->get_id()); - } - - public static function getById(int $id): ?Page { - $db = DB(); - $q = $db->query("SELECT * FROM pages WHERE id=?", $id); - return $db->numRows($q) ? new Page($db->fetch($q)) : null; - } - - public static function getByName(string $short_name): ?Page { - $db = DB(); - $q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name); - return $db->numRows($q) ? new Page($db->fetch($q)) : null; - } - - /** - * @return Page[] - */ - public static function getAll(): array { - $db = DB(); - return array_map('Page::create_instance', $db->fetchAll($db->query("SELECT * FROM pages"))); - } - -} \ No newline at end of file diff --git a/lib/posts.php b/lib/posts.php deleted file mode 100644 index f95f865..0000000 --- a/lib/posts.php +++ /dev/null @@ -1,153 +0,0 @@ -result($db->query($sql)); - } - - /** - * @return Post[] - */ - public static function getList(int $offset = 0, - int $count = -1, - bool $include_hidden = false, - ?PostLanguage $filter_by_lang = null - ): array { - $db = DB(); - $sql = "SELECT * FROM posts"; - if (!$include_hidden) - $sql .= " WHERE visible=1"; - $sql .= " ORDER BY `date` DESC"; - if ($offset != 0 || $count != -1) - $sql .= " LIMIT $offset, $count"; - $q = $db->query($sql); - $posts = []; - while ($row = $db->fetch($q)) { - $posts[$row['id']] = $row; - } - - if (!empty($posts)) { - foreach ($posts as &$post) - $post = new Post($post); - $q = $db->query("SELECT * FROM posts_texts WHERE post_id IN (".implode(',', array_keys($posts)).")"); - while ($row = $db->fetch($q)) { - $posts[$row['post_id']]->registerText(new PostText($row)); - } - } - - if ($filter_by_lang !== null) - $posts = array_filter($posts, fn(Post $post) => $post->hasLang($filter_by_lang)); - - return array_values($posts); - } - - public static function add(array $data = []): ?Post { - $db = DB(); - if (!$db->insert('posts', $data)) - return null; - return self::get($db->insertId()); - } - - public static function delete(Post $post): void { - $db = DB(); - $db->query("DELETE FROM posts WHERE id=?", $post->id); - - $text_ids = []; - $q = $db->query("SELECT id FROM posts_texts WHERE post_id=?", $post->id); - while ($row = $db->fetch($q)) - $text_ids = $row['id']; - previous_texts::delete(PreviousText::TYPE_POST_TEXT, $text_ids); - - $db->query("DELETE FROM posts_texts WHERE post_id=?", $post->id); - } - - public static function get(int $id): ?Post { - $db = DB(); - $q = $db->query("SELECT * FROM posts WHERE id=?", $id); - return $db->numRows($q) ? new Post($db->fetch($q)) : null; - } - - public static function getText(int $text_id): ?PostText { - $db = DB(); - $q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id); - return $db->numRows($q) ? new PostText($db->fetch($q)) : null; - } - - public static function getByName(string $short_name): ?Post { - $db = DB(); - $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name); - return $db->numRows($q) ? new Post($db->fetch($q)) : null; - } - - public static function getPostsById(array $ids, bool $flat = false): array { - if (empty($ids)) { - return []; - } - - $db = DB(); - $posts = array_fill_keys($ids, null); - - $q = $db->query("SELECT * FROM posts WHERE id IN(".implode(',', $ids).")"); - - while ($row = $db->fetch($q)) { - $posts[(int)$row['id']] = new Post($row); - } - - if ($flat) { - $list = []; - foreach ($ids as $id) { - $list[] = $posts[$id]; - } - unset($posts); - return $list; - } - - return $posts; - } - - public static function getPostTextsById(array $ids, bool $flat = false): array { - if (empty($ids)) { - return []; - } - - $db = DB(); - $posts = array_fill_keys($ids, null); - - $q = $db->query("SELECT * FROM posts_texts WHERE id IN(".implode(',', $ids).")"); - - while ($row = $db->fetch($q)) { - $posts[(int)$row['id']] = new PostText($row); - } - - if ($flat) { - $list = []; - foreach ($ids as $id) { - $list[] = $posts[$id]; - } - unset($posts); - return $list; - } - - return $posts; - } - - /** - * @param Upload $upload - * @return PostText[] Array of PostTexts that includes specified upload - */ - public static function getTextsWithUpload(Upload $upload): array { - $db = DB(); - $q = $db->query("SELECT id FROM posts_texts WHERE md LIKE '%{image:{$upload->randomId}%'"); - $ids = []; - while ($row = $db->fetch($q)) - $ids[] = (int)$row['id']; - return self::getPostTextsById($ids, true); - } - -} \ No newline at end of file diff --git a/lib/uploads.php b/lib/uploads.php deleted file mode 100644 index 01fc0a0..0000000 --- a/lib/uploads.php +++ /dev/null @@ -1,161 +0,0 @@ -result($db->query("SELECT COUNT(*) FROM uploads")); - } - - public static function isExtensionAllowed(string $ext): bool { - return in_array($ext, self::ALLOWED_EXTENSIONS); - } - - public static function add(string $tmp_name, - string $name, - string $note_en = '', - string $note_ru = '', - string $source_url = ''): ?int { - global $config; - - $name = sanitizeFilename($name); - if (!$name) - $name = 'file'; - - $random_id = self::_getNewUploadRandomId(); - $size = filesize($tmp_name); - $is_image = detectImageType($tmp_name) !== false; - $image_w = 0; - $image_h = 0; - if ($is_image) { - list($image_w, $image_h) = getimagesize($tmp_name); - } - - $db = DB(); - if (!$db->insert('uploads', [ - 'random_id' => $random_id, - 'ts' => time(), - 'name' => $name, - 'size' => $size, - 'image' => (int)$is_image, - 'image_w' => $image_w, - 'image_h' => $image_h, - 'note_ru' => $note_ru, - 'note_en' => $note_en, - 'downloads' => 0, - 'source_url' => $source_url, - ])) { - return null; - } - - $id = $db->insertId(); - - $dir = $config['uploads_dir'].'/'.$random_id; - $path = $dir.'/'.$name; - - mkdir($dir); - chmod($dir, 0775); // g+w - - rename($tmp_name, $path); - setperm($path); - - return $id; - } - - public static function delete(int $id): bool { - $upload = self::get($id); - if (!$upload) - return false; - - $db = DB(); - $db->query("DELETE FROM uploads WHERE id=?", $id); - - rrmdir($upload->getDirectory()); - return true; - } - - /** - * @return Upload[] - */ - public static function getAllUploads(): array { - $db = DB(); - $q = $db->query("SELECT * FROM uploads ORDER BY id DESC"); - return array_map('Upload::create_instance', $db->fetchAll($q)); - } - - public static function get(int $id): ?Upload { - $db = DB(); - $q = $db->query("SELECT * FROM uploads WHERE id=?", $id); - if ($db->numRows($q)) { - return new Upload($db->fetch($q)); - } else { - return null; - } - } - - /** - * @param string[] $ids - * @param bool $flat - * @return Upload[] - */ - public static function getUploadsByRandomId(array $ids, bool $flat = false): array { - if (empty($ids)) { - return []; - } - - $db = DB(); - $uploads = array_fill_keys($ids, null); - - $q = $db->query("SELECT * FROM uploads WHERE random_id IN('".implode('\',\'', array_map([$db, 'escape'], $ids))."')"); - - while ($row = $db->fetch($q)) { - $uploads[$row['random_id']] = new Upload($row); - } - - if ($flat) { - $list = []; - foreach ($ids as $id) { - $list[] = $uploads[$id]; - } - unset($uploads); - return $list; - } - - return $uploads; - } - - public static function getUploadByRandomId(string $random_id): ?Upload { - $db = DB(); - $q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id); - if ($db->numRows($q)) { - return new Upload($db->fetch($q)); - } else { - return null; - } - } - - public static function getUploadBySourceUrl(string $source_url): ?Upload { - $db = DB(); - $q = $db->query("SELECT * FROM uploads WHERE source_url=? LIMIT 1", $source_url); - if ($db->numRows($q)) { - return new Upload($db->fetch($q)); - } else { - return null; - } - } - - public static function _getNewUploadRandomId(): string { - $db = DB(); - do { - $random_id = strgen(8); - } while ($db->numRows($db->query("SELECT id FROM uploads WHERE random_id=?", $random_id)) > 0); - return $random_id; - } - -} diff --git a/skin/error.twig b/skin/error.twig deleted file mode 100644 index 934f165..0000000 --- a/skin/error.twig +++ /dev/null @@ -1,9 +0,0 @@ - -{{ code }} {{ title }} - -

{{ code }} {{ title }}

-{% if message %} -

{{ message }}

-{% endif %} - - \ No newline at end of file diff --git a/src/engine/GlobalContext.php b/src/engine/GlobalContext.php new file mode 100644 index 0000000..f1e2535 --- /dev/null +++ b/src/engine/GlobalContext.php @@ -0,0 +1,56 @@ +requestHandler->skin; + } + + public function getStrings(): lang\Strings { + return $this->requestHandler->skin->strings; + } + + public function setProject(string $project) { + $this->setProperty('project', $project); + } + + public function setRequestHandler(http\RequestHandler $requestHandler) { + $this->setProperty('requestHandler', $requestHandler); + } + + public function setLogger(logging\Logger $logger) { + $this->setProperty('logger', $logger); + } + + public function setIsDevelopmentEnvironment(bool $isDevelopmentEnvironment) { + $this->setProperty('isDevelopmentEnvironment', $isDevelopmentEnvironment); + } + + private function setProperty(string $name, mixed $value) { + if (isset($this->setProperties[$name])) + throw new \RuntimeException("$name can only be set once"); + $this->$name = $value; + $this->setProperties[$name] = true; + } +} \ No newline at end of file diff --git a/engine/model.php b/src/engine/Model.php similarity index 72% rename from engine/model.php rename to src/engine/Model.php index ffcdc5a..860f608 100644 --- a/engine/model.php +++ b/src/engine/Model.php @@ -1,19 +1,12 @@ properties; - } - - public function getDbNameMap(): array { - return $this->dbNameMap; - } - - public function getPropNames(): array { - return array_keys($this->dbNameMap); - } - -} - -class ModelProperty { - - public function __construct( - protected ?ModelFieldType $type, - protected mixed $realType, - protected bool $nullable, - protected string $modelName, - protected string $dbName - ) {} - - public function getDbName(): string { - return $this->dbName; - } - - public function getModelName(): string { - return $this->modelName; - } - - public function isNullable(): bool { - return $this->nullable; - } - - public function getType(): ?ModelFieldType { - return $this->type; - } - - public function fromRawValue(mixed $value): mixed { - if ($this->nullable && is_null($value)) - return null; - - switch ($this->type) { - case ModelFieldType::BOOLEAN: - return (bool)$value; - - case ModelFieldType::INTEGER: - return (int)$value; - - case ModelFieldType::FLOAT: - return (float)$value; - - case ModelFieldType::ARRAY: - return array_filter(explode(',', $value)); - - case ModelFieldType::JSON: - $val = jsonDecode($value); - if (!$val) - $val = null; - return $val; - - case ModelFieldType::SERIALIZED: - $val = unserialize($value); - if ($val === false) - $val = null; - return $val; - - case ModelFieldType::BITFIELD: - return new mysql_bitfield($value); - - case ModelFieldType::BACKED_ENUM: - try { - return $this->realType::from($value); - } catch (ValueError $e) { - if ($this->nullable) - return null; - throw $e; - } - - default: - return (string)$value; - } - } - -} \ No newline at end of file diff --git a/src/engine/ModelFieldType.php b/src/engine/ModelFieldType.php new file mode 100644 index 0000000..a46770a --- /dev/null +++ b/src/engine/ModelFieldType.php @@ -0,0 +1,16 @@ +dbName; + } + + public function getModelName(): string { + return $this->modelName; + } + + public function isNullable(): bool { + return $this->nullable; + } + + public function getType(): ?ModelFieldType { + return $this->type; + } + + public function fromRawValue(mixed $value): mixed { + if ($this->nullable && is_null($value)) + return null; + + switch ($this->type) { + case ModelFieldType::BOOLEAN: + return (bool)$value; + + case ModelFieldType::INTEGER: + return (int)$value; + + case ModelFieldType::FLOAT: + return (float)$value; + + case ModelFieldType::ARRAY: + return array_filter(explode(',', $value)); + + case ModelFieldType::JSON: + $val = jsonDecode($value); + if (!$val) + $val = null; + return $val; + + case ModelFieldType::SERIALIZED: + $val = unserialize($value); + if ($val === false) + $val = null; + return $val; + + case ModelFieldType::BITFIELD: + return new MySQLBitField($value); + + case ModelFieldType::BACKED_ENUM: + try { + return $this->realType::from($value); + } catch (\ValueError $e) { + if ($this->nullable) + return null; + throw $e; + } + + default: + return (string)$value; + } + } +} \ No newline at end of file diff --git a/src/engine/ModelSpec.php b/src/engine/ModelSpec.php new file mode 100644 index 0000000..7672941 --- /dev/null +++ b/src/engine/ModelSpec.php @@ -0,0 +1,27 @@ +properties; + } + + public function getDbNameMap(): array { + return $this->dbNameMap; + } + + public function getPropNames(): array { + return array_keys($this->dbNameMap); + } +} \ No newline at end of file diff --git a/engine/mysql.php b/src/engine/MySQL.php similarity index 70% rename from engine/mysql.php rename to src/engine/MySQL.php index 510bab5..caa6e62 100644 --- a/engine/mysql.php +++ b/src/engine/MySQL.php @@ -1,7 +1,14 @@ query(...$values); @@ -129,9 +137,9 @@ class mysql { try { $q = $this->link->query($sql); if (!$q) - logError(__METHOD__.': '.$this->link->error."\n$sql\n".backtraceAsString(1)); + logError(__METHOD__.': '.$this->link->error."\n$sql\n".logging\Util::backtraceAsString(1)); } catch (mysqli_sql_exception $e) { - logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".backtraceAsString(1)); + logError(__METHOD__.': '.$e->getMessage()."\n$sql\n".logging\Util::backtraceAsString(1)); } return $q; } @@ -187,89 +195,4 @@ class mysql { public function escape(string $s): string { return $this->link->real_escape_string($s); } - -} - -class mysql_bitfield { - - private GMP $value; - private int $size; - - public function __construct($value, int $size = 64) { - $this->value = gmp_init($value); - $this->size = $size; - } - - public function has(int $bit): bool { - $this->validateBit($bit); - return gmp_testbit($this->value, $bit); - } - - public function set(int $bit): void { - $this->validateBit($bit); - gmp_setbit($this->value, $bit); - } - - public function clear(int $bit): void { - $this->validateBit($bit); - gmp_clrbit($this->value, $bit); - } - - public function isEmpty(): bool { - return !gmp_cmp($this->value, 0); - } - - public function __toString(): string { - $buf = ''; - for ($bit = $this->size-1; $bit >= 0; --$bit) - $buf .= gmp_testbit($this->value, $bit) ? '1' : '0'; - if (($pos = strpos($buf, '1')) !== false) { - $buf = substr($buf, $pos); - } else { - $buf = '0'; - } - return $buf; - } - - private function validateBit(int $bit): void { - if ($bit < 0 || $bit >= $this->size) - throw new Exception('invalid bit '.$bit.', allowed range: [0..'.$this->size.')'); - } -} - -function DB(): mysql|null { - global $config; - - /** @var ?mysql $link */ - static $link = null; - if (!is_null($link)) - return $link; - - $link = new mysql( - $config['mysql']['host'], - $config['mysql']['user'], - $config['mysql']['password'], - $config['mysql']['database']); - if (!$link->connect()) { - if (!isCli()) { - header('HTTP/1.1 503 Service Temporarily Unavailable'); - header('Status: 503 Service Temporarily Unavailable'); - header('Retry-After: 300'); - die('database connection failed'); - } else { - fwrite(STDERR, 'database connection failed'); - exit(1); - } - } - - return $link; -} - -function MC(): Memcached { - static $mc = null; - if ($mc === null) { - $mc = new Memcached(); - $mc->addServer("127.0.0.1", 11211); - } - return $mc; } diff --git a/src/engine/MySQLBitField.php b/src/engine/MySQLBitField.php new file mode 100644 index 0000000..f4b6b9b --- /dev/null +++ b/src/engine/MySQLBitField.php @@ -0,0 +1,65 @@ +value = gmp_init($value); + $this->size = $size; + } + + /** + * @throws Exception + */ + public function has(int $bit): bool { + $this->validateBit($bit); + return gmp_testbit($this->value, $bit); + } + + /** + * @throws Exception + */ + public function set(int $bit): void { + $this->validateBit($bit); + gmp_setbit($this->value, $bit); + } + + /** + * @throws Exception + */ + public function clear(int $bit): void { + $this->validateBit($bit); + gmp_clrbit($this->value, $bit); + } + + public function isEmpty(): bool { + return !gmp_cmp($this->value, 0); + } + + public function __toString(): string { + $buf = ''; + for ($bit = $this->size - 1; $bit >= 0; --$bit) + $buf .= gmp_testbit($this->value, $bit) ? '1' : '0'; + if (($pos = strpos($buf, '1')) !== false) { + $buf = substr($buf, $pos); + } else { + $buf = '0'; + } + return $buf; + } + + /** + * @throws Exception + */ + private function validateBit(int $bit): void { + if ($bit < 0 || $bit >= $this->size) + throw new Exception('invalid bit ' . $bit . ', allowed range: [0..' . $this->size . ')'); + } +} \ No newline at end of file diff --git a/engine/router.php b/src/engine/Router.php similarity index 83% rename from engine/router.php rename to src/engine/Router.php index 4baaa2c..ac05f3a 100644 --- a/engine/router.php +++ b/src/engine/Router.php @@ -1,7 +1,9 @@ [], 're_children' => [] ]; - protected static ?router $instance = null; + protected static ?Router $instance = null; - public static function getInstance(): router { + public static function getInstance(): Router { if (self::$instance === null) - self::$instance = new router(); + self::$instance = new Router(); return self::$instance; } private function __construct() { - $mc = MC(); + global $globalContext; + $mc = getMC(); $from_cache = !isDev(); $write_cache = !isDev(); @@ -34,9 +37,9 @@ class router { } if (!$from_cache) { - $routes_table = require_once APP_ROOT.'/routes.php'; + $routes_table = require_once APP_ROOT.'/src/routes.php'; - foreach ($routes_table as $controller => $routes) { + foreach ($routes_table[$globalContext->project] as $controller => $routes) { foreach ($routes as $route => $resolve) $this->add($route, $controller.' '.$resolve); } @@ -56,15 +59,17 @@ class router { foreach ($matches[1] as $match_index => $variants) { $variants = explode(',', $variants); $variants = array_map('trim', $variants); - $variants = array_filter($variants, function($s) { return $s != ''; }); + $variants = array_filter($variants, function ($s) { + return $s != ''; + }); - for ($i = 0; $i < count($templates); ) { + for ($i = 0; $i < count($templates);) { list($template, $value) = $templates[$i]; $new_templates = []; foreach ($variants as $variant_index => $variant) { $new_templates[] = [ strReplaceOnce($matches[0][$match_index], $variant, $template), - str_replace('${'.($match_index+1).'}', $variant, $value) + str_replace('${'.($match_index + 1).'}', $variant, $value) ]; } array_splice($templates, $i, 1, $new_templates); @@ -84,8 +89,8 @@ class router { while ($start_pos < $template_len) { $slash_pos = strpos($template, '/', $start_pos); if ($slash_pos !== false) { - $part = substr($template, $start_pos, $slash_pos-$start_pos+1); - $start_pos = $slash_pos+1; + $part = substr($template, $start_pos, $slash_pos - $start_pos + 1); + $start_pos = $slash_pos + 1; } else { $part = substr($template, $start_pos); $start_pos = $template_len; @@ -99,7 +104,7 @@ class router { protected function &_addRoute(&$parent, $part, $value = null) { $par_pos = strpos($part, '('); - $is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos-1] != '\\'); + $is_regex = $par_pos !== false && ($par_pos == 0 || $part[$par_pos - 1] != '\\'); $children_key = !$is_regex ? 'children' : 're_children'; @@ -128,7 +133,7 @@ class router { return $parent[$children_key][$part]; } - public function find($uri) { + public function find($uri): ?string { if ($uri != '/' && $uri[0] == '/') { $uri = substr($uri, 1); } @@ -140,8 +145,8 @@ class router { while ($start_pos < $uri_len) { $slash_pos = strpos($uri, '/', $start_pos); if ($slash_pos !== false) { - $part = substr($uri, $start_pos, $slash_pos-$start_pos+1); - $start_pos = $slash_pos+1; + $part = substr($uri, $start_pos, $slash_pos - $start_pos + 1); + $start_pos = $slash_pos + 1; } else { $part = substr($uri, $start_pos); $start_pos = $uri_len; @@ -171,19 +176,17 @@ class router { } } - if (!$found) { - return false; - } + if (!$found) + return null; } - if (!isset($parent['value'])) { - return false; - } + if (!isset($parent['value'])) + return null; $value = $parent['value']; if (!empty($matches)) { foreach ($matches as $i => $match) { - $needle = '$('.($i+1).')'; + $needle = '$('.($i + 1).')'; $pos = strpos($value, $needle); if ($pos !== false) { $value = substr_replace($value, $match, $pos, strlen($needle)); diff --git a/lib/sphinx.php b/src/engine/SphinxUtil.php similarity index 95% rename from lib/sphinx.php rename to src/engine/SphinxUtil.php index 5be2d48..29881d5 100644 --- a/lib/sphinx.php +++ b/src/engine/SphinxUtil.php @@ -1,13 +1,17 @@ 1) { $mark_count = substr_count($sql, '?'); - $positions = array(); + $positions = []; $last_pos = -1; for ($i = 0; $i < $mark_count; $i++) { $last_pos = strpos($sql, '?', $last_pos + 1); @@ -88,12 +92,12 @@ class sphinx { protected static function getLink($auto_create = true) { global $config; - /** @var ?mysqli $link */ + /** @var ?\mysqli $link */ static $link = null; if (!is_null($link) || !$auto_create) return $link; - $link = new mysqli(); + $link = new \mysqli(); $link->real_connect( $config['sphinx']['host'], ini_get('mysql.default_user'), @@ -104,6 +108,4 @@ class sphinx { return $link; } - - } \ No newline at end of file diff --git a/src/engine/exceptions/InvalidDomainException.php b/src/engine/exceptions/InvalidDomainException.php new file mode 100644 index 0000000..6a499db --- /dev/null +++ b/src/engine/exceptions/InvalidDomainException.php @@ -0,0 +1,5 @@ + $error], $code); + } +} \ No newline at end of file diff --git a/src/engine/http/AjaxOk.php b/src/engine/http/AjaxOk.php new file mode 100644 index 0000000..460b7a5 --- /dev/null +++ b/src/engine/http/AjaxOk.php @@ -0,0 +1,11 @@ + $response], $code); + } +} \ No newline at end of file diff --git a/src/engine/http/AjaxResponse.php b/src/engine/http/AjaxResponse.php new file mode 100644 index 0000000..0bc6757 --- /dev/null +++ b/src/engine/http/AjaxResponse.php @@ -0,0 +1,15 @@ +name); + } +} \ No newline at end of file diff --git a/src/engine/http/HTTPMethod.php b/src/engine/http/HTTPMethod.php new file mode 100644 index 0000000..d8e2dbe --- /dev/null +++ b/src/engine/http/HTTPMethod.php @@ -0,0 +1,9 @@ +data = $html; + } + + public function getBody(): string { + return $this->data; + } + + public function getHeaders(): ?array { + return ['Content-Type: '.$this->contentType]; + } +} \ No newline at end of file diff --git a/src/engine/http/InputVarType.php b/src/engine/http/InputVarType.php new file mode 100644 index 0000000..570015a --- /dev/null +++ b/src/engine/http/InputVarType.php @@ -0,0 +1,11 @@ +data = $data; + } + + public function getBody(): string { + return jsonEncode($this->data); + } + + public function getHeaders(): ?array { + return ['Content-Type: application/json; charset=utf-8']; + } +} diff --git a/src/engine/http/PlainTextResponse.php b/src/engine/http/PlainTextResponse.php new file mode 100644 index 0000000..e39c3f9 --- /dev/null +++ b/src/engine/http/PlainTextResponse.php @@ -0,0 +1,20 @@ +data = $text; + } + + public function getBody(): string { + return $this->data; + } + + public function getHeaders(): ?array { + return ['Content-Type: text/plain; charset=utf-8']; + } +} \ No newline at end of file diff --git a/src/engine/http/RequestHandler.php b/src/engine/http/RequestHandler.php new file mode 100644 index 0000000..a4554c5 --- /dev/null +++ b/src/engine/http/RequestHandler.php @@ -0,0 +1,203 @@ + ($orig_domain_len = strlen($config['domain']))) { + $sub = substr($_SERVER['HTTP_HOST'], 0, -$orig_domain_len - 1); + if (!array_key_exists($sub, $config['subdomains'])) + throw new InvalidDomainException('invalid subdomain '.$sub); + $globalContext->setProject($config['subdomains'][$sub]); + } + + if (!in_array($_SERVER['REQUEST_METHOD'], ['POST', 'GET'])) + throw new NotImplemented('Method '.$_SERVER['REQUEST_METHOD'].' not implemented'); + + $uri = $_SERVER['REQUEST_URI']; + if (($pos = strpos($uri, '?')) !== false) + $uri = substr($uri, 0, $pos); + + $router = router::getInstance(); + $route = $router->find($uri); + if ($route === null) + throw new NotFound('Route not found'); + + $route = preg_split('/ +/', $route); + $handler_class = 'app\\'.$globalContext->project.'\\'.$route[0].'Handler'; + if (!class_exists($handler_class)) + throw new NotFound(isDev() ? 'Handler class "'.$handler_class.'" not found' : ''); + + $action = $route[1]; + $input = []; + if (count($route) > 2) { + for ($i = 2; $i < count($route); $i++) { + $var = $route[$i]; + list($k, $v) = explode('=', $var); + $input[trim($k)] = trim($v); + } + } + + $rh = new $handler_class(); + $globalContext->setRequestHandler($rh); + $response = $rh->callAct($_SERVER['REQUEST_METHOD'], $action, $input); + } + + catch (InvalidDomainException|NotImplementedException $e) { + $stacktrace = \engine\logging\Util::getErrorFullStackTrace($e); + logError($e, stacktrace: $stacktrace); + self::renderError($e->getMessage(), HTTPCode::InternalServerError, $stacktrace); + } + + catch (BaseRedirect $e) { + if (isXHRRequest()) { + $response = new AjaxOk(['redirect' => $e->getLocation()]); + } else { + header('Location: '.$e->getLocation(), $e->shouldReplace(), $e->getHTTPCode()->value); + exit; + } + } + + catch (HTTPError $e) { + if (isXHRRequest()) { + $data = []; + $message = $e->getDescription(); + if ($message != '') + $data['message'] = $message; + $response = new AjaxError((object)$data, $e->getHTTPCode()); + } else { + self::renderError($e->getMessage(), $e->getHTTPCode()); + } + } + + catch (\Throwable $e) { + $stacktrace = \engine\logging\Util::getErrorFullStackTrace($e); + logError(get_class($e).': '.$e->getMessage(), stacktrace: $stacktrace); + self::renderError(get_class($e).': '.$e->getMessage(), HTTPCode::InternalServerError, $stacktrace); + } + + finally { + if (!empty($response)) { + if ($response instanceof Response) { + $response->send(); + } else { + logError(__METHOD__.': $response is not Response'); + } + } + } + } + + protected static function renderError(string $message, HTTPCode $code, ?string $stacktrace = null): never { + http_response_code($code->value); + $skin = new ErrorSkin(); + switch ($code) { + case HTTPCode::NotFound: + $skin->renderNotFound(); + default: + $skin->renderError($code->getTitle(), $message, $stacktrace); + } + } + + public function beforeDispatch(string $http_method, string $action) {} + + public function callAct(string $http_method, string $action, array $input = []) { + $handler_method = $_SERVER['REQUEST_METHOD'].'_'.$action; + if (!method_exists($this, $handler_method)) + throw new NotFound(static::class.'::'.$handler_method.' is not defined'); + + if (!(new \ReflectionMethod($this, $handler_method)->isPublic())) + throw new NotFound(static::class.'::'.$handler_method.' is not public'); + + if (!empty($input)) + $this->routerInput += $input; + + $args = $this->beforeDispatch($http_method, $action); + return call_user_func_array([$this, $handler_method], is_array($args) ? [$args] : []); + } + + protected function getPage(int $per_page, ?int $count = null): array { + list($page) = $this->input('i:page'); + $pages = $count !== null ? ceil($count / $per_page) : null; + if ($pages !== null && $page > $pages) + $page = $pages; + if ($page < 1) + $page = 1; + $offset = $per_page * ($page - 1); + return [$page, $pages, $offset]; + } + + public function input(string $input, + bool $trim = false): array + { + $input = preg_split('/,\s+?/', $input, -1, PREG_SPLIT_NO_EMPTY); + $result = []; + foreach ($input as $var) { + $pos = strpos($var, ':'); + if ($pos === 1) { + $type = InputVarType::from(substr($var, 0, $pos)); + $name = trim(substr($var, $pos + 1)); + } else { + $type = InputVarType::STRING; + $name = trim($var); + } + $val = null; + if (isset($this->routerInput[$name])) + $val = $this->routerInput[$name]; + else if (isset($_POST[$name])) + $val = $_POST[$name]; + else if (isset($_GET[$name])) + $val = $_GET[$name]; + if (is_array($val)) + $val = implode($val); + $result[] = match ($type) { + InputVarType::INTEGER => (int)$val, + InputVarType::FLOAT => (float)$val, + InputVarType::BOOLEAN => (bool)$val, + default => $trim ? trim((string)$val) : (string)$val + }; + } + return $result; + } + + public function getCSRF(string $key): string { + global $config; + $user_key = isAdmin() ? \app\Admin::getCSRFSalt() : $_SERVER['REMOTE_ADDR']; + return substr(hash('sha256', $config['csrf_token'].$user_key.$key), 0, 20); + } + + /** + * @throws errors\Forbidden + */ + protected function checkCSRF(string $key): void { + if ($this->getCSRF($key) != ($_REQUEST['token'] ?? '')) + throw new errors\Forbidden('invalid token'); + } + + /** + * @throws InvalidRequest + */ + protected function ensureIsXHR(): void { + if (!isXHRRequest()) + throw new InvalidRequest(); + } +} diff --git a/src/engine/http/Response.php b/src/engine/http/Response.php new file mode 100644 index 0000000..edccbcd --- /dev/null +++ b/src/engine/http/Response.php @@ -0,0 +1,25 @@ +code->value); + $headers = $this->getHeaders(); + if ($headers) { + foreach ($headers as $header) + header($header); + } + echo $this->getBody(); + } + + abstract public function getHeaders(): ?array; + abstract public function getBody(): string; +} diff --git a/src/engine/http/errors/BaseRedirect.php b/src/engine/http/errors/BaseRedirect.php new file mode 100644 index 0000000..cd9030e --- /dev/null +++ b/src/engine/http/errors/BaseRedirect.php @@ -0,0 +1,55 @@ +no_ajax = $no_ajax; + } + + public function getLocation(): string { + return $this->message; + } + + public function shouldReplace(): bool { + return $this->replace; + } + + public function isNoAjaxSet(): bool { + return $this->no_ajax; + } +} \ No newline at end of file diff --git a/src/engine/http/errors/Forbidden.php b/src/engine/http/errors/Forbidden.php new file mode 100644 index 0000000..0a4b045 --- /dev/null +++ b/src/engine/http/errors/Forbidden.php @@ -0,0 +1,13 @@ +http_code = $code; + $this->description = $message; + parent::__construct($message, $code->value, $previous); + } + + public function getHTTPCode(): HTTPCode { + return $this->http_code; + } + + public function getDescription(): string { + return $this->description; + } +} \ No newline at end of file diff --git a/src/engine/http/errors/InternalServerError.php b/src/engine/http/errors/InternalServerError.php new file mode 100644 index 0000000..9d4292c --- /dev/null +++ b/src/engine/http/errors/InternalServerError.php @@ -0,0 +1,13 @@ +getData(); + else { + // TODO implement proper cache in StringsPack class + if (isset($this->loadedLangPacks[$arg])) + continue; + $raw = $this->getStringsPack($arg, true); + } + $this->data = array_merge($this->data, $raw); + $keys = array_merge($keys, array_keys($raw)); + $this->loadedLangPacks[$arg] = true; + } + return $keys; + } + + /** + * @param string $name Pack name. + * @param bool $raw Whether to return raw data. + * @return array|StringsPack + */ + public function getStringsPack(string $name, bool $raw = false): array|StringsPack { + $file = APP_ROOT.'/src/strings/'.$name.'.yaml'; + $data = yaml_parse_file($file); + if ($data === false) + logError(__METHOD__.': yaml_parse_file failed on file '.$file); + return $raw ? $data : new StringsPack($data); + } + + public function search(string $regexp): array { + return preg_grep($regexp, array_keys($this->data)); + } +} diff --git a/src/engine/lang/StringsBase.php b/src/engine/lang/StringsBase.php new file mode 100644 index 0000000..0c2386c --- /dev/null +++ b/src/engine/lang/StringsBase.php @@ -0,0 +1,105 @@ + + */ +class StringsBase implements ArrayAccess +{ + /** @var array */ + protected array $data = []; + + /** + * @throws NotImplementedException + */ + public function offsetSet(mixed $offset, mixed $value): void { + throw new NotImplementedException(); + } + + public function offsetExists(mixed $offset): bool { + return isset($this->data[$offset]); + } + + /** + * @throws NotImplementedException + */ + public function offsetUnset(mixed $offset): void { + throw new NotImplementedException(); + } + + /** + * @param mixed $offset + * @return string|string[] + */ + public function offsetGet(mixed $offset): mixed { + if (!isset($this->data[$offset])) { + logError(__METHOD__.': '.$offset.' not found'); + return '{'.$offset.'}'; + } + return $this->data[$offset]; + } + + /** + * Get string or plural strings. + * @param string $key Key. + * @param mixed ...$sprintf_args `sprintf` values. If this argument is + * supplied, then strings value is treated as `sprintf` format string + * and additional arguments as `sprintf` values. + * @return string|string[] + */ + public function get(string $key, mixed ...$sprintf_args): string|array { + $val = $this->offsetGet($key); + if (!empty($sprintf_args)) { + return sprintf($val, ...$sprintf_args); + } else { + return $val; + } + } + + /** + * Pluralize by number. + * @param string|array{0:string,1:string,2:string,3:string} $key Strings key or plural array. + * @param int $num Count. + * @param array{format:bool|(\Closure(int $num):string),format_delim:string} $opts Additional options. + * @return string + */ + public function num(string|array $key, int $num, array $opts = []): string { + $s = is_array($key) ? $key : $this->offsetGet($key); + /** @var array{format:bool|(\Closure(int $num):string),format_delim:string} */ + $opts = array_merge([ + 'format' => true, + 'format_delim' => ' ' + ], $opts); + + $n = $num % 100; + if ($n > 19) + $n %= 10; + + if ($n == 1) { + $word = 0; + } elseif ($n >= 2 && $n <= 4) { + $word = 1; + } elseif ($num == 0 && count($s) == 4) { + $word = 3; + } else { + $word = 2; + } + + // if zero + if ($word == 3) + return $s[3]; + + if (is_callable($opts['format'])) { + /** @var string */ + $num = $opts['format']($num); + } else if ($opts['format'] === true) { + $num = formatNumber($num, $opts['format_delim']); + } + + return sprintf($s[$word], $num); + } +} diff --git a/src/engine/lang/StringsPack.php b/src/engine/lang/StringsPack.php new file mode 100644 index 0000000..75a6033 --- /dev/null +++ b/src/engine/lang/StringsPack.php @@ -0,0 +1,12 @@ +data; + } +} diff --git a/src/engine/logging/AnsiColor.php b/src/engine/logging/AnsiColor.php new file mode 100644 index 0000000..7380436 --- /dev/null +++ b/src/engine/logging/AnsiColor.php @@ -0,0 +1,15 @@ + time(), + 'num' => $num, + 'time' => exectime(), + 'errno' => $errno ?: 0, + 'file' => $errfile ?: '?', + 'line' => $errline ?: 0, + 'text' => $message, + 'level' => $level->value, + 'stacktrace' => $stacktrace ?: Util::backtraceAsString(2), + 'is_cli' => intval(isCli()), + 'admin_id' => isAdmin() ? \app\Admin::getId() : 0, + ]; + + if (isCli()) { + $data += [ + 'ip' => '', + 'ua' => '', + 'url' => '', + ]; + } else { + $data += [ + 'ip' => ip2ulong($_SERVER['REMOTE_ADDR']), + 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '', + 'url' => $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'] + ]; + } + + $db->insert('backend_errors', $data); + } +} diff --git a/src/engine/logging/FileLogger.php b/src/engine/logging/FileLogger.php new file mode 100644 index 0000000..3585c21 --- /dev/null +++ b/src/engine/logging/FileLogger.php @@ -0,0 +1,87 @@ +logFile)) { + fprintf(STDERR, __METHOD__.': logfile is not set'); + return; + } + + $time = time(); + + // TODO rewrite using sprintf + $exec_time = strval(exectime()); + if (strlen($exec_time) < 6) + $exec_time .= str_repeat('0', 6 - strlen($exec_time)); + + $title = isCli() ? 'cli' : $_SERVER['REQUEST_URI']; + $date = date('d/m/y H:i:s', $time); + + $buf = ''; + if ($num == 0) { + $buf .= Util::ansi(" $title ", + fg: AnsiColor::WHITE, + bg: AnsiColor::MAGENTA, + bold: true, + fg_bright: true); + $buf .= Util::ansi(" $date ", fg: AnsiColor::WHITE, bg: AnsiColor::BLUE, fg_bright: true); + $buf .= "\n"; + } + + $letter = strtoupper($level->name[0]); + $color = match ($level) { + LogLevel::ERROR => AnsiColor::RED, + // LogLevel::INFO => AnsiColor::GREEN, + LogLevel::DEBUG => AnsiColor::WHITE, + LogLevel::WARNING => AnsiColor::YELLOW + }; + + $buf .= Util::ansi($letter.Util::ansi('='.Util::ansi($num, bold: true)), fg: $color).' '; + $buf .= Util::ansi($exec_time, fg: AnsiColor::CYAN).' '; + if (!is_null($errno)) { + $buf .= Util::ansi($errfile, fg: AnsiColor::GREEN); + $buf .= Util::ansi(':', fg: AnsiColor::WHITE); + $buf .= Util::ansi($errline, fg: AnsiColor::GREEN, fg_bright: true); + $buf .= ' ('.Util::getPHPErrorName($errno).') '; + } + + $buf .= $message."\n"; + if (in_array($level, [LogLevel::ERROR, LogLevel::WARNING])) + $buf .= ($stacktrace ?: Util::backtraceAsString(2))."\n"; + + $set_perm = false; + if (!file_exists($this->logFile)) { + $set_perm = true; + $dir = dirname($this->logFile); + echo "dir: $dir\n"; + + if (!file_exists($dir)) { + mkdir($dir); + setperm($dir); + } + } + + $f = fopen($this->logFile, 'a'); + if (!$f) { + fprintf(STDERR, __METHOD__.': failed to open file \''.$this->logFile.'\' for writing'); + return; + } + + fwrite($f, $buf); + fclose($f); + + if ($set_perm) + setperm($this->logFile); + } +} \ No newline at end of file diff --git a/src/engine/logging/LogLevel.php b/src/engine/logging/LogLevel.php new file mode 100644 index 0000000..f0b2acc --- /dev/null +++ b/src/engine/logging/LogLevel.php @@ -0,0 +1,11 @@ +filter = $filter; + } + + public function disable(): void { + $this->enabled = false; + } + + public function enable(): void { + static $error_handler_set = false; + $this->enabled = true; + + if ($error_handler_set) + return; + + $self = $this; + + set_error_handler(function ($no, $str, $file, $line) use ($self) { + if (!$self->enabled) + return; + + if (is_callable($self->filter) && !($self->filter)($no, $file, $line, $str)) + return; + + static::write(LogLevel::ERROR, $str, + errno: $no, + errfile: $file, + errline: $line); + }); + + set_exception_handler(function (\Throwable $e): void { + static::write(LogLevel::ERROR, get_class($e).': '.$e->getMessage(), + errfile: $e->getFile() ?: '?', + errline: $e->getLine() ?: 0, + stacktrace: $e->getTraceAsString()); + }); + + register_shutdown_function(function () use ($self) { + if (!$self->enabled || !($error = error_get_last())) + return; + + if (is_callable($self->filter) + && !($self->filter)($error['type'], $error['file'], $error['line'], $error['message'])) { + return; + } + + static::write(LogLevel::ERROR, $error['message'], + errno: $error['type'], + errfile: $error['file'], + errline: $error['line']); + }); + + $error_handler_set = true; + } + + public function log(LogLevel $level, ?string $stacktrace = null, ...$args): void { + if (!isDev() && $level == LogLevel::DEBUG) + return; + $this->write($level, Util::strVars($args), + stacktrace: $stacktrace); + } + + public function canReport(): bool { + return $this->recursionLevel < 3; + } + + protected function write(LogLevel $level, + string $message, + ?int $errno = null, + ?string $errfile = null, + ?string $errline = null, + ?string $stacktrace = null): void { + $this->recursionLevel++; + + if ($this->canReport()) + $this->writer($level, $this->counter++, $message, $errno, $errfile, $errline, $stacktrace); + + $this->recursionLevel--; + } + + abstract protected function writer(LogLevel $level, + int $num, + string $message, + ?int $errno = null, + ?string $errfile = null, + ?string $errline = null, + ?string $stacktrace = null): void; +} \ No newline at end of file diff --git a/src/engine/logging/Util.php b/src/engine/logging/Util.php new file mode 100644 index 0000000..afe4762 --- /dev/null +++ b/src/engine/logging/Util.php @@ -0,0 +1,68 @@ + match (gettype($a)) { + 'string' => $a, + 'array', 'object' => self::strVarDump($a, true), + default => self::strVarDump($a) + }, $args); + return implode(' ', $args); + } + + public static function backtraceAsString(int $shift = 0): string { + $bt = debug_backtrace(); + $lines = []; + foreach ($bt as $i => $t) { + if ($i < $shift) + continue; + + if (!isset($t['file'])) { + $lines[] = 'from ?'; + } else { + $lines[] = 'from '.$t['file'].':'.$t['line']; + } + } + return implode("\n", $lines); + } + + public static function ansi(string $text, + ?AnsiColor $fg = null, + ?AnsiColor $bg = null, + bool $bold = false, + bool $fg_bright = false, + bool $bg_bright = false): string { + $codes = []; + if (!is_null($fg)) + $codes[] = $fg->value + ($fg_bright ? 90 : 30); + if (!is_null($bg)) + $codes[] = $bg->value + ($bg_bright ? 100 : 40); + if ($bold) + $codes[] = 1; + + if (empty($codes)) + return $text; + + return "\033[".implode(';', $codes)."m".$text."\033[0m"; + } + + public static function getErrorFullStackTrace(\Throwable $error): string { + return '#_ '.$error->getFile().'('.$error->getLine().')'."\n".$error->getTraceAsString(); + } +} \ No newline at end of file diff --git a/src/engine/skin/BaseSkin.php b/src/engine/skin/BaseSkin.php new file mode 100644 index 0000000..8bb435f --- /dev/null +++ b/src/engine/skin/BaseSkin.php @@ -0,0 +1,96 @@ +project; + if (!file_exists($cache_dir)) { + if (mkdir($cache_dir, $config['dirs_mode'], true)) + setperm($cache_dir); + } + + $this->twig = new \Twig\Environment($this->getTwigLoader(), [ + 'cache' => $cache_dir, + 'auto_reload' => isDev() + ]); + $this->twig->addExtension(new SkinTwigExtension($this)); + + $this->strings = Strings::getInstance(); // why singleton here? + } + + abstract protected function getTwigLoader(): LoaderInterface; + + public function set($arg1, $arg2 = null) { + if (is_array($arg1)) { + foreach ($arg1 as $key => $value) + $this->vars[$key] = $value; + } elseif ($arg2 !== null) { + $this->vars[$arg1] = $arg2; + } + } + + public function setGlobal($arg1, $arg2 = null): void { + if ($this->globalsApplied) + logError(__METHOD__.': WARNING: globals were already applied, your change will not be visible'); + + if (is_array($arg1)) { + foreach ($arg1 as $key => $value) + $this->globalVars[$key] = $value; + } elseif ($arg2 !== null) { + $this->globalVars[$arg1] = $arg2; + } + } + + public function applyGlobals(): void { + if (!empty($this->globalVars) && !$this->globalsApplied) { + foreach ($this->globalVars as $key => $value) + $this->twig->addGlobal($key, $value); + $this->globalsApplied = true; + } + } + + public function render($template, array $vars = []): string { + $this->applyGlobals(); + return $this->doRender($template, $this->vars + $vars); + } + + protected function doRender(string $template, array $vars = []): string { + $s = ''; + try { + $s = $this->twig->render($template, $vars); + } catch (\Twig\Error\Error $e) { + $error = get_class($e).": failed to render"; + $source_ctx = $e->getSourceContext(); + if ($source_ctx) { + $path = $source_ctx->getPath(); + if (str_starts_with($path, APP_ROOT)) + $path = substr($path, strlen(APP_ROOT) + 1); + $error .= " ".$source_ctx->getName()." (".$path.") at line ".$e->getTemplateLine(); + } + $error .= ": "; + $error .= $e->getMessage(); + logError($error); + if (isDev()) { + $s = $error."\n

\n"; + $s .= nl2br(htmlescape($e->getTraceAsString())); + } + } + return $s; + } +} diff --git a/src/engine/skin/ErrorSkin.php b/src/engine/skin/ErrorSkin.php new file mode 100644 index 0000000..45f0516 --- /dev/null +++ b/src/engine/skin/ErrorSkin.php @@ -0,0 +1,35 @@ +project) && file_exists(self::TEMPLATES_ROOT.'/notfound_'.$globalContext->project.'.twig')) + $template = 'notfound_'.$globalContext->project.'.twig'; + else + $template = 'notfound.twig'; + echo $this->twig->render($template); + exit; + } + + public function renderError(string $title, ?string $message = null, ?string $stacktrace = null): never { + echo $this->twig->render('error.twig', [ + 'title' => $title, + 'stacktrace' => $stacktrace, + 'message' => $message, + ]); + exit; + } +} \ No newline at end of file diff --git a/src/engine/skin/FeaturedSkin.php b/src/engine/skin/FeaturedSkin.php new file mode 100644 index 0000000..5044331 --- /dev/null +++ b/src/engine/skin/FeaturedSkin.php @@ -0,0 +1,296 @@ +meta = new Meta(); + + $this->twig->addExtension(new JsTwigExtension($this)); + $this->twig->addRuntimeLoader(new \Twig\RuntimeLoader\FactoryRuntimeLoader([ + JsTagRuntime::class => function () { + return new JsTagRuntime($this); + }, + ])); + } + + public function exportStrings(array|string $keys): void { + $this->exportedStrings = array_merge($this->exportedStrings, is_string($keys) ? $this->strings->search($keys) : $keys); + } + + public function addStatic(string ...$files): void { + foreach ($files as $file) + $this->static[] = $file; + } + + public function addJS(string $js): void { + if ($js != '') + $this->js[] = $js; + } + + protected function getJS(): string { + if (empty($this->js)) + return ''; + return implode("\n", $this->js); + } + + public function preloadSVG(string $name): void { + if (isset($this->svgDefs[$name])) + return; + + if (!preg_match_all('/\d+/', $name, $matches)) + throw new \InvalidArgumentException('icon name '.$name.' is invalid, it should follow following pattern: $name_$size[_$size]'); + + $size = array_slice($matches[0], -2); + $this->svgDefs[$name] = [ + 'width' => $size[0], + 'height' => $size[1] ?? $size[0] + ]; + } + + public function getSVG(string $name, bool $in_place = false): ?string { + $this->preloadSVG($name); + $w = $this->svgDefs[$name]['width']; + $h = $this->svgDefs[$name]['height']; + if ($in_place) { + $svg = ''; + $svg .= file_get_contents(APP_ROOT.'/src/skins/svg/'.$name.'.svg'); + $svg .= ''; + return $svg; + } else { + return ''; + } + } + + public function renderBreadCrumbs(array $items, ?string $style = null, bool $mt = false): string { + static $chevron = ''; + $buf = implode(array_map(function (array $i) use ($chevron): string { + $buf = ''; + $has_url = array_key_exists('url', $i); + + if ($has_url) + $buf .= ''; + else + $buf .= ''; + $buf .= htmlescape($i['text']); + + if ($has_url) + $buf .= ' '.$chevron.''; + else + $buf .= ''; + + return $buf; + }, $items)); + $class = 'bc'; + if ($mt) + $class .= ' mt'; + return '
'.$buf.'
'; + } + + public function renderPageNav(int $page, int $pages, string $link_template, ?array $opts = null): string { + if ($opts === null) { + $count = 0; + } else { + $opts = array_merge(['count' => 0], $opts); + $count = $opts['count']; + } + + $min_page = max(1, $page - 2); + $max_page = min($pages, $page + 2); + + $pages_html = ''; + $base_class = 'pn-button no-hover no-select no-drag is-page'; + for ($p = $min_page; $p <= $max_page; $p++) { + $class = $base_class; + if ($p == $page) + $class .= ' is-page-cur'; + $pages_html .= ''.$p.''; + } + + if ($min_page > 2) { + $pages_html = '
 
'.$pages_html; + } + if ($min_page > 1) { + $pages_html = '1'.$pages_html; + } + + if ($max_page < $pages - 1) { + $pages_html .= '
 
'; + } + if ($max_page < $pages) { + $pages_html .= ''.$pages.''; + } + + $pn_class = 'pn'; + if ($pages < 2) { + $pn_class .= ' no-nav'; + if (!$count) { + $pn_class .= ' no-results'; + } + } + + $html = << +
+ {$pages_html} +
+ +HTML; + + return $html; + } + + protected static function pageNavGetLink($page, $link_template) { + return is_callable($link_template) ? $link_template($page) : str_replace('{page}', $page, $link_template); + } + + protected function getSVGTags(): string { + $buf = ''; + foreach ($this->svgDefs as $name => $icon) { + $content = file_get_contents(APP_ROOT.'/src/skins/svg/'.$name.'.svg'); + $buf .= "$content"; + } + $buf .= ''; + return $buf; + } + + protected function getHeaderStaticTags(): string { + $html = []; + $theme = ThemesUtil::getUserTheme(); + $dark = $theme == 'dark' || ($theme == 'auto' && ThemesUtil::isUserSystemThemeDark()); + $this->styleNames = []; + foreach ($this->static as $name) { + // javascript + if (str_starts_with($name, 'js/')) + $html[] = $this->jsLink($name); + + // css + else if (str_starts_with($name, 'css/')) { + $html[] = $this->cssLink($name, 'light', $style_name_ptr); + $this->styleNames[] = $style_name_ptr; + + if ($dark) + $html[] = $this->cssLink($name, 'dark', $style_name_ptr); + else if (!isDev()) + $html[] = $this->cssPrefetchLink($style_name_ptr.'_dark'); + } else + logError(__FUNCTION__.': unexpected static entry: '.$name); + } + return implode("\n", $html); + } + + protected function getFooterScriptTags(): string { + global $config; + + $html = ''; + return $html; + } + + protected function jsLink(string $name): string { + list (, $bname) = $this->getStaticNameParts($name); + if (isDev()) { + $href = '/js.php?name='.urlencode($bname).'&v='.time(); + } else { + $href = '/dist-js/'.$bname.'.js?v='.$this->getStaticVersion($name); + } + return ''; + } + + protected function cssLink(string $name, string $theme, &$bname = null): string { + list(, $bname) = $this->getStaticNameParts($name); + + $config_name = 'css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css'; + + if (isDev()) { + $href = '/sass.php?name='.urlencode($bname).'&theme='.$theme.'&v='.time(); + } else { + $version = $this->getStaticVersion($config_name); + $href = '/dist-css/'.$bname.($theme == 'dark' ? '_dark' : '').'.css?v='.$version; + } + + $id = 'style_'.$bname; + if ($theme == 'dark') + $id .= '_dark'; + + return 'getStaticIntegrityAttribute($config_name).'>'; + } + + protected function cssPrefetchLink(string $name): string { + $url = '/dist-css/'.$name.'.css?v='.$this->getStaticVersion('css/'.$name.'.css'); + $integrity = $this->getStaticIntegrityAttribute('css/'.$name.'.css'); + return ''; + } + + protected function getStaticNameParts(string $name): array { + $dname = dirname($name); + $bname = basename($name); + if (($pos = strrpos($bname, '.'))) { + $ext = substr($bname, $pos + 1); + $bname = substr($bname, 0, $pos); + } else { + $ext = ''; + } + return [$dname, $bname, $ext]; + } + + protected function getStaticVersion(string $name): string { + global $config; + if (isDev()) + return time(); + if (str_starts_with($name, '/')) { + logWarning(__FUNCTION__.': '.$name.' starts with /'); + $name = substr($name, 1); + } + return $config['static'][$name]['version'] ?? 'notfound'; + } + + protected function getStaticIntegrityAttribute(string $name): string { + if (isDev()) + return ''; + global $config; + return ' integrity="'.implode(' ', array_map(fn($hash_type) => $hash_type.'-'.$config['static'][$name]['integrity'][$hash_type], self::RESOURCE_INTEGRITY_HASHES)).'"'; + } +} \ No newline at end of file diff --git a/src/engine/skin/Meta.php b/src/engine/skin/Meta.php new file mode 100644 index 0000000..852db41 --- /dev/null +++ b/src/engine/skin/Meta.php @@ -0,0 +1,67 @@ + 70, + 'description' => 200 + ]; + + public ?string $url = null; + public ?string $title = null; + public ?string $description = null; + public ?string $keywords = null; + public ?string $image = null; + protected array $social = []; + + public function setSocial(string $name, string $value): void { + $this->social[$name] = $value; + } + + public function getHtml(): string { + $tags = []; + $add_og_twitter = function ($key, $value) use (&$tags) { + foreach (['og', 'twitter'] as $social) { + if ($social == 'twitter' && isset(self::TWITTER_LIMITS[$key])) { + if (mb_strlen($value) > self::TWITTER_LIMITS[$key]) + $value = mb_substr($value, 0, self::TWITTER_LIMITS[$key] - 3).'...'; + } + $tags[] = [ + $social == 'twitter' ? 'name' : 'property' => $social.':'.$key, + 'content' => $value + ]; + } + }; + + foreach (['url', 'title', 'image'] as $k) { + if ($this->$k !== null) + $add_og_twitter($k, $this->$k); + } + foreach (['description', 'keywords'] as $k) { + if ($this->$k !== null) { + $add_og_twitter($k, $this->$k); + $tags[] = ['name' => $k, 'content' => $this->$k]; + } + } + if (!empty($this->social)) { + foreach ($this->social as $key => $value) { + if (str_starts_with($key, 'og:')) { + $tags[] = ['property' => $key, 'content' => $value]; + } else { + logWarning("unsupported meta: $key => $value"); + } + } + } + + return implode('', array_map(function (array $item): string { + $s = ' $v) + $s .= ' '.htmlescape($k).'="'.htmlescape($v).'"'; + $s .= '/>'; + $s .= "\n"; + return $s; + }, $tags)); + } +} \ No newline at end of file diff --git a/src/engine/skin/Options.php b/src/engine/skin/Options.php new file mode 100644 index 0000000..1d4a672 --- /dev/null +++ b/src/engine/skin/Options.php @@ -0,0 +1,15 @@ + $value) { + $snake = strtolower(preg_replace('/([a-z0-9])([A-Z])/', '$1_$2', $prop)); + $opts[$snake] = $value; + } + return $opts; + } +} \ No newline at end of file diff --git a/src/engine/skin/ServiceSkin.php b/src/engine/skin/ServiceSkin.php new file mode 100644 index 0000000..3209703 --- /dev/null +++ b/src/engine/skin/ServiceSkin.php @@ -0,0 +1,14 @@ +getNode('params')); @@ -30,10 +31,10 @@ class JsTagNode extends \Twig\Node\Node { } $compiler - ->write('skin::getInstance()->addJS($js);') + // ->write('$context[\'__skin_ref\']->addJS($js);') + ->write('$this->env->getRuntime(\''.JsTagRuntime::class.'\')->addJS($js);') ->raw(PHP_EOL) ->write('unset($js);') ->raw(PHP_EOL); } - } diff --git a/lib/TwigAddons/JsTagParamsNode.php b/src/engine/skin/TwigAddons/JsTagParamsNode.php similarity index 71% rename from lib/TwigAddons/JsTagParamsNode.php rename to src/engine/skin/TwigAddons/JsTagParamsNode.php index e0a07a3..7583076 100644 --- a/lib/TwigAddons/JsTagParamsNode.php +++ b/src/engine/skin/TwigAddons/JsTagParamsNode.php @@ -1,6 +1,6 @@ skin = $skin; + } + + public function addJS(string $js): void { + $this->skin->addJS($js); + } +} \ No newline at end of file diff --git a/lib/TwigAddons/JsTagTokenParser.php b/src/engine/skin/TwigAddons/JsTagTokenParser.php similarity index 98% rename from lib/TwigAddons/JsTagTokenParser.php rename to src/engine/skin/TwigAddons/JsTagTokenParser.php index 00a3e49..a9ce55c 100644 --- a/lib/TwigAddons/JsTagTokenParser.php +++ b/src/engine/skin/TwigAddons/JsTagTokenParser.php @@ -1,9 +1,11 @@ getLine(); $stream = $this->parser->getStream(); @@ -80,5 +82,4 @@ class JsTagTokenParser extends \Twig\TokenParser\AbstractTokenParser { public function getTag() { return 'js'; } - } diff --git a/src/engine/skin/TwigAddons/JsTwigExtension.php b/src/engine/skin/TwigAddons/JsTwigExtension.php new file mode 100644 index 0000000..2bb13fb --- /dev/null +++ b/src/engine/skin/TwigAddons/JsTwigExtension.php @@ -0,0 +1,16 @@ + \skin::getInstance()->getSVG($name), + new TwigFunction('svg', fn($name) => $this->skin->getSVG($name), ['is_safe' => ['html']]), - new TwigFunction('svgInPlace', fn($name) => \skin::getInstance()->getSVG($name, in_place: true), + new TwigFunction('svgInPlace', fn($name) => $this->skin->getSVG($name, in_place: true), ['is_safe' => ['html']]), new TwigFunction('svgPreload', function(...$icons) { - $skin = \skin::getInstance(); foreach ($icons as $icon) - $skin->preloadSVG($icon); + $this->skin->preloadSVG($icon); return null; }), - new TwigFunction('bc', fn(...$args) => \skin::getInstance()->renderBreadCrumbs(...$args), + new TwigFunction('bc', fn(...$args) => $this->skin->renderBreadCrumbs(...$args), ['is_safe' => ['html']]), - new TwigFunction('pageNav', fn(...$args) => \skin::getInstance()->renderPageNav(...$args), + new TwigFunction('pageNav', fn(...$args) => $this->skin->renderPageNav(...$args), ['is_safe' => ['html']]), - new TwigFunction('csrf', fn($value) => \request_handler::getCSRF($value)) + new TwigFunction('csrf', function($value) { + global $globalContext; + return $globalContext->requestHandler->getCSRF($value); + }) ]; } public function getFilters() { - return array( + return [ new TwigFilter('lang', function($key, array $args = []) { - global $__lang; array_walk($args, function(&$item, $key) { $item = htmlescape($item); }); array_unshift($args, $key); - return call_user_func_array([$__lang, 'get'], $args); + return $this->skin->strings->get(...$args); }, ['is_variadic' => true]), new TwigFilter('hl', function($s, $keywords) { @@ -49,19 +54,9 @@ class MyExtension extends AbstractExtension { }), new TwigFilter('plural', function($text, array $args = []) { - global $__lang; array_unshift($args, $text); - return call_user_func_array([$__lang, 'num'], $args); + return $this->skin->strings->num(...$args); }, ['is_variadic' => true]), - ); + ]; } - - public function getTokenParsers() { - return [new JsTagTokenParser()]; - } - - public function getName() { - return 'lang'; - } - } diff --git a/src/engine_functions.php b/src/engine_functions.php new file mode 100644 index 0000000..c1b7112 --- /dev/null +++ b/src/engine_functions.php @@ -0,0 +1,81 @@ +connect()) { + if (!isCli()) { + header('HTTP/1.1 503 Service Temporarily Unavailable'); + header('Status: 503 Service Temporarily Unavailable'); + header('Retry-After: 300'); + die('database connection failed'); + } else { + fwrite(STDERR, 'database connection failed'); + exit(1); + } + } + + return $link; +} + +function getMC(): Memcached { + static $mc = null; + if ($mc === null) { + $mc = new Memcached(); + $mc->addServer("127.0.0.1", 11211); + } + return $mc; +} + +function logDebug(...$args): void { + global $globalContext; + $globalContext->logger->log(engine\logging\LogLevel::DEBUG, null, ...$args); +} + +function logWarning(...$args): void { + global $globalContext; + $globalContext->logger->log(engine\logging\LogLevel::WARNING, null, ...$args); +} + +function logError(...$args): void { + global $globalContext; + if (array_key_exists('stacktrace', $args)) { + $st = $args['stacktrace']; + unset($args['stacktrace']); + } else { + $st = null; + } + if ($globalContext->logger->canReport()) + $globalContext->logger->log(engine\logging\LogLevel::ERROR, $st, ...$args); +} + +function lang(...$args) { + global $globalContext; + return $globalContext->getStrings()->get(...$args); +} + +function langNum(...$args) { + global $globalContext; + return $globalContext->getStrings()->num(...$args); +} + +function isDev(): bool { global $globalContext; return $globalContext->isDevelopmentEnvironment; } +function isCli(): bool { return PHP_SAPI == 'cli'; }; +function isRetina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; } +function isXHRRequest() { return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest'; } + +function isAdmin(): bool { + if (app\Admin::getId() === null) + app\Admin::check(); + return app\Admin::getId() != 0; +} diff --git a/functions.php b/src/functions.php similarity index 85% rename from functions.php rename to src/functions.php index 1142135..e753bef 100644 --- a/functions.php +++ b/src/functions.php @@ -1,32 +1,5 @@ ($orig_domain_len = strlen($config['domain']))) { - $sub = substr($host, 0, -$orig_domain_len-1); - if (in_array($sub, $config['dev_domains'])) { - $config['is_dev'] = true; - } else if (!in_array($sub, $config['subdomains'])) { - throw new RuntimeException('invalid subdomain '.$sub); - } - } - - if (isCli() && str_ends_with(__DIR__, 'www-dev')) - $config['is_dev'] = true; -} - function htmlescape(string|array $s): string|array { if (is_array($s)) { foreach ($s as $k => $v) { @@ -38,11 +11,11 @@ function htmlescape(string|array $s): string|array { } function sizeString(int $size): string { - $ks = array('B', 'KiB', 'MiB', 'GiB'); + $ks = ['B', 'KiB', 'MiB', 'GiB']; foreach ($ks as $i => $k) { if ($size < pow(1024, $i + 1)) { if ($i == 0) - return $size . ' ' . $k; + return $size.' '.$k; return round($size / pow(1024, $i), 2).' '.$k; } } @@ -83,20 +56,20 @@ function detectImageType(string $filename) { } function transliterate(string $string): string { - $roman = array( + $roman = [ 'Sch', 'sch', 'Yo', 'Zh', 'Kh', 'Ts', 'Ch', 'Sh', 'Yu', 'ya', 'yo', 'zh', 'kh', 'ts', 'ch', 'sh', 'yu', 'ya', 'A', 'B', 'V', 'G', 'D', 'E', 'Z', 'I', 'Y', 'K', 'L', 'M', 'N', 'O', 'P', 'R', 'S', 'T', 'U', 'F', '', 'Y', '', 'E', 'a', 'b', 'v', 'g', 'd', 'e', 'z', 'i', 'y', 'k', 'l', 'm', 'n', 'o', 'p', 'r', 's', 't', 'u', 'f', '', 'y', '', 'e' - ); - $cyrillic = array( + ]; + $cyrillic = [ 'Щ', 'щ', 'Ё', 'Ж', 'Х', 'Ц', 'Ч', 'Ш', 'Ю', 'я', 'ё', 'ж', 'х', 'ц', 'ч', 'ш', 'ю', 'я', 'А', 'Б', 'В', 'Г', 'Д', 'Е', 'З', 'И', 'Й', 'К', 'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф', 'Ь', 'Ы', 'Ъ', 'Э', 'а', 'б', 'в', 'г', 'д', 'е', 'з', 'и', 'й', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'ь', 'ы', 'ъ', 'э' - ); + ]; return str_replace($cyrillic, $roman, $string); } @@ -256,25 +229,6 @@ function formatNumber(int|float $num, string $delim = ' ', bool $short = false): return number_format($num, 0, '.', $delim); } -function lang() { - global $__lang; - return call_user_func_array([$__lang, 'get'], func_get_args()); -} -function langNum() { - global $__lang; - return call_user_func_array([$__lang, 'num'], func_get_args()); -} - -function isDev(): bool { global $config; return $config['is_dev']; } -function isCli(): bool { return PHP_SAPI == 'cli'; }; -function isRetina(): bool { return isset($_COOKIE['is_retina']) && $_COOKIE['is_retina']; } - -function isAdmin(): bool { - if (admin::getId() === null) - admin::check(); - return admin::getId() != 0; -} - function jsonEncode($obj): ?string { return json_encode($obj, JSON_UNESCAPED_UNICODE) ?: null; } function jsonDecode($json) { return json_decode($json, true); } @@ -350,7 +304,7 @@ function highlightSubstring(string $s, string|array|null $keywords = []): string return $buf; } -function formatTime($ts, array $opts = array()) { +function formatTime($ts, array $opts = []) { $default_opts = [ 'date_only' => false, 'day_of_week' => false, diff --git a/handlers/AdminHandler.php b/src/handlers/foreignone/AdminHandler.php similarity index 65% rename from handlers/AdminHandler.php rename to src/handlers/foreignone/AdminHandler.php index 3a1ef23..50b783f 100644 --- a/handlers/AdminHandler.php +++ b/src/handlers/foreignone/AdminHandler.php @@ -1,58 +1,72 @@ skin->addStatic('css/admin.css', 'js/admin.js'); $this->skin->exportStrings(['error']); - $this->skin->setRenderOptions(['inside_admin_interface' => true]); + $this->skin->options->insideAdminInterface = true; } public function beforeDispatch(string $http_method, string $action) { if ($action != 'login' && !isAdmin()) - self::forbidden(); - } + throw new Forbidden(); + } - public function GET_index() { - //$admin_info = admin_current_info(); - $this->skin->setTitle('$admin_title'); - $this->skin->renderPage('admin_index.twig', [ - 'admin_login' => admin::getLogin(), - 'logout_token' => self::getCSRF('logout'), + public function GET_index(): Response { + $this->skin->title = lang('admin_title'); + return $this->skin->renderPage('admin_index.twig', [ + 'admin_login' => Admin::getLogin(), + 'logout_token' => $this->getCSRF('logout'), ]); } - public function GET_login() { + public function GET_login(): Response { if (isAdmin()) - self::redirect('/admin/'); - $this->skin->setTitle('$admin_title'); - $this->skin->renderPage('admin_login.twig', [ - 'form_token' => self::getCSRF('adminlogin'), + throw new Redirect('/admin/'); + $this->skin->title = lang('admin_title'); + return $this->skin->renderPage('admin_login.twig', [ + 'form_token' => $this->getCSRF('adminlogin'), ]); } - public function POST_login() { - self::checkCSRF('adminlogin'); + public function POST_login(): Response { + $this->checkCSRF('adminlogin'); list($login, $password) = $this->input('login, password'); - admin::auth($login, $password) - ? self::redirect('/admin/') - : self::forbidden(); + if (Admin::auth($login, $password)) + throw new PermanentRedirect('/admin/'); + throw new Forbidden(''); } - public function GET_logout() { - self::checkCSRF('logout'); - admin::logout(); - self::redirect('/admin/login/', HTTPCode::Found); + public function GET_logout(): Response { + $this->checkCSRF('logout'); + Admin::logout(); + throw new Redirect('/admin/login/'); } - public function GET_errors() { + public function GET_errors(): Response { list($ip, $query, $url_query, $file_query, $line_query, $per_page) = $this->input('i:ip, query, url_query, file_query, i:line_query, i:per_page'); if (!$per_page) $per_page = 100; - $db = DB(); + $db = getDB(); $query = trim($query ?? ''); $url_query = trim($url_query ?? ''); @@ -93,7 +107,7 @@ class AdminHandler extends request_handler { 'short_months' => true, ]); $row['full_url'] = !str_starts_with($row['url'], 'https://') ? 'https://'.$row['url'] : $row['url']; - $error_name = getPHPErrorName((int)$row['errno']); + $error_name = \engine\logging\Util::getPHPErrorName((int)$row['errno']); if (!is_null($error_name)) $row['errtype'] = $error_name; $list[] = $row; @@ -145,13 +159,13 @@ class AdminHandler extends request_handler { $vars += [$query_var_name => $$query_var_name]; } - $this->skin->setRenderOptions(['wide' => true]); - $this->skin->setTitle('$admin_errors'); - $this->skin->renderPage('admin_errors.twig', $vars); + $this->skin->options->wide = true; + $this->skin->title = lang('admin_errors'); + return $this->skin->renderPage('admin_errors.twig', $vars); } - public function GET_auth_log() { - $db = DB(); + public function GET_auth_log(): Response { + $db = getDB(); $count = (int)$db->result($db->query("SELECT COUNT(*) FROM admin_log")); $per_page = 100; list($page, $pages, $offset) = $this->getPage($per_page, $count); @@ -174,18 +188,18 @@ class AdminHandler extends request_handler { }, $list); } - $this->skin->setRenderOptions(['wide' => true]); - $this->skin->setTitle('$admin_auth_log'); + $this->skin->options->wide = true; + $this->skin->title = lang('admin_auth_log'); $this->skin->set([ 'list' => $list, 'pn_page' => $page, 'pn_pages' => $pages ]); - $this->skin->renderPage('admin_auth_log.twig'); + return $this->skin->renderPage('admin_auth_log.twig'); } - public function GET_actions_log() { - $field_types = \AdminActions\Util\Logger::getFieldTypes(); + public function GET_actions_log(): Response { + $field_types = \app\AdminActions\Util\Logger::getFieldTypes(); foreach ($field_types as $type_prefix => $type_data) { for ($i = 1; $i <= $type_data['count']; $i++) { $name = $type_prefix.'arg'.$i; @@ -196,13 +210,13 @@ class AdminHandler extends request_handler { $per_page = 100; - $count = \AdminActions\Util\Logger::getRecordsCount(); + $count = \app\AdminActions\Util\Logger::getRecordsCount(); list($page, $pages, $offset) = $this->getPage($per_page, $count); $admin_ids = []; $admin_logins = []; - $records = \AdminActions\Util\Logger::getRecords($offset, $per_page); + $records = \app\AdminActions\Util\Logger::getRecords($offset, $per_page); foreach ($records as $record) { list($admin_id) = $record->getActorInfo(); if ($admin_id !== null) @@ -210,7 +224,7 @@ class AdminHandler extends request_handler { } if (!empty($admin_ids)) - $admin_logins = admin::getLoginsById(array_keys($admin_ids)); + $admin_logins = Admin::getLoginsById(array_keys($admin_ids)); $url = '/admin/actions-log/?'; @@ -222,37 +236,37 @@ class AdminHandler extends request_handler { } } - $this->skin->setRenderOptions(['wide' => true]); - $this->skin->setTitle('$admin_actions_log'); - $this->skin->renderPage('admin_actions_log.twig', [ + $this->skin->options->wide = true; + $this->skin->title = lang('admin_actions_log'); + return $this->skin->renderPage('admin_actions_log.twig', [ 'list' => $records, 'pn_page' => $page, 'pn_pages' => $pages, 'admin_logins' => $admin_logins, 'url' => $url, - 'action_types' => \AdminActions\Util\Logger::getActions(true), + 'action_types' => \app\AdminActions\Util\Logger::getActions(true), ]); } - public function GET_uploads() { + public function GET_uploads(): Response { list($error) = $this->input('error'); - $uploads = uploads::getAllUploads(); + $uploads = Upload::getAllUploads(); - $this->skin->setTitle('$blog_upload'); - $this->skin->renderPage('admin_uploads.twig', [ + $this->skin->title = lang('blog_upload'); + return $this->skin->renderPage('admin_uploads.twig', [ 'error' => $error, 'uploads' => $uploads, 'langs' => PostLanguage::casesAsStrings(), - 'form_token' => self::getCSRF('add_upload'), + 'form_token' => $this->getCSRF('add_upload'), ]); } - public function POST_uploads() { - self::checkCSRF('add_upload'); + public function POST_uploads(): Response { + $this->checkCSRF('add_upload'); list($custom_name, $note_en, $note_ru) = $this->input('name, note_en, note_ru'); if (!isset($_FILES['files'])) - self::redirect('/admin/uploads/?error='.urlencode('no file')); + throw new Redirect('/admin/uploads/?error='.urlencode('no file')); $files = []; for ($i = 0; $i < count($_FILES['files']['name']); $i++) { @@ -273,56 +287,56 @@ class AdminHandler extends request_handler { foreach ($files as $f) { if ($f['error']) - self::redirect('/admin/uploads/?error='.urlencode('error code '.$f['error'])); + throw new Redirect('/admin/uploads/?error='.urlencode('error code '.$f['error'])); if (!$f['size']) - self::redirect('/admin/uploads/?error='.urlencode('received empty file')); + throw new Redirect('/admin/uploads/?error='.urlencode('received empty file')); $ext = extension($f['name']); - if (!uploads::isExtensionAllowed($ext)) - self::redirect('/admin/uploads/?error='.urlencode('extension not allowed')); + if (!Upload::isExtensionAllowed($ext)) + throw new Redirect('/admin/uploads/?error='.urlencode('extension not allowed')); $name = $custom_name ?: $f['name']; - $upload_id = uploads::add( + $upload_id = Upload::add( $f['tmp_name'], $name, $note_en, $note_ru); if (!$upload_id) - self::redirect('/admin/uploads/?error='.urlencode('failed to create upload')); + throw new Redirect('/admin/uploads/?error='.urlencode('failed to create upload')); - admin::log(new \AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru)); + Admin::logAction(new \app\AdminActions\UploadsAdd($upload_id, $name, $note_en, $note_ru)); } - self::redirect('/admin/uploads/'); + throw new Redirect('/admin/uploads/'); } - public function GET_upload_delete() { + public function GET_upload_delete(): Response { list($id) = $this->input('i:id'); - $upload = uploads::get($id); + $upload = Upload::get($id); if (!$upload) - self::redirect('/admin/uploads/?error='.urlencode('upload not found')); - self::checkCSRF('delupl'.$id); - uploads::delete($id); - admin::log(new \AdminActions\UploadsDelete($id)); - self::redirect('/admin/uploads/'); + throw new Redirect('/admin/uploads/?error='.urlencode('upload not found')); + $this->checkCSRF('delupl'.$id); + Upload::delete($id); + Admin::logAction(new \app\AdminActions\UploadsDelete($id)); + throw new Redirect('/admin/uploads/'); } - public function POST_upload_edit_note() { + public function POST_upload_edit_note(): Response { list($id, $note, $lang) = $this->input('i:id, note, lang'); $lang = PostLanguage::tryFrom($lang); if (!$lang) - self::notFound(); + throw new NotFound(); - $upload = uploads::get($id); + $upload = Upload::get($id); if (!$upload) - self::redirect('/admin/uploads/?error='.urlencode('upload not found')); + throw new Redirect('/admin/uploads/?error='.urlencode('upload not found')); - self::checkCSRF('editupl'.$id); + $this->checkCSRF('editupl'.$id); $upload->setNote($lang, $note); - $texts = posts::getTextsWithUpload($upload); + $texts = PostText::getTextsWithUpload($upload); if (!empty($texts)) { foreach ($texts as $text) { $text->updateHtml(); @@ -330,12 +344,12 @@ class AdminHandler extends request_handler { } } - admin::log(new \AdminActions\UploadsEditNote($id, $note, $lang->value)); - self::redirect('/admin/uploads/'); + Admin::logAction(new \app\AdminActions\UploadsEditNote($id, $note, $lang->value)); + throw new Redirect('/admin/uploads/'); } - public function POST_ajax_md_preview() { - self::ensureXhr(); + public function POST_ajax_md_preview(): Response { + $this->ensureIsXHR(); list($md, $title, $use_image_previews, $lang, $is_page) = $this->input('md, title, b:use_image_previews, lang, b:is_page'); $lang = PostLanguage::tryFrom($lang); if (!$lang) @@ -344,32 +358,33 @@ class AdminHandler extends request_handler { $md = '# '.$title."\n\n".$md; $title = ''; } - $html = markup::markdownToHtml($md, $use_image_previews, $lang); + $html = MarkupUtil::markdownToHtml($md, $use_image_previews, $lang); $html = $this->skin->render('markdown_preview.twig', [ 'unsafe_html' => $html, 'title' => $title ]); - self::ajaxOk(['html' => $html]); + return new AjaxOk(['html' => $html]); } - public function GET_page_add() { + public function GET_page_add(): Response { list($name) = $this->input('short_name'); - $page = pages::getByName($name); + $page = Page::getByName($name); if ($page) - self::redirect($page->getUrl(), code: HTTPCode::Found); + throw new Redirect($page->getUrl()); + $this->skin->exportStrings('/^(err_)?pages_/'); $this->skin->exportStrings('/^(err_)?blog_/'); - $this->skin->setTitle(lang('pages_create_title', $name)); + $this->skin->title = lang('pages_create_title', $name); $this->setWidePageOptions(); $js_params = [ 'pages' => true, 'edit' => false, - 'token' => self::getCSRF('addpage'), + 'token' => $this->getCSRF('addpage'), 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing ]; - $this->skin->renderPage('admin_page_form.twig', [ + return $this->skin->renderPage('admin_page_form.twig', [ 'is_edit' => false, 'short_name' => $name, 'title' => '', @@ -380,13 +395,13 @@ class AdminHandler extends request_handler { ]); } - public function POST_page_add() { - self::checkCSRF('addpage'); + public function POST_page_add(): Response { + $this->checkCSRF('addpage'); list($name, $text, $title) = $this->input('short_name, text, title'); - $page = pages::getByName($name); + $page = Page::getByName($name); if ($page) - self::notFound(); + throw new NotFound(); $error_code = null; @@ -397,56 +412,52 @@ class AdminHandler extends request_handler { } if ($error_code) - self::ajaxError(['code' => $error_code]); + return new AjaxError(['code' => $error_code]); - if (!pages::add([ + if (!Page::add([ 'short_name' => $name, 'title' => $title, 'md' => $text ])) { - self::ajaxError(['code' => 'db_err']); + return new AjaxError(['code' => 'db_err']); } - admin::log(new \AdminActions\PageCreate($name)); + Admin::logAction(new \app\AdminActions\PageCreate($name)); - $page = pages::getByName($name); - self::ajaxOk(['url' => $page->getUrl()]); + $page = Page::getByName($name); + return new AjaxOk(['url' => $page->getUrl()]); } - public function GET_page_delete() { + public function GET_page_delete(): Response { list($name) = $this->input('short_name'); - $page = pages::getByName($name); + $page = Page::getByName($name); if (!$page) - self::notFound(); + throw new NotFound(); $url = $page->getUrl(); - self::checkCSRF('delpage'.$page->shortName); - pages::delete($page); - admin::log(new \AdminActions\PageDelete($name)); - self::redirect($url, code: HTTPCode::Found); + $this->checkCSRF('delpage'.$page->shortName); + Page::delete($page); + Admin::logAction(new \app\AdminActions\PageDelete($name)); + throw new Redirect($url); } - public function GET_page_edit() { + public function GET_page_edit(): Response { list($short_name, $saved) = $this->input('short_name, b:saved'); - $page = pages::getByName($short_name); + $page = Page::getByName($short_name); if (!$page) - self::notFound(); + throw new NotFound(); $this->skin->exportStrings('/^(err_)?pages_/'); $this->skin->exportStrings('/^(err_)?blog_/'); - $this->skin->setTitle(lang('pages_page_edit_title', $page->shortName)); + $this->skin->title = lang('pages_page_edit_title', $page->shortName); $this->setWidePageOptions(); - $js_text = [ - 'text' => $page->md, - 'title' => $page->title, - ]; - + $parent = ''; if ($page->parentId) { - $parent_page = pages::getById($page->parentId); + $parent_page = Page::getById($page->parentId); if ($parent_page) $parent = $parent_page->shortName; } @@ -454,7 +465,7 @@ class AdminHandler extends request_handler { $js_params = [ 'pages' => true, 'edit' => true, - 'token' => self::getCSRF('editpage'.$short_name), + 'token' => $this->getCSRF('editpage'.$short_name), 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), // still needed for draft erasing 'text' => [ 'text' => $page->md, @@ -462,7 +473,7 @@ class AdminHandler extends request_handler { ] ]; - $this->skin->renderPage('admin_page_form.twig', [ + return $this->skin->renderPage('admin_page_form.twig', [ 'is_edit' => true, 'short_name' => $page->shortName, 'title' => $page->title, @@ -476,15 +487,15 @@ class AdminHandler extends request_handler { ]); } - public function POST_page_edit() { - self::ensureXhr(); + public function POST_page_edit(): Response { + $this->ensureIsXHR(); list($short_name) = $this->input('short_name'); - $page = pages::getByName($short_name); + $page = Page::getByName($short_name); if (!$page) - self::notFound(); + throw new NotFound(); - self::checkCSRF('editpage'.$page->shortName); + $this->checkCSRF('editpage'.$page->shortName); list($text, $title, $visible, $short_name, $parent, $render_title) = $this->input('text, title, b:visible, new_short_name, parent, b:render_title'); @@ -502,13 +513,13 @@ class AdminHandler extends request_handler { } if ($error_code) - self::ajaxError(['code' => $error_code]); + return new AjaxError(['code' => $error_code]); $new_short_name = $page->shortName != $short_name ? $short_name : null; - $parent_page = pages::getByName($parent); + $parent_page = Page::getByName($parent); $parent_id = $parent_page ? $parent_page->id : 0; - previous_texts::add(PreviousText::TYPE_PAGE, $page->get_id(), $page->md, $page->updateTs ?: $page->ts); + PreviousText::add(PreviousText::TYPE_PAGE, $page->get_id(), $page->md, $page->updateTs ?: $page->ts); $page->edit([ 'title' => $title, 'md' => $text, @@ -518,13 +529,13 @@ class AdminHandler extends request_handler { 'parent_id' => $parent_id ]); - admin::log(new \AdminActions\PageEdit($short_name, $new_short_name)); - self::ajaxOk(['url' => $page->getUrl().'edit/?saved=1']); + Admin::logAction(new \app\AdminActions\PageEdit($short_name, $new_short_name)); + return new AjaxOk(['url' => $page->getUrl().'edit/?saved=1']); } - public function GET_post_add() { + public function GET_post_add(): Response { $this->skin->exportStrings('/^(err_)?blog_/'); - $this->skin->setTitle('$blog_write'); + $this->skin->title = lang('blog_write'); $this->setWidePageOptions(); $js_texts = []; @@ -539,7 +550,7 @@ class AdminHandler extends request_handler { $js_params = [ 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), - 'token' => self::getCSRF('post_add') + 'token' => $this->getCSRF('post_add') ]; $form_url = '/articles/write/'; @@ -548,8 +559,7 @@ class AdminHandler extends request_handler { ['text' => lang('blog_new_post')] ]; - $this->skin->renderPage('admin_post_form.twig', [ - // form data + return $this->skin->renderPage('admin_post_form.twig', [ 'title' => '', 'text' => '', 'short_name' => '', @@ -565,19 +575,23 @@ class AdminHandler extends request_handler { ]); } - public function POST_post_add() { - self::ensureXhr(); - self::checkCSRF('post_add'); + public function POST_post_add(): Response { + $this->ensureIsXHR(); + $this->checkCSRF('post_add'); list($visibility_enabled, $short_name, $langs, $date) = $this->input('b:visible, short_name, langs, date'); - self::_postEditValidateCommonData($date); + try { + self::_postEditValidateCommonData($date); + } catch (ParseFormException $e) { + return new AjaxError(['code' => $e->getCode()]); + } $lang_data = []; $at_least_one_lang_is_written = false; foreach (PostLanguage::cases() as $lang) { - list($title, $text, $keywords, $toc_enabled) = $this->input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", ['trim' => true]); + list($title, $text, $keywords, $toc_enabled) = $this->input("title:{$lang->value}, text:{$lang->value}, keywords:{$lang->value}, b:toc:{$lang->value}", trim: true); if ($title !== '' && $text !== '') { $lang_data[$lang->value] = [$title, $text, $keywords, $toc_enabled]; $at_least_one_lang_is_written = true; @@ -591,9 +605,9 @@ class AdminHandler extends request_handler { $error_code = 'no_short_name'; } if ($error_code) - self::ajaxError(['code' => $error_code]); + return new AjaxError(['code' => $error_code]); - $post = posts::add([ + $post = Post::add([ 'visible' => $visibility_enabled, 'short_name' => $short_name, 'date' => $date, @@ -601,7 +615,7 @@ class AdminHandler extends request_handler { ]); if (!$post) - self::ajaxError(['code' => 'db_err', 'message' => 'failed to add post']); + return new AjaxError(['code' => 'db_err', 'message' => 'failed to add post']); // add texts $added_texts = []; // for admin actions logging, at the end @@ -614,48 +628,48 @@ class AdminHandler extends request_handler { keywords: $keywords, toc: $toc_enabled)) ) { - posts::delete($post); - self::ajaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]); + Post::delete($post); + return new AjaxError(['code' => 'db_err', 'message' => 'failed to add text language '.$lang]); } else { $added_texts[] = [$new_post_text->id, $lang]; } } - admin::log(new \AdminActions\PostCreate($post->id)); + Admin::logAction(new \app\AdminActions\PostCreate($post->id)); foreach ($added_texts as $added_text) { list($id, $lang) = $added_text; - admin::log(new \AdminActions\PostTextCreate($id, $post->id, $lang)); + Admin::logAction(new \app\AdminActions\PostTextCreate($id, $post->id, $lang)); } // done - self::ajaxOk(['url' => $post->getUrl()]); + return new AjaxOk(['url' => $post->getUrl()]); } - public function GET_post_delete() { + public function GET_post_delete(): Response { list($name) = $this->input('short_name'); - $post = posts::getByName($name); + $post = Post::getByName($name); if (!$post) - self::notFound(); + throw new NotFound(); $id = $post->id; - self::checkCSRF('delpost'.$id); - posts::delete($post); - admin::log(new \AdminActions\PostDelete($id)); - self::redirect('/articles/', code: HTTPCode::Found); + $this->checkCSRF('delpost'.$id); + Post::delete($post); + Admin::logAction(new \app\AdminActions\PostDelete($id)); + throw new Redirect('/articles/'); } - public function GET_post_edit() { + public function GET_post_edit(): Response { list($short_name, $saved, $lang) = $this->input('short_name, b:saved, lang'); $lang = PostLanguage::from($lang); - $post = posts::getByName($short_name); + $post = Post::getByName($short_name); if (!$post) - self::notFound(); + throw new NotFound(); $texts = $post->getTexts(); if (!isset($texts[$lang->value])) - self::notFound(); + throw new NotFound(); $js_texts = []; foreach (PostLanguage::cases() as $pl) { @@ -681,7 +695,7 @@ class AdminHandler extends request_handler { $this->skin->exportStrings('/^(err_)?blog_/'); $this->skin->exportStrings(['blog_post_edit_title']); - $this->skin->setTitle(lang('blog_post_edit_title', $text->title)); + $this->skin->title = lang('blog_post_edit_title', $text->title); $this->setWidePageOptions(); $bc = [ @@ -691,14 +705,14 @@ class AdminHandler extends request_handler { $js_params = [ 'langs' => array_map(fn($lang) => $lang->value, PostLanguage::cases()), - 'token' => self::getCSRF('editpost'.$post->id), + 'token' => $this->getCSRF('editpost'.$post->id), 'edit' => true, 'id' => $post->id, 'texts' => $js_texts ]; $form_url = $post->getUrl().'edit/'; - $this->skin->renderPage('admin_post_form.twig', [ + return $this->skin->renderPage('admin_post_form.twig', [ 'is_edit' => true, 'post' => $post, 'title' => $text->title, @@ -718,21 +732,24 @@ class AdminHandler extends request_handler { ]); } - public function POST_post_edit() { - self::ensureXhr(); - + public function POST_post_edit(): Response { + $this->ensureIsXHR(); list($old_short_name, $short_name, $langs, $date, $source_url) = $this->input('short_name, new_short_name, langs, date, source_url'); - $post = posts::getByName($old_short_name); + $post = Post::getByName($old_short_name); if (!$post) - self::notFound(); + throw new NotFound(); - self::checkCSRF('editpost'.$post->id); + $this->checkCSRF('editpost'.$post->id); - self::_postEditValidateCommonData($date); + try { + self::_postEditValidateCommonData($date); + } catch (ParseFormException $e) { + return new AjaxError(['code' => $e->getCode()]); + } if (empty($short_name)) - self::ajaxError(['code' => 'no_short_name']); + return new AjaxError(['code' => 'no_short_name']); foreach (explode(',', $langs) as $lang) { $lang = PostLanguage::from($lang); @@ -744,7 +761,7 @@ class AdminHandler extends request_handler { else if (!$text) $error_code = 'no_text'; if ($error_code) - self::ajaxError(['code' => $error_code]); + return new AjaxError(['code' => $error_code]); $pt = $post->getText($lang); if (!$pt) { @@ -756,9 +773,9 @@ class AdminHandler extends request_handler { toc: $toc ); if (!$pt) - self::ajaxError(['code' => 'db_err']); + return new AjaxError(['code' => 'db_err']); } else { - previous_texts::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp()); + PreviousText::add(PreviousText::TYPE_POST_TEXT, $pt->id, $pt->md, $post->getUpdateTimestamp() ?: $post->getTimestamp()); $pt->edit([ 'title' => $title, 'md' => $text, @@ -777,27 +794,24 @@ class AdminHandler extends request_handler { $post_data['short_name'] = $short_name; $post->edit($post_data); - admin::log(new \AdminActions\PostEdit($post->id)); - self::ajaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]); + Admin::logAction(new \app\AdminActions\PostEdit($post->id)); + return new AjaxOk(['url' => $post->getUrl().'edit/?saved=1&lang='.$lang->value]); } - public function GET_books() { - $this->skin->setTitle('$admin_books'); - $this->skin->renderPage('admin_books.twig'); + public function GET_books(): Response { + $this->skin->title = lang('admin_books'); + return $this->skin->renderPage('admin_books.twig'); } protected static function _postEditValidateCommonData($date) { $dt = DateTime::createFromFormat("Y-m-d", $date); $date_is_valid = $dt && $dt->format("Y-m-d") === $date; if (!$date_is_valid) - self::ajaxError(['code' => 'no_date']); + throw new ParseFormException(code: 'no_date'); } protected function setWidePageOptions(): void { - $this->skin->setRenderOptions([ - 'full_width' => true, - 'no_footer' => true - ]); + $this->skin->options->fullWidth = true; + $this->skin->options->noFooter = true; } - } \ No newline at end of file diff --git a/src/handlers/foreignone/BaseHandler.php b/src/handlers/foreignone/BaseHandler.php new file mode 100644 index 0000000..29b9710 --- /dev/null +++ b/src/handlers/foreignone/BaseHandler.php @@ -0,0 +1,25 @@ +skin = new ForeignOneSkin(); + $this->skin->strings->load('main'); + $this->skin->addStatic( + 'css/common.css', + 'js/common.js' + ); + $this->skin->setGlobal([ + 'is_admin' => isAdmin(), + 'is_dev' => isDev() + ]); + } +} \ No newline at end of file diff --git a/src/handlers/foreignone/FilesHandler.php b/src/handlers/foreignone/FilesHandler.php new file mode 100644 index 0000000..02f1fa7 --- /dev/null +++ b/src/handlers/foreignone/FilesHandler.php @@ -0,0 +1,216 @@ + new Archive($c), ArchiveType::cases()); + $books = Book::getList(section: SectionType::BOOKS_AND_ARTICLES); + $misc = Book::getList(section: SectionType::MISC); + + $this->skin->meta->title = lang('meta_files_title'); + $this->skin->meta->description = lang('meta_files_description'); + $this->skin->title = lang('files'); + $this->skin->options->headSection = 'files'; + return $this->skin->renderPage('files_index.twig', [ + 'collections' => $collections, + 'books' => $books, + 'misc' => $misc + ]); + } + + public function GET_folder(): Response { + list($folder_id) = $this->input('i:folder_id'); + + $parents = Book::getFolder($folder_id, with_parents: true); + if (!$parents) + throw new NotFound(); + + if (count($parents) > 1) + $parents = array_reverse($parents); + + $folder = $parents[count($parents)-1]; + $files = Book::getList($folder->section, $folder_id); + + $bc = [ + ['text' => lang('files'), 'url' => '/files/'], + ]; + if ($parents) { + for ($i = 0; $i < count($parents)-1; $i++) { + $parent = $parents[$i]; + $bc_item = ['text' => $parent->getTitle()]; + if ($i < count($parents)-1) + $bc_item['url'] = $parent->getUrl(); + $bc[] = $bc_item; + } + } + $bc[] = ['text' => $folder->title]; + + $this->skin->meta->title = lang('meta_files_book_folder_title', $folder->getTitle()); + $this->skin->meta->description = lang('meta_files_book_folder_description', $folder->getTitle()); + $this->skin->title = lang('files').' - '.$folder->title; + return $this->skin->renderPage('files_folder.twig', [ + 'folder' => $folder, + 'bc' => $bc, + 'files' => $files + ]); + } + + public function GET_collection(): Response { + list($archive, $folder_id, $query, $offset) = $this->input('collection, i:folder_id, q, i:offset'); + $archive = ArchiveType::from($archive); + $parents = null; + + $query = trim($query); + if (!$query) + $query = null; + + $this->skin->exportStrings('/^files_(.*?)_collection$/'); + $this->skin->exportStrings([ + 'files_search_results_count' + ]); + + $vars = []; + $text_excerpts = null; + $func_prefix = $archive->value; + + if ($query !== null) { + $files = FilesUtil::searchArchive($archive, $query, $offset, self::SEARCH_RESULTS_PER_PAGE); + $vars += [ + 'search_count' => $files['count'], + 'search_query' => $query + ]; + + $files = $files['items']; + $query_words = array_map('mb_strtolower', preg_split('/\s+/', $query)); + $found = []; + $result_ids = []; + foreach ($files as $file) { + if ($file->type == 'folder') + continue; + $result_ids[] = $file->id; + $candidates = []; + switch ($archive) { + case ArchiveType::MercureDeFrance: + $candidates = [ + $file->date, + (string)$file->issue + ]; + break; + case ArchiveType::WilliamFriedman: + $candidates = [ + mb_strtolower($file->getTitle()), + strtolower($file->documentId) + ]; + break; + } + foreach ($candidates as $haystack) { + foreach ($query_words as $qw) { + if (mb_strpos($haystack, $qw) !== false) { + $found[$file->id] = true; + continue 2; + } + } + } + } + + $found = array_map('intval', array_keys($found)); + $not_found = array_diff($result_ids, $found); + if (!empty($not_found)) + $text_excerpts = FilesUtil::getTextExcerpts($archive, $not_found, $query_words); + + if (isXHRRequest()) { + return new AjaxOk( + [ + ...$vars, + 'new_offset' => $offset + count($files), + 'html' => $this->skin->render('files_list.twig', [ + 'files' => $files, + 'search_query' => $query, + 'text_excerpts' => $text_excerpts + ]) + ] + ); + } + } else { + if (in_array($archive, [ArchiveType::WilliamFriedman, ArchiveType::Baconiana]) && $folder_id) { + $parents = $archive->getFolderGetter()($folder_id, with_parents: true); + if (!$parents) + throw new NotFound(); + if (count($parents) > 1) + $parents = array_reverse($parents); + } + $files = $archive->getListGetter()($folder_id); + } + + $title = lang('files_'.$archive->value.'_collection'); + if ($folder_id && $parents) + $title .= ' - '.htmlescape($parents[count($parents)-1]->getTitle()); + if ($query) + $title .= ' - '.htmlescape($query); + $this->skin->title = $title; + + if (!$folder_id && !$query) { + $this->skin->meta->title = lang('4in1').' - '.lang('meta_files_collection_title', lang('files_'.$archive->value.'_collection')); + $this->skin->meta->description = lang('meta_files_'.$archive->value.'_description'); + } else if ($query || $parents) { + $this->skin->meta->title = lang('4in1').' - '.$title; + $this->skin->meta->description = lang('meta_files_'.($query ? 'search' : 'folder').'_description', + $query ?: $parents[count($parents)-1]->getTitle(), + lang('files_'.$archive->value.'_collection')); + } + + $bc = [ + ['text' => lang('files'), 'url' => '/files/'], + ]; + if ($parents) { + $bc[] = ['text' => lang('files_'.$archive->value.'_collection_short'), 'url' => "/files/{$archive->value}/"]; + for ($i = 0; $i < count($parents); $i++) { + $parent = $parents[$i]; + $bc_item = ['text' => $parent->getTitle()]; + if ($i < count($parents)-1) + $bc_item['url'] = $parent->getUrl(); + $bc[] = $bc_item; + } + } else { + $bc[] = ['text' => lang('files_'.$archive->value.'_collection')]; + } + + $js_params = [ + 'container' => 'files_list', + 'per_page' => self::SEARCH_RESULTS_PER_PAGE, + 'min_query_length' => self::SEARCH_MIN_QUERY_LENGTH, + 'base_url' => "/files/{$archive->value}/", + 'query' => $vars['search_query'] ?? '', + 'count' => $vars['search_count'] ?? 0, + 'collection_name' => $archive->value, + 'inited_with_search' => !!($vars['search_query'] ?? "") + ]; + + $this->skin->set($vars); + $this->skin->set([ + 'collection' => $archive->value, + 'files' => $files, + 'bc' => $bc, + 'do_show_search' => empty($parents), + 'do_show_more' => ($vars['search_count'] ?? 0) > 0 && count($files) < ($vars['search_count'] ?? 0), + 'text_excerpts' => $text_excerpts, + 'js_params' => $js_params, + ]); + return $this->skin->renderPage('files_collection.twig'); + } +} \ No newline at end of file diff --git a/handlers/MainHandler.php b/src/handlers/foreignone/MainHandler.php similarity index 52% rename from handlers/MainHandler.php rename to src/handlers/foreignone/MainHandler.php index 48aaea8..7964ad3 100644 --- a/handlers/MainHandler.php +++ b/src/handlers/foreignone/MainHandler.php @@ -1,66 +1,70 @@ skin->addMeta([ - 'og:type' => 'website', - '@url' => 'https://'.$config['domain'].'/', - '@title' => lang('meta_index_title'), - '@description' => lang('meta_index_description'), - '@image' => 'https://'.$config['domain'].'/img/4in1-preview.jpg' - ]); - $this->skin->setTitle('$site_title'); + $this->skin->meta->title = lang('meta_index_title'); + $this->skin->meta->description = lang('meta_index_description'); + $this->skin->meta->url = 'https://'.$config['domain'].'/'; + $this->skin->meta->image = 'https://'.$config['domain'].'/img/4in1-preview.jpg'; + $this->skin->meta->setSocial('og:type', 'website'); + + $this->skin->options->isIndex = true; + $this->skin->set([ 'posts' => $posts, 'posts_lang' => $posts_lang, 'versions' => $config['book_versions'] ]); - $this->skin->setRenderOptions(['is_index' => true]); - $this->skin->renderPage('index.twig'); + return $this->skin->renderPage('index.twig'); } - public function GET_about() { self::redirect('/info/'); } - public function GET_contacts() { self::redirect('/info/'); } + public function GET_about() { throw new PermanentRedirect('/info/'); } + public function GET_contacts() { throw new PermanentRedirect('/info/'); } - public function GET_page() { + public function GET_page(): Response { global $config; list($name) = $this->input('name'); - $page = pages::getByName($name); + $page = Page::getByName($name); if (!$page) { if (isAdmin()) { - $this->skin->setTitle($name); - $this->skin->renderPage('admin_page_new.twig', [ + $this->skin->title = $name; + return $this->skin->renderPage('admin_page_new.twig', [ 'short_name' => $name ]); } - self::notFound(); + throw new NotFound(); } if (!isAdmin() && !$page->visible) - self::notFound(); + throw new NotFound(); $bc = null; - $render_opts = []; if ($page) { - $this->skin->addMeta([ - '@url' => 'https://'.$config['domain'].$page->getUrl(), - '@title' => $page->title, - ]); + $this->skin->meta->url = 'https://'.$config['domain'].$page->getUrl(); + $this->skin->meta->title = $page->title; if ($page->parentId) { $bc = []; $parent = $page; while ($parent?->parentId) { - $parent = pages::getById($parent->parentId); + $parent = Page::getById($parent->parentId); if ($parent) $bc[] = ['url' => $parent->getUrl(), 'text' => $parent->title]; } @@ -69,22 +73,21 @@ class MainHandler extends request_handler { } if ($page->shortName == 'info') - $render_opts = ['head_section' => 'about']; + $this->skin->options->headSection = 'about'; else if ($page->shortName == $config['wiki_root']) - $render_opts = ['head_section' => $page->shortName]; + $this->skin->options->headSection = $page->shortName; } - $this->skin->setRenderOptions($render_opts); - $this->skin->setTitle($page ? $page->title : '???'); - $this->skin->renderPage('page.twig', [ + $this->skin->title = $page ? $page->title : '???'; + return $this->skin->renderPage('page.twig', [ 'page' => $page, - 'html' => $page->getHtml(isRetina(), themes::getUserTheme()), + 'html' => $page->getHtml(isRetina(), ThemesUtil::getUserTheme()), 'bc' => $bc, - 'delete_token' => self::getCSRF('delpage'.$page->shortName) + 'delete_token' => $this->getCSRF('delpage'.$page->shortName) ]); } - public function GET_post() { + public function GET_post(): Response { global $config; list($name, $input_lang) = $this->input('name, lang'); @@ -92,23 +95,23 @@ class MainHandler extends request_handler { try { if ($input_lang) $lang = PostLanguage::from($input_lang); - } catch (ValueError $e) { - self::notFound($e->getMessage()); + } catch (\ValueError $e) { + throw new NotFound($e->getMessage()); } if (!$lang) $lang = PostLanguage::getDefault(); - $post = posts::getByName($name); + $post = Post::getByName($name); if (!$post || (!$post->visible && !isAdmin())) - self::notFound(); + throw new NotFound(); if ($lang == PostLanguage::getDefault() && $input_lang == $lang->value) - self::redirect($post->getUrl()); + throw new PermanentRedirect($post->getUrl()); if (!$post->hasLang($lang)) - self::notFound('no text for language '.$lang->name); + throw new NotFound('no text for language '.$lang->name); if (!$post->visible && !isAdmin()) - self::notFound(); + throw new NotFound(); $pt = $post->getText($lang); @@ -120,26 +123,24 @@ class MainHandler extends request_handler { $other_langs[] = $pl->value; } - $meta = [ - '@title' => $pt->title, - '@url' => $config['domain'].$post->getUrl(), - '@description' => $pt->getDescriptionPreview(155) - ]; + $this->skin->meta->title = $pt->title; + $this->skin->meta->description = $pt->getDescriptionPreview(155); + $this->skin->meta->url = $config['domain'].$post->getUrl(); if ($pt->keywords) - $meta['@keywords'] = $pt->keywords; - $this->skin->addMeta($meta); + $this->skin->meta->keywords = $pt->keywords; if (($img = $pt->getFirstImage()) !== null) - $this->skin->addMeta(['@image' => $img->getDirectUrl()]); + $this->skin->meta->image = $img->getDirectUrl(); - $this->skin->setTitle($pt->title); - $this->skin->setRenderOptions(['articles_lang' => $lang->value, 'wide' => $pt->hasTableOfContents()]); - $this->skin->renderPage('post.twig', [ + $this->skin->title = $pt->title; + $this->skin->options->articlesLang = $lang->value; + $this->skin->options->wide = $pt->hasTableOfContents(); + return $this->skin->renderPage('post.twig', [ 'post' => $post, 'pt' => $pt, - 'html' => $pt->getHtml(isRetina(), themes::getUserTheme()), + 'html' => $pt->getHtml(isRetina(), ThemesUtil::getUserTheme()), 'selected_lang' => $lang->value, 'other_langs' => $other_langs, - 'delete_token' => self::getCSRF('delpost'.$post->id) + 'delete_token' => $this->getCSRF('delpost'.$post->id) ]); } @@ -158,7 +159,7 @@ class MainHandler extends request_handler { 'pub_date' => date(DATE_RSS, $post->getTimestamp()), 'description' => $pt->getDescriptionPreview(500) ]; - }, posts::getList(0, 20, filter_by_lang: $lang)); + }, Post::getList(0, 20, filter_by_lang: $lang)); $body = $this->skin->render('rss.twig', [ 'title' => lang('site_title'), @@ -172,29 +173,26 @@ class MainHandler extends request_handler { exit; } - public function GET_articles() { + public function GET_articles(): Response { list($lang) = $this->input('lang'); if ($lang) { $lang = PostLanguage::tryFrom($lang); if (!$lang || $lang == PostLanguage::getDefault()) - self::redirect('/articles/'); + throw new PermanentRedirect('/articles/'); } else { $lang = PostLanguage::getDefault(); } - $posts = posts::getList( + $posts = Post::getList( include_hidden: isAdmin(), filter_by_lang: $lang); - $this->skin->setTitle('$articles'); - $this->skin->addMeta([ - '@description' => lang('blog_expl_'.$lang->value) - ]); - $this->skin->setRenderOptions(['head_section' => 'articles']); - $this->skin->renderPage('articles.twig', [ + $this->skin->title = lang('articles'); + $this->skin->meta->description = lang('blog_expl_'.$lang->value); + $this->skin->options->headSection = 'articles'; + return $this->skin->renderPage('articles.twig', [ 'posts' => $posts, 'selected_lang' => $lang->value ]); } - } \ No newline at end of file diff --git a/src/handlers/foreignone/ServicesHandler.php b/src/handlers/foreignone/ServicesHandler.php new file mode 100644 index 0000000..382f161 --- /dev/null +++ b/src/handlers/foreignone/ServicesHandler.php @@ -0,0 +1,28 @@ +input('lang'); + if (!isset($config['book_versions'][$lang])) + throw new NotFound(); + throw new Redirect("https://files.4in1.ws/4in1-{$lang}.pdf?{$config['book_versions'][$lang]}"); + } +} \ No newline at end of file diff --git a/src/handlers/ic/BaseHandler.php b/src/handlers/ic/BaseHandler.php new file mode 100644 index 0000000..80de7e6 --- /dev/null +++ b/src/handlers/ic/BaseHandler.php @@ -0,0 +1,25 @@ +skin = new InvisibleCollegeSkin(); + // $this->skin->strings->load('ic'); + $this->skin->addStatic( + 'css/common.css', + 'js/common.js' + ); + $this->skin->setGlobal([ + 'is_admin' => isAdmin(), + 'is_dev' => isDev() + ]); + } +} \ No newline at end of file diff --git a/src/handlers/ic/MainHandler.php b/src/handlers/ic/MainHandler.php new file mode 100644 index 0000000..f2d42c3 --- /dev/null +++ b/src/handlers/ic/MainHandler.php @@ -0,0 +1,13 @@ +skin->render('soon.twig')); + } +} \ No newline at end of file diff --git a/init.php b/src/init.php similarity index 53% rename from init.php rename to src/init.php index fb3ce41..40b782a 100644 --- a/init.php +++ b/src/init.php @@ -8,31 +8,15 @@ date_default_timezone_set('Europe/Moscow'); mb_internal_encoding('UTF-8'); mb_regex_encoding('UTF-8'); -const APP_ROOT = __DIR__; +define('APP_ROOT', dirname(__DIR__)); define('START_TIME', microtime(true)); set_include_path(get_include_path().PATH_SEPARATOR.APP_ROOT); -spl_autoload_register(function($class) { - if (str_contains($class, '\\')) - $class = str_replace('\\', '/', $class); - - if ($class == 'model') - $path = 'engine/model'; - else if (str_ends_with($class, 'Handler')) - $path = 'handlers/'.$class; - else - $path = 'lib/'.$class; - - if (!is_file(APP_ROOT.'/'.$path.'.php')) - return; - - require_once APP_ROOT.'/'.$path.'.php'; -}); - if (!file_exists(APP_ROOT.'/config.yaml')) die('Fatal: config.yaml not found'); +global $config; $config = yaml_parse_file(APP_ROOT.'/config.yaml'); if ($config === false) die('Fatal: failed to parse config.yaml'); @@ -41,48 +25,45 @@ if ($config === false) umask($config['umask']); require_once 'functions.php'; -require_once 'engine/mysql.php'; -require_once 'engine/router.php'; -require_once 'engine/request.php'; -require_once 'engine/logging.php'; +require_once 'engine_functions.php'; +require_once 'vendor/autoload.php'; + +global $globalContext; +$globalContext = engine\GlobalContext::getInstance(); try { if (isCli()) { - verifyHostname($config['domain']); + if (str_ends_with(__DIR__, 'www-dev')) + $globalContext->setIsDevelopmentEnvironment(true); $_SERVER['HTTP_HOST'] = $config['domain']; $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; } else { - verifyHostname(); + // IE moment + if (($pos = strpos($_SERVER['HTTP_HOST'], ':')) !== false) + $_SERVER['HTTP_HOST'] = substr($_SERVER['HTTP_HOST'], 0, $pos); + $_SERVER['HTTP_HOST'] = strtolower($_SERVER['HTTP_HOST']); if (array_key_exists('HTTP_X_REAL_IP', $_SERVER)) $_SERVER['REMOTE_ADDR'] = $_SERVER['HTTP_X_REAL_IP']; - - require_once 'engine/strings.php'; - require_once 'engine/skin.php'; - require_once 'lib/admin.php'; + $globalContext->setIsDevelopmentEnvironment(str_ends_with(dirname($_SERVER['DOCUMENT_ROOT']), 'www-dev')); } } catch (RuntimeException $e) { die('Fatal error: '.$e->getMessage()); } -$__logger = isDev() - ? new FileLogger(APP_ROOT.'/log/debug.log') - : new DatabaseLogger(); -$__logger->enable(); +$logger = isDev() + ? new engine\logging\FileLogger(APP_ROOT.'/log/debug.log') + : new engine\logging\DatabaseLogger(); +$logger->enable(); +$globalContext->setLogger($logger); +unset($logger); if (!isDev()) { if (file_exists(APP_ROOT.'/config-static.php')) $config['static'] = require_once 'config-static.php'; else - die('confic-static.php not found'); + die('config-static.php not found'); // turn off errors output on production domains error_reporting(0); ini_set('display_errors', 0); } - -if (!isCli()) { - $__lang = Strings::getInstance(); - $__lang->load('main'); -} - -require 'vendor/autoload.php'; diff --git a/lib/admin.php b/src/lib/Admin.php similarity index 89% rename from lib/admin.php rename to src/lib/Admin.php index e3e2c7b..440a5d5 100644 --- a/lib/admin.php +++ b/src/lib/Admin.php @@ -1,7 +1,9 @@ result($db->query("SELECT COUNT(*) FROM admins WHERE login=? LIMIT 1", $login)) > 0; } public static function add(string $login, string $password): int { - $db = DB(); + $db = getDB(); $db->insert('admins', [ 'login' => $login, 'password' => saltPassword($password), @@ -28,7 +30,7 @@ class admin { } public static function delete(string $login): bool { - $db = DB(); + $db = getDB(); $id = self::getIdByLogin($login); if (!$db->query("DELETE FROM admins WHERE login=?", $login)) return false; if (!$db->query("DELETE FROM admin_auth WHERE admin_id=?", $id)) return false; @@ -40,7 +42,7 @@ class admin { * @return string[] */ public static function getLoginsById(array $ids): array { - $db = DB(); + $db = getDB(); $logins = []; $q = $db->query("SELECT id, login FROM admins WHERE id IN (".implode(',', $ids).")"); while ($row = $db->fetch($q)) { @@ -50,19 +52,19 @@ class admin { } protected static function getIdByLogin(string $login): ?int { - $db = DB(); + $db = getDB(); $q = $db->query("SELECT id FROM admins WHERE login=?", $login); return $db->numRows($q) > 0 ? (int)$db->result($q) : null; } public static function setPassword(string $login, string $password): bool { - $db = DB(); + $db = getDB(); $db->query("UPDATE admins SET password=? WHERE login=?", saltPassword($password), $login); return $db->affectedRows() > 0; } public static function auth(string $login, string $password): bool { - $db = DB(); + $db = getDB(); $salted_password = saltPassword($password); $q = $db->query("SELECT id, active FROM admins WHERE login=? AND password=?", $login, $salted_password); if (!$db->numRows($q)) { @@ -108,15 +110,15 @@ class admin { if (!isAdmin()) return; - $db = DB(); + $db = getDB(); $db->query("DELETE FROM admin_auth WHERE id=?", self::$authId); self::unsetSessionData(); self::unsetCookie(); } - public static function log(\AdminActions\BaseAction $action) { - \AdminActions\Util\Logger::record($action); + public static function logAction(\app\AdminActions\BaseAction $action) { + \app\AdminActions\Util\Logger::record($action); } public static function check(): void { @@ -124,7 +126,7 @@ class admin { return; $cookie = (string)$_COOKIE[self::ADMIN_COOKIE_NAME]; - $db = DB(); + $db = getDB(); $time = time(); $q = $db->query("SELECT admin_auth.id AS auth_id, @@ -173,8 +175,15 @@ class admin { self::$login = null; } - public static function getId(): ?int { return self::$id; } - public static function getCSRFSalt(): ?string { return self::$csrfSalt; } - public static function getLogin(): ?string { return self::$login; } + public static function getId(): ?int { + return self::$id; + } + public static function getCSRFSalt(): ?string { + return self::$csrfSalt; + } + + public static function getLogin(): ?string { + return self::$login; + } } diff --git a/lib/AdminActions/BaseAction.php b/src/lib/AdminActions/BaseAction.php similarity index 97% rename from lib/AdminActions/BaseAction.php rename to src/lib/AdminActions/BaseAction.php index b2f0072..66308e8 100644 --- a/lib/AdminActions/BaseAction.php +++ b/src/lib/AdminActions/BaseAction.php @@ -1,9 +1,9 @@ \admin::getId(), + 'admin_id' => \app\Admin::getId(), 'ip' => !empty($_SERVER['REMOTE_ADDR']) ? ip2ulong($_SERVER['REMOTE_ADDR']) : 0, ]; } @@ -48,14 +47,14 @@ class Logger { } } - $db = DB(); + $db = getDB(); $db->insert(self::TABLE, $data); return $db->insertId(); } public static function getRecordById(int $id): ?BaseAction { - $db = DB(); + $db = getDB(); $q = $db->query("SELECT * FROM ".self::TABLE." WHERE id=?", $id); if (!$db->numRows($q)) return null; @@ -65,7 +64,7 @@ class Logger { public static function getRecordsCount(?array $admin_types = null, ?array $actions = null, ?array $arguments = null): int { - $db = DB(); + $db = getDB(); $sql = "SELECT COUNT(*) FROM ".self::TABLE; $where = self::getSQLSelectConditions($admin_types, $actions, $arguments); if ($where != '') @@ -87,7 +86,7 @@ class Logger { ?array $admin_types = null, ?array $actions = null, ?array $arguments = null): array { - $db = DB(); + $db = getDB(); $sql = "SELECT * FROM ".self::TABLE; $where = self::getSQLSelectConditions($admin_types, $actions, $arguments); if ($where != '') @@ -104,7 +103,7 @@ class Logger { * @return BaseAction[] */ public static function getUserRecords(int $user_id, ?int $time_from, ?int $time_to): array { - $db = DB(); + $db = getDB(); $sql = "SELECT * FROM ".self::TABLE." WHERE admin_id={$user_id}"; if ($time_from && $time_to) $sql .= " AND ts BETWEEN {$time_from} AND {$time_to} "; @@ -116,15 +115,15 @@ class Logger { ?array $actions = null, ?array $arguments = null): string { $wheres = []; - $db = DB(); + $db = getDB(); if (!empty($admin_types)) $wheres[] = "admin_type IN ('".implode("', '", $admin_types)."')"; if (!empty($actions)) { $actions = array_map( - /** @var BaseAction|int $action */ - fn($action) => is_string($action) ? $action::getActionId() : $action, $actions); + fn(BaseAction|int $action) => $action instanceof BaseAction ? $action::getActionId() : $action, + $actions); $wheres[] = "action IN (".implode(',', $actions).")"; } @@ -249,7 +248,7 @@ class Logger { continue; $class_name = substr($f, 0, strpos($f, '.')); - $class = '\\AdminActions\\'.$class_name; + $class = '\\app\\AdminActions\\'.$class_name; if (interface_exists($class) || !class_exists($class)) { // logError(__METHOD__.': class '.$class.' not found'); @@ -314,5 +313,4 @@ class Logger { } return $types; } - } diff --git a/lib/cli.php b/src/lib/CliUtil.php similarity index 92% rename from lib/cli.php rename to src/lib/CliUtil.php index 8df2203..1cb887c 100644 --- a/lib/cli.php +++ b/src/lib/CliUtil.php @@ -1,7 +1,9 @@ usage(); if (empty($this->commands)) - cli::die("no commands added"); + CliUtil::die("no commands added"); $func = $argv[1]; if (!isset($this->commands[$func])) @@ -64,5 +66,4 @@ class cli { public static function error($error): void { fwrite(STDERR, "error: {$error}\n"); } - } \ No newline at end of file diff --git a/lib/markup.php b/src/lib/MarkupUtil.php similarity index 67% rename from lib/markup.php rename to src/lib/MarkupUtil.php index 29b73dd..765bfe1 100644 --- a/lib/markup.php +++ b/src/lib/MarkupUtil.php @@ -1,11 +1,15 @@ text($md); @@ -25,15 +29,15 @@ class markup { $html = preg_replace($re, '

'.$span_opening_tag.'$1 $3

', $html); $re = '/'.implode('|', array_map(fn($m) => '(?:'.$span_opening_tag.')?'.preg_quote($m, '/'), $matches[1])).'/'; $html = preg_replace_callback($re, - function($match) use ($span_opening_tag, $reftitles_map) { - if (str_starts_with($match[0], $span_opening_tag)) - return $match[0]; - if (!preg_match('/\[([io]?\d{1,2})]/', $match[0], $refmatch)) - return $match[0]; - $refname = $refmatch[1]; - $reftitle = $reftitles_map[$refname]; - return ''.$match[0].''; - }, $html); + function ($match) use ($span_opening_tag, $reftitles_map) { + if (str_starts_with($match[0], $span_opening_tag)) + return $match[0]; + if (!preg_match('/\[([io]?\d{1,2})]/', $match[0], $refmatch)) + return $match[0]; + $refname = $refmatch[1]; + $reftitle = $reftitles_map[$refname]; + return ''.$match[0].''; + }, $html); } } @@ -67,13 +71,12 @@ class markup { $is_dark_theme = $user_theme === 'dark'; return preg_replace_callback( '/('.preg_quote($config['uploads_path'], '/').'\/\w{8}\/)([ap])(\d+)x(\d+)(\.jpg)/', - function($match) use ($is_retina, $is_dark_theme) { + function ($match) use ($is_retina, $is_dark_theme) { $mult = $is_retina ? 2 : 1; $is_alpha = $match[2] == 'a'; - return $match[1].$match[2].(intval($match[3])*$mult).'x'.(intval($match[4])*$mult).($is_alpha && $is_dark_theme ? '_dark' : '').$match[5]; + return $match[1].$match[2].(intval($match[3]) * $mult).'x'.(intval($match[4]) * $mult).($is_alpha && $is_dark_theme ? '_dark' : '').$match[5]; }, $html ); } - } \ No newline at end of file diff --git a/lib/MyParsedown.php b/src/lib/MyParsedown.php similarity index 87% rename from lib/MyParsedown.php rename to src/lib/MyParsedown.php index 4b5160c..0ec942c 100644 --- a/lib/MyParsedown.php +++ b/src/lib/MyParsedown.php @@ -1,14 +1,19 @@ strlen($matches[0]), 'element' => [ @@ -46,7 +52,7 @@ class MyParsedown extends ParsedownExtended { unset($result['element']['text']); - $result['element']['rawHtml'] = skin::getInstance()->render('markdown_fileupload.twig', [ + $result['element']['rawHtml'] = $globalContext->getSkin()->render('markdown_fileupload.twig', [ 'name' => $upload->name, 'direct_url' => $upload->getDirectUrl(), 'note' => $upload->noteRu, @@ -58,6 +64,7 @@ class MyParsedown extends ParsedownExtended { } protected function inlineImage($excerpt) { + global $globalContext; if (preg_match('/^{image:([\w]{8}),(.*?)}{\/image}/', $excerpt['text'], $matches)) { $random_id = $matches[1]; @@ -82,7 +89,7 @@ class MyParsedown extends ParsedownExtended { } } - $image = uploads::getUploadByRandomId($random_id); + $image = Upload::getUploadByRandomId($random_id); $result = [ 'extent' => strlen($matches[0]), 'element' => [ @@ -110,7 +117,7 @@ class MyParsedown extends ParsedownExtended { unset($result['element']['text']); - $result['element']['rawHtml'] = skin::getInstance()->render('markdown_image.twig', [ + $result['element']['rawHtml'] = $globalContext->getSkin()->render('markdown_image.twig', [ 'w' => $opts['w'], 'nolabel' => $opts['nolabel'], 'align' => $opts['align'], @@ -119,7 +126,7 @@ class MyParsedown extends ParsedownExtended { 'url' => $image_url, 'direct_url' => $image->getDirectUrl(), - 'unsafe_note' => markup::markdownToHtml( + 'unsafe_note' => MarkupUtil::markdownToHtml( md: $this->lang !== null && $this->lang == PostLanguage::Russian ? $image->noteRu : $image->noteEn, no_paragraph: true), ]); @@ -129,6 +136,7 @@ class MyParsedown extends ParsedownExtended { } protected function inlineVideo($excerpt) { + global $globalContext; if (preg_match('/^{video:([\w]{8})(?:,(.*?))?}{\/video}/', $excerpt['text'], $matches)) { $random_id = $matches[1]; @@ -151,7 +159,7 @@ class MyParsedown extends ParsedownExtended { } } - $video = uploads::getUploadByRandomId($random_id); + $video = Upload::getUploadByRandomId($random_id); $result = [ 'extent' => strlen($matches[0]), 'element' => [ @@ -169,7 +177,7 @@ class MyParsedown extends ParsedownExtended { unset($result['element']['text']); - $result['element']['rawHtml'] = skin::getInstance()->render('markdown_video.twig', [ + $result['element']['rawHtml'] = $globalContext->getSkin()->render('markdown_video.twig', [ 'url' => $video_url, 'w' => $opts['w'], 'h' => $opts['h'] @@ -219,5 +227,4 @@ class MyParsedown extends ParsedownExtended { return parent::blockFencedCodeComplete($block); } - } diff --git a/lib/themes.php b/src/lib/ThemesUtil.php similarity index 97% rename from lib/themes.php rename to src/lib/ThemesUtil.php index 07a025a..c535ad8 100644 --- a/lib/themes.php +++ b/src/lib/ThemesUtil.php @@ -1,7 +1,9 @@ [ 'bg' => 0x222222, @@ -46,5 +48,4 @@ class themes { public static function isUserSystemThemeDark(): bool { return ($_COOKIE['theme-system-value'] ?? '') === 'dark'; } - } \ No newline at end of file diff --git a/src/lib/foreignone/ForeignOneSkin.php b/src/lib/foreignone/ForeignOneSkin.php new file mode 100644 index 0000000..8a30ac0 --- /dev/null +++ b/src/lib/foreignone/ForeignOneSkin.php @@ -0,0 +1,103 @@ +title) && $this->title ? $this->title : lang('site_title'); + if (!$this->options->isIndex) + $title = $title.' - '.lang('4in1'); + return $title; + } + + set(string $title) { + $this->title = $title; + } + } + + public function __construct() { + parent::__construct(); + $this->options = new ForeignOneSkinOptions(); + } + + protected function getTwigLoader(): LoaderInterface { + $twig_loader = new FilesystemLoader(APP_ROOT.'/src/skins/foreignone', APP_ROOT); + // $twig_loader->addPath(APP_ROOT.'/htdocs/svg', 'svg'); + return $twig_loader; + } + + public function renderPage(string $template, array $vars = []): Response { + $this->exportStrings(['4in1']); + $this->applyGlobals(); + + // render body first + $b = $this->renderBody($template, $vars); + + // then everything else + $h = $this->renderHeader(); + $f = $this->renderFooter(); + + return new HtmlResponse($h.$b.$f); + } + + protected function renderHeader(): string { + global $config; + + $body_class = []; + if ($this->options->fullWidth) + $body_class[] = 'full-width'; + else if ($this->options->wide) + $body_class[] = 'wide'; + + $vars = [ + 'title' => $this->title, + 'meta_html' => $this->meta->getHtml(), + 'static_html' => $this->getHeaderStaticTags(), + 'svg_html' => $this->getSVGTags(), + 'render_options' => $this->options->getOptions(), + 'app_config' => [ + 'domain' => $config['domain'], + 'devMode' => $config['is_dev'], + 'cookieHost' => $config['cookie_host'], + ], + 'body_class' => $body_class, + 'theme' => ThemesUtil::getUserTheme(), + ]; + + return $this->doRender('header.twig', $vars); + } + + protected function renderBody(string $template, array $vars): string { + return $this->doRender($template, $this->vars + $vars); + } + + protected function renderFooter(): string { + global $config; + + $exec_time = microtime(true) - START_TIME; + $exec_time = round($exec_time, 4); + + $footer_vars = [ + 'exec_time' => $exec_time, + 'render_options' => $this->options->getOptions(), + 'admin_email' => $config['admin_email'], + // 'lang_json' => json_encode($this->getLangKeys(), JSON_UNESCAPED_UNICODE), + // 'static_config' => $this->getStaticConfig(), + 'script_html' => $this->getFooterScriptTags(), + 'this_page_url' => $_SERVER['REQUEST_URI'], + 'theme' => ThemesUtil::getUserTheme(), + ]; + return $this->doRender('footer.twig', $footer_vars); + } +} \ No newline at end of file diff --git a/src/lib/foreignone/ForeignOneSkinOptions.php b/src/lib/foreignone/ForeignOneSkinOptions.php new file mode 100644 index 0000000..175ac45 --- /dev/null +++ b/src/lib/foreignone/ForeignOneSkinOptions.php @@ -0,0 +1,15 @@ +md || $fields['render_title'] != $this->renderTitle || $fields['title'] != $this->title) { + $md = $fields['md']; + if ($fields['render_title']) + $md = '# '.$fields['title']."\n\n".$md; + $fields['html'] = MarkupUtil::markdownToHtml($md); + } + parent::edit($fields); + } + + public function isUpdated(): bool { + return $this->updateTs && $this->updateTs != $this->ts; + } + + public function getHtml(bool $is_retina, string $user_theme): string { + return MarkupUtil::htmlImagesFix($this->html, $is_retina, $user_theme); + } + + public function getUrl(): string { + return "/{$this->shortName}/"; + } + + public function updateHtml(): void { + $html = MarkupUtil::markdownToHtml($this->md); + $this->html = $html; + getDB()->query("UPDATE pages SET html=? WHERE short_name=?", $html, $this->shortName); + } + + + /** + * Static methods + */ + + public static function add(array $data): bool { + $db = getDB(); + $data['ts'] = time(); + $data['html'] = MarkupUtil::markdownToHtml($data['md']); + if (!$db->insert('pages', $data)) + return false; + return true; + } + + public static function delete(Page $page): void { + getDB()->query("DELETE FROM pages WHERE short_name=?", $page->shortName); + PreviousText::delete(PreviousText::TYPE_PAGE, $page->get_id()); + } + + public static function getById(int $id): ?Page { + $db = getDB(); + $q = $db->query("SELECT * FROM pages WHERE id=?", $id); + return $db->numRows($q) ? new Page($db->fetch($q)) : null; + } + + public static function getByName(string $short_name): ?Page { + $db = getDB(); + $q = $db->query("SELECT * FROM pages WHERE short_name=?", $short_name); + return $db->numRows($q) ? new Page($db->fetch($q)) : null; + } + + /** + * @return Page[] + */ + public static function getAll(): array { + $db = getDB(); + return array_map(static::create_instance(...), $db->fetchAll($db->query("SELECT * FROM pages"))); + } +} diff --git a/src/lib/foreignone/Post.php b/src/lib/foreignone/Post.php new file mode 100644 index 0000000..8ab84ab --- /dev/null +++ b/src/lib/foreignone/Post.php @@ -0,0 +1,217 @@ + $title, + 'lang' => $lang->value, + 'post_id' => $this->id, + 'html' => $html, + 'text' => $text, + 'md' => $md, + 'toc' => $toc, + 'keywords' => $keywords, + ]; + + $db = getDB(); + if (!$db->insert('posts_texts', $data)) + return null; + + $id = $db->insertId(); + + $post_text = PostText::get($id); + $post_text->updateImagePreviews(); + + return $post_text; + } + + public function registerText(PostText $postText): void { + if (array_key_exists($postText->lang->value, $this->texts)) + throw new Exception("text for language {$postText->lang->value} has already been registered"); + $this->texts[$postText->lang->value] = $postText; + } + + public function loadTexts() { + if (!empty($this->texts)) + return; + $db = getDB(); + $q = $db->query("SELECT * FROM posts_texts WHERE post_id=?", $this->id); + while ($row = $db->fetch($q)) { + $text = new PostText($row); + $this->registerText($text); + } + } + + /** + * @return PostText[] + */ + public function getTexts(): array { + $this->loadTexts(); + return $this->texts; + } + + public function getText(PostLanguage|string $lang): ?PostText { + if (is_string($lang)) + $lang = PostLanguage::from($lang); + $this->loadTexts(); + return $this->texts[$lang->value] ?? null; + } + + public function hasLang(PostLanguage $lang) { + $this->loadTexts(); + return array_any($this->texts, fn($text) => $text->lang == $lang); + } + + public function hasSourceUrl(): bool { + return $this->sourceUrl != ''; + } + + public function getUrl(PostLanguage|string|null $lang = null): string { + $buf = $this->shortName != '' ? "/articles/{$this->shortName}/" : "/articles/{$this->id}/"; + if ($lang) { + if (is_string($lang)) + $lang = PostLanguage::from($lang); + if ($lang != PostLanguage::English) + $buf .= '?lang='.$lang->value; + } + return $buf; + } + + public function getTimestamp(): int { + return new DateTime($this->date)->getTimestamp(); + } + + public function getUpdateTimestamp(): ?int { + if (!$this->updateTime) + return null; + return new DateTime($this->updateTime)->getTimestamp(); + } + + public function getDate(): string { + return date('j M', $this->getTimestamp()); + } + + public function getYear(): int { + return (int)date('Y', $this->getTimestamp()); + } + + public function getFullDate(): string { + return date('j F Y', $this->getTimestamp()); + } + + public function getDateForInputField(): string { + return date('Y-m-d', $this->getTimestamp()); + } + + + /** + * Static methods + */ + + public static function getCount(bool $include_hidden = false): int { + $db = getDB(); + $sql = "SELECT COUNT(*) FROM posts"; + if (!$include_hidden) { + $sql .= " WHERE visible=1"; + } + return (int)$db->result($db->query($sql)); + } + + /** + * @return Post[] + */ + public static function getList(int $offset = 0, + int $count = -1, + bool $include_hidden = false, + ?PostLanguage $filter_by_lang = null): array + { + $db = getDB(); + $sql = "SELECT * FROM posts"; + if (!$include_hidden) + $sql .= " WHERE visible=1"; + $sql .= " ORDER BY `date` DESC"; + if ($offset != 0 || $count != -1) + $sql .= " LIMIT $offset, $count"; + $q = $db->query($sql); + $posts = []; + while ($row = $db->fetch($q)) { + $posts[$row['id']] = $row; + } + + if (!empty($posts)) { + foreach ($posts as &$post) + $post = new Post($post); + $q = $db->query("SELECT * FROM posts_texts WHERE post_id IN (".implode(',', array_keys($posts)).")"); + while ($row = $db->fetch($q)) { + $posts[$row['post_id']]->registerText(new PostText($row)); + } + } + + if ($filter_by_lang !== null) + $posts = array_filter($posts, fn(Post $post) => $post->hasLang($filter_by_lang)); + + return array_values($posts); + } + + public static function add(array $data = []): ?Post { + $db = getDB(); + if (!$db->insert('posts', $data)) + return null; + return self::get($db->insertId()); + } + + public static function delete(Post $post): void { + $db = getDB(); + $db->query("DELETE FROM posts WHERE id=?", $post->id); + + $text_ids = []; + $q = $db->query("SELECT id FROM posts_texts WHERE post_id=?", $post->id); + while ($row = $db->fetch($q)) + $text_ids = $row['id']; + PreviousText::delete(PreviousText::TYPE_POST_TEXT, $text_ids); + + $db->query("DELETE FROM posts_texts WHERE post_id=?", $post->id); + } + + public static function get(int $id): ?Post { + $db = getDB(); + $q = $db->query("SELECT * FROM posts WHERE id=?", $id); + return $db->numRows($q) ? new Post($db->fetch($q)) : null; + } + + public static function getByName(string $short_name): ?Post { + $db = getDB(); + $q = $db->query("SELECT * FROM posts WHERE short_name=?", $short_name); + return $db->numRows($q) ? new Post($db->fetch($q)) : null; + } + +} \ No newline at end of file diff --git a/lib/PostLanguage.php b/src/lib/foreignone/PostLanguage.php similarity index 87% rename from lib/PostLanguage.php rename to src/lib/foreignone/PostLanguage.php index ac5b5e7..137baca 100644 --- a/lib/PostLanguage.php +++ b/src/lib/foreignone/PostLanguage.php @@ -1,7 +1,9 @@ $v->value, self::cases()); } - } \ No newline at end of file diff --git a/lib/PostText.php b/src/lib/foreignone/PostText.php similarity index 51% rename from lib/PostText.php rename to src/lib/foreignone/PostText.php index 1796188..f0c47c2 100644 --- a/lib/PostText.php +++ b/src/lib/foreignone/PostText.php @@ -1,6 +1,13 @@ md) { - $fields['html'] = markup::markdownToHtml($fields['md'], lang: $this->lang); - $fields['text'] = markup::htmlToText($fields['html']); + $fields['html'] = MarkupUtil::markdownToHtml($fields['md'], lang: $this->lang); + $fields['text'] = MarkupUtil::htmlToText($fields['html']); } if ((isset($fields['toc']) && $fields['toc']) || $this->toc) { - $fields['toc_html'] = markup::toc($fields['md']); + $fields['toc_html'] = MarkupUtil::toc($fields['md']); } parent::edit($fields); @@ -29,33 +36,33 @@ class PostText extends model { } public function updateHtml(): void { - $html = markup::markdownToHtml($this->md, lang: $this->lang); + $html = MarkupUtil::markdownToHtml($this->md, lang: $this->lang); $this->html = $html; - DB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id); + getDB()->query("UPDATE posts_texts SET html=? WHERE id=?", $html, $this->id); } public function updateText(): void { - $html = markup::markdownToHtml($this->md, lang: $this->lang); - $text = markup::htmlToText($html); + $html = MarkupUtil::markdownToHtml($this->md, lang: $this->lang); + $text = MarkupUtil::htmlToText($html); $this->text = $text; - DB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id); + getDB()->query("UPDATE posts_texts SET text=? WHERE id=?", $text, $this->id); } public function getDescriptionPreview(int $len): string { if (mb_strlen($this->text) >= $len) - return mb_substr($this->text, 0, $len-3).'...'; + return mb_substr($this->text, 0, $len - 3).'...'; return $this->text; } public function getFirstImage(): ?Upload { - if (!preg_match('/\{image:([\w]{8})/', $this->md, $match)) + if (!preg_match('/\{image:(\w{8})/', $this->md, $match)) return null; - return uploads::getUploadByRandomId($match[1]); + return Upload::getUploadByRandomId($match[1]); } public function getHtml(bool $is_retina, string $theme): string { $html = $this->html; - return markup::htmlImagesFix($html, $is_retina, $theme); + return MarkupUtil::htmlImagesFix($html, $is_retina, $theme); } public function getTableOfContentsHtml(): ?string { @@ -69,11 +76,11 @@ class PostText extends model { /** * @param bool $update Whether to overwrite preview if already exists * @return int - * @throws Exception + * @throws \Exception */ public function updateImagePreviews(bool $update = false): int { $images = []; - if (!preg_match_all('/\{image:([\w]{8}),(.*?)}/', $this->md, $matches)) + if (!preg_match_all('/\{image:(\w{8}),(.*?)}/', $this->md, $matches)) return 0; for ($i = 0; $i < count($matches[0]); $i++) { @@ -96,7 +103,7 @@ class PostText extends model { return 0; $images_affected = 0; - $uploads = uploads::getUploadsByRandomId(array_keys($images), true); + $uploads = Upload::getUploadsByRandomId(array_keys($images), true); foreach ($uploads as $upload_key => $u) { if ($u === null) { logError(__METHOD__.': upload '.$upload_key.' is null'); @@ -113,4 +120,52 @@ class PostText extends model { return $images_affected; } + + /** + * Static methods + */ + + public static function get(int $text_id): ?PostText { + $db = getDB(); + $q = $db->query("SELECT * FROM posts_texts WHERE id=?", $text_id); + return $db->numRows($q) ? new PostText($db->fetch($q)) : null; + } + + public static function getPostTextsById(array $ids, bool $flat = false): array { + if (empty($ids)) + return []; + + $db = getDB(); + $posts = array_fill_keys($ids, null); + + $q = $db->query("SELECT * FROM posts_texts WHERE id IN(".implode(',', $ids).")"); + + while ($row = $db->fetch($q)) { + $posts[(int)$row['id']] = new PostText($row); + } + + if ($flat) { + $list = []; + foreach ($ids as $id) { + $list[] = $posts[$id]; + } + unset($posts); + return $list; + } + + return $posts; + } + + /** + * @param Upload $upload + * @return PostText[] Array of PostTexts that includes specified upload + */ + public static function getTextsWithUpload(Upload $upload): array { + $db = getDB(); + $q = $db->query("SELECT id FROM posts_texts WHERE md LIKE '%{image:{$upload->randomId}%'"); + $ids = []; + while ($row = $db->fetch($q)) + $ids[] = (int)$row['id']; + return self::getPostTextsById($ids, true); + } } diff --git a/lib/previous_texts.php b/src/lib/foreignone/PreviousText.php similarity index 59% rename from lib/previous_texts.php rename to src/lib/foreignone/PreviousText.php index c4f22c8..f6c6c0a 100644 --- a/lib/previous_texts.php +++ b/src/lib/foreignone/PreviousText.php @@ -1,9 +1,30 @@ insert(PreviousText::DB_TABLE, [ 'object_type' => $object_type, 'object_id' => $object_id, @@ -21,7 +42,6 @@ class previous_texts { $sql .= '=?'; $args[] = $object_id; } - DB()->query($sql, ...$args); + getDB()->query($sql, ...$args); } - } \ No newline at end of file diff --git a/src/lib/foreignone/Upload.php b/src/lib/foreignone/Upload.php new file mode 100644 index 0000000..43a97ec --- /dev/null +++ b/src/lib/foreignone/Upload.php @@ -0,0 +1,329 @@ +randomId; + } + + public function getDirectUrl(): string { + global $config; + return $config['uploads_path'].'/'.$this->randomId.'/'.$this->name; + } + + public function getDirectPreviewUrl(int $w, int $h, bool $retina = false): string { + global $config; + if ($w == $this->imageW && $this->imageH == $h) + return $this->getDirectUrl(); + + if ($retina) { + $w *= 2; + $h *= 2; + } + + $prefix = $this->imageMayHaveAlphaChannel() ? 'a' : 'p'; + return $config['uploads_path'].'/'.$this->randomId.'/'.$prefix.$w.'x'.$h.'.jpg'; + } + + public function getSize(): string { + return sizeString($this->size); + } + + public function getMarkdown(?string $options = null): string { + if ($this->isImage()) { + $md = '{image:'.$this->randomId.',w='.$this->imageW.',h='.$this->imageH.($options ? ','.$options : '').'}{/image}'; + } else if ($this->isVideo()) { + $md = '{video:'.$this->randomId.($options ? ','.$options : '').'}{/video}'; + } else { + $md = '{fileAttach:'.$this->randomId.($options ? ','.$options : '').'}{/fileAttach}'; + } + $md .= ' '; + return $md; + } + + public function setNote(PostLanguage $lang, string $note) { + $db = getDB(); + $db->query("UPDATE uploads SET note_{$lang->value}=? WHERE id=?", $note, $this->id); + } + + public function isImage(): bool { + return in_array(extension($this->name), self::IMAGE_EXTENSIONS); + } + + // assume all png images have alpha channel + // i know this is wrong, but anyway + public function imageMayHaveAlphaChannel(): bool { + return strtolower(extension($this->name)) == 'png'; + } + + public function isVideo(): bool { + return in_array(extension($this->name), self::VIDEO_EXTENSIONS); + } + + public function getImageRatio(): float { + return $this->imageW / $this->imageH; + } + + public function getImagePreviewSize(?int $w = null, ?int $h = null): array { + if (is_null($w) && is_null($h)) + throw new \Exception(__METHOD__.': both width and height can\'t be null'); + + if (is_null($h)) + $h = round($w / $this->getImageRatio()); + + if (is_null($w)) + $w = round($h * $this->getImageRatio()); + + return [$w, $h]; + } + + public function createImagePreview(?int $w = null, + ?int $h = null, + bool $force_update = false, + bool $may_have_alpha = false): bool { + global $config; + + $orig = $config['uploads_dir'].'/'.$this->randomId.'/'.$this->name; + $updated = false; + + foreach (ThemesUtil::getThemes() as $theme) { + if (!$may_have_alpha && $theme == 'dark') + continue; + + for ($mult = 1; $mult <= 2; $mult++) { + $dw = $w * $mult; + $dh = $h * $mult; + + $prefix = $may_have_alpha ? 'a' : 'p'; + $dst = $config['uploads_dir'].'/'.$this->randomId.'/'.$prefix.$dw.'x'.$dh.($theme == 'dark' ? '_dark' : '').'.jpg'; + + if (file_exists($dst)) { + if (!$force_update) + continue; + unlink($dst); + } + + $img = imageopen($orig); + imageresize($img, $dw, $dh, ThemesUtil::getThemeAlphaColorAsRGB($theme)); + imagejpeg($img, $dst, $mult == 1 ? 93 : 67); + imagedestroy($img); + + setperm($dst); + $updated = true; + } + } + + return $updated; + } + + /** + * @return int Number of deleted files + */ + public function deleteAllImagePreviews(): int { + global $config; + $dir = $config['uploads_dir'].'/'.$this->randomId; + $files = scandir($dir); + $deleted = 0; + foreach ($files as $f) { + if (preg_match('/^[ap](\d+)x(\d+)(?:_dark)?\.jpg$/', $f)) { + if (is_file($dir.'/'.$f)) + unlink($dir.'/'.$f); + else + logError(__METHOD__.': '.$dir.'/'.$f.' is not a file!'); + $deleted++; + } + } + return $deleted; + } + + public function getJSONEncodedHtmlSafeNote(string $lang): string { + $value = $lang == 'en' ? $this->noteEn : $this->noteRu; + return jsonEncode(preg_replace('/(\r)?\n/', '\n', addslashes($value))); + } + + + /** + * Static methods + */ + + public static function getCount(): int { + $db = getDB(); + return (int)$db->result($db->query("SELECT COUNT(*) FROM uploads")); + } + + public static function isExtensionAllowed(string $ext): bool { + return in_array($ext, self::ALLOWED_EXTENSIONS); + } + + public static function add(string $tmp_name, + string $name, + string $note_en = '', + string $note_ru = '', + string $source_url = ''): ?int { + global $config; + + $name = sanitizeFilename($name); + if (!$name) + $name = 'file'; + + $random_id = self::getNewUploadRandomId(); + $size = filesize($tmp_name); + $is_image = detectImageType($tmp_name) !== false; + $image_w = 0; + $image_h = 0; + if ($is_image) { + list($image_w, $image_h) = getimagesize($tmp_name); + } + + $db = getDB(); + if (!$db->insert('uploads', [ + 'random_id' => $random_id, + 'ts' => time(), + 'name' => $name, + 'size' => $size, + 'image' => (int)$is_image, + 'image_w' => $image_w, + 'image_h' => $image_h, + 'note_ru' => $note_ru, + 'note_en' => $note_en, + 'downloads' => 0, + 'source_url' => $source_url, + ])) { + return null; + } + + $id = $db->insertId(); + + $dir = $config['uploads_dir'].'/'.$random_id; + $path = $dir.'/'.$name; + + mkdir($dir); + chmod($dir, 0775); // g+w + + rename($tmp_name, $path); + setperm($path); + + return $id; + } + + public static function delete(int $id): bool { + $upload = self::get($id); + if (!$upload) + return false; + + $db = getDB(); + $db->query("DELETE FROM uploads WHERE id=?", $id); + + rrmdir($upload->getDirectory()); + return true; + } + + /** + * @return Upload[] + */ + public static function getAllUploads(): array { + $db = getDB(); + $q = $db->query("SELECT * FROM uploads ORDER BY id DESC"); + return array_map(static::create_instance(...), $db->fetchAll($q)); + } + + public static function get(int $id): ?Upload { + $db = getDB(); + $q = $db->query("SELECT * FROM uploads WHERE id=?", $id); + if ($db->numRows($q)) { + return new Upload($db->fetch($q)); + } else { + return null; + } + } + + /** + * @param string[] $ids + * @param bool $flat + * @return Upload[] + */ + public static function getUploadsByRandomId(array $ids, bool $flat = false): array { + if (empty($ids)) { + return []; + } + + $db = getDB(); + $uploads = array_fill_keys($ids, null); + + $q = $db->query("SELECT * FROM uploads WHERE random_id IN('".implode('\',\'', array_map([$db, 'escape'], $ids))."')"); + + while ($row = $db->fetch($q)) { + $uploads[$row['random_id']] = new Upload($row); + } + + if ($flat) { + $list = []; + foreach ($ids as $id) { + $list[] = $uploads[$id]; + } + unset($uploads); + return $list; + } + + return $uploads; + } + + public static function getUploadByRandomId(string $random_id): ?Upload { + $db = getDB(); + $q = $db->query("SELECT * FROM uploads WHERE random_id=? LIMIT 1", $random_id); + if ($db->numRows($q)) { + return new Upload($db->fetch($q)); + } else { + return null; + } + } + + public static function getUploadBySourceUrl(string $source_url): ?Upload { + $db = getDB(); + $q = $db->query("SELECT * FROM uploads WHERE source_url=? LIMIT 1", $source_url); + if ($db->numRows($q)) { + return new Upload($db->fetch($q)); + } else { + return null; + } + } + + protected static function getNewUploadRandomId(): string { + $db = getDB(); + do { + $random_id = strgen(8); + } while ($db->numRows($db->query("SELECT id FROM uploads WHERE random_id=?", $random_id)) > 0); + return $random_id; + } +} \ No newline at end of file diff --git a/src/lib/foreignone/files/Archive.php b/src/lib/foreignone/files/Archive.php new file mode 100644 index 0000000..147c6c5 --- /dev/null +++ b/src/lib/foreignone/files/Archive.php @@ -0,0 +1,47 @@ +archiveType->value; + } + + public function getUrl(): string { + return '/files/'.$this->archiveType->value.'/'; + } + + public function getSize(): ?int { + return null; + } + + public function getTitle(): string { + return lang("files_{$this->archiveType->value}_collection"); + } + + public function getMeta(?string $hl_matched = null): array { + return []; + } + + public function isTargetBlank(): bool { + return false; + } + + public function getSubtitle(): ?string { + return null; + } + + public function getIcon(): string { + return 'folder'; + } +} diff --git a/src/lib/foreignone/files/ArchiveType.php b/src/lib/foreignone/files/ArchiveType.php new file mode 100644 index 0000000..9ea2de2 --- /dev/null +++ b/src/lib/foreignone/files/ArchiveType.php @@ -0,0 +1,49 @@ + 'wff_collection', + self::MercureDeFrance => 'mdf_archive', + self::Baconiana => 'baconiana_archive', + }; + } + + public function getMySQLData(): array { + return match ($this) { + self::WilliamFriedman => ['wff_texts', 'wff_id'], + self::MercureDeFrance => ['mdf_texts', 'mdf_id'], + self::Baconiana => ['baconiana_texts', 'bcn_id'], + }; + } + + public function getItemsByIdGetter(): callable { + return match ($this) { + self::WilliamFriedman => WFFArchiveFile::getItemsById(...), + self::MercureDeFrance => MDFIssue::getIssuesById(...), + self::Baconiana => BaconianaIssue::getIssuesById(...), + }; + } + + public function getFolderGetter(): callable { + return match ($this) { + ArchiveType::WilliamFriedman => WFFArchiveFile::getFolder(...), + ArchiveType::Baconiana => BaconianaIssue::getFolder(...), + }; + } + + public function getListGetter(): callable { + return match ($this) { + self::WilliamFriedman => WFFArchiveFile::getList(...), + self::MercureDeFrance => MDFIssue::getAll(...), + self::Baconiana => BaconianaIssue::getList(...), + }; + } +} diff --git a/src/lib/foreignone/files/BaconianaIssue.php b/src/lib/foreignone/files/BaconianaIssue.php new file mode 100644 index 0000000..1aa0785 --- /dev/null +++ b/src/lib/foreignone/files/BaconianaIssue.php @@ -0,0 +1,142 @@ +title !== '') + return $this->title; + + return ($this->jobc ? lang('baconiana_old_name') : lang('baconiana')).' №'.$this->issues; + } + + public function isTargetBlank(): bool { + return $this->type == 'file'; + } + + public function getId(): string { + return $this->id; + } + + public function getUrl(): string { + if ($this->type == 'folder') { + return '/files/'.ArchiveType::Baconiana->value.'/'.$this->id.'/'; + } + global $config; + return 'https://'.$config['files_domain'].'/'.$this->path; + } + + public function getMeta(?string $hl_matched = null): array { + $items = []; + if ($this->type == 'folder') + return $items; + + if ($this->year >= 2007) + $items = array_merge($items, ['Online Edition']); + + $items = array_merge($items, [ + sizeString($this->size), + 'PDF' + ]); + + return [ + 'inline' => false, + 'items' => $items + ]; + } + + public function getSubtitle(): ?string { + return $this->year > 0 ? '('.$this->year.')' : null; + } + + public function getSize(): ?int { + return $this->type == 'file' ? $this->size : null; + } + + public function getIcon(): string { + return $this->type; + } + + public function getFullText(): ?string { + $db = getDB(); + $q = $db->query("SELECT text FROM baconiana_texts WHERE bcn_id=?", $this->id); + if (!$db->numRows($q)) + return null; + return $db->result($q); + } + + + /** + * Static methods + */ + + /** + * @param int|null $parent_id + * @return BaconianaIssue[] + */ + public static function getList(?int $parent_id = 0): array { + $db = getDB(); + $sql = "SELECT * FROM baconiana_collection"; + if ($parent_id !== null) + $sql .= " WHERE parent_id='".$db->escape($parent_id)."'"; + $sql .= " ORDER BY type, year, id"; + $q = $db->query($sql); + return array_map(static::create_instance(...), $db->fetchAll($q)); + } + + /** + * @param int[] $ids + * @return BaconianaIssue[] + */ + public static function getIssuesById(array $ids): array { + $db = getDB(); + $q = $db->query("SELECT * FROM baconiana_collection WHERE id IN (".implode(',', $ids).")"); + return array_map(static::create_instance(...), $db->fetchAll($q)); + } + + /** + * @param int $folder_id + * @param bool $with_parents + * @return BaconianaIssue|BaconianaIssue[]|null + */ + public static function getFolder(int $folder_id, bool $with_parents = false): static|array|null { + $db = getDB(); + $q = $db->query("SELECT * FROM baconiana_collection WHERE id=?", $folder_id); + if (!$db->numRows($q)) + return null; + $item = new BaconianaIssue($db->fetch($q)); + if ($item->type != 'folder') + return null; + if ($with_parents) { + $items = [$item]; + if ($item->parentId) { + $parents = static::getFolder($item->parentId, with_parents: true); + if ($parents !== null) + $items = array_merge($items, $parents); + } + return $items; + } + return $item; + } +} diff --git a/src/lib/foreignone/files/Book.php b/src/lib/foreignone/files/Book.php new file mode 100644 index 0000000..d3532f7 --- /dev/null +++ b/src/lib/foreignone/files/Book.php @@ -0,0 +1,139 @@ +id; + } + + public function getUrl(): string { + if ($this->type == 'folder' && !$this->external) + return '/files/'.$this->id.'/'; + global $config; + $buf = 'https://'.$config['files_domain']; + if (!str_starts_with($this->path, '/')) + $buf .= '/'; + $buf .= $this->path; + return $buf; + } + + public function getTitleHtml(): ?string { + if ($this->type == 'folder' || !$this->author) + return null; + $buf = ''.htmlescape($this->author).''; + if (!str_ends_with($this->author, '.')) + $buf .= '.'; + $buf .= ' '.htmlescape($this->title).''; + return $buf; + } + + public function getTitle(): string { + return $this->title; + } + + public function getMeta(?string $hl_matched = null): array { + if ($this->type == 'folder') + return []; + + $items = [ + sizeString($this->size), + strtoupper($this->getExtension()) + ]; + + return [ + 'inline' => false, + 'items' => $items + ]; + } + + protected function getExtension(): string { + return extension(basename($this->path)); + } + + public function isTargetBlank(): bool { + return $this->type == 'file' || $this->external; + } + + public function getSubtitle(): ?string { + if (!$this->year && !$this->subtitle) + return null; + $buf = '('; + $buf .= $this->subtitle ?: $this->year; + $buf .= ')'; + return $buf; + } + + public function getSize(): ?int { + return $this->type == 'file' ? $this->size : null; + } + + public function getIcon(): string { + if ($this->fileType == 'book') + return 'book'; + return $this->type; + } + + + /** + * Static methods + */ + + /** + * @return Book[] + */ + public static function getList(SectionType $section, int $parent_id = 0): array + { + $db = getDB(); + $order_by = $section == SectionType::BOOKS_AND_ARTICLES + ? "type, ".($parent_id != 0 ? 'year, ': '')."author, title" + : "type, title"; + $q = $db->query("SELECT * FROM books WHERE category=? AND parent_id=? ORDER BY $order_by", + $section->value, $parent_id); + return array_map(static::create_instance(...), $db->fetchAll($q)); + } + + /** + * @param int $id + * @param bool $with_parents + * @return static[]|static|null + */ + public static function getFolder(int $id, bool $with_parents = false): static|array|null { + $db = getDB(); + $q = $db->query("SELECT * FROM books WHERE id=?", $id); + if (!$db->numRows($q)) + return null; + $item = new Book($db->fetch($q)); + if ($item->type != 'folder') + return null; + if ($with_parents) { + $items = [$item]; + if ($item->parentId) { + $parents = static::getFolder($item->parentId, with_parents: true); + if ($parents !== null) + $items = array_merge($items, $parents); + } + return $items; + } + return $item; + } +} diff --git a/lib/FilesItemInterface.php b/src/lib/foreignone/files/FileInterface.php similarity index 70% rename from lib/FilesItemInterface.php rename to src/lib/foreignone/files/FileInterface.php index 62e2421..ac3442c 100644 --- a/lib/FilesItemInterface.php +++ b/src/lib/foreignone/files/FileInterface.php @@ -1,15 +1,16 @@ issue}, {$this->getHumanFriendlyDate()}"; @@ -30,8 +35,14 @@ class MDFCollectionItem extends model implements FilesItemInterface { return $dt->format('j M Y'); } - public function isTargetBlank(): bool { return true; } - public function getId(): string { return $this->id; } + public function isTargetBlank(): bool { + return true; + } + + public function getId(): string { + return $this->id; + } + public function getUrl(): string { global $config; return 'https://'.$config['files_domain'].'/Mercure-de-France-OCR/'.$this->path; @@ -80,4 +91,43 @@ class MDFCollectionItem extends model implements FilesItemInterface { return null; //return 'Vol. '.$this->getRomanVolume().', pp. '.$this->pageFrom.'-'.$this->pageTo; } + + public function getSize(): ?int { + return $this->type == 'file' ? $this->size : null; + } + + public function getIcon(): string { + return $this->type; + } + + public function getFullText(): ?string { + if ($this->type != 'file') + return null; + $db = getDB(); + return $db->result($db->query("SELECT text FROM mdf_texts WHERE mdf_id=?", $this->id)); + } + + + /** + * Static methods + */ + + /** + * @return MDFIssue[] + */ + public static function getAll(): array { + $db = getDB(); + $q = $db->query("SELECT * FROM mdf_collection ORDER BY `date`"); + return array_map(static::create_instance(...), $db->fetchAll($q)); + } + + /** + * @param int[] $ids + * @return MDFIssue[] + */ + public static function getIssuesById(array $ids): array { + $db = getDB(); + $q = $db->query("SELECT * FROM mdf_collection WHERE id IN (".implode(',', $ids).")"); + return array_map(static::create_instance(...), $db->fetchAll($q)); + } } diff --git a/src/lib/foreignone/files/SectionType.php b/src/lib/foreignone/files/SectionType.php new file mode 100644 index 0000000..01d7f41 --- /dev/null +++ b/src/lib/foreignone/files/SectionType.php @@ -0,0 +1,10 @@ +getMySQLData(); + + $results = []; + foreach ($ids as $id) + $results[$id] = null; + + $db = getDB(); + + $dynamic_sql_parts = []; + $combined_parts = []; + foreach ($keywords as $keyword) { + $part = "LOCATE('".$db->escape($keyword)."', text)"; + $dynamic_sql_parts[] = $part; + } + if (count($dynamic_sql_parts) > 1) { + foreach ($dynamic_sql_parts as $part) + $combined_parts[] = "IF({$part} > 0, {$part}, CHAR_LENGTH(text) + 1)"; + $combined_parts = implode(', ', $combined_parts); + $combined_parts = 'LEAST('.$combined_parts.')'; + } else { + $combined_parts = "IF({$dynamic_sql_parts[0]} > 0, {$dynamic_sql_parts[0]}, CHAR_LENGTH(text) + 1)"; + } + + $total = $before + $after; + $sql = "SELECT + {$field_id} AS id, + GREATEST( + 1, + {$combined_parts} - {$before} + ) AS excerpt_start_index, + SUBSTRING( + text, + GREATEST( + 1, + {$combined_parts} - {$before} + ), + LEAST( + CHAR_LENGTH(text), + {$total} + {$combined_parts} - GREATEST(1, {$combined_parts} - {$before}) + ) + ) AS excerpt + FROM + {$table} + WHERE + {$field_id} IN (".implode(',', $ids).")"; + + $q = $db->query($sql); + while ($row = $db->fetch($q)) { + $results[$row['id']] = [ + 'excerpt' => preg_replace('/\s+/', ' ', $row['excerpt']), + 'index' => (int)$row['excerpt_start_index'] + ]; + } + + return $results; + } + + public static function searchArchive(ArchiveType $type, + string $q, + int $offset, + int $count): array + { + $index = $type->getSphinxIndex(); + $query_filtered = SphinxUtil::mkquery($q); + + $cl = SphinxUtil::getClient(); + $cl->setLimits($offset, $count); + $cl->setMatchMode(SphinxClient::SPH_MATCH_EXTENDED); + $cl->setRankingMode(SphinxClient::SPH_RANK_PROXIMITY_BM25); + + switch ($type) { + case ArchiveType::Baconiana: + $cl->setFieldWeights([ + 'year' => 10, + 'issues' => 9, + 'text' => 8 + ]); + $cl->setSortMode(SphinxClient::SPH_SORT_RELEVANCE); + break; + + case ArchiveType::MercureDeFrance: + $cl->setFieldWeights([ + 'date' => 10, + 'issue' => 9, + 'text' => 8 + ]); + $cl->setSortMode(SphinxClient::SPH_SORT_RELEVANCE); + break; + + case ArchiveType::WilliamFriedman: + $cl->setFieldWeights([ + 'title' => 50, + 'document_id' => 60, + ]); + $cl->setSortMode(SphinxClient::SPH_SORT_EXTENDED, '@relevance DESC, is_folder DESC'); + break; + } + + // run search + $final_query = "$query_filtered"; + $result = $cl->query($final_query, $index); + $error = $cl->getLastError(); + $warning = $cl->getLastWarning(); + if ($error) + logError(__METHOD__, $error); + if ($warning) + logWarning(__METHOD__, $warning); + if ($result === false) + return ['count' => 0, 'items' => []]; + + $total_found = (int)$result['total_found']; + + $items = []; + if (!empty($result['matches'])) + $items = $type->getItemsByIdGetter()(array_keys($result['matches'])); + + return ['count' => $total_found, 'items' => $items]; + } + + public static function reindexArchive(ArchiveType $type): void { + $index = $type->getSphinxIndex(); + SphinxUtil::execute("TRUNCATE RTINDEX $index"); + + switch ($type) { + case ArchiveType::MercureDeFrance: + foreach (MDFIssue::getAll() as $item) { + $text = $item->getFullText(); + SphinxUtil::execute("INSERT INTO $index (id, volume, issue, date, text) VALUES (?, ?, ?, ?, ?)", + $item->id, $item->volume, (string)$item->issue, $item->getHumanFriendlyDate(), $text); + } + break; + + case ArchiveType::WilliamFriedman: + foreach (WFFArchiveFile::getAll() as $item) { + $text = $item->getFullText(); + SphinxUtil::execute("INSERT INTO $index (id, document_id, title, text, is_folder, parent_id) VALUES (?, ?, ?, ?, ?, ?)", + $item->id, $item->getDocumentId(), $item->title, $text, (int)($item->type == 'folder'), $item->parentId); + } + break; + + case ArchiveType::Baconiana: + foreach (BaconianaIssue::getList() as $item) { + $text = $item->getFullText(); + if (!$text) + continue; + SphinxUtil::execute("INSERT INTO $index (id, title, year, text) VALUES (?, ?, ?, ?)", + $item->id, "$item->year ($item->issues)", $item->year, $text); + } + break; + } + } +} \ No newline at end of file diff --git a/src/lib/foreignone/files/WFFArchiveFile.php b/src/lib/foreignone/files/WFFArchiveFile.php new file mode 100644 index 0000000..298a372 --- /dev/null +++ b/src/lib/foreignone/files/WFFArchiveFile.php @@ -0,0 +1,165 @@ +id; + } + + public function getTitle(): string { + return $this->title; + } + + public function getDocumentId(): string { + return $this->type == 'folder' ? str_replace('_', ' ', basename($this->path)) : $this->documentId; + } + + public function isTargetBlank(): bool { + return $this->type == 'file'; + } + + public function getSubtitle(): ?string { + return null; + } + + public function getUrl(): string { + global $config; + return $this->type == 'folder' + ? "/files/wff/{$this->id}/" + : "https://{$config['files_domain']}/NSA Friedman Documents/{$this->path}"; + } + + public function getMeta(?string $hl_matched = null): array { + if ($this->type == 'folder') { + if (!$this->parentId) + return []; + return [ + 'items' => [ + highlightSubstring($this->getDocumentId(), $hl_matched), + langNum('files_count', $this->filesCount) + ] + ]; + } + return [ + 'inline' => false, + 'items' => [ + highlightSubstring('Document '.$this->documentId, $hl_matched), + sizeString($this->size), + 'PDF' + ] + ]; + } + + public function getSize(): ?int { + return $this->type == 'file' ? $this->size : null; + } + + public function getIcon(): string { + return $this->type; // it's either 'file' or 'folder' + } + + public function getFullText(): ?string { + if ($this->type != 'file') + return null; + $db = getDB(); + return $db->result($db->query("SELECT text FROM wff_texts WHERE wff_id=?", $this->id)); + } + + + /** + * Static methods + */ + + /** + * @return WFFArchiveFile[] + */ + public static function getAll(): array { + $db = getDB(); + $q = $db->query("SELECT * FROM wff_collection"); + return array_map(static::create_instance(...), $db->fetchAll($q)); + } + + /** + * @param int|int[]|null $parent_id + * @return array + */ + public static function getList(int|array|null $parent_id = null): array { + $db = getDB(); + + $where = []; + $args = []; + + if (!is_null($parent_id)) { + if (is_int($parent_id)) { + $where[] = "parent_id=?"; + $args[] = $parent_id; + } else { + $where[] = "parent_id IN (".implode(", ", $parent_id).")"; + } + } + $sql = "SELECT * FROM wff_collection"; + if (!empty($where)) + $sql .= " WHERE ".implode(" AND ", $where); + $sql .= " ORDER BY title"; + $q = $db->query($sql, ...$args); + + return array_map(static::create_instance(...), $db->fetchAll($q)); + } + + /** + * @param int[] $ids + * @return WFFArchiveFile[] + */ + public static function getItemsById(array $ids): array { + $db = getDB(); + $q = $db->query("SELECT * FROM wff_collection WHERE id IN (".implode(',', $ids).")"); + return array_map(static::create_instance(...), $db->fetchAll($q)); + } + + /** + * @param int $folder_id + * @param bool $with_parents + * @return static|static[]|null + */ + public static function getFolder(int $folder_id, bool $with_parents = false): static|array|null + { + $db = getDB(); + $q = $db->query("SELECT * FROM wff_collection WHERE id=?", $folder_id); + if (!$db->numRows($q)) + return null; + $item = new WFFArchiveFile($db->fetch($q)); + if ($item->type != 'folder') + return null; + if ($with_parents) { + $items = [$item]; + if ($item->parentId) { + $parents = static::getFolder($item->parentId, with_parents: true); + if ($parents !== null) + $items = array_merge($items, $parents); + } + return $items; + } + return $item; + } +} diff --git a/src/lib/ic/InvisibleCollegeSkin.php b/src/lib/ic/InvisibleCollegeSkin.php new file mode 100644 index 0000000..c048c67 --- /dev/null +++ b/src/lib/ic/InvisibleCollegeSkin.php @@ -0,0 +1,90 @@ +addPath(APP_ROOT.'/src/skins/foreignone', 'foreignone'); + return $twig_loader; + } + + /* + public function renderPage(string $template, array $vars = []): Response { + $this->exportStrings(['4in1']); + $this->applyGlobals(); + + // render body first + $b = $this->renderBody($template, $vars); + + // then everything else + $h = $this->renderHeader(); + $f = $this->renderFooter(); + + return new HtmlResponse($h.$b.$f); + } + + protected function renderHeader(): string { + global $config; + + $body_class = []; + if ($this->options->fullWidth) + $body_class[] = 'full-width'; + else if ($this->options->wide) + $body_class[] = 'wide'; + + $vars = [ + 'title' => $this->title, + 'meta_html' => $this->meta->getHtml(), + 'static_html' => $this->getHeaderStaticTags(), + 'svg_html' => $this->getSVGTags(), + 'render_options' => $this->options->getOptions(), + 'app_config' => [ + 'domain' => $config['domain'], + 'devMode' => $config['is_dev'], + 'cookieHost' => $config['cookie_host'], + ], + 'body_class' => $body_class, + 'theme' => ThemesUtil::getUserTheme(), + ]; + + return $this->doRender('header.twig', $vars); + } + + protected function renderBody(string $template, array $vars): string { + return $this->doRender($template, $this->vars + $vars); + } + + protected function renderFooter(): string { + global $config; + + $exec_time = microtime(true) - START_TIME; + $exec_time = round($exec_time, 4); + + $footer_vars = [ + 'exec_time' => $exec_time, + 'render_options' => $this->options->getOptions(), + 'admin_email' => $config['admin_email'], + // 'lang_json' => json_encode($this->getLangKeys(), JSON_UNESCAPED_UNICODE), + // 'static_config' => $this->getStaticConfig(), + 'script_html' => $this->getFooterScriptTags(), + 'this_page_url' => $_SERVER['REQUEST_URI'], + 'theme' => ThemesUtil::getUserTheme(), + ]; + return $this->doRender('footer.twig', $footer_vars); + } + */ +} \ No newline at end of file diff --git a/routes.php b/src/routes.php similarity index 86% rename from routes.php rename to src/routes.php index c4ae38c..ccaa596 100644 --- a/routes.php +++ b/src/routes.php @@ -1,12 +1,15 @@ (function() { global $config; - require_once 'lib/files.php'; + // require_once 'lib/files.php'; - $files_collections = array_map(fn(FilesCollection $fn) => $fn->value, FilesCollection::cases()); - $coll_with_folder_support = [FilesCollection::WilliamFriedman->value, FilesCollection::Baconiana->value]; + $files_collections = array_map(fn(ArchiveType $fn) => $fn->value, ArchiveType::cases()); + $coll_with_folder_support = [ArchiveType::WilliamFriedman->value, ArchiveType::Baconiana->value]; $pagename_regex = '[a-zA-Z0-9-]+'; $wiki_root = $config['wiki_root']; @@ -51,4 +54,13 @@ return (function() { ]; return $routes; -})(); +})(), + +'ic' => (function() { + return [ + 'Main' => [ + '/' => 'index', + ], + ]; +})() +]; diff --git a/src/skins/error/error.twig b/src/skins/error/error.twig new file mode 100644 index 0000000..0fdb472 --- /dev/null +++ b/src/skins/error/error.twig @@ -0,0 +1,13 @@ + + +{{ title }} + +

{{ title }}

+{% if message %} +

{{ message }}

+ {% if stacktrace %} +
{{ stacktrace }}
+ {% endif %} +{% endif %} + + \ No newline at end of file diff --git a/src/skins/error/notfound.twig b/src/skins/error/notfound.twig new file mode 100644 index 0000000..5254bf8 --- /dev/null +++ b/src/skins/error/notfound.twig @@ -0,0 +1,62 @@ + + + + + +Not Found + + + +
+ +

Page not found

+
+ + diff --git a/src/skins/error/notfound_ic.twig b/src/skins/error/notfound_ic.twig new file mode 100644 index 0000000..7dc7377 --- /dev/null +++ b/src/skins/error/notfound_ic.twig @@ -0,0 +1,64 @@ + + + + + +Not Found + + + +
+ +

Page not found

+
+ + diff --git a/skin/admin_actions_log.twig b/src/skins/foreignone/admin_actions_log.twig similarity index 100% rename from skin/admin_actions_log.twig rename to src/skins/foreignone/admin_actions_log.twig diff --git a/skin/admin_auth_log.twig b/src/skins/foreignone/admin_auth_log.twig similarity index 100% rename from skin/admin_auth_log.twig rename to src/skins/foreignone/admin_auth_log.twig diff --git a/skin/admin_errors.twig b/src/skins/foreignone/admin_errors.twig similarity index 100% rename from skin/admin_errors.twig rename to src/skins/foreignone/admin_errors.twig diff --git a/skin/admin_index.twig b/src/skins/foreignone/admin_index.twig similarity index 100% rename from skin/admin_index.twig rename to src/skins/foreignone/admin_index.twig diff --git a/skin/admin_login.twig b/src/skins/foreignone/admin_login.twig similarity index 100% rename from skin/admin_login.twig rename to src/skins/foreignone/admin_login.twig diff --git a/skin/admin_page_form.twig b/src/skins/foreignone/admin_page_form.twig similarity index 100% rename from skin/admin_page_form.twig rename to src/skins/foreignone/admin_page_form.twig diff --git a/skin/admin_page_new.twig b/src/skins/foreignone/admin_page_new.twig similarity index 100% rename from skin/admin_page_new.twig rename to src/skins/foreignone/admin_page_new.twig diff --git a/skin/admin_post_form.twig b/src/skins/foreignone/admin_post_form.twig similarity index 100% rename from skin/admin_post_form.twig rename to src/skins/foreignone/admin_post_form.twig diff --git a/skin/admin_uploads.twig b/src/skins/foreignone/admin_uploads.twig similarity index 100% rename from skin/admin_uploads.twig rename to src/skins/foreignone/admin_uploads.twig diff --git a/skin/articles.twig b/src/skins/foreignone/articles.twig similarity index 100% rename from skin/articles.twig rename to src/skins/foreignone/articles.twig diff --git a/skin/articles_right_links.twig b/src/skins/foreignone/articles_right_links.twig similarity index 100% rename from skin/articles_right_links.twig rename to src/skins/foreignone/articles_right_links.twig diff --git a/skin/files_collection.twig b/src/skins/foreignone/files_collection.twig similarity index 100% rename from skin/files_collection.twig rename to src/skins/foreignone/files_collection.twig diff --git a/skin/files_file.twig b/src/skins/foreignone/files_file.twig similarity index 68% rename from skin/files_file.twig rename to src/skins/foreignone/files_file.twig index a10a5d5..ad01733 100644 --- a/skin/files_file.twig +++ b/src/skins/foreignone/files_file.twig @@ -11,33 +11,23 @@ {% import _self as macros %} {% set subtitle = file.getSubtitle() %} -{% set meta = file.getMeta(query) %} +{% set meta = file.getMeta(search_query) %} {% set title = file.getTitleHtml() %} {% if not title %} - {% set title = file.getTitle()|hl(query) %} + {% set title = file.getTitle()|hl(search_query) %} {% endif %} -
- {% if file.isBook() %} - {{ svg('book_20') }} - {% else %} - {% if file.isFile() %} - {{ svg('file_20') }} - {% else %} - {{ svg('folder_20') }} - {% endif %} - {% endif %} -
- +
{{ svg(file.getIcon()~'_20') }}
{{ title|raw }} - {% if file.isFolder() and file.isTargetBlank() %} + + {% if file.type == 'folder' and file.isTargetBlank() %} {{ svg('arrow_up_right_out_square_outline_12') }} {% endif %} @@ -47,7 +37,7 @@ {% if meta.inline %} {% for item in meta.items %} -
{{ item }}
+
{{ item|raw }}
{% endfor %} {% endif %}
@@ -55,13 +45,13 @@ {% if meta.items and not meta.inline %}
{% for item in meta.items %} -
{{ item }}
+
{{ item|raw }}
{% endfor %}
{% endif %} {% if text_excerpts[file.getId()] %} - {{ macros.excerptWithHighlight(text_excerpts[file.getId()]['index'], text_excerpts[file.getId()]['excerpt'], query) }} + {{ macros.excerptWithHighlight(text_excerpts[file.getId()]['index'], text_excerpts[file.getId()]['excerpt'], search_query) }} {% endif %}
\ No newline at end of file diff --git a/skin/files_folder.twig b/src/skins/foreignone/files_folder.twig similarity index 100% rename from skin/files_folder.twig rename to src/skins/foreignone/files_folder.twig diff --git a/skin/files_index.twig b/src/skins/foreignone/files_index.twig similarity index 100% rename from skin/files_index.twig rename to src/skins/foreignone/files_index.twig diff --git a/skin/files_list.twig b/src/skins/foreignone/files_list.twig similarity index 100% rename from skin/files_list.twig rename to src/skins/foreignone/files_list.twig diff --git a/skin/footer.twig b/src/skins/foreignone/footer.twig similarity index 100% rename from skin/footer.twig rename to src/skins/foreignone/footer.twig diff --git a/skin/header.twig b/src/skins/foreignone/header.twig similarity index 100% rename from skin/header.twig rename to src/skins/foreignone/header.twig diff --git a/skin/index.twig b/src/skins/foreignone/index.twig similarity index 100% rename from skin/index.twig rename to src/skins/foreignone/index.twig diff --git a/skin/markdown_fileupload.twig b/src/skins/foreignone/markdown_fileupload.twig similarity index 100% rename from skin/markdown_fileupload.twig rename to src/skins/foreignone/markdown_fileupload.twig diff --git a/skin/markdown_image.twig b/src/skins/foreignone/markdown_image.twig similarity index 100% rename from skin/markdown_image.twig rename to src/skins/foreignone/markdown_image.twig diff --git a/skin/markdown_preview.twig b/src/skins/foreignone/markdown_preview.twig similarity index 100% rename from skin/markdown_preview.twig rename to src/skins/foreignone/markdown_preview.twig diff --git a/skin/markdown_video.twig b/src/skins/foreignone/markdown_video.twig similarity index 100% rename from skin/markdown_video.twig rename to src/skins/foreignone/markdown_video.twig diff --git a/skin/page.twig b/src/skins/foreignone/page.twig similarity index 100% rename from skin/page.twig rename to src/skins/foreignone/page.twig diff --git a/skin/post.twig b/src/skins/foreignone/post.twig similarity index 100% rename from skin/post.twig rename to src/skins/foreignone/post.twig diff --git a/skin/rss.twig b/src/skins/foreignone/rss.twig similarity index 100% rename from skin/rss.twig rename to src/skins/foreignone/rss.twig diff --git a/skin/spinner.twig b/src/skins/foreignone/spinner.twig similarity index 100% rename from skin/spinner.twig rename to src/skins/foreignone/spinner.twig diff --git a/src/skins/ic/soon.twig b/src/skins/ic/soon.twig new file mode 100644 index 0000000..a13b7f7 --- /dev/null +++ b/src/skins/ic/soon.twig @@ -0,0 +1,52 @@ + + + + + + I.C. + + + +
+ simurgh +

COMING SOON

+
+ + diff --git a/skin/svg/arrow_up_right_out_square_outline_12.svg b/src/skins/svg/arrow_up_right_out_square_outline_12.svg similarity index 100% rename from skin/svg/arrow_up_right_out_square_outline_12.svg rename to src/skins/svg/arrow_up_right_out_square_outline_12.svg diff --git a/skin/svg/book_20.svg b/src/skins/svg/book_20.svg similarity index 100% rename from skin/svg/book_20.svg rename to src/skins/svg/book_20.svg diff --git a/skin/svg/clear_16.svg b/src/skins/svg/clear_16.svg similarity index 100% rename from skin/svg/clear_16.svg rename to src/skins/svg/clear_16.svg diff --git a/skin/svg/clear_20.svg b/src/skins/svg/clear_20.svg similarity index 100% rename from skin/svg/clear_20.svg rename to src/skins/svg/clear_20.svg diff --git a/skin/svg/college_20.svg b/src/skins/svg/college_20.svg similarity index 100% rename from skin/svg/college_20.svg rename to src/skins/svg/college_20.svg diff --git a/skin/svg/file_20.svg b/src/skins/svg/file_20.svg similarity index 100% rename from skin/svg/file_20.svg rename to src/skins/svg/file_20.svg diff --git a/skin/svg/folder_20.svg b/src/skins/svg/folder_20.svg similarity index 100% rename from skin/svg/folder_20.svg rename to src/skins/svg/folder_20.svg diff --git a/skin/svg/moon_auto_18.svg b/src/skins/svg/moon_auto_18.svg similarity index 100% rename from skin/svg/moon_auto_18.svg rename to src/skins/svg/moon_auto_18.svg diff --git a/skin/svg/moon_dark_18.svg b/src/skins/svg/moon_dark_18.svg similarity index 100% rename from skin/svg/moon_dark_18.svg rename to src/skins/svg/moon_dark_18.svg diff --git a/skin/svg/moon_light_18.svg b/src/skins/svg/moon_light_18.svg similarity index 100% rename from skin/svg/moon_light_18.svg rename to src/skins/svg/moon_light_18.svg diff --git a/skin/svg/search_20.svg b/src/skins/svg/search_20.svg similarity index 100% rename from skin/svg/search_20.svg rename to src/skins/svg/search_20.svg diff --git a/skin/svg/settings_28.svg b/src/skins/svg/settings_28.svg similarity index 100% rename from skin/svg/settings_28.svg rename to src/skins/svg/settings_28.svg diff --git a/strings/main.yaml b/src/strings/main.yaml similarity index 100% rename from strings/main.yaml rename to src/strings/main.yaml diff --git a/tools/cli_util.php b/tools/cli_util.php index 384bca9..b4be2b9 100755 --- a/tools/cli_util.php +++ b/tools/cli_util.php @@ -1,38 +1,44 @@ #!/usr/bin/env php on('admin-add', function() { list($login, $password) = _get_admin_login_password_input(); - if (admin::exists($login)) - cli::die("Admin ".$login." already exists"); + if (Admin::exists($login)) + CliUtil::die("Admin ".$login." already exists"); - $id = admin::add($login, $password); + $id = Admin::add($login, $password); echo "ok: id = $id\n"; }) ->on('admin-delete', function() { - $login = cli::input('Login: '); - if (!admin::exists($login)) - cli::die("No such admin"); - if (!admin::delete($login)) - cli::die("Database error"); + $login = CliUtil::input('Login: '); + if (!Admin::exists($login)) + CliUtil::die("No such admin"); + if (!Admin::delete($login)) + CliUtil::die("Database error"); echo "ok\n"; }) ->on('admin-set-password', function() { list($login, $password) = _get_admin_login_password_input(); - echo admin::setPassword($login, $password) ? 'ok' : 'fail'; + echo Admin::setPassword($login, $password) ? 'ok' : 'fail'; echo "\n"; }) ->on('blog-erase', function() { - $db = DB(); + $db = getDB(); $tables = ['posts', 'posts_texts']; foreach ($tables as $t) { $db->query("TRUNCATE TABLE $t"); @@ -41,7 +47,7 @@ require_once 'lib/admin.php'; ->on('posts-html', function() { $kw = ['include_hidden' => true]; - $posts = posts::getList(0, posts::getCount(...$kw), ...$kw); + $posts = Post::getList(0, Post::getCount(...$kw), ...$kw); foreach ($posts as $p) { $texts = $p->getTexts(); foreach ($texts as $t) { @@ -53,7 +59,7 @@ require_once 'lib/admin.php'; ->on('posts-images', function() { $kw = ['include_hidden' => true]; - $posts = posts::getList(0, posts::getCount(...$kw), ...$kw); + $posts = Post::getList(0, Post::getCount(...$kw), ...$kw); foreach ($posts as $p) { $texts = $p->getTexts(); foreach ($texts as $t) { @@ -63,27 +69,26 @@ require_once 'lib/admin.php'; }) ->on('pages-html', function() { - $pages = Pages::getAll(); + $pages = Page::getAll(); foreach ($pages as $p) { $p->updateHtml(); } }) ->on('add-files-to-uploads', function() { - $path = cli::input('Enter path: '); + $path = CliUtil::input('Enter path: '); if (!file_exists($path)) - cli::die("file $path doesn't exists"); + CliUtil::die("file $path doesn't exists"); $name = basename($path); $ext = extension($name); - $id = Uploads::add($path, $name, ''); + $id = Upload::add($path, $name, ''); echo "upload id: $id\n"; }) -->on('collection-reindex', function() { - require_once 'lib/files.php'; - $collections = array_map(fn($c) => $c->value, FilesCollection::cases()); - $s = cli::input('Enter collection to reindex (variants: '.implode(', ', $collections).': '); - $c = FilesCollection::from($s); +->on('archive-reindex', function() { + $archives = array_map(fn($c) => $c->value, ArchiveType::cases()); + $s = CliUtil::input('Enter archive to reindex (variants: '.implode(', ', $archives).': '); + $c = ArchiveType::from($s); $f = "{$s}_reindex"; echo "calling $f()... "; call_user_func($f); @@ -93,18 +98,18 @@ require_once 'lib/admin.php'; ->run(); function _get_admin_login_password_input(): array { - $login = cli::input('Login: '); - $pwd1 = cli::silentInput("Password: "); - $pwd2 = cli::silentInput("Again: "); + $login = CliUtil::input('Login: '); + $pwd1 = CliUtil::silentInput("Password: "); + $pwd2 = CliUtil::silentInput("Again: "); if ($pwd1 != $pwd2) - cli::die("Passwords do not match"); + CliUtil::die("Passwords do not match"); if (trim($pwd1) == '') - cli::die("Password can not be empty"); + CliUtil::die("Password can not be empty"); - if (strlen($login) > admin::ADMIN_LOGIN_MAX_LENGTH) - cli::die("Login is longer than max length (".admin::ADMIN_LOGIN_MAX_LENGTH.")"); + if (strlen($login) > Admin::ADMIN_LOGIN_MAX_LENGTH) + CliUtil::die("Login is longer than max length (".Admin::ADMIN_LOGIN_MAX_LENGTH.")"); return [$login, $pwd1]; } diff --git a/tools/import_article.php b/tools/import_article.php index f3a17d6..9a4001c 100755 --- a/tools/import_article.php +++ b/tools/import_article.php @@ -1,7 +1,12 @@ #!/usr/bin/env php '', 'visible' => false, 'short_name' => $options['short-name'], @@ -19,7 +24,7 @@ $post = posts::add([ 'source_url' => '', ]); if (!$post) - cli::die("failed to create post"); + CliUtil::die("failed to create post"); foreach ($langs as $lang) { $text = $post->addText( @@ -29,8 +34,8 @@ foreach ($langs as $lang) { keywords: '', toc: false); if (!$text) { - posts::delete($post); - cli::die("failed to create post text"); + Post::delete($post); + CliUtil::die("failed to create post text"); } } @@ -63,14 +68,14 @@ function checkFile($file) { function processImages($md) { return preg_replace_callback( - '/!\[.*?\]\((https?:\/\/[^\s)]+)\)/', + '/!\[.*?]\((https?:\/\/[^\s)]+)\)/', function ($matches) { $url = $matches[1]; $parsed_url = parse_url($url); $clean_url = $parsed_url['scheme'] . '://' . $parsed_url['host'] . $parsed_url['path']; - $upload = uploads::getUploadBySourceUrl($clean_url); + $upload = Upload::getUploadBySourceUrl($clean_url); if (!$upload) { $name = basename($clean_url); $ext = extension($clean_url); @@ -79,8 +84,8 @@ function processImages($md) { logError('failed to download '.$clean_url.' to '.$tmp); return $matches[0]; } - $upload_id = uploads::add($tmp, $name, source_url: $clean_url); - $upload = uploads::get($upload_id); + $upload_id = Upload::add($tmp, $name, source_url: $clean_url); + $upload = Upload::get($upload_id); // $tmp file has already been deleted by uploads::add() at this point } else { logDebug('found existing upload with source_url='.$clean_url);