From 3c399fac4e9c26d4ddfe84af56174b08e3fb4a5b Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Mon, 21 Feb 2022 13:25:55 +0000 Subject: [PATCH 01/22] Add error handling on admin page (fixes #274) --- public/js/admin.js | 3 +++ src/views/admin.html.ecr | 1 + 2 files changed, 4 insertions(+) diff --git a/public/js/admin.js b/public/js/admin.js index a3299cc4..57926b1e 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -31,6 +31,9 @@ const component = () => { this.scanMs = data.milliseconds; this.scanTitles = data.titles; }) + .catch(e => { + alert('danger', `Failed to trigger a scan. Error: ${e}`); + }) .always(() => { this.scanning = false; }); diff --git a/src/views/admin.html.ecr b/src/views/admin.html.ecr index fb64d3ea..47eead85 100644 --- a/src/views/admin.html.ecr +++ b/src/views/admin.html.ecr @@ -40,5 +40,6 @@ Log Out <% content_for "script" do %> + <% end %> From 91561ecd6b30fd6e0130db1ccdc2de7582c88e7d Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Fri, 11 Mar 2022 13:44:16 +0000 Subject: [PATCH 02/22] Add simple manifest.json (closes #262) --- gulpfile.js | 2 +- public/img/icon.png | Bin 11630 -> 0 bytes public/img/icons/icon.png | Bin 0 -> 17983 bytes public/img/icons/icon_x192.png | Bin 0 -> 7002 bytes public/img/icons/icon_x512.png | Bin 0 -> 22058 bytes public/manifest.json | 18 ++++++++++++++++++ src/library/entry.cr | 2 +- src/library/title.cr | 2 +- src/views/components/head.html.ecr | 1 + src/views/layout.html.ecr | 2 +- 10 files changed, 23 insertions(+), 4 deletions(-) delete mode 100644 public/img/icon.png create mode 100644 public/img/icons/icon.png create mode 100644 public/img/icons/icon_x192.png create mode 100644 public/img/icons/icon_x512.png create mode 100644 public/manifest.json diff --git a/gulpfile.js b/gulpfile.js index 4496b8c0..b1634e6d 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -55,7 +55,7 @@ gulp.task('minify-css', () => { gulp.task('copy-files', () => { return gulp.src([ 'public/*.*', - 'public/img/*', + 'public/img/**', 'public/webfonts/*', 'public/js/*.min.js' ], { diff --git a/public/img/icon.png b/public/img/icon.png deleted file mode 100644 index 7a25f213c8f8685727d570aaa8b41795a24c664a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11630 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 literal 0 HcmV?d00001 diff --git a/public/img/icons/icon_x192.png b/public/img/icons/icon_x192.png new file mode 100644 index 0000000000000000000000000000000000000000..1d3bfeae14d33cf0d9cd96ea3ffd9d968d2a94af GIT binary patch literal 7002 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 OfU>;0T$PM@@P7dAI7)2* literal 0 HcmV?d00001 diff --git a/public/img/icons/icon_x512.png b/public/img/icons/icon_x512.png new file mode 100644 index 0000000000000000000000000000000000000000..ef62dfb92cb794cecf0be63dc9997efac132eafd GIT binary patch literal 22058 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 z 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 U(5MW 0 url = readable_entries[0].cover_url diff --git a/src/views/components/head.html.ecr b/src/views/components/head.html.ecr index 2126ab56..abd5af51 100644 --- a/src/views/components/head.html.ecr +++ b/src/views/components/head.html.ecr @@ -6,6 +6,7 @@ + diff --git a/src/views/layout.html.ecr b/src/views/layout.html.ecr index c32bfb5b..fc5adb79 100644 --- a/src/views/layout.html.ecr +++ b/src/views/layout.html.ecr @@ -36,7 +36,7 @@

- +
  • Home
  • Library
  • From 6ab885499c086f15b7172c9feafa9e9898d92053 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Fri, 11 Mar 2022 14:04:28 +0000 Subject: [PATCH 03/22] Use smaller icons on web UI --- public/img/icons/icon.png | Bin 17983 -> 0 bytes public/img/icons/icon_x96.png | Bin 0 -> 3183 bytes public/manifest.json | 5 +++++ src/library/entry.cr | 2 +- src/library/title.cr | 2 +- src/views/layout.html.ecr | 2 +- 6 files changed, 8 insertions(+), 3 deletions(-) delete mode 100644 public/img/icons/icon.png create mode 100644 public/img/icons/icon_x96.png diff --git a/public/img/icons/icon.png b/public/img/icons/icon.png deleted file mode 100644 index 87fdc9b17ab93458cd75978750b871896fbcb8dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17983 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 diff --git a/public/img/icons/icon_x96.png b/public/img/icons/icon_x96.png new file mode 100644 index 0000000000000000000000000000000000000000..e1b1158da9aac14164b8052abafe5e3b7c770387 GIT binary patch literal 3183 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 literal 0 HcmV?d00001 diff --git a/public/manifest.json b/public/manifest.json index c3c868ac..b52119a1 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -2,6 +2,11 @@ "name": "Mango", "description": "Mango: A self-hosted manga server and web reader", "icons": [ + { + "src": "/img/icons/icon_x96.png", + "sizes": "96x96", + "type": "image/png" + }, { "src": "/img/icons/icon_x192.png", "sizes": "192x192", diff --git a/src/library/entry.cr b/src/library/entry.cr index d72ed692..43cadbe7 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -108,7 +108,7 @@ class Entry end def cover_url - return "#{Config.current.base_url}img/icons/icon.png" if @err_msg + return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg unless @book.entry_cover_url_cache TitleInfo.new @book.dir do |info| diff --git a/src/library/title.cr b/src/library/title.cr index b76a2ba8..bc844347 100644 --- a/src/library/title.cr +++ b/src/library/title.cr @@ -411,7 +411,7 @@ class Title cached_cover_url = @cached_cover_url return cached_cover_url unless cached_cover_url.nil? - url = "#{Config.current.base_url}img/icons/icon.png" + url = "#{Config.current.base_url}img/icons/icon_x192.png" readable_entries = @entries.select &.err_msg.nil? if readable_entries.size > 0 url = readable_entries[0].cover_url diff --git a/src/views/layout.html.ecr b/src/views/layout.html.ecr index fc5adb79..b2a85759 100644 --- a/src/views/layout.html.ecr +++ b/src/views/layout.html.ecr @@ -36,7 +36,7 @@

- +
  • Home
  • Library
  • From 8814778c22fb4848e71025276bfa164bc9a919b2 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sat, 12 Mar 2022 14:18:08 +0000 Subject: [PATCH 04/22] Add error handling on `read_page` (fixes #281) --- src/library/entry.cr | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/library/entry.cr b/src/library/entry.cr index 43cadbe7..55d0062c 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -144,13 +144,17 @@ class Entry def read_page(page_num) raise "Unreadble archive. #{@err_msg}" if @err_msg img = nil - sorted_archive_entries do |file, entries| - page = entries[page_num - 1] - data = file.read_entry page - if data - img = Image.new data, MIME.from_filename(page.filename), page.filename, - data.size + begin + sorted_archive_entries do |file, entries| + page = entries[page_num - 1] + data = file.read_entry page + if data + img = Image.new data, MIME.from_filename(page.filename), + page.filename, data.size + end end + rescue e + Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}" end img end From 1817efe608322669cf9bfb872aa71172cbddb3aa Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Thu, 17 Mar 2022 16:21:06 +0000 Subject: [PATCH 05/22] Fix icon transparency issue --- public/img/icons/icon.png | Bin 0 -> 11630 bytes src/views/layout.html.ecr | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 public/img/icons/icon.png diff --git a/public/img/icons/icon.png b/public/img/icons/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7a25f213c8f8685727d570aaa8b41795a24c664a GIT binary patch literal 11630 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
- +
<%= render_component "uikit" %> <%= yield_content "script" %> From 703e6d076bce144a748ca35d6ac3bea6b3560bfb Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 20 Mar 2022 09:57:10 +0000 Subject: [PATCH 06/22] Allow authentication through bearer token --- src/handlers/auth_handler.cr | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/handlers/auth_handler.cr b/src/handlers/auth_handler.cr index bf79dc38..ad25d432 100644 --- a/src/handlers/auth_handler.cr +++ b/src/handlers/auth_handler.cr @@ -6,6 +6,7 @@ class AuthHandler < Kemal::Handler # Some of the code is copied form kemalcr/kemal-basic-auth on GitHub BASIC = "Basic" + BEARER = "Bearer" AUTH = "Authorization" AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \ "You have to login with proper credentials" @@ -35,13 +36,17 @@ class AuthHandler < Kemal::Handler def validate_auth_header(env) if env.request.headers[AUTH]? if value = env.request.headers[AUTH] - if value.size > 0 && value.starts_with?(BASIC) + if value.starts_with? BASIC token = verify_user value return false if token.nil? env.session.string "token", token return true end + if value.starts_with? BEARER + token = value.split(" ")[1] + return Storage.default.verify_token token + end end end false @@ -62,8 +67,8 @@ class AuthHandler < Kemal::Handler end # Check user is logged in - if validate_token env - # Skip if the request has a valid token + if validate_token(env) || validate_auth_header(env) + # Skip if the request has a valid token (either from cookies or header) elsif Config.current.disable_login # Check default username if login is disabled unless Storage.default.username_exists Config.current.default_username From 20910532211cc1491791f6310e13f7e5ae5620eb Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 20 Mar 2022 09:57:36 +0000 Subject: [PATCH 07/22] Allow CORS --- src/handlers/auth_handler.cr | 4 ++++ src/handlers/cors_handler.cr | 8 ++++++++ src/routes/api.cr | 25 +++++++++++++++++++------ src/server.cr | 6 +++++- src/util/web.cr | 15 +++++++++++++++ 5 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 src/handlers/cors_handler.cr diff --git a/src/handlers/auth_handler.cr b/src/handlers/auth_handler.cr index ad25d432..472e60d3 100644 --- a/src/handlers/auth_handler.cr +++ b/src/handlers/auth_handler.cr @@ -59,6 +59,10 @@ class AuthHandler < Kemal::Handler end def call(env) + # OPTIONS requests do not require authentication + if env.request.method === "OPTIONS" + return call_next(env) + end # Skip all authentication if requesting /login, /logout, /api/login, # or a static file if request_path_startswith(env, ["/login", "/logout", "/api/login"]) || diff --git a/src/handlers/cors_handler.cr b/src/handlers/cors_handler.cr new file mode 100644 index 00000000..d199b129 --- /dev/null +++ b/src/handlers/cors_handler.cr @@ -0,0 +1,8 @@ +class CORSHandler < Kemal::Handler + def call(env) + if request_path_startswith env, ["/api"] + env.response.headers["Access-Control-Allow-Origin"] = "*" + end + call_next env + end +end diff --git a/src/routes/api.cr b/src/routes/api.cr index 413c318b..a0f7cfb5 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -63,6 +63,11 @@ struct APIRouter "username" => String, "password" => String, } + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "token" => String?, + } Koa.tag "users" post "/api/login" do |env| begin @@ -71,11 +76,17 @@ struct APIRouter token = Storage.default.verify_user(username, password).not_nil! env.session.string "token", token - "Authenticated" + send_json env, { + "success" => true, + "token" => token, + }.to_json rescue e Logger.error e env.response.status_code = 403 - e.message + send_json env, { + "success" => false, + "error" => e.message, + }.to_json end end @@ -114,7 +125,7 @@ struct APIRouter rescue e Logger.error e env.response.status_code = 500 - e.message + send_text env, e.message end end @@ -151,7 +162,7 @@ struct APIRouter rescue e Logger.error e env.response.status_code = 500 - e.message + send_text env, e.message end end @@ -191,7 +202,7 @@ struct APIRouter rescue e Logger.error e env.response.status_code = 404 - e.message + send_text env, e.message end end @@ -250,6 +261,7 @@ struct APIRouter spawn do Library.default.generate_thumbnails end + send_text env, "" end Koa.describe "Deletes a user with `username`" @@ -675,7 +687,7 @@ struct APIRouter e_tag = "W/#{file_hash}" if e_tag == prev_e_tag env.response.status_code = 304 - "" + send_text env, "" else sizes = entry.page_dimensions env.response.headers["ETag"] = e_tag @@ -709,6 +721,7 @@ struct APIRouter rescue e Logger.error e env.response.status_code = 404 + send_text env, e.message end end diff --git a/src/server.cr b/src/server.cr index e8dc54b0..eb793748 100644 --- a/src/server.cr +++ b/src/server.cr @@ -23,7 +23,11 @@ class Server AdminRouter.new ReaderRouter.new APIRouter.new - OPDSRouter.new + + options "/api/*" do |env| + cors + halt env + end Kemal.config.logging = false add_handler LogHandler.new diff --git a/src/util/web.cr b/src/util/web.cr index 5704ea88..e74d4f99 100644 --- a/src/util/web.cr +++ b/src/util/web.cr @@ -39,6 +39,7 @@ macro send_error_page(msg) end macro send_img(env, img) + cors send_file {{env}}, {{img}}.data, {{img}}.mime end @@ -57,12 +58,26 @@ macro get_username(env) end end +macro cors + env.response.headers["Allow"] = "HEAD,GET,PUT,POST,DELETE,OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With, X-HTTP-Method-Override, Content-Type, Cache-Control, Accept, Authorization" + env.response.headers["Access-Control-Allow-Origin"] = "*" +end + def send_json(env, json) + cors env.response.content_type = "application/json" env.response.print json end +def send_text(env, text) + cors + env.response.content_type = "text/plain" + env.response.print text +end + def send_attachment(env, path) + cors send_file env, path, filename: File.basename(path), disposition: "attachment" end From c3736d222c9976d7988888bbbf4e90f4c8af1752 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Sun, 20 Mar 2022 10:01:44 +0000 Subject: [PATCH 08/22] Fix long line --- src/util/web.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/util/web.cr b/src/util/web.cr index e74d4f99..3662edb1 100644 --- a/src/util/web.cr +++ b/src/util/web.cr @@ -60,7 +60,9 @@ end macro cors env.response.headers["Allow"] = "HEAD,GET,PUT,POST,DELETE,OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With, X-HTTP-Method-Override, Content-Type, Cache-Control, Accept, Authorization" + env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \ + "X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \ + "Authorization" env.response.headers["Access-Control-Allow-Origin"] = "*" end From 0d52544617f5d57ffc3deaacffccb9e92d5e4fd2 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Mon, 21 Mar 2022 03:41:24 +0000 Subject: [PATCH 09/22] Use sessid and not token and fix get_username --- src/handlers/auth_handler.cr | 15 +++++++++++---- src/routes/api.cr | 4 ++-- src/server.cr | 12 +++++++++--- src/util/web.cr | 18 ++++++++++++++++-- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/handlers/auth_handler.cr b/src/handlers/auth_handler.cr index 472e60d3..26a149ae 100644 --- a/src/handlers/auth_handler.cr +++ b/src/handlers/auth_handler.cr @@ -19,8 +19,14 @@ class AuthHandler < Kemal::Handler end def require_auth(env) - env.session.string "callback", env.request.path - redirect env, "/login" + if request_path_startswith env, ["/api"] + # Do not redirect API requests + env.response.status_code = 401 + send_text env, "Unauthorized" + else + env.session.string "callback", env.request.path + redirect env, "/login" + end end def validate_token(env) @@ -44,8 +50,9 @@ class AuthHandler < Kemal::Handler return true end if value.starts_with? BEARER - token = value.split(" ")[1] - return Storage.default.verify_token token + session_id = value.split(" ")[1] + token = Kemal::Session.get(session_id).try &.string? "token" + return !token.nil? && Storage.default.verify_token token end end end diff --git a/src/routes/api.cr b/src/routes/api.cr index a0f7cfb5..5f33234a 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -77,8 +77,8 @@ struct APIRouter env.session.string "token", token send_json env, { - "success" => true, - "token" => token, + "success" => true, + "session_id" => env.session.id, }.to_json rescue e Logger.error e diff --git a/src/server.cr b/src/server.cr index eb793748..b0a022d4 100644 --- a/src/server.cr +++ b/src/server.cr @@ -24,9 +24,15 @@ class Server ReaderRouter.new APIRouter.new - options "/api/*" do |env| - cors - halt env + {% for path in %w(/api/* /uploads/* /img/*) %} + options {{path}} do |env| + cors + halt env + end + {% end %} + + static_headers do |response| + response.headers.add("Access-Control-Allow-Origin", "*") end Kemal.config.logging = false diff --git a/src/util/web.cr b/src/util/web.cr index 3662edb1..42021777 100644 --- a/src/util/web.cr +++ b/src/util/web.cr @@ -43,10 +43,24 @@ macro send_img(env, img) send_file {{env}}, {{img}}.data, {{img}}.mime end +def get_token_from_auth_header(env) : String? + value = env.request.headers["Authorization"] + if value && value.starts_with? "Bearer" + session_id = value.split(" ")[1] + return Kemal::Session.get(session_id).try &.string? "token" + end +end + macro get_username(env) begin - token = env.session.string "token" - (Storage.default.verify_token token).not_nil! + # Check if we can get the session id from the cookie + token = env.session.string? "token" + if token.nil? + # If not, check if we can get the session id from the auth header + token = get_token_from_auth_header env + end + # If we still don't have a token, we handle it in `resuce` with `not_nil!` + (Storage.default.verify_token token.not_nil!).not_nil! rescue e if Config.current.disable_login Config.current.default_username From 461398d219c7c40ebd4143e0627368643ee70d82 Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Tue, 22 Mar 2022 16:30:01 +0800 Subject: [PATCH 10/22] Feature/plugin v2 (#284) * Add "title_title" to slim JSON * WIP * WIP * WIP * WIP * Add plugin subscription types * Revert "Subscription manager" This reverts commit a612500b0fabf7259a5ee0c841b0157d191e5bdd. * Use auto overflow tables cherry-picked from a612500b0fabf7259a5ee0c841b0157d191e5bdd * Add endpoint for plugin subscription * WIP * WIP * Simplify subscription JSON parsing * Remove MangaDex files that are no longer needed * Fix linter * Refactor date filtering and use native date picker * Delete unnecessary raise for debugging * Subscription management API endpoints * Store manga ID with subscriptions * Add subscription manager page (WIP) * Finish subscription manager page * WIP * Finish plugin updater * Base64 encode chapter IDs * Fix actions on download manager * Trigger subscription update from manager page * Fix timestamp precision issue in plugin * Show target API version * Update last checked from manager page * Update last checked even when no chapters found * Fix null pid * Clean up * Document the subscription endpoints * Fix BigFloat conversion issue * Confirmation before deleting subscriptions * Reset table sort options * Show manga title on subscription manager --- public/js/plugin-download.js | 566 ++++++++++++++++++------ public/js/subscription-manager.js | 147 ++++++ src/config.cr | 1 + src/library/entry.cr | 1 + src/mango.cr | 1 + src/plugin/plugin.cr | 171 ++++++- src/plugin/subscriptions.cr | 115 +++++ src/plugin/updater.cr | 75 ++++ src/queue.cr | 8 +- src/routes/admin.cr | 4 + src/routes/api.cr | 236 +++++++++- src/routes/main.cr | 10 - src/subscription.cr | 83 ---- src/views/download.html.ecr | 162 ------- src/views/layout.html.ecr | 2 + src/views/mangadex.html.ecr | 39 -- src/views/plugin-download.html.ecr | 257 ++++++++--- src/views/subscription-manager.html.ecr | 101 +++++ src/views/subscription.html.ecr | 54 --- 19 files changed, 1469 insertions(+), 564 deletions(-) create mode 100644 public/js/subscription-manager.js create mode 100644 src/plugin/subscriptions.cr create mode 100644 src/plugin/updater.cr delete mode 100644 src/subscription.cr delete mode 100644 src/views/download.html.ecr delete mode 100644 src/views/mangadex.html.ecr create mode 100644 src/views/subscription-manager.html.ecr delete mode 100644 src/views/subscription.html.ecr diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 11c047ce..2e9d0a0b 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -1,144 +1,446 @@ -const loadPlugin = id => { - localStorage.setItem('plugin', id); - const url = `${location.protocol}//${location.host}${location.pathname}`; - const newURL = `${url}?${$.param({ - plugin: id - })}`; - window.location.href = newURL; -}; +const component = () => { + return { + plugins: [], + info: undefined, + pid: undefined, + chapters: undefined, // undefined: not searched yet, []: empty + manga: undefined, // undefined: not searched yet, []: empty + mid: undefined, // id of the selected manga + allChapters: [], + query: "", + mangaTitle: "", + searching: false, + adding: false, + sortOptions: [], + showFilters: false, + appliedFilters: [], + chaptersLimit: 500, + listManga: false, + subscribing: false, + subscriptionName: "", + + init() { + const tableObserver = new MutationObserver(() => { + console.log("table mutated"); + $("#selectable").selectable({ + filter: "tr", + }); + }); + tableObserver.observe($("table").get(0), { + childList: true, + subtree: true, + }); + fetch(`${base_url}api/admin/plugin`) + .then((res) => res.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + this.plugins = data.plugins; + + const pid = localStorage.getItem("plugin"); + if (pid && this.plugins.map((p) => p.id).includes(pid)) + return this.loadPlugin(pid); + + if (this.plugins.length > 0) + this.loadPlugin(this.plugins[0].id); + }) + .catch((e) => { + alert( + "danger", + `Failed to list the available plugins. Error: ${e}` + ); + }); + }, + loadPlugin(pid) { + fetch( + `${base_url}api/admin/plugin/info?${new URLSearchParams({ + plugin: pid, + })}` + ) + .then((res) => res.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + this.info = data.info; + this.pid = pid; + }) + .catch((e) => { + alert( + "danger", + `Failed to get plugin metadata. Error: ${e}` + ); + }); + }, + pluginChanged() { + this.loadPlugin(this.pid); + localStorage.setItem("plugin", this.pid); + }, + get chapterKeys() { + if (this.allChapters.length < 1) return []; + return Object.keys(this.allChapters[0]).filter( + (k) => !["manga_title"].includes(k) + ); + }, + searchChapters(query) { + this.searching = true; + this.allChapters = []; + this.sortOptions = []; + this.chapters = undefined; + this.listManga = false; + fetch( + `${base_url}api/admin/plugin/list?${new URLSearchParams({ + plugin: this.pid, + query: query, + })}` + ) + .then((res) => res.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + try { + this.mangaTitle = data.chapters[0].manga_title; + if (!this.mangaTitle) throw new Error(); + } catch (e) { + this.mangaTitle = data.title; + } + + this.allChapters = data.chapters; + this.chapters = data.chapters; + }) + .catch((e) => { + alert("danger", `Failed to list chapters. Error: ${e}`); + }) + .finally(() => { + this.searching = false; + }); + }, + searchManga(query) { + this.searching = true; + this.allChapters = []; + this.chapters = undefined; + this.manga = undefined; + fetch( + `${base_url}api/admin/plugin/search?${new URLSearchParams({ + plugin: this.pid, + query: query, + })}` + ) + .then((res) => res.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + this.manga = data.manga; + this.listManga = true; + }) + .catch((e) => { + alert("danger", `Search failed. Error: ${e}`); + }) + .finally(() => { + this.searching = false; + }); + }, + search() { + const query = this.query.trim(); + if (!query) return; + + this.manga = undefined; + if (this.info.version === 1) { + this.searchChapters(query); + } else { + this.searchManga(query); + } + }, + selectAll() { + $("tbody > tr").each((i, e) => { + $(e).addClass("ui-selected"); + }); + }, + clearSelection() { + $("tbody > tr").each((i, e) => { + $(e).removeClass("ui-selected"); + }); + }, + download() { + const selected = $("tbody > tr.ui-selected").get(); + if (selected.length === 0) return; + + UIkit.modal + .confirm(`Download ${selected.length} selected chapters?`) + .then(() => { + const ids = selected.map((e) => e.id); + const chapters = this.chapters.filter((c) => + ids.includes(c.id) + ); + console.log(chapters); + this.adding = true; + fetch(`${base_url}api/admin/plugin/download`, { + method: "POST", + body: JSON.stringify({ + chapters, + plugin: this.pid, + title: this.mangaTitle, + }), + headers: { + "Content-Type": "application/json", + }, + }) + .then((res) => res.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + const successCount = parseInt(data.success); + const failCount = parseInt(data.fail); + alert( + "success", + `${successCount} of ${ + successCount + failCount + } chapters added to the download queue. You can view and manage your download queue on the download manager page.` + ); + }) + .catch((e) => { + alert( + "danger", + `Failed to add chapters to the download queue. Error: ${e}` + ); + }) + .finally(() => { + this.adding = false; + }); + }); + }, + thClicked(event) { + const idx = parseInt(event.currentTarget.id.split("-")[1]); + if (idx === undefined || isNaN(idx)) return; + const curOption = this.sortOptions[idx]; + let option; + this.sortOptions = []; + switch (curOption) { + case 1: + option = -1; + break; + case -1: + option = 0; + break; + default: + option = 1; + } + this.sortOptions[idx] = option; + this.sort(this.chapterKeys[idx], option); + }, + // Returns an array of filtered but unsorted chapters. Useful when + // reseting the sort options. + get filteredChapters() { + let ary = this.allChapters.slice(); + + console.log("initial size:", ary.length); + for (let filter of this.appliedFilters) { + if (!filter.value) continue; + if (filter.type === "array" && filter.value === "all") continue; + if (filter.type.startsWith("number") && isNaN(filter.value)) + continue; + + if (filter.type === "string") { + ary = ary.filter((ch) => + ch[filter.key] + .toLowerCase() + .includes(filter.value.toLowerCase()) + ); + } + if (filter.type === "number-min") { + ary = ary.filter( + (ch) => Number(ch[filter.key]) >= Number(filter.value) + ); + } + if (filter.type === "number-max") { + ary = ary.filter( + (ch) => Number(ch[filter.key]) <= Number(filter.value) + ); + } + if (filter.type === "date-min") { + ary = ary.filter( + (ch) => Number(ch[filter.key]) >= Number(filter.value) + ); + } + if (filter.type === "date-max") { + ary = ary.filter( + (ch) => Number(ch[filter.key]) <= Number(filter.value) + ); + } + if (filter.type === "array") { + ary = ary.filter((ch) => + ch[filter.key] + .map((s) => + typeof s === "string" ? s.toLowerCase() : s + ) + .includes(filter.value.toLowerCase()) + ); + } + + console.log("filtered size:", ary.length); + } -$(() => { - var storedID = localStorage.getItem('plugin'); - if (storedID && storedID !== pid) { - loadPlugin(storedID); - } else { - $('#controls').removeAttr('hidden'); - } - - $('#search-input').keypress(event => { - if (event.which === 13) { - search(); - } - }); - $('#plugin-select').val(pid); - $('#plugin-select').change(() => { - const id = $('#plugin-select').val(); - loadPlugin(id); - }); -}); - -let mangaTitle = ""; -let searching = false; -const search = () => { - if (searching) - return; - - const query = $.param({ - query: $('#search-input').val(), - plugin: pid - }); - $.ajax({ - type: 'GET', - url: `${base_url}api/admin/plugin/list?${query}`, - contentType: "application/json", - dataType: 'json' - }) - .done(data => { - console.log(data); - if (data.error) { - alert('danger', `Search failed. Error: ${data.error}`); + return ary; + }, + // option: + // - 1: asending + // - -1: desending + // - 0: unsorted + sort(key, option) { + if (option === 0) { + this.chapters = this.filteredChapters; return; } - mangaTitle = data.title; - $('#title-text').text(data.title); - buildTable(data.chapters); - }) - .fail((jqXHR, status) => { - alert('danger', `Search failed. Error: [${jqXHR.status}] ${jqXHR.statusText}`); - }) - .always(() => {}); -}; -const buildTable = (chapters) => { - $('#table').attr('hidden', ''); - $('table').empty(); + this.chapters = this.filteredChapters.sort((a, b) => { + const comp = this.compare(a[key], b[key]); + return option < 0 ? comp * -1 : comp; + }); + }, + compare(a, b) { + if (a === b) return 0; - const keys = Object.keys(chapters[0]).map(k => `${k}`).join(''); - const thead = `${keys}`; - $('table').append(thead); + // try numbers (also covers dates) + if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b); - const rows = chapters.map(ch => { - const tds = Object.values(ch).map(v => { - const maxLength = 40; - const shouldShrink = v && v.length > maxLength; - const content = shouldShrink ? `${v.substring(0, maxLength)}...
${v}
` : v; - return `${content}` - }).join(''); - return `${tds}`; - }); - const tbody = `${rows}`; - $('table').append(tbody); - - $('#selectable').selectable({ - filter: 'tr' - }); - - $('#table table').tablesorter(); - $('#table').removeAttr('hidden'); -}; + const preprocessString = (val) => { + if (typeof val !== "string") return val; + return val.toLowerCase().replace(/\s\s/g, " ").trim(); + }; -const selectAll = () => { - $('tbody > tr').each((i, e) => { - $(e).addClass('ui-selected'); - }); -}; + return preprocessString(a) > preprocessString(b) ? 1 : -1; + }, + fieldType(values) { + if (values.every((v) => this.numIsDate(v))) return "date"; + if (values.every((v) => !isNaN(v))) return "number"; + if (values.every((v) => Array.isArray(v))) return "array"; + return "string"; + }, + get filters() { + if (this.allChapters.length < 1) return []; + const keys = Object.keys(this.allChapters[0]).filter( + (k) => !["manga_title", "id"].includes(k) + ); + return keys.map((k) => { + let values = this.allChapters.map((c) => c[k]); + const type = this.fieldType(values); -const unselect = () => { - $('tbody > tr').each((i, e) => { - $(e).removeClass('ui-selected'); - }); -}; + if (type === "array") { + // if the type is an array, return the list of available elements + // example: an array of groups or authors + values = Array.from( + new Set( + values.flat().map((v) => { + if (typeof v === "string") + return v.toLowerCase(); + }) + ) + ); + } -const download = () => { - const selected = $('tbody > tr.ui-selected'); - if (selected.length === 0) return; - UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => { - $('#download-btn').attr('hidden', ''); - $('#download-spinner').removeAttr('hidden'); - const chapters = selected.map((i, e) => { - return { - id: $(e).attr('data-id'), - title: $(e).attr('data-title') - } - }).get(); - console.log(chapters); - $.ajax({ - type: 'POST', - url: base_url + 'api/admin/plugin/download', - data: JSON.stringify({ - plugin: pid, - chapters: chapters, - title: mangaTitle + return { + key: k, + type: type, + values: values, + }; + }); + }, + get filterSettings() { + return $("#filter-form input:visible, #filter-form select:visible") + .get() + .map((i) => { + const type = i.getAttribute("data-filter-type"); + let value = i.value.trim(); + if (type.startsWith("date")) + value = value ? Date.parse(value).toString() : ""; + return { + key: i.getAttribute("data-filter-key"), + value: value, + type: type, + }; + }); + }, + applyFilters() { + this.appliedFilters = this.filterSettings; + this.chapters = this.filteredChapters; + this.sortOptions = []; + }, + clearFilters() { + $("#filter-form input") + .get() + .forEach((i) => (i.value = "")); + $("#filter-form select").val("all"); + this.appliedFilters = []; + this.chapters = this.filteredChapters; + this.sortOptions = []; + }, + mangaSelected(event) { + const mid = event.currentTarget.getAttribute("data-id"); + this.mid = mid; + this.searchChapters(mid); + }, + subscribe(modal) { + this.subscribing = true; + fetch(`${base_url}api/admin/plugin/subscriptions`, { + method: "POST", + body: JSON.stringify({ + filters: this.filterSettings, + plugin: this.pid, + name: this.subscriptionName.trim(), + manga: this.mangaTitle, + manga_id: this.mid, }), - contentType: "application/json", - dataType: 'json' - }) - .done(data => { - console.log(data); - if (data.error) { - alert('danger', `Failed to add chapters to the download queue. Error: ${data.error}`); - return; - } - const successCount = parseInt(data.success); - const failCount = parseInt(data.fail); - alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the download manager page.`); - }) - .fail((jqXHR, status) => { - alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + headers: { + "Content-Type": "application/json", + }, }) - .always(() => { - $('#download-spinner').attr('hidden', ''); - $('#download-btn').removeAttr('hidden'); - }); - }); + .then((res) => res.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + alert("success", "Subscription created"); + }) + .catch((e) => { + alert("danger", `Failed to subscribe. Error: ${e}`); + }) + .finally(() => { + this.subscribing = false; + UIkit.modal(modal).hide(); + }); + }, + numIsDate(num) { + return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980 + }, + renderCell(value) { + if (this.numIsDate(value)) + return `${moment(Number(value)).format( + "MMM D, YYYY" + )}`; + const maxLength = 40; + if (value && value.length > maxLength) + return `${value.substr( + 0, + maxLength + )}...
${value}
`; + return `${value}`; + }, + renderFilterRow(ft) { + const key = ft.key; + let type = ft.type; + switch (type) { + case "number-min": + type = "number (minimum value)"; + break; + case "number-max": + type = "number (maximum value)"; + break; + case "date-min": + type = "minimum date"; + break; + case "date-max": + type = "maximum date"; + break; + } + let value = ft.value; + + if (ft.type.startsWith("number") && isNaN(value)) value = ""; + else if (ft.type.startsWith("date") && value) + value = moment(Number(value)).format("MMM D, YYYY"); + + return `${key}${type}${value}`; + }, + }; }; diff --git a/public/js/subscription-manager.js b/public/js/subscription-manager.js new file mode 100644 index 00000000..fad4e56c --- /dev/null +++ b/public/js/subscription-manager.js @@ -0,0 +1,147 @@ +const component = () => { + return { + subscriptions: [], + plugins: [], + pid: undefined, + subscription: undefined, // selected subscription + loading: false, + + init() { + fetch(`${base_url}api/admin/plugin`) + .then((res) => res.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + this.plugins = data.plugins; + + const pid = localStorage.getItem("plugin"); + if (pid && this.plugins.map((p) => p.id).includes(pid)) + this.pid = pid; + else if (this.plugins.length > 0) + this.pid = this.plugins[0].id; + + this.list(pid); + }) + .catch((e) => { + alert( + "danger", + `Failed to list the available plugins. Error: ${e}` + ); + }); + }, + pluginChanged() { + localStorage.setItem("plugin", this.pid); + this.list(this.pid); + }, + list(pid) { + if (!pid) return; + fetch( + `${base_url}api/admin/plugin/subscriptions?${new URLSearchParams( + { + plugin: pid, + } + )}`, + { + method: "GET", + } + ) + .then((response) => response.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + this.subscriptions = data.subscriptions; + }) + .catch((e) => { + alert( + "danger", + `Failed to list subscriptions. Error: ${e}` + ); + }); + }, + renderStrCell(str) { + const maxLength = 40; + if (str.length > maxLength) + return `${str.substring( + 0, + maxLength + )}...
${str}
`; + return `${str}`; + }, + renderDateCell(timestamp) { + return `${moment + .duration(moment.unix(timestamp).diff(moment())) + .humanize(true)}`; + }, + selected(event, modal) { + const id = event.currentTarget.getAttribute("sid"); + this.subscription = this.subscriptions.find((s) => s.id === id); + UIkit.modal(modal).show(); + }, + renderFilterRow(ft) { + const key = ft.key; + let type = ft.type; + switch (type) { + case "number-min": + type = "number (minimum value)"; + break; + case "number-max": + type = "number (maximum value)"; + break; + case "date-min": + type = "minimum date"; + break; + case "date-max": + type = "maximum date"; + break; + } + let value = ft.value; + + if (ft.type.startsWith("number") && isNaN(value)) value = ""; + else if (ft.type.startsWith("date") && value) + value = moment(Number(value)).format("MMM D, YYYY"); + + return `${key}${type}${value}`; + }, + actionHandler(event, type) { + const id = $(event.currentTarget).closest("tr").attr("sid"); + if (type !== 'delete') return this.action(id, type); + UIkit.modal.confirm('Are you sure you want to delete the subscription? This cannot be undone.', { + labels: { + ok: 'Yes, delete it', + cancel: 'Cancel' + } + }).then(() => { + this.action(id, type); + }); + }, + action(id, type) { + if (this.loading) return; + this.loading = true; + fetch( + `${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams( + { + plugin: this.pid, + subscription: id, + } + )}`, + { + method: type === 'delete' ? "DELETE" : 'POST' + } + ) + .then((response) => response.json()) + .then((data) => { + if (!data.success) throw new Error(data.error); + if (type === 'update') + alert("success", `Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`); + }) + .catch((e) => { + alert( + "danger", + `Failed to ${type} subscription. Error: ${e}` + ); + }) + .finally(() => { + this.loading = false; + this.list(this.pid); + }); + }, + }; +}; diff --git a/src/config.cr b/src/config.cr index b5b77dbf..807a74cb 100644 --- a/src/config.cr +++ b/src/config.cr @@ -25,6 +25,7 @@ class Config property disable_login = false property default_username = "" property auth_proxy_header_name = "" + property plugin_update_interval_hours : Int32 = 24 @@singlet : Config? diff --git a/src/library/entry.cr b/src/library/entry.cr index 55d0062c..18209207 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -59,6 +59,7 @@ class Entry json.field {{str}}, @{{str.id}} {% end %} json.field "title_id", @book.id + json.field "title_title", @book.title json.field "sort_title", sort_title json.field "pages" { json.number @pages } unless slim diff --git a/src/mango.cr b/src/mango.cr index 3cdafc05..14603b9e 100644 --- a/src/mango.cr +++ b/src/mango.cr @@ -61,6 +61,7 @@ class CLI < Clim Library.load_instance Library.default Plugin::Downloader.default + Plugin::Updater.default spawn do begin diff --git a/src/plugin/plugin.cr b/src/plugin/plugin.cr index 6bedea17..5175b3a0 100644 --- a/src/plugin/plugin.cr +++ b/src/plugin/plugin.cr @@ -2,6 +2,8 @@ require "duktape/runtime" require "myhtml" require "xml" +require "./subscriptions" + class Plugin class Error < ::Exception end @@ -16,12 +18,19 @@ class Plugin end struct Info + include JSON::Serializable + {% for name in ["id", "title", "placeholder"] %} getter {{name.id}} = "" {% end %} - getter wait_seconds : UInt64 = 0 + getter wait_seconds = 0u64 + getter version = 0u64 + getter settings = {} of String => String? getter dir : String + @[JSON::Field(ignore: true)] + @json : JSON::Any + def initialize(@dir) info_path = File.join @dir, "info.json" @@ -37,6 +46,16 @@ class Plugin @{{name.id}} = @json[{{name}}].as_s {% end %} @wait_seconds = @json["wait_seconds"].as_i.to_u64 + @version = @json["api_version"]?.try(&.as_i.to_u64) || 1u64 + + if @version > 1 && (settings_hash = @json["settings"]?.try &.as_h?) + settings_hash.each do |k, v| + unless str_value = v.as_s? + raise "The settings object can only contain strings or null" + end + @settings[k] = str_value + end + end unless @id.alphanumeric_underscore? raise "Plugin ID can only contain alphanumeric characters and " \ @@ -114,6 +133,33 @@ class Plugin @info.not_nil! end + def subscribe(subscription : Subscription) + list = SubscriptionList.new info.dir + list << subscription + list.save + end + + def list_subscriptions + SubscriptionList.new(info.dir).ary + end + + def list_subscriptions_raw + SubscriptionList.new(info.dir) + end + + def unsubscribe(id : String) + list = SubscriptionList.new info.dir + list.reject! &.id.== id + list.save + end + + def check_subscription(id : String) + list = list_subscriptions_raw + sub = list.find &.id.== id + Plugin::Updater.default.check_subscription self, sub.not_nil! + list.save + end + def initialize(id : String) Plugin.build_info_ary @@ -138,6 +184,12 @@ class Plugin sbx.push_string path sbx.put_prop_string -2, "storage_path" + sbx.push_pointer info.dir.as(Void*) + path = sbx.require_pointer(-1).as String + sbx.pop + sbx.push_string path + sbx.put_prop_string -2, "info_dir" + def_helper_functions sbx end @@ -152,23 +204,67 @@ class Plugin {% end %} end + def assert_manga_type(obj : JSON::Any) + obj["id"].as_s && obj["title"].as_s + rescue e + raise Error.new "Missing required fields in the Manga type" + end + + def assert_chapter_type(obj : JSON::Any) + obj["id"].as_s && obj["title"].as_s && obj["pages"].as_i && + obj["manga_title"].as_s + rescue e + raise Error.new "Missing required fields in the Chapter type" + end + + def assert_page_type(obj : JSON::Any) + obj["url"].as_s && obj["filename"].as_s + rescue e + raise Error.new "Missing required fields in the Page type" + end + + def search_manga(query : String) + if info.version == 1 + raise Error.new "Manga searching is only available for plugins " \ + "targeting API v2 or above" + end + json = eval_json "searchManga('#{query}')" + begin + json.as_a.each do |obj| + assert_manga_type obj + end + rescue e + raise Error.new e.message + end + json + end + def list_chapters(query : String) json = eval_json "listChapters('#{query}')" begin - check_fields ["title", "chapters"] - - ary = json["chapters"].as_a - ary.each do |obj| - id = obj["id"]? - raise "Field `id` missing from `listChapters` outputs" if id.nil? - - unless id.to_s.alphanumeric_underscore? - raise "The `id` field can only contain alphanumeric characters " \ - "and underscores" + if info.version > 1 + # Since v2, listChapters returns an array + json.as_a.each do |obj| + assert_chapter_type obj + end + else + check_fields ["title", "chapters"] + + ary = json["chapters"].as_a + ary.each do |obj| + id = obj["id"]? + raise "Field `id` missing from `listChapters` outputs" if id.nil? + + unless id.to_s.alphanumeric_underscore? + raise "The `id` field can only contain alphanumeric characters " \ + "and underscores" + end + + title = obj["title"]? + if title.nil? + raise "Field `title` missing from `listChapters` outputs" + end end - - title = obj["title"]? - raise "Field `title` missing from `listChapters` outputs" if title.nil? end rescue e raise Error.new e.message @@ -179,10 +275,14 @@ class Plugin def select_chapter(id : String) json = eval_json "selectChapter('#{id}')" begin - check_fields ["title", "pages"] + if info.version > 1 + assert_chapter_type json + else + check_fields ["title", "pages"] - if json["title"].to_s.empty? - raise "The `title` field of the chapter can not be empty" + if json["title"].to_s.empty? + raise "The `title` field of the chapter can not be empty" + end end rescue e raise Error.new e.message @@ -194,7 +294,21 @@ class Plugin json = eval_json "nextPage()" return if json.size == 0 begin - check_fields ["filename", "url"] + assert_page_type json + rescue e + raise Error.new e.message + end + json + end + + def new_chapters(manga_id : String, after : Int64) + # Converting standard timestamp to milliseconds so plugins can easily do + # `new Date(ms_timestamp)` in JS. + json = eval_json "newChapters('#{manga_id}', #{after * 1000})" + begin + json.as_a.each do |obj| + assert_chapter_type obj + end rescue e raise Error.new e.message end @@ -379,6 +493,27 @@ class Plugin end sbx.put_prop_string -2, "storage" + if info.version > 1 + sbx.push_proc 1 do |ptr| + env = Duktape::Sandbox.new ptr + key = env.require_string 0 + + env.get_global_string "info_dir" + info_dir = env.require_string -1 + env.pop + info = Info.new info_dir + + if value = info.settings[key]? + env.push_string value + else + env.push_undefined + end + + env.call_success + end + sbx.put_prop_string -2, "settings" + end + sbx.put_prop_string -2, "mango" end end diff --git a/src/plugin/subscriptions.cr b/src/plugin/subscriptions.cr new file mode 100644 index 00000000..153667d7 --- /dev/null +++ b/src/plugin/subscriptions.cr @@ -0,0 +1,115 @@ +require "uuid" +require "big" + +enum FilterType + String + NumMin + NumMax + DateMin + DateMax + Array + + def self.from_string(str) + case str + when "string" + String + when "number-min" + NumMin + when "number-max" + NumMax + when "date-min" + DateMin + when "date-max" + DateMax + when "array" + Array + else + raise "Unknown filter type with string #{str}" + end + end +end + +struct Filter + include JSON::Serializable + + property key : String + property value : String | Int32 | Int64 | Float32 | Nil + property type : FilterType + + def initialize(@key, @value, @type) + end + + def self.from_json(str) : Filter + json = JSON.parse str + key = json["key"].as_s + type = FilterType.from_string json["type"].as_s + _value = json["value"] + value = _value.as_s? || _value.as_i? || _value.as_i64? || + _value.as_f32? || nil + self.new key, value, type + end + + def match_chapter(obj : JSON::Any) : Bool + return true if value.nil? || value.to_s.empty? + raw_value = obj[key] + case type + when FilterType::String + raw_value.as_s.downcase == value.to_s.downcase + when FilterType::NumMin, FilterType::DateMin + BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32 + when FilterType::NumMax, FilterType::DateMax + BigFloat.new(raw_value.as_s) <= BigFloat.new value.not_nil!.to_f32 + when FilterType::Array + return true if value == "all" + raw_value.as_s.downcase.split(",") + .map(&.strip).includes? value.to_s.downcase.strip + else + false + end + end +end + +# We use class instead of struct so we can update `last_checked` from +# `SubscriptionList` +class Subscription + include JSON::Serializable + + property id : String + property plugin_id : String + property manga_id : String + property manga_title : String + property name : String + property created_at : Int64 + property last_checked : Int64 + property filters = [] of Filter + + def initialize(@plugin_id, @manga_id, @manga_title, @name) + @id = UUID.random.to_s + @created_at = Time.utc.to_unix + @last_checked = Time.utc.to_unix + end + + def match_chapter(obj : JSON::Any) : Bool + filters.all? &.match_chapter(obj) + end +end + +struct SubscriptionList + @dir : String + @path : String + + getter ary = [] of Subscription + + forward_missing_to @ary + + def initialize(@dir) + @path = Path[@dir, "subscriptions.json"].to_s + if File.exists? @path + @ary = Array(Subscription).from_json File.read @path + end + end + + def save + File.write @path, @ary.to_pretty_json + end +end diff --git a/src/plugin/updater.cr b/src/plugin/updater.cr new file mode 100644 index 00000000..81ba8c89 --- /dev/null +++ b/src/plugin/updater.cr @@ -0,0 +1,75 @@ +class Plugin + class Updater + use_default + + def initialize + interval = Config.current.plugin_update_interval_hours + return if interval <= 0 + spawn do + loop do + Plugin.list.map(&.["id"]).each do |pid| + check_updates pid + end + sleep interval.hours + end + end + end + + def check_updates(plugin_id : String) + Logger.debug "Checking plugin #{plugin_id} for updates" + + plugin = Plugin.new plugin_id + if plugin.info.version == 1 + Logger.debug "Plugin #{plugin_id} is targeting API version 1. " \ + "Skipping update check" + return + end + + subscriptions = plugin.list_subscriptions_raw + subscriptions.each do |sub| + check_subscription plugin, sub + end + subscriptions.save + rescue e + Logger.error "Error checking plugin #{plugin_id} for updates: " \ + "#{e.message}" + end + + def check_subscription(plugin : Plugin, sub : Subscription) + Logger.debug "Checking subscription #{sub.name} for updates" + matches = plugin.new_chapters(sub.manga_id, sub.last_checked) + .as_a.select do |chapter| + sub.match_chapter chapter + end + if matches.empty? + Logger.debug "No new chapters found." + sub.last_checked = Time.utc.to_unix + return + end + Logger.debug "Found #{matches.size} new chapters. " \ + "Pushing to download queue" + jobs = matches.map { |ch| + Queue::Job.new( + "#{plugin.info.id}-#{Base64.encode ch["id"].as_s}", + "", # manga_id + ch["title"].as_s, + sub.manga_title, + Queue::JobStatus::Pending, + Time.utc + ) + } + inserted_count = Queue.default.push jobs + Logger.info "#{inserted_count}/#{matches.size} new chapters added " \ + "to the download queue. Plugin ID #{plugin.info.id}, " \ + "subscription name #{sub.name}" + if inserted_count != matches.size + Logger.error "Failed to add #{matches.size - inserted_count} " \ + "chapters to download queue" + end + sub.last_checked = Time.utc.to_unix + rescue e + Logger.error "Error when checking updates for subscription " \ + "#{sub.name}: #{e.message}" + end + end +end diff --git a/src/queue.cr b/src/queue.cr index 01cef38c..82fdedac 100644 --- a/src/queue.cr +++ b/src/queue.cr @@ -70,7 +70,13 @@ class Queue ary = @id.split("-") if ary.size == 2 @plugin_id = ary[0] - @plugin_chapter_id = ary[1] + # This begin-rescue block is for backward compatibility. In earlier + # versions we didn't encode the chapter ID + @plugin_chapter_id = begin + Base64.decode_string ary[1] + rescue + ary[1] + end end end diff --git a/src/routes/admin.cr b/src/routes/admin.cr index a63bc0eb..c3692c99 100644 --- a/src/routes/admin.cr +++ b/src/routes/admin.cr @@ -69,6 +69,10 @@ struct AdminRouter layout "download-manager" end + get "/admin/subscriptions" do |env| + layout "subscription-manager" + end + get "/admin/missing" do |env| layout "missing-items" end diff --git a/src/routes/api.cr b/src/routes/api.cr index 413c318b..3e02a645 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -56,6 +56,23 @@ struct APIRouter "error" => String?, } + Koa.schema "filter", { + "key" => String, + "type" => String, + "value" => String | Int32 | Int64 | Float32, + } + + Koa.schema "subscription", { + "id" => String, + "plugin_id" => String, + "manga_id" => String, + "manga_title" => String, + "name" => String, + "created_at" => Int64, + "last_checked" => Int64, + "filters" => ["filter"], + } + Koa.describe "Authenticates a user", <<-MD After successful login, the cookie `mango-sessid-#{Config.current.port}` will contain a valid session ID that can be used for subsequent requests MD @@ -567,6 +584,209 @@ struct APIRouter end end + Koa.describe "Returns a list of available plugins" + Koa.tags ["admin", "downloader"] + Koa.query "plugin", schema: String + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "plugins" => [{ + "id" => String, + "title" => String, + }], + } + get "/api/admin/plugin" do |env| + begin + send_json env, { + "success" => true, + "plugins" => Plugin.list, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Returns the metadata of a plugin" + Koa.tags ["admin", "downloader"] + Koa.query "plugin", schema: String + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "info" => { + "dir" => String, + "id" => String, + "title" => String, + "placeholder" => String, + "wait_seconds" => Int32, + "version" => Int32, + "settings" => {} of String => String, + }, + } + get "/api/admin/plugin/info" do |env| + begin + plugin = Plugin.new env.params.query["plugin"].as String + send_json env, { + "success" => true, + "info" => plugin.info, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Searches for manga matching the given query from a plugin", <<-MD + Only available for plugins targeting API v2 or above. + MD + Koa.tags ["admin", "downloader"] + Koa.query "plugin", schema: String + Koa.query "query", schema: String + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "manga" => [{ + "id" => String, + "title" => String, + }], + } + get "/api/admin/plugin/search" do |env| + begin + query = env.params.query["query"].as String + plugin = Plugin.new env.params.query["plugin"].as String + + manga_ary = plugin.search_manga(query).as_a + send_json env, { + "success" => true, + "manga" => manga_ary, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Creates a new subscription" + Koa.tags ["admin", "downloader", "subscription"] + Koa.body schema: { + "plugin" => String, + "manga" => String, + "manga_id" => String, + "name" => String, + "filters" => ["filter"], + } + Koa.response 200, schema: "result" + post "/api/admin/plugin/subscriptions" do |env| + begin + plugin_id = env.params.json["plugin"].as String + manga_title = env.params.json["manga"].as String + manga_id = env.params.json["manga_id"].as String + filters = env.params.json["filters"].as(Array(JSON::Any)).map do |f| + Filter.from_json f.to_json + end + name = env.params.json["name"].as String + + sub = Subscription.new plugin_id, manga_id, manga_title, name + sub.filters = filters + + plugin = Plugin.new plugin_id + plugin.subscribe sub + + send_json env, { + "success" => true, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Returns the list of subscriptions for a plugin" + Koa.tags ["admin", "downloader", "subscription"] + Koa.query "plugin", desc: "The ID of the plugin" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "subscriptions" => ["subscription"], + } + get "/api/admin/plugin/subscriptions" do |env| + begin + pid = env.params.query["plugin"].as String + send_json env, { + "success" => true, + "subscriptions" => Plugin.new(pid).list_subscriptions, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Deletes a subscription" + Koa.tags ["admin", "downloader", "subscription"] + Koa.body schema: { + "plugin" => String, + "subscription" => String, + } + Koa.response 200, schema: "result" + delete "/api/admin/plugin/subscriptions" do |env| + begin + pid = env.params.query["plugin"].as String + sid = env.params.query["subscription"].as String + + Plugin.new(pid).unsubscribe sid + + send_json env, { + "success" => true, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end + + Koa.describe "Checks for updates for a subscription" + Koa.tags ["admin", "downloader", "subscription"] + Koa.body schema: { + "plugin" => String, + "subscription" => String, + } + Koa.response 200, schema: "result" + post "/api/admin/plugin/subscriptions/update" do |env| + pid = env.params.query["plugin"].as String + sid = env.params.query["subscription"].as String + + Plugin.new(pid).check_subscription sid + + send_json env, { + "success" => true, + }.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + Koa.describe "Lists the chapters in a title from a plugin" Koa.tags ["admin", "downloader"] Koa.query "plugin", schema: String @@ -575,8 +795,8 @@ struct APIRouter "success" => Bool, "error" => String?, "chapters?" => [{ - "id" => String, - "title" => String, + "id" => String, + "title?" => String, }], "title" => String?, } @@ -586,8 +806,14 @@ struct APIRouter plugin = Plugin.new env.params.query["plugin"].as String json = plugin.list_chapters query - chapters = json["chapters"] - title = json["title"] + + if plugin.info.version == 1 + chapters = json["chapters"] + title = json["title"] + else + chapters = json + title = nil + end send_json env, { "success" => true, @@ -625,7 +851,7 @@ struct APIRouter jobs = chapters.map { |ch| Queue::Job.new( - "#{plugin.info.id}-#{ch["id"]}", + "#{plugin.info.id}-#{Base64.encode ch["id"].as_s}", "", # manga_id ch["title"].as_s, manga_title, diff --git a/src/routes/main.cr b/src/routes/main.cr index ea2f0d8c..0e7bf341 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -80,16 +80,6 @@ struct MainRouter get "/download/plugins" do |env| begin - id = env.params.query["plugin"]? - plugins = Plugin.list - plugin = nil - - if id - plugin = Plugin.new id - elsif !plugins.empty? - plugin = Plugin.new plugins[0][:id] - end - layout "plugin-download" rescue e Logger.error e diff --git a/src/subscription.cr b/src/subscription.cr deleted file mode 100644 index e3913601..00000000 --- a/src/subscription.cr +++ /dev/null @@ -1,83 +0,0 @@ -require "db" -require "json" - -struct Subscription - include DB::Serializable - include JSON::Serializable - - getter id : Int64 = 0 - getter username : String - getter manga_id : Int64 - property language : String? - property group_id : Int64? - property min_volume : Int64? - property max_volume : Int64? - property min_chapter : Int64? - property max_chapter : Int64? - @[DB::Field(key: "last_checked")] - @[JSON::Field(key: "last_checked")] - @raw_last_checked : Int64 - @[DB::Field(key: "created_at")] - @[JSON::Field(key: "created_at")] - @raw_created_at : Int64 - - def last_checked : Time - Time.unix @raw_last_checked - end - - def created_at : Time - Time.unix @raw_created_at - end - - def initialize(@manga_id, @username) - @raw_created_at = Time.utc.to_unix - @raw_last_checked = Time.utc.to_unix - end - - private def in_range?(value : String, lowerbound : Int64?, - upperbound : Int64?) : Bool - lb = lowerbound.try &.to_f64 - ub = upperbound.try &.to_f64 - - return true if lb.nil? && ub.nil? - - v = value.to_f64? - return false unless v - - if lb.nil? - v <= ub.not_nil! - elsif ub.nil? - v >= lb.not_nil! - else - v >= lb.not_nil! && v <= ub.not_nil! - end - end - - def match?(chapter : MangaDex::Chapter) : Bool - if chapter.manga_id != manga_id || - (language && chapter.language != language) || - (group_id && !chapter.groups.map(&.id).includes? group_id) - return false - end - - in_range?(chapter.volume, min_volume, max_volume) && - in_range?(chapter.chapter, min_chapter, max_chapter) - end - - def check_for_updates : Int32 - Logger.debug "Checking updates for subscription with ID #{id}" - jobs = [] of Queue::Job - get_client(username).user.updates_after last_checked do |chapter| - next unless match? chapter - jobs << chapter.to_job - end - Storage.default.update_subscription_last_checked id - count = Queue.default.push jobs - Logger.debug "#{count}/#{jobs.size} of updates added to queue" - count - rescue e - Logger.error "Error occurred when checking updates for " \ - "subscription with ID #{id}. #{e}" - 0 - end -end diff --git a/src/views/download.html.ecr b/src/views/download.html.ecr deleted file mode 100644 index 0ea85276..00000000 --- a/src/views/download.html.ecr +++ /dev/null @@ -1,162 +0,0 @@ -

Download from MangaDex

-
-
-
- -
-
-
- -
-
- - - -
-
-
- -
-
-

Title:

-

-

-
-
-

Filter Chapters

-

-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
-
-
- -
-
- - - -
-
-

Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.

-
-

- - - - - - - - - - - - - - -
IDTitleLanguageGroupVolumeChapterTimestamp
-
- - -
- -<% content_for "script" do %> - <%= render_component "moment" %> - <%= render_component "jquery-ui" %> - - -<% end %> diff --git a/src/views/layout.html.ecr b/src/views/layout.html.ecr index 24a036a4..7264ba74 100644 --- a/src/views/layout.html.ecr +++ b/src/views/layout.html.ecr @@ -19,6 +19,7 @@ <% end %> @@ -51,6 +52,7 @@
  • Plugins
  • Download Manager
  • +
  • Subscription Manager
  • diff --git a/src/views/mangadex.html.ecr b/src/views/mangadex.html.ecr deleted file mode 100644 index 764c4f4d..00000000 --- a/src/views/mangadex.html.ecr +++ /dev/null @@ -1,39 +0,0 @@ -
    -

    Connect to MangaDex

    -
    -
    -

    This step is optional but highly recommended if you are using the MangaDex downloader. Connecting to MangaDex allows you to:

    -
      -
    • Search MangaDex by search terms in addition to manga IDs
    • -
    • Automatically download new chapters when they are available (coming soon)
    • -
    -
    - -
    -

    - You have logged in to MangaDex! - You have logged in to MangaDex but the token has expired. - The expiration date of your token is . - If the integration is not working, you - You - can log in again and the token will be updated. -

    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -<% content_for "script" do %> - <%= render_component "moment" %> - - -<% end %> diff --git a/src/views/plugin-download.html.ecr b/src/views/plugin-download.html.ecr index ece56b6f..7c3b4d55 100644 --- a/src/views/plugin-download.html.ecr +++ b/src/views/plugin-download.html.ecr @@ -1,77 +1,214 @@ -<% if plugins.empty? %> -
    -

    No Plugins Found

    -

    We could't find any plugins in the directory <%= Config.current.plugin_path %>.

    -

    You can download official plugins from the Mango plugins repository.

    -
    +
    +
    +
    +

    No Plugins Found

    +

    We could't find any plugins in the directory <%= Config.current.plugin_path %>.

    +

    You can download official plugins from the Mango plugins repository.

    +
    -<% else %> -

    Download with Plugins

    +
    +

    Download with Plugins + +

    -