diff --git a/README.md b/README.md index 53cea050..6c34c3a9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r ### CLI ``` - Mango - Manga Server and Web Reader. Version 0.25.0 + Mango - Manga Server and Web Reader. Version 0.26.0 Usage: @@ -94,9 +94,10 @@ cache_log_enabled: true disable_login: false default_username: "" auth_proxy_header_name: "" +plugin_update_interval_hours: 24 ``` -- `scan_interval_minutes`, `thumbnail_generation_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks +- `scan_interval_minutes`, `thumbnail_generation_interval_hours`, and `plugin_update_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks - `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging - You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work. - By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`. 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/icons/icon.png similarity index 100% rename from public/img/icon.png rename to public/img/icons/icon.png diff --git a/public/img/icons/icon_x192.png b/public/img/icons/icon_x192.png new file mode 100644 index 00000000..1d3bfeae Binary files /dev/null and b/public/img/icons/icon_x192.png differ diff --git a/public/img/icons/icon_x512.png b/public/img/icons/icon_x512.png new file mode 100644 index 00000000..ef62dfb9 Binary files /dev/null and b/public/img/icons/icon_x512.png differ diff --git a/public/img/icons/icon_x96.png b/public/img/icons/icon_x96.png new file mode 100644 index 00000000..e1b1158d Binary files /dev/null and b/public/img/icons/icon_x96.png differ 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/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/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..b52119a1 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,23 @@ +{ + "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", + "type": "image/png" + }, + { + "src": "/img/icons/icon_x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "display": "fullscreen", + "start_url": "/" +} diff --git a/shard.lock b/shard.lock index df6fc4c4..292a16fc 100644 --- a/shard.lock +++ b/shard.lock @@ -50,7 +50,7 @@ shards: koa: git: https://github.com/hkalexling/koa.git - version: 0.8.0 + version: 0.9.0 mg: git: https://github.com/hkalexling/mg.git diff --git a/shard.yml b/shard.yml index 44a09242..14a49aa5 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: mango -version: 0.25.0 +version: 0.26.0 authors: - Alex Ling 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/handlers/auth_handler.cr b/src/handlers/auth_handler.cr index bf79dc38..26a149ae 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" @@ -18,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) @@ -35,13 +42,18 @@ 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 + 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 false @@ -54,6 +66,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"]) || @@ -62,8 +78,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 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/library/entry.cr b/src/library/entry.cr index ceaa5311..dd50ed34 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -55,10 +55,14 @@ class Entry def build_json(*, slim = false) JSON.build do |json| json.object do - {% for str in ["zip_path", "title", "size", "id"] %} + {% for str in %w(zip_path title size id) %} json.field {{str}}, @{{str.id}} {% end %} + if err_msg + json.field "err_msg", err_msg + 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 @@ -108,7 +112,7 @@ class Entry end def cover_url - return "#{Config.current.base_url}img/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| @@ -144,13 +148,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 diff --git a/src/library/library.cr b/src/library/library.cr index 210912f4..2da4c864 100644 --- a/src/library/library.cr +++ b/src/library/library.cr @@ -139,14 +139,31 @@ class Library titles.flat_map &.deep_entries end - def build_json(*, slim = false, depth = -1) + def build_json(*, slim = false, depth = -1, sort_context = nil, + percentage = false) + _titles = if sort_context + sorted_titles sort_context[:username], + sort_context[:opt] + else + self.titles + end JSON.build do |json| json.object do json.field "dir", @dir json.field "titles" do json.array do - self.titles.each do |title| - json.raw title.build_json(slim: slim, depth: depth) + _titles.each do |title| + json.raw title.build_json(slim: slim, depth: depth, + sort_context: sort_context, percentage: percentage) + end + end + end + if percentage && sort_context + json.field "title_percentages" do + json.array do + _titles.each do |title| + json.number title.load_percentage sort_context[:username] + end end end end diff --git a/src/library/title.cr b/src/library/title.cr index 539f114f..e3d79d55 100644 --- a/src/library/title.cr +++ b/src/library/title.cr @@ -202,7 +202,21 @@ class Title alias SortContext = NamedTuple(username: String, opt: SortOptions) def build_json(*, slim = false, depth = -1, - sort_context : SortContext? = nil) + sort_context : SortContext? = nil, + percentage = false) + _titles = if sort_context + sorted_titles sort_context[:username], + sort_context[:opt] + else + self.titles + end + _entries = if sort_context + sorted_entries sort_context[:username], + sort_context[:opt] + else + @entries + end + JSON.build do |json| json.object do {% for str in ["dir", "title", "id"] %} @@ -218,25 +232,39 @@ class Title unless depth == 0 json.field "titles" do json.array do - self.titles.each do |title| + _titles.each do |title| json.raw title.build_json(slim: slim, - depth: depth > 0 ? depth - 1 : depth) + depth: depth > 0 ? depth - 1 : depth, + sort_context: sort_context, percentage: percentage) end end end json.field "entries" do json.array do - _entries = if sort_context - sorted_entries sort_context[:username], - sort_context[:opt] - else - @entries - end _entries.each do |entry| json.raw entry.build_json(slim: slim) end end end + if percentage && sort_context + json.field "title_percentages" do + json.array do + _titles.each do |t| + json.number t.load_percentage sort_context[:username] + end + end + end + json.field "entry_percentages" do + json.array do + load_percentage_for_all_entries( + sort_context[:username], + sort_context[:opt] + ).each do |p| + json.number p.nan? ? 0 : p + end + end + end + end end json.field "parents" do json.array do @@ -411,7 +439,7 @@ class Title cached_cover_url = @cached_cover_url return cached_cover_url unless cached_cover_url.nil? - url = "#{Config.current.base_url}img/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/library/types.cr b/src/library/types.cr index 4c9dc937..973aa5ea 100644 --- a/src/library/types.cr +++ b/src/library/types.cr @@ -55,6 +55,13 @@ class SortOptions def to_tuple {@method.to_s.underscore, ascend} end + + def to_json + { + "method" => method.to_s.underscore, + "ascend" => ascend, + }.to_json + end end struct Image diff --git a/src/mango.cr b/src/mango.cr index 3cdafc05..e57750d4 100644 --- a/src/mango.cr +++ b/src/mango.cr @@ -7,7 +7,7 @@ require "option_parser" require "clim" require "tallboy" -MANGO_VERSION = "0.25.0" +MANGO_VERSION = "0.26.0" # From http://www.network-science.de/ascii/ BANNER = %{ @@ -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..e664b281 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -47,7 +47,12 @@ struct APIRouter "mtime" => Int64, "entries" => ["entry"], "titles" => ["title"], - "parents" => [String], + "parents" => [{ + "title" => String, + "id" => String, + }], + "title_percentages" => [Float64?], + "entry_percentages" => [Float64?], }.merge(s %w(dir title id display_name cover_url)), desc: "A manga title (a collection of entries and sub-titles)" @@ -56,6 +61,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 @@ -63,6 +85,12 @@ struct APIRouter "username" => String, "password" => String, } + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "session_id" => String?, + "is_admin" => Bool?, + } Koa.tag "users" post "/api/login" do |env| begin @@ -71,11 +99,18 @@ struct APIRouter token = Storage.default.verify_user(username, password).not_nil! env.session.string "token", token - "Authenticated" + send_json env, { + "success" => true, + "session_id" => env.session.id, + "is_admin" => Storage.default.username_is_admin username, + }.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 +149,7 @@ struct APIRouter rescue e Logger.error e env.response.status_code = 500 - e.message + send_text env, e.message end end @@ -151,11 +186,13 @@ struct APIRouter rescue e Logger.error e env.response.status_code = 500 - e.message + send_text env, e.message end end Koa.describe "Returns the book with title `tid`", <<-MD + The entries and titles will be sorted by the default sorting method for the logged-in user. + - Supply the `percentage` query parameter to include the reading progress - Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time - Supply the `depth` query parameter to control the depth of nested titles to return. - When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them @@ -166,8 +203,7 @@ struct APIRouter Koa.path "tid", desc: "Title ID" Koa.query "slim" Koa.query "depth" - Koa.query "sort", desc: "Sorting option for entries. Can be one of 'auto', 'title', 'progress', 'time_added' and 'time_modified'" - Koa.query "ascend", desc: "Sorting direction for entries. Set to 0 for the descending order. Doesn't work without specifying 'sort'" + Koa.query "percentage" Koa.response 200, schema: "title" Koa.response 404, "Title not found" Koa.tag "library" @@ -175,29 +211,104 @@ struct APIRouter begin username = get_username env - sort_opt = SortOptions.new - get_sort_opt - tid = env.params.url["tid"] title = Library.default.get_title tid raise "Title ID `#{tid}` not found" if title.nil? + sort_opt = SortOptions.from_info_json title.dir, username + slim = !env.params.query["slim"]?.nil? depth = env.params.query["depth"]?.try(&.to_i?) || -1 + percentage = !env.params.query["percentage"]?.nil? send_json env, title.build_json(slim: slim, depth: depth, sort_context: {username: username, - opt: sort_opt}) + opt: sort_opt}, percentage: percentage) rescue e Logger.error e env.response.status_code = 404 - e.message + send_text env, e.message + end + end + + Koa.describe "Returns the sorting option of a title or the library", <<-MD + - If the query parameter `tid` is supplied, returns the sorting option of the title identified by the `tid`. + - If the query parameter `tid` is missing, returns the sorting option of the library. + MD + Koa.query "tid" + Koa.response 200, schema: { + "method" => String?, + "ascend" => Bool?, + "error" => String?, + } + Koa.tag "library" + get "/api/sort_opt" do |env| + username = get_username env + + tid = env.params.query["tid"]? + dir = if tid + (Library.default.get_title tid).not_nil!.dir + else + Library.default.dir + end + sort_opt = SortOptions.from_info_json dir, username + send_json env, sort_opt.to_json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + + Koa.describe "Updates the sorting option of a title or the library", <<-MD + - When the `tid` field is supplied in the body, updates the sorting option of the title identified by the `tid`. + - When the `tid` field is missing in the body, updates the sorting option of the library. + MD + Koa.body schema: { + "tid" => String?, + "method" => String, + "ascend" => Bool, + } + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + } + Koa.tag "library" + put "/api/sort_opt" do |env| + username = get_username env + + tid = env.params.json["tid"]?.try &.as String + dir = if tid + (Library.default.get_title tid).not_nil!.dir + else + Library.default.dir + end + + method = env.params.json["sort"].as String + ascend = env.params.json["ascend"].as Bool + sort_opt = SortOptions.new method, ascend + + TitleInfo.new dir do |info| + info.sort_by[username] = sort_opt.to_tuple + info.save end + 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 "Returns the entire library with all titles and entries", <<-MD + The titles will be sorted by the default sorting method for the logged-in user. - Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time - Supply the `dpeth` query parameter to control the depth of nested titles to return. + - Supply the `percentage` query parameter to include the reading progress - When `depth` is 1, returns the requested title and sub-titles/entries one level in it - When `depth` is 0, returns the requested title without its sub-titles/entries - When `depth` is N, returns the requested title and sub-titles/entries N levels in it @@ -205,16 +316,162 @@ struct APIRouter MD Koa.query "slim" Koa.query "depth" + Koa.query "percentage" Koa.response 200, schema: { - "dir" => String, - "titles" => ["title"], + "dir" => String, + "titles" => ["title"], + "title_percentage" => [Float64?], } Koa.tag "library" get "/api/library" do |env| + username = get_username env + + sort_opt = SortOptions.from_info_json Library.default.dir, username + slim = !env.params.query["slim"]?.nil? depth = env.params.query["depth"]?.try(&.to_i?) || -1 + percentage = !env.params.query["percentage"]?.nil? + + send_json env, Library.default.build_json(slim: slim, depth: depth, + sort_context: {username: username, + opt: sort_opt}, percentage: percentage) + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + + Koa.describe "Returns the continue reading entries" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "entries" => ["entry"], + "entry_percentages" => [Float64], + } + Koa.tag "library" + get "/api/library/continue_reading" do |env| + username = get_username env + cr_entries = Library.default.get_continue_reading_entries username - send_json env, Library.default.build_json(slim: slim, depth: depth) + json = JSON.build do |j| + j.object do + j.field "success" do + j.bool true + end + j.field "entries" do + j.array do + cr_entries.each do |e| + j.raw e[:entry].build_json + end + end + end + j.field "entry_percentages" do + j.array do + cr_entries.each do |e| + j.number e[:percentage] + end + end + end + end + end + + send_json env, json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + + Koa.describe "Returns the start reading titles" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "titles" => ["title"], + } + Koa.tag "library" + get "/api/library/start_reading" do |env| + username = get_username env + titles = Library.default.get_start_reading_titles username + + json = JSON.build do |j| + j.object do + j.field "success" do + j.bool true + end + j.field "titles" do + j.array do + titles.each do |t| + j.raw t.build_json depth: 1 + end + end + end + end + end + + send_json env, json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + + Koa.describe "Returns the recently added items" + Koa.response 200, schema: { + "success" => Bool, + "error" => String?, + "items" => [{ + "item" => "title | entry", + "percentage" => Float64, + "count" => Int32, + }], + } + Koa.tag "library" + get "/api/library/recently_added" do |env| + username = get_username env + ra_entries = Library.default.get_recently_added_entries username + + json = JSON.build do |j| + j.object do + j.field "success" do + j.bool true + end + j.field "items" do + j.array do + ra_entries.each do |e| + j.object do + j.field "item" do + if e[:grouped_count] === 1 + j.raw e[:entry].build_json + else + j.raw e[:entry].book.build_json depth: 0 + end + end + j.field "percentage" do + j.number e[:percentage] + end + j.field "count" do + j.number e[:grouped_count] + end + end + end + end + end + end + end + + send_json env, json + rescue e + Logger.error e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json end Koa.describe "Triggers a library scan" @@ -250,6 +507,7 @@ struct APIRouter spawn do Library.default.generate_thumbnails end + send_text env, "" end Koa.describe "Deletes a user with `username`" @@ -567,6 +825,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 +1036,8 @@ struct APIRouter "success" => Bool, "error" => String?, "chapters?" => [{ - "id" => String, - "title" => String, + "id" => String, + "title?" => String, }], "title" => String?, } @@ -586,8 +1047,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 +1092,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, @@ -675,7 +1142,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 +1176,7 @@ struct APIRouter rescue e Logger.error e env.response.status_code = 404 + send_text env, e.message end end 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/server.cr b/src/server.cr index e8dc54b0..b0a022d4 100644 --- a/src/server.cr +++ b/src/server.cr @@ -23,7 +23,17 @@ class Server AdminRouter.new ReaderRouter.new APIRouter.new - OPDSRouter.new + + {% 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 add_handler LogHandler.new 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/util/web.cr b/src/util/web.cr index 5704ea88..366af041 100644 --- a/src/util/web.cr +++ b/src/util/web.cr @@ -39,13 +39,28 @@ macro send_error_page(msg) end macro send_img(env, img) + cors 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 @@ -57,12 +72,29 @@ macro get_username(env) end end +macro cors + env.response.headers["Access-Control-Allow-Methods"] = "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 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 %> 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/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 c32bfb5b..7264ba74 100644 --- a/src/views/layout.html.ecr +++ b/src/views/layout.html.ecr @@ -19,6 +19,7 @@ <% end %> @@ -36,7 +37,7 @@
- +
@@ -78,7 +80,7 @@ <%= render_component "uikit" %> <%= yield_content "script" %> 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 + +

-