Update code

This commit is contained in:
holo-gfx 2021-03-19 13:06:32 -07:00
parent cb9029387f
commit 3a8c3dc66b
126 changed files with 12738 additions and 3993 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
API_URL=https://api.mangadex.org/v2/

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
config.req.php
/images/manga
/images/avatars
/images/lists
/images/groups
/images/covers
/images/banners
/data
/data-drama
/data-saver
/delete
/dl/*
/transferred
/vendor
/node_modules
sitemap.xml
/db/
/dist/*
phpunit.xml
/.idea
.env*.local
composer.phar

View File

@ -1,3 +1,45 @@
Code vulenerable to a PHP RCE.
**Edit a file, create a new file, and clone from Bitbucket in under 2 minutes**
IP Address for Site: 152.228.156.26
When you're done, you can delete the content in this README and update the file with details for others getting started with your repository.
*We recommend that you open this README in another tab as you perform the tasks below. You can [watch our video](https://youtu.be/0ocf7u76WSo) for a full demo of all the steps in this tutorial. Open the video in a new tab to avoid leaving Bitbucket.*
---
## Edit a file
Youll start by editing this README file to learn how to edit a file in Bitbucket.
1. Click **Source** on the left side.
2. Click the README.md link from the list of files.
3. Click the **Edit** button.
4. Delete the following text: *Delete this line to make a change to the README from Bitbucket.*
5. After making your change, click **Commit** and then **Commit** again in the dialog. The commit page will open and youll see the change you just made.
6. Go back to the **Source** page.
---
## Create a file
Next, youll add a new file to this repository.
1. Click the **New file** button at the top of the **Source** page.
2. Give the file a filename of **contributors.txt**.
3. Enter your name in the empty file space.
4. Click **Commit** and then **Commit** again in the dialog.
5. Go back to the **Source** page.
Before you move on, go ahead and explore the repository. You've already seen the **Source** page, but check out the **Commits**, **Branches**, and **Settings** pages.
---
## Clone a repository
Use these steps to clone from SourceTree, our client for using the repository command-line free. Cloning allows you to work on your files locally. If you don't yet have SourceTree, [download and install first](https://www.sourcetreeapp.com/). If you prefer to clone from the command line, see [Clone a repository](https://confluence.atlassian.com/x/4whODQ).
1. Youll see the clone button under the **Source** heading. Click that button.
2. Now click **Check out in SourceTree**. You may need to create a SourceTree account or log in.
3. When you see the **Clone New** dialog in SourceTree, update the destination path and name if youd like to and then click **Clone**.
4. Open the directory you just created to see your repositorys files.
Now that you're more familiar with your Bitbucket repository, go ahead and add a new file locally. You can [push your change back to Bitbucket with SourceTree](https://confluence.atlassian.com/x/iqyBMg), or you can [add, commit,](https://confluence.atlassian.com/x/8QhODQ) and [push from the command line](https://confluence.atlassian.com/x/NQ0zDQ).

View File

@ -1,10 +1,5 @@
<?php
//if (isset($_GET['_'])) {
// http_response_code(666);
// die();
//}
require_once ('../bootstrap.php');
define('IS_NOJS', (isset($_GET['nojs']) && $_GET['nojs']));
@ -174,13 +169,34 @@ switch ($function) {
$recipient_user = new User($thread->recipient_id, 'user_id');
$sender_user = new User($thread->sender_id, 'user_id');
// in the context of a pm thread, "sender" is the op rather than necessarily whoever is currently sending the message
// so in case the current replying user is the "recipient", flip the variables around to match the correct meaning
if ($thread->recipient_id == $user->user_id) {
$recipient_user = $sender_user;
$sender_user = $user;
}
/*$canReceiveDms = \validate_level($user, 'pr') // Staff can always send dms
|| ($recipient_user->dm_privacy ?? 0) < 1 // User has no dm restriction set
|| \in_array( // sender is a friend of recipient
$user->user_id,
\array_map(static function ($u) {
return $u['user_id'];
},
\array_filter($recipient_user->get_friends_user_ids(), static function ($u) {
return $u['accepted'] === 1;
})
),
true
);*/
$sender_blocked = $sender_user->get_blocked_user_ids();
$recipient_blocked = $recipient_user->get_blocked_user_ids();
// DM restriction if there is an active restriction and the sender isnt staff. restricted users can always message staff
$dm_restriction = $user->has_active_restriction(USER_RESTRICTION_CREATE_DM) && !validate_level($recipient_user, 'mod');
if (($user->user_id == $thread->sender_id || $user->user_id == $thread->recipient_id) && !isset($sender_blocked[$thread->recipient_id]) && !isset($recipient_blocked[$thread->sender_id]) && !$dm_restriction) {
if (/*$canReceiveDms &&*/($user->user_id == $thread->sender_id || $user->user_id == $thread->recipient_id) && !isset($sender_blocked[$thread->recipient_id]) && !isset($recipient_blocked[$thread->sender_id]) && !$dm_restriction) {
$sql->modify('msg_reply', ' INSERT INTO mangadex_pm_msgs (msg_id, thread_id, user_id, timestamp, text) VALUES (NULL, ?, ?, UNIX_TIMESTAMP(), ?) ', [$id, $user->user_id, $reply]);
if ($thread->sender_id == $user->user_id)
@ -199,6 +215,8 @@ switch ($function) {
$details = "You can't reply to the message because they are blocked.";
elseif (isset($recipient_blocked[$thread->sender_id]))
$details = "You can't reply to the message because they are blocked.";
/*elseif (!$canReceiveDms)
$details = "You can't send messages to this user until you have accepted each other as friends.";*/
elseif ($dm_restriction)
$details = $user->get_restriction_message(USER_RESTRICTION_CREATE_DM) ?? "You can't reply to this dm.";
else
@ -248,6 +266,20 @@ switch ($function) {
$recipient_id = $sql->prep('recipient_id', ' SELECT user_id FROM mangadex_users WHERE username = ?', [$recipient], 'fetchColumn', '', -1);
$recipient_user = new User($recipient_id, 'user_id');
$canReceiveDms = \validate_level($user, 'pr') // Staff can always send dms
|| ($recipient_user->dm_privacy ?? 0) < 1 // User has no dm restriction set
|| \in_array( // sender is a friend of recipient
$user->user_id,
\array_map(static function ($u) {
return $u['user_id'];
},
\array_filter($recipient_user->get_friends_user_ids(), static function ($u) {
return $u['accepted'] === 1;
})
),
true
);
$user_blocked = $user->get_blocked_user_ids();
$recipient_blocked = $recipient_user->get_blocked_user_ids();
@ -257,7 +289,7 @@ switch ($function) {
$has_banned_word = !validate_level($user, "pr") && (strpos_arr($message, SPAM_WORDS) !== FALSE || strpos_arr($subject, SPAM_WORDS) !== FALSE);
$has_dmed_recently = !validate_level($user, "pr") && ($timestamp - $last_message_timestamp < 30);
$is_valid_recipient = $recipient_id && $recipient_id != $user->user_id;
$is_valid_recipient = $canReceiveDms && $recipient_id && $recipient_id != $user->user_id;
$is_blocked = isset($user_blocked[$recipient_id]) || isset($recipient_blocked[$user->user_id]);
if(!validate_level($user, 'member') || $dm_restriction){
@ -273,20 +305,17 @@ switch ($function) {
$memcached->delete("user_{$recipient_id}_unread_msgs");
$details = $thread_id;
}
else if($has_dmed_recently) {
} else if ($has_dmed_recently) {
$details = "Please wait before sending another message.";
}
else if(!$is_valid_recipient) {
} else if (!$canReceiveDms) {
$details = "You can't send messages to this user until you have accepted each other as friends.";
} else if (!$is_valid_recipient) {
$details = "$recipient is an invalid recipient.";
}
else if($is_blocked) {
} else if ($is_blocked) {
$details = "$recipient has blocked you or you have blocked them.";
}
else if(!$captcha_validate['success']) {
} else if (!$captcha_validate['success']) {
$details = 'You need to solve the captcha to send messages.';
}
else {
} else {
$thread_id = $sql->modify('msg_send', ' INSERT INTO mangadex_pm_threads (thread_id, thread_subject, sender_id, recipient_id, thread_timestamp, sender_read, recipient_read, sender_deleted, recipient_deleted)
VALUES (NULL, ?, ?, ?, UNIX_TIMESTAMP(), 1, 0, 0, 0) ', [$subject, $user->user_id, $recipient_id]);
@ -545,6 +574,89 @@ switch ($function) {
$result = ($details) ? 0 : 1;
break;
case "banner_upload":
$file = $_FILES["file"];
$user_id = prepare_numeric($_POST["user_id"]);
$is_anonymous = isset($_POST["is_anonymous"]) ? 1 : 0;
$is_enabled = isset($_POST["is_enabled"]) ? 1 : 0;
$file_extension = strtolower(end(explode(".", $file["name"])));
if($file["error"] != UPLOAD_ERR_OK){
$error .= display_alert('danger', 'Failed', "File upload error.");
}
if(!validate_level($user, 'pr')) {
$error .= display_alert('danger', 'Failed', "You can't upload banners.");
}
if(!in_array($file_extension, ALLOWED_IMG_EXT)){
$error .= display_alert('danger', 'Failed', "Illegal file extension.");
}
if(!$error){
try {
$banner_id = $sql->modify('banner_upload',
"INSERT INTO mangadex_banners (user_id, is_anonymous, is_enabled, ext) VALUES (?, ?, ?, ?)",
[$user_id, $is_anonymous, $is_enabled, $file_extension]);
move_uploaded_file($file["tmp_name"], ABS_DATA_BASEPATH . "/banners/affiliatebanner$banner_id.$file_extension");
$memcached->delete("banners_all");
$memcached->delete("banners_enabled");
}
catch(Exception $e){
$error .= display_alert('danger', 'Failed', "Database error.");
}
}
if($error){
$details = $error;
print $error;
}
$result = $details ? 0 : 1;
break;
case "banner_edit":
$file = $_FILES["file"];
$banner_id = prepare_numeric($_GET["banner_id"]);
$user_id = prepare_numeric($_POST["user_id"]);
$is_anonymous = isset($_POST["is_anonymous"]) ? 1 : 0;
$is_enabled = isset($_POST["is_enabled"]) ? 1 : 0;
if($file["error"] == UPLOAD_ERR_NO_FILE){
$file_extension = $sql->prep("banner_ext", "SELECT ext FROM mangadex_banners WHERE banner_id = ?", [$banner_id], "fetch", PDO::FETCH_ASSOC, -1)["ext"];
}
else if($file["error"] == UPLOAD_ERR_OK){
$file_extension = strtolower(end(explode(".", $file["name"])));
}
else{
$error .= display_alert('danger', 'Failed', "File upload error.");
}
if(!validate_level($user, 'pr')) {
$error .= display_alert('danger', 'Failed', "You can't edit banners.");
}
if(!in_array($file_extension, ALLOWED_IMG_EXT)){
$error .= display_alert('danger', 'Failed', "Illegal file extension.");
}
if(!$error){
try {
$sql->modify('banner_edit',
"UPDATE mangadex_banners SET user_id = ?, is_anonymous = ?, is_enabled = ?, ext = ? WHERE banner_id = ?",
[$user_id, $is_anonymous, $is_enabled, $file_extension, $banner_id]);
if($file["error"] == UPLOAD_ERR_OK){
move_uploaded_file($file["tmp_name"], ABS_DATA_BASEPATH . "/banners/affiliatebanner$banner_id.$file_extension");
}
$memcached->delete("banners_all");
$memcached->delete("banners_enabled");
}
catch(Exception $e){
$error .= display_alert('danger', 'Failed', "Database error.");
}
}
if($error){
$details = $error;
print $error;
}
$result = $details ? 0 : 1;
break;
}
/*
if (!in_array($function, ['manga_follow', 'manga_unfollow']))

View File

@ -236,7 +236,7 @@ switch ($function) {
$to = $email1;
$subject = "MangaDex: Account Creation - $username";
$body = "Thank you for creating an account on MangaDex. \n\nUsername: $username \nPassword: (your chosen password) \n\nActivation code: $activation_key \n\nPlease visit " . URL . "activation/$activation_key to activate your account.";
$body = "Thank you for creating an account on MangaDex. \n\nUsername: $username \nPassword: (your chosen password) \n\nActivation code: $activation_key \n\nPlease visit " . URL . "activation/$activation_key to activate your account. \n\n If the above link doesn't work, try logging in and entering the activation code manually here " . URL . "activation instead.";
//$body = "Thank you for creating an account on MangaDex. \n\nUsername: $username \nPassword: (your chosen password) Due to problem with a spammer, activation codes are temporarily not being sent in this email. Please reply to this email to request an activation code. Apologies for the inconvenience!";
send_email($to, $subject, $body);
@ -427,7 +427,7 @@ switch ($function) {
FROM mangadex_users u
JOIN mangadex_ip_bans b
ON u.creation_ip = b.ip OR u.last_ip = b.ip
WHERE user_id = ? LIMIT 1', [$user->user_id], "fetchAll", PDO::FETCH_UNIQUE, -1);
WHERE user_id = ? LIMIT 1', [$user->user_id], "fetchColumn", '', -1);
if($user_banned){
$sql->modify('activate', ' UPDATE mangadex_users SET level_id = 0, activated = 1 WHERE user_id = ? AND activated = 0 LIMIT 1 ', [$user->user_id]);
}
@ -465,6 +465,39 @@ switch ($function) {
$result = 1;
break;
case "change_activation_email":
$email = $_POST['email'];
if($email != $user->email){
// check for another account with this email
$count_email = $sql->prep('count_email', ' SELECT count(*) FROM mangadex_users WHERE email = ? ', [$email], 'fetchColumn', '', -1);
//check for banned hosts
$banned_hosts = $sql->query_read('tempmail', "SELECT host FROM mangadex_tempmail ORDER BY host ASC ", 'fetchAll', PDO::FETCH_COLUMN);
$email_parts = explode('@', $email);
$banned_email = in_array($email_parts[1], $banned_hosts);
if($count_email || $banned_email){
$details = 'This email cannot be used.';
print display_alert('danger', 'Failed', $details); // wrong code
$result = 0;
}
else{
$sql->modify('change_email', ' UPDATE mangadex_users SET email = ? WHERE user_id = ? LIMIT 1 ', [$email, $user->user_id]);
$memcached->delete("user_$user->user_id");
$to = $email;
$subject = "MangaDex: Resend Activation Code - $user->username";
$body = "Here's your activation code. \n\nUsername: $user->username \n\nActivation code: $user->activation_key \n\nPlease visit " . URL . "activation/$user->activation_key to activate your account. ";
send_email($to, $subject, $body, 3);
$result = 1;
}
}
break;
case "2fa_setup":
if ($user === null || $user->user_id < 2 || !validate_level($user, 'member')) {

View File

@ -738,6 +738,25 @@ switch ($function) {
$result = (!is_numeric($details)) ? 0 : 1;
break;
case "manga_regenerate_thumb":
$id = prepare_numeric($_GET['id']);
if (validate_level($user, 'mod')) {
$manga = new Manga($id);
$ext = strtolower($manga->manga_image);
generate_thumbnail(ABS_DATA_BASEPATH . "/manga/$manga->manga_id.$ext", 1);
$details = $id;
}
else {
$details = "You can't regenerate this thumbnail.";
print display_alert('danger', 'Failed', $details); //fail
}
$result = (!is_numeric($details)) ? 0 : 1;
break;
case "manga_report":
$id = prepare_numeric($_GET['id']);
$report_text = htmlentities($_POST["report_text"]);

View File

@ -25,10 +25,10 @@ switch ($function) {
if (!$user->user_id)
$error .= display_alert('danger', 'Failed', "Your session has timed out. Please log in again.");
elseif ($upload < 80)
$error .= display_alert('danger', 'Failed', "Your upload speed must be at least 80 Mbps.");
elseif ($download < 80)
$error .= display_alert('danger', 'Failed', "Your download speed must be at least 80 Mbps.");
elseif ($upload < 40)
$error .= display_alert('danger', 'Failed', "Your upload speed must be at least 40 Mbps.");
elseif ($download < 40)
$error .= display_alert('danger', 'Failed', "Your download speed must be at least 40 Mbps.");
elseif ($upload > 65535)
$error .= display_alert('danger', 'Failed', "Your upload speed is too high.");
elseif ($download > 65535)

View File

@ -298,6 +298,7 @@ switch ($function) {
$post_sensitivity = prepare_numeric($_POST['swipe_sensitivity']);
$reader_mode = prepare_numeric($_POST['reader_mode']) ?? 0;
$image_fit = prepare_numeric($_POST['image_fit']) ?? 0;
$data_saver = prepare_numeric($_POST['data_saver']) ?? 0;
$img_server = prepare_numeric($_POST['img_server']);
if ($reader_mode && $image_fit == 2)
$image_fit = 0;
@ -312,6 +313,7 @@ switch ($function) {
$sql->modify('reader_settings', '
UPDATE mangadex_users SET reader = ?, swipe_direction = ?, swipe_sensitivity = ?, reader_mode = ?, reader_click = ?, image_fit = ?, img_server = ? WHERE user_id = ? LIMIT 1
', [$reader, $swipe_direction, $swipe_sensitivity, $reader_mode, $reader_click, $image_fit, $img_server, $user->user_id]);
$sql->modify('reader_settings', ' UPDATE mangadex_user_options SET data_saver = ? WHERE user_id = ? LIMIT 1 ', [(int) $data_saver, $user->user_id]);
$memcached->delete("user_$user->user_id");
}
@ -328,6 +330,7 @@ switch ($function) {
$website = str_replace(['javascript:'], '', htmlentities($_POST['website']));
$user_bio = str_replace(['javascript:'], '', htmlentities($_POST['user_bio']));
$old_file = $_FILES['file']['name'];
$email = $_POST['email'];
// Make sure website has http://
if (!empty($website) && stripos($website, 'http://') === false && stripos($website, 'https://') === false)
@ -351,6 +354,21 @@ switch ($function) {
}
}
if($email != $user->email){
// check for another account with this email
$count_email = $sql->prep('count_email', ' SELECT count(*) FROM mangadex_users WHERE email = ? ', [$email], 'fetchColumn', '', -1);
//check for banned hosts
$banned_hosts = $sql->query_read('tempmail', "SELECT host FROM mangadex_tempmail ORDER BY host ASC ", 'fetchAll', PDO::FETCH_COLUMN);
$email_parts = explode('@', $email);
$banned_email = in_array($email_parts[1], $banned_hosts);
if($count_email || $banned_email){
$fail_reason = "This email cannot be used.";
$error .= display_alert("danger", "Failed", $fail_reason);
}
}
if (!$user->user_id)
$error .= display_alert('danger', 'Failed', 'Your session has timed out. Please log in again.'); //success
@ -358,7 +376,7 @@ switch ($function) {
$error .= display_alert('danger', 'Failed', 'You need to be at least a member.'); //success
if (!$error) {
$sql->modify('change_profile', ' UPDATE mangadex_users SET language = ?, user_website = ?, user_bio = ? WHERE user_id = ? LIMIT 1 ', [$lang_id, $website, $user_bio, $user->user_id]);
$sql->modify('change_profile', ' UPDATE mangadex_users SET language = ?, user_website = ?, user_bio = ?, email = ? WHERE user_id = ? LIMIT 1 ', [$lang_id, $website, $user_bio, $email, $user->user_id]);
if ($old_file) {
$arr = explode('.', $_FILES['file']['name']);
@ -399,8 +417,9 @@ switch ($function) {
$theme_id = prepare_numeric($_POST['theme_id']);
$navigation = prepare_numeric($_POST['navigation']);
$list_privacy = prepare_numeric($_POST['list_privacy']);
$dm_privacy = prepare_numeric($_POST['dm_privacy']);
$reader = $_POST['reader'] ?? 0;
$data_saver = $_POST['data_saver'] ?? 0;
$port_limit = prepare_numeric($_POST['mdh_portlimit'] ?? 0);
$display_lang_id = prepare_numeric($_POST['display_lang_id']);
$old_file = $_FILES['file']['name'];
$hentai_mode = prepare_numeric($_POST["hentai_mode"]);
@ -423,10 +442,10 @@ switch ($function) {
if (!$error) {
$sql->modify('site_settings', '
UPDATE mangadex_users SET hentai_mode = ?, display_moderated = ?, latest_updates = ?, reader = ?, default_lang_ids = ?, style = ?, display_lang_id = ?, list_privacy = ?, excluded_genres = ?, navigation = ?, show_unavailable = ? WHERE user_id = ? LIMIT 1
', [$hentai_mode, $display_moderated, $latest_updates, (int) $reader, $default_lang_ids, $theme_id, $display_lang_id, $list_privacy, implode(',', $excluded_genres), $navigation, $show_unavailable, $user->user_id]);
UPDATE mangadex_users SET hentai_mode = ?, display_moderated = ?, latest_updates = ?, reader = ?, default_lang_ids = ?, style = ?, display_lang_id = ?, list_privacy = ?, excluded_genres = ?, navigation = ?, dm_privacy = ?, show_unavailable = ? WHERE user_id = ? LIMIT 1
', [$hentai_mode, $display_moderated, $latest_updates, (int) $reader, $default_lang_ids, $theme_id, $display_lang_id, $list_privacy, implode(',', $excluded_genres), $navigation, $dm_privacy, $show_unavailable, $user->user_id]);
$sql->modify('site_settings', ' UPDATE mangadex_user_options SET data_saver = ? WHERE user_id = ? LIMIT 1 ', [(int) $data_saver, $user->user_id]);
$sql->modify('site_settings', ' UPDATE mangadex_user_options SET mdh_portlimit = ? WHERE user_id = ? LIMIT 1 ', [$port_limit, $user->user_id]);
if ($old_file && !$reset_list_banner) {
$arr = explode(".", $_FILES["file"]["name"]);

View File

@ -280,36 +280,18 @@ switch ($type) {
$page_array = array_combine(range(1, count($arr)), array_values($arr));
}
$server_fallback = LOCAL_SERVER_URL;
$server_fallback = IMG_SERVER_URL;
$server_network = null;
// when a chapter does not exist on the local webserver, it gets an id. since all imageservers share the same data, we can assign any imageserver
// with the best location to the user.
if ($chapter->server > 0) {
if (isset($user->md_at_home) && $user->md_at_home && stripos($chapter->page_order, 'http') === false) {
// use md@h for all images
try {
$subsubdomain = $mdAtHomeClient->getServerUrl($chapter->chapter_hash, explode(',', $chapter->page_order), _IP);
$subsubdomain = $mdAtHomeClient->getServerUrl($chapter->chapter_hash, explode(',', $chapter->page_order), _IP, $user->mdh_portlimit ?? false);
if (!empty($subsubdomain)) {
$server_network = $subsubdomain;
}
} catch (Throwable $t) {
} catch (\Throwable $t) {
trigger_error($t->getMessage(), E_USER_WARNING);
}
}
$server_id = -1;
// If a usersetting overwrites it, take this
if (isset($_GET['server'])) {
// if the parameter was trash, this returns -1
$server_id = get_server_id_by_code($_GET['server']);
}
if ($server_id < 1) {
// Try to select a region based server if we havent set one already
$server_id = get_server_id_by_geography();
}
if ($server_id > 0) {
$server_fallback = "https://s$server_id.mangadex.org";
}
}
$server = $server_network ?: $server_fallback;
@ -341,11 +323,16 @@ switch ($type) {
if (!empty($server_network)) {
$array['server_fallback'] = $server_fallback.$data_dir;
}
$isRestricted = in_array($chapter->manga_id, RESTRICTED_MANGA_IDS) && !validate_level($user, 'contributor') && $user->get_chapters_read_count() < MINIMUM_CHAPTERS_READ_FOR_RESTRICTED_MANGA;
$countryCode = strtoupper(get_country_code($user->last_ip));
$isRegionBlocked = isset(REGION_BLOCKED_MANGA[$countryCode]) && in_array($manga->manga_id, REGION_BLOCKED_MANGA[$countryCode]) && !validate_level($user, 'pr');
if ($status === 'external') {
$array['external'] = $chapter->page_order;
}
elseif (in_array($chapter->manga_id, RESTRICTED_MANGA_IDS) && !validate_level($user, 'contributor') && $user->get_chapters_read_count() < MINIMUM_CHAPTERS_READ_FOR_RESTRICTED_MANGA) {
elseif ($isRestricted || $isRegionBlocked) {
$array = [
'id' => $chapter->chapter_id,
'status' => 'restricted',

View File

@ -11,6 +11,7 @@ use Mangadex\Controller\API\MangaController;
use Mangadex\Controller\API\RelationTypeController;
use Mangadex\Controller\API\TagController;
use Mangadex\Controller\API\UserController;
use Mangadex\Controller\API\HighestChapterIDController;
use Mangadex\Exception\Http\HttpException;
use Mangadex\Exception\Http\NotFoundHttpException;
use Mangadex\Exception\Http\TooManyRequestsHttpException;
@ -67,6 +68,9 @@ try {
case 'index':
$controller = new IndexController();
break;
case 'highest_chapter_id':
$controller = new HighestChapterIDController();
break;
default:
throw new NotFoundHttpException("Invalid endpoint");
break;

View File

@ -8,6 +8,7 @@ define('DB_USER', 'mangadex');
define('DB_PASSWORD', '');
define('DB_NAME', 'mangadex');
define('DB_HOST', 'localhost');
define('DB_PERSISTENT', false);
define('DB_READ_HOSTS', ['127.0.0.1']);
define('DB_READ_NAME', DB_NAME);
@ -29,6 +30,8 @@ define('URL', 'https://mangadex.org/');
define('TITLE', 'MangaDex');
define('DESCRIPTION', 'Read manga online for free at MangaDex with no ads, high quality images and support scanlation groups!');
define('MEMCACHED_HOST', '127.0.0.1');
define('MEMCACHED_SYNC_HOST', null);
define('MEMCACHED_SYNC_PORT', null);
define('GOOGLE_CAPTCHA_SITEKEY', 'xxx');
define('GOOGLE_CAPTCHA_SECRET', 'xxx');
@ -60,6 +63,7 @@ define('GOOGLE_SERVICE_ACCOUNT_PATH', '/var/www/google_service_credentials.json'
define('MAX_CHAPTER_FILESIZE', 104857600); //100*1024*1024
define('DMS_DISPLAY_LIMIT', 25);
define('PRIVATE_API_KEY', sha1('secretpass_changeme'));
define('REQUIRE_LOGIN_PAGES', ['users', 'follows', 'followed_manga', 'followed_groups', 'follows_import', 'upload', 'settings', 'messages', 'message', 'send_message', 'activation', 'admin', 'mod', 'group_new', 'manga_new', 'stats', 'social']);
@ -90,8 +94,9 @@ define('MAX_IMAGE_FILESIZE', 1048576);
define('ALLOWED_IMG_EXT', ['jpg', 'jpeg', 'png', 'gif']);
define('ALLOWED_MIME_TYPES', ['image/png', 'image/jpeg', 'image/gif']);
define('IMAGE_SERVER', 0);
define('IMG_SERVER_URL', 'https://s1.mangadex.org');
define('IMG_SERVER_URL', 'https://s2.mangadex.org');
define('LOCAL_SERVER_URL', 'https://cdndex.com/data/');
define('API_V2_URL', 'https://api.mangadex.org/v2/');
//$server_array = ['eu2' => 1, 'na' => 2, 'eu' => 3, 'na2' => 4, 'na3' => 5];
define('IMAGE_SERVER_INFO', [

View File

@ -9,6 +9,9 @@ require_once (__DIR__.'/../bootstrap.php');
require_once (ABSPATH . "/scripts/header.req.php");
echo "START @ ".date("F j, Y, g:i a")."\n";
echo "prune remote files ...\n";
// prune remote file upload tmpfiles
$dirh = opendir(sys_get_temp_dir());
$nameFormat = 'remote_file_dl_';
@ -23,13 +26,14 @@ while (false !== ($entry = readdir($dirh))) {
}
//updated featured
echo "featured ...\n";
$memcached->delete('featured');
$manga_lists = new Manga_Lists();
$array_of_featured_manga_ids = $manga_lists->get_manga_list(11);
$array_of_featured_manga_ids = $manga_lists->get_manga_list(12);
if (!empty($array_of_featured_manga_ids)) {
$manga_ids_in = prepare_in($array_of_featured_manga_ids);
$featured = $sql->prep('featured', "
SELECT chapters.manga_id, chapters.chapter_id, chapters.chapter_views, chapters.chapter, chapters.upload_timestamp,
SELECT /*+ MAX_EXECUTION_TIME(1800000) */ chapters.manga_id, chapters.chapter_id, chapters.chapter_views, chapters.chapter, chapters.upload_timestamp,
mangas.manga_name, mangas.manga_image, mangas.manga_hentai, mangas.manga_bayesian,
(SELECT count(*) FROM mangadex_follow_user_manga WHERE mangadex_follow_user_manga.manga_id = mangas.manga_id) AS count_follows
FROM mangadex_chapters AS chapters
@ -40,51 +44,55 @@ if (!empty($array_of_featured_manga_ids)) {
AND mangas.manga_id IN ($manga_ids_in)
GROUP BY chapters.manga_id
ORDER BY chapters.chapter_views DESC
", $array_of_featured_manga_ids , 'fetchAll', PDO::FETCH_ASSOC, 3600);
", $array_of_featured_manga_ids , 'fetchAll', PDO::FETCH_ASSOC, 3600, true);
}
//update new manga
echo "new_manga ...\n";
$memcached->delete('new_manga');
$new_manga = $sql->query_read('new_manga', "
SELECT mangas.manga_id, mangas.manga_name, mangas.manga_image, mangas.manga_hentai, chapters.chapter_id, chapters.chapter_views, chapters.chapter, chapters.upload_timestamp
$new_manga = $sql->prep('new_manga', "
SELECT /*+ MAX_EXECUTION_TIME(1800000) */ mangas.manga_id, mangas.manga_name, mangas.manga_image, mangas.manga_hentai, chapters.chapter_id, chapters.chapter_views, chapters.chapter, chapters.upload_timestamp
FROM mangadex_mangas AS mangas
LEFT JOIN mangadex_chapters AS chapters
ON mangas.manga_id = chapters.manga_id
WHERE mangas.manga_hentai = 0 AND chapters.chapter_id IS NOT NULL
GROUP BY mangas.manga_id
ORDER BY mangas.manga_id DESC LIMIT 10
", 'fetchAll', PDO::FETCH_ASSOC, 3600);
", [], 'fetchAll', PDO::FETCH_ASSOC, 3600, true);
//update top follows
echo "top_follows ...\n";
$memcached->delete('top_follows');
$top_follows = $sql->query_read('top_follows', "
SELECT mangas.manga_id, mangas.manga_image, mangas.manga_name, mangas.manga_hentai, mangas.manga_bayesian,
$top_follows = $sql->prep('top_follows', "
SELECT /*+ MAX_EXECUTION_TIME(1800000) */ mangas.manga_id, mangas.manga_image, mangas.manga_name, mangas.manga_hentai, mangas.manga_bayesian,
(SELECT count(*) FROM mangadex_manga_ratings WHERE mangadex_manga_ratings.manga_id = mangas.manga_id) AS count_pop,
(SELECT count(*) FROM mangadex_follow_user_manga WHERE mangadex_follow_user_manga.manga_id = mangas.manga_id) AS count_follows
FROM mangadex_mangas AS mangas
WHERE mangas.manga_hentai = 0
ORDER BY count_follows DESC LIMIT 10
", 'fetchAll', PDO::FETCH_ASSOC, 3600);
", [], 'fetchAll', PDO::FETCH_ASSOC, 3600, true);
//update top rating
echo "top_rating ...\n";
$memcached->delete('top_rating');
$top_rating = $sql->query_read('top_rating', "
SELECT mangas.manga_id, mangas.manga_image, mangas.manga_name, mangas.manga_hentai, mangas.manga_bayesian,
$top_rating = $sql->prep('top_rating', "
SELECT /*+ MAX_EXECUTION_TIME(1800000) */ mangas.manga_id, mangas.manga_image, mangas.manga_name, mangas.manga_hentai, mangas.manga_bayesian,
(SELECT count(*) FROM mangadex_manga_ratings WHERE mangadex_manga_ratings.manga_id = mangas.manga_id) AS count_pop,
(SELECT count(*) FROM mangadex_follow_user_manga WHERE mangadex_follow_user_manga.manga_id = mangas.manga_id) AS count_follows
FROM mangadex_mangas AS mangas
WHERE mangas.manga_hentai = 0
ORDER BY manga_bayesian DESC LIMIT 10
", 'fetchAll', PDO::FETCH_ASSOC, 3600);
", [], 'fetchAll', PDO::FETCH_ASSOC, 3600, true);
//process logs
$last_timestamp = $sql->query_read('last_timestamp', " SELECT visit_timestamp FROM mangadex_logs_visits ORDER BY visit_timestamp ASC LIMIT 1 ", 'fetchColumn', '', -1) + 3600;
echo "last_timestamp ...\n";
$last_timestamp = $sql->prep('last_timestamp', " SELECT /*+ MAX_EXECUTION_TIME(1800000) */ visit_timestamp FROM mangadex_logs_visits ORDER BY visit_timestamp ASC LIMIT 1 ", [], 'fetchColumn', '', -1, true) + 3600;
for($i = $last_timestamp; $i < ($last_timestamp + 3600); $i+=3600) {
$views_guests = $sql->query_read('views_guests', " SELECT count(*) FROM mangadex_logs_visits WHERE visit_timestamp >= ($i - 3600) AND visit_timestamp < $i AND visit_user_id = 0 ", 'fetchColumn', '', -1);
$views_logged_in = $sql->query_read('views_logged_in', " SELECT count(*) FROM mangadex_logs_visits WHERE visit_timestamp >= ($i - 3600) AND visit_timestamp < $i AND visit_user_id > 0 ", 'fetchColumn', '', -1);
$views_guests = $sql->prep('views_guests', " SELECT count(*) FROM mangadex_logs_visits WHERE visit_timestamp >= ($i - 3600) AND visit_timestamp < $i AND visit_user_id = 0 ", [], 'fetchColumn', '', -1, true);
$views_logged_in = $sql->prep('views_logged_in', " SELECT count(*) FROM mangadex_logs_visits WHERE visit_timestamp >= ($i - 3600) AND visit_timestamp < $i AND visit_user_id > 0 ", [], 'fetchColumn', '', -1, true);
$users_guests = $sql->query_read('users_guests', " SELECT COUNT(*) FROM (SELECT `visit_user_id` FROM `mangadex_logs_visits` WHERE visit_timestamp >= ($i - 3600) AND visit_timestamp < $i AND visit_user_id = 0 GROUP BY `visit_ip`) AS `TABLE` ", 'fetchColumn', '', -1);
$users_logged_in = $sql->query_read('users_logged_in', " SELECT COUNT(*) FROM (SELECT `visit_user_id` FROM `mangadex_logs_visits` WHERE visit_timestamp >= ($i - 3600) AND visit_timestamp < $i AND visit_user_id > 0 GROUP BY `visit_user_id`) AS `TABLE` ", 'fetchColumn', '', -1);
$users_guests = $sql->prep('users_guests', " SELECT COUNT(*) FROM (SELECT `visit_user_id` FROM `mangadex_logs_visits` WHERE visit_timestamp >= ($i - 3600) AND visit_timestamp < $i AND visit_user_id = 0 GROUP BY `visit_ip`) AS `TABLE` ", [], 'fetchColumn', '', -1, true);
$users_logged_in = $sql->prep('users_logged_in', " SELECT COUNT(*) FROM (SELECT `visit_user_id` FROM `mangadex_logs_visits` WHERE visit_timestamp >= ($i - 3600) AND visit_timestamp < $i AND visit_user_id > 0 GROUP BY `visit_user_id`) AS `TABLE` ", [], 'fetchColumn', '', -1, true);
$sql->modify('insert', ' INSERT INTO `mangadex_logs_visits_summary` (`id`, `timestamp`, `users_guests`, `users_logged_in`, `views_guests`, `views_logged_in`) VALUES (NULL, ?, ?, ?, ?, ?) ', [$i, $users_guests, $users_logged_in, $views_guests, $views_logged_in]);
$sql->modify('delete', ' DELETE FROM `mangadex_logs_visits` WHERE visit_timestamp >= (? - 3600) AND visit_timestamp < ? ', [$i, $i]);
@ -92,16 +100,22 @@ for($i = $last_timestamp; $i < ($last_timestamp + 3600); $i+=3600) {
}
// Prune old chapter_history data
echo "prune_manga_history ...\n";
$cutoff = time() - (60 * 60 * 24 * 90); // 90 days
$sql->modify('prune_manga_history', 'DELETE FROM mangadex_manga_history WHERE `timestamp` < ?', [$cutoff]);
// Prune expired ip bans
echo "prune_ip_bans ...\n";
$sql->modify('prune_ip_bans', 'DELETE FROM mangadex_ip_bans WHERE expires < UNIX_TIMESTAMP()', []);
// Prune expired sessions each month on the 1st
if (date('j') == 1 && date('G') < 1) {
echo "prune_sessions ...\n";
$sql->modify('prune_sessions', 'DELETE FROM mangadex_sessions WHERE (created + ?) < UNIX_TIMESTAMP()', [60*60*24*365]);
}
//prune old chapter_live_views for trending data
echo "prune_trending ...\n";
$sql->modify('prune_trending', 'DELETE FROM `mangadex_chapter_live_views` WHERE (timestamp + ?) < UNIX_TIMESTAMP()', [60*60*25]);
echo "END @ ".date("F j, Y, g:i a")."\n";

View File

@ -1,15 +1,19 @@
<?php
if (PHP_SAPI !== 'cli')
if (PHP_SAPI !== 'cli') {
die();
}
echo "START @ ".date("F j, Y, g:i a")."\n";
require_once (__DIR__.'/../bootstrap.php');
require_once (ABSPATH . "/scripts/header.req.php");
echo "latest_manga_comments ...\n";
$memcached->delete("latest_manga_comments");
$latest_manga_comments = $sql->query_read('latest_manga_comments', "
SELECT posts.post_id, posts.text, posts.timestamp, posts.thread_id, mangas.manga_name, mangas.manga_id,
$latest_manga_comments = $sql->prep('latest_manga_comments', "
SELECT /*+ MAX_EXECUTION_TIME(600000) */ posts.post_id, posts.text, posts.timestamp, posts.thread_id, mangas.manga_name, mangas.manga_id,
(SELECT (count(*) -1) DIV 20 + 1 FROM mangadex_forum_posts
WHERE mangadex_forum_posts.post_id <= posts.post_id
AND mangadex_forum_posts.thread_id = posts.thread_id
@ -21,11 +25,12 @@ $latest_manga_comments = $sql->query_read('latest_manga_comments', "
ON threads.thread_name = mangas.manga_id
WHERE threads.forum_id = 11 AND threads.thread_deleted = 0
ORDER BY timestamp DESC LIMIT 10
", 'fetchAll', PDO::FETCH_ASSOC, 600);
", [], 'fetchAll', PDO::FETCH_ASSOC, 600, true);
echo "latest_forum_posts ...\n";
$memcached->delete("latest_forum_posts");
$latest_forum_posts = $sql->query_read('latest_forum_posts', "
SELECT posts.post_id, posts.text, posts.timestamp, posts.thread_id, threads.thread_name, forums.forum_name,
$latest_forum_posts = $sql->prep('latest_forum_posts', "
SELECT /*+ MAX_EXECUTION_TIME(600000) */ posts.post_id, posts.text, posts.timestamp, posts.thread_id, threads.thread_name, forums.forum_name,
(SELECT (count(*) -1) DIV 20 + 1 FROM mangadex_forum_posts
WHERE mangadex_forum_posts.post_id <= posts.post_id
AND mangadex_forum_posts.thread_id = posts.thread_id
@ -37,11 +42,12 @@ $latest_forum_posts = $sql->query_read('latest_forum_posts', "
ON threads.forum_id = forums.forum_id
WHERE threads.forum_id NOT IN (11, 12, 14, 17, 18, 20) AND threads.thread_deleted = 0
ORDER BY timestamp DESC LIMIT 10
", 'fetchAll', PDO::FETCH_ASSOC, 600);
", [], 'fetchAll', PDO::FETCH_ASSOC, 600, true);
echo "latest_news_posts ...\n";
$memcached->delete("latest_news_posts");
$latest_forum_posts = $sql->query_read('latest_news_posts', "
SELECT posts.post_id, posts.text, posts.timestamp, posts.thread_id, threads.thread_name, forums.forum_name,
$latest_forum_posts = $sql->prep('latest_news_posts', "
SELECT /*+ MAX_EXECUTION_TIME(600000) */ posts.post_id, posts.text, posts.timestamp, posts.thread_id, threads.thread_name, forums.forum_name,
(SELECT (count(*) -1) DIV 20 + 1 FROM mangadex_forum_posts
WHERE mangadex_forum_posts.post_id <= posts.post_id
AND mangadex_forum_posts.thread_id = posts.thread_id
@ -53,16 +59,17 @@ $latest_forum_posts = $sql->query_read('latest_news_posts', "
ON threads.forum_id = forums.forum_id
WHERE threads.forum_id = 26 AND threads.thread_sticky = 1
ORDER BY timestamp ASC LIMIT 1
", 'fetchAll', PDO::FETCH_ASSOC, 600);
", [], 'fetchAll', PDO::FETCH_ASSOC, 600, true);
///
/// Put delayed chapters that just expired into the last_updated table
///
echo "expired_delayed_chapters ... \n";
// Collect all chapters that have been uploaded as delayed, but where the delay is expired
$expired_delayed_chapters = $sql->query_read('expired_delayed_chapters', '
SELECT c.chapter_id, c.manga_id, c.volume, c.chapter, c.title, c.upload_timestamp, c.user_id, c.lang_id, c.group_id, c.group_id_2, c.group_id_3, c.available FROM mangadex_chapters c, mangadex_delayed_chapters d WHERE d.upload_timestamp < UNIX_TIMESTAMP() AND c.chapter_id = d.chapter_id
', 'fetchAll', PDO::FETCH_ASSOC, -1);
$expired_delayed_chapters = $sql->prep('expired_delayed_chapters', '
SELECT /*+ MAX_EXECUTION_TIME(600000) */ c.chapter_id, c.manga_id, c.volume, c.chapter, c.title, c.upload_timestamp, c.user_id, c.lang_id, c.group_id, c.group_id_2, c.group_id_3, c.available FROM mangadex_chapters c, mangadex_delayed_chapters d WHERE d.upload_timestamp < UNIX_TIMESTAMP() AND c.chapter_id = d.chapter_id
', [], 'fetchAll', PDO::FETCH_ASSOC, -1, true);
// Only process if we found any
if (!empty($expired_delayed_chapters)) {
// Collect all chapter ids in this array, so we can unset them as delayed after this
@ -94,6 +101,8 @@ INSERT INTO mangadex_last_updated (`chapter_id`, `manga_id`, `volume`, `chapter`
$sql->modify('unexpire_delayed_chapters', 'DELETE FROM mangadex_delayed_chapters WHERE chapter_id IN ('.$unexpire_in.')', []);
}
echo "mdAtHomeClient->getStatus(); ... ";
try {
$stats = $mdAtHomeClient->getStatus();
foreach ($stats as $client) {
@ -113,3 +122,10 @@ foreach ($stats as $client) {
$bytes_served = (int) $client['bytes_served'];
$sql->modify('x', " update mangadex_clients set upload_speed = ?, client_ip = ?, client_subsubdomain = ?, client_port = ?, client_available = ?, shard_count = ?, images_served = ?, images_failed = ?, bytes_served = ?, update_timestamp = UNIX_TIMESTAMP() WHERE client_id = ? AND user_id = ? LIMIT 1 ", [$speed, $client_ip, $subsubdomain, $port, $available, $shard_count, $images_served, $images_failed, $bytes_served, $client_id, $user_id]);
}
echo "OK\n";
} catch (\Throwable $t) {
echo "FAIL: ".$t->getMessage()."\n";
}
echo "END @ ".date("F j, Y, g:i a")."\n";

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

BIN
images/agg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
images/misc/dj.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

BIN
images/rock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

4334
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,33 +5,34 @@
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack --config=webpack.config.js --mode=production",
"build": "webpack --config=webpack.config.js --mode=production && webpack --config=webpack-reader.config.js --mode=production",
"build-watch": "webpack --config=webpack.config.js --mode=production --watch",
"build-dev": "webpack --config=webpack.config.js --mode=development"
"build-dev": "webpack --config=webpack.config.js --mode=development",
"build-reader": "webpack --config=webpack-reader.config.js --mode=development"
},
"author": "MangaDex",
"private": true,
"devDependencies": {
"@babel/core": "^7.3.3",
"@babel/plugin-transform-runtime": "^7.2.0",
"@babel/preset-env": "^7.3.1",
"babel-loader": "^8.0.5",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-decorators": "^6.24.1",
"webpack": "^4.29.5",
"webpack-cli": "^3.2.3",
"webpack-merge": "^4.2.1"
"@babel/core": "^7.12.10",
"@babel/plugin-transform-runtime": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"babel-loader": "^8.2.2",
"core-js": "^3.8.2",
"webpack": "^4.46.0",
"webpack-cli": "^3.3.12",
"webpack-merge": "^4.2.2"
},
"dependencies": {
"@babel/polyfill": "^7.2.5",
"@babel/runtime": "^7.3.1",
"date-fns": "^2.0.0-alpha.27",
"dotenv-webpack": "^1.7.0",
"@babel/runtime": "^7.12.5",
"abortcontroller-polyfill": "^1.7.1",
"date-fns": "^2.16.1",
"dotenv-webpack": "^1.8.0",
"eligrey-classlist-js-polyfill": "^1.2.20180112",
"formdata-polyfill": "^3.0.18",
"polyfill-queryselector": "^1.0.2",
"url-polyfill": "^1.1.3",
"formdata-polyfill": "^3.0.20",
"js-cookie": "^2.2.1",
"natsort": "^2.0.2",
"vtt.js": "^0.13.0",
"whatwg-fetch": "^3.0.0"
"whatwg-fetch": "^3.5.0",
"wolfy87-eventemitter": "^5.2.9"
}
}

3
pages/affiliates.req.php Normal file
View File

@ -0,0 +1,3 @@
<?php
$page_html = parse_template('static_pages/affiliates');

View File

@ -0,0 +1,3 @@
<?php
$page_html = parse_template('user/change_activation_email', ['user' => $user]);

View File

@ -1,6 +1,6 @@
<?php
$manga_lists = new Manga_Lists();
$array_of_manga_ids = $manga_lists->get_manga_list(11);
$array_of_manga_ids = $manga_lists->get_manga_list(12);
$search = [];

View File

@ -441,6 +441,7 @@ else {
", array_keys($blocked_group_ids), 'fetchAll', PDO::FETCH_ASSOC, 60);
}
$banners = get_banners();
$featured = $memcached->get('featured');
@ -471,6 +472,7 @@ $templateVars = [
'latest_news_posts' => $latest_news_posts,
'featured' => $featured,
'new_manga' => $new_manga,
'banners' => $banners,
];
$page_html = parse_template('home', $templateVars);

View File

@ -35,12 +35,17 @@ $manga = new Manga($id);
$relation_types = new Relation_Types(); // This is needed, otherwise it breaks manga.req.js
$countryCode = strtoupper(get_country_code($user->last_ip));
if (!isset($manga->manga_id)) {
$page_html = parse_template('partials/alert', ['type' => 'danger', 'strong' => 'Warning', 'text' => "Manga #$id does not exist."]);
}
elseif (in_array($manga->manga_id, RESTRICTED_MANGA_IDS) && !validate_level($user, 'contributor') && $user->get_chapters_read_count() < MINIMUM_CHAPTERS_READ_FOR_RESTRICTED_MANGA) {
$page_html = parse_template('partials/alert', ['type' => 'danger', 'strong' => 'Warning', 'text' => "Manga #$id is not available. Contact staff on discord for more information."]);
}
elseif (isset(REGION_BLOCKED_MANGA[$countryCode]) && in_array($manga->manga_id, REGION_BLOCKED_MANGA[$countryCode]) && !validate_level($user, 'pr')) {
$page_html = parse_template('partials/alert', ['type' => 'danger', 'strong' => 'Warning', 'text' => "Manga #$id is not available."]);
}
else {
update_views_v2($page, $manga->manga_id, $ip);

View File

@ -1,4 +1,7 @@
<?php
use Mangadex\Model\MdexAtHomeClient;
$section = $_GET['section'] ?? 'info';
$approvaltime = $user->get_client_approval_time();
@ -40,6 +43,8 @@ switch ($section) {
'section' => $section,
'user_clients' => $user->get_clients(),
'approvaltime' => $approvaltime,
'ip' => _IP,
'backend' => new MdexAtHomeClient(),
];
if (validate_level($user, 'member')) {
@ -54,6 +59,8 @@ switch ($section) {
'user' => $user,
'section' => $section,
'clients' => $clients,
'ip' => _IP,
'backend' => new MdexAtHomeClient(),
];
if (validate_level($user, 'admin')) {

View File

@ -34,13 +34,20 @@ switch ($mode) {
default:
$deleted = ($mode == 'bin') ? 1 : 0;
$current_page = (isset($_GET['p']) && $_GET['p'] > 0) ? $_GET['p'] : 1;
$limit = 100;
$threads = new PM_Threads($user->user_id, $deleted);
$threads_obj = $threads->query_read();
$threads_obj = $threads->query_read($current_page, $limit);
if ($threads->num_rows < 1) {
$messages_tab_html = parse_template('partials/alert', ['type' => 'info', 'strong' => 'Notice', 'text' => 'You have no messages']);
$messages_tab_html = parse_template('partials/alert', ['type' => 'info', 'strong' => 'Notice', 'text' => 'There are no messages.']);
} else {
$templateVars = [
'thread_count' => $threads->num_rows,
'current_page' => $current_page,
'mode' => $mode,
'limit' => $limit,
'threads' => $threads_obj,
'user' => $user,
'deleted' => $deleted,

View File

@ -1,6 +1,17 @@
<?php
if (!validate_level($user, 'pr')) die('No access');
//pages
$mode = $_GET['mode'] ?? 'banners';
$templateVars = [
'mode' => $mode,
];
$page_html = parse_template('pr/partials/pr_navtabs', $templateVars);
switch ($mode) {
case 'email_search':
$search = [];
if (isset($_GET['username']) && !empty($_GET['username']))
@ -27,12 +38,8 @@ else {
$users_obj = new stdClass();
}
$page_html = "";
$templateVars = ['search' => $search];
$page_html .= parse_template('pr/partials/user_list_searchbox', $templateVars);
$templateVars = [
'search' => $search,
'sort' => $sort,
@ -50,3 +57,12 @@ if (!$search['username'] && !$search['email']) {
} else {
$page_html .= parse_template('user/user_list', $templateVars);
}
break;
case "banners":
default:
$templateVars['banners'] = get_banners(false);
$page_html .= parse_template('pr/banners', $templateVars);
break;
}

View File

@ -1,4 +1,28 @@
<?php
$mode = $_GET['mode'] ?? 'home';
$templateVars = [
'mode' => $mode
];
$page_html = parse_template('support/partials/support_navtabs', $templateVars);
switch ($mode) {
case 'home':
$page_html .= parse_template('support/home', [
'user' => $user
]);
break;
case 'donate':
$wallet_no = substr($user->user_id, -1);
$wallet_no_2 = floor(substr($user->user_id, -1) / 2);
$page_html .= parse_template('support/donate', [
'wallet_no' => $wallet_no,
'wallet_no_2' => $wallet_no_2
]);
break;
case 'history':
$transactions = $user->get_transactions();
if ($transactions) {
@ -7,11 +31,12 @@ if ($transactions) {
$memcached->delete("user_{$user->user_id}_transactions");
}
$wallet_no = substr($user->user_id, -1);
$wallet_no_2 = floor(substr($user->user_id, -1) / 2);
$page_html = parse_template('user/support', [
'user' => $user,
'wallet_no' => $wallet_no,
'wallet_no_2' => $wallet_no_2,
$page_html .= parse_template('support/history', [
'user' => $user
]);
break;
case 'affiliates':
$page_html .= parse_template('support/affiliates', $templateVars);
break;
}

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,41 @@
<?php
class Cache extends Memcached
class Synced_Memcached extends Memcached
{
private $memcachedSync = null;
public function __construct($persistent_id = '', $on_new_object_cb = null, $connection_str = '')
{
parent::__construct($persistent_id, $on_new_object_cb, $connection_str);
if (defined('MEMCACHED_SYNC_HOST') && !empty(MEMCACHED_SYNC_HOST)) {
$this->memcachedSync = new Memcached('sync_host');
if (!$this->memcachedSync->getServerList()) {
// Persistent servers remember the serverlist, so only add if its empty after a php-fpm restart
$this->memcachedSync->addServer(MEMCACHED_SYNC_HOST, defined('MEMCACHED_SYNC_PORT') ? MEMCACHED_SYNC_PORT : 11211);
}
}
}
public function setSynced($key, $value, $expiration = 0, $udf_flags = 0)
{
parent::set($key, $value, $expiration);
if ($this->memcachedSync !== null) {
$this->memcachedSync->set($key, $value, $expiration);
}
}
public function deleteSynced($key, $time = 0)
{
parent::delete($key, $time);
if ($this->memcachedSync !== null) {
$this->memcachedSync->delete($key, $time);
}
}
}
class Cache extends Synced_Memcached
{
const RESULT_CODES = [
@ -76,7 +111,7 @@ class Cache extends Memcached
public function get($key, $cache_cb = null, $flags = null)
{
$res = parent::get($key, $cache_cb, $flags); // TODO: Change the autogenerated stub
$res = parent::get($key, $cache_cb, $flags);
$this->stats[$res === false ? 'miss' : 'hit']++;
$this->log[] = [
'method' => "GET",
@ -88,7 +123,7 @@ class Cache extends Memcached
return $res;
}
public function set($key, $value, $expiration = 0)
public function set($key, $value, $expiration = 0, $udf_flags = 0)
{
parent::set($key, $value, $expiration);
$this->stats['set']++;
@ -103,8 +138,7 @@ class Cache extends Memcached
public function delete($key, $time = 0)
{
parent::delete($key, $time); // TODO: Change the autogenerated stub
parent::delete($key, $time);
$this->stats['delete']++;
$this->log[] = [
'method' => "DEL",

View File

@ -102,7 +102,7 @@ class Chapters {
$limit = prepare_numeric($limit);
$offset = prepare_numeric($limit * ($current_page - 1));
$results = $this->sql->prep("chapters_query_" . hash_array($this->pdo_bind) . "_orderby_".md5($orderby)."_offset_$offset", "
$results = $this->sql->prep("chapters_query_" . hash_array($this->pdo_bind) . "_orderby_" . md5($orderby) . "_offset_$offset" . "_limit_$limit", "
SELECT chapters.*,
lang.*,
users.username,

View File

@ -182,7 +182,7 @@ class User {
SELECT count(*)
FROM mangadex_pm_threads
WHERE (sender_id = ? AND sender_read = 0) OR (recipient_id = ? AND recipient_read = 0)
", [$this->user_id, $this->user_id], 'fetchColumn', '', -1);
", [$this->user_id, $this->user_id], 'fetchColumn', '', 60);
}
public function get_unread_notifications() {
@ -190,7 +190,7 @@ class User {
SELECT count(*)
FROM mangadex_notifications
WHERE mentionee_user_id = ? AND is_read = 0
", [$this->user_id], 'fetchColumn', '');
", [$this->user_id], 'fetchColumn', '', 60);
}
public function get_groups() {
@ -237,20 +237,18 @@ class User {
}
public function get_manga_userdata($manga_id) { //contains progress data, title, and rating
return $this->sql->prep("user_{$this->user_id}_manga_{$manga_id}_api", "
SELECT m.manga_id, m.manga_name AS title, f.follow_type, f.volume, f.chapter, COALESCE(r.rating, 0) as rating
FROM mangadex_mangas m
LEFT JOIN mangadex_follow_user_manga f
ON m.manga_id = f.manga_id AND f.user_id = ?
LEFT JOIN mangadex_manga_ratings r
ON m.manga_id = r.manga_id AND r.user_id = ?
WHERE m.manga_id = ?
", [$this->user_id, $this->user_id, $manga_id], 'fetchAll', PDO::FETCH_ASSOC);
$follows = $this->get_followed_manga_ids_api();
foreach ($follows as $manga) {
if ($manga['manga_id'] == $manga_id) {
return $manga;
}
}
return null;
}
public function get_followed_manga_ids_api() { //contains progress data, title, and rating for all followed manga
return $this->sql->prep("user_{$this->user_id}_followed_manga_ids_api", "
SELECT f.manga_id, m.manga_name AS title, f.follow_type, f.volume, f.chapter, COALESCE(r.rating, 0) as rating
SELECT f.manga_id, m.manga_name AS title, m.manga_hentai, m.manga_image, f.follow_type, f.volume, f.chapter, COALESCE(r.rating, 0) as rating
FROM mangadex_follow_user_manga f
JOIN mangadex_mangas m
ON m.manga_id = f.manga_id
@ -330,7 +328,7 @@ class User {
return $this->sql->prep("user_{$this->user_id}_friends_user_ids", "
SELECT relations.target_user_id, relations.accepted, user.user_id, user.username, user.last_seen_timestamp, user.list_privacy, user_level.level_colour
FROM mangadex_user_relations AS relations
LEFT JOIN mangadex_users AS user
JOIN mangadex_users AS user
ON relations.target_user_id = user.user_id
LEFT JOIN mangadex_user_levels AS user_level
ON user.level_id = user_level.level_id
@ -343,26 +341,26 @@ class User {
return $this->sql->prep("user_{$this->user_id}_pending_friends_user_ids", "
SELECT relations.user_id, user.user_id, user.username, user.last_seen_timestamp, user_level.level_colour
FROM mangadex_user_relations AS relations
LEFT JOIN mangadex_users AS user
JOIN mangadex_users AS user
ON relations.user_id = user.user_id
LEFT JOIN mangadex_user_levels AS user_level
ON user.level_id = user_level.level_id
WHERE relations.relation_id = 1 AND relations.accepted = 0 AND relations.target_user_id = ?
ORDER BY user.username ASC
", [$this->user_id], 'fetchAll', PDO::FETCH_UNIQUE);
", [$this->user_id], 'fetchAll', PDO::FETCH_UNIQUE, 60*60*24);
}
public function get_blocked_user_ids() {
return $this->sql->prep("user_{$this->user_id}_blocked_user_ids", "
SELECT relations.target_user_id, user.user_id, user.username, user_level.level_colour
FROM mangadex_user_relations AS relations
LEFT JOIN mangadex_users AS user
JOIN mangadex_users AS user
ON relations.target_user_id = user.user_id
LEFT JOIN mangadex_user_levels AS user_level
ON user.level_id = user_level.level_id
WHERE relations.relation_id = 0 AND relations.user_id = ? AND user.level_id < ?
ORDER BY user.username ASC
", [$this->user_id, 10 /** staff level: PR **/], 'fetchAll', PDO::FETCH_UNIQUE);
", [$this->user_id, 10 /** staff level: PR **/], 'fetchAll', PDO::FETCH_UNIQUE, 60*60*24);
}
public function get_active_restrictions() {
@ -515,15 +513,18 @@ class PM_Threads {
FROM mangadex_pm_threads
WHERE (sender_id = ? AND sender_deleted = ?)
OR (recipient_id = ? AND recipient_deleted = ?)
ORDER BY thread_timestamp DESC LIMIT 20
", [$user_id, $deleted, $user_id, $deleted], 'fetchColumn', '', -1);
$this->user_id = $user_id;
$this->deleted = $deleted;
}
public function query_read() {
$results = $this->sql->prep("user_{$this->user_id}_PMs", "
public function query_read($page = 1, $limit = 100)
{
$offset = ($page - 1) * $limit;
$results = $this->sql->prep(
"user_{$this->user_id}_PMs",
"
SELECT threads.*,
sender.username AS sender_username,
recipient.username AS recipient_username,
@ -541,8 +542,13 @@ class PM_Threads {
WHERE (threads.sender_id = ? AND threads.sender_deleted = ?)
OR (threads.recipient_id = ? AND threads.recipient_deleted = ?)
ORDER BY threads.thread_timestamp DESC
LIMIT 100
", [$this->user_id, $this->deleted, $this->user_id, $this->deleted], 'fetchAll', PDO::FETCH_ASSOC, -1);
LIMIT ? OFFSET ?
",
[$this->user_id, $this->deleted, $this->user_id, $this->deleted, $limit, $offset],
'fetchAll',
PDO::FETCH_ASSOC,
-1
);
//return get_results_as_object($results, 'thread_id');
return $results;

View File

@ -19,7 +19,6 @@
border-radius: 5px;
}
.noselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-epub-user-select: none;
-moz-user-select: none;
@ -29,11 +28,6 @@
}
.nodrag {
-webkit-user-drag: none;
-epub-user-drag: none;
-moz-user-drag: none;
-ms-user-drag: none;
-o-user-drag: none;
user-drag: none;
}
.noevents {
-webkit-pointer-events: none;
@ -70,12 +64,14 @@ body {
.reader-page-bar,
.reader-page-bar .trail,
.reader-page-bar .thumb,
.reader-page-bar .track {
.reader-page-bar .track,
.reader-controls-collapser:before,
.reader-controls-collapser span {
transition-property: all;
transition-duration: 0.2s;
}
#reader-controls-collapser span {
transition-property: all;
#reader-controls-collapser-bar {
transition-property: opacity;
transition-duration: 0.4s;
}
@ -88,18 +84,12 @@ nav.navbar {
.footer {
border-top: 1px solid rgba(128, 128, 128, 0.5);
}
#reader-controls-collapser {
border-left: 1px solid rgba(128, 128, 128, 0.5);
border-right: 1px solid rgba(128, 128, 128, 0.5);
}
/* settings and controls */
#modal-settings:not(.show-advanced) .advanced {
display: none;
}
#modal-settings .advanced label {
}
#modal-settings .advanced label:before {
content: '* ';
}
@ -110,12 +100,70 @@ nav.navbar {
left: 0;
max-width: 100vw;
}
#reader-controls-collapser {
width: 34px;
.reader-controls-collapser {
display: none;
color: #eee;
text-shadow: 0px 0px 3px rgba(0, 0, 0, 0.5);
}
.reader.hide-sidebar #reader-controls-collapser .fa-caret-right {
.reader-controls-collapser:hover {
color: #fff;
}
#reader-controls-collapser-button {
position: absolute;
width: 2.75rem;
height: 2.75rem;
}
#reader-controls-collapser-button > span {
position: absolute;
z-index: 100;
margin-right: 1rem;
margin-bottom: 0.7rem;
}
#reader-controls-collapser-button:before {
content: "";
position: absolute;
width: 0;
height: 0;
border-style: solid;
border-width: 2.75rem 2.75rem 0 0;
border-color: rgba(128, 128, 128, 0.2) transparent transparent transparent;
}
#reader-controls-collapser-button:hover:before {
border-color: rgba(128, 128, 128, 0.4) transparent transparent transparent;
}
.reader.hide-sidebar #reader-controls-collapser-button {
position: fixed;
z-index: 99;
top: 3.5rem;
right: 0;
}
.reader.hide-sidebar.hide-header #reader-controls-collapser-button {
top: 0;
}
.reader.hide-sidebar #reader-controls-collapser-button {
transform: rotateY(180deg);
}
#reader-controls-collapser-bar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
margin-left: -40px;
width: 40px;
background: linear-gradient(to right, rgba(64, 64, 64, 0), rgba(64, 64, 64, 0.4));
opacity: 0;
}
#reader-controls-collapser-bar:hover {
opacity: 1;
}
.reader.hide-sidebar #reader-controls-collapser-bar span {
transform: rotateY(180deg);
}
#reader-controls-collapser-bar span {
font-size: 2.75rem;
margin-left: 0.3rem;
}
.reader-controls-mode span:not(.fas) {
display: none
}
@ -333,9 +381,6 @@ body {
.reader-page-bar:hover .thumb {
background: #eee;
}
.reader-page-bar:hover .track {
/*border: 2px solid #ccc;*/
}
.reader-page-bar .notch:not(.loaded) {
background: repeating-linear-gradient(
-45deg,
@ -431,6 +476,11 @@ body {
min-height: 100%;
}
/* cursor hiding */
.hide-cursor .reader-images img {
cursor: none;
}
/* Modernizr */
.no-localstorage #alert-storage-warning {
@ -454,6 +504,15 @@ body {
/* desktop definitions */
@media (min-width: 992px) {
.reader-controls {
border-left: 1px solid rgba(128, 128, 128, 0.5);
}
.reader-controls-title {
padding-left: 0 !important;
}
.reader-controls-title .manga-title-col {
padding: 0 2.75rem;
}
.reader.layout-horizontal .reader-controls-wrapper {
order: 2;
@ -462,6 +521,10 @@ body {
order: 1;
}
#right_swipe_area {
display: none;
}
/* controls */
.reader:not(.layout-horizontal) .d-lg-none {
@ -469,7 +532,7 @@ body {
}
.reader:not(.layout-horizontal) .reader-controls-pages,
.reader:not(.layout-horizontal) .reader-controls-footer,
.reader:not(.layout-horizontal) #reader-controls-collapser
.reader:not(.layout-horizontal) .reader-controls-collapser
{
display: none !important;
}
@ -486,12 +549,17 @@ body {
top: 0;
}
.reader.layout-horizontal.hide-sidebar .reader-controls-wrapper {
width: 34px;
width: 0;
}
.reader.layout-horizontal.hide-sidebar .reader-controls {
overflow: hidden;
}
.reader[data-collapser="bar"] #reader-controls-collapser-bar,
.reader[data-collapser="button"] #reader-controls-collapser-button {
display: flex;
}
/* load icon */
.reader.layout-horizontal .reader-load-icon {
@ -511,7 +579,7 @@ body {
width: auto;
}
.reader.layout-horizontal.hide-sidebar .reader-page-bar {
right: 34px;
right: 0;
}
.reader.layout-horizontal .reader-page-bar:hover .track {
height: 50px;
@ -532,7 +600,8 @@ body {
padding-right: 20vw !important;
}
.reader.layout-horizontal.hide-sidebar .reader-images {
padding-right: 34px !important;
padding-right: 0 !important;
}
}

View File

@ -11,9 +11,9 @@ body {
font-family: "Ubuntu", sans-serif;
}
.flag{display:inline-block;background:url(/images/flags-flat-24.png) no-repeat;background-size:100%;width:24px;min-width:24px;height:24px;vertical-align:bottom;opacity:0.75;}
.flag{display:inline-block;background:url(/images/flags-flat-24-20201130.png) no-repeat;background-size:100%;width:24px;min-width:24px;height:24px;vertical-align:bottom;opacity:0.75;}
.flag-_unknown{background-position:0 -0px}.flag-bd{background-position:0 -24px}.flag-bg{background-position:0 -48px}.flag-br{background-position:0 -72px}.flag-cn{background-position:0 -96px}.flag-ct{background-position:0 -120px}.flag-cz{background-position:0 -144px}.flag-de{background-position:0 -168px}.flag-dk{background-position:0 -192px}.flag-es{background-position:0 -216px}.flag-fi{background-position:0 -240px}.flag-fr{background-position:0 -264px}.flag-gb{background-position:0 -288px}.flag-gr{background-position:0 -312px}.flag-hk{background-position:0 -336px}.flag-hu{background-position:0 -360px}.flag-id{background-position:0 -384px}.flag-il{background-position:0 -408px}.flag-in{background-position:0 -432px}.flag-ir{background-position:0 -456px}.flag-it{background-position:0 -480px}.flag-jp{background-position:0 -504px}.flag-kr{background-position:0 -528px}.flag-lt{background-position:0 -552px}.flag-mm{background-position:0 -576px}.flag-mn{background-position:0 -600px}.flag-mx{background-position:0 -624px}.flag-my{background-position:0 -648px}.flag-nl{background-position:0 -672px}.flag-no{background-position:0 -696px}.flag-ph{background-position:0 -720px}.flag-pl{background-position:0 -744px}.flag-pt{background-position:0 -768px}.flag-ro{background-position:0 -792px}.flag-rs{background-position:0 -816px}.flag-ru{background-position:0 -840px}.flag-sa{background-position:0 -864px}.flag-se{background-position:0 -888px}.flag-th{background-position:0 -912px}.flag-tr{background-position:0 -936px}.flag-ua{background-position:0 -960px}.flag-vn{background-position:0 -984px}
.flag-_unknown{background-position:0 -0px}.flag-bd{background-position:0 -24px}.flag-bg{background-position:0 -48px}.flag-br{background-position:0 -72px}.flag-cn{background-position:0 -96px}.flag-ct{background-position:0 -120px}.flag-cz{background-position:0 -144px}.flag-de{background-position:0 -168px}.flag-dk{background-position:0 -192px}.flag-es{background-position:0 -216px}.flag-fi{background-position:0 -240px}.flag-fr{background-position:0 -264px}.flag-gb{background-position:0 -288px}.flag-gr{background-position:0 -312px}.flag-hk{background-position:0 -336px}.flag-hu{background-position:0 -360px}.flag-id{background-position:0 -384px}.flag-il{background-position:0 -408px}.flag-in{background-position:0 -432px}.flag-ir{background-position:0 -456px}.flag-it{background-position:0 -480px}.flag-jp{background-position:0 -504px}.flag-kr{background-position:0 -528px}.flag-kz{background-position:0 -552px}.flag-lt{background-position:0 -576px}.flag-mm{background-position:0 -600px}.flag-mn{background-position:0 -624px}.flag-mx{background-position:0 -648px}.flag-my{background-position:0 -672px}.flag-nl{background-position:0 -696px}.flag-no{background-position:0 -720px}.flag-ph{background-position:0 -744px}.flag-pl{background-position:0 -768px}.flag-pt{background-position:0 -792px}.flag-ro{background-position:0 -816px}.flag-rs{background-position:0 -840px}.flag-ru{background-position:0 -864px}.flag-sa{background-position:0 -888px}.flag-se{background-position:0 -912px}.flag-th{background-position:0 -936px}.flag-tr{background-position:0 -960px}.flag-ua{background-position:0 -984px}.flag-vn{background-position:0 -1008px}
.badge {
user-select: none;

View File

@ -529,12 +529,6 @@ function display_manga_ext_links($links_array) {
}
}
function display_manga_logo_link($manga) {
return "<a alt='Manga $manga->manga_id' title='$manga->manga_name' href='/title/$manga->manga_id/" . slugify($manga->manga_name) . "'>
<img class='rounded' src='" . IMG_SERVER_URL . "/images/manga/$manga->manga_id.thumb.jpg' alt='Manga image' />
</a>";
}
function display_js_posting() {
return "
$('.bbcode').click(function(){
@ -640,7 +634,7 @@ function display_forum($forum, $user) {
$return = "
<div class='d-flex row m-0 py-1 border-bottom align-items-center'>
<div class='col-auto px-2 ' >
<a href='/forum/$forum->forum_id'><img src='" . LOCAL_SERVER_URL . "/images/forums/$forum->forum_name.svg' width='70px' ></a>
<a href='/forum/$forum->forum_id'><img src='" . LOCAL_SERVER_URL . "/images/forums/" . str_replace(' ', '-', $forum->forum_name) . ".svg' width='70px' ></a>
</div>
<div class='col p-0 text-truncate'>
<div class='row m-2'>
@ -1520,6 +1514,12 @@ function display_lock_manga($user, $manga) {
}
}
function display_regenerate_manga_thumb($user) {
if (validate_level($user, 'mod')) {
return "<button class='btn btn-info' id='manga_regenerate_thumb_button'>" . display_fa_icon('sync', 'Regenerate thumb') . " <span class='d-none d-xl-inline'>Regenerate thumb</span></button>";
}
}
function display_delete_manga($user) {
if (validate_level($user, 'admin'))
return "<button class='btn btn-danger float-right' id='delete_button'>" . display_fa_icon('trash', 'Delete') . " <span class='d-none d-xl-inline'>Delete</span></button>";

View File

@ -998,6 +998,24 @@ function get_zip_originalsize($filename) {
return $size;
}
function get_banners($enabledOnly = true){
global $sql;
$query = "
SELECT banner_id, banners.user_id, username, ext, is_enabled, is_anonymous, levels.level_name, levels.level_colour
FROM mangadex_banners banners
JOIN mangadex_users users
ON banners.user_id = users.user_id
JOIN mangadex_user_levels levels
ON users.level_id = levels.level_id
";
if($enabledOnly){
$query .= " WHERE is_enabled = 1";
}
$banners = $sql->prep("banners_" . ($enabledOnly ? "enabled" : "all"), $query, [], 'fetchAll', PDO::FETCH_ASSOC, 600);
return $banners;
}
/*************************************
* Discord webhook
*************************************/

View File

@ -36,6 +36,7 @@ $opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => defined('DB_PERSISTENT') ? (bool)DB_PERSISTENT : false,
];
class SQL extends PDO {
@ -45,11 +46,35 @@ class SQL extends PDO {
/** @var \PDO */
private $slave_sql;
private $credentials = [];
private $isConnected = false;
public function __construct(string $dsn_master, array $dsn_slaves, $username = null, $passwd = null, $options = null)
{
$this->credentials = [
'dsn_master' => $dsn_master,
'dsn_slaves' => $dsn_slaves,
'username' => $username,
'passwd' => $passwd,
'options' => $options,
];
}
private function ensureConnected(): void
{
if (!$this->isConnected) {
$dsn_master = $this->credentials['dsn_master'];
$dsn_slaves = $this->credentials['dsn_slaves'];
$username = $this->credentials['username'];
$passwd = $this->credentials['passwd'];
$options = $this->credentials['options'];
$this->credentials = [];
// Establish connection with master
parent::__construct($dsn_master, $username, $passwd, $options);
$this->isConnected = true;
// Randomize pick order
shuffle($dsn_slaves);
@ -75,14 +100,16 @@ class SQL extends PDO {
// Fall back to master
$this->slave_sql = $this;
}
}
public function query_read($name, $query, $fetch, $pdo_mode, $expiry = 0) {
global $memcached;
$name = str_replace(' ', '_', $name);
if ($expiry < 0) //delete from cache and update
if ($expiry < 0) {
$memcached->delete($name);
}
$start = microtime(true);
@ -90,15 +117,20 @@ class SQL extends PDO {
$from_cache = 'Y';
if ($cache === FALSE) {
if ($fetch == 'fetchAll')
$this->ensureConnected();
if ($fetch === 'fetchAll') {
$cache = $this->slave_sql->query($query)->fetchAll($pdo_mode);
elseif ($fetch == 'fetchColumn')
}
elseif ($fetch === 'fetchColumn') {
$cache = $this->slave_sql->query($query)->fetchColumn();
else
}
else {
$cache = $this->slave_sql->query($query)->fetch($pdo_mode);
}
if ($expiry >= 0)
if ($expiry >= 0) {
$memcached->set($name, $cache, $expiry);
}
$from_cache = 'N';
}
@ -110,13 +142,14 @@ class SQL extends PDO {
return $cache;
}
public function prep($name, $query, $bind, $fetch, $pdo_mode = '', $expiry = 0) {
public function prep($name, $query, $bind, $fetch, $pdo_mode = '', $expiry = 0, $force_master = false) {
global $memcached;
$name = str_replace(' ', '_', $name);
if ($expiry < 0) //delete from cache and update
if ($expiry < 0) {
$memcached->delete($name);
}
$start = microtime(true);
@ -124,7 +157,8 @@ class SQL extends PDO {
$from_cache = 'Y';
if ($cache === FALSE) {
$stmt = $this->slave_sql->prepare($query);
$this->ensureConnected();
$stmt = $force_master ? $this->prepare($query) : $this->slave_sql->prepare($query);
$stmt->execute($bind);
switch ($fetch) {
@ -141,8 +175,9 @@ class SQL extends PDO {
break;
}
if ($expiry >= 0)
if ($expiry >= 0) {
$memcached->set($name, $cache, $expiry);
}
$from_cache = 'N';
}
@ -164,6 +199,7 @@ class SQL extends PDO {
$from_cache = '/';
$this->ensureConnected();
$stmt = $this->prepare($query);
$stmt->execute($bind);
@ -178,10 +214,6 @@ class SQL extends PDO {
return $this->lastInsertId();
}
public function modify_deferred($name, $query, $bind) {
}
public function debug() {
global $memcached;
@ -305,7 +337,7 @@ require_once ABSPATH . '/scripts/classes/cache.class.req.php';
if (defined('CAPTURE_CACHE_STATS') && CAPTURE_CACHE_STATS) {
$memcached = new Cache();
} else {
$memcached = new Memcached();
$memcached = new Synced_Memcached();
}
$memcached->addServer(MEMCACHED_HOST, 11211);

View File

@ -0,0 +1,5 @@
<?php
if ($user->user_id){
print jquery_post("change_activation_email", 0, "check", "Confirm", "Confirming", "Your email has been updated.", "");
}
?>

View File

@ -397,6 +397,8 @@ $(".mass_edit_delete_button").click(function(event){
<?= jquery_get("manga_unlock", $id, '', "Unlock", "Unlocking", "You have unlocked this manga.", "location.reload();") ?>
<?= jquery_get("manga_regenerate_thumb", $id, '', "Regenerate thumb", "Regenerating thumb", "You have regenerated the thumbnail.", "location.reload();") ?>
<?php } ?>
<?php if (validate_level($user, 'gmod')) { ?>

View File

@ -1,7 +1,93 @@
<?php
switch ($mode) {
case 'email_search':
?>
$("#user_search_form").submit(function(event) {
var email = encodeURIComponent($("#email").val());
var username = encodeURIComponent($("#username").val());
$("#search_button").html("<?= display_fa_icon('spinner', '', 'fa-pulse') ?> Searching...").attr("disabled", true);
location.href = "/pr/?username="+username+"&email="+email;
location.href = "/pr/email_search?username="+username+"&email="+email;
event.preventDefault();
});
<?php
break;
case 'banners':
default:
?>
$("#banner_upload_form").submit(function(evt) {
evt.preventDefault();
$("#banner_upload_button").html("<span class='fas fa-spinner fa-pulse' aria-hidden='true' title=''></span> Uploading...").attr("disabled", true);
const success_msg = "<div class='alert alert-success text-center' role='alert'><strong>Success:</strong> Your banner has been uploaded.</div>";
const error_msg = "<div class='alert alert-warning text-center' role='alert'><strong>Warning:</strong> Something went wrong with your upload.</div>";
const form = this;
const formdata = new FormData(form);
$.ajax({
url: "/ajax/actions.ajax.php?function=banner_upload",
type: 'POST',
data: formdata,
cache: false,
contentType: false,
processData: false,
success: function (data) {
if (!data) {
$("#message_container").html(success_msg).show().delay(3000).fadeOut();
location.reload();
}
else {
$("#banner_upload_button").html("<?= display_fa_icon('upload') ?> Upload").attr("disabled", false);
$("#message_container").html(data).show().delay(5000).fadeOut();
}
},
error: function(err) {
console.error(err);
$("#banner_upload_button").html("<?= display_fa_icon('upload') ?> Upload").attr("disabled", false);
$("#message_container").html(error_msg).show().delay(5000).fadeOut();
}
});
});
$(".toggle_banner_edit_button, .cancel_banner_edit_button").click(function(evt) {
evt.preventDefault();
let id = $(this).attr("data-toggle");
$("#banner_edit_" + id).toggle();
$("#banner_" + id).toggle();
});
$(".banner_edit_form").submit(function(evt) {
evt.preventDefault();
const id = $(this).attr("data-banner-id");
const success_msg = "<div class='alert alert-success text-center' role='alert'><strong>Success:</strong> Your banner has been edited.</div>";
const error_msg = "<div class='alert alert-warning text-center' role='alert'><strong>Warning:</strong> Something went wrong with your edit.</div>";
const formData = new FormData($(this)[0]);
$("#banner_edit_button_"+id).html("<?= display_fa_icon('spinner', '', 'fa-pulse') ?>").attr("disabled", true);
$.ajax({
url: "/ajax/actions.ajax.php?function=banner_edit&banner_id=" + id,
type: 'POST',
data: formData,
cache: false,
contentType: false,
processData: false,
success: function(data) {
if (!data) {
$("#message_container").html(success_msg).show().delay(3000).fadeOut();
}
else {
$("#message_container").html(data).show().delay(10000).fadeOut();
}
$("#banner_edit_button_" + id).html("<?= display_fa_icon('pencil-alt', '', 'fa-fw') ?>").attr("disabled", false);
},
error: function(err) {
console.error(err);
$("#banner_edit_button_" + id).html("<?= display_fa_icon('pencil-alt', '', 'fa-fw') ?>").attr("disabled", false);
$("#message_container").html(error_msg).show().delay(10000).fadeOut();
}
});
});
<?php
break;
}
?>

View File

@ -1,16 +0,0 @@
/* closest */
if (!Element.prototype.matches) {
Element.prototype.matches = Element.prototype.msMatchesSelector ||
Element.prototype.webkitMatchesSelector;
}
if (!Element.prototype.closest) {
Element.prototype.closest = function(s) {
var el = this;
do {
if (el.matches(s)) return el;
el = el.parentElement || el.parentNode;
} while (el !== null && el.nodeType === 1);
return null;
};
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,213 @@
import EventEmitter from 'wolfy87-eventemitter'
export default class ReaderPageModel extends EventEmitter {
constructor(number, chapterId, url, fallbackURL) {
super()
this._number = number
this._chapter = chapterId
this._state = ReaderPageModel.STATE_WAIT
this._error = null
this._url = url
this._fallbackURL = fallbackURL
this._image = new Image()
this.addImageListeners()
}
get number() { return this._number }
get chapter() { return this._chapter }
get image() { return this._image }
get waiting() { return this.state === ReaderPageModel.STATE_WAIT }
get loading() { return this.state === ReaderPageModel.STATE_LOADING }
get loaded() { return this.state === ReaderPageModel.STATE_LOADED }
get hasError() { return this.state === ReaderPageModel.STATE_ERROR }
get isDone() { return this.loaded || this.hasError }
get error() { return this._error }
get state() { return this._state }
set state(v) {
this._state = v
this.trigger('statechange', [this])
}
get stateName() {
switch (this.state) {
case 0: return 'wait'
case 1: return 'loading'
case 2: return 'loaded'
case 3: return 'error'
}
}
load(breakCache = false) {
return new Promise((resolve, reject) => {
if (!breakCache && this.isDone) {
return resolve(this)
} else {
if (!this.loading) {
this._error = null
this.unload()
loadImage(this._url, this._fallbackURL).then(url => {
this._image.src = url
}).catch(e => {
this._error = e
this.state = ReaderPageModel.STATE_ERROR
reject(this)
})
this.state = ReaderPageModel.STATE_LOADING
}
this.once('statechange', () => {
switch (this.state) {
case ReaderPageModel.STATE_LOADED: return resolve(this)
case ReaderPageModel.STATE_ERROR: return reject(this)
}
})
}
})
}
unload() {
try {
if (this._image.src && this._image.src.startsWith('blob:')) {
URL.revokeObjectURL(this._image.src)
this._image.src = ''
}
} catch () {}
}
addImageListeners() {
const _errorHandler = () => {
this._error = new Error(`Image #${this.number} failed to load.`)
this.state = ReaderPageModel.STATE_ERROR
this.unload()
}
const _loadHandler = () => {
this.state = ReaderPageModel.STATE_LOADED
try { this._image.decode() } catch (e) { }
}
this._image.addEventListener('error', _errorHandler)
this._image.addEventListener('load', _loadHandler)
}
reload(breakCache = false) {
return this.load(this.hasError || breakCache)
}
static get STATE_WAIT() { return 0 }
static get STATE_LOADING() { return 1 }
static get STATE_LOADED() { return 2 }
static get STATE_ERROR() { return 3 }
}
async function loadImage(primaryURL, fallbackURL) {
try {
await caches.delete('mangadex_images')
} catch () {}
// Otherwise fetch the image and time how long it takes
let resp, timing, body
if (!fallbackURL || primaryURL === fallbackURL || !window.AbortController) {
// If we only have one URL, load it normally
;[resp, timing, body] = await fetchWithTiming(primaryURL, null)
} else {
// Otherwise things get weird. We want to race the requests
// but also get a working response if possible.
const primaryA = new AbortController()
const fallbackA = new AbortController()
// Make a promise for each URL, starting the fallback 5s after the primary
const primaryP = fetchWithTiming(primaryURL, primaryA.signal).catch(e => {
return [new Response(null, { status: 599 }), 0, 0]
})
const fallbackP = sleep(5000, fallbackA.signal)
.then(() => {
return fetchWithTiming(fallbackURL, fallbackA.signal)
})
.catch(e => {
return [new Response(null, { status: 599 }), 0, 0]
})
// Get the first response
;[resp, timing, body] = await Promise.race([primaryP, fallbackP])
// If the first response failed, wait for the second
if (!resp.ok) {
const results = await Promise.all([primaryP, fallbackP])
// Find the first good result, if it exists
;[resp, timing, body] = results.find(([r]) => r.ok) || results[0]
}
// Cancel the other request
// We need seperate AbortControllers & abort calls since abort also cancels the *response*
// normalize the URLs so we can actually compare
if (new URL(resp.url).href === new URL(primaryURL).href) {
fallbackA.abort()
} else {
primaryA.abort()
}
}
// Async report to MD@H server how it went, if applicable
if (primaryURL && /mangadex\.network/.test(primaryURL)) {
const success = (new URL(resp.url).href === new URL(primaryURL).href) && resp.ok
fetch('https://api.mangadex.network/report', {
method: 'post',
mode: 'cors',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: primaryURL,
success: success,
bytes: success ? body.size : 0,
duration: success ? timing : 0,
cached: success && resp.headers.get('X-Cache') === 'HIT',
}),
keepalive: true, // Keep sending the report even if the user leaves the page as we send it
})
.then(res => {
if (!res.ok) throw res
})
.catch(e => null)
}
return URL.createObjectURL(body)
}
function sleep(ms, signal) {
return new Promise((resolve) => {
if (signal.aborted) return resolve()
const t = setTimeout(resolve, ms)
signal.addEventListener('abort', () => {
clearTimeout(t)
resolve()
})
})
}
async function fetchWithTiming(url, signal) {
let resp, body
const networkImage = /mangadex\.network/.test(url)
const start = 'performance' in self ? performance.now() : +new Date()
const hashM = /\/(?:data)\/.*-([0-9a-f]{64})\.[a-z]{3,4}$/.exec(url)
resp = await fetch(url, {
mode: 'cors',
cache: networkImage ? 'no-store' : 'default',
referrer: 'no-referrer',
redirect: 'error', // Cross-origin redirects strip required headers, so explicitly error instead
integrity: hashM ? `sha256-${hex2b64(hashM[1])}` : undefined, // if the image has a hash in it, check that it matches what we got
signal, // Cancel the fetch if we already got a response
})
body = await resp.blob()
const end = 'performance' in self ? performance.now() : +new Date()
return [resp, end - start, body]
}
function hex2b64(s) {
let b = ''
const n = Math.ceil(s.length / 2)
for (let i = 0, o = 0; i < n; i++, o += 2) {
b += String.fromCharCode(parseInt(s.substr(o, 2), 16))
}
return btoa(b)
}

View File

@ -0,0 +1,61 @@
import { StoreCookie, StoreLocalStorage } from './Store'
const SettingStore = Modernizr.localstorage ? StoreLocalStorage : StoreCookie
export default class ReaderSetting {
constructor(name, def, test, parser) {
this._name = name
this._default = def
this._value = def
this.test = test || ReaderSetting.getTest(def)
this.parser = parser || ReaderSetting.getParser(def)
}
get name() { return this._name }
get default() { return this._default }
get value() { return this._value }
set value(val) {
const parsedVal = this.parser(val)
if (this.test(parsedVal)) {
this._value = parsedVal
}
}
load() {
this.value = SettingStore.load(this.name)
}
save(val) {
this.value = val
if (this.value === this.default) {
SettingStore.clear(this.name)
} else {
SettingStore.save(this.name, this.value)
}
}
clear() {
SettingStore.clear(this.name)
}
static getTest(val) {
switch (typeof val) {
case 'number': return (val) => !isNaN(val)
case 'string': return (val) => typeof val === 'string'
default: return (val) => true
}
}
static getParser(val) {
switch (typeof val) {
case 'number': return (val) => parseFloat(val)
default: return (val) => val
}
}
static clearAllExcept(retainedKeys) {
SettingStore.clearAllExcept(retainedKeys)
}
}

54
scripts/reader/Store.js Normal file
View File

@ -0,0 +1,54 @@
import Cookies from 'js-cookie'
class Store {
static save(key, val) { }
static load(key) { }
static clear(key) { }
}
export class StoreLocalStorage extends Store {
static save(key, val) {
localStorage.setItem(`reader.${key}`, val)
}
static load(key) {
return localStorage.getItem(`reader.${key}`)
}
static clear(key) {
localStorage.removeItem(`reader.${key}`)
}
static clearAllExcept(retainedKeys) {
retainedKeys = retainedKeys.map(key => `reader.${key}`)
for (let [key, value] of Object.entries(localStorage)) {
if (key.startsWith('reader.') && retainedKeys.indexOf(key) === -1) {
localStorage.removeItem(key)
}
}
}
}
export class StoreCookie extends Store {
static get cookies() {
return document.cookie.split(';').reduce((a, c) => {
const [k, v] = c.split('=')
a[k.trim()] = v.trim()
return a
}, {})
}
static save(key, val) {
Cookies.set(`r-${key}`, val)
}
static load(key) {
return Coookies.get(`r-${key}`)
}
static clear(key) {
Coookies.remove(`r-${key}`)
}
static clearAllExcept(retainedKeys) {
retainedKeys = retainedKeys.map(key => `r-${key}`)
for (let [key, value] of Object.entries(Cookies.get())) {
if (key.startsWith('reader.') && retainedKeys.indexOf(key) === -1) {
Coookies.remove(key)
}
}
}
}

418
scripts/reader/api.js Normal file
View File

@ -0,0 +1,418 @@
import Utils from './utils.js'
import natsort from 'natsort'
export default class Resource {
get resourceType() { return 'resource' }
get resourceFormat() { return 'json' }
constructor(data = {}) {
this.initialize(data)
}
initialize(data = {}) {
this._data = data
}
get status() { return this._data.status }
load(opts = {}, force = false) {
const id = opts.id != null ? opts.id : ''
const type = this.resourceType
return new Promise((resolve, reject) => {
opts.type = type
if (!(type in Resource.cache)) {
Resource.cache[type] = {}
}
if (!force && id in Resource.cache[type]) {
return resolve(Resource.cache[type][id])
}
const baseURL = opts.baseURL || window.location
delete opts.baseURL
const url = new URL(this.constructor.API_URL(), baseURL)
for (let key in opts) {
url.searchParams.append(key, opts[key])
}
return resolve(fetch(url, {
credentials: 'same-origin',
}).catch(err => {
console.error(err)
this.response = err
this._data.message = err.message
throw err
}).then(res => {
this.response = res
if (!res.ok) {
console.error('Fetch not ok:', type, id, res)
return { id }
}
if (type === 'follows') {
return res.text()
} else {
return res.json().catch(err => {
console.error('JSON parsing error:', err)
return { id }
})
}
}).then(json => {
Resource.cache[type][id] = this
this.initialize(json)
if (!this.response.ok) {
console.error('Response status:', this.response.status, this.response.statusText)
console.error('Resource status:', json.status)
}
return this
}))
}).catch(err => {
console.error('Error:', err)
throw err
})
}
static create(opts, force) {
const resource = new this()
return resource.load(opts, force)
}
}
Resource.cache = {}
var xx = 0
export class Manga extends Resource {
get resourceType() {
return xx++ ? 'manga' : 'mangaa'
}
// static API_URL(id = '') { return `/api/manga/${id}` }
static API_URL() { return `/api/` }
initialize(data) {
super.initialize(data.manga)
this.chapters = Object.entries(data.chapter || {}).map(([id, ch]) => { ch.id = parseInt(id); return ch })
this.chapterList = []
}
get id() { return this._data.id }
get title() { return this._data.title || '' }
get langCode() { return this._data.lang_flag }
get langName() { return this._data.lang_name }
get lastChapter() { return this._data.last_chapter }
get isLongStrip() { return this._data.genres && this._data.genres.includes(36) }
get isDoujinshi() { return this._data.genres && this._data.genres.includes(7) }
get isHentai() { return !!this._data.hentai }
get url() {
const title = this.title.toLowerCase().replace(/&[0-9a-z]+;/gi, '').replace(/[^0-9a-z]/gi, ' ').split(' ').filter(t => t).join('-')
return `/title/${this.id}/${title}`
}
get coverUrl() { return `/images/manga/${this.id}.jpg` }
get coverThumbUrl() { return `/images/manga/${this.id}.thumb.jpg` }
getChapterData(id) {
return this.chapters.find(c => c.id === id)
}
getChapterTitle(id, noTitle = false) {
const ch = this.getChapterData(id)
if (!ch) {
return ''
} else {
let title = ''
if (ch.volume)
title += `Vol. ${ch.volume} `
if (ch.chapter)
title += `Ch. ${ch.chapter} `
if (ch.title && !noTitle)
title += `${ch.title}`
if (!title)
title = 'Oneshot'
return title.trim()
}
}
getChapterName(id) {
const ch = this.getChapterData(id)
if (!ch) {
return ''
} else {
if (ch.title)
return ch.title
if (ch.volume && ch.chapter)
return `Vol. ${ch.volume} Ch. ${ch.chapter}`
if (ch.chapter)
return `Ch. ${ch.chapter}`
if (ch.volume)
return `Vol. ${ch.volume}`
return 'Oneshot'
}
}
makeChapterList(lang, [g1 = 0, g2 = 0, g3 = 0]) {
this.chapterList = []
const sameLang = this.chapters.filter(c => c.lang_code === lang)
Manga.sortChapters(sameLang)
let best = null
for (let ch of sameLang) {
if (!best) {
best = ch
} else {
if (!ch.chapter && (!ch.volume || ch.volume === "0") || (best.chapter !== ch.chapter || best.volume !== ch.volume)) {
this.chapterList.push(best)
best = ch
} else if (ch.group_id === g1 && ch.group_id_2 === g2 && ch.group_id_3 === g3) {
best = ch
}
}
}
if (best) {
this.chapterList.push(best)
}
return this.chapterList
}
getAltChapters(id) {
const cur = this.getChapterData(id)
if (!cur) {
return []
} else {
const isNonNumbered = (cur.volume === "" || cur.volume === "0") && cur.chapter === ""
return this.chapters
.filter(c =>
c.lang_code === cur.lang_code
&& c.volume === cur.volume && c.chapter === cur.chapter
&& (!isNonNumbered || cur.title === c.title)
).map(c => new Chapter(c))
}
}
getPrevChapterId(id) {
const index = this.chapterList.findIndex(c => c.id === id)
if (index <= 0) {
return -1
} else {
return this.chapterList[index - 1].id
}
}
getNextChapterId(id) {
const index = this.chapterList.findIndex(c => c.id === id)
if (index === -1 || index === this.chapterList.length - 1) {
return 0
} else {
return this.chapterList[index + 1].id
}
}
areChaptersSequential(id1, id2) {
const c1 = this.getChapterData(id1)
const c2 = this.getChapterData(id2)
if (!c1 || !c2) {
return true
}
const c1Chapter = parseFloat(c1.chapter)
const c2Chapter = parseFloat(c2.chapter)
const c1Volume = parseFloat(c1.volume)
const c2Volume = parseFloat(c2.volume)
if (isNaN(c1Chapter) || isNaN(c2Chapter)) {
return true
} else if (c1Chapter === c2Chapter && c1Volume === c2Volume) {
return true
} else if (Math.abs(c1Chapter - c2Chapter).toFixed(1) <= 1.1) {
return true
} else if ((c1Chapter <= 1 && Math.floor(c1Volume - c2Volume) <= 1) || (c2Chapter <= 1 && Math.floor(c2Volume - c1Volume) <= 1)) {
return true
} else {
return false
}
}
/*areChaptersSequential(c1Chapter, c1Volume, c2Chapter, c2Volume) {
c1Chapter = parseFloat(c1Chapter)
c2Chapter = parseFloat(c2Chapter)
c1Volume = parseFloat(c1Volume)
c2Volume = parseFloat(c2Volume)
if (isNaN(c1Chapter) || isNaN(c2Chapter)) {
return true
} else if (Math.abs(c1Chapter - c2Chapter).toFixed(1) <= 1.1) {
return true
} else if ((c1Chapter === 1 || c2Chapter === 1) && Math.abs(c1Volume - c2Volume) <= 1) {
return true
}
return false
}*/
static sortChapters(chapters) {
const sorter = natsort({ desc: false, insensitive: true })
// sort by volume desc, so that vol null > vol number where ch are equal
Utils.stableSort(chapters, (a, b) => sorter(b.volume, a.volume))
// sort by first group
Utils.stableSort(chapters, (a, b) => sorter(a.group_id, b.group_id))
// sort by chapter number
Utils.stableSort(chapters, (a, b) => sorter(a.chapter, b.chapter))
// add ghost prev vol numbers
let pv = '0'
chapters.forEach(c => {
c.__prev_vol = pv
if (c.volume) {
pv = c.volume
}
})
// sort by vol or prev vol
Utils.stableSort(chapters, (a, b) => sorter(a.volume || a.__prev_vol, b.volume || b.__prev_vol))
// remove ghost vols
chapters.forEach(c => { delete c.__prev_vol })
}
static create(opts, force) {
return super.create(opts, force).then(manga => {
manga._data.id = opts.id
return manga
})
}
}
export class Chapter extends Resource {
get resourceType() { return 'chapter' }
// static API_URL(id = '') { return `/api/chapter/${id}` }
static API_URL() { return `/api/` }
get id() { return this._data.id }
get mangaId() { return this._data.manga_id }
get title() { return this._data.title }
get chapter() { return this._data.chapter }
get volume() { return this._data.volume }
get comments() { return this._data.comments }
get isLastChapter() { return this.manga && this.manga.lastChapter && this.manga.lastChapter !== "0" && this.manga.lastChapter === this.chapter }
get langCode() { return this._data.lang_code }
get langName() { return this._data.lang_name }
get totalPages() { return this._data.page_array ? this._data.page_array.length : 0 }
get groupIds() { return [this._data.group_id, this._data.group_id_2, this._data.group_id_3].filter(n => n) }
get groupNames() { return [this._data.group_name, this._data.group_name_2, this._data.group_name_3].filter(n => n) }
get groupWebsite() { return this._data.group_website }
get timestamp() { return this._data.timestamp }
get prevChapterId() { return this.manga.getPrevChapterId(this.id) }
get nextChapterId() { return this.manga.getNextChapterId(this.id) }
get url() { return `/chapter/${this.id}` }
get externalUrl() { return this._data.external || '' }
get fullTitle() {
let title = ''
if (this.volume) title += `Vol. ${this.volume} `
if (this.chapter) title += `Ch. ${this.chapter} `
if (this.title) title += `${this.title}`
if (!title) title = 'Oneshot'
return title.trim()
}
get isMangaFailed() { try { return !this.manga.response.ok } catch (err) { return true } }
get isNotFound() { try { return this.response.status == 404 } catch (err) { return false } }
get isDelayed() { try { return this.response.status == 409 } catch (err) { return false } }
get isDeleted() { try { return this.response.status == 410 } catch (err) { return false } }
get isRestricted() { try { return this.response.status == 451 } catch (err) { return false } }
get isExternal() { return this._data.status === 'external' }
get message() { return this._data.message }
get isNetworkServer() {
if (!this._isNetworkServer) {
this._isNetworkServer = /mangadex\.network/.test(this._data.server || '')
}
return this._isNetworkServer
}
get pageArray() { return this._data.page_array || [] }
getPage(pg) {
return pg >= 1 && pg <= this.totalPages ? this._data.page_array[pg - 1] : ''
}
get pagesFullURL() { return this.pageArray.map((pg, i) => this.imageURL(i + 1)) }
imageURL(pg) {
return this._data.server + this._data.hash + '/' + this.getPage(pg)
}
makeMangaChapterList() {
this.manga.makeChapterList(this.langCode, this.groupIds)
}
loadManga(force) {
if (!this._data.manga_id) {
console.warn('No manga id for chapter', this.id)
return Promise.resolve(this)
}
return Manga.create({ id: this._data.manga_id }, force).then(manga => {
this.manga = manga
if (!manga.response.ok) {
return Promise.reject(this)
} else {
this.makeMangaChapterList()
return Promise.resolve(this)
}
})
}
static create(opts, force) {
return super.create(opts, force)
.catch(ch => ch.mangaId ? Promise.resolve(ch) : Promise.reject(ch))
.then(ch => {
if (ch._data.page_array) {
ch._data.page_array = ch._data.page_array.filter(p => !!p)
}
return Promise.resolve(ch)
})
.then(ch => ch.loadManga(force))
.catch(ch => ch.manga && !ch.isMangaFailed ? Promise.resolve(ch) : Promise.reject(ch))
}
}
export class Follows extends Resource {
get resourceType() { return 'follows' }
get resourceFormat() { return 'text' }
static API_URL() { return '/follows/' }
get unreadChapters() {
return this.chapters.filter(c => c.id && !c._data.read)
}
get unreadManga() {
return this.unreadChapters.reduce((acc, cur) => {
acc[cur.manga.id] = cur.manga
return acc
}, {})
}
static create(opts, force) {
return super.create(opts, force).then(follows => {
const rows = follows._data.match(/col-md-3 [\s\S]*?chapter-list-group/gim)
if (!rows) {
return []
}
const mangaCache = {}
let mangaTitle = ''
follows.chapters = rows.map(row => {
const none = ['', '']
mangaTitle = (row.match(/manga_title[\s\S]*?title='([\s\S]*?)'/) || none)[1].trim() || ''
if (mangaTitle) { console.log(mangaTitle) }
const mangaId = parseInt((row.match(/data-manga-id="(\d*?)"/) || none)[1])
if (!(mangaId in mangaCache)) {
mangaCache[mangaId] = new Manga()
mangaCache[mangaId].initialize({
manga: {
id: mangaId || 0,
title: mangaTitle,
}
})
}
const manga = mangaCache[mangaId]
const chapter = new Chapter()
chapter.initialize({
id: parseInt((row.match(/data-id="(\d*?)"/) || none)[1]) || null,
title: (row.match(/data-title="([\s\S]*?)"/) || none)[1],
chapter: parseFloat((row.match(/data-chapter="([\d\.]*?)"/) || none)[1]) || null,
volume: parseFloat((row.match(/data-volume="([\d\.]*?)"/) || none)[1]) || null,
timestamp: parseInt((row.match(/data-timestamp="(.*?)"/) || none)[1]) * 1000 || null,
lang_code: (row.match(/flag-(..)/) || none)[1],
read: /chapter_mark_unread_button/.test(row),
})
chapter.manga = manga
manga.chapters.push(chapter)
return chapter
})
return follows
})
}
}
// export default { Manga, Chapter, Follows }

6
scripts/reader/index.js Normal file
View File

@ -0,0 +1,6 @@
import Reader from './reader-controller.js'
const reader = new Reader()
reader.initialize()
window.reader = reader

View File

@ -0,0 +1,138 @@
import ReaderView from './reader-view.js'
export default class KeyboardShortcuts {
static registerDefaults() {
const defaultScroll = 50
// kbd shortcuts
// ^ only when shift is pressed
// ! only when shift is not pressed
this.register('turnPageLeft', ['arrowleft', 'left', 'a'], function (evt, view) {
if (ReaderView.isScrolledToLeft) {
view.turnPageLeft(evt.shiftKey ? 1 : undefined)
}
})
this.register('turnPageRight', ['arrowright', 'right', 'd'], function (evt, view) {
if (ReaderView.isScrolledToRight) {
view.turnPageRight(evt.shiftKey ? 1 : undefined)
}
})
this.register('turnPageUp', ['arrowup', 'up', 'w'], function (evt, view) {
if (view.model.settings.pageWheelTurn == 1 && ReaderView.isScrolledToTop) {
view.turnPageBackward(evt.shiftKey ? 1 : undefined)
}
})
this.register('turnPageDown', ['arrowdown', 'down', 's'], function (evt, view) {
if (view.model.settings.pageWheelTurn == 1 && ReaderView.isScrolledToBottom) {
view.turnPageForward(evt.shiftKey ? 1 : undefined)
}
})
this.register('scrollLeft', ['arrowleft', 'left', 'a'], function (evt, view) {
if (view.model.settings.scrollingMethod == 1) {
ReaderView.scroll(-Math.floor(view.el.clientWidth * 0.9), 0, 'smooth')
} else if (view.model.settings.scrollingMethod == 0) {
const key = evt.key.toLowerCase()
if (key !== 'arrowleft' && key !== 'left') {
ReaderView.scroll(-defaultScroll, 0)
}
}
})
this.register('scrollRight', ['arrowright', 'right', 'd'], function (evt, view) {
if (view.model.settings.scrollingMethod == 1) {
ReaderView.scroll(Math.floor(view.el.clientWidth * 0.9), 0, 'smooth')
} else if (view.model.settings.scrollingMethod == 0) {
const key = evt.key.toLowerCase()
if (key !== 'arrowright' && key !== 'right') {
ReaderView.scroll(defaultScroll, 0)
}
}
})
this.register('scrollUp', ['arrowup', 'up', 'w'], function (evt, view) {
if (view.model.settings.scrollingMethod == 1) {
ReaderView.scroll(0, -Math.floor(view.el.clientHeight * 0.9), 'smooth')
} else if (view.model.settings.scrollingMethod == 0) {
const key = evt.key.toLowerCase()
if (key !== 'arrowup' && key !== 'up') {
ReaderView.scroll(0, -defaultScroll)
}
}
})
this.register('scrollDown', ['arrowdown', 'down', 's'], function (evt, view) {
if (view.model.settings.scrollingMethod == 1) {
ReaderView.scroll(0, Math.floor(view.el.clientHeight * 0.9), 'smooth')
} else if (view.model.settings.scrollingMethod == 0) {
const key = evt.key.toLowerCase()
if (key !== 'arrowdown' && key !== 'down') {
ReaderView.scroll(0, defaultScroll)
}
}
})
this.register('turnChapterLeft', ['^q'], function (evt, view) {
view.moveToChapter(view.model.isDirectionLTR ? view.model.chapter.prevChapterId : view.model.chapter.nextChapterId, 1)
})
this.register('turnChapterRight', ['^e'], function (evt, view) {
view.moveToChapter(view.model.isDirectionRTL ? view.model.chapter.prevChapterId : view.model.chapter.nextChapterId, 1)
})
this.register('toggleDisplayFit', ['f'], function (evt, view) {
view.model.saveSetting('displayFit', view.model.displayFit % 2 + (evt.shiftKey ? 3 : 1))
})
this.register('toggleRenderingMode', ['g'], function (evt, view) {
view.model.saveSetting('renderingMode', ((view.model.renderingMode) + (evt.shiftKey ? -1 : 1)) % 3 || 3)
})
this.register('toggleDirection', ['h'], function (evt, view) {
view.model.saveSetting('direction', view.model.direction % 2 + 1)
})
this.register('toggleHeader', ['!r'], function (evt, view) {
view.model.saveSetting('hideHeader', view.model.settings.hideHeader ? 0 : 1)
})
this.register('toggleSidebar', ['!t'], function (evt, view) {
view.model.saveSetting('hideSidebar', view.model.settings.hideSidebar ? 0 : 1)
})
this.register('togglePagebar', ['!y'], function (evt, view) {
view.model.saveSetting('hidePagebar', view.model.settings.hidePagebar ? 0 : 1)
})
this.register('toggleAllBars', ['^r', '^t', '^y'], function (evt, view) {
let any = view.model.settings.hideSidebar || view.model.settings.hideHeader || view.model.settings.hidePagebar
view.model.saveSetting('hideSidebar', any ? 0 : 1)
view.model.saveSetting('hideHeader', any ? 0 : 1)
view.model.saveSetting('hidePagebar', any ? 0 : 1)
})
this.register('exitToManga', ['^m'], function (evt, view) {
view.exitToURL(view.model.manga.url)
})
this.register('exitToComments', ['^k'], function (evt, view) {
view.exitToURL(`${view.pageURL(view.model.chapter.id)}/comments`)
})
}
static register(action, keys, handler) {
this._kbdInput = this._kbdInput || {}
this._kbdHandlers = this._kbdHandlers || {}
this._kbdHandlers[action] = handler
for (let key of keys) {
this._kbdInput[key] = this._kbdInput[key] || []
this._kbdInput[key].push(action)
}
}
static fire(key, evt, view) {
if (key in this._kbdInput) {
for (let action of this._kbdInput[key]) {
if (action in this._kbdHandlers) {
this._kbdHandlers[action](evt, view)
}
}
}
}
static keydownHandler(evt, view) {
if (!(evt.altKey || evt.ctrlKey || evt.metaKey || evt.key === 'OS')) {
const tag = (evt.target || evt.srcElement).tagName
const key = evt.key.toLowerCase()
if (!['INPUT','SELECT','TEXTAREA'].includes(tag)) {
evt.stopPropagation()
this.fire(key, evt, view)
this.fire(evt.shiftKey ? '^'+key : '!'+key, evt, view)
}
}
}
}

View File

@ -0,0 +1,89 @@
export default class ReaderComponent {
static create(type, props) {
const el = typeof type === 'string' ? document.createElement(type) : type
for (let i in props) {
el[i] = props[i]
}
return el
}
static empty(node) {
while (node && node.firstChild) {
node.removeChild(node.firstChild)
}
}
}
export class Option extends ReaderComponent {
static render(data) {
return this.create('option', {
value: data.value,
selected: data.selected || false,
textContent: data.text,
})
}
}
export class Flag extends ReaderComponent {
static render(data, el) {
const langCode = (data.language || '_unknown').replace(/\W/g, '')
el = this.create(el || 'span', {
title: langCode,
})
el.className = ''
el.classList.add('rounded', 'flag', `flag-${langCode}`.trim())
return el
}
}
export class Link extends ReaderComponent {
static render(data, el) {
return this.create(el || 'a', {
href: data.url,
title: data.title,
innerHTML: data.title,
})
}
}
export class ChapterDropdown extends ReaderComponent {
static render(model, el) {
this.empty(el)
for (let ch of model.manga.uniqueChapterList.slice().reverse()) {
el.appendChild(Option.render({
value: ch.id,
selected: ch.id === model.chapter.id,
text: model.settings.showDropdownTitles ? ch.fullTitle : ch.numberTitle,
}))
}
return el
}
}
export class PageDropdown extends ReaderComponent {
static render(model, el) {
this.empty(el)
for (let i = 1; i <= model.chapter.totalPages; ++i) {
el.appendChild(Option.render({
value: i,
selected: i === model.currentPage,
text: i
}))
}
return el
}
}
export class GroupItem extends ReaderComponent {
static render(chapter) {
const li = this.create('li')
const flag = li.appendChild(Flag.render(chapter))
flag.classList.add('mr-1')
const link = li.appendChild(this.create(!chapter.isCurrentChapter ? 'a' : 'strong', {
innerHTML: chapter.groups.map(g => g.name).join(' | '),
href: chapter.url
}))
link.dataset.action = "chapter"
link.dataset.chapter = chapter.id
return li
}
}

View File

@ -0,0 +1,55 @@
import ReaderModel from './reader-model'
import ReaderView from './reader-view'
export default class Reader {
constructor() {
this.model = new ReaderModel()
this.view = new ReaderView(this.model)
}
initialize() {
return new Promise((resolve, reject) => {
const meta = document.querySelector('meta[name="app"]')
this.model.appMeta = meta ? meta.dataset : {}
this.model.loadSettings()
for (let mode of ['renderingMode', 'displayFit', 'direction']) {
this.model[mode] = this.model.settings[mode]
}
if (typeof window === 'undefined') {
return reject()
}
return resolve()
}).then(() => {
this.view.initialize(document.querySelector('div[role="main"]'))
this.view.addListeners()
if (this.model.appMeta.page === 'recs') {
return this.model.moveToRecommendations()
.then(() => {
this.view.renderer.render()
})
}
let page = parseInt(this.model.appMeta.page) || 1
if (page === -1 && this.model.chapter) {
page = this.model.chapter.totalPages
}
return this.model.setChapter(parseInt(this.model.appMeta.chapterId), page)
.then((chapter) => {
if (this.model.appMeta.page === 'recs') {
this.view.moveToRecommendations()
} else if (!chapter.error) {
this.model.preload(page)
if (this.model.isStateReading) {
return this.view.moveToPage(page, false).then(() => {
this.view.replaceHistory()
this.view.updatePage()
})
}
} else {
this.view.replaceHistory(chapter.id, null)
}
})
}).catch((err) => {
console.error(err)
})
}
}

View File

@ -0,0 +1,445 @@
import EventEmitter from 'wolfy87-eventemitter'
import Chapter from './resource/Chapter'
import Manga from './resource/Manga'
import Follows from './resource/Follows'
import Utils from './utils.js'
import ReaderPageModel from './ReaderPageModel'
import ReaderSetting from './ReaderSetting'
export default class ReaderModel extends EventEmitter {
constructor() {
super()
this._state = ReaderModel.readerState.READING
this._isLoading = false
this._currentPage = 0
this._chapter = null
this._renderingMode = 0
this._displayFit = 0
this._direction = 0
this._appMeta = {}
this._settings = {}
this._settingsShortcut = {}
this._pageCache = new Map()
this._preloadSet = new Set()
}
get appMeta() { return this._appMeta }
set appMeta(val) {
this._appMeta = val
}
get isUserGuest() { return this.appMeta.guest !== '0' }
get isLoading() { return this._isLoading }
set isLoading(val) {
val = !!val
if (this._isLoading !== val && !this.exiting) {
this._isLoading = val
this.trigger('loadingchange', [val])
}
}
get currentPage() { return this._currentPage }
setCurrentPage(val) {
if (this._currentPage !== val && !isNaN(val) && !this.exiting) {
this._currentPage = val
this.trigger('currentpagechange', [val])
return true
}
}
get totalPages() { return this.chapter ? this.chapter.totalPages : 0 }
get state() { return this._state }
set state(val) {
if (this._state !== val && !this.exiting) {
this._state = val
this.trigger('statechange', [val])
}
}
get isStateReading() { return this._state === ReaderModel.readerState.READING }
get isStateRecommendations() { return this._state === ReaderModel.readerState.RECS }
get isStateGap() { return this._state === ReaderModel.readerState.GAP }
get exiting() { return this._state === ReaderModel.readerState.EXITING }
exitReader() {
this.state = ReaderModel.readerState.EXITING
// bfcache
window.onunload = () => { this.state = ReaderModel.readerState.READING }
}
get renderingMode() { return this._renderingMode }
set renderingMode(val) {
if (this._renderingMode !== val && !this.exiting) {
this._renderingMode = val
this.trigger('renderingmodechange', [val])
}
}
get displayFit() { return this._displayFit }
set displayFit(val) {
if (this._displayFit !== val && !this.exiting) {
this._displayFit = val
this.trigger('displayfitchange', [val])
}
}
get direction() { return this._direction }
set direction(val) {
if (this._direction !== val && !this.exiting) {
this._direction = val
this.trigger('directionchange', [val])
}
}
get isSinglePage() { return this.renderingMode === ReaderModel.renderingModeState.SINGLE }
get isDoublePage() { return this.renderingMode === ReaderModel.renderingModeState.DOUBLE }
get isLongStrip() { return this.renderingMode === ReaderModel.renderingModeState.LONG }
get isNoResize() { return this.displayFit === ReaderModel.displayFitState.NO_RESIZE }
get isFitHeight() { return this.displayFit === ReaderModel.displayFitState.FIT_HEIGHT }
get isFitWidth() { return this.displayFit === ReaderModel.displayFitState.FIT_WIDTH }
get isFitBoth() { return this.displayFit === ReaderModel.displayFitState.FIT_BOTH }
get isDirectionLTR() { return this.direction === ReaderModel.directionState.LTR }
get isDirectionRTL() { return this.direction === ReaderModel.directionState.RTL }
get settings() { return this._settingsShortcut }
get settingDefaults() {
return Array.from(Object.values(this._settings)).reduce((acc, setting) => {
acc[setting.name] = setting.default
return acc
}, {})
}
saveSetting(key, value) {
const setting = this._settings[key]
setting.save(value)
this._settingsShortcut[setting.name] = setting.value
if (['renderingMode', 'displayFit', 'direction'].includes(key)) {
this[key] = setting.value
}
this.trigger('settingchange', [setting.name, setting.value])
}
loadSettings() {
const defaults = [
new ReaderSetting('displayFit', ReaderModel.displayFitState.FIT_WIDTH),
new ReaderSetting('direction', ReaderModel.directionState.LTR),
new ReaderSetting('renderingMode', ReaderModel.renderingModeState.SINGLE),
new ReaderSetting('showAdvancedSettings', 0),
new ReaderSetting('scrollingMethod', 0),
new ReaderSetting('swipeDirection', 0),
new ReaderSetting('swipeSensitivity', 3),
new ReaderSetting('pageTapTurn', 1),
new ReaderSetting('pageTurnLongStrip', 1),
new ReaderSetting('pageWheelTurn', 0),
new ReaderSetting('showDropdownTitles', 1),
new ReaderSetting('tapTargetArea', 1),
new ReaderSetting('hideHeader', 0),
new ReaderSetting('hideSidebar', 0),
new ReaderSetting('hidePagebar', 0),
new ReaderSetting('collapserStyle', 0),
new ReaderSetting('hideCursor', 0),
new ReaderSetting('betaRecommendations', 0),
new ReaderSetting('imageServer', '0'),
new ReaderSetting('dataSaverV2', 0),
new ReaderSetting('gapWarning', 1),
new ReaderSetting('restrictChLang', 1, null, (val) => {
val = parseInt(val)
if (this.chapter && this.manga) {
this.manga.updateChapterList(this.chapter, val)
this.trigger('chapterlistchange', [])
}
return val
}),
new ReaderSetting('preloadPages', 10, () => true, (val) => {
if (isNaN(parseInt(val)))
return 10
const clamped = Utils.clamp(parseInt(val), 0, this.preloadMax)
return !isNaN(clamped) ? clamped : 0
}),
new ReaderSetting('containerWidth', null, (val) => {
return !val || !isNaN(parseInt(val))
}),
]
for (let setting of defaults) {
setting.load()
this._settings[setting.name] = setting
this._settingsShortcut[setting.name] = setting.value
this.trigger('settingchange', [setting.name, setting.value])
}
this.saveSetting('gapWarning', 1)
ReaderSetting.clearAllExcept(defaults.map(s => s.name))
}
get chapter() { return this._chapter }
get manga() { return this._chapter ? this._chapter.manga : null }
getChapterParams() {
return {
server: this.settings.imageServer !== '0' ? this.settings.imageServer : null,
saver: this.settings.dataSaverV2,
}
}
async setChapter(id, pg = 1) {
if (this.exiting || this.isLoading) {
throw new Error(`Trying to set chapter while ${this.exiting ? 'exiting' : 'loading'}`)
} else if (isNaN(id) || id <= 0) {
throw new Error("Trying to set invalid chapter: " + id)
}
try {
this.isLoading = true
let chapter = Chapter.getResource(id)
if (chapter && !chapter.isFullyLoaded) {
chapter = await Chapter.load(id, this.getChapterParams())
} else if (!chapter) {
chapter = await Chapter.loadWithManga(id, this.getChapterParams())
await Manga.loadChapterList(chapter.manga.id)
}
const oldManga = this.manga
this._chapter = chapter
this._currentPage = pg
this.isLoading = false
chapter.manga.updateChapterList(chapter, this.settings.restrictChLang)
this._createPageCache(chapter)
this.state = ReaderModel.readerState.READING
this.trigger('chapterchange', [chapter])
if (!oldManga || oldManga.id !== chapter.manga.id) {
this.trigger('mangachange', [chapter.manga])
}
if (chapter.error || chapter.isExternal) {
throw chapter
} else if (chapter.totalPages === 0) {
chapter.error = new Error("Chapter has no pages.")
throw chapter
}
if (chapter.isNetworkServer) {
this.preload(this.currentPage, Infinity)
}
return chapter
} catch (error) {
console.error(error)
this.isLoading = false
this.state = ReaderModel.readerState.ERROR
this.trigger('readererror', [error])
return error
}
}
async reloadChapterList() {
if (this.manga.isChapterListOutdated) {
this.isLoading = true
try {
await Manga.loadChapterList(this.manga.id)
} catch (error) { }
this.isLoading = false
}
}
_createPageCache(chapter) {
for (let [i, page] of this._pageCache) {
page.unload()
page.off()
}
this._pageCache.clear()
this._preloadSet.clear()
this._preloading = false
let pgNum = 1
for (let [url, fallbackURL] of chapter.getAllPageUrls()) {
const page = new ReaderPageModel(pgNum, chapter.id, url, fallbackURL)
this._pageCache.set(pgNum, page)
page.on('statechange', (page) => {
switch (page.state) {
case ReaderPageModel.STATE_LOADING: return this.trigger('pageloading', [page])
case ReaderPageModel.STATE_LOADED: return this.trigger('pageload', [page])
case ReaderPageModel.STATE_ERROR: return this.trigger('pageerror', [page])
}
})
++pgNum
}
}
_loadPage(pg, skipCache = false) {
const page = this._pageCache.get(pg)
if (!page) {
return Promise.reject(new Error(`Page ${pg} not in cache`))
} else {
return page.load(skipCache)
}
}
getPage(pg) {
return new Promise((resolve, reject) => {
if (this.chapter == null || this._pageCache.size === 0) {
return reject(new Error("Tried to get a page before chapter has loaded"))
} else if (isNaN(pg) || pg < 1 || pg > this.totalPages) {
return resolve(null)
// return reject(new Error("Page not a number or out of bounds"))
} else if (this._pageCache.get(pg).loaded) {
return resolve(this._pageCache.get(pg))
} else {
return resolve(this._loadPage(pg))
}
})
}
reloadErrorPages() {
Array.from(this._pageCache.values()).filter(i => i.hasError).forEach(i => i.reload())
}
getPageWithoutLoading(pg) {
if (!this._pageCache.has(pg)) {
//throw new Error(`No page ${pg} set.`)
return null
}
return this._pageCache.get(pg)
}
get currentPageObject() { return this.getPageWithoutLoading(this.currentPage) }
get isPageCacheEmpty() { return this._pageCache.size === 0 }
getAllPages() {
return Array.from(this._pageCache.values())
}
getLoadedPages() {
return this.getAllPages().filter(i => i.loaded || i.hasError)
}
_preloadNextInSet() {
if (this._preloadSet.size > 0) {
this._preloading = true
const pg = [...this._preloadSet][0]
this._preloadSet.delete(pg)
this._loadPage(pg)
.catch((page) => { /*console.warn(`Preload failed for page ${pg}`)*/ })
.then(() => this._preloadNextInSet())
} else {
this._preloading = false
}
}
_preloadArray(pages) {
if (pages.length > 0) {
pages
.filter(pg => !this.getPageWithoutLoading(pg).loaded)
.forEach(pg => this._preloadSet.add(pg))
if (!this._preloading) {
return this._preloadNextInSet()
}
}
}
get preloadMax() {
return this.isUserGuest ? PRELOAD_MAX_GUEST : PRELOAD_MAX_USER
}
// TODO: preload backwards iff [current, end] already loaded
preload(start = this.currentPage + 1, amount = this.settings.preloadPages) {
if (this.isPageCacheEmpty) {
return
}
if (amount == null) {
amount = 10
}
if (amount !== Infinity) {
amount = Utils.clamp(amount, 0, this.chapter.isNetworkServer ? this.totalPages : this.preloadMax)
}
start = Utils.clamp(start, 1, this.totalPages + 1)
const end = Utils.clamp(start + amount, 1, this.totalPages + 1)
return this._preloadArray(Utils.range(start, end))
}
preloadEverything() {
return this.preload(1, Infinity) // lol
}
moveToPage(pg) {
if (!this.chapter || isNaN(pg)) {
return Promise.resolve()
} else if (pg <= -1) {
return this.moveToPage(this.totalPages)
} else if (pg === 0) {
return Promise.reject({ chapter: this.chapter.prevChapterId, page: -1 })
// return this.moveToChapter(this.chapter.prevChapterId, -1)
} else if (pg > this.totalPages) {
return Promise.reject({ chapter: this.chapter.nextChapterId, page: 1 })
// return this.moveToChapter(this.chapter.nextChapterId, 1)
} else {
if (this.currentPage === pg) {
this.trigger('currentpagechange', [pg])
} else {
this.setCurrentPage(pg)
}
return Promise.resolve()
}
}
moveToChapter(id, pg = 1) {
if (id <= 0) {
return Promise.reject({ chapter: id })
} else {
return this.setChapter(id, pg).then(() => {
if (this.chapter && this.totalPages > 0 && this.isStateReading) {
return this.moveToPage(pg)
} else {
return Promise.resolve()
}
})
}
}
moveToRecommendations() {
this.isLoading = true
return Follows.load({}, false)
.then(recs => {
this.recommendations = recs
this.isLoading = false
this.state = ReaderModel.readerState.RECS
return Promise.resolve(recs)
})
.catch(err => {
this.recommendations = null
this.isLoading = false
this.state = ReaderModel.readerState.ERROR
this.trigger('readererror', [err])
return Promise.reject(err)
})
}
}
const PRELOAD_MAX_USER = 20
const PRELOAD_MAX_GUEST = 5
ReaderModel.renderingModeState = {
SINGLE: 1,
DOUBLE: 2,
LONG: 3,
ALERT: 4,
RECS: 5,
}
ReaderModel.directionState = {
LTR: 1,
RTL: 2,
}
ReaderModel.displayFitState = {
FIT_BOTH: 1,
FIT_WIDTH: 2,
FIT_HEIGHT: 3,
NO_RESIZE: 4,
}
ReaderModel.readerState = {
ERROR: 0,
READING: 1,
RECS: 2,
EXITING: 3,
GAP: 4,
}

File diff suppressed because it is too large Load Diff

652
scripts/reader/renderer.js Normal file
View File

@ -0,0 +1,652 @@
import { formatDistance } from 'date-fns'
import Utils from './utils'
import ReaderView from './reader-view'
import Chapter from './resource/Chapter'
import Manga from './resource/Manga'
export default class AbstractRenderer {
constructor(container, model, view) {
this.el = container
this.model = model
this.view = view
this._initialized = false
}
get chapter() { return this.model.chapter }
initialize() {
// console.log('initialize',this.name)
this._initialized = true
this.clearImageContainer()
this.renderedPages = 0
this._pageStateHandler = (page) => {
//console.info('pagestatehandler', this.name, page)
this.pageStateHandler(page)
}
this.model.on('pageloading', this._pageStateHandler)
this.model.on('pageload', this._pageStateHandler)
this.model.on('pageerror', this._pageStateHandler)
}
destroy() {
if (!this._initialized) {
return
}
// console.log('destroy',this.name)
this._initialized = false
this.clearImageContainer()
this.model.off('pageloading', this._pageStateHandler)
this.model.off('pageload', this._pageStateHandler)
this.model.off('pageerror', this._pageStateHandler)
}
reinitialize() {
if (this._initialized) {
this.destroy()
}
this.initialize()
}
createAndAppendWrapper(page) {
return this.el.appendChild(this.updateWrapper(this.createWrapper(), page))
}
createWrapper() {
const wrapper = document.createElement('div')
const classes = [
'reader-image-wrapper',
'col-auto',
'my-auto',
'justify-content-center',
'align-items-center',
'noselect', 'nodrag',
'row', 'no-gutters',
]
wrapper.classList.add(...classes)
wrapper.dataset.state = 0
return wrapper
}
updateWrapper(wrapper, page = {}) {
//console.log('update wrapper to', page.number, page)
if (page.state !== parseInt(wrapper.dataset.state)) {
while (wrapper.firstChild) {
wrapper.removeChild(wrapper.firstChild)
}
switch (page.state) {
case 1: wrapper.appendChild(this.createPageLoading()); break;
case 2: wrapper.appendChild(this.createPageLoaded()); break;
case 3: wrapper.appendChild(this.createPageError()); break;
}
}
wrapper.style.order = page.number || 0
wrapper.dataset.page = page.number || 0
wrapper.dataset.state = page.state || 0
switch (page.state) {
case 1: wrapper.querySelector('.loading-page-number').textContent = page.number; break;
case 2: wrapper.firstChild.src = page.image.src; break;
case 3: wrapper.querySelector('.alert .message').textContent = page.error.message; break;
}
return wrapper
}
createPageLoading() {
const container = document.createElement('div')
container.classList.add('m-5', 'd-flex', 'align-items-center', 'justify-content-center')
container.style.color = '#fff'
container.style.textShadow = '0 0 7px rgba(0,0,0,0.5)'
const spinner = container.appendChild(document.createElement('span'))
spinner.classList.add('fas', 'fa-circle-notch', 'fa-spin', 'position-absolute')
spinner.style.opacity = '0.5'
spinner.style.fontSize = '7em'
const pgNum = container.appendChild(document.createElement('span'))
pgNum.classList.add('loading-page-number')
pgNum.style.fontSize = '2em'
return container
}
createPageLoaded() {
const container = document.createElement('img')
container.draggable = false
container.classList.add('noselect', 'nodrag', 'cursor-pointer')
return container
}
createPageError() {
const container = Alert.container('', 'danger')
const tapMsg = container.appendChild(document.createElement('div'))
tapMsg.innerHTML = "Tap to reload."
container.addEventListener('click', evt => {
evt.preventDefault()
evt.stopPropagation()
const page = this.model.getPageWithoutLoading(parseInt(container.parentElement.dataset.page))
page.reload(true).catch(console.error)
})
return container
}
createMangaError(chapter) {
const container = Alert.container('', 'danger')
const tapMsg = container.appendChild(document.createElement('div'))
tapMsg.innerHTML = "Tap to reload."
container.addEventListener('click', evt => {
evt.preventDefault()
evt.stopPropagation()
container.parentElement.removeChild(container)
this.model.isLoading = true
chapter.loadManga(true)
.then(chapter => {
this.model.isLoading = false
this.model.setChapter(chapter.id)
.then(() => { this.view.moveToPage(1, false) })
})
.catch(err => {
this.model.isLoading = false
this.model.trigger('readererror', [err])
})
})
return container
}
clearImageContainer() {
while (this.el && this.el.firstChild) {
this.el.removeChild(this.el.firstChild)
}
}
render() {
throw new Error("Not implemented")
}
pageStateHandler() {
throw new Error("Not implemented")
}
}
export class SinglePage extends AbstractRenderer {
get name() { return 'single-page' }
initialize() {
super.initialize()
this.renderedPages = 1
this.pageToRender = null
this.pageWrapper = this.createAndAppendWrapper()
}
pageStateHandler(page) {
if (this.pageToRender === page) {
this.updateWrapper(this.pageWrapper, page)
}
}
render(pg) {
const page = this.model.getPageWithoutLoading(pg)
this.pageToRender = page
this.updateWrapper(this.pageWrapper, page)
return page.load().catch(p => Promise.resolve(p))
}
}
export class DoublePage extends AbstractRenderer {
get name() { return 'double-page' }
get renderedPages() { return this.pagesToRender.length }
set renderedPages(v) { }
isPageTurnForwards() { return this.previousPage < this.model.currentPage }
isSinglePageBackwards() { return this.previousPage === this.model.currentPage + 1 }
isImageTooWide(img) {
return img && img.naturalWidth > img.naturalHeight && img.naturalWidth > this.el.offsetWidth / 2
}
initialize() {
super.initialize()
this.pageWrapperLoading = this.createAndAppendWrapper({ state: 1, number: '', })
this.previousPage = 0
this.pageWrappers = [this.createAndAppendWrapper(), this.createAndAppendWrapper()]
this.pagesToRender = []
this.setLoading(true)
}
pageStateHandler(page) {
if (this.pagesToRender.includes(page)) {
this.checkRender()
}
}
render(pg) {
this.pagesToRender = [pg, pg + 1]
.map(p => this.model.getPageWithoutLoading(p))
.filter(p => p)
this.checkRender()
return Promise.all(
this.pagesToRender.map(page =>
page.load().catch(p => Promise.resolve(p))
)
)
}
checkRender() {
const pagesDone = this.pagesToRender.every(p => p.isDone)
if (pagesDone) {
if (this.pagesToRender.length > 1 && this.pagesToRender.some(p => this.isImageTooWide(p.image))) {
if (this.isPageTurnForwards() || this.isSinglePageBackwards()) {
this.pagesToRender.pop()
} else {
this.pagesToRender.shift()
this.model.setCurrentPage(this.pagesToRender[0].number)
}
}
this.updateWrapper(this.pageWrappers[0], this.pagesToRender[0])
this.updateWrapper(this.pageWrappers[1], this.pagesToRender[1])
this.previousPage = this.model.currentPage
}
this.setLoading(!pagesDone)
}
setLoading(state) {
this.pageWrapperLoading.classList.toggle('d-none', !state)
for (let wrapper of this.pageWrappers) {
wrapper.classList.toggle('d-none', state)
}
}
}
export class LongStrip extends AbstractRenderer {
get name() { return 'long-strip' }
get renderedPages() { return this._renderedPageSet.length }
set renderedPages(v) { }
get lastRenderedPage() { return this._renderedPageSet[this._renderedPageSet.length - 1] }
initialize() {
super.initialize()
this._pageWrapperMap = new Map()
this._renderedPageSet = []
this._scrollY = -1
this.observer = new MutationObserver((mutationsList) => {
// this is horrible
if (this._scrollY === -1 && this.model.currentPage !== 1) {
this._scrollY = -2
requestAnimationFrame(() => {
this.getPageWrapper(this.model.currentPage).scrollIntoView(true)
requestAnimationFrame(() => {
this._scrollY = window.pageYOffset || -1
// console.log('did it',this._scrollY)
if (this._scrollY !== -1) {
ReaderView.scroll(0, -document.querySelector('nav.navbar').offsetHeight + 1)
this.observer.disconnect()
}
})
})
//this.scrollToPage(this.model.currentPage)
}
})
this.observer.observe(this.el, { childList: true, subtree: true })
for (let page of this.model.getAllPages()) {
this._pageWrapperMap.set(page.number, this.createAndAppendWrapper(page))
if (page.isDone) {
this._renderedPageSet.push(page.number)
}
}
Utils.stableSort(this._renderedPageSet)
this.renderEndBlock()
this.render(this.model.currentPage)
this.addScrollHandler()
this._currentPageHandler = (pg) => {
this.render(pg + 1)
.then(() => {
this.render(pg - 1)
})
}
this.model.on('currentpagechange', this._currentPageHandler)
}
destroy() {
if (!this._initialized) {
return
}
super.destroy()
this.observer.disconnect()
//this.el.scrollIntoView(true)
this._pageWrapperMap.clear()
this.removeScrollHandler()
window.scrollTo(0, 0)
this.model.off('currentpagechange', this._currentPageHandler)
}
pageStateHandler(page) {
this.updateWrapper(this.getPageWrapper(page.number), page)
if (page.isDone || page.loading) {
if (!this.isRendered(page.number)) {
this._renderedPageSet.push(page.number)
Utils.stableSort(this._renderedPageSet)
}
if (this.isChapterFullyRendered) {
this.showEndBlock()
}
if (this._scrollY >= 0) {
this.updateCurrentPage()
} //else {
// this._scrollY = -2
// requestAnimationFrame(() => {
// this.getPageWrapper(this.model.currentPage).scrollIntoView(true)
// requestAnimationFrame(() => {
// ReaderView.scroll(0, -document.querySelector('nav.navbar').offsetHeight + 1)
// requestAnimationFrame(() => {
// this._scrollY = window.pageYOffset || -1
// console.log('did it',this._scrollY)
// })
// })
// })
//this.scrollToPage(this.model.currentPage)
// }
}
}
getPageWrapper(pg) {
if (!this._pageWrapperMap.has(pg)) {
throw new Error("No wrapper for page ", pg)
}
return this._pageWrapperMap.get(pg)
}
isRendered(pg) {
return this._renderedPageSet.includes(pg)
}
get isChapterFullyRendered() {
return this.renderedPages === this.model.totalPages
}
render(pg) {
if (!this.isChapterFullyRendered && !this.isRendered(pg)) {
return this.model.getPage(pg)
.catch(p => Promise.resolve(p))
}
return Promise.resolve()
}
renderEndBlock() {
this._endBlock = this.createAndAppendWrapper({
number: this.model.totalPages + 1,
chapter: this.model.chapter.id,
})
this._endBlock.textContent = 'End of chapter / Go to next'
this._endBlock.classList.add('reader-image-block', 'py-3', 'd-none')
this._endBlock.addEventListener('click', (evt) => {
evt.stopPropagation()
this.view.moveToChapter(this.model.chapter.nextChapterId)
}, { once: true })
if (this.isChapterFullyRendered) {
this.showEndBlock()
}
}
showEndBlock() {
this._endBlock.classList.remove('d-none')
}
updateCurrentPage() {
if (this.renderedPages > 0 && !this._updating) {
this._updating = true
if (ReaderView.isScrolledToTop) {
this.model.setCurrentPage(this._renderedPageSet[0])
this.view.replaceHistory()
} else if (ReaderView.isScrolledToBottom) {
this.model.setCurrentPage(this.lastRenderedPage)
this.view.replaceHistory()
} else {
const scrollY = Math.floor(window.pageYOffset)
for (let i = this._renderedPageSet.length - 1; i >= 0; --i) {
const pg = this._renderedPageSet[i]
const wrapper = this.getPageWrapper(pg)
if (scrollY >= wrapper.offsetTop) {
if (this.model.setCurrentPage(pg)) {
this.view.replaceHistory()
}
break
}
}
}
this._updating = false
}
}
scrollToPage(pg) {
// requestAnimationFrame(() => {
const wrapper = this.getPageWrapper(pg)
if (this.isRendered(pg) && wrapper) {
// console.log('scrolling to', pg, wrapper.offsetTop + 1)
//window.scrollTo(window.pageXOffset, wrapper.offsetTop + 1)
wrapper.scrollIntoView(true)
if (!ReaderView.isScrolledToBottom) {
ReaderView.scroll(0, -document.querySelector('nav.navbar').offsetHeight + 1)
}
// requestAnimationFrame(() => {
// })
}
// })
}
addScrollHandler() {
if (!this._scrollHandler) {
const update = () => {
if (this.model.chapter) {
this.updateCurrentPage()
}
}
if (Modernizr.requestanimationframe) {
let wait = false
this._scrollHandler = () => {
if (!wait) {
wait = true
requestAnimationFrame(() => {
update()
wait = false
})
}
}
} else {
this._scrollHandler = () => {
update()
}
}
window.addEventListener('scroll', this._scrollHandler)
}
}
removeScrollHandler() {
if (this._scrollHandler) {
window.removeEventListener('scroll', this._scrollHandler)
this._scrollHandler = null
}
}
}
export class Alert extends AbstractRenderer {
get name() { return 'alert' }
pageStateHandler() { }
renderChapterButtons(data) {
const chBtnContainer = this.el.appendChild(document.createElement('div'))
chBtnContainer.classList.add('row', 'm-auto', 'justify-content-center', 'directional')
const buttons = [
{ text: 'Previous chapter', id: data.prevChapterId, order: 1 },
{ text: 'Next chapter', id: data.nextChapterId, order: 2 },
]
const classes = ['col-auto', 'hover', 'text-dark']
for (let btn of buttons) {
const link = chBtnContainer.appendChild(Alert.container(btn.text, 'dark', 'a'))
link.setAttribute('href', this.view.pageURL(btn.id))
link.dataset.action = 'chapter'
link.dataset.chapter = btn.id
link.classList.add(...classes)
link.classList.replace('m-auto', 'm-1')
link.style.order = btn.order
}
}
render(data) {
this.clearImageContainer()
if (typeof data !== 'object') {
return Promise.reject({ message: "Data is not an object", data: data, revert: true })
}
if (data.isExternal) {
this.el.appendChild(Alert.container(`This chapter can be read for free on the official publisher's website.<br>Feel free to write your comments about it here on MangaDex!`, 'info'))
const link = Alert.container(`${Alert.icon('external-link-alt', 'Website')} <strong>Read the chapter</strong>`, 'success', 'a', false)
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.href = data.pages
this.el.appendChild(link)
this.renderChapterButtons(data)
} else if (data.isDelayed) {
const now = new Date()
const release = new Date(data.timestamp * 1000)
const relativeDate = release > now ? formatDistance(release, now, { addSuffix: true }) : 'within a few minutes'
this.el.appendChild(Alert.container(`Due to the group's delay policy, this chapter will be available ${relativeDate}.`, 'danger'))
this.el.appendChild(Alert.container(`You might be able to read it on the group's <a target='_blank' rel='noopener noreferrer' href='${data.groupWebsite}'>${Alert.icon('external-link-alt', 'Website')} <strong>website</strong></a>.`, 'info'))
this.renderChapterButtons(data)
} else if (data.isSpoilerNet) {
const alert = document.createElement('div')
alert.classList.add('alert', `alert-warning`, 'text-center', 'm-auto')
alert.attributes.role = 'alert'
alert.innerHTML = `<h3>${Alert.icon('warning')} Spoiler Warning</h3><div class="my-3"><div>There seems to be a gap between chapters (${Chapter.getResource(data.prevChapterId).fullTitle} &rarr; ${Chapter.getResource(data.chapterId).fullTitle}).</div><div>This may be an attempt to troll you into reading a chapter early.</div>`
const button = document.createElement('button')
button.classList.add('btn', 'btn-secondary')
button.type = 'button'
button.textContent = "I understand, I'm fine with spoilers!"
button.addEventListener('click', (evt) => {
evt.stopPropagation()
this.view.moveToChapter(data.chapterId, 1, true, true)
})
alert.appendChild(button)
this.el.appendChild(alert)
} else if (data.isNotFound) {
this.el.appendChild(Alert.container(`Data not found${data.message ? ': ' + data.message : '.'}`, 'danger'))
} else if (data.isDeleted) {
this.el.appendChild(Alert.container(`This chapter has been deleted.`, 'danger'))
} else if (data.isRestricted) {
this.el.appendChild(Alert.container(`This chapter is unavailable.`, 'danger'))
} else if (data.isMangaFailed) {
const alert = this.createMangaError(data)
alert.querySelector('.message').textContent = "The manga data failed to load."
alert.draggable = false
alert.classList.add('noselect', 'nodrag', 'cursor-pointer')
this.el.appendChild(alert)
} else if (data.status === 'unavailable') {
this.el.appendChild(Alert.container(`This chapter is unavailable.`, 'danger'))
} else if (data != null) {
const isError = data instanceof Error || data.error instanceof Error
const type = data.type || isError ? 'danger' : undefined
const msg = data.message || data.error && data.error.message || data.error && data.error.status || data
this.el.appendChild(Alert.container(msg, type))
// if (isError) {
// this.el.appendChild(Alert.container('dark', data.stack || data.error.stack))
// }
}
return Promise.resolve()
}
static icon(type, title) {
type = Alert.iconTypes[type] || type
return `<span class='fas fa-${type} fa-fw' aria-hidden='true'${title ? ` title=${title}` : ''}></span> `
}
static get iconTypes() {
return {
success: 'check-circle',
danger: 'times',
info: 'info',
warning: 'exclamation-triangle',
}
}
static container(message = '', type = 'dark', element = 'div', icon = true) {
const div = document.createElement(element)
div.classList.add('alert', `alert-${type}`, 'text-center', 'm-auto')
div.attributes.role = 'alert'
if (icon && Alert.iconTypes[type]) {
div.innerHTML = Alert.icon(type)
}
const span = div.appendChild(document.createElement('span'))
span.classList.add('message')
span.innerHTML = message
return div
}
}
export class Recommendations extends AbstractRenderer {
get name() { return 'recommendations' }
render() {
if (!this.model.recommendations) {
this.el.innerHTML = ''
return this.el.appendChild(Alert.container("No recommendations found. You must be logged in and have followed some titles.", "danger"))
}
let recStr = ''
for (let [manga, chapters] of this.model.recommendations.unreadChaptersGroupedByManga) {
if (chapters.length > 0) {
const chapter = chapters[chapters.length - 1]
const more = chapters.length >= 2 ? ` <em class="ml-1">(+${chapters.length - 1} more)</em>` : ''
const relativeDate = formatDistance(new Date(chapter.timestamp * 1000), new Date(), { addSuffix: true })
recStr += `
<div class="col-xl-3 col-md-4 col-sm-6 border-bottom p-2 text-left">
<div class="rounded sm_md_logo float-left mr-2">
<a href="${manga.url}">
<img class="rounded max-width" src="${manga.coverThumb}">
</a>
</div>
<div>
<div class="text-truncate py-0 mb-1 border-bottom">
<span class="fas fa-book fa-fw" aria-hidden="true" title=""></span>
<a class="manga_title" title="${manga.title}" href="${manga.url}">${manga.title}</a>
</div>
<p class="py-0 mb-1 row no-gutters align-items-center flex-nowrap">
<div class="col-auto">
<span class="col-auto px-0">
<span class="rounded flag flag-${chapter.language}"></span>
</span>
<a class="" href="${chapter.url}" data-chapter="${chapter.id}">${chapter.fullTitle}</a>
${more}
</div>
</p>
<p class="text-truncate py-0 mb-1"><span class="far fa-clock fa-fw " aria-hidden="true" title=""></span> ${relativeDate}</span></p>
</div>
</div>`
}
}
this.el.innerHTML = `<h2 class="text-left">Recommendations</h2><h3 class="text-left">Unread Follows</h3><div class="row no-gutters">${recStr}</div>`
const handler = (evt) => {
const chapter = evt.target.dataset.chapter || evt.currentTarget.dataset.chapter
if (chapter) {
evt.preventDefault()
evt.stopPropagation()
this.view.moveToChapter(parseInt(chapter), 1)
// this.model.setRenderer(this.model.settings.renderingMode)
}
}
this.el.querySelectorAll('a').forEach(c => c.addEventListener('click', handler, true))
return Promise.resolve()
}
pageStateHandler() { }
getChapterTitle(ch, numOnly) {
let title = ''
if (ch.volume) title += `Vol. ${ch.volume} `
if (ch.chapter) title += `Ch. ${ch.chapter} `
if (ch.title && !numOnly) title += `${ch.title}`
if (!title) title = 'Oneshot'
return title.trim()
}
}

View File

@ -0,0 +1,158 @@
import Resource from './Resource'
import Manga from './Manga'
import Group from './Group'
const VOL_SHORT = 'Vol.'
const CH_SHORT = 'Ch.'
const TITLE_ONESHOT = 'Oneshot'
const TITLE_EMPTY = '(Untitled)'
export default class Chapter extends Resource {
static get resourceType() { return 'chapter' }
constructor(data = {}, responseCode = -1, responseStatus = null) {
super(data, responseCode, responseStatus)
this.id = data.id
this.hash = data.hash
this.mangaId = data.mangaId
this.chapter = data.chapter
this.volume = data.volume
this.title = data.title
this.timestamp = data.timestamp
this.language = data.language
this.threadId = data.threadId || null
this.comments = data.comments
this.server = data.server
this.serverFallback = data.serverFallback
this.groupIds = (data.groups || []).map(g => g.id || g)
this.groupWebsite = data.groupWebsite
this.pages = data.pages || []
this.read = data.read || false
this.status = data.status
}
// derived data
get textTitle() {
return this.title || ((this.volume || this.chapter) ? TITLE_EMPTY : TITLE_ONESHOT)
}
get numberTitle() {
return [
this.volume ? `${VOL_SHORT} ${this.volume}` : '',
this.chapter ? `${CH_SHORT} ${this.chapter}` : '',
].filter(n => n).join(' ')
}
get fullTitle() {
return [
this.numberTitle,
this.title,
].filter(n => n).join(' ') || TITLE_ONESHOT
}
get manga() { return Manga.getResource(this.mangaId) }
get groups() { return this.groupIds.map(id => Group.getResource(id)) }
get totalPages() { return !this.isExternal ? this.pages.length : 1 }
get url() { return `/chapter/${this.id}` }
get isNotFound() { return this._response.code === 404 }
get isDeleted() { return this.status === 'deleted' }
get isDelayed() { return this.status === 'delayed' }
get isUnavailable() { return this.status === 'unavailable' }
get isRestricted() { return this.status === 'restricted' }
get isExternal() { return this.status === 'external' }
get isFullyLoaded() { return this.hash != null && this.server != null }
get isUnnumbered() { return !this.volume && !this.chapter }
get isLastChapter() { return this.manga && this.manga.isLastChapter(this) }
get nextChapterId() { if (this.manga) return this.manga.getNextChapterId(this.id) }
get prevChapterId() { if (this.manga) return this.manga.getPrevChapterId(this.id) }
get nextChapter() { return Chapter.getResource(this.nextChapterId) }
get prevChapter() { return Chapter.getResource(this.prevChapterId) }
get isNetworkServer() {
if (this._isNetworkServer == null) {
this._isNetworkServer = /mangadex\.network/.test(this.server || '')
}
return this._isNetworkServer
}
// methods
getPage(i) {
return (i >= 1 && i <= this.totalPages && !this.isExternal) ? this.pages[i - 1] : ''
}
getPageUrl(i) {
return this.server + this.hash + '/' + this.getPage(i)
}
getAllPageUrls() {
function serverOrUndefined(server, hash, pg) {
if (!server) {
return server;
}
return server + hash + '/' + pg;
}
return !this.isExternal ? this.pages.map(pg => [serverOrUndefined(this.server, this.hash, pg), serverOrUndefined(this.serverFallback, this.hash, pg)]) : []
}
isAlternativeOf(ch) {
return ch.chapter == this.chapter
&& (ch.volume == this.volume || ch.volume == '' || this.volume == '')
&& (!this.isUnnumbered || ch.title == this.title)
}
isSequentialWith(chapter) {
if (!chapter) {
return undefined
} else if (typeof chapter == 'number') {
chapter = Chapter.getResource(chapter)
}
const ch1 = parseFloat(this.chapter)
const ch2 = parseFloat(chapter.chapter)
const vol1 = parseInt(this.volume) || 0
const vol2 = parseInt(chapter.volume) || 0
if (isNaN(ch1) || isNaN(ch2) || this.isUnnumbered || chapter.isUnnumbered) {
return undefined
} else if (this.isAlternativeOf(chapter)) {
return undefined
} else if (Math.abs(ch1 - ch2).toFixed(1) <= 1.1) {
return true
} else if ((ch1 === 1 && vol1 === vol2 + 1) || (ch2 === 1 && vol2 === vol1 + 1)) {
return true
} else {
return false
}
}
hasSameGroupsWith(chapter) {
if (this === chapter) {
return true
} else if (this.groups.length !== chapter.groups.length) {
return false
}
for (let i = 0; i < this.groups.length; ++i) {
if (this.groups[i].id !== chapter.groups[i].id) {
return false
}
}
return true
}
static async load(id, params = {}, cache = true) {
const json = await super.load(`chapter/${id}`, params)
try {
json.data.groups.forEach(g => Group.fromJSON(g))
return this.fromJSON(json.data, json.code, json.status, cache)
} catch (error) {
console.error(error)
throw this.fromJSON({ id, status: json.message }, json.code, json.status, false)
}
}
static async loadWithManga(id, params = {}, cache = true) {
const json = await super.load(`chapter/${id}`, Object.assign(params, { include: 'manga' }))
try {
json.data.chapter.groups.forEach(g => Group.fromJSON(g))
const chapter = this.fromJSON(json.data.chapter, json.code, json.status, cache)
Manga.fromJSON(json.data.manga, json.code, json.status, cache)
return chapter
} catch (error) {
console.error(error)
throw this.fromJSON({ id, status: json.message }, json.code, json.status, false)
}
}
}

View File

@ -0,0 +1,36 @@
import Cookies from 'js-cookie'
import Resource from './Resource'
import Chapter from './Chapter'
import Manga from './Manga'
export default class Follows extends Resource {
static get resourceType() { return 'follows' }
constructor(data = {}, responseCode = -1, responseStatus = null) {
super(data, responseCode, responseStatus)
this.chapters = data.chapters.map(c => new Chapter(c)).sort((a, b) => b.timestamp - a.timestamp)
this.manga = new Map(Object.values(data.manga).map(m => [m.id, new Manga(m)]))
}
get unreadChapters() {
return this.chapters.filter(c => !c.read)
}
get unreadChaptersGroupedByManga() {
return this.unreadChapters.reduce((map, ch) => {
const manga = this.manga.get(ch.mangaId)
if (!map.has(manga)) {
map.set(manga, [])
}
map.get(manga).push(ch)
return map
}, new Map())
}
static async load(params = {}, cache = true) {
const type = 1 // Reading
const hentai = Cookies.get('mangadex_h_toggle')
const json = await super.load(`user/me/followed-updates`, Object.assign({ type, hentai }, params)) // type 1 = Reading
return this.fromJSON(json.data, json.code, json.status, cache)
}
}

View File

@ -0,0 +1,15 @@
import Resource from './Resource'
export default class Group extends Resource {
static get resourceType() { return 'group' }
constructor(data = {}, responseCode = -1, responseStatus = null) {
super(data, responseCode, responseStatus)
this.id = data.id
this.name = data.name
}
static async load(id, cache = true) {
const json = await super.load(`group/${id}`, {})
return this.fromJSON(json.data, json.code, json.status, cache)
}
}

View File

@ -0,0 +1,165 @@
import { differenceInHours } from 'date-fns'
import Resource from './Resource'
import Chapter from './Chapter'
import Group from './Group'
import Utils from '../utils'
import natsort from 'natsort'
export default class Manga extends Resource {
static get resourceType() { return 'manga' }
constructor(data = {}, responseCode = -1, responseStatus = null) {
super(data, responseCode, responseStatus)
this.id = data.id
this.title = data.title
this.language = data.publication ? data.publication.language : null
this.isHentai = data.isHentai
this.lastChapter = data.lastChapter || ''
this.lastVolume = data.lastVolume || ''
this.mainCover = data.mainCover
this.tags = data.tags || []
this.links = data.links || {}
this._uniqueChapters = []
this._chapters = []
this.chapterListLastLoaded = null
}
// derived data
get isLongStrip() { return this.tags.indexOf(36) !== -1 }
get isDoujinshi() { return this.tags.indexOf(7) !== -1 }
get url() {
const title = this.title.toLowerCase().replace(/&[0-9a-z]+;/gi, '').replace(/[^0-9a-z]/gi, ' ').split(' ').filter(t => t).join('-')
return `/title/${this.id}/${title}`
}
get coverThumb() { return `/images/manga/${this.id}.thumb.jpg` }
get isChapterListOutdated() { return differenceInHours(this.chapterListLastLoaded, new Date()) >= 1 }
// methods
isLastChapter(chapter) {
return this.lastChapter && (chapter.chapter === this.lastChapter) && (chapter.volume == this.lastVolume)
}
getChapter(id) {
return Chapter.getResource(id)
}
get chapterList() {
return this._chapters
}
get uniqueChapterList() {
return this._uniqueChapters
}
updateChapterList(baseChapter, restrictChLang = false) {
let chapters = Chapter.findResources(c => c.mangaId === this.id)
if (restrictChLang) {
chapters = chapters.filter(c => c.language === baseChapter.language)
}
const chapterIds = Manga.createSortedChapterIdList(chapters)
this._chapters = chapterIds.map(id => Chapter.getResource(id))
this._uniqueChapters = Manga.createUniqueChapterList(baseChapter, this._chapters)
}
getNextChapterId(id) {
const index = this._uniqueChapters.map(c => c.id).indexOf(id)
return (index >= 0 && index + 1 < this._uniqueChapters.length) ? this._uniqueChapters[index + 1].id : 0
}
getPrevChapterId(id) {
const index = this._uniqueChapters.map(c => c.id).indexOf(id)
return (index > 0) ? this._uniqueChapters[index - 1].id : -1
}
getAltChapters(id) {
const cur = Chapter.getResource(id)
return cur ? this.chapterList.filter(ch => cur.isAlternativeOf(ch)) : []
}
static createSortedChapterIdList(chapters) {
chapters = chapters.map(ch => {
return !ch ? null : {
id: ch.id,
timestamp: ch.timestamp,
chapter: ch.chapter,
volume: ch.volume,
group: ch.groupIds[0],
}
}).filter(c => c)
const sorter = natsort({ asc: true, insensitive: true })
// sort by timestamp desc
Utils.stableSort(chapters, (a, b) => sorter(b.timestamp, a.timestamp))
// sort by volume desc, so that vol null > vol number where ch are equal
Utils.stableSort(chapters, (a, b) => sorter(b.volume, a.volume))
// sort by group
Utils.stableSort(chapters, (a, b) => sorter(a.group, b.group))
// sort by chapter number
Utils.stableSort(chapters, (a, b) => sorter(a.chapter, b.chapter))
// add "ghost" prev vol numbers
let pv = '0'
for (let c of chapters) {
c.prevVolume = pv
pv = c.volume ? c.volume : pv
}
// sort by vol or prev vol
Utils.stableSort(chapters, (a, b) => sorter(a.volume || a.prevVolume, b.volume || b.prevVolume))
return chapters.map(c => c.id)
}
static createUniqueChapterList(baseChapter, chapters) {
const list = []
if (chapters.length > 0) {
let best = chapters[0]
for (let ch of chapters.slice(1)) {
if (!ch.isAlternativeOf(best)) {
list.push(best)
best = ch
} else if (best !== baseChapter && ch.hasSameGroupsWith(baseChapter)) {
best = ch
}
}
list.push(best)
}
return list
}
static async load(id, params = {}, cache = true) {
const json = await super.load(`manga/${id}`, params)
try {
return this.fromJSON(json.data, json.code, json.status, cache)
} catch (error) {
throw this.fromJSON({ id, status: json.message }, json.code, json.status, false)
}
}
static async loadChapterList(id, params = {}, cache = true) {
const json = await super.load(`manga/${id}/chapters`, params)
try {
const { chapters } = this.fromChapterListJSON(id, json, cache)
return chapters
} catch (error) {
return []
}
}
static async loadWithChapterList(id, params = {}, cache = true) {
const json = await super.load(`manga/${id}`, Object.assign(params, { include: 'chapters' }))
try {
const { manga } = this.fromChapterListJSON(id, json, cache)
return manga
} catch (error) {
throw this.fromJSON({ id, status: json.message }, json.code, json.status, false)
}
}
static fromChapterListJSON(id, json, cache) {
// avoid overwriting group/chapter resources that already exist because these are going to be incomplete
const groups = json.data.groups.map(g => Group.getResource(g.id) || Group.fromJSON(g))
const chapters = json.data.chapters.map(c => Chapter.getResource(c.id) || Chapter.fromJSON(c, json.code, json.status, cache))
const manga = json.data.manga ? this.fromJSON(json.data.manga, json.code, json.status, cache) : this.getResource(id)
manga.chapterListLastLoaded = new Date()
//manga.updateChapterList()
return { groups, chapters, manga }
}
}

View File

@ -0,0 +1,62 @@
export default class Resource {
constructor(data = {}, responseCode = -1, responseStatus = null) {
this._response = {
code: responseCode,
status: responseStatus,
}
Resource.addResource(this)
}
static addResource(resource) {
const id = resource.id
const type = resource.constructor.resourceType
if (id && type) {
if (!(type in Resource.cache)) {
Resource.cache[type] = {}
}
Resource.cache[type][id] = resource
}
}
static getResource(id) {
try { return Resource.cache[this.resourceType][id] }
catch (e) { return null }
}
static findResources(filterFn) {
try { return Object.values(Resource.cache[this.resourceType]).filter(filterFn) }
catch (e) { return [] }
}
static fromJSON(data, responseCode = -1, responseStatus = null, cache = true) {
const resource = new this(data, responseCode, responseStatus)
if (cache) {
Resource.addResource(resource)
}
return resource
}
static async load(resourceURL, params = {}) {
try {
const url = new URL(process.env.API_URL + resourceURL)
for (let key in params) {
if (params[key] != null) {
url.searchParams.append(key, params[key])
}
}
const res = await fetch(url, {
credentials: 'include',
})
if (res.status >= 500) {
throw new Error("Error while loading a resource. The server may be busy at the moment.")
}
return await res.json()
} catch (e) {
console.error("Resource loading error:", e)
throw e
}
}
}
Resource.cache = {}

356
scripts/reader/ui.js Normal file
View File

@ -0,0 +1,356 @@
'use strict'
/* global Renderer */
const utils = {
empty: (node) => {
while (node.firstChild) {
node.removeChild(node.firstChild)
}
}
}
class UI {
constructor(reader) {
this.reader = reader
}
get isSinglePage() { return this.renderingMode === UI.RENDERING_MODE.SINGLE }
get isDoublePage() { return this.renderingMode === UI.RENDERING_MODE.DOUBLE }
get isLongStrip() { return this.renderingMode === UI.RENDERING_MODE.LONG }
get isNoResize() { return this.displayFit === UI.DISPLAY_FIT.NO_RESIZE }
get isFitHeight() { return this.displayFit === UI.DISPLAY_FIT.FIT_HEIGHT }
get isFitWidth() { return this.displayFit === UI.DISPLAY_FIT.FIT_WIDTH }
get isFitBoth() { return this.displayFit === UI.DISPLAY_FIT.FIT_BOTH }
get isDirectionLTR() { return this.direction === UI.DIRECTION.LTR }
get isDirectionRTL() { return this.direction === UI.DIRECTION.RTL }
get renderedPages() {
if (this.renderer == null) {
return 0
} else if (this.isLongStrip) {
return 1
} else {
return this.renderer.renderedPages
}
}
initializeContainer(userIsGuest = false) {
this.container = document.querySelector('div[role="main"]')
this.container.classList.remove('container')
this.container.classList.add('reader', 'row', 'flex-column', 'flex-lg-row', 'no-gutters')
this.imageContainer = this.container.querySelector('.reader-images')
document.querySelector('footer').classList.add('d-none')
document.body.style.removeProperty('margin-bottom')
if (userIsGuest) {
const reportBtn = this.container.querySelector('#report-button')
reportBtn.dataset.toggle = ''
reportBtn.href = '/login'
reportBtn.firstElementChild.classList.replace('fa-flag', 'fa-sign-in-alt')
}
}
setRenderer(mode = this.reader.settings.renderingMode, useHistory = true) {
if (this.renderingMode !== mode) {
if (this.renderer != null) {
this.renderer.destroy()
}
this.renderingMode = mode
switch(mode) {
case UI.RENDERING_MODE.LONG:
this.renderer = new Renderer.LongStrip(this.reader)
break
case UI.RENDERING_MODE.DOUBLE:
this.renderer = new Renderer.DoublePage(this.reader)
break
case UI.RENDERING_MODE.SINGLE:
default:
this.renderer = new Renderer.SinglePage(this.reader)
break
case UI.RENDERING_MODE.ALERT:
this.renderer = new Renderer.Alert(this.reader)
break
case UI.RENDERING_MODE.RECS:
this.renderer = new Renderer.Recommendations(this.reader)
break
}
this.container.dataset.renderer = this.renderer.name
if (useHistory) {
this.pushHistory(this.reader.currentPage)
}
}
}
setDirection(direction) {
this.direction = direction
this.container.dataset.direction = UI.DIRECTION.LTR === direction ? 'ltr' : 'rtl'
}
setDisplayFit(fit) {
this.displayFit = fit
this.container.dataset.display = UI.DISPLAY_FIT_STR[fit]
this.container.classList.toggle('fit-horizontal', this.isFitBoth || this.isFitWidth)
this.container.classList.toggle('fit-vertical', this.isFitBoth || this.isFitHeight)
}
onChapterChange(chapter) {
if (this.renderer) {
this.renderer.initialize()
}
this.container.classList.toggle('native-long-strip', chapter.manga.isLongStrip)
this.setRenderer(chapter.manga.isLongStrip ? UI.RENDERING_MODE.LONG : this.reader.settings.renderer, false)
this.setDisplayFit(chapter.manga.isLongStrip ? UI.DISPLAY_FIT.FIT_WIDTH : this.reader.settings.displayFit)
this.resetPageBar(chapter.totalPages)
// TODO?: it's assumed that the input is already escaped; when not, change innerHTML to textContent
this.updateTitles(chapter)
this.updateChapterDropdown(chapter, !this.reader.settings.showDropdownTitles)
this.updatePageDropdown(chapter.totalPages, this.reader.currentPage)
this.updateGroupList(chapter)
this.updateCommentsButton(chapter)
this.updateChapterLinks(chapter)
}
updateSetting(key, value) {
switch (key) {
case 'direction':
this.setDirection(value)
this.updateChapterLinks()
this.setRenderer()
if (this.reader.currentPage) {
this.render(this.reader.currentPage)
}
break
case 'renderingMode':
this.setRenderer(value)
if (this.reader.currentPage) {
this.render(this.reader.currentPage)
}
break
case 'displayFit':
this.setDisplayFit(value)
break
case 'containerWidth':
if (!value) {
value = null
}
this.imageContainer.style.maxWidth = value ? `${value}px` : null
break
case 'showDropdownTitles':
this.updateChapterDropdown(this.reader.chapter, !value)
break
case 'hideHeader':
this.container.classList.toggle('hide-header', value)
document.querySelector('nav.navbar').classList.toggle('d-none', value)
document.querySelector('#fullscreen-button').classList.toggle('active', value)
break
case 'hideSidebar':
this.container.classList.toggle('hide-sidebar', value)
break
case 'hidePagebar':
this.container.classList.toggle('hide-page-bar', value)
break
}
Array.from(this.container.querySelectorAll(`#modal-settings input[data-setting="${key}"]`)).forEach(n => { n.value = value })
Array.from(this.container.querySelectorAll(`#modal-settings select[data-setting="${key}"]`)).forEach(n => { n.value = value })
Array.from(this.container.querySelectorAll(`#modal-settings button[data-setting="${key}"]`)).forEach(n => { n.classList.toggle('active', n.dataset.value == value) })
}
updateTitles(chapter) {
const manga = chapter.manga
document.title = `${manga.title} - ${manga.getChapterTitle(chapter.id)} - MangaDex`
const mangaFlag = this.container.querySelector('.reader-controls-title .lang-flag')
mangaFlag.parentElement.replaceChild(UI.flagImg(manga.langCode, manga.langName), mangaFlag)
const mangaLink = this.container.querySelector('.manga-link')
mangaLink.href = manga.url
mangaLink.title = manga.title
mangaLink.innerHTML = manga.title
this.container.querySelector('.chapter-title').innerHTML = chapter.title
this.container.querySelector('.chapter-tag-h').classList.toggle('d-none', !manga.isHentai)
this.container.querySelector('.chapter-tag-end').classList.toggle('d-none', !chapter.isLastChapter)
this.container.querySelector('.chapter-tag-doujinshi').classList.toggle('d-none', !manga.isDoujinshi)
}
updateChapterDropdown(chapter, hideTitles) {
if (chapter) {
const manga = chapter.manga
const chapters = this.container.querySelector('#jump-chapter')
utils.empty(chapters)
for (let ch of manga.chapterList.slice().reverse()) {
const option = chapters.appendChild(document.createElement('option'))
option.value = ch.id
option.selected = ch.id === chapter.id
option.appendChild(document.createTextNode(manga.getChapterTitle(ch.id, hideTitles)))
}
}
}
updatePageDropdown(totalPages, currentPage) {
const pages = this.container.querySelector('#jump-page')
utils.empty(pages)
for (let i = 1; i <= totalPages; ++i) {
const option = pages.appendChild(document.createElement('option'))
option.value = i
option.selected = currentPage === i
option.appendChild(document.createTextNode(i))
}
}
updateGroupList(chapter) {
const groups = this.container.querySelector('.reader-controls-groups ul')
utils.empty(groups)
for (let g of chapter.manga.getGroupsOfChapter(chapter.id)) {
const li = groups.appendChild(document.createElement('li'))
const flag = li.appendChild(UI.flagImg(g.lang_code, g.lang_code))
flag.classList.add('mr-1')
const link = li.appendChild(document.createElement(g.id == chapter.id ? 'b' : 'a'))
link.innerHTML = [g.group_name, g.group_name_2, g.group_name_3].filter(n => n).join(' | ')
link.dataset.chapter = g.id
link.href = `/chapter/${g.id}`
}
}
updateCommentsButton(chapter) {
this.container.querySelector('#comment-button').href = this.pageURL(chapter.id) + '/comments'
this.container.querySelector('.comment-amount').textContent = chapter.comments || ''
}
updateChapterLinks(chapter) {
if (chapter) {
const update = (toLeft) => {
if (this.direction != null) {
let id = (toLeft === this.isDirectionLTR) ? chapter.prevChapterId : chapter.nextChapterId
return (a) => {
a.dataset.chapter = id
a.href = this.pageURL(id)
a.title = chapter.manga.getChapterTitle(id) || 'Back to manga'
}
}
}
Array.from(this.container.querySelectorAll('a.chapter-link-left')).forEach(update(true))
Array.from(this.container.querySelectorAll('a.chapter-link-right')).forEach(update(false))
}
}
updatePageLinks(chapter, pg) {
if (chapter && typeof pg === 'number') {
const pgStr = `${pg}${this.renderedPages===2 ? ` - ${pg + 1}` : ''}`
this.container.querySelector('.reader-controls-pages .current-page').textContent = pgStr
this.container.querySelector('.reader-controls-pages .total-pages').textContent = chapter.totalPages
this.container.querySelector('.reader-controls-pages .page-link-left').href = this.pageLeftURL(chapter.id, 1)
this.container.querySelector('.reader-controls-pages .page-link-right').href = this.pageRightURL(chapter.id, 1)
this.container.querySelector('#jump-page').value = pg
}
}
pageURL(id, pg) {
if (id != null && id > 0) {
if (pg != null) {
if (pg === 0) {
return this.pageURL(this.reader.chapter.prevChapterId, -1)
} else if (pg > this.reader.chapter.totalPages) {
return this.pageURL(this.reader.chapter.nextChapterId)
}
return this.isTestReader ? `/?page=chapter_test&id=${id}&p=${pg}` : `/chapter/${id}/${pg}`
}
return this.isTestReader ? `/?page=chapter_test&id=${id}` : `/chapter/${id}`
}
return this.manga.url
}
pageLeftURL(id, pages = this.isDoublePage ? 2 : 1) {
return this.pageURL(id, Math.min(this.reader.currentPage + (this.isDirectionLTR ? -pages : pages)), 0)
}
pageRightURL(id, pages = this.isDoublePage ? 2 : 1) {
return this.pageURL(id, Math.min(this.reader.currentPage + (this.isDirectionLTR ? pages : -pages)), 0)
}
resetPageBar(totalPages) {
if (totalPages) {
const notches = this.container.querySelector('.reader-page-bar .notches')
utils.empty(notches)
for (let i = 1; i <= totalPages; ++i) {
const notch = notches.appendChild(document.createElement('div'))
notch.classList.add('notch', 'col')
notch.style.order = i
notch.dataset.page = i
notch.title = `Page ${i}`
// notch.classList.toggle('trail', i <= pg-this.renderedPages)
// notch.classList.toggle('thumb', i > pg-this.renderedPages && i <= pg)
}
this.updatePageBar(totalPages, 1)
}
}
updatePageBar(totalPages, pg) {
if (totalPages && typeof pg === 'number') {
const trail = this.container.querySelector('.reader-page-bar .trail')
const thumb = this.container.querySelector('.reader-page-bar .thumb')
const notchSize = 100 / Math.max(totalPages, 1)
trail.style.width = Math.min(pg * notchSize, 100) + '%'
thumb.style.width = (100 / pg * Math.max(this.reader.renderedPages, 1)) + '%'
trail.style.right = this.reader.isDirectionLTR ? null : 0
thumb.style.float = this.reader.isDirectionLTR ? 'right' : 'left'
}
}
render(chapter, pg) {
if (!pg || chapter == null || this.ui.renderer == null) {
return Promise.reject()
}
return this.renderer.render(pg).then(() => {
this.updatePageLinks(chapter, this.reader.currentPage)
this.updatePageBar(chapter, this.reader.currentPage + this.renderedPages - 1)
}).catch((err) => this.renderError(err))
}
renderError(err) {
console.error('Render error', err)
this.setRenderer(UI.RENDERING_MODE.ALERT)
return this.renderer.render({ type: 'danger', 'message': err.message, 'err': err })
}
static flagImg (langCode = 'jp', langName = 'Unknown') {
const flag = document.createElement('img')
flag.classList.add('lang-flag')
flag.src = `https://mangadex.org/images/flags/${langCode}.png`
flag.alt = langName
flag.title = langName
return flag
}
}
UI.RENDERING_MODE = {
SINGLE: 1,
DOUBLE: 2,
LONG: 3,
ALERT: 4,
RECS: 5,
}
UI.DIRECTION = {
LTR: 1,
RTL: 2,
}
UI.DISPLAY_FIT = {
FIT_BOTH: 1,
FIT_WIDTH: 2,
FIT_HEIGHT: 3,
NO_RESIZE: 4,
}
UI.DISPLAY_FIT_STR = {
1: 'fit-both',
2: 'fit-width',
3: 'fit-height',
4: 'no-resize',
}
if (typeof exports === 'object' && typeof module === 'object') {
module.exports = UI
} else if (typeof define === 'function' && define.amd) {
define([], function () {
return UI
})
} else if (typeof exports === 'object') {
exports.UI = UI
} else {
(typeof window !== 'undefined' ? window : this).UI = UI
}

69
scripts/reader/utils.js Normal file
View File

@ -0,0 +1,69 @@
export default class Utils {
static range (start, end, step = 1) {
const r = []
for (let i = start; i < end; i += step) {
r.push(i)
}
return r
}
static clamp (val, min, max) {
return Math.max(min, Math.min(val, max))
}
static emptyNode (node) {
while (node && node.firstChild) {
node.removeChild(node.firstChild)
}
}
static scrollBy () {
window.scrollBy.apply(null, arguments)
}
static stableSort (array, cmp) {
// https://medium.com/@fsufitch/is-javascript-array-sort-stable-46b90822543f
cmp = !!cmp ? cmp : (a, b) => {
if (a < b) return -1
if (a > b) return 1
return 0
}
const stabilized = array.map((el, index) => [el, index])
const stableCmp = (a, b) => {
const order = cmp(a[0], b[0])
return order != 0 ? order : a[1] - b[1]
}
stabilized.sort(stableCmp)
for (let i = 0; i < array.length; ++i) {
array[i] = stabilized[i][0]
}
return array
}
static htmlTextDecodeHack(str) {
const textarea = document.createElement('textarea')
textarea.innerHTML = str
return textarea.value
}
}
try {
window.scrollBy({ top: 0, behavior: "smooth" })
} catch(e) {
Utils.scrollBy = function() {
const arg = arguments[0]
switch(typeof arg) {
case 'object':
return window.scrollBy(arg.top || 0, arg.left || 0)
case 'number':
return window.scrollBy.apply(null, arguments)
}
}
}
if (typeof exports === 'object' && typeof module === 'object') {
module.exports = Utils
}

58
sql.php
View File

@ -2,9 +2,22 @@
if (PHP_SAPI !== 'cli')
die();
require_once ('/home/www/mangadex.org/bootstrap.php'); //must be like this
require_once ('/var/www/mangadex.org/bootstrap.php'); //must be like this
require_once (ABSPATH . "/scripts/header.req.php");
/*
for ($id = 0; $id < 2491159; $id++) {
$memcached->delete("user_{$id}_friends_user_ids");
$memcached->delete("user_{$id}_pending_friends_user_ids");
$memcached->delete("user_{$id}_friends_user_ids");
$memcached->delete("user_{$id}_pending_friends_user_ids");
if ($id % 1000 === 0) echo ".";
}
die("\nend\n");
*/
/*
$result = $sql->query_read('x', " SELECT COUNT(*) AS `Rows`, `user_id` FROM `mangadex_clients` where approved = 1 GROUP BY `user_id` ORDER BY `user_id` ", 'fetchAll', PDO::FETCH_ASSOC, -1);
@ -154,7 +167,7 @@ foreach ($txs as $tx) {
}
*/
/*
$joined_timestamp = $sql->query_read('x', " SELECT joined_timestamp FROM mangadex_users WHERE joined_timestamp >= 1594166400 ", 'fetchAll', PDO::FETCH_COLUMN, -1);
$joined_timestamp = $sql->query_read('x', " SELECT joined_timestamp FROM mangadex_users WHERE joined_timestamp >= 1597622400 ", 'fetchAll', PDO::FETCH_COLUMN, -1);
foreach($joined_timestamp as $value) {
$date = date('Y-m-d', $value);
@ -259,19 +272,19 @@ foreach ($results as $ro) {
/*
$dir = '/home/www/mangadex.org/data/';
$dir = '/var/www/mangadex.org/data/';
$files = array_diff(scandir($dir), array('..', '.'));
foreach ($files as $file) {
$chapter_id = $sql->query_read('chapter_id', " SELECT chapter_id FROM mangadex_chapters WHERE chapter_hash LIKE '$file' ", 'fetchColumn', '', -1);
if ($chapter_id) {
if (!$chapter_id) {
print $file . " - $chapter_id\n";
$sql->modify('update', " UPDATE mangadex_chapters SET server = 1 WHERE chapter_id = ? LIMIT 1; ", [$chapter_id]);
$memcached->delete("chapter_$chapter_id");
//$sql->modify('update', " UPDATE mangadex_chapters SET server = 1 WHERE chapter_id = ? LIMIT 1; ", [$chapter_id]);
//$memcached->delete("chapter_$chapter_id");
//rename("/home/www/mangadex.org/data/$file", "/home/www/mangadex.org/delete/$file");
rename("/var/www/mangadex.org/data/$file", "/var/www/mangadex.org/delete/$file");
}
}
@ -338,7 +351,7 @@ foreach ($array as $manga) {
}
*/
/*
$results = $sql->query_read('x', " SELECT * FROM mangadex_users where level_id = 0 and user_id > 1900000 order by user_id desc ", 'fetchAll', PDO::FETCH_ASSOC, -1);
foreach ($results as $row) {
$uid = $row['user_id'];
@ -348,7 +361,7 @@ foreach ($results as $row) {
print $uid . ' ';
}
*/
/*
foreach (WALLET_QR['ETH'] as $qr) {
print "Fetching $qr\n\n";
@ -409,12 +422,30 @@ foreach ($txs as $tx) {
//$result = $sql->query_read('x', " SELECT chapters.*, users.level_id, users.user_id, users.username FROM `mangadex_chapters` as chapters left join mangadex_users as users on chapters.user_id = users.user_id where users.level_id = 0 and chapters.chapter_deleted = 1 and chapters.server = 0 ", 'fetchAll', PDO::FETCH_ASSOC, -1);
/*
$result = $sql->query_read('x', " SELECT * FROM `mangadex_chapters` WHERE `manga_id` = 47 AND `server` = 1 AND `chapter_deleted` = 1 ", 'fetchAll', PDO::FETCH_ASSOC, -1);
//$result = $sql->query_read('x', " SELECT * FROM `mangadex_chapters` WHERE `manga_id` = 47 AND `server` = 0 AND `chapter_deleted` = 1 ", 'fetchAll', PDO::FETCH_ASSOC, -1);
$result = $sql->query_read('x', "
SELECT * FROM `mangadex_chapters` WHERE `upload_timestamp` > 1604707200 AND upload_timestamp < 1605312000 and `group_id` != 9097 AND `server` = 0 AND `chapter_deleted` = 0
", 'fetchAll', PDO::FETCH_ASSOC, -1);
foreach ($result as $row) {
print $row['chapter_hash'] . ' ';
}*/
//print $row['chapter_hash'] . ' ';
$file = $row['chapter_hash'];
$chapter_id = $row['chapter_id'];
$memcached->delete("chapter_$chapter_id");
print "$file - $chapter_id\n";
$sql->modify('update', " UPDATE mangadex_chapters SET server = 1 WHERE chapter_id = ? LIMIT 1; ", [$chapter_id]);
//$memcached->delete("chapter_$chapter_id");
rename("/var/www/mangadex.org/data/$file", "/var/www/mangadex.org/transferred/$file");
}
//$result = $sql->query_read('x', " SELECT chapters.*, users.level_id, users.user_id, users.username FROM `mangadex_chapters` as chapters left join mangadex_users as users on chapters.user_id = users.user_id where users.level_id = 0 and chapters.chapter_deleted = 1 and chapters.server = 0 ", 'fetchAll', PDO::FETCH_ASSOC, -1);
@ -482,3 +513,4 @@ foreach ($result as $row) {
//var_dump(is_banned_asn('177.100.112.109'));
//var_dump(get_asn('177.100.112.109'));

View File

@ -10,7 +10,7 @@ use Mangadex\Exception\Http\UnavailableForLegalReasonsHttpException;
class ChapterController extends APIController
{
const CHAPTERS_LIMIT = 6000;
const CHAPTERS_LIMIT = 8000;
const CH_STATUS_OK = 'OK';
const CH_STATUS_DELETED = 'deleted';
const CH_STATUS_DELAYED = 'delayed';
@ -36,10 +36,9 @@ class ChapterController extends APIController
public function view($path)
{
/**
* @param array{0: int|string, 1: string|null, 2: int|string|mixed|null} $path
*/
[$id, $subResource, $subResourceId] = $path;
$id = $path[0] ?? null;
$subResource = $path[1] ?? null;
$subResourceId = $path[2] ?? null;
$id = $this->validateId($id);
@ -53,6 +52,13 @@ class ChapterController extends APIController
if (isset($normalized['pages'])) {
$this->updateChapterViews($chapter);
}
if (in_array('manga', $this->request->query->getList('include'))) {
$mangaController = new MangaController();
$manga = $mangaController->normalize($mangaController->fetch($normalized['mangaId']));
$normalized = ['chapter' => $normalized, 'manga' => $manga];
}
return $normalized;
}
@ -87,6 +93,13 @@ class ChapterController extends APIController
throw new BadRequestHttpException("Invalid limit, range must be within 10 - 100.");
}
if ($this->request->query->getBoolean('blockgroups', true)) {
$blockedGroups = $this->user->get_blocked_groups();
if ($blockedGroups) {
$search['blocked_groups'] = array_keys($blockedGroups);
}
}
$chapters = new \Chapters($search);
$list = $chapters->query_read($order, self::CHAPTERS_LIMIT, 1);
if ($page > 0) {
@ -153,10 +166,12 @@ class ChapterController extends APIController
if (!empty($langFilter)) {
$search["multi_lang_id"] = $langFilter;
}
if ($this->request->query->getBoolean('blockgroups', true)) {
$blockedGroups = $this->user->get_blocked_groups();
if ($blockedGroups) {
$search['blocked_groups'] = array_keys($blockedGroups);
}
}
if ($hentai !== 1) { // i.e. if hentai is 0 (hide) or >1 (show only)
$search['manga_hentai'] = $hentai ? 1 : 0;
}
@ -174,13 +189,25 @@ class ChapterController extends APIController
$chaptersResult = $chapters->query_read($order, $limit, max($page, 1));
$normalized = $this->normalizeList($chaptersResult, false);
if ($userResource->user_id === $this->user->user_id) {
$readChapters = $this->user->get_read_chapters();
foreach ($normalized['chapters'] as &$chapter) {
$chapter['read'] = in_array(
$chapter['id'],
$readChapters
);
}
}
// include basic manga entities
$manga = [];
foreach ($chaptersResult as $chapter) {
foreach ($chaptersResult as &$chapter) {
if (!isset($manga[$chapter['manga_id']])) {
$manga[$chapter['manga_id']] = [
'id' => $chapter['manga_id'],
// TODO: remove 'name'
'name' => $chapter['manga_name'],
'title' => $chapter['manga_name'],
'isHentai' => (bool)$chapter['manga_hentai'],
'lastChapter' => (!empty($chapter['manga_last_chapter']) && $chapter['manga_last_chapter'] !== '0') ? $chapter['manga_last_chapter'] : null,
'lastVolume' => (string)$chapter['manga_last_volume'] ?: null,
@ -208,6 +235,7 @@ class ChapterController extends APIController
'groups' => [],
'uploader' => $chapter->user_id,
'timestamp' => $chapter->upload_timestamp,
'threadId' => $chapter->thread_id,
'comments' => $chapter->thread_posts ?? 0,
'views' => $chapter->chapter_views ?? 0,
];
@ -218,36 +246,58 @@ class ChapterController extends APIController
});
$normalized['groups'] = array_map(function ($g) {
return ['id' => $g[0], 'name' => $g[1]];
}, $groupsFiltered);
}, array_values($groupsFiltered));
if ($fullData) {
$isValidated = validate_level($this->user, 'pr')
|| $this->request->headers->get("API_KEY") === PRIVATE_API_KEY;
$normalized['status'] = self::CH_STATUS_OK;
$isExternal = substr($chapter->page_order, 0, 4) === 'http';
$isRestricted = in_array($chapter->manga_id, RESTRICTED_MANGA_IDS) &&
!validate_level($this->user, 'contributor') &&
!$hasPrivateAuth &&
$this->user->get_chapters_read_count() < MINIMUM_CHAPTERS_READ_FOR_RESTRICTED_MANGA;
$countryCode = strtoupper(get_country_code($this->user->last_ip));
$isRegionBlocked = isset(REGION_BLOCKED_MANGA[$countryCode]) &&
in_array($chapter->manga_id, REGION_BLOCKED_MANGA[$countryCode]) &&
!$isValidated;
// Set status when something other than OK
if ($chapter->chapter_deleted) {
if (!validate_level($this->user, 'pr')) {
if (!$isValidated) {
throw new GoneHttpException(self::CH_STATUS_DELETED);
}
$normalized['status'] = self::CH_STATUS_DELETED;
} else if (!$chapter->available) {
if (!validate_level($this->user, 'pr')) {
if (!$isValidated) {
throw new UnavailableForLegalReasonsHttpException(self::CH_STATUS_UNAVAILABLE);
}
$normalized['status'] = self::CH_STATUS_UNAVAILABLE;
$normalized['groups'] = [];
} else if ($chapter->upload_timestamp > time()) {
if (!$isValidated) {
$groupLeaderIds = [$chapter->group_leader_id, $chapter->group_leader_id_2, $chapter->group_leader_id_3];
$isValidated = in_array($this->user->user_id, array_filter($groupLeaderIds, function ($n) {
return $n > 0;
}));
}
if (!$isValidated) {
$groups = array_map(function ($g) {
return new \Group($g['id']);
}, $normalized['groups']);
$groupMemberIds = array_reduce($groups, function ($acc, $g) {
return array_merge($acc, array_keys($g->get_members()));
}, []);
$isValidated = in_array($this->user->user_id, $groupMemberIds);
}
$normalized['status'] = self::CH_STATUS_DELAYED;
$normalized['groupWebsite'] = $chapter->group_website ?: null;
} else if ($isExternal) {
$normalized['status'] = self::CH_STATUS_EXTERNAL;
$normalized['pages'] = $chapter->page_order;
} else if (
in_array($chapter->manga_id, RESTRICTED_MANGA_IDS) &&
!validate_level($this->user, 'contributor') &&
$this->user->get_chapters_read_count() < MINIMUM_CHAPTERS_READ_FOR_RESTRICTED_MANGA
) {
if (!validate_level($this->user, 'pr')) {
} else if ($isRestricted || $isRegionBlocked) {
if (!$isValidated) {
throw new ForbiddenHttpException(self::CH_STATUS_RESTRICTED);
}
$normalized = [
@ -257,37 +307,21 @@ class ChapterController extends APIController
}
// Include page information for non-external chapters and only for non-restricted users
if (!$isExternal && ($normalized['status'] === self::CH_STATUS_OK || validate_level($this->user, 'pr'))) {
if (!$isExternal && ($normalized['status'] === self::CH_STATUS_OK || $isValidated)) {
$pages = explode(',', $chapter->page_order);
$serverFallback = LOCAL_SERVER_URL;
$serverFallback = IMG_SERVER_URL;
$serverNetwork = null;
// when a chapter does not exist on the local webserver, it gets an id
// since all imageservers share the same data, we can assign any imageserver with the best location to the user
if ($chapter->server > 0) {
if ($this->user->md_at_home ?? false) {
// use md@h for all images
try {
$subsubdomain = $this->mdAtHomeClient->getServerUrl($chapter->chapter_hash, $pages, _IP);
$subsubdomain = $this->mdAtHomeClient->getServerUrl($chapter->chapter_hash, $pages, _IP, $this->user->mdh_portlimit ?? false);
if (!empty($subsubdomain)) {
$serverNetwork = $subsubdomain;
}
} catch (\Throwable $t) {
trigger_error($t->getMessage(), E_USER_WARNING);
}
}
$serverId = -1;
if ($this->request->query->has('server')) {
// if the parameter was trash, this returns -1
$serverId = get_server_id_by_code($this->request->query->get('server'));
}
if ($serverId < 1) {
// try to select a region-based server if we haven't one set already
$serverId = get_server_id_by_geography();
}
if ($serverId > 0) {
$serverFallback = "https://s$serverId.mangadex.org";
}
}
$server = $serverNetwork ?: $serverFallback;
$dataDir = $this->request->query->getBoolean('saver') ? '/data-saver/' : '/data/';

View File

@ -8,10 +8,9 @@ class FollowsController extends APIController
{
public function view($path)
{
/**
* @param array{0: int|string, 1: string|null, 2: int|string|mixed|null} $path
*/
[$id, $subResource, $subResourceId] = $path;
$id = $path[0] ?? null;
$subResource = $path[1] ?? null;
$subResourceId = $path[2] ?? null;
if (!empty($id)) {
throw new NotFoundHttpException();

View File

@ -8,10 +8,9 @@ class GroupController extends APIController
{
public function view($path)
{
/**
* @param array{0: int|string, 1: string|null, 2: int|string|mixed|null} $path
*/
[$id, $subResource, $subResourceId] = $path;
$id = $path[0] ?? null;
$subResource = $path[1] ?? null;
$subResourceId = $path[2] ?? null;
$id = $this->validateId($id);

View File

@ -0,0 +1,42 @@
<?php
namespace Mangadex\Controller\API;
use Mangadex\Exception\Http\ForbiddenHttpException;
class HighestChapterIDController extends APIController
{
public function view($path)
{
$isAuthorized = validate_level($this->user, 'pr') || $this->request->headers->get("API_KEY") === PRIVATE_API_KEY;
if (!$isAuthorized) {
throw new ForbiddenHttpException("You are not authorized.");
}
$id = $this->fetch();
$normalized = $this->normalize($id);
return $normalized;
}
public function fetch()
{
global $sql;
return $sql->prep(
"latest_chapter_id",
" SELECT MAX(chapter_id) FROM mangadex_chapters",
[],
'fetchColumn',
'',
-1
);
}
protected function normalize($id)
{
$normalized = [
'id' => $id,
];
return $normalized;
}
}

View File

@ -8,7 +8,7 @@ class IndexController extends APIController
{
return [
"information" => "Authentication is achieved by the same means as logging in to the site (i.e. the mangadex_session, mangadex_rememberme_token cookies, correct User-Agent). Some chapters may require authenticated permissions to access. The Content-Type header for requests with bodies must be application/json, and the content must be valid JSON. Boolean query values are evaluated 1/true/on/yes for true, otherwise false.",
"baseUrl" => URL . "api/v2",
"baseUrl" => defined('API_V2_URL') ? API_V2_URL : "https://api.mangadex.org/v2/",
"resources" => [
"GET /" => [
"description" => "The current page, the API index.",
@ -29,6 +29,7 @@ class IndexController extends APIController
"queryParameters" => [
"p" => "(Optional) The current page of the paginated results, starting from 1. Integer, default disables pagination.",
"limit" => "(Optional) The limit of the paginated results, allowed range 10 - 100. Integer, default 100.",
"blockgroups" => "(Optional) Do not include chapters by groups blocked by the user. Boolean, default true.",
],
],
"GET /manga/{id}/covers" => [
@ -61,6 +62,7 @@ class IndexController extends APIController
"queryParameters" => [
"p" => "(Optional) The current page of the paginated results, starting from 1. Integer, default disables pagination.",
"limit" => "(Optional) The limit of the paginated results, allowed range 10 - 100. Integer, default 100.",
"blockgroups" => "(Optional) Do not include chapters by groups blocked by the user. Boolean, default true.",
],
],
],
@ -79,13 +81,18 @@ class IndexController extends APIController
"queryParameters" => [
"p" => "(Optional) The current page of the paginated results, starting from 1. Integer, default disables pagination.",
"limit" => "(Optional) The limit of the paginated results, allowed range 10 - 100. Integer, default 100.",
"blockgroups" => "(Optional) Do not include chapters by groups blocked by the user. Boolean, default true.",
],
],
"GET /user/{id}/settings" => [
"description" => "(Authorization required) Get a user's website settings.",
],
"GET /user/{id}/followed-manga" => [
"description" => "(Authorization required) Get a user's followed manga and personal data for them.",
"description" => "(Authorization required) Get a user's followed manga and personal data for them. The target user's MDList privacy setting is taken into account when determining authorization.",
"queryParameters" => [
"type" => "(Optional) Filter the results by the follow type ID (i.e. 1 = Reading, 2 = Completed etc). Use 0 to remove filtering. Integer, default 0.",
"hentai" => "(Optional) Filter results based on whether the titles are marked as hentai. 0 = Hide H, 1 = Show all, 2 = Show H only. Integer, default 0.",
],
],
"GET /user/{id}/followed-updates" => [
"description" => "(Authorization required) Get the latest uploaded chapters for the manga that the user has followed, as well as basic related manga information. Ordered by timestamp descending (the datetime when the chapter is available). Limit 100 chapters per page. Note that the results are automatically filtered by the authorized user's chapter language filter setting.",
@ -94,6 +101,7 @@ class IndexController extends APIController
"type" => "(Optional) Filter the results by the follow type ID (i.e. 1 = Reading, 2 = Completed etc). Use 0 to remove filtering. Integer, default 0.",
"hentai" => "(Optional) Filter results based on whether the titles are marked as hentai. 0 = Hide H, 1 = Show all, 2 = Show H only. Integer, default 0.",
"delayed" => "(Optional) Include delayed chapters in the results. Boolean, default false.",
"blockgroups" => "(Optional) Do not include chapters by groups blocked by the user. Boolean, default true.",
//"langs" => "(Optional) Filter results based on the scanlation language. Use a comma-separated list of language IDs.",
],
],

View File

@ -8,10 +8,9 @@ class MangaController extends APIController
{
public function view($path)
{
/**
* @param array{0: int|string, 1: string|null, 2: int|string|mixed|null} $path
*/
[$id, $subResource, $subResourceId] = $path;
$id = $path[0] ?? null;
$subResource = $path[1] ?? null;
$subResourceId = $path[2] ?? null;
$id = $this->validateId($id);
@ -45,20 +44,22 @@ class MangaController extends APIController
return $manga;
}
private function normalize($manga)
public function normalize($manga)
{
$coverPath = "/images/manga/$manga->manga_id.$manga->manga_image";
$normalized = [
//'type' => 'manga',
'id' => $manga->manga_id,
'title' => $manga->manga_name,
'altTitles' => array_map(function ($alt_name) {
return \html_entity_decode($alt_name);
return trim(\html_entity_decode($alt_name));
}, $manga->get_manga_alt_names()),
'description' => $manga->manga_description,
'artist' => explode(',', $manga->manga_artist),
'author' => explode(',', $manga->manga_author),
'artist' => array_map(function ($a) {
return trim($a);
}, explode(',', $manga->manga_artist)),
'author' => array_map(function ($a) {
return trim($a);
}, explode(',', $manga->manga_author)),
'publication' => [
'language' => $manga->lang_flag,
'status' => $manga->manga_status_id,
@ -74,7 +75,7 @@ class MangaController extends APIController
'id' => $relation['related_manga_id'],
'title' => $relation['manga_name'],
'type' => $relation['relation_id'],
'isHentai' => (bool)$relation['hentai'],
'isHentai' => (bool)$relation['manga_hentai'],
];
}, $manga->get_related_manga()),
'rating' => [

View File

@ -8,10 +8,9 @@ class RelationTypeController extends APIController
{
public function view($path)
{
/**
* @param array{0: int|string, 1: string|null, 2: int|string|mixed|null} $path
*/
[$id, $subResource, $subResourceId] = $path;
$id = $path[0] ?? null;
$subResource = $path[1] ?? null;
$subResourceId = $path[2] ?? null;
if (!empty($id)) {
throw new NotFoundHttpException();

View File

@ -8,10 +8,9 @@ class TagController extends APIController
{
public function view($path)
{
/**
* @param array{0: int|string, 1: string|null, 2: int|string|mixed|null} $path
*/
[$id, $subResource, $subResourceId] = $path;
$id = $path[0] ?? null;
$subResource = $path[1] ?? null;
$subResourceId = $path[2] ?? null;
if ($id) {
$id = $this->validateId($id);

View File

@ -24,12 +24,26 @@ class UserController extends APIController
return $id == $this->user->user_id || ($level !== null && validate_level($this->user, $level));
}
protected function isAuthorizedUserForMangaList($targetUser, $level)
{
if ($this->isAuthorizedUser($targetUser->user_id, $level)) {
return true;
} else if ($targetUser->list_privacy === 1) {
return true;
} else if ($targetUser->list_privacy === 2) {
$friends = $targetUser->get_friends_user_ids();
if ($friends[$this->user->user_id]['accepted'] ?? false) {
return true;
}
}
return false;
}
public function view($path)
{
/**
* @param array{0: int|string, 1: string|null, 2: int|string|mixed|null} $path
*/
[$id, $subResource, $subResourceId] = $path;
$id = $path[0] ?? null;
$subResource = $path[1] ?? null;
$subResourceId = $path[2] ?? null;
$id = $this->validateId($id);
@ -38,7 +52,8 @@ class UserController extends APIController
$this->fetch($id); // check if exists
return (new ChapterController())->fetchForUser($id);
case 'followed-manga':
if (!$this->isAuthorizedUser($id)) {
$user = $this->fetch($id);
if (!$this->isAuthorizedUserForMangaList($user, 'mod')) {
throw new ForbiddenHttpException();
}
return $this->fetchFollowedManga($id);
@ -123,10 +138,13 @@ class UserController extends APIController
return [
'userId' => $userId,
'mangaId' => $data['manga_id'],
'mangaTitle' => $data['title'],
'isHentai' => (bool) $data['manga_hentai'],
'followType' => $data['follow_type'],
'volume' => $data['volume'],
'chapter' => $data['chapter'],
'rating' => $data['rating'] ?: null,
'mainCover' => $data['manga_image'] ? $this->getFileUrl("/images/manga/{$data['manga_id']}.{$data['manga_image']}") : null,
];
}
@ -134,9 +152,21 @@ class UserController extends APIController
{
$userResource = $this->fetch($id);
$follows = $userResource->get_followed_manga_ids_api();
return array_map(function ($data) use ($id) {
if ($this->request->query->has('type')) {
$type = $this->request->query->getInt('type');
$follows = array_filter($follows, function ($m) use ($type) {
return $m['follow_type'] === $type;
});
}
$hentai = $this->request->query->getInt('hentai', 0);
if ($hentai !== 1) {
$follows = array_filter($follows, function ($m) use ($hentai) {
return $m['manga_hentai'] === 0 && $hentai === 0 || $m['manga_hentai'] === 1 && $hentai === 2;
});
}
return array_values(array_map(function ($data) use ($id) {
return $this->normalizeMangaUserData($id, $data);
}, $follows);
}, $follows));
}
public function fetchFollowedUpdates($id)
@ -148,7 +178,7 @@ class UserController extends APIController
public function fetchMangaUserData($id, $mangaId)
{
$userResource = $this->fetch($id);
$data = $userResource->get_manga_userdata($mangaId)[0] ?? null;
$data = $userResource->get_manga_userdata($mangaId) ?? null;
if ($data === null) {
throw new NotFoundHttpException("Manga not found.");
}
@ -171,20 +201,28 @@ class UserController extends APIController
public function fetchSettings($id)
{
$user = $this->fetch($id);
$langIds = explode(',', $user->default_lang_ids ?? '');
$exludedTags = explode(',', $user->excluded_genres ?? '');
return [
'id' => $user->user_id,
'hentaiMode' => $user->hentai_mode,
'latestUpdates' => $user->latest_updates,
'showModeratedPosts' => (bool)$user->display_moderated,
'showUnavailableChapters' => (bool)$user->show_unavailable,
'shownChapterLangs' => explode(',', $user->default_lang_ids ?: ''),
'excludedTags' => explode(',', $user->excluded_genres ?: ''),
'shownChapterLangs' => array_map(function ($id) {
return ['id' => $id];
}, $langIds),
'excludedTags' => array_map(function ($id) {
return ['id' => (int)$id];
}, $exludedTags),
];
}
public function create($path)
{
[$id, $subResource, $subResourceId] = $path;
$id = $path[0] ?? null;
$subResource = $path[1] ?? null;
$subResourceId = $path[2] ?? null;
$id = $this->validateId($id);
$content = $this->decodeJSONContent();

View File

@ -75,6 +75,10 @@ class Guard
$check = $this->sql->prep('user_rememberme_check', 'SELECT user_id, region_data FROM mangadex_sessions WHERE session_token = ? AND created > UNIX_TIMESTAMP() - ?',
[$rememberMeToken, SESSION_REMEMBERME_TIMEOUT], 'fetch', \PDO::FETCH_ASSOC, -1);
if (!$check) {
return;
}
$tokenRegionData = json_decode($check['region_data'], 1);
$userRegionData = $this->getClientDetails();
@ -142,7 +146,7 @@ class Guard
$sessionInfo['updated'] = time();
$sessionInfo['ip'] = _IP;
$this->memcached->set('session:'.$sessionId, $sessionInfo, time() + SESSION_TIMEOUT);
$this->memcached->setSynced('session:'.$sessionId, $sessionInfo, time() + SESSION_TIMEOUT);
setcookie(SESSION_COOKIE_NAME,
$sessionId,
time() + SESSION_TIMEOUT,
@ -169,7 +173,7 @@ class Guard
$sessionInfo['userid']
]
);
$this->memcached->set("user_{$sessionInfo['userid']}_lastseen", 1, 60);
$this->memcached->setSynced("user_{$sessionInfo['userid']}_lastseen", 1, 60);
}
} else {
// No session found? It could've been kicked out of memcached. Lets destroy the session cookie
@ -196,7 +200,7 @@ class Guard
'userid' => (int)$userId,
'is_rememberme' => $isRemembermeSession,
];
$this->memcached->set('session:'.$sessionId, $sessionInfo, time() + SESSION_TIMEOUT);
$this->memcached->setSynced('session:'.$sessionId, $sessionInfo, time() + SESSION_TIMEOUT);
setcookie(SESSION_COOKIE_NAME,
$sessionId,
time() + SESSION_TIMEOUT,
@ -249,6 +253,9 @@ class Guard
public function verifyUserCredentials($userId, $rawPassword)
{
$user = $this->getUser($userId);
if (!$user) {
return false;
}
return password_verify($rawPassword, $user->password);
}

View File

@ -5,22 +5,46 @@ namespace Mangadex\Model;
class MdexAtHomeClient
{
public function getServerUrl(string $chapterHash, array $chapterPages, string $ip): string
public function getServerUrl(string $chapterHash, array $chapterPages, string $ip, bool $onlySsl): string
{
$path = '/assign';
$payload = [
'ip' => $ip,
'hash' => $chapterHash,
'images' => $chapterPages,
'only_443' => $onlySsl,
];
$ch = $this->getCurl($path, $ip.$chapterHash.implode($chapterPages), $payload);
$res = curl_exec($ch);
curl_close($ch);
if ($res === false) {
throw new \RuntimeException('MD@H::getServerUrl curl error: '.curl_error($ch));
return $this->queryAssign($ch, $payload);
}
public function getClientUrl(string $chapterHash, array $chapterPages, string $ip, string $clientId): string
{
$path = '/assign';
$payload = [
'ip' => $ip,
'hash' => $chapterHash,
'images' => $chapterPages,
'client_id' => $clientId,
];
$ch = $this->getCurl($path, $ip.$chapterHash.implode($chapterPages), $payload);
return $this->queryAssign($ch, $payload);
}
private function queryAssign($ch, $payload): string
{
$res = curl_exec($ch);
if ($res === false) {
$error = curl_error($ch);
curl_close($ch);
throw new \RuntimeException('MD@H::getServerUrl curl error: '.$error);
}
curl_close($ch);
$dec = \json_decode($res, true);
if (!$dec) {
throw new \RuntimeException('MD@H::getServerUrl failed to decode: '.$res);

View File

@ -13,7 +13,7 @@
<div class="table-responsive">
<table class="table table-striped table-hover table-sm">
<?php foreach ($templateVar['stats'] ?? [] AS $dsn => $serverStats) : ?>
<?php if (empty($serverStats)) continue; ?>
<?php if (empty($serverStats) || empty($serverStats[0] ?? [])) continue; ?>
<thead>
<tr class="border-top-0">
<th colspan="2">Server: <?=$dsn?></th>
@ -24,8 +24,8 @@
</tr>
</thead>
<tbody>
<?php foreach ($serverStats as $key => $val) : ?>
<?php if (!in_array($key, ['Slave_IO_State', 'Slave_IO_Running', 'Seconds_Behind_Master'])) continue; ?>
<?php foreach ($serverStats[0] as $key => $val) : ?>
<?php if (!in_array($key, ['Relay_Log_File', 'Relay_Log_Space', 'Last_Error', 'Last_IO_Error', 'Last_SQL_Error', 'Slave_SQL_Running_State', 'Slave_IO_State', 'Slave_IO_Running', 'Seconds_Behind_Master'])) continue; ?>
<tr>
<td><?=$key?></td>
<td><?=$val?></td>

View File

@ -0,0 +1,27 @@
<!-- ad template -->
<?php
$banner = $templateVar[array_rand($templateVar)];
?>
<?php if (!isset($_COOKIE["hide_banner"])) : ?>
<div id="affiliate-banner" class="affiliate-banner mb-3">
<div class="d-flex position-relative">
<img id="close-banner" src="/images/banners/close.png" style="right:0;top:0;bottom:0;height:100%;cursor:pointer;" class="position-absolute"><a href="/support/affiliates">
<img class="w-100" src="/images/banners/affiliatebanner<?= $banner['banner_id'] ?>.<?= $banner['ext'] ?>">
</a>
</div>
<div class="d-none d-md-block" style="text-align: right;margin-right:20px;">
This affiliate banner was designed by <?= $banner["is_anonymous"] ? "an anonymous user" : display_user_link_v2($banner) ?>
</div>
<div class="d-md-none small" style="text-align: right;margin-right:20px;">
This affiliate banner was designed by <?= $banner["is_anonymous"] ? "an anonymous user" : display_user_link_v2($banner) ?>
</div>
</div>
<script type="text/javascript">
const closeButton = document.querySelector('#close-banner')
closeButton.addEventListener('click', evt => {
document.querySelector("#affiliate-banner").classList.add("d-none")
document.cookie = "hide_banner=true; max-age=2678400"
})
</script>
<?php endif; ?>

View File

@ -11,7 +11,7 @@
<div class="row">
<div class="col-lg-8">
<?= parse_template('ads/mobile_app_ad', $templateVar['banners']) ?>
<div class="card mb-3">
<h6 class="card-header text-center"><?= display_fa_icon('external-link-alt') ?> <a href="/updates">Latest updates</a></h6>
<div class="card-header p-0">

View File

@ -1,5 +1,9 @@
<?php
$env = (defined('DEBUG') && DEBUG) ? 'dev' : 'prod';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
@ -29,11 +33,22 @@
<title><?= $templateVar['og']['title'] ?></title>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-TS59XX9');</script>
<script>
(function(w, d, s, l, i) {
w[l] = w[l] || [];
w[l].push({
'gtm.start': new Date().getTime(),
event: 'gtm.js'
});
var f = d.getElementsByTagName(s)[0],
j = d.createElement(s),
dl = l != 'dataLayer' ? '&l=' + l : '';
j.async = true;
j.src =
'https://www.googletagmanager.com/gtm.js?id=' + i + dl;
f.parentNode.insertBefore(j, f);
})(window, document, 'script', 'dataLayer', 'GTM-TS59XX9');
</script>
<!-- End Google Tag Manager -->
<!-- Google fonts -->
@ -69,11 +84,7 @@
<link href="/scripts/css/reader.css?<?= @filemtime(ABSPATH . "/scripts/css/reader.css") ?>" rel="stylesheet" />
<?php } ?>
<?php if (defined('DEBUG') && DEBUG): ?>
<script type="module" src="/dist/js/bundle.dev.js?<?= @filemtime(ABSPATH . "/dist/js/bundle.dev.js") ?>"></script>
<?php else: ?>
<script type="module" src="/dist/js/bundle.prod.js?<?= @filemtime(ABSPATH . "/dist/js/bundle.prod.js") ?>"></script>
<?php endif; ?>
<script type="module" src="/dist/js/bundle.<?= $env ?>.js?<?= @filemtime(ABSPATH . "/dist/js/bundle.$env.js") ?>"></script>
</head>
<body>
@ -102,7 +113,6 @@
</div>
<?php } ?>
<?php
/** Print page content */
print $templateVar['page_html'];
@ -196,7 +206,7 @@
<?= parse_template('partials/report_modal', $templateVar); ?>
<footer class="footer">
<p class="m-0 text-center text-muted">&copy; <?= date('Y') ?> <a href="/" title="<?php print_r($templateVar['memcached']->get($templateVar['ip'])) ?>">MangaDex</a> | <a href="https://path.net/" target="_blank" title="Provider of DDoS mitigation services">Path Network</a> | <a href="https://sdbx.moe/" target="_blank" title="seedbox provider">sdbx.moe</a></p>
<p class="m-0 text-center text-muted">&copy; <?= date('Y') ?> <a href="/" title="<?php print_r($templateVar['memcached']->get($templateVar['ip'])) ?>">MangaDex</a> | <a href="https://path.net/" target="_blank" title="Provider of DDoS mitigation services">Path Network</a> | <a href="https://sdbx.moe/" target="_blank" title="seedbox provider">sdbx.moe</a> | <a href="https://ddos-guard.net?affiliate=119953" target="_blank" title="ddos-guard">DDoS Protection by DDoS-GUARD</a> | <a href="https://onramper.com/" target="_blank" title="Crypto Widget">Onramper</a></p>
</footer>
<?php
@ -224,20 +234,21 @@
<script>
if (!('URL' in window) || !('URLSearchParams' in window)) {
document.head.appendChild(Object.assign(document.createElement("script"), {
"src": "/dist/js/polyfills.prod.js?<?= @filemtime(ABSPATH . "/dist/js/polyfills.prod.js") ?>", "async": true,
"src": "/dist/js/polyfills.prod.js?<?= @filemtime(ABSPATH . "/dist/js/polyfills.prod.js") ?>",
"async": true,
}))
}
</script>
<?php if (in_array($templateVar['page'], ['chapter', 'chapter_test']) && (!isset($_GET['mode']) || $_GET['mode'] == 'chapter') && !$templateVar['user']->reader) { ?>
<?php if ($templateVar['page'] == 'chapter' && (!isset($_GET['mode']) || $_GET['mode'] == 'chapter') && !$templateVar['user']->reader) { ?>
<script src="/scripts/modernizr-custom.js"></script>
<?php }
if ($templateVar['page'] == 'chapter' && (!isset($_GET['mode']) || $_GET['mode'] == 'chapter') && !$templateVar['user']->reader) { ?>
<script async src="/scripts/reader.min.js?<?= @filemtime(ABSPATH . "/scripts/reader.min.js") ?>"></script>
<script async src="/dist/js/reader.<?= $env ?>.js?<?= @filemtime(ABSPATH . "/dist/js/reader.$env.js") ?>"></script>
<?php if ($env !== 'prod') { ?>
<script nomodule src="/dist/js/reader.prod.js?<?= @filemtime(ABSPATH . "/dist/js/reader.prod.js") ?>"></script>
<?php } ?>
<?php } ?>
<script src="/scripts/js/reporting.js"></script>
<script type="text/javascript">
<?php if (defined('INCLUDE_JS_REDIRECT') && INCLUDE_JS_REDIRECT) : ?>
var t = 'mang';
t = t + 'adex.org';
@ -344,7 +355,9 @@
function highlightPost(node) {
if (node) {
Array.from(document.querySelectorAll('.highlighted')).forEach(function(n) {n.classList.remove('highlighted')});
Array.from(document.querySelectorAll('.highlighted')).forEach(function(n) {
n.classList.remove('highlighted')
});
node.classList.add('highlighted')
}
}
@ -360,7 +373,7 @@
});
</script>
</body>
</html>

View File

@ -42,9 +42,7 @@
?>
<img class="long-strip <?= ($templateVar['user']->reader_click) ? "click" : "" ?>" src="/img.php?x=/data/<?= "{$templateVar['chapter']->chapter_hash}/$x" ?>" alt="image <?= $key ?>" />
<?php
}
else {
} else {
?>
<img class="long-strip <?= ($templateVar['user']->reader_click) ? "click" : "" ?>" src="<?= "{$templateVar['server']}{$templateVar['chapter']->chapter_hash}/$x" ?>" alt="image <?= $key ?>" />
<?php
@ -59,9 +57,7 @@
?>
<img class="webtoon <?= ($templateVar['user']->reader_click) ? "click" : "" ?>" src="/img.php?x=/data/<?= "{$templateVar['chapter']->chapter_hash}/$x" ?>" alt="image <?= $key ?>" />
<?php
}
else {
} else {
?>
<img class="webtoon <?= ($templateVar['user']->reader_click) ? "click" : "" ?>" src="<?= "{$templateVar['server']}{$templateVar['chapter']->chapter_hash}/$x" ?>" alt="image <?= $key ?>" />
@ -123,7 +119,9 @@
<div class="col-md-9">
<select required title="Select a reason" class="form-control selectpicker" id="type_id" name="type_id">
<?php
$chapter_reasons = array_filter($templateVar['report_reasons'], function($reason) { return REPORT_TYPES[$reason['type_id']] === 'Chapter'; });
$chapter_reasons = array_filter($templateVar['report_reasons'], function ($reason) {
return REPORT_TYPES[$reason['type_id']] === 'Chapter';
});
foreach ($chapter_reasons as $reason) : ?>
<option value="<?= $reason['id'] ?>"><?= $reason['text'] ?><?= $reason['is_info_required'] ? ' *' : '' ?></option>
<?php endforeach; ?>
@ -176,6 +174,15 @@
</select>
</div>
</div>
<div class="form-group row">
<label for="data_saver" class="col-md-3 col-form-label">Data saver:</label>
<div class="col-md-9">
<select class="form-control selectpicker" id="data_saver" name="data_saver">
<option <?= !$templateVar['user']->data_saver ? 'selected' : '' ?> value="0">Off</option>
<option <?= $templateVar['user']->data_saver ? 'selected' : '' ?> value="1">On</option>
</select>
</div>
</div>
<div class="form-group row">
<label for="reader_mode" class="col-md-3 col-form-label">Reader mode:</label>
<div class="col-md-9">

View File

@ -187,6 +187,7 @@ $links_array = ($templateVar['manga']->manga_links) ? json_decode($templateVar['
<div class="col-lg-3 col-xl-2 strong">Mod:</div>
<div class="col-lg-9 col-xl-10">
<?= display_lock_manga($templateVar['user'], $templateVar['manga']) ?>
<?= display_regenerate_manga_thumb($templateVar['user']) ?>
<?= display_delete_manga($templateVar['user']) ?>
</div>
</div>

View File

@ -4,7 +4,6 @@
<li class="nav-item"><a class="nav-link <?= ($templateVar['section'] == 'stats') ? 'active' : '' ?>" href="/md_at_home/stats"><?= display_fa_icon('chart-line', 'Statistics') ?> <span class="d-none d-lg-inline">Statistics</span></a></li>
<li class="nav-item"><a class="nav-link <?= ($templateVar['section'] == 'request') ? 'active' : '' ?>" href="/md_at_home/request"><?= display_fa_icon('envelope', 'Request a client') ?> <span class="d-none d-lg-inline">Request a client</span></a></li>
<?php if (validate_level($templateVar['user'], 'member')) : ?>
<li class="nav-item"><a class="nav-link <?= ($templateVar['section'] == 'options') ? 'active' : '' ?>" href="/md_at_home/options"><?= display_fa_icon('cog', 'Options') ?> <span class="d-none d-lg-inline">Options</span></a></li>
<li class="nav-item"><a class="nav-link <?= ($templateVar['section'] == 'clients') ? 'active' : '' ?>" href="/md_at_home/clients"><?= display_fa_icon('server', 'My clients') ?> <span class="d-none d-lg-inline">My clients</span></a></li>
<?php endif; ?>
<?php if (validate_level($templateVar['user'], 'admin')) : ?>

View File

@ -1,19 +1,18 @@
<table class="table table-striped table-hover">
<thead>
<tr>
<th><?= display_fa_icon('hashtag') ?></th>
<th class="text-center"><?= display_fa_icon('hashtag') ?></th>
<th><?= display_fa_icon('user') ?></th>
<th>IP</th>
<th><?= display_fa_icon('globe-asia') ?></th>
<th><?= display_fa_icon('globe') ?></th>
<th><?= display_fa_icon('bolt') ?></th>
<th><?= display_fa_icon('upload', 'Mbps') ?></th>
<th><?= display_fa_icon('download', 'Mbps') ?></th>
<th><?= display_fa_icon('hdd', 'GB', '', 'far') ?></th>
<th class="text-center"><?= display_fa_icon('network-wired', 'Test') ?></th>
<th class="text-center"><?= display_fa_icon('globe-asia') ?></th>
<th class="text-center"><?= display_fa_icon('globe') ?></th>
<th class="text-center"><?= display_fa_icon('bolt') ?></th>
<th class="text-center"><?= display_fa_icon('upload', 'Mbps') ?></th>
<th class="text-center"><?= display_fa_icon('download', 'Mbps') ?></th>
<th class="text-center"><?= display_fa_icon('hdd', 'GB', '', 'far') ?></th>
<th><?= display_fa_icon('calendar-alt') ?></th>
<th><?= display_fa_icon('key') ?></th>
<th>Data transferred (GB)</th>
<th>Daily average (GB)</th>
<th><?= display_fa_icon('check') ?><?= display_fa_icon('times') ?></th>
</tr>
</thead>
@ -23,6 +22,7 @@
<td>#<?= $client_id ?></td>
<td><?= display_user_link($client->user_id, $client->username, $client->level_colour) ?></td>
<td><?= $client->client_ip ?></td>
<td class="text-center"><?= $client->approved === 1 ? "<a target='_blank' href='{$templateVar['backend']->getClientUrl('a61fa9f7f1313194787116d1357a7784', ['N9.jpg'], $templateVar['ip'], $client_id)}/data/a61fa9f7f1313194787116d1357a7784/N9.jpg' class='btn btn-sm btn-info' title='Test your client'>" . display_fa_icon('network-wired', 'Test') . "</a>" : "<button class='btn btn-sm btn-info disabled'>" . display_fa_icon('network-wired', 'Test') . "</button>"?></td>
<td><?= $client->client_continent ?></td>
<td><img src="/images/flags/<?= $client->client_country ?>.png" alt="<?= $client->client_country ?>" /></td>
<td><a href="<?= $client->speedtest ?>" target="_blank"><?= display_fa_icon('external-link-alt') ?></a></td>
@ -31,8 +31,6 @@
<td><?= $client->disk_cache_size ?></td>
<td><?= $client->timestamp ? date('Y-m-d H:i:s', $client->timestamp) . ' UTC' : '' ?></td>
<td><code><?= $client->client_secret ?></code></td>
<td>0</td>
<td>0</td>
<td>
<?php if ($client->approved === 0 || $client->approved === NULL) : ?>
<button class="btn btn-success btn-sm approve_button" data-id="<?= $client_id ?>"><?= display_fa_icon('check') ?></button>
@ -60,14 +58,13 @@
<th></th>
<th></th>
<th></th>
<th></th>
<th><?= $total_upload ?></th>
<th><?= $total_download ?></th>
<th><?= $total_disk ?></th>
<th></th>
<th></th>
<th></th>
<th>total</th>
<th>avg</th>
</tr>
</tfoot>
</table>

View File

@ -2,19 +2,14 @@
<table class="table table-striped table-hover">
<thead>
<tr>
<th><?= display_fa_icon('hashtag', 'Client ID') ?></th>
<th class="text-center"><?= display_fa_icon('hashtag', 'Client ID') ?></th>
<th>IP</th>
<th class="text-center"><?= display_fa_icon('network-wired', 'Test') ?></th>
<th class="text-center"><?= display_fa_icon('globe-asia', 'Continent') ?></th>
<th class="text-center"><?= display_fa_icon('globe', 'Country') ?></th>
<th class="text-center"><?= display_fa_icon('upload', 'Upload speed (Mbps)') ?></th>
<th class="text-center"><?= display_fa_icon('download', 'Download speed (Mbps)') ?></th>
<th class="text-center"><?= display_fa_icon('hdd', 'Disk allocation (GB)') ?></th>
<th class="text-center">Status</th>
<th><?= display_fa_icon('calendar-alt', 'Time of approval') ?></th>
<th><?= display_fa_icon('key', 'Client secret') ?></th>
<!--<th>Data transferred (GB)</th>
<th>Daily average (GB)</th>-->
</tr>
</thead>
<tbody>
@ -27,72 +22,31 @@
<tr class="text-<?= $client['approved'] ? 'success' : ($client['approved'] === 0 ? 'danger' : 'warning' ) ?>">
<td>#<?= $client['client_id'] ?></td>
<td><?= $client['client_ip'] ?></td>
<td class="text-center"><?= $client['approved'] ? "<a target='_blank' href='https://{$client['client_subsubdomain']}.mangadex.network:{$client['client_port']}/data/a61fa9f7f1313194787116d1357a7784/N9.jpg' class='btn btn-sm btn-info' title='Test your client'>" . display_fa_icon('network-wired', 'Test') . "</a>" : "<button class='btn btn-sm btn-info disabled'>" . display_fa_icon('network-wired', 'Test') . "</button>"?></td>
<td class="text-center"><?= $client['approved'] ? "<a target='_blank' href='{$templateVar['backend']->getClientUrl('a61fa9f7f1313194787116d1357a7784', ['N9.jpg'], $templateVar['ip'], $client['client_id'])}/data/a61fa9f7f1313194787116d1357a7784/N9.jpg' class='btn btn-sm btn-info' title='Test your client'>" . display_fa_icon('network-wired', 'Test') . "</a>" : "<button class='btn btn-sm btn-info disabled'>" . display_fa_icon('network-wired', 'Test') . "</button>"?></td>
<td class="text-center"><?= $client['client_continent'] ?></td>
<td class="text-center"><img src="/images/flags/<?= $client['client_country'] ?>.png" alt="<?= $client['client_country'] ?>" title="<?= $client['client_country'] ?>" /></td>
<td class="text-center"><?= $client['upload_speed'] ?></td>
<td class="text-center"><?= $client['download_speed'] ?></td>
<td class="text-center"><?= $client['disk_cache_size'] ?></td>
<td class="text-center"><?= $client['approved'] ? display_fa_icon('check', 'Approved') : ($client['approved'] === 0 ? display_fa_icon('times', 'rejected') : 'Pending' ) ?></td>
<td><?= $client['timestamp'] ? date('Y-m-d H:i:s', $client['timestamp']) . ' UTC' : '' ?></td>
<td><code><?= $client['client_secret'] ?></code></td>
<!--<td>0</td>
<td>0</td>-->
</tr>
<?php
$total_upload += $client['upload_speed'];
$total_download += $client['download_speed'];
$total_disk += $client['disk_cache_size'];
?>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th><?= $total_upload ?></th>
<th><?= $total_download ?></th>
<th><?= $total_disk ?></th>
<th></th>
<th></th>
<th></th>
<!--<th>total</th>
<th>avg</th>-->
</tr>
</tfoot>
</table>
<h3>Instructions: </h3>
<?php if ($templateVar['approvaltime']) : ?>
<ul>
<li>Download the <a href="/dl/mangadex_at_home-1.2.2.zip">client</a> and included settings sample (right click and save as).</li>
<pre class="bg-dark text-light p-2">md5: 7c0c8941544ec09f637a4e8e49204d96
sha-256: 68e26adf68268ae9781919fd5dd80a29595eabf562bbc0cda3dcbe332dd959d8</pre>
<li>settings.json needs editing to your config.</li>
<pre class="bg-dark text-light p-2">settings.json example:
{
"client_secret": "iiesenpaithisisoursecret",
"client_hostname": "0.0.0.0", // "0.0.0.0" is the default and binds to everything
"client_port": 443, // 443 is recommended if possible, otherwise use something higher, e.g. 44300
"client_external_port": 0, //443 is recommended; This port will be send to mdah-backend.
//You need to forward this to the client_port in your router - 0 uses `client_port`
"threads": 16,
"graceful_shutdown_wait_seconds": 60, // Time from graceful shutdown start to force quit
// This rounds down to 15-second increments
"max_cache_size_in_mebibytes": 80000,
"max_kilobits_per_second": 0, // 0 disables max brust limiting
"max_mebibytes_per_hour": 0, // 0 disables hourly bandwidth limiting
"web_settings": { // delete this block to disable webui
"ui_hostname": "127.0.0.1", // "127.0.0.1" is the default and binds to localhost only
"ui_port": 8080
}
}</pre>
<li>Download <a href="/dl/mangadex_at_home-2.0.0.zip">the client and settings file</a>.</li>
<pre class="bg-dark text-light p-2">md5: e077e54df77d406d973ce269a7e7febb
sha-256: ef4d139b346837b223c15032dd9f38790fe77d7b5f40320f1d82f6caf7a7bb10</pre>
<li>settings.sample.yaml needs editing to your config, and renamed as settings.yaml</li>
<li>To start the client, put .jar and settings.json in same folder and run: </li>
<pre class="bg-dark text-light p-2">java -Dfile-level=trace -Dstdout-level=info -jar mangadex_at_home-1.2.2-all.jar</pre>
<pre class="bg-dark text-light p-2">java -Dfile-level=trace -Dstdout-level=info -jar mangadex_at_home-2.0.0-all.jar</pre>
<li>For no logging, run: </li>
<pre class="bg-dark text-light p-2">java -Dfile-level=off -Dstdout-level=info -jar mangadex_at_home-1.2.2-all.jar</pre>
<pre class="bg-dark text-light p-2">java -Dfile-level=off -Dstdout-level=info -jar mangadex_at_home-2.0.0-all.jar</pre>
<li>(The name of the .jar file will change in future versions, so edit accordingly.)</li>
<li>If you need help, come on <a href="https://discord.gg/mangadex">Discord</a>.</li>
</ul>

View File

@ -7,7 +7,7 @@
<ul>
<li>An IPv4 address (static or dynamic).</li>
<li>A minimum network speed of 80Mbps up/down.</li>
<li>A minimum network speed of 40Mbps up/down.</li>
<li>A minimum of 40GB of dedicated storage space, preferably more.</li>
<li>24/7 availability (This means the machine must be *on* 24/7).</li>
</ul>

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