diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7e0a47..f15ce4d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest container: - image: crystallang/crystal:0.34.0-alpine + image: crystallang/crystal:0.35.1-alpine steps: - uses: actions/checkout@v2 diff --git a/Dockerfile b/Dockerfile index ee1f6d1..7dc5f01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM crystallang/crystal:0.34.0-alpine AS builder +FROM crystallang/crystal:0.35.1-alpine AS builder WORKDIR /Mango diff --git a/Dockerfile.arm32v7 b/Dockerfile.arm32v7 index 7f52e48..854f4ec 100644 --- a/Dockerfile.arm32v7 +++ b/Dockerfile.arm32v7 @@ -2,7 +2,7 @@ FROM arm32v7/ubuntu:18.04 RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev -RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd .. +RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd .. diff --git a/Dockerfile.arm64v8 b/Dockerfile.arm64v8 index 67675c6..888f797 100644 --- a/Dockerfile.arm64v8 +++ b/Dockerfile.arm64v8 @@ -2,7 +2,7 @@ FROM arm64v8/ubuntu:18.04 RUN apt-get update && apt-get install -y wget git make llvm-8 llvm-8-dev g++ libsqlite3-dev libyaml-dev libgc-dev libssl-dev libcrypto++-dev libevent-dev libgmp-dev zlib1g-dev libpcre++-dev pkg-config libarchive-dev libxml2-dev libacl1-dev nettle-dev liblzo2-dev liblzma-dev libbz2-dev libjpeg-turbo8-dev libpng-dev libtiff-dev -RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.34.0 && make deps && cd .. +RUN git clone https://github.com/crystal-lang/crystal && cd crystal && git checkout 0.35.1 && make deps && cd .. RUN git clone https://github.com/kostya/myhtml && cd myhtml/src/ext && git checkout v1.5.0 && make && cd .. RUN git clone https://github.com/jessedoyle/duktape.cr && cd duktape.cr/ext && git checkout v0.20.0 && make && cd .. RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr && git checkout v0.2.0 && make && cd .. diff --git a/README.md b/README.md index d624a1d..c1c7d65 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r ### CLI ``` - Mango - Manga Server and Web Reader. Version 0.16.0 + Mango - Manga Server and Web Reader. Version 0.17.0 Usage: diff --git a/dev/linewidth.sh b/dev/linewidth.sh index 7f76fcb..6e83fbc 100755 --- a/dev/linewidth.sh +++ b/dev/linewidth.sh @@ -1,5 +1,5 @@ #!/bin/sh -[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | tee /dev/tty)" ] \ +[ ! -z "$(grep '.\{80\}' --exclude-dir=lib --include="*.cr" -nr --color=always . | grep -v "routes/api.cr" | tee /dev/tty)" ] \ && echo "The above lines exceed the 80 characters limit" \ || exit 0 diff --git a/public/js/common.js b/public/js/common.js new file mode 100644 index 0000000..a80fc53 --- /dev/null +++ b/public/js/common.js @@ -0,0 +1,147 @@ +/** + * --- Alpine helper functions + */ + +/** + * Set an alpine.js property + * + * @function setProp + * @param {string} key - Key of the data property + * @param {*} prop - The data property + * @param {string} selector - The jQuery selector to the root element + */ +const setProp = (key, prop, selector = '#root') => { + $(selector).get(0).__x.$data[key] = prop; +}; + +/** + * Get an alpine.js property + * + * @function getProp + * @param {string} key - Key of the data property + * @param {string} selector - The jQuery selector to the root element + * @return {*} The data property + */ +const getProp = (key, selector = '#root') => { + return $(selector).get(0).__x.$data[key]; +}; + +/** + * --- Theme related functions + * Note: In the comments below we treat "theme" and "theme setting" + * differently. A theme can have only two values, either "dark" or + * "light", while a theme setting can have the third value "system". + */ + +/** + * Check if the system setting prefers dark theme. + * from https://flaviocopes.com/javascript-detect-dark-mode/ + * + * @function preferDarkMode + * @return {bool} + */ +const preferDarkMode = () => { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; +}; + +/** + * Check whether a given string represents a valid theme setting + * + * @function validThemeSetting + * @param {string} theme - The string representing the theme setting + * @return {bool} + */ +const validThemeSetting = (theme) => { + return ['dark', 'light', 'system'].indexOf(theme) >= 0; +}; + +/** + * Load theme setting from local storage, or use 'light' + * + * @function loadThemeSetting + * @return {string} A theme setting ('dark', 'light', or 'system') + */ +const loadThemeSetting = () => { + let str = localStorage.getItem('theme'); + if (!str || !validThemeSetting(str)) str = 'light'; + return str; +}; + +/** + * Load the current theme (not theme setting) + * + * @function loadTheme + * @return {string} The current theme to use ('dark' or 'light') + */ +const loadTheme = () => { + let setting = loadThemeSetting(); + if (setting === 'system') { + setting = preferDarkMode() ? 'dark' : 'light'; + } + return setting; +}; + +/** + * Save a theme setting + * + * @function saveThemeSetting + * @param {string} setting - A theme setting + */ +const saveThemeSetting = setting => { + if (!validThemeSetting(setting)) setting = 'light'; + localStorage.setItem('theme', setting); +}; + +/** + * Toggle the current theme. When the current theme setting is 'system', it + * will be changed to either 'light' or 'dark' + * + * @function toggleTheme + */ +const toggleTheme = () => { + const theme = loadTheme(); + const newTheme = theme === 'dark' ? 'light' : 'dark'; + saveThemeSetting(newTheme); + setTheme(newTheme); +}; + +/** + * Apply a theme, or load a theme and then apply it + * + * @function setTheme + * @param {string?} theme - (Optional) The theme to apply. When omitted, use + * `loadTheme` to get a theme and apply it. + */ +const setTheme = (theme) => { + if (!theme) theme = loadTheme(); + if (theme === 'dark') { + $('html').css('background', 'rgb(20, 20, 20)'); + $('body').addClass('uk-light'); + $('.uk-card').addClass('uk-card-secondary'); + $('.uk-card').removeClass('uk-card-default'); + $('.ui-widget-content').addClass('dark'); + } else { + $('html').css('background', ''); + $('body').removeClass('uk-light'); + $('.uk-card').removeClass('uk-card-secondary'); + $('.uk-card').addClass('uk-card-default'); + $('.ui-widget-content').removeClass('dark'); + } +}; + +// do it before document is ready to prevent the initial flash of white on +// most pages +setTheme(); +$(() => { + // hack for the reader page + setTheme(); + + // on system dark mode setting change + if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', event => { + if (loadThemeSetting() === 'system') + setTheme(event.matches ? 'dark' : 'light'); + }); + } +}); diff --git a/public/js/download-manager.js b/public/js/download-manager.js index 4ff6e2e..6136232 100644 --- a/public/js/download-manager.js +++ b/public/js/download-manager.js @@ -1,28 +1,42 @@ -$(() => { - $('input.uk-checkbox').each((i, e) => { - $(e).change(() => { - loadConfig(); +/** + * Get the current queue and update the view + * + * @function load + */ +const load = () => { + try { + setProp('loading', true); + } catch {} + $.ajax({ + type: 'GET', + url: base_url + 'api/admin/mangadex/queue', + dataType: 'json' + }) + .done(data => { + if (!data.success && data.error) { + alert('danger', `Failed to fetch download queue. Error: ${data.error}`); + return; + } + setProp('jobs', data.jobs); + setProp('paused', data.paused); + }) + .fail((jqXHR, status) => { + alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }) + .always(() => { + setProp('loading', false); }); - }); - loadConfig(); - load(); - - const intervalMS = 5000; - setTimeout(() => { - setInterval(() => { - if (globalConfig.autoRefresh !== true) return; - load(); - }, intervalMS); - }, intervalMS); -}); -var globalConfig = {}; -var loading = false; - -const loadConfig = () => { - globalConfig.autoRefresh = $('#auto-refresh').prop('checked'); }; -const remove = (id) => { - var url = base_url + 'api/admin/mangadex/queue/delete'; + +/** + * Perform an action on either a specific job or the entire queue + * + * @function jobAction + * @param {string} action - The action to perform. Should be either 'delete' or 'retry' + * @param {string?} id - (Optional) A job ID. When omitted, apply the action to the queue + */ +const jobAction = (action, id) => { + let url = `${base_url}api/admin/mangadex/queue/${action}`; if (id !== undefined) url += '?' + $.param({ id: id @@ -35,42 +49,24 @@ const remove = (id) => { }) .done(data => { if (!data.success && data.error) { - alert('danger', `Failed to remove job from download queue. Error: ${data.error}`); + alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`); return; } load(); }) .fail((jqXHR, status) => { - alert('danger', `Failed to remove job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); - }); -}; -const refresh = (id) => { - var url = base_url + 'api/admin/mangadex/queue/retry'; - if (id !== undefined) - url += '?' + $.param({ - id: id - }); - console.log(url); - $.ajax({ - type: 'POST', - url: url, - dataType: 'json' - }) - .done(data => { - if (!data.success && data.error) { - alert('danger', `Failed to restart download job. Error: ${data.error}`); - return; - } - load(); - }) - .fail((jqXHR, status) => { - alert('danger', `Failed to restart download job. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); }); }; + +/** + * Pause/resume the download + * + * @function toggle + */ const toggle = () => { - $('#pause-resume-btn').attr('disabled', ''); - const paused = $('#pause-resume-btn').text() === 'Resume download'; - const action = paused ? 'resume' : 'pause'; + setProp('toggling', true); + const action = getProp('paused') ? 'resume' : 'pause'; const url = `${base_url}api/admin/mangadex/queue/${action}`; $.ajax({ type: 'POST', @@ -82,64 +78,47 @@ const toggle = () => { }) .always(() => { load(); - $('#pause-resume-btn').removeAttr('disabled'); + setProp('toggling', false); }); }; -const load = () => { - if (loading) return; - loading = true; - console.log('fetching'); - $.ajax({ - type: 'GET', - url: base_url + 'api/admin/mangadex/queue', - dataType: 'json' - }) - .done(data => { - if (!data.success && data.error) { - alert('danger', `Failed to fetch download queue. Error: ${data.error}`); - return; - } - console.log(data); - const btnText = data.paused ? "Resume download" : "Pause download"; - $('#pause-resume-btn').text(btnText); - $('#pause-resume-btn').removeAttr('hidden'); - const rows = data.jobs.map(obj => { - var cls = 'label '; - if (obj.status === 'Pending') - cls += 'label-pending'; - if (obj.status === 'Completed') - cls += 'label-success'; - if (obj.status === 'Error') - cls += 'label-danger'; - if (obj.status === 'MissingPages') - cls += 'label-warning'; - const info = obj.status_message.length > 0 ? '' : ''; - const statusSpan = `${obj.status} ${info}`; - const dropdown = obj.status_message.length > 0 ? `
${obj.status_message}
` : ''; - const retryBtn = obj.status_message.length > 0 ? `` : ''; - return ` - ${obj.plugin_id ? obj.title : `${obj.title}`} - ${obj.plugin_id ? obj.manga_title : `${obj.manga_title}`} - ${obj.success_count}/${obj.pages} - ${moment(obj.time).fromNow()} - ${statusSpan} ${dropdown} - ${obj.plugin_id || ""} - - - ${retryBtn} - - `; - }); - - const tbody = `${rows.join('')}`; - $('tbody').remove(); - $('table').append(tbody); - }) - .fail((jqXHR, status) => { - alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`); - }) - .always(() => { - loading = false; - }); +/** + * Get the uk-label class name for a given job status + * + * @function statusClass + * @param {string} status - The job status + * @return {string} The class name string + */ +const statusClass = status => { + let cls = 'label '; + switch (status) { + case 'Pending': + cls += 'label-pending'; + break; + case 'Completed': + cls += 'label-success'; + break; + case 'Error': + cls += 'label-danger'; + break; + case 'MissingPages': + cls += 'label-warning'; + break; + } + return cls; }; + +$(() => { + const ws = new WebSocket(`ws://${location.host}/api/admin/mangadex/queue`); + ws.onmessage = event => { + const data = JSON.parse(event.data); + setProp('jobs', data.jobs); + setProp('paused', data.paused); + }; + ws.onerror = err => { + alert('danger', `Socket connection failed. Error: ${err}`); + }; + ws.onclose = err => { + alert('danger', 'Socket connection failed'); + }; +}); diff --git a/public/js/plugin-download.js b/public/js/plugin-download.js index 746dda8..e1fba96 100644 --- a/public/js/plugin-download.js +++ b/public/js/plugin-download.js @@ -33,14 +33,13 @@ const search = () => { if (searching) return; - const query = $('#search-input').val(); + const query = $.param({ + query: $('#search-input').val(), + plugin: pid + }); $.ajax({ - type: 'POST', - url: base_url + 'api/admin/plugin/list', - data: JSON.stringify({ - query: query, - plugin: pid - }), + type: 'GET', + url: `${base_url}api/admin/plugin/list?${query}`, contentType: "application/json", dataType: 'json' }) diff --git a/public/js/reader.js b/public/js/reader.js index 6d44a06..7ff46aa 100644 --- a/public/js/reader.js +++ b/public/js/reader.js @@ -61,28 +61,6 @@ const updateMode = (mode, targetPage) => { }); }; -/** - * Set an alpine.js property - * - * @function setProp - * @param {string} key - Key of the data property - * @param {*} prop - The data property - */ -const setProp = (key, prop) => { - $('#root').get(0).__x.$data[key] = prop; -}; - -/** - * Get an alpine.js property - * - * @function getProp - * @param {string} key - Key of the data property - * @return {*} The data property - */ -const getProp = (key) => { - return $('#root').get(0).__x.$data[key]; -}; - /** * Get dimension of the pages in the entry from the API and update the view */ @@ -163,10 +141,9 @@ const waitForPage = (idx, cb) => { * Show the control modal * * @function showControl - * @param {object} event - The onclick event that triggers the function + * @param {string} idx - One-based index of the current page */ -const showControl = (event) => { - const idx = parseInt($(event.currentTarget).attr('id')); +const showControl = (idx) => { const pageCount = $('#page-select > option').length; const progressText = `Progress: ${idx}/${pageCount} (${(idx/pageCount * 100).toFixed(1)}%)`; $('#progress-label').text(progressText); @@ -213,6 +190,8 @@ const setupScroller = () => { $(el).on('inview', (event, inView) => { if (inView) { const current = $(event.currentTarget).attr('id'); + + setProp('curItem', getProp('items')[current - 1]); replaceHistory(current); } }); @@ -240,15 +219,19 @@ const saveProgress = (idx, cb) => { lastSavedPage = idx; console.log('saving progress', idx); - const url = `${base_url}api/progress/${tid}/${idx}?${$.param({entry: eid})}`; - $.post(url) - .then(data => { - if (data.error) throw new Error(data.error); + const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`; + $.ajax({ + method: 'PUT', + url: url, + dataType: 'json' + }) + .done(data => { + if (data.error) + alert('danger', data.error); if (cb) cb(); }) - .catch(e => { - console.error(e); - alert('danger', e); + .fail((jqXHR, status) => { + alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); }); } }; diff --git a/public/js/theme.js b/public/js/theme.js deleted file mode 100644 index 3cd6690..0000000 --- a/public/js/theme.js +++ /dev/null @@ -1,72 +0,0 @@ -// https://flaviocopes.com/javascript-detect-dark-mode/ -const preferDarkMode = () => { - return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; -}; - -const validThemeSetting = (theme) => { - return ['dark', 'light', 'system'].indexOf(theme) >= 0; -}; - -// dark / light / system -const loadThemeSetting = () => { - let str = localStorage.getItem('theme'); - if (!str || !validThemeSetting(str)) str = 'light'; - return str; -}; - -// dark / light -const loadTheme = () => { - let setting = loadThemeSetting(); - if (setting === 'system') { - setting = preferDarkMode() ? 'dark' : 'light'; - } - return setting; -}; - -const saveThemeSetting = setting => { - if (!validThemeSetting(setting)) setting = 'light'; - localStorage.setItem('theme', setting); -}; - -// when toggled, Auto will be changed to light or dark -const toggleTheme = () => { - const theme = loadTheme(); - const newTheme = theme === 'dark' ? 'light' : 'dark'; - saveThemeSetting(newTheme); - setTheme(newTheme); -}; - -const setTheme = (theme) => { - if (!theme) theme = loadTheme(); - if (theme === 'dark') { - $('html').css('background', 'rgb(20, 20, 20)'); - $('body').addClass('uk-light'); - $('.uk-card').addClass('uk-card-secondary'); - $('.uk-card').removeClass('uk-card-default'); - $('.ui-widget-content').addClass('dark'); - } else { - $('html').css('background', ''); - $('body').removeClass('uk-light'); - $('.uk-card').removeClass('uk-card-secondary'); - $('.uk-card').addClass('uk-card-default'); - $('.ui-widget-content').removeClass('dark'); - } -}; - -// do it before document is ready to prevent the initial flash of white on -// most pages -setTheme(); - -$(() => { - // hack for the reader page - setTheme(); - - // on system dark mode setting change - if (window.matchMedia) { - window.matchMedia('(prefers-color-scheme: dark)') - .addEventListener('change', event => { - if (loadThemeSetting() === 'system') - setTheme(event.matches ? 'dark' : 'light'); - }); - } -}); diff --git a/public/js/title.js b/public/js/title.js index 0942cd1..d46c173 100644 --- a/public/js/title.js +++ b/public/js/title.js @@ -55,7 +55,7 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi $('#modal-edit-btn').attr('onclick', `edit("${entryID}")`); - $('#modal-download-btn').attr('href', `${base_url}opds/download/${titleID}/${entryID}`); + $('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`); UIkit.modal($('#modal')).show(); } @@ -63,18 +63,27 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi const updateProgress = (tid, eid, page) => { let url = `${base_url}api/progress/${tid}/${page}` const query = $.param({ - entry: eid + eid: eid }); if (eid) url += `?${query}`; - $.post(url, (data) => { - if (data.success) { - location.reload(); - } else { - error = data.error; - alert('danger', error); - } - }); + + $.ajax({ + method: 'PUT', + url: url, + dataType: 'json' + }) + .done(data => { + if (data.success) { + location.reload(); + } else { + error = data.error; + alert('danger', error); + } + }) + .fail((jqXHR, status) => { + alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }); }; const renameSubmit = (name, eid) => { @@ -89,14 +98,14 @@ const renameSubmit = (name, eid) => { } const query = $.param({ - entry: eid + eid: eid }); let url = `${base_url}api/admin/display_name/${titleId}/${name}`; if (eid) url += `?${query}`; $.ajax({ - type: 'POST', + type: 'PUT', url: url, contentType: "application/json", dataType: 'json' @@ -131,6 +140,7 @@ const edit = (eid) => { const displayNameField = $('#display-name-field'); displayNameField.attr('value', displayName); + console.log(displayNameField); displayNameField.keyup(event => { if (event.keyCode === 13) { renameSubmit(displayNameField.val(), eid); @@ -150,10 +160,10 @@ const setupUpload = (eid) => { const bar = $('#upload-progress').get(0); const titleId = upload.attr('data-title-id'); const queryObj = { - title: titleId + tid: titleId }; if (eid) - queryObj['entry'] = eid; + queryObj['eid'] = eid; const query = $.param(queryObj); const url = `${base_url}api/admin/upload/cover?${query}`; console.log(url); @@ -218,9 +228,9 @@ const selectedIDs = () => { const bulkProgress = (action, el) => { const tid = $(el).attr('data-id'); const ids = selectedIDs(); - const url = `${base_url}api/bulk-progress/${action}/${tid}`; + const url = `${base_url}api/bulk_progress/${action}/${tid}`; $.ajax({ - type: 'POST', + type: 'PUT', url: url, contentType: "application/json", dataType: 'json', diff --git a/public/js/user.js b/public/js/user.js index 9d69fd4..b1b910f 100644 --- a/public/js/user.js +++ b/public/js/user.js @@ -1,11 +1,16 @@ -function remove(username) { - $.post(base_url + 'api/admin/user/delete/' + username, function(data) { - if (data.success) { - location.reload(); - } - else { - error = data.error; - alert('danger', error); - } - }); -} +const remove = (username) => { + $.ajax({ + url: `${base_url}api/admin/user/delete/${username}`, + type: 'DELETE', + dataType: 'json' + }) + .done(data => { + if (data.success) + location.reload(); + else + alert('danger', data.error); + }) + .fail((jqXHR, status) => { + alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`); + }); +}; diff --git a/shard.lock b/shard.lock index f2604c9..6189fcd 100644 --- a/shard.lock +++ b/shard.lock @@ -1,62 +1,70 @@ -version: 1.0 +version: 2.0 shards: ameba: - github: crystal-ameba/ameba + git: https://github.com/crystal-ameba/ameba.git version: 0.12.1 archive: - github: hkalexling/archive.cr + git: https://github.com/hkalexling/archive.cr.git version: 0.4.0 baked_file_system: - github: schovi/baked_file_system - version: 0.9.8 + git: https://github.com/schovi/baked_file_system.git + version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b clim: - github: at-grandpa/clim + git: https://github.com/at-grandpa/clim.git version: 0.12.0 db: - github: crystal-lang/crystal-db + git: https://github.com/crystal-lang/crystal-db.git version: 0.9.0 duktape: - github: jessedoyle/duktape.cr + git: https://github.com/jessedoyle/duktape.cr.git version: 0.20.0 exception_page: - github: crystal-loot/exception_page + git: https://github.com/crystal-loot/exception_page.git version: 0.1.4 http_proxy: - github: mamantoha/http_proxy + git: https://github.com/mamantoha/http_proxy.git version: 0.7.1 image_size: - github: hkalexling/image_size.cr + git: https://github.com/hkalexling/image_size.cr.git version: 0.4.0 kemal: - github: kemalcr/kemal - version: 0.26.1 + git: https://github.com/kemalcr/kemal.git + version: 0.27.0 kemal-session: - github: kemalcr/kemal-session + git: https://github.com/kemalcr/kemal-session.git version: 0.12.1 kilt: - github: jeromegn/kilt + git: https://github.com/jeromegn/kilt.git version: 0.4.0 + koa: + git: https://github.com/hkalexling/koa.git + version: 0.5.0 + myhtml: - github: kostya/myhtml + git: https://github.com/kostya/myhtml.git version: 1.5.1 + open_api: + git: https://github.com/jreinert/open_api.cr.git + version: 1.2.1+git.commit.95e4df2ca10b1fe88b8b35c62a18b06a10267b6c + radix: - github: luislavena/radix + git: https://github.com/luislavena/radix.git version: 0.3.9 sqlite3: - github: crystal-lang/crystal-sqlite3 + git: https://github.com/crystal-lang/crystal-sqlite3.git version: 0.16.0 diff --git a/shard.yml b/shard.yml index bca96d9..3fe7aca 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: mango -version: 0.16.0 +version: 0.17.0 authors: - Alex Ling @@ -8,7 +8,7 @@ targets: mango: main: src/mango.cr -crystal: 0.34.0 +crystal: 0.35.1 license: MIT @@ -21,6 +21,7 @@ dependencies: github: crystal-lang/crystal-sqlite3 baked_file_system: github: schovi/baked_file_system + version: 0.9.8+git.commit.fb3091b546797fbec3c25dc0e1e2cff60bb9033b archive: github: hkalexling/archive.cr ameba: @@ -36,3 +37,5 @@ dependencies: github: mamantoha/http_proxy image_size: github: hkalexling/image_size.cr + koa: + github: hkalexling/koa diff --git a/src/archive.cr b/src/archive.cr index 29dedfb..068b6d5 100644 --- a/src/archive.cr +++ b/src/archive.cr @@ -1,13 +1,13 @@ -require "zip" +require "compress/zip" require "archive" -# A unified class to handle all supported archive formats. It uses the ::Zip -# module in crystal standard library if the target file is a zip archive. -# Otherwise it uses `archive.cr`. +# A unified class to handle all supported archive formats. It uses the +# Compress::Zip module in crystal standard library if the target file is +# a zip archive. Otherwise it uses `archive.cr`. class ArchiveFile def initialize(@filename : String) if [".cbz", ".zip"].includes? File.extname filename - @archive_file = Zip::File.new filename + @archive_file = Compress::Zip::File.new filename else @archive_file = Archive::File.new filename end @@ -20,16 +20,16 @@ class ArchiveFile end def close - if @archive_file.is_a? Zip::File - @archive_file.as(Zip::File).close + if @archive_file.is_a? Compress::Zip::File + @archive_file.as(Compress::Zip::File).close end end # Lists all file entries def entries - ary = [] of Zip::File::Entry | Archive::Entry + ary = [] of Compress::Zip::File::Entry | Archive::Entry @archive_file.entries.map do |e| - if (e.is_a? Zip::File::Entry && e.file?) || + if (e.is_a? Compress::Zip::File::Entry && e.file?) || (e.is_a? Archive::Entry && e.info.file?) ary.push e end @@ -37,8 +37,8 @@ class ArchiveFile ary end - def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes? - if e.is_a? Zip::File::Entry + def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes? + if e.is_a? Compress::Zip::File::Entry data = nil e.open do |io| slice = Bytes.new e.uncompressed_size diff --git a/src/handlers/static_handler.cr b/src/handlers/static_handler.cr index b3a2d0e..edb772d 100644 --- a/src/handlers/static_handler.cr +++ b/src/handlers/static_handler.cr @@ -23,7 +23,7 @@ class StaticHandler < Kemal::Handler slice = Bytes.new file.size file.read slice - return send_file env, slice, file.mime_type + return send_file env, slice, MIME.from_filename file.path end call_next env end diff --git a/src/library/entry.cr b/src/library/entry.cr index 7a8cfae..810c6c8 100644 --- a/src/library/entry.cr +++ b/src/library/entry.cr @@ -47,8 +47,7 @@ class Entry def to_json(json : JSON::Builder) json.object do - {% for str in ["zip_path", "title", "size", "id", - "encoded_path", "encoded_title"] %} + {% for str in ["zip_path", "title", "size", "id"] %} json.field {{str}}, @{{str.id}} {% end %} json.field "title_id", @book.id diff --git a/src/library/title.cr b/src/library/title.cr index 6754504..f3d8b2f 100644 --- a/src/library/title.cr +++ b/src/library/title.cr @@ -56,7 +56,7 @@ class Title def to_json(json : JSON::Builder) json.object do - {% for str in ["dir", "title", "id", "encoded_title"] %} + {% for str in ["dir", "title", "id"] %} json.field {{str}}, @{{str.id}} {% end %} json.field "display_name", display_name diff --git a/src/logger.cr b/src/logger.cr index 3777e9c..92be20e 100644 --- a/src/logger.cr +++ b/src/logger.cr @@ -26,9 +26,9 @@ class Logger {% end %} @log = Log.for("") - @backend = Log::IOBackend.new - @backend.formatter = ->(entry : Log::Entry, io : IO) do + + format_proc = ->(entry : Log::Entry, io : IO) do color = :default {% begin %} case entry.severity.label.to_s().downcase @@ -45,12 +45,14 @@ class Logger io << entry.message end - Log.builder.bind "*", @@severity, @backend + @backend.formatter = Log::Formatter.new &format_proc + Log.setup @@severity, @backend end # Ignores @@severity and always log msg def log(msg) - @backend.write Log::Entry.new "", Log::Severity::None, msg, nil + @backend.write Log::Entry.new "", Log::Severity::None, msg, + Log::Metadata.empty, nil end def self.log(msg) diff --git a/src/mangadex/downloader.cr b/src/mangadex/downloader.cr index 793763d..e2babb6 100644 --- a/src/mangadex/downloader.cr +++ b/src/mangadex/downloader.cr @@ -1,12 +1,12 @@ require "./api" -require "zip" +require "compress/zip" module MangaDex class PageJob property success = false property url : String property filename : String - property writer : Zip::Writer + property writer : Compress::Zip::Writer property tries_remaning : Int32 def initialize(@url, @filename, @writer, @tries_remaning) @@ -69,7 +69,7 @@ module MangaDex # Find the number of digits needed to store the number of pages len = Math.log10(chapter.pages.size).to_i + 1 - writer = Zip::Writer.new zip_path + writer = Compress::Zip::Writer.new zip_path # Create a buffered channel. It works as an FIFO queue channel = Channel(PageJob).new chapter.pages.size spawn do @@ -91,6 +91,7 @@ module MangaDex end channel.send page_job + break unless @queue.exists? job end end @@ -98,6 +99,9 @@ module MangaDex page_jobs = [] of PageJob chapter.pages.size.times do page_job = channel.receive + + break unless @queue.exists? job + Logger.debug "[#{page_job.success ? "success" : "failed"}] " \ "#{page_job.url}" page_jobs << page_job @@ -110,6 +114,13 @@ module MangaDex Logger.error msg end end + + unless @queue.exists? job + Logger.debug "Download cancelled" + @downloading = false + next + end + fail_count = page_jobs.count { |j| !j.success } Logger.debug "Download completed. " \ "#{fail_count}/#{page_jobs.size} failed" diff --git a/src/mango.cr b/src/mango.cr index e2bd119..933032b 100644 --- a/src/mango.cr +++ b/src/mango.cr @@ -7,7 +7,7 @@ require "option_parser" require "clim" require "./plugin/*" -MANGO_VERSION = "0.16.0" +MANGO_VERSION = "0.17.0" # From http://www.network-science.de/ascii/ BANNER = %{ diff --git a/src/plugin/downloader.cr b/src/plugin/downloader.cr index 31d066d..054698e 100644 --- a/src/plugin/downloader.cr +++ b/src/plugin/downloader.cr @@ -53,7 +53,7 @@ class Plugin end zip_path = File.join manga_dir, "#{chapter_title}.cbz.part" - writer = Zip::Writer.new zip_path + writer = Compress::Zip::Writer.new zip_path rescue e @queue.set_status Queue::JobStatus::Error, job unless e.message.nil? @@ -66,6 +66,8 @@ class Plugin fail_count = 0 while page = plugin.next_page + break unless @queue.exists? job + fn = process_filename page["filename"].as_s url = page["url"].as_s headers = HTTP::Headers.new @@ -109,6 +111,12 @@ class Plugin end end + unless @queue.exists? job + Logger.debug "Download cancelled" + @downloading = false + return + end + Logger.debug "Download completed. #{fail_count}/#{pages} failed" writer.close filename = File.join File.dirname(zip_path), File.basename(zip_path, diff --git a/src/queue.cr b/src/queue.cr index 0281b61..c9f805c 100644 --- a/src/queue.cr +++ b/src/queue.cr @@ -196,6 +196,21 @@ class Queue self.delete job.id end + def exists?(id : String) + res = false + MainFiber.run do + DB.open "sqlite3://#{@path}" do |db| + res = db.query_one "select count(*) from queue where id = (?)", id, + as: Bool + end + end + res + end + + def exists?(job : Job) + self.exists? job.id + end + def delete_status(status : JobStatus) MainFiber.run do DB.open "sqlite3://#{@path}" do |db| diff --git a/src/routes/api.cr b/src/routes/api.cr index ce2caf5..ad4f497 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -1,9 +1,171 @@ require "./router" require "../mangadex/*" require "../upload" +require "koa" class APIRouter < Router + @@api_json : String? + + API_VERSION = "0.1.0" + + macro s(fields) + { + {% for field in fields %} + {{field}} => "string", + {% end %} + } + end + def initialize + Koa.init "Mango API", version: API_VERSION, desc: <<-MD + # A Word of Caution + + This API was designed for internal use only, and the design doesn't comply with the resources convention of a RESTful API. Because of this, most of the API endpoints listed here will soon be updated and removed in future versions of Mango, so use them at your own risk! + + # Authentication + + All endpoints require authentication. After logging in, your session ID would be stored as a cookie named `mango-sessid-#{Config.current.port}`, which can be used to authenticate the API access. Note that all admin API endpoints (`/api/admin/...`) require the logged-in user to have admin access. + + # Terminologies + + - Entry: An entry is a `cbz`/`cbr` file in your library. Depending on how you organize your manga collection, an entry can contain a chapter, a volume or even an entire manga. + - Title: A title contains a list of entries and optionally some sub-titles. For example, you can have a title to store a manga, and it contains a list of sub-titles representing the volumes in the manga. Each sub-title would then contain a list of entries representing the chapters in the volume. + - Library: The library is a collection of top-level titles, and it does not contain entries (though the titles do). A Mango instance can only have one library. + MD + + Koa.cookie_auth "cookie", "mango-sessid-#{Config.current.port}" + Koa.global_tag "admin", desc: <<-MD + These are the admin endpoints only accessible for users with admin access. A non-admin user will get HTTP 403 when calling the endpoints. + MD + + Koa.binary "binary", desc: "A binary file" + Koa.array "entryAry", "$entry", desc: "An array of entries" + Koa.array "titleAry", "$title", desc: "An array of titles" + Koa.array "strAry", "string", desc: "An array of strings" + + entry_schema = { + "pages" => "integer", + "mtime" => "integer", + }.merge s %w(zip_path title size id title_id display_name cover_url) + Koa.object "entry", entry_schema, desc: "An entry in a book" + + title_schema = { + "mtime" => "integer", + "entries" => "$entryAry", + "titles" => "$titleAry", + "parents" => "$strAry", + }.merge s %w(dir title id display_name cover_url) + Koa.object "title", title_schema, + desc: "A manga title (a collection of entries and sub-titles)" + + Koa.object "library", { + "dir" => "string", + "titles" => "$titleAry", + }, desc: "A library containing a list of top-level titles" + + Koa.object "scanResult", { + "milliseconds" => "integer", + "titles" => "integer", + } + + Koa.object "progressResult", { + "progress" => "number", + } + + Koa.object "result", { + "success" => "boolean", + "error" => "string?", + } + + mc_schema = { + "groups" => "object", + }.merge s %w(id title volume chapter language full_title time manga_title manga_id) + Koa.object "mangadexChapter", mc_schema, desc: "A MangaDex chapter" + + Koa.array "chapterAry", "$mangadexChapter" + + mm_schema = { + "chapers" => "$chapterAry", + }.merge s %w(id title description author artist cover_url) + Koa.object "mangadexManga", mm_schema, desc: "A MangaDex manga" + + Koa.object "chaptersObj", { + "chapters" => "$chapterAry", + } + + Koa.object "successFailCount", { + "success" => "integer", + "fail" => "integer", + } + + job_schema = { + "pages" => "integer", + "success_count" => "integer", + "fail_count" => "integer", + "time" => "integer", + }.merge s %w(id manga_id title manga_title status_message status) + Koa.object "job", job_schema, desc: "A download job in the queue" + + Koa.array "jobAry", "$job" + + Koa.object "jobs", { + "success" => "boolean", + "paused" => "boolean", + "jobs" => "$jobAry", + } + + Koa.object "binaryUpload", { + "file" => "$binary", + } + + Koa.object "pluginListBody", { + "plugin" => "string", + "query" => "string", + } + + Koa.object "pluginChapter", { + "id" => "string", + "title" => "string", + } + + Koa.array "pluginChapterAry", "$pluginChapter" + + Koa.object "pluginList", { + "success" => "boolean", + "chapters" => "$pluginChapterAry?", + "title" => "string?", + "error" => "string?", + } + + Koa.object "pluginDownload", { + "plugin" => "string", + "title" => "string", + "chapters" => "$pluginChapterAry", + } + + Koa.object "dimension", { + "width" => "integer", + "height" => "integer", + } + + Koa.array "dimensionAry", "$dimension" + + Koa.object "dimensionResult", { + "success" => "boolean", + "dimensions" => "$dimensionAry?", + "error" => "string?", + } + + Koa.object "ids", { + "ids" => "$strAry", + } + + Koa.describe "Returns a page in a manga entry" + Koa.path "tid", desc: "Title ID" + Koa.path "eid", desc: "Entry ID" + Koa.path "page", type: "integer", desc: "The page number to return (starts from 1)" + Koa.response 200, ref: "$binary", media_type: "image/*" + Koa.response 500, "Page not found or not readable" get "/api/page/:tid/:eid/:page" do |env| begin tid = env.params.url["tid"] @@ -26,6 +188,11 @@ class APIRouter < Router end end + Koa.describe "Returns the cover image of a manga entry" + Koa.path "tid", desc: "Title ID" + Koa.path "eid", desc: "Entry ID" + Koa.response 200, ref: "$binary", media_type: "image/*" + Koa.response 500, "Page not found or not readable" get "/api/cover/:tid/:eid" do |env| begin tid = env.params.url["tid"] @@ -48,6 +215,10 @@ class APIRouter < Router end end + Koa.describe "Returns the book with title `tid`" + Koa.path "tid", desc: "Title ID" + Koa.response 200, ref: "$title" + Koa.response 404, "Title not found" get "/api/book/:tid" do |env| begin tid = env.params.url["tid"] @@ -57,15 +228,20 @@ class APIRouter < Router send_json env, title.to_json rescue e @context.error e - env.response.status_code = 500 + env.response.status_code = 404 e.message end end - get "/api/book" do |env| + Koa.describe "Returns the entire library with all titles and entries" + Koa.response 200, ref: "$library" + get "/api/library" do |env| send_json env, @context.library.to_json end + Koa.describe "Triggers a library scan" + Koa.tag "admin" + Koa.response 200, ref: "$scanResult" post "/api/admin/scan" do |env| start = Time.utc @context.library.scan @@ -76,19 +252,27 @@ class APIRouter < Router }.to_json end + Koa.describe "Returns the thumbnail generation progress between 0 and 1" + Koa.tag "admin" + Koa.response 200, ref: "$progressResult" get "/api/admin/thumbnail_progress" do |env| send_json env, { "progress" => Library.default.thumbnail_generation_progress, }.to_json end + Koa.describe "Triggers a thumbnail generation" + Koa.tag "admin" post "/api/admin/generate_thumbnails" do |env| spawn do Library.default.generate_thumbnails end end - post "/api/admin/user/delete/:username" do |env| + Koa.describe "Deletes a user with `username`" + Koa.tag "admin" + Koa.response 200, ref: "$result" + delete "/api/admin/user/delete/:username" do |env| begin username = env.params.url["username"] @context.storage.delete_user username @@ -103,13 +287,24 @@ class APIRouter < Router end end - post "/api/progress/:title/:page" do |env| + Koa.describe "Updates the reading progress of an entry or the whole title for the current user", <<-MD + When `eid` is provided, sets the reading progress of the entry to `page`. + + When `eid` is omitted, updates the progress of the entire title. Specifically: + + - if `page` is 0, marks the entire title as unread + - otherwise, marks the entire title as read + MD + Koa.path "tid", desc: "Title ID" + Koa.query "eid", desc: "Entry ID", required: false + Koa.path "page", desc: "The new page number indicating the progress" + Koa.response 200, ref: "$result" + put "/api/progress/:tid/:page" do |env| begin username = get_username env - title = (@context.library.get_title env.params.url["title"]) - .not_nil! + title = (@context.library.get_title env.params.url["tid"]).not_nil! page = env.params.url["page"].to_i - entry_id = env.params.query["entry"]? + entry_id = env.params.query["eid"]? if !entry_id.nil? entry = title.get_entry(entry_id).not_nil! @@ -131,10 +326,15 @@ class APIRouter < Router end end - post "/api/bulk-progress/:action/:title" do |env| + Koa.describe "Updates the reading progress of multiple entries in a title" + Koa.path "action", desc: "The action to perform. Can be either `read` or `unread`" + Koa.path "tid", desc: "Title ID" + Koa.body ref: "$ids", desc: "An array of entry IDs" + Koa.response 200, ref: "$result" + put "/api/bulk_progress/:action/:tid" do |env| begin username = get_username env - title = (@context.library.get_title env.params.url["title"]).not_nil! + title = (@context.library.get_title env.params.url["tid"]).not_nil! action = env.params.url["action"] ids = env.params.json["ids"].as(Array).map &.as_s @@ -153,12 +353,20 @@ class APIRouter < Router end end - post "/api/admin/display_name/:title/:name" do |env| + Koa.describe "Sets the display name of a title or an entry", <<-MD + When `eid` is provided, apply the display name to the entry. Otherwise, apply the display name to the title identified by `tid`. + MD + Koa.tag "admin" + Koa.path "tid", desc: "Title ID" + Koa.query "eid", desc: "Entry ID", required: false + Koa.path "name", desc: "The new display name" + Koa.response 200, ref: "$result" + put "/api/admin/display_name/:tid/:name" do |env| begin - title = (@context.library.get_title env.params.url["title"]) + title = (@context.library.get_title env.params.url["tid"]) .not_nil! name = env.params.url["name"] - entry = env.params.query["entry"]? + entry = env.params.query["eid"]? if entry.nil? title.set_display_name name else @@ -176,6 +384,12 @@ class APIRouter < Router end end + Koa.describe "Returns a MangaDex manga identified by `id`", <<-MD + On error, returns a JSON that contains the error message in the `error` field. + MD + Koa.tag "admin" + Koa.path "id", desc: "A MangaDex manga ID" + Koa.response 200, ref: "$mangadexManga" get "/api/admin/mangadex/manga/:id" do |env| begin id = env.params.url["id"] @@ -188,6 +402,12 @@ class APIRouter < Router end end + Koa.describe "Adds a list of MangaDex chapters to the download queue", <<-MD + On error, returns a JSON that contains the error message in the `error` field. + MD + Koa.tag "admin" + Koa.body ref: "$chaptersObj" + Koa.response 200, ref: "$successFailCount" post "/api/admin/mangadex/download" do |env| begin chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h } @@ -212,6 +432,23 @@ class APIRouter < Router end end + ws "/api/admin/mangadex/queue" do |socket, env| + interval_raw = env.params.query["interval"]? + interval = (interval_raw.to_i? if interval_raw) || 5 + loop do + socket.send({ + "jobs" => @context.queue.get_all, + "paused" => @context.queue.paused?, + }.to_json) + sleep interval.seconds + end + end + + Koa.describe "Returns the current download queue", <<-MD + On error, returns a JSON that contains the error message in the `error` field. + MD + Koa.tag "admin" + Koa.response 200, ref: "$jobs" get "/api/admin/mangadex/queue" do |env| begin jobs = @context.queue.get_all @@ -228,6 +465,19 @@ class APIRouter < Router end end + Koa.describe "Perform an action on a download job or all jobs in the queue", <<-MD + The `action` parameter can be `delete`, `retry`, `pause` or `resume`. + + When `action` is `pause` or `resume`, pauses or resumes the download queue, respectively. + + When `action` is set to `delete`, the behavior depends on `id`. If `id` is provided, deletes the specific job identified by the ID. Otherwise, deletes all **completed** jobs in the queue. + + When `action` is set to `retry`, the behavior depends on `id`. If `id` is provided, restarts the job identified by the ID. Otherwise, retries all jobs in the `Error` or `MissingPages` status in the queue. + MD + Koa.tag "admin" + Koa.path "action", desc: "The action to perform. It should be one of the followins: `delete`, `retry`, `pause` and `resume`." + Koa.query "id", required: false, desc: "A job ID" + Koa.response 200, ref: "$result" post "/api/admin/mangadex/queue/:action" do |env| begin action = env.params.url["action"] @@ -262,6 +512,22 @@ class APIRouter < Router end end + Koa.describe "Uploads a file to the server", <<-MD + Currently the only supported value for the `target` parameter is `cover`. + + ### Cover + + Uploads a cover image for a title or an entry. + + Query parameters: + - `tid`: A title ID + - `eid`: (Optional) An entry ID + + When `eid` is omitted, the new cover image will be applied to the title. Otherwise, applies the image to the specified entry. + MD + Koa.tag "admin" + Koa.body type: "multipart/form-data", ref: "$binaryUpload" + Koa.response 200, ref: "$result" post "/api/admin/upload/:target" do |env| begin target = env.params.url["target"] @@ -276,8 +542,8 @@ class APIRouter < Router case target when "cover" - title_id = env.params.query["title"] - entry_id = env.params.query["entry"]? + title_id = env.params.query["tid"] + entry_id = env.params.query["eid"]? title = @context.library.get_title(title_id).not_nil! unless SUPPORTED_IMG_TYPES.includes? \ @@ -316,10 +582,14 @@ class APIRouter < Router end end - post "/api/admin/plugin/list" do |env| + Koa.describe "Lists the chapters in a title from a plugin" + Koa.tag "admin" + Koa.body ref: "$pluginListBody" + Koa.response 200, ref: "$pluginList" + get "/api/admin/plugin/list" do |env| begin - query = env.params.json["query"].as String - plugin = Plugin.new env.params.json["plugin"].as String + query = env.params.query["query"].as String + plugin = Plugin.new env.params.query["plugin"].as String json = plugin.list_chapters query chapters = json["chapters"] @@ -338,6 +608,10 @@ class APIRouter < Router end end + Koa.describe "Adds a list of chapters from a plugin to the download queue" + Koa.tag "admin" + Koa.body ref: "$pluginDownload" + Koa.response 200, ref: "$successFailCount" post "/api/admin/plugin/download" do |env| begin plugin = Plugin.new env.params.json["plugin"].as String @@ -367,6 +641,10 @@ class APIRouter < Router end end + Koa.describe "Returns the image dimensions of all pages in an entry" + Koa.path "tid", desc: "A title ID" + Koa.path "eid", desc: "An entry ID" + Koa.response 200, ref: "$dimensionResult" get "/api/dimensions/:tid/:eid" do |env| begin tid = env.params.url["tid"] @@ -389,5 +667,33 @@ class APIRouter < Router }.to_json end end + + Koa.describe "Downloads an entry" + Koa.path "tid", desc: "A title ID" + Koa.path "eid", desc: "An entry ID" + Koa.response 200, ref: "$binary" + Koa.response 404, "Entry not found" + get "/api/download/:tid/:eid" do |env| + begin + title = (@context.library.get_title env.params.url["tid"]).not_nil! + entry = (title.get_entry env.params.url["eid"]).not_nil! + + send_attachment env, entry.zip_path + rescue e + @context.error e + env.response.status_code = 404 + end + end + + doc = Koa.generate + @@api_json = doc.to_json if doc + + get "/openapi.json" do |env| + if @@api_json + send_json env, @@api_json + else + env.response.status_code = 404 + end + end end end diff --git a/src/routes/main.cr b/src/routes/main.cr index 4c55472..cb919cf 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -113,5 +113,9 @@ class MainRouter < Router env.response.status_code = 500 end end + + get "/api" do |env| + render "src/views/api.html.ecr" + end end end diff --git a/src/routes/opds.cr b/src/routes/opds.cr index 567931e..eafbcb9 100644 --- a/src/routes/opds.cr +++ b/src/routes/opds.cr @@ -16,17 +16,5 @@ class OPDSRouter < Router env.response.status_code = 404 end end - - get "/opds/download/:title/:entry" do |env| - begin - title = (@context.library.get_title env.params.url["title"]).not_nil! - entry = (title.get_entry env.params.url["entry"]).not_nil! - - send_attachment env, entry.zip_path - rescue e - @context.error e - env.response.status_code = 404 - end - end end end diff --git a/src/util/proxy.cr b/src/util/proxy.cr index 2325419..88316be 100644 --- a/src/util/proxy.cr +++ b/src/util/proxy.cr @@ -35,7 +35,8 @@ private def env_to_proxy(key : String) : HTTP::Proxy::Client? begin uri = URI.parse val - HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil! + HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!, + username: uri.user, password: uri.password rescue nil end diff --git a/src/util/util.cr b/src/util/util.cr index 9019720..3fc1b2d 100644 --- a/src/util/util.cr +++ b/src/util/util.cr @@ -61,3 +61,9 @@ class String self.chars.all? { |c| c.alphanumeric? || c == '_' } end end + +def env_is_true?(key : String) : Bool + val = ENV[key.upcase]? || ENV[key.downcase]? + return false unless val + val.downcase.in? "1", "true" +end diff --git a/src/util/web.cr b/src/util/web.cr index 1bd38ec..c384099 100644 --- a/src/util/web.cr +++ b/src/util/web.cr @@ -85,9 +85,14 @@ end module HTTP class Client private def self.exec(uri : URI, tls : TLSContext = nil) - Logger.debug "Setting read timeout" previous_def uri, tls do |client, path| + if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION" + Logger.debug "Disabling SSL verification" + client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE + end + Logger.debug "Setting read timeout" client.read_timeout = Config.current.download_timeout_seconds.seconds + Logger.debug "Requesting #{uri}" yield client, path end end diff --git a/src/views/api.html.ecr b/src/views/api.html.ecr new file mode 100644 index 0000000..5bfdfa4 --- /dev/null +++ b/src/views/api.html.ecr @@ -0,0 +1,14 @@ + + + + + + Mango API Documentation + + + + + + + + diff --git a/src/views/components/head.html.ecr b/src/views/components/head.html.ecr index d2421d0..c65ddea 100644 --- a/src/views/components/head.html.ecr +++ b/src/views/components/head.html.ecr @@ -14,5 +14,5 @@ - + diff --git a/src/views/download-manager.html.ecr b/src/views/download-manager.html.ecr index ec8618a..db919c1 100644 --- a/src/views/download-manager.html.ecr +++ b/src/views/download-manager.html.ecr @@ -1,32 +1,68 @@ -
-
- - - - -
-
- +
+
+ + + +
+ + + + + + + + + + + + + + + +
ChapterMangaProgressTimeStatusPluginActions
- - - - - - - - - - - - -
ChapterMangaProgressTimeStatusPluginActions
<% content_for "script" do %> - diff --git a/src/views/opds/title.xml.ecr b/src/views/opds/title.xml.ecr index 392978c..b159687 100644 --- a/src/views/opds/title.xml.ecr +++ b/src/views/opds/title.xml.ecr @@ -29,7 +29,7 @@ - + diff --git a/src/views/reader.html.ecr b/src/views/reader.html.ecr index 540fd0c..9136071 100644 --- a/src/views/reader.html.ecr +++ b/src/views/reader.html.ecr @@ -39,7 +39,7 @@ :width="item.width" :height="item.height" :id="item.id" - @click="showControl($event)" + :onclick="`showControl('${item.id}')`" /> <%- if next_entry_url -%> @@ -55,7 +55,7 @@ 'uk-align-center': true, 'uk-animation-slide-left': flipAnimation === 'left', 'uk-animation-slide-right': flipAnimation === 'right' - }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="` + }" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" :onclick="`showControl('${curItem.id}')`" :style="` width:${mode === 'width' ? '100vw' : 'auto'}; height:${mode === 'height' ? '100vh' : 'auto'}; margin-bottom:0;