├── .all-contributorsrc ├── .ameba.yml ├── .dockerignore ├── .github └── workflows │ └── docker-mango.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── dist └── js │ └── redoc.standalone.js ├── docker-compose.yml ├── env.example ├── gulpfile.js ├── migration ├── foreign_keys.6.cr ├── ids.2.cr ├── ids_signature.7.cr ├── md_account.11.cr ├── relative_path.8.cr ├── relative_path_fix.10.cr ├── sort_title.12.cr ├── tags.4.cr ├── thumbnails.3.cr ├── titles.5.cr ├── unavailable.9.cr └── users.1.cr ├── package.json ├── public ├── css │ ├── mango.less │ ├── select2.min.css │ ├── tags.less │ └── uikit.less ├── favicon.ico ├── img │ ├── banner-paddings.png │ ├── banner.png │ ├── icons │ │ ├── icon.png │ │ ├── icon_x192.png │ │ ├── icon_x512.png │ │ └── icon_x96.png │ └── loading.gif ├── js │ ├── admin.js │ ├── alert.js │ ├── alpine-ie11.min.js │ ├── alpine.min.js │ ├── common.js │ ├── dotdotdot.js │ ├── dots.js │ ├── download-manager.js │ ├── jquery-ui.min.js │ ├── jquery.inview.min.js │ ├── jquery.min.js │ ├── missing-items.js │ ├── moment.min.js │ ├── plugin-download.js │ ├── reader.js │ ├── search.js │ ├── select2.min.js │ ├── sort-items.js │ ├── subscription-manager.js │ ├── subscription.js │ ├── title.js │ ├── uikit-icons.min.js │ ├── uikit.min.js │ ├── user-edit.js │ └── user.js ├── manifest.json └── robots.txt ├── shard.lock ├── shard.yml ├── spec ├── asset │ ├── plugins │ │ └── plugin │ │ │ ├── index.js │ │ │ └── info.json │ └── test-config.yml ├── config_spec.cr ├── plugin_spec.cr ├── rename_spec.cr ├── spec_helper.cr ├── storage_spec.cr └── util_spec.cr └── src ├── archive.cr ├── config.cr ├── handlers ├── auth_handler.cr ├── cors_handler.cr ├── log_handler.cr ├── static_handler.cr └── upload_handler.cr ├── library ├── archive_entry.cr ├── cache.cr ├── dir_entry.cr ├── entry.cr ├── library.cr ├── title.cr └── types.cr ├── logger.cr ├── main_fiber.cr ├── mango.cr ├── plugin ├── downloader.cr ├── plugin.cr ├── subscriptions.cr └── updater.cr ├── queue.cr ├── rename.cr ├── routes ├── admin.cr ├── api.cr ├── main.cr ├── opds.cr └── reader.cr ├── server.cr ├── storage.cr ├── upload.cr ├── util ├── chapter_sort.cr ├── numeric_sort.cr ├── proxy.cr ├── signature.cr ├── util.cr ├── validation.cr └── web.cr └── views ├── admin.html.ecr ├── api.html.ecr ├── components ├── card.html.ecr ├── dots.html.ecr ├── entry-modal.html.ecr ├── head.html.ecr ├── jquery-ui.html.ecr ├── moment.html.ecr ├── sort-form.html.ecr └── uikit.html.ecr ├── download-manager.html.ecr ├── home.html.ecr ├── layout.html.ecr ├── library.html.ecr ├── login.html.ecr ├── message.html.ecr ├── missing-items.html.ecr ├── opds ├── index.xml.ecr └── title.xml.ecr ├── plugin-download.html.ecr ├── reader-error.html.ecr ├── reader.html.ecr ├── subscription-manager.html.ecr ├── tag.html.ecr ├── tags.html.ecr ├── title.html.ecr ├── user-edit.html.ecr └── user.html.ecr /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "Mango", 3 | "projectOwner": "hkalexling", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "hkalexling", 15 | "name": "Alex Ling", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/7845831?v=4", 17 | "profile": "https://github.com/hkalexling/", 18 | "contributions": [ 19 | "code", 20 | "doc", 21 | "infra" 22 | ] 23 | }, 24 | { 25 | "login": "jaredlt", 26 | "name": "jaredlt", 27 | "avatar_url": "https://avatars1.githubusercontent.com/u/8590311?v=4", 28 | "profile": "https://github.com/jaredlt", 29 | "contributions": [ 30 | "code", 31 | "ideas", 32 | "design" 33 | ] 34 | }, 35 | { 36 | "login": "shincurry", 37 | "name": "ココロ", 38 | "avatar_url": "https://avatars1.githubusercontent.com/u/4946624?v=4", 39 | "profile": "https://windisco.com/", 40 | "contributions": [ 41 | "infra" 42 | ] 43 | }, 44 | { 45 | "login": "noirscape", 46 | "name": "Valentijn", 47 | "avatar_url": "https://avatars0.githubusercontent.com/u/13433513?v=4", 48 | "profile": "https://catgirlsin.space/", 49 | "contributions": [ 50 | "infra" 51 | ] 52 | }, 53 | { 54 | "login": "flying-sausages", 55 | "name": "flying-sausages", 56 | "avatar_url": "https://avatars1.githubusercontent.com/u/23618693?v=4", 57 | "profile": "https://github.com/flying-sausages", 58 | "contributions": [ 59 | "doc", 60 | "ideas" 61 | ] 62 | }, 63 | { 64 | "login": "XavierSchiller", 65 | "name": "Xavier", 66 | "avatar_url": "https://avatars1.githubusercontent.com/u/22575255?v=4", 67 | "profile": "https://github.com/XavierSchiller", 68 | "contributions": [ 69 | "infra" 70 | ] 71 | }, 72 | { 73 | "login": "WROIATE", 74 | "name": "Jarao", 75 | "avatar_url": "https://avatars3.githubusercontent.com/u/44677306?v=4", 76 | "profile": "https://github.com/WROIATE", 77 | "contributions": [ 78 | "infra" 79 | ] 80 | }, 81 | { 82 | "login": "Leeingnyo", 83 | "name": "이인용", 84 | "avatar_url": "https://avatars0.githubusercontent.com/u/6760150?v=4", 85 | "profile": "https://github.com/Leeingnyo", 86 | "contributions": [ 87 | "code" 88 | ] 89 | }, 90 | { 91 | "login": "h45h74x", 92 | "name": "Simon", 93 | "avatar_url": "https://avatars1.githubusercontent.com/u/27204033?v=4", 94 | "profile": "http://h45h74x.eu.org", 95 | "contributions": [ 96 | "code" 97 | ] 98 | }, 99 | { 100 | "login": "davidkna", 101 | "name": "David Knaack", 102 | "avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4", 103 | "profile": "https://github.com/davidkna", 104 | "contributions": [ 105 | "infra" 106 | ] 107 | }, 108 | { 109 | "login": "lincolnthedev", 110 | "name": "i use arch btw", 111 | "avatar_url": "https://avatars.githubusercontent.com/u/41193328?v=4", 112 | "profile": "https://lncn.dev", 113 | "contributions": [ 114 | "infra" 115 | ] 116 | }, 117 | { 118 | "login": "BradleyDS2", 119 | "name": "BradleyDS2", 120 | "avatar_url": "https://avatars.githubusercontent.com/u/2174921?v=4", 121 | "profile": "https://github.com/BradleyDS2", 122 | "contributions": [ 123 | "doc" 124 | ] 125 | }, 126 | { 127 | "login": "nduja", 128 | "name": "Robbo", 129 | "avatar_url": "https://avatars.githubusercontent.com/u/69299134?v=4", 130 | "profile": "https://github.com/nduja", 131 | "contributions": [ 132 | "code" 133 | ] 134 | } 135 | ], 136 | "contributorsPerLine": 7, 137 | "skipCi": true 138 | } 139 | -------------------------------------------------------------------------------- /.ameba.yml: -------------------------------------------------------------------------------- 1 | Lint/UselessAssign: 2 | Excluded: 3 | - src/routes/* 4 | - src/server.cr 5 | Lint/UnusedArgument: 6 | Excluded: 7 | - src/routes/* 8 | Metrics/CyclomaticComplexity: 9 | Enabled: false 10 | Layout/LineLength: 11 | Enabled: true 12 | MaxLength: 80 13 | Excluded: 14 | - src/routes/api.cr 15 | - spec/plugin_spec.cr 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | Dockerfile 4 | Dockerfile.arm32v7 5 | Dockerfile.arm64v8 6 | README.md 7 | .all-contributorsrc 8 | env.example 9 | .github/ 10 | -------------------------------------------------------------------------------- /.github/workflows/docker-mango.yml: -------------------------------------------------------------------------------- 1 | name: Docker_Image_Mango 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | APP_NAME: mango_cn 11 | DOCKERHUB_REPO: dezhao/mango_cn 12 | 13 | jobs: 14 | main: 15 | # 在 Ubuntu 上运行 16 | runs-on: ubuntu-latest 17 | steps: 18 | # git checkout 代码 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | # 设置 QEMU, 后面 docker buildx 依赖此. 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v1 24 | # 设置 Docker buildx, 方便构建 Multi platform 镜像 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v1 27 | # 登录 docker hub 28 | - name: Login to DockerHub 29 | uses: docker/login-action@v1 30 | with: 31 | # GitHub Repo => Settings => Secrets 增加 docker hub 登录密钥信息 32 | # DOCKERHUB_USERNAME 是 docker hub 账号名. 33 | # DOCKERHUB_TOKEN: docker hub => Account Setting => Security 创建. 34 | username: ${{ secrets.DOCKER_USERNAME }} 35 | password: ${{ secrets.DOCKER_PASSWORD }} 36 | # 通过 git 命令获取当前 tag 信息, 存入环境变量 APP_VERSION 37 | - name: Generate App Version 38 | run: echo APP_VERSION=`git describe --tags --always` >> $GITHUB_ENV 39 | # 构建 Docker 并推送到 Docker hub 40 | - name: Build and push 41 | id: docker_build 42 | uses: docker/build-push-action@v2 43 | with: 44 | # 是否 docker push 45 | push: true 46 | # 生成多平台镜像, see https://github.com/docker-library/bashbrew/blob/v0.1.1/architecture/oci-platform.go 47 | platforms: | 48 | linux/amd64 49 | # docker build arg, 注入 APP_NAME/APP_VERSION 50 | build-args: | 51 | APP_NAME=${{ env.APP_NAME }} 52 | APP_VERSION=${{ github.ref }} 53 | # 生成两个 docker tag: ${APP_VERSION} 和 latest 54 | tags: | 55 | ${{ env.DOCKERHUB_REPO }}:latest 56 | ${{ env.DOCKERHUB_REPO }}:0.27.0 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | node_modules 7 | yarn.lock 8 | dist 9 | mango 10 | .env 11 | *.md 12 | public/css/uikit.css 13 | public/img/*.svg 14 | public/js/*.min.js 15 | public/css/*.css 16 | public/webfonts 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM crystallang/crystal:1.0.0-alpine AS builder 2 | 3 | WORKDIR /Mango 4 | 5 | COPY . . 6 | RUN apk add --no-cache yarn yaml-static sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static libjpeg-turbo-dev libpng-dev tiff-dev 7 | RUN make static || make static 8 | 9 | FROM library/alpine 10 | 11 | WORKDIR / 12 | 13 | COPY --from=builder /Mango/mango . 14 | 15 | CMD ["./mango"] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Alex Ling 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PREFIX ?= /usr/local 2 | INSTALL_DIR=$(PREFIX)/bin 3 | 4 | all: uglify | build 5 | 6 | uglify: 7 | yarn 8 | yarn uglify 9 | 10 | setup: libs 11 | yarn 12 | yarn gulp dev 13 | 14 | build: libs 15 | crystal build src/mango.cr --release --progress --error-trace 16 | 17 | static: uglify | libs 18 | crystal build src/mango.cr --release --progress --static --error-trace 19 | 20 | libs: 21 | shards install --production 22 | 23 | run: 24 | crystal run src/mango.cr --error-trace 25 | 26 | test: 27 | crystal spec 28 | 29 | check: 30 | crystal tool format --check 31 | ./bin/ameba 32 | 33 | arm32v7: 34 | crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7 35 | 36 | arm64v8: 37 | crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='aarch64-linux-gnu' -o mango-arm64v8 38 | 39 | install: 40 | cp mango $(INSTALL_DIR)/mango 41 | 42 | uninstall: 43 | rm -f $(INSTALL_DIR)/mango 44 | 45 | cleandist: 46 | rm -rf dist 47 | rm -f yarn.lock 48 | rm -rf node_modules 49 | 50 | clean: 51 | rm -f mango 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner-paddings](https://user-images.githubusercontent.com/38988286/199423262-68f03906-5444-499c-8616-aa675039544e.png) 2 | 3 | 4 | # Mango 5 | 6 | This repo is a fork of getmango/Mango , those things i've done was translated this repo into chinese .and fix the access permissions of the manga folder on synology nas. also packed a synology spk package ,you can direct install it through package center . 7 | 8 | 9 | 原仓库地址:https://github.com/getmango/Mango 10 | 该版本为Mango的汉化版,并且将原版中从cnd中获取js脚本改到本地,以方便大陆用户使用,修复docker版在群晖上的权限问题,并打包了群晖spk套件版。 11 | 12 | docker版需要将漫画文件夹挂载到/root/mango/library 13 | 14 | [](https://hub.docker.com/r/dezhao/mango_cn/) 15 | [![IC](https://github.com/uparrows/mango_cn/actions/workflows/docker-mango.yml/badge.svg?branch=main)](https://github.com/uparrows/mango_cn/actions/workflows/docker-mango.yml) 16 | 17 | 芒果是一个自托管的漫画服务器和阅读器。它的功能包括 18 | 19 | 多用户支持 20 | OPDS 支持 21 | 暗/亮模式开关 22 | 支持的格式:.cbz.zip.cbr.rar 23 | 支持库中的嵌套文件夹 24 | 自动存储阅读进度 25 | 缩略图生成 26 | 支持从第三方网站下载插件 27 | 网络阅读器响应迅速,在移动设备上运行良好,因此不需要移动应用程序 28 | 所有静态文件都嵌入在二进制文件中,因此部署过程简单无痛 29 | 请查看维基以获取更多信息。 30 | 31 | ![01](https://user-images.githubusercontent.com/38988286/199410588-535b4fa4-4db8-4a33-919f-7a321a93628b.jpg) 32 | 33 | ![02](https://user-images.githubusercontent.com/38988286/199411029-3af1f388-c817-424a-a591-f42d0e8e4e5a.jpg) 34 | 35 | ![03](https://user-images.githubusercontent.com/38988286/199411040-5cb37266-aa00-47ca-9d68-4e34b24b0a5e.jpg) 36 | 37 | ![04](https://user-images.githubusercontent.com/38988286/199411046-6f9047d9-1c24-4be7-9c1c-b6ca0010f08a.jpg) 38 | 39 | ![04_5](https://user-images.githubusercontent.com/38988286/199411087-34d3eb7a-f408-4964-b2e3-91f6e215fbfc.jpg) 40 | 41 | ![05](https://user-images.githubusercontent.com/38988286/199411060-0e4120d3-aa38-4d99-9224-24cc91847bf9.jpg) 42 | 43 | 44 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | mango: 5 | container_name: mango 6 | build: 7 | context: . 8 | dockerfile: ./Dockerfile 9 | expose: 10 | - ${PORT} 11 | ports: 12 | - "${PORT}:9000" 13 | volumes: 14 | - ${MAIN_DIRECTORY_PATH}:/root/mango 15 | - ${CONFIG_DIRECTORY_PATH}:/root/.config/mango 16 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # Port that exposes the HTTP frontend 2 | PORT=9000 3 | 4 | # Path to the mango main directory 5 | # This directory holds the database and the library files 6 | MAIN_DIRECTORY_PATH= 7 | 8 | # Path to the mango config directory 9 | # This directory holds the mango configuration path 10 | CONFIG_DIRECTORY_PATH= 11 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const babel = require('gulp-babel'); 3 | const minify = require('gulp-babel-minify'); 4 | const minifyCss = require('gulp-minify-css'); 5 | const less = require('gulp-less'); 6 | 7 | gulp.task('copy-img', () => { 8 | return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg') 9 | .pipe(gulp.dest('public/img')); 10 | }); 11 | 12 | gulp.task('copy-font', () => { 13 | return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**') 14 | .pipe(gulp.dest('public/webfonts')); 15 | }); 16 | 17 | // Copy files from node_modules 18 | gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font')); 19 | 20 | // Compile less 21 | gulp.task('less', () => { 22 | return gulp.src([ 23 | 'public/css/mango.less', 24 | 'public/css/tags.less' 25 | ]) 26 | .pipe(less()) 27 | .pipe(gulp.dest('public/css')); 28 | }); 29 | 30 | // Transpile and minify JS files and output to dist 31 | gulp.task('babel', () => { 32 | return gulp.src(['public/js/*.js', '!public/js/*.min.js']) 33 | .pipe(babel({ 34 | presets: [ 35 | ['@babel/preset-env', { 36 | targets: '>0.25%, not dead, ios>=9' 37 | }] 38 | ], 39 | })) 40 | .pipe(minify({ 41 | removeConsole: true, 42 | builtIns: false 43 | })) 44 | .pipe(gulp.dest('dist/js')); 45 | }); 46 | 47 | // Minify CSS and output to dist 48 | gulp.task('minify-css', () => { 49 | return gulp.src('public/css/*.css') 50 | .pipe(minifyCss()) 51 | .pipe(gulp.dest('dist/css')); 52 | }); 53 | 54 | // Copy static files (includeing images) to dist 55 | gulp.task('copy-files', () => { 56 | return gulp.src([ 57 | 'public/*.*', 58 | 'public/img/**', 59 | 'public/webfonts/*', 60 | 'public/js/*.min.js' 61 | ], { 62 | base: 'public' 63 | }) 64 | .pipe(gulp.dest('dist')); 65 | }); 66 | 67 | // Set up the public folder for development 68 | gulp.task('dev', gulp.parallel('node-modules-copy', 'less')); 69 | 70 | // Set up the dist folder for deployment 71 | gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files')); 72 | 73 | // Default task 74 | gulp.task('default', gulp.series('dev', 'deploy')); 75 | -------------------------------------------------------------------------------- /migration/foreign_keys.6.cr: -------------------------------------------------------------------------------- 1 | class ForeignKeys < MG::Base 2 | def up : String 3 | <<-SQL 4 | -- add foreign key to tags 5 | ALTER TABLE tags RENAME TO tmp; 6 | 7 | CREATE TABLE tags ( 8 | id TEXT NOT NULL, 9 | tag TEXT NOT NULL, 10 | UNIQUE (id, tag), 11 | FOREIGN KEY (id) REFERENCES titles (id) 12 | ON UPDATE CASCADE 13 | ON DELETE CASCADE 14 | ); 15 | 16 | INSERT INTO tags 17 | SELECT * FROM tmp; 18 | 19 | DROP TABLE tmp; 20 | 21 | CREATE INDEX tags_id_idx ON tags (id); 22 | CREATE INDEX tags_tag_idx ON tags (tag); 23 | 24 | -- add foreign key to thumbnails 25 | ALTER TABLE thumbnails RENAME TO tmp; 26 | 27 | CREATE TABLE thumbnails ( 28 | id TEXT NOT NULL, 29 | data BLOB NOT NULL, 30 | filename TEXT NOT NULL, 31 | mime TEXT NOT NULL, 32 | size INTEGER NOT NULL, 33 | FOREIGN KEY (id) REFERENCES ids (id) 34 | ON UPDATE CASCADE 35 | ON DELETE CASCADE 36 | ); 37 | 38 | INSERT INTO thumbnails 39 | SELECT * FROM tmp; 40 | 41 | DROP TABLE tmp; 42 | 43 | CREATE UNIQUE INDEX tn_index ON thumbnails (id); 44 | SQL 45 | end 46 | 47 | def down : String 48 | <<-SQL 49 | -- remove foreign key from thumbnails 50 | ALTER TABLE thumbnails RENAME TO tmp; 51 | 52 | CREATE TABLE thumbnails ( 53 | id TEXT NOT NULL, 54 | data BLOB NOT NULL, 55 | filename TEXT NOT NULL, 56 | mime TEXT NOT NULL, 57 | size INTEGER NOT NULL 58 | ); 59 | 60 | INSERT INTO thumbnails 61 | SELECT * FROM tmp; 62 | 63 | DROP TABLE tmp; 64 | 65 | CREATE UNIQUE INDEX tn_index ON thumbnails (id); 66 | 67 | -- remove foreign key from tags 68 | ALTER TABLE tags RENAME TO tmp; 69 | 70 | CREATE TABLE tags ( 71 | id TEXT NOT NULL, 72 | tag TEXT NOT NULL, 73 | UNIQUE (id, tag) 74 | ); 75 | 76 | INSERT INTO tags 77 | SELECT * FROM tmp; 78 | 79 | DROP TABLE tmp; 80 | 81 | CREATE INDEX tags_id_idx ON tags (id); 82 | CREATE INDEX tags_tag_idx ON tags (tag); 83 | SQL 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /migration/ids.2.cr: -------------------------------------------------------------------------------- 1 | class CreateIds < MG::Base 2 | def up : String 3 | <<-SQL 4 | CREATE TABLE IF NOT EXISTS ids ( 5 | path TEXT NOT NULL, 6 | id TEXT NOT NULL, 7 | is_title INTEGER NOT NULL 8 | ); 9 | CREATE UNIQUE INDEX IF NOT EXISTS path_idx ON ids (path); 10 | CREATE UNIQUE INDEX IF NOT EXISTS id_idx ON ids (id); 11 | SQL 12 | end 13 | 14 | def down : String 15 | <<-SQL 16 | DROP TABLE ids; 17 | SQL 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /migration/ids_signature.7.cr: -------------------------------------------------------------------------------- 1 | class IDSignature < MG::Base 2 | def up : String 3 | <<-SQL 4 | ALTER TABLE ids ADD COLUMN signature TEXT; 5 | SQL 6 | end 7 | 8 | def down : String 9 | <<-SQL 10 | -- remove signature column from ids 11 | ALTER TABLE ids RENAME TO tmp; 12 | 13 | CREATE TABLE ids ( 14 | path TEXT NOT NULL, 15 | id TEXT NOT NULL 16 | ); 17 | 18 | INSERT INTO ids 19 | SELECT path, id 20 | FROM tmp; 21 | 22 | DROP TABLE tmp; 23 | 24 | -- recreate the indices 25 | CREATE UNIQUE INDEX path_idx ON ids (path); 26 | CREATE UNIQUE INDEX id_idx ON ids (id); 27 | 28 | -- recreate the foreign key constraint on thumbnails 29 | ALTER TABLE thumbnails RENAME TO tmp; 30 | 31 | CREATE TABLE thumbnails ( 32 | id TEXT NOT NULL, 33 | data BLOB NOT NULL, 34 | filename TEXT NOT NULL, 35 | mime TEXT NOT NULL, 36 | size INTEGER NOT NULL, 37 | FOREIGN KEY (id) REFERENCES ids (id) 38 | ON UPDATE CASCADE 39 | ON DELETE CASCADE 40 | ); 41 | 42 | INSERT INTO thumbnails 43 | SELECT * FROM tmp; 44 | 45 | DROP TABLE tmp; 46 | 47 | CREATE UNIQUE INDEX tn_index ON thumbnails (id); 48 | SQL 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /migration/md_account.11.cr: -------------------------------------------------------------------------------- 1 | class CreateMangaDexAccount < MG::Base 2 | def up : String 3 | <<-SQL 4 | CREATE TABLE md_account ( 5 | username TEXT NOT NULL PRIMARY KEY, 6 | token TEXT NOT NULL, 7 | expire INTEGER NOT NULL, 8 | FOREIGN KEY (username) REFERENCES users (username) 9 | ON UPDATE CASCADE 10 | ON DELETE CASCADE 11 | ); 12 | SQL 13 | end 14 | 15 | def down : String 16 | <<-SQL 17 | DROP TABLE md_account; 18 | SQL 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /migration/relative_path.8.cr: -------------------------------------------------------------------------------- 1 | class RelativePath < MG::Base 2 | def up : String 3 | base = Config.current.library_path 4 | # Escape single quotes in case the path contains them, and remove the 5 | # trailing slash (this is a mistake, fixed in DB version 10) 6 | base = base.gsub("'", "''").rstrip "/" 7 | 8 | <<-SQL 9 | -- update the path column in ids to relative paths 10 | UPDATE ids 11 | SET path = REPLACE(path, '#{base}', ''); 12 | 13 | -- update the path column in titles to relative paths 14 | UPDATE titles 15 | SET path = REPLACE(path, '#{base}', ''); 16 | SQL 17 | end 18 | 19 | def down : String 20 | base = Config.current.library_path 21 | base = base.gsub("'", "''").rstrip "/" 22 | 23 | <<-SQL 24 | -- update the path column in ids to absolute paths 25 | UPDATE ids 26 | SET path = '#{base}' || path; 27 | 28 | -- update the path column in titles to absolute paths 29 | UPDATE titles 30 | SET path = '#{base}' || path; 31 | SQL 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /migration/relative_path_fix.10.cr: -------------------------------------------------------------------------------- 1 | # In DB version 8, we replaced the absolute paths in DB with relative paths, 2 | # but we mistakenly left the starting slashes. This migration removes them. 3 | class RelativePathFix < MG::Base 4 | def up : String 5 | <<-SQL 6 | -- remove leading slashes from the paths in ids 7 | UPDATE ids 8 | SET path = SUBSTR(path, 2, LENGTH(path) - 1) 9 | WHERE path LIKE '/%'; 10 | 11 | -- remove leading slashes from the paths in titles 12 | UPDATE titles 13 | SET path = SUBSTR(path, 2, LENGTH(path) - 1) 14 | WHERE path LIKE '/%'; 15 | SQL 16 | end 17 | 18 | def down : String 19 | <<-SQL 20 | -- add leading slashes to paths in ids 21 | UPDATE ids 22 | SET path = '/' || path 23 | WHERE path NOT LIKE '/%'; 24 | 25 | -- add leading slashes to paths in titles 26 | UPDATE titles 27 | SET path = '/' || path 28 | WHERE path NOT LIKE '/%'; 29 | SQL 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /migration/sort_title.12.cr: -------------------------------------------------------------------------------- 1 | class SortTitle < MG::Base 2 | def up : String 3 | <<-SQL 4 | -- add sort_title column to ids and titles 5 | ALTER TABLE ids ADD COLUMN sort_title TEXT; 6 | ALTER TABLE titles ADD COLUMN sort_title TEXT; 7 | SQL 8 | end 9 | 10 | def down : String 11 | <<-SQL 12 | -- remove sort_title column from ids 13 | ALTER TABLE ids RENAME TO tmp; 14 | 15 | CREATE TABLE ids ( 16 | path TEXT NOT NULL, 17 | id TEXT NOT NULL, 18 | signature TEXT, 19 | unavailable INTEGER NOT NULL DEFAULT 0 20 | ); 21 | 22 | INSERT INTO ids 23 | SELECT path, id, signature, unavailable 24 | FROM tmp; 25 | 26 | DROP TABLE tmp; 27 | 28 | -- recreate the indices 29 | CREATE UNIQUE INDEX path_idx ON ids (path); 30 | CREATE UNIQUE INDEX id_idx ON ids (id); 31 | 32 | -- recreate the foreign key constraint on thumbnails 33 | ALTER TABLE thumbnails RENAME TO tmp; 34 | 35 | CREATE TABLE thumbnails ( 36 | id TEXT NOT NULL, 37 | data BLOB NOT NULL, 38 | filename TEXT NOT NULL, 39 | mime TEXT NOT NULL, 40 | size INTEGER NOT NULL, 41 | FOREIGN KEY (id) REFERENCES ids (id) 42 | ON UPDATE CASCADE 43 | ON DELETE CASCADE 44 | ); 45 | 46 | INSERT INTO thumbnails 47 | SELECT * FROM tmp; 48 | 49 | DROP TABLE tmp; 50 | 51 | CREATE UNIQUE INDEX tn_index ON thumbnails (id); 52 | 53 | -- remove sort_title column from titles 54 | ALTER TABLE titles RENAME TO tmp; 55 | 56 | CREATE TABLE titles ( 57 | id TEXT NOT NULL, 58 | path TEXT NOT NULL, 59 | signature TEXT, 60 | unavailable INTEGER NOT NULL DEFAULT 0 61 | ); 62 | 63 | INSERT INTO titles 64 | SELECT id, path, signature, unavailable 65 | FROM tmp; 66 | 67 | DROP TABLE tmp; 68 | 69 | -- recreate the indices 70 | CREATE UNIQUE INDEX titles_id_idx on titles (id); 71 | CREATE UNIQUE INDEX titles_path_idx on titles (path); 72 | 73 | -- recreate the foreign key constraint on tags 74 | ALTER TABLE tags RENAME TO tmp; 75 | 76 | CREATE TABLE tags ( 77 | id TEXT NOT NULL, 78 | tag TEXT NOT NULL, 79 | UNIQUE (id, tag), 80 | FOREIGN KEY (id) REFERENCES titles (id) 81 | ON UPDATE CASCADE 82 | ON DELETE CASCADE 83 | ); 84 | 85 | INSERT INTO tags 86 | SELECT * FROM tmp; 87 | 88 | DROP TABLE tmp; 89 | 90 | CREATE INDEX tags_id_idx ON tags (id); 91 | CREATE INDEX tags_tag_idx ON tags (tag); 92 | SQL 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /migration/tags.4.cr: -------------------------------------------------------------------------------- 1 | class CreateTags < MG::Base 2 | def up : String 3 | <<-SQL 4 | CREATE TABLE IF NOT EXISTS tags ( 5 | id TEXT NOT NULL, 6 | tag TEXT NOT NULL, 7 | UNIQUE (id, tag) 8 | ); 9 | CREATE INDEX IF NOT EXISTS tags_id_idx ON tags (id); 10 | CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag); 11 | SQL 12 | end 13 | 14 | def down : String 15 | <<-SQL 16 | DROP TABLE tags; 17 | SQL 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /migration/thumbnails.3.cr: -------------------------------------------------------------------------------- 1 | class CreateThumbnails < MG::Base 2 | def up : String 3 | <<-SQL 4 | CREATE TABLE IF NOT EXISTS thumbnails ( 5 | id TEXT NOT NULL, 6 | data BLOB NOT NULL, 7 | filename TEXT NOT NULL, 8 | mime TEXT NOT NULL, 9 | size INTEGER NOT NULL 10 | ); 11 | CREATE UNIQUE INDEX IF NOT EXISTS tn_index ON thumbnails (id); 12 | SQL 13 | end 14 | 15 | def down : String 16 | <<-SQL 17 | DROP TABLE thumbnails; 18 | SQL 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /migration/titles.5.cr: -------------------------------------------------------------------------------- 1 | class CreateTitles < MG::Base 2 | def up : String 3 | <<-SQL 4 | -- create titles 5 | CREATE TABLE titles ( 6 | id TEXT NOT NULL, 7 | path TEXT NOT NULL, 8 | signature TEXT 9 | ); 10 | CREATE UNIQUE INDEX titles_id_idx on titles (id); 11 | CREATE UNIQUE INDEX titles_path_idx on titles (path); 12 | 13 | -- migrate data from ids to titles 14 | INSERT INTO titles 15 | SELECT id, path, null 16 | FROM ids 17 | WHERE is_title = 1; 18 | 19 | DELETE FROM ids 20 | WHERE is_title = 1; 21 | 22 | -- remove the is_title column from ids 23 | ALTER TABLE ids RENAME TO tmp; 24 | 25 | CREATE TABLE ids ( 26 | path TEXT NOT NULL, 27 | id TEXT NOT NULL 28 | ); 29 | 30 | INSERT INTO ids 31 | SELECT path, id 32 | FROM tmp; 33 | 34 | DROP TABLE tmp; 35 | 36 | -- recreate the indices 37 | CREATE UNIQUE INDEX path_idx ON ids (path); 38 | CREATE UNIQUE INDEX id_idx ON ids (id); 39 | SQL 40 | end 41 | 42 | def down : String 43 | <<-SQL 44 | -- insert the is_title column 45 | ALTER TABLE ids ADD COLUMN is_title INTEGER NOT NULL DEFAULT 0; 46 | 47 | -- migrate data from titles to ids 48 | INSERT INTO ids 49 | SELECT path, id, 1 50 | FROM titles; 51 | 52 | -- remove titles 53 | DROP TABLE titles; 54 | SQL 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /migration/unavailable.9.cr: -------------------------------------------------------------------------------- 1 | class UnavailableIDs < MG::Base 2 | def up : String 3 | <<-SQL 4 | -- add unavailable column to ids 5 | ALTER TABLE ids ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0; 6 | 7 | -- add unavailable column to titles 8 | ALTER TABLE titles ADD COLUMN unavailable INTEGER NOT NULL DEFAULT 0; 9 | SQL 10 | end 11 | 12 | def down : String 13 | <<-SQL 14 | -- remove unavailable column from ids 15 | ALTER TABLE ids RENAME TO tmp; 16 | 17 | CREATE TABLE ids ( 18 | path TEXT NOT NULL, 19 | id TEXT NOT NULL, 20 | signature TEXT 21 | ); 22 | 23 | INSERT INTO ids 24 | SELECT path, id, signature 25 | FROM tmp; 26 | 27 | DROP TABLE tmp; 28 | 29 | -- recreate the indices 30 | CREATE UNIQUE INDEX path_idx ON ids (path); 31 | CREATE UNIQUE INDEX id_idx ON ids (id); 32 | 33 | -- recreate the foreign key constraint on thumbnails 34 | ALTER TABLE thumbnails RENAME TO tmp; 35 | 36 | CREATE TABLE thumbnails ( 37 | id TEXT NOT NULL, 38 | data BLOB NOT NULL, 39 | filename TEXT NOT NULL, 40 | mime TEXT NOT NULL, 41 | size INTEGER NOT NULL, 42 | FOREIGN KEY (id) REFERENCES ids (id) 43 | ON UPDATE CASCADE 44 | ON DELETE CASCADE 45 | ); 46 | 47 | INSERT INTO thumbnails 48 | SELECT * FROM tmp; 49 | 50 | DROP TABLE tmp; 51 | 52 | CREATE UNIQUE INDEX tn_index ON thumbnails (id); 53 | 54 | -- remove unavailable column from titles 55 | ALTER TABLE titles RENAME TO tmp; 56 | 57 | CREATE TABLE titles ( 58 | id TEXT NOT NULL, 59 | path TEXT NOT NULL, 60 | signature TEXT 61 | ); 62 | 63 | INSERT INTO titles 64 | SELECT path, id, signature 65 | FROM tmp; 66 | 67 | DROP TABLE tmp; 68 | 69 | -- recreate the indices 70 | CREATE UNIQUE INDEX titles_id_idx on titles (id); 71 | CREATE UNIQUE INDEX titles_path_idx on titles (path); 72 | 73 | -- recreate the foreign key constraint on tags 74 | ALTER TABLE tags RENAME TO tmp; 75 | 76 | CREATE TABLE tags ( 77 | id TEXT NOT NULL, 78 | tag TEXT NOT NULL, 79 | UNIQUE (id, tag), 80 | FOREIGN KEY (id) REFERENCES titles (id) 81 | ON UPDATE CASCADE 82 | ON DELETE CASCADE 83 | ); 84 | 85 | INSERT INTO tags 86 | SELECT * FROM tmp; 87 | 88 | DROP TABLE tmp; 89 | 90 | CREATE INDEX tags_id_idx ON tags (id); 91 | CREATE INDEX tags_tag_idx ON tags (tag); 92 | SQL 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /migration/users.1.cr: -------------------------------------------------------------------------------- 1 | class CreateUsers < MG::Base 2 | def up : String 3 | <<-SQL 4 | CREATE TABLE IF NOT EXISTS users ( 5 | username TEXT NOT NULL, 6 | password TEXT NOT NULL, 7 | token TEXT, 8 | admin INTEGER NOT NULL 9 | ); 10 | CREATE UNIQUE INDEX IF NOT EXISTS username_idx ON users (username); 11 | CREATE UNIQUE INDEX IF NOT EXISTS token_idx ON users (token); 12 | SQL 13 | end 14 | 15 | def down : String 16 | <<-SQL 17 | DROP TABLE users; 18 | SQL 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mango", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/hkalexling/Mango.git", 6 | "author": "Alex Ling ", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@babel/preset-env": "^7.11.5", 10 | "all-contributors-cli": "^6.19.0", 11 | "gulp": "^4.0.2", 12 | "gulp-babel": "^8.0.0", 13 | "gulp-babel-minify": "^0.5.1", 14 | "gulp-less": "^4.0.1", 15 | "gulp-minify-css": "^1.2.4", 16 | "less": "^3.11.3" 17 | }, 18 | "scripts": { 19 | "uglify": "gulp" 20 | }, 21 | "dependencies": { 22 | "@fortawesome/fontawesome-free": "^5.14.0", 23 | "uikit": "^3.5.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public/css/mango.less: -------------------------------------------------------------------------------- 1 | // UIKit 2 | @import "./uikit.less"; 3 | 4 | // FontAwesome 5 | @import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less"; 6 | @import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less"; 7 | 8 | @font-face { 9 | src: url('@{fa-font-path}/fa-solid-900.woff2'); 10 | src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'), 11 | url('@{fa-font-path}/fa-solid-900.woff') format('woff'); 12 | } 13 | 14 | // Item cards 15 | .item .uk-card { 16 | cursor: pointer; 17 | .uk-card-media-top { 18 | width: 100%; 19 | height: 250px; 20 | @media (min-width: 600px) { 21 | height: 300px; 22 | } 23 | 24 | img { 25 | height: 100%; 26 | width: 100%; 27 | object-fit: cover; 28 | 29 | &.grayscale { 30 | filter: grayscale(100%); 31 | } 32 | } 33 | } 34 | .uk-card-body { 35 | padding: 20px; 36 | .uk-card-title { 37 | font-size: 1rem; 38 | } 39 | .uk-card-title:not(.free-height) { 40 | max-height: 3em; 41 | } 42 | } 43 | } 44 | 45 | // jQuery selectable 46 | #selectable { 47 | .ui-selecting { 48 | background: #EEE6B9; 49 | } 50 | .ui-selected { 51 | background: #F4E487; 52 | } 53 | .uk-light & { 54 | .ui-selecting { 55 | background: #5E5731; 56 | } 57 | .ui-selected { 58 | background: #9D9252; 59 | } 60 | } 61 | } 62 | 63 | // Edit modal 64 | #edit-modal { 65 | .uk-grid > div { 66 | height: 300px; 67 | } 68 | #cover { 69 | height: 100%; 70 | width: 100%; 71 | object-fit: cover; 72 | } 73 | #cover-upload { 74 | height: 100%; 75 | box-sizing: border-box; 76 | } 77 | .uk-modal-body .uk-inline { 78 | width: 100%; 79 | } 80 | } 81 | 82 | // Dark theme 83 | .uk-light { 84 | .uk-modal-header, 85 | .uk-modal-body, 86 | .uk-modal-footer { 87 | background: #222; 88 | } 89 | .uk-navbar-dropdown, 90 | .uk-dropdown { 91 | color: #ccc; 92 | background: #333; 93 | } 94 | .uk-nav-header, 95 | .uk-description-list > dt { 96 | color: #555; 97 | } 98 | } 99 | 100 | // Alpine magic 101 | [x-cloak] { 102 | display: none; 103 | } 104 | 105 | // Batch select bar on title page 106 | #select-bar-controls { 107 | a { 108 | transform: scale(1.5, 1.5); 109 | 110 | &:hover { 111 | color: orange; 112 | } 113 | } 114 | } 115 | 116 | // Totop button 117 | #totop-wrapper { 118 | position: absolute; 119 | top: 100vh; 120 | right: 2em; 121 | bottom: 0; 122 | 123 | a { 124 | position: fixed; 125 | position: sticky; 126 | top: calc(100vh - 5em); 127 | } 128 | } 129 | 130 | // Misc 131 | .uk-alert-close { 132 | color: black !important; 133 | } 134 | .break-word { 135 | word-wrap: break-word; 136 | } 137 | .uk-search { 138 | width: 100%; 139 | } 140 | -------------------------------------------------------------------------------- /public/css/tags.less: -------------------------------------------------------------------------------- 1 | @light-gray: #e5e5e5; 2 | @gray: #666666; 3 | @black: #141414; 4 | @blue: rgb(30, 135, 240); 5 | @white1: rgba(255, 255, 255, .1); 6 | @white2: rgba(255, 255, 255, .2); 7 | @white7: rgba(255, 255, 255, .7); 8 | 9 | .select2-container--default { 10 | .select2-selection--multiple { 11 | border: 1px solid @light-gray; 12 | .select2-selection__choice, 13 | .select2-selection__choice__remove, 14 | .select2-selection__choice__remove:hover 15 | { 16 | background-color: @blue; 17 | color: white; 18 | border: none; 19 | border-radius: 2px; 20 | } 21 | } 22 | .select2-dropdown { 23 | .select2-results__option--highlighted.select2-results__option--selectable { 24 | background-color: @blue; 25 | } 26 | .select2-results__option--selected:not(.select2-results__option--highlighted) { 27 | background-color: @light-gray 28 | } 29 | } 30 | } 31 | 32 | .uk-light { 33 | .select2-container--default { 34 | .select2-selection { 35 | background-color: @white1; 36 | } 37 | .select2-selection--multiple { 38 | border: 1px solid @white2; 39 | .select2-selection__choice, 40 | .select2-selection__choice__remove, 41 | .select2-selection__choice__remove:hover 42 | { 43 | background-color: white; 44 | color: @gray; 45 | border: none; 46 | } 47 | .select2-search__field { 48 | color: @white7; 49 | } 50 | } 51 | } 52 | .select2-dropdown { 53 | background-color: @black; 54 | .select2-results__option--selected:not(.select2-results__option--highlighted) { 55 | background-color: @white2; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/css/uikit.less: -------------------------------------------------------------------------------- 1 | @import "node_modules/uikit/src/less/uikit.theme.less"; 2 | 3 | .label { 4 | display: inline-block; 5 | padding: @label-padding-vertical @label-padding-horizontal; 6 | background: @label-background; 7 | line-height: @label-line-height; 8 | font-size: @label-font-size; 9 | color: @label-color; 10 | vertical-align: middle; 11 | white-space: nowrap; 12 | .hook-label; 13 | } 14 | 15 | .label-success { 16 | background-color: @label-success-background; 17 | color: @label-success-color; 18 | } 19 | 20 | .label-warning { 21 | background-color: @label-warning-background; 22 | color: @label-warning-color; 23 | } 24 | 25 | .label-danger { 26 | background-color: @label-danger-background; 27 | color: @label-danger-color; 28 | } 29 | 30 | .label-pending { 31 | background-color: @global-secondary-background; 32 | color: @global-inverse-color; 33 | } 34 | 35 | @internal-divider-icon-image: "../img/divider-icon.svg"; 36 | @internal-form-select-image: "../img/form-select.svg"; 37 | @internal-form-datalist-image: "../img/form-datalist.svg"; 38 | @internal-form-radio-image: "../img/form-radio.svg"; 39 | @internal-form-checkbox-image: "../img/form-checkbox.svg"; 40 | @internal-form-checkbox-indeterminate-image: "../img/form-checkbox-indeterminate.svg"; 41 | @internal-nav-parent-close-image: "../img/nav-parent-close.svg"; 42 | @internal-nav-parent-open-image: "../img/nav-parent-open.svg"; 43 | @internal-list-bullet-image: "../img/list-bullet.svg"; 44 | @internal-accordion-open-image: "../img/accordion-open.svg"; 45 | @internal-accordion-close-image: "../img/accordion-close.svg"; 46 | 47 | .hook-card-default() { 48 | .uk-light & { 49 | background: @card-secondary-background; 50 | color: @card-secondary-color; 51 | } 52 | } 53 | 54 | .hook-card-default-title() { 55 | .uk-light & { 56 | color: @card-secondary-title-color; 57 | } 58 | } 59 | 60 | .hook-card-default-hover() { 61 | .uk-light & { 62 | background-color: @card-secondary-hover-background; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/public/favicon.ico -------------------------------------------------------------------------------- /public/img/banner-paddings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/public/img/banner-paddings.png -------------------------------------------------------------------------------- /public/img/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/public/img/banner.png -------------------------------------------------------------------------------- /public/img/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/public/img/icons/icon.png -------------------------------------------------------------------------------- /public/img/icons/icon_x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/public/img/icons/icon_x192.png -------------------------------------------------------------------------------- /public/img/icons/icon_x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/public/img/icons/icon_x512.png -------------------------------------------------------------------------------- /public/img/icons/icon_x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/public/img/icons/icon_x96.png -------------------------------------------------------------------------------- /public/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/public/img/loading.gif -------------------------------------------------------------------------------- /public/js/admin.js: -------------------------------------------------------------------------------- 1 | const component = () => { 2 | return { 3 | progress: 1.0, 4 | generating: false, 5 | scanning: false, 6 | scanTitles: 0, 7 | scanMs: -1, 8 | themeSetting: '', 9 | 10 | init() { 11 | this.getProgress(); 12 | setInterval(() => { 13 | this.getProgress(); 14 | }, 5000); 15 | 16 | const setting = loadThemeSetting(); 17 | this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1); 18 | }, 19 | themeChanged(event) { 20 | const newSetting = $(event.currentTarget).val().toLowerCase(); 21 | saveThemeSetting(newSetting); 22 | setTheme(); 23 | }, 24 | scan() { 25 | if (this.scanning) return; 26 | this.scanning = true; 27 | this.scanMs = -1; 28 | this.scanTitles = 0; 29 | $.post(`${base_url}api/admin/scan`) 30 | .then(data => { 31 | this.scanMs = data.milliseconds; 32 | this.scanTitles = data.titles; 33 | }) 34 | .catch(e => { 35 | alert('danger', `Failed to trigger a scan. Error: ${e}`); 36 | }) 37 | .always(() => { 38 | this.scanning = false; 39 | }); 40 | }, 41 | generateThumbnails() { 42 | if (this.generating) return; 43 | this.generating = true; 44 | this.progress = 0.0; 45 | $.post(`${base_url}api/admin/generate_thumbnails`) 46 | .then(() => { 47 | this.getProgress() 48 | }); 49 | }, 50 | getProgress() { 51 | $.get(`${base_url}api/admin/thumbnail_progress`) 52 | .then(data => { 53 | this.progress = data.progress; 54 | this.generating = data.progress > 0; 55 | }); 56 | }, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /public/js/alert.js: -------------------------------------------------------------------------------- 1 | const alert = (level, text) => { 2 | $('#alert').empty(); 3 | const html = `

${text}

`; 4 | $('#alert').append(html); 5 | $("html, body").animate({ scrollTop: 0 }); 6 | }; 7 | -------------------------------------------------------------------------------- /public/js/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * --- Alpine helper functions 3 | */ 4 | 5 | /** 6 | * Set an alpine.js property 7 | * 8 | * @function setProp 9 | * @param {string} key - Key of the data property 10 | * @param {*} prop - The data property 11 | * @param {string} selector - The jQuery selector to the root element 12 | */ 13 | const setProp = (key, prop, selector = '#root') => { 14 | $(selector).get(0).__x.$data[key] = prop; 15 | }; 16 | 17 | /** 18 | * Get an alpine.js property 19 | * 20 | * @function getProp 21 | * @param {string} key - Key of the data property 22 | * @param {string} selector - The jQuery selector to the root element 23 | * @return {*} The data property 24 | */ 25 | const getProp = (key, selector = '#root') => { 26 | return $(selector).get(0).__x.$data[key]; 27 | }; 28 | 29 | /** 30 | * --- Theme related functions 31 | * Note: In the comments below we treat "theme" and "theme setting" 32 | * differently. A theme can have only two values, either "dark" or 33 | * "light", while a theme setting can have the third value "system". 34 | */ 35 | 36 | /** 37 | * Check if the system setting prefers dark theme. 38 | * from https://flaviocopes.com/javascript-detect-dark-mode/ 39 | * 40 | * @function preferDarkMode 41 | * @return {bool} 42 | */ 43 | const preferDarkMode = () => { 44 | return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 45 | }; 46 | 47 | /** 48 | * Check whether a given string represents a valid theme setting 49 | * 50 | * @function validThemeSetting 51 | * @param {string} theme - The string representing the theme setting 52 | * @return {bool} 53 | */ 54 | const validThemeSetting = (theme) => { 55 | return ['dark', 'light', 'system'].indexOf(theme) >= 0; 56 | }; 57 | 58 | /** 59 | * Load theme setting from local storage, or use 'light' 60 | * 61 | * @function loadThemeSetting 62 | * @return {string} A theme setting ('dark', 'light', or 'system') 63 | */ 64 | const loadThemeSetting = () => { 65 | let str = localStorage.getItem('theme'); 66 | if (!str || !validThemeSetting(str)) str = 'system'; 67 | return str; 68 | }; 69 | 70 | /** 71 | * Load the current theme (not theme setting) 72 | * 73 | * @function loadTheme 74 | * @return {string} The current theme to use ('dark' or 'light') 75 | */ 76 | const loadTheme = () => { 77 | let setting = loadThemeSetting(); 78 | if (setting === 'system') { 79 | setting = preferDarkMode() ? 'dark' : 'light'; 80 | } 81 | return setting; 82 | }; 83 | 84 | /** 85 | * Save a theme setting 86 | * 87 | * @function saveThemeSetting 88 | * @param {string} setting - A theme setting 89 | */ 90 | const saveThemeSetting = setting => { 91 | if (!validThemeSetting(setting)) setting = 'system'; 92 | localStorage.setItem('theme', setting); 93 | }; 94 | 95 | /** 96 | * Toggle the current theme. When the current theme setting is 'system', it 97 | * will be changed to either 'light' or 'dark' 98 | * 99 | * @function toggleTheme 100 | */ 101 | const toggleTheme = () => { 102 | const theme = loadTheme(); 103 | const newTheme = theme === 'dark' ? 'light' : 'dark'; 104 | saveThemeSetting(newTheme); 105 | setTheme(newTheme); 106 | }; 107 | 108 | /** 109 | * Apply a theme, or load a theme and then apply it 110 | * 111 | * @function setTheme 112 | * @param {string?} theme - (Optional) The theme to apply. When omitted, use 113 | * `loadTheme` to get a theme and apply it. 114 | */ 115 | const setTheme = (theme) => { 116 | if (!theme) theme = loadTheme(); 117 | if (theme === 'dark') { 118 | $('html').css('background', 'rgb(20, 20, 20)'); 119 | $('body').addClass('uk-light'); 120 | $('.ui-widget-content').addClass('dark'); 121 | } else { 122 | $('html').css('background', ''); 123 | $('body').removeClass('uk-light'); 124 | $('.ui-widget-content').removeClass('dark'); 125 | } 126 | }; 127 | 128 | // do it before document is ready to prevent the initial flash of white on 129 | // most pages 130 | setTheme(); 131 | $(() => { 132 | // hack for the reader page 133 | setTheme(); 134 | 135 | // on system dark mode setting change 136 | if (window.matchMedia) { 137 | window.matchMedia('(prefers-color-scheme: dark)') 138 | .addEventListener('change', event => { 139 | if (loadThemeSetting() === 'system') 140 | setTheme(event.matches ? 'dark' : 'light'); 141 | }); 142 | } 143 | }); 144 | -------------------------------------------------------------------------------- /public/js/dotdotdot.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * dotdotdot JS 4.0.11 3 | * 4 | * dotdotdot.frebsite.nl 5 | * 6 | * Copyright (c) Fred Heusschen 7 | * www.frebsite.nl 8 | * 9 | * License: CC-BY-NC-4.0 10 | * http://creativecommons.org/licenses/by-nc/4.0/ 11 | */ 12 | var Dotdotdot=function(){function t(e,i){void 0===i&&(i=t.options);var n=this;for(var o in this.container=e,this.options=i||{},this.watchTimeout=null,this.watchInterval=null,this.resizeEvent=null,t.options)t.options.hasOwnProperty(o)&&void 0===this.options[o]&&(this.options[o]=t.options[o]);var r=this.container.dotdotdot;r&&r.destroy(),this.API={},["truncate","restore","destroy","watch","unwatch"].forEach(function(t){n.API[t]=function(){return n[t].call(n)}}),this.container.dotdotdot=this.API,this.originalStyle=this.container.getAttribute("style")||"",this.originalContent=this._getOriginalContent(),this.ellipsis=document.createTextNode(this.options.ellipsis);var s=window.getComputedStyle(this.container);"break-word"!==s["word-wrap"]&&(this.container.style["word-wrap"]="break-word"),"pre"===s["white-space"]?this.container.style["white-space"]="pre-wrap":"nowrap"===s["white-space"]&&(this.container.style["white-space"]="normal"),null===this.options.height&&(this.options.height=this._getMaxHeight()),this.truncate(),this.options.watch&&this.watch()}return t.prototype.restore=function(){var t=this;this.unwatch(),this.container.setAttribute("style",this.originalStyle),this.container.classList.remove("ddd-truncated"),this.container.innerHTML="",this.originalContent.forEach(function(e){t.container.append(e)})},t.prototype.destroy=function(){this.restore(),this.container.dotdotdot=null},t.prototype.watch=function(){var t=this;this.unwatch();var e={width:null,height:null},i=function(i,n,o){if(t.container.offsetWidth||t.container.offsetHeight||t.container.getClientRects().length){var r={width:i[n],height:i[o]};return e.width==r.width&&e.height==r.height||t.truncate(),r}return e};"window"==this.options.watch?(this.resizeEvent=function(n){t.watchTimeout&&clearTimeout(t.watchTimeout),t.watchTimeout=setTimeout(function(){e=i(window,"innerWidth","innerHeight")},100)},window.addEventListener("resize",this.resizeEvent)):this.watchInterval=setInterval(function(){e=i(t.container,"clientWidth","clientHeight")},1e3)},t.prototype.unwatch=function(){this.resizeEvent&&(window.removeEventListener("resize",this.resizeEvent),this.resizeEvent=null),this.watchInterval&&clearInterval(this.watchInterval),this.watchTimeout&&clearTimeout(this.watchTimeout)},t.prototype.truncate=function(){var t=this,e=!1;return this.container.innerHTML="",this.originalContent.forEach(function(e){t.container.append(e.cloneNode(!0))}),this.maxHeight=this._getMaxHeight(),this._fits()||(e=!0,this._truncateToNode(this.container)),this.container.classList[e?"add":"remove"]("ddd-truncated"),this.options.callback.call(this.container,e),e},t.prototype._truncateToNode=function(e){var i=[],n=[];if(t.$.contents(e).forEach(function(t){if(1!=t.nodeType||!t.matches(".ddd-keep")){var e=document.createComment("");t.replaceWith(e),n.push(t),i.push(e)}}),n.length){for(var o=0;o1)return void n[o-2].remove();break}}for(var a=o;a=0;o--)if(t.textContent=this._addEllipsis(n.slice(0,o).join(i)),this._fits()){"letter"==this.options.truncate&&(t.textContent=n.slice(0,o+1).join(i),this._truncateToLetter(t));break}},t.prototype._truncateToLetter=function(t){for(var e=t.textContent.split(""),i="",n=e.length;n>=0&&(!(i=e.slice(0,n).join("")).length||(t.textContent=this._addEllipsis(i),!this._fits()));n--);},t.prototype._fits=function(){return this.container.scrollHeight<=this.maxHeight+this.options.tolerance},t.prototype._addEllipsis=function(t){for(var e=[" "," ",",",";",".","!","?"];e.indexOf(t.slice(-1))>-1;)t=t.slice(0,-1);return t+=this.ellipsis.textContent},t.prototype._getOriginalContent=function(){var e="script, style";this.options.keep&&(e+=", "+this.options.keep),t.$.find(e,this.container).forEach(function(t){t.classList.add("ddd-keep")});var i="div, section, article, header, footer, p, h1, h2, h3, h4, h5, h6, table, td, td, dt, dd, li";[this.container].concat(t.$.find("*",this.container)).forEach(function(e){e.normalize(),t.$.contents(e).forEach(function(t){8==t.nodeType&&e.removeChild(t)}),t.$.contents(e).forEach(function(t){if(3==t.nodeType&&""==t.textContent.trim()){var n=t.previousSibling,o=t.nextSibling;(t.parentElement.matches("table, thead, tbody, tfoot, tr, dl, ul, ol, video")||!n||1==n.nodeType&&n.matches(i)||!o||1==o.nodeType&&o.matches(i))&&e.removeChild(t)}})});var n=[];return t.$.contents(this.container).forEach(function(t){n.push(t.cloneNode(!0))}),n},t.prototype._getMaxHeight=function(){if("number"==typeof this.options.height)return this.options.height;for(var t=window.getComputedStyle(this.container),e=["maxHeight","height"],i=0,n=0;n { 8 | $(e).dotdotdot({ 9 | truncate: 'letter', 10 | watch: true, 11 | callback: (truncated) => { 12 | if (truncated) { 13 | $(e).attr('uk-tooltip', $(e).attr('data-title')); 14 | } else { 15 | $(e).removeAttr('uk-tooltip'); 16 | } 17 | } 18 | }); 19 | }; 20 | 21 | $('.uk-card-title').each((i, e) => { 22 | // Truncate the title when it first enters the view 23 | $(e).one('inview', () => { 24 | truncate(e); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /public/js/download-manager.js: -------------------------------------------------------------------------------- 1 | const component = () => { 2 | return { 3 | jobs: [], 4 | paused: undefined, 5 | loading: false, 6 | toggling: false, 7 | ws: undefined, 8 | 9 | wsConnect(secure = true) { 10 | const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`; 11 | console.log(`Connecting to ${url}`); 12 | this.ws = new WebSocket(url); 13 | this.ws.onmessage = event => { 14 | const data = JSON.parse(event.data); 15 | this.jobs = data.jobs; 16 | this.paused = data.paused; 17 | }; 18 | this.ws.onclose = () => { 19 | if (this.ws.failed) 20 | return this.wsConnect(false); 21 | alert('danger', 'Socket 连接关闭'); 22 | }; 23 | this.ws.onerror = () => { 24 | if (secure) 25 | return this.ws.failed = true; 26 | alert('danger', 'Socket连接失败'); 27 | }; 28 | }, 29 | init() { 30 | this.wsConnect(); 31 | this.load(); 32 | }, 33 | load() { 34 | this.loading = true; 35 | $.ajax({ 36 | type: 'GET', 37 | url: base_url + 'api/admin/mangadex/queue', 38 | dataType: 'json' 39 | }) 40 | .done(data => { 41 | if (!data.success && data.error) { 42 | alert('danger', `获取下载队列失败. Error: ${data.error}`); 43 | return; 44 | } 45 | this.jobs = data.jobs; 46 | this.paused = data.paused; 47 | }) 48 | .fail((jqXHR, status) => { 49 | alert('danger', `获取下载队列失败. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 50 | }) 51 | .always(() => { 52 | this.loading = false; 53 | }); 54 | }, 55 | jobAction(action, event) { 56 | let url = `${base_url}api/admin/mangadex/queue/${action}`; 57 | if (event) { 58 | const id = event.currentTarget.closest('tr').id.split('-').slice(1).join('-'); 59 | url = `${url}?${$.param({ 60 | id: id 61 | })}`; 62 | } 63 | console.log(url); 64 | $.ajax({ 65 | type: 'POST', 66 | url: url, 67 | dataType: 'json' 68 | }) 69 | .done(data => { 70 | if (!data.success && data.error) { 71 | alert('danger', `下载队列中的 ${action} 作业失败. Error: ${data.error}`); 72 | return; 73 | } 74 | this.load(); 75 | }) 76 | .fail((jqXHR, status) => { 77 | alert('danger', `下载队列中的 ${action} 作业失败. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 78 | }); 79 | }, 80 | toggle() { 81 | this.toggling = true; 82 | const action = this.paused ? 'resume' : 'pause'; 83 | const url = `${base_url}api/admin/mangadex/queue/${action}`; 84 | $.ajax({ 85 | type: 'POST', 86 | url: url, 87 | dataType: 'json' 88 | }) 89 | .fail((jqXHR, status) => { 90 | alert('danger', ` ${action} 下载队列失败. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 91 | }) 92 | .always(() => { 93 | this.load(); 94 | this.toggling = false; 95 | }); 96 | }, 97 | statusClass(status) { 98 | let cls = 'label '; 99 | switch (status) { 100 | case 'Pending': 101 | cls += 'label-pending'; 102 | break; 103 | case 'Completed': 104 | cls += 'label-success'; 105 | break; 106 | case 'Error': 107 | cls += 'label-danger'; 108 | break; 109 | case 'MissingPages': 110 | cls += 'label-warning'; 111 | break; 112 | } 113 | return cls; 114 | } 115 | }; 116 | }; 117 | -------------------------------------------------------------------------------- /public/js/jquery.inview.min.js: -------------------------------------------------------------------------------- 1 | !function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof exports?module.exports=a(require("jquery")):a(jQuery)}(function(a){function i(){var b,c,d={height:f.innerHeight,width:f.innerWidth};return d.height||(b=e.compatMode,(b||!a.support.boxModel)&&(c="CSS1Compat"===b?g:e.body,d={height:c.clientHeight,width:c.clientWidth})),d}function j(){return{top:f.pageYOffset||g.scrollTop||e.body.scrollTop,left:f.pageXOffset||g.scrollLeft||e.body.scrollLeft}}function k(){if(b.length){var e=0,f=a.map(b,function(a){var b=a.data.selector,c=a.$element;return b?c.find(b):c});for(c=c||i(),d=d||j();ed.top&&l.topd.left&&l.left { 2 | return { 3 | empty: true, 4 | titles: [], 5 | entries: [], 6 | loading: true, 7 | 8 | load() { 9 | this.loading = true; 10 | this.request('GET', `${base_url}api/admin/titles/missing`, data => { 11 | this.titles = data.titles; 12 | this.request('GET', `${base_url}api/admin/entries/missing`, data => { 13 | this.entries = data.entries; 14 | this.loading = false; 15 | this.empty = this.entries.length === 0 && this.titles.length === 0; 16 | }); 17 | }); 18 | }, 19 | rm(event) { 20 | const rawID = event.currentTarget.closest('tr').id; 21 | const [type, id] = rawID.split('-'); 22 | const url = `${base_url}api/admin/${type === 'title' ? 'titles' : 'entries'}/missing/${id}`; 23 | this.request('DELETE', url, () => { 24 | this.load(); 25 | }); 26 | }, 27 | rmAll() { 28 | UIkit.modal.confirm('你确定吗? 与这些项目相关的所有元数据,包括它们的标签和缩略图,都将从数据库中删除。', { 29 | labels: { 30 | ok: '是的,删除它们', 31 | cancel: '取消' 32 | } 33 | }).then(() => { 34 | this.request('DELETE', `${base_url}api/admin/titles/missing`, () => { 35 | this.request('DELETE', `${base_url}api/admin/entries/missing`, () => { 36 | this.load(); 37 | }); 38 | }); 39 | }); 40 | }, 41 | request(method, url, cb) { 42 | console.log(url); 43 | $.ajax({ 44 | type: method, 45 | url: url, 46 | contentType: 'application/json' 47 | }) 48 | .done(data => { 49 | if (data.error) { 50 | alert('danger', `未能 ${method} ${url}. Error: ${data.error}`); 51 | return; 52 | } 53 | if (cb) cb(data); 54 | }) 55 | .fail((jqXHR, status) => { 56 | alert('danger', `未能 ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 57 | }); 58 | } 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /public/js/search.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | var filter = []; 3 | var result = []; 4 | $('.uk-card-title').each(function(){ 5 | filter.push($(this).text()); 6 | }); 7 | $('.uk-search-input').keyup(function(){ 8 | var input = $('.uk-search-input').val(); 9 | var regex = new RegExp(input, 'i'); 10 | 11 | if (input === '') { 12 | $('.item').each(function(){ 13 | $(this).removeAttr('hidden'); 14 | }); 15 | } 16 | else { 17 | filter.forEach(function(text, i){ 18 | result[i] = text.match(regex); 19 | }); 20 | $('.item').each(function(i){ 21 | if (result[i]) { 22 | $(this).removeAttr('hidden'); 23 | } 24 | else { 25 | $(this).attr('hidden', ''); 26 | } 27 | }); 28 | } 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /public/js/sort-items.js: -------------------------------------------------------------------------------- 1 | $(() => { 2 | $('#sort-select').change(() => { 3 | const sort = $('#sort-select').find(':selected').attr('id'); 4 | const ary = sort.split('-'); 5 | const by = ary[0]; 6 | const dir = ary[1]; 7 | 8 | const url = `${location.protocol}//${location.host}${location.pathname}`; 9 | const newURL = `${url}?${$.param({ 10 | sort: by, 11 | ascend: dir === 'up' ? 1 : 0 12 | })}`; 13 | window.location.href = newURL; 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /public/js/subscription-manager.js: -------------------------------------------------------------------------------- 1 | const component = () => { 2 | return { 3 | subscriptions: [], 4 | plugins: [], 5 | pid: undefined, 6 | subscription: undefined, // selected subscription 7 | loading: false, 8 | 9 | init() { 10 | fetch(`${base_url}api/admin/plugin`) 11 | .then((res) => res.json()) 12 | .then((data) => { 13 | if (!data.success) throw new Error(data.error); 14 | this.plugins = data.plugins; 15 | 16 | const pid = localStorage.getItem("plugin"); 17 | if (pid && this.plugins.map((p) => p.id).includes(pid)) 18 | this.pid = pid; 19 | else if (this.plugins.length > 0) 20 | this.pid = this.plugins[0].id; 21 | 22 | this.list(pid); 23 | }) 24 | .catch((e) => { 25 | alert( 26 | "danger", 27 | `无法列出可用插件. Error: ${e}` 28 | ); 29 | }); 30 | }, 31 | pluginChanged() { 32 | localStorage.setItem("plugin", this.pid); 33 | this.list(this.pid); 34 | }, 35 | list(pid) { 36 | if (!pid) return; 37 | fetch( 38 | `${base_url}api/admin/plugin/subscriptions?${new URLSearchParams( 39 | { 40 | plugin: pid, 41 | } 42 | )}`, 43 | { 44 | method: "GET", 45 | } 46 | ) 47 | .then((response) => response.json()) 48 | .then((data) => { 49 | if (!data.success) throw new Error(data.error); 50 | this.subscriptions = data.subscriptions; 51 | }) 52 | .catch((e) => { 53 | alert( 54 | "danger", 55 | `未能列出订阅. Error: ${e}` 56 | ); 57 | }); 58 | }, 59 | renderStrCell(str) { 60 | const maxLength = 40; 61 | if (str.length > maxLength) 62 | return `${str.substring( 63 | 0, 64 | maxLength 65 | )}...
${str}
`; 66 | return `${str}`; 67 | }, 68 | renderDateCell(timestamp) { 69 | return `${moment 70 | .duration(moment.unix(timestamp).diff(moment())) 71 | .humanize(true)}`; 72 | }, 73 | selected(event, modal) { 74 | const id = event.currentTarget.getAttribute("sid"); 75 | this.subscription = this.subscriptions.find((s) => s.id === id); 76 | UIkit.modal(modal).show(); 77 | }, 78 | renderFilterRow(ft) { 79 | const key = ft.key; 80 | let type = ft.type; 81 | switch (type) { 82 | case "number-min": 83 | type = "number (minimum value)"; 84 | break; 85 | case "number-max": 86 | type = "number (maximum value)"; 87 | break; 88 | case "date-min": 89 | type = "minimum date"; 90 | break; 91 | case "date-max": 92 | type = "maximum date"; 93 | break; 94 | } 95 | let value = ft.value; 96 | 97 | if (ft.type.startsWith("number") && isNaN(value)) value = ""; 98 | else if (ft.type.startsWith("date") && value) 99 | value = moment(Number(value)).format("MMM D, YYYY"); 100 | 101 | return `${key}${type}${value}`; 102 | }, 103 | actionHandler(event, type) { 104 | const id = $(event.currentTarget).closest("tr").attr("sid"); 105 | if (type !== 'delete') return this.action(id, type); 106 | UIkit.modal.confirm('您确定要删除订阅吗? 这不能被撤消。', { 107 | labels: { 108 | ok: '是的,删除', 109 | cancel: '取消' 110 | } 111 | }).then(() => { 112 | this.action(id, type); 113 | }); 114 | }, 115 | action(id, type) { 116 | if (this.loading) return; 117 | this.loading = true; 118 | fetch( 119 | `${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams( 120 | { 121 | plugin: this.pid, 122 | subscription: id, 123 | } 124 | )}`, 125 | { 126 | method: type === 'delete' ? "DELETE" : 'POST' 127 | } 128 | ) 129 | .then((response) => response.json()) 130 | .then((data) => { 131 | if (!data.success) throw new Error(data.error); 132 | if (type === 'update') 133 | alert("success", `检查订阅更新 ${id}. 检查日志以了解进度或稍后返回此页面.`); 134 | }) 135 | .catch((e) => { 136 | alert( 137 | "danger", 138 | `未能 ${type} 成功订阅. Error: ${e}` 139 | ); 140 | }) 141 | .finally(() => { 142 | this.loading = false; 143 | this.list(this.pid); 144 | }); 145 | }, 146 | }; 147 | }; 148 | -------------------------------------------------------------------------------- /public/js/subscription.js: -------------------------------------------------------------------------------- 1 | const component = () => { 2 | return { 3 | available: undefined, 4 | subscriptions: [], 5 | 6 | init() { 7 | $.getJSON(`${base_url}api/admin/mangadex/expires`) 8 | .done((data) => { 9 | if (data.error) { 10 | alert('danger', '未能检查 MangaDex 集成状态. Error: ' + data.error); 11 | return; 12 | } 13 | this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000)); 14 | 15 | if (this.available) this.getSubscriptions(); 16 | }) 17 | .fail((jqXHR, status) => { 18 | alert('danger', `未能检查 MangaDex 集成状态. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 19 | }) 20 | }, 21 | 22 | getSubscriptions() { 23 | $.getJSON(`${base_url}api/admin/mangadex/subscriptions`) 24 | .done(data => { 25 | if (data.error) { 26 | alert('danger', '获取订阅失败. Error: ' + data.error); 27 | return; 28 | } 29 | this.subscriptions = data.subscriptions; 30 | }) 31 | .fail((jqXHR, status) => { 32 | alert('danger', `获取订阅失败. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 33 | }) 34 | }, 35 | 36 | rm(event) { 37 | const id = event.currentTarget.parentNode.getAttribute('data-id'); 38 | $.ajax({ 39 | type: 'DELETE', 40 | url: `${base_url}api/admin/mangadex/subscriptions/${id}`, 41 | contentType: 'application/json' 42 | }) 43 | .done(data => { 44 | if (data.error) { 45 | alert('danger', `删除订阅失败. Error: ${data.error}`); 46 | } 47 | this.getSubscriptions(); 48 | }) 49 | .fail((jqXHR, status) => { 50 | alert('danger', `删除订阅失败. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 51 | }); 52 | }, 53 | 54 | check(event) { 55 | const id = event.currentTarget.parentNode.getAttribute('data-id'); 56 | $.ajax({ 57 | type: 'POST', 58 | url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`, 59 | contentType: 'application/json' 60 | }) 61 | .done(data => { 62 | if (data.error) { 63 | alert('danger', `检查订阅失败. Error: ${data.error}`); 64 | return; 65 | } 66 | alert('success', 'Mango 现在正在检查订阅是否有更新。 这可能需要一段时间,但您可以安全地离开该页面.'); 67 | }) 68 | .fail((jqXHR, status) => { 69 | alert('danger', `检查订阅失败. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 70 | }); 71 | }, 72 | 73 | formatRange(min, max) { 74 | if (!isNaN(min) && isNaN(max)) return `≥ ${min}`; 75 | if (isNaN(min) && !isNaN(max)) return `≤ ${max}`; 76 | if (isNaN(min) && isNaN(max)) return 'All'; 77 | 78 | if (min === max) return `= ${min}`; 79 | return `${min} - ${max}`; 80 | } 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /public/js/user-edit.js: -------------------------------------------------------------------------------- 1 | $(() => { 2 | var target = base_url + 'admin/user/edit'; 3 | if (username) target += username; 4 | $('form').attr('action', target); 5 | if (error) alert('danger', error); 6 | }); 7 | -------------------------------------------------------------------------------- /public/js/user.js: -------------------------------------------------------------------------------- 1 | const remove = (username) => { 2 | $.ajax({ 3 | url: `${base_url}api/admin/user/delete/${username}`, 4 | type: 'DELETE', 5 | dataType: 'json' 6 | }) 7 | .done(data => { 8 | if (data.success) 9 | location.reload(); 10 | else 11 | alert('danger', data.error); 12 | }) 13 | .fail((jqXHR, status) => { 14 | alert('danger', `删除用户失败. Error: [${jqXHR.status}] ${jqXHR.statusText}`); 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mango", 3 | "description": "Mango: A self-hosted manga server and web reader", 4 | "icons": [ 5 | { 6 | "src": "/img/icons/icon_x96.png", 7 | "sizes": "96x96", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/img/icons/icon_x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/img/icons/icon_x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "display": "fullscreen", 22 | "start_url": "/" 23 | } 24 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | ameba: 4 | git: https://github.com/crystal-ameba/ameba.git 5 | version: 0.14.3 6 | 7 | archive: 8 | git: https://github.com/hkalexling/archive.cr.git 9 | version: 0.5.0 10 | 11 | baked_file_system: 12 | git: https://github.com/schovi/baked_file_system.git 13 | version: 0.10.0 14 | 15 | clim: 16 | git: https://github.com/at-grandpa/clim.git 17 | version: 0.17.1 18 | 19 | db: 20 | git: https://github.com/crystal-lang/crystal-db.git 21 | version: 0.10.1 22 | 23 | duktape: 24 | git: https://github.com/jessedoyle/duktape.cr.git 25 | version: 1.0.0 26 | 27 | exception_page: 28 | git: https://github.com/crystal-loot/exception_page.git 29 | version: 0.1.5 30 | 31 | http_proxy: 32 | git: https://github.com/mamantoha/http_proxy.git 33 | version: 0.8.0 34 | 35 | image_size: 36 | git: https://github.com/hkalexling/image_size.cr.git 37 | version: 0.5.0 38 | 39 | kemal: 40 | git: https://github.com/kemalcr/kemal.git 41 | version: 1.0.0 42 | 43 | kemal-session: 44 | git: https://github.com/kemalcr/kemal-session.git 45 | version: 1.0.0 46 | 47 | kilt: 48 | git: https://github.com/jeromegn/kilt.git 49 | version: 0.4.1 50 | 51 | koa: 52 | git: https://github.com/hkalexling/koa.git 53 | version: 0.9.0 54 | 55 | mg: 56 | git: https://github.com/hkalexling/mg.git 57 | version: 0.5.0+git.commit.697e46e27cde8c3969346e228e372db2455a6264 58 | 59 | myhtml: 60 | git: https://github.com/kostya/myhtml.git 61 | version: 1.5.8 62 | 63 | open_api: 64 | git: https://github.com/hkalexling/open_api.cr.git 65 | version: 1.2.1+git.commit.1d3c55dd5534c6b0af18964d031858a08515553a 66 | 67 | radix: 68 | git: https://github.com/luislavena/radix.git 69 | version: 0.4.1 70 | 71 | sanitize: 72 | git: https://github.com/hkalexling/sanitize.git 73 | version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce 74 | 75 | sqlite3: 76 | git: https://github.com/crystal-lang/crystal-sqlite3.git 77 | version: 0.18.0 78 | 79 | tallboy: 80 | git: https://github.com/epoch/tallboy.git 81 | version: 0.9.3+git.commit.9be1510bb0391c95e92f1b288f3afb429a73caa6 82 | 83 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: mango 2 | version: 0.27.0 3 | 4 | authors: 5 | - Alex Ling 6 | 7 | targets: 8 | mango: 9 | main: src/mango.cr 10 | 11 | crystal: 1.0.0 12 | 13 | license: MIT 14 | 15 | dependencies: 16 | kemal: 17 | github: kemalcr/kemal 18 | kemal-session: 19 | github: kemalcr/kemal-session 20 | sqlite3: 21 | github: crystal-lang/crystal-sqlite3 22 | baked_file_system: 23 | github: schovi/baked_file_system 24 | archive: 25 | github: hkalexling/archive.cr 26 | ameba: 27 | github: crystal-ameba/ameba 28 | clim: 29 | github: at-grandpa/clim 30 | duktape: 31 | github: jessedoyle/duktape.cr 32 | myhtml: 33 | github: kostya/myhtml 34 | http_proxy: 35 | github: mamantoha/http_proxy 36 | image_size: 37 | github: hkalexling/image_size.cr 38 | koa: 39 | github: hkalexling/koa 40 | tallboy: 41 | github: epoch/tallboy 42 | branch: master 43 | mg: 44 | github: hkalexling/mg 45 | sanitize: 46 | github: hkalexling/sanitize 47 | -------------------------------------------------------------------------------- /spec/asset/plugins/plugin/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uparrows/Mango_cn/61d2ef463570b1dd9d0ab27a4e9c08b04e28f111/spec/asset/plugins/plugin/index.js -------------------------------------------------------------------------------- /spec/asset/plugins/plugin/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "test", 3 | "title": "Test Plugin", 4 | "placeholder": "placeholder", 5 | "wait_seconds": 1 6 | } 7 | -------------------------------------------------------------------------------- /spec/asset/test-config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | port: 3000 3 | -------------------------------------------------------------------------------- /spec/config_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Config do 4 | it "creates default config if it does not exist" do 5 | with_default_config do |config, path| 6 | File.exists?(path).should be_true 7 | config.port.should eq 9000 8 | end 9 | end 10 | 11 | it "correctly loads config" do 12 | config = Config.load "spec/asset/test-config.yml" 13 | config.port.should eq 3000 14 | config.base_url.should eq "/" 15 | end 16 | 17 | it "correctly reads config defaults from ENV" do 18 | ENV["LOG_LEVEL"] = "debug" 19 | config = Config.load "spec/asset/test-config.yml" 20 | config.log_level.should eq "debug" 21 | config.base_url.should eq "/" 22 | end 23 | 24 | it "correctly handles ENV truthiness" do 25 | ENV["CACHE_ENABLED"] = "false" 26 | config = Config.load "spec/asset/test-config.yml" 27 | config.cache_enabled.should be_false 28 | config.cache_log_enabled.should be_true 29 | config.disable_login.should be_false 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/plugin_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Plugin do 4 | describe "helper functions" do 5 | it "mango.text" do 6 | with_plugin do |plugin| 7 | res = plugin.eval <<-JS 8 | mango.text('Click Me'); 9 | JS 10 | res.should eq "Click Me" 11 | end 12 | end 13 | 14 | it "mango.text returns empty string when no text" do 15 | with_plugin do |plugin| 16 | res = plugin.eval <<-JS 17 | mango.text(''); 18 | JS 19 | res.should eq "" 20 | end 21 | end 22 | 23 | it "mango.css" do 24 | with_plugin do |plugin| 25 | res = plugin.eval <<-JS 26 | mango.css('
  • A
  • B
  • C
', 'li.test'); 27 | 28 | JS 29 | res.should eq ["
  • A
  • ", "
  • B
  • "] 30 | end 31 | end 32 | 33 | it "mango.css returns empty array when no match" do 34 | with_plugin do |plugin| 35 | res = plugin.eval <<-JS 36 | mango.css('
    • A
    • B
    • C
    ', 'li.noclass'); 37 | JS 38 | res.should eq [] of String 39 | end 40 | end 41 | 42 | it "mango.attribute" do 43 | with_plugin do |plugin| 44 | res = plugin.eval <<-JS 45 | mango.attribute('
    Click Me', 'href'); 46 | JS 47 | res.should eq "https://github.com" 48 | end 49 | end 50 | 51 | it "mango.attribute returns undefined when no match" do 52 | with_plugin do |plugin| 53 | res = plugin.eval <<-JS 54 | mango.attribute('
    ', 'href') === undefined; 55 | JS 56 | res.should be_true 57 | end 58 | end 59 | 60 | # https://github.com/hkalexling/Mango/issues/320 61 | it "mango.attribute handles tags in attribute values" do 62 | with_plugin do |plugin| 63 | res = plugin.eval <<-JS 64 | mango.attribute('
    ', 'data-b'); 65 | JS 66 | res.should eq "test" 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/rename_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "../src/rename" 3 | 4 | include Rename 5 | 6 | describe Rule do 7 | it "raises on nested brackets" do 8 | expect_raises Exception do 9 | Rule.new "[[]]" 10 | end 11 | expect_raises Exception do 12 | Rule.new "{{}}" 13 | end 14 | end 15 | 16 | it "raises on unclosed brackets" do 17 | expect_raises Exception do 18 | Rule.new "[" 19 | end 20 | expect_raises Exception do 21 | Rule.new "{" 22 | end 23 | expect_raises Exception do 24 | Rule.new "[{]}" 25 | end 26 | end 27 | 28 | it "raises when closing unopened brackets" do 29 | expect_raises Exception do 30 | Rule.new "]" 31 | end 32 | expect_raises Exception do 33 | Rule.new "[}" 34 | end 35 | end 36 | 37 | it "handles `|` in patterns" do 38 | rule = Rule.new "{a|b|c}" 39 | rule.render({"b" => "b"}).should eq "b" 40 | rule.render({"a" => "a", "b" => "b"}).should eq "a" 41 | end 42 | 43 | it "raises on escaped characters" do 44 | expect_raises Exception do 45 | Rule.new "hello/world" 46 | end 47 | end 48 | 49 | it "handles spaces in patterns" do 50 | rule = Rule.new "{ a }" 51 | rule.render({"a" => "a"}).should eq "a" 52 | end 53 | 54 | it "strips leading and tailing spaces" do 55 | rule = Rule.new " hello " 56 | rule.render({"a" => "a"}).should eq "hello" 57 | end 58 | 59 | it "renders a few examples correctly" do 60 | rule = Rule.new "[Ch. {chapter }] {title | id} testing" 61 | rule.render({"id" => "ID"}).should eq "ID testing" 62 | rule.render({"chapter" => "CH", "id" => "ID"}) 63 | .should eq "Ch. CH ID testing" 64 | rule.render({} of String => String).should eq "testing" 65 | end 66 | 67 | it "escapes illegal characters" do 68 | rule = Rule.new "{a}" 69 | rule.render({"a" => "/?<>:*|\"^"}).should eq "_________" 70 | end 71 | 72 | it "strips trailing spaces and dots" do 73 | rule = Rule.new "hello. world. .." 74 | rule.render({} of String => String).should eq "hello. world" 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/queue" 3 | require "../src/server" 4 | require "../src/config" 5 | require "../src/main_fiber" 6 | require "../src/plugin/plugin" 7 | 8 | class State 9 | @@hash = {} of String => String 10 | 11 | def self.get(key) 12 | @@hash[key]? 13 | end 14 | 15 | def self.get!(key) 16 | @@hash[key] 17 | end 18 | 19 | def self.set(key, value) 20 | return if value.nil? 21 | @@hash[key] = value 22 | end 23 | 24 | def self.reset 25 | @@hash.clear 26 | end 27 | end 28 | 29 | def get_tempfile(name) 30 | path = State.get name 31 | if path.nil? || !File.exists? path 32 | file = File.tempfile name 33 | State.set name, file.path 34 | file 35 | else 36 | File.new path 37 | end 38 | end 39 | 40 | def with_default_config 41 | temp_config = get_tempfile "mango-test-config" 42 | config = Config.load temp_config.path 43 | config.set_current 44 | yield config, temp_config.path 45 | temp_config.delete 46 | end 47 | 48 | def with_storage 49 | with_default_config do 50 | temp_db = get_tempfile "mango-test-db" 51 | storage = Storage.new temp_db.path, false 52 | clear = yield storage, temp_db.path 53 | if clear == true 54 | temp_db.delete 55 | end 56 | end 57 | end 58 | 59 | def with_plugin 60 | with_default_config do 61 | plugin = Plugin.new "test", "spec/asset/plugins" 62 | yield plugin 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/storage_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Storage do 4 | it "creates DB at given path" do 5 | with_storage do |_, path| 6 | File.exists?(path).should be_true 7 | end 8 | end 9 | 10 | it "deletes user" do 11 | with_storage &.delete_user "admin" 12 | end 13 | 14 | it "creates new user" do 15 | with_storage do |storage| 16 | storage.new_user "user", "123456", false 17 | storage.new_user "admin", "123456", true 18 | end 19 | end 20 | 21 | it "verifies username/password combination" do 22 | with_storage do |storage| 23 | user_token = storage.verify_user "user", "123456" 24 | admin_token = storage.verify_user "admin", "123456" 25 | user_token.should_not be_nil 26 | admin_token.should_not be_nil 27 | State.set "user_token", user_token 28 | State.set "admin_token", admin_token 29 | end 30 | end 31 | 32 | it "rejects duplicate username" do 33 | with_storage do |storage| 34 | expect_raises SQLite3::Exception, 35 | "UNIQUE constraint failed: users.username" do 36 | storage.new_user "admin", "123456", true 37 | end 38 | end 39 | end 40 | 41 | it "verifies token" do 42 | with_storage do |storage| 43 | user_token = State.get! "user_token" 44 | user = storage.verify_token user_token 45 | user.should eq "user" 46 | end 47 | end 48 | 49 | it "verfies admin token" do 50 | with_storage do |storage| 51 | admin_token = State.get! "admin_token" 52 | storage.verify_admin(admin_token).should be_true 53 | end 54 | end 55 | 56 | it "rejects non-admin token" do 57 | with_storage do |storage| 58 | user_token = State.get! "user_token" 59 | storage.verify_admin(user_token).should be_false 60 | end 61 | end 62 | 63 | it "updates user" do 64 | with_storage do |storage| 65 | storage.update_user "admin", "admin", "654321", true 66 | token = storage.verify_user "admin", "654321" 67 | admin_token = State.get! "admin_token" 68 | token.should eq admin_token 69 | end 70 | end 71 | 72 | it "logs user out" do 73 | with_storage do |storage| 74 | user_token = State.get! "user_token" 75 | admin_token = State.get! "admin_token" 76 | storage.logout user_token 77 | storage.logout admin_token 78 | storage.verify_token(user_token).should be_nil 79 | storage.verify_token(admin_token).should be_nil 80 | end 81 | end 82 | 83 | it "cleans up" do 84 | with_storage do 85 | true 86 | end 87 | State.reset 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/util_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe "compare_numerically" do 4 | it "sorts filenames with leading zeros correctly" do 5 | ary = ["010.jpg", "001.jpg", "002.png"] 6 | ary.sort! { |a, b| 7 | compare_numerically a, b 8 | } 9 | ary.should eq ["001.jpg", "002.png", "010.jpg"] 10 | end 11 | 12 | it "sorts filenames without leading zeros correctly" do 13 | ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"] 14 | ary.sort! { |a, b| 15 | compare_numerically a, b 16 | } 17 | ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"] 18 | end 19 | 20 | # https://ux.stackexchange.com/a/95441 21 | it "sorts like the stack exchange post" do 22 | ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2", 23 | "text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"] 24 | ary.reverse.sort! { |a, b| 25 | compare_numerically a, b 26 | }.should eq ary 27 | end 28 | 29 | # https://github.com/hkalexling/Mango/issues/22 30 | it "handles numbers larger than Int32" do 31 | ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"] 32 | ary.reverse.sort! { |a, b| 33 | compare_numerically a, b 34 | }.should eq ary 35 | end 36 | end 37 | 38 | describe "is_supported_file" do 39 | it "returns true when the filename has a supported extension" do 40 | filename = "manga.cbz" 41 | is_supported_file(filename).should eq true 42 | end 43 | 44 | it "returns true when the filename does not have a supported extension" do 45 | filename = "info.json" 46 | is_supported_file(filename).should eq false 47 | end 48 | 49 | it "is case insensitive" do 50 | filename = "manga.ZiP" 51 | is_supported_file(filename).should eq true 52 | end 53 | end 54 | 55 | describe "chapter_sort" do 56 | it "sorts correctly" do 57 | ary = ["Vol.1 Ch.01", "Vol.1 Ch.02", "Vol.2 Ch. 2.5", "Ch. 3", "Ch.04"] 58 | sorter = ChapterSorter.new ary 59 | ary.reverse.sort! do |a, b| 60 | sorter.compare a, b 61 | end.should eq ary 62 | end 63 | end 64 | 65 | describe "sanitize_filename" do 66 | it "returns a random string for empty sanitized string" do 67 | sanitize_filename("..").should_not eq sanitize_filename("..") 68 | end 69 | it "sanitizes correctly" do 70 | sanitize_filename(".. \n\v.\rマンゴー/|*()<[1/2] 3.14 hello world ") 71 | .should eq "マンゴー_()[1_2] 3.14 hello world" 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /src/archive.cr: -------------------------------------------------------------------------------- 1 | require "compress/zip" 2 | require "archive" 3 | 4 | # A unified class to handle all supported archive formats. It uses the 5 | # Compress::Zip module in crystal standard library if the target file is 6 | # a zip archive. Otherwise it uses `archive.cr`. 7 | class ArchiveFile 8 | def initialize(@filename : String) 9 | if [".cbz", ".zip"].includes? File.extname filename 10 | @archive_file = Compress::Zip::File.new filename 11 | else 12 | @archive_file = Archive::File.new filename 13 | end 14 | end 15 | 16 | def self.open(filename : String, &) 17 | s = self.new filename 18 | yield s 19 | s.close 20 | end 21 | 22 | def close 23 | if @archive_file.is_a? Compress::Zip::File 24 | @archive_file.as(Compress::Zip::File).close 25 | end 26 | end 27 | 28 | # Lists all file entries 29 | def entries 30 | ary = [] of Compress::Zip::File::Entry | Archive::Entry 31 | @archive_file.entries.map do |e| 32 | if (e.is_a? Compress::Zip::File::Entry && e.file?) || 33 | (e.is_a? Archive::Entry && e.info.file?) 34 | ary.push e 35 | end 36 | end 37 | ary 38 | end 39 | 40 | def read_entry(e : Compress::Zip::File::Entry | Archive::Entry) : Bytes? 41 | if e.is_a? Compress::Zip::File::Entry 42 | data = nil 43 | e.open do |io| 44 | slice = Bytes.new e.uncompressed_size 45 | bytes_read = io.read_fully? slice 46 | data = slice if bytes_read 47 | end 48 | data 49 | else 50 | e.read 51 | end 52 | end 53 | 54 | def check 55 | if @archive_file.is_a? Archive::File 56 | @archive_file.as(Archive::File).check 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /src/config.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | class Config 4 | private OPTIONS = { 5 | "host" => "0.0.0.0", 6 | "port" => 9000, 7 | "base_url" => "/", 8 | "session_secret" => "mango-session-secret", 9 | "library_path" => "~/mango/library", 10 | "library_cache_path" => "~/mango/library.yml.gz", 11 | "db_path" => "~/mango.db", 12 | "queue_db_path" => "~/mango/queue.db", 13 | "scan_interval_minutes" => 5, 14 | "thumbnail_generation_interval_hours" => 24, 15 | "log_level" => "info", 16 | "upload_path" => "~/mango/uploads", 17 | "plugin_path" => "~/mango/plugins", 18 | "download_timeout_seconds" => 30, 19 | "cache_enabled" => true, 20 | "cache_size_mbs" => 50, 21 | "cache_log_enabled" => true, 22 | "disable_login" => false, 23 | "default_username" => "", 24 | "auth_proxy_header_name" => "", 25 | "plugin_update_interval_hours" => 24, 26 | } 27 | 28 | include YAML::Serializable 29 | 30 | @[YAML::Field(ignore: true)] 31 | property path : String = "" 32 | 33 | # Go through the options constant above and define them as properties. 34 | # Allow setting the default values through environment variables. 35 | # Overall precedence: config file > environment variable > default value 36 | {% begin %} 37 | {% for k, v in OPTIONS %} 38 | {% if v.is_a? StringLiteral %} 39 | property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }} 40 | {% elsif v.is_a? NumberLiteral %} 41 | property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i 42 | {% elsif v.is_a? BoolLiteral %} 43 | property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }} 44 | {% else %} 45 | raise "Unknown type in config option: {{ v.class_name.id }}" 46 | {% end %} 47 | {% end %} 48 | {% end %} 49 | 50 | @@singlet : Config? 51 | 52 | def self.current 53 | @@singlet.not_nil! 54 | end 55 | 56 | def set_current 57 | @@singlet = self 58 | end 59 | 60 | def self.load(path : String?) 61 | path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil? 62 | cfg_path = File.expand_path path, home: true 63 | if File.exists? cfg_path 64 | config = self.from_yaml File.read cfg_path 65 | config.path = path 66 | config.expand_paths 67 | config.preprocess 68 | return config 69 | end 70 | puts "The config file #{cfg_path} does not exist. " \ 71 | "Dumping the default config there." 72 | default = self.allocate 73 | default.path = path 74 | default.expand_paths 75 | cfg_dir = File.dirname cfg_path 76 | unless Dir.exists? cfg_dir 77 | Dir.mkdir_p cfg_dir 78 | end 79 | File.write cfg_path, default.to_yaml 80 | puts "The config file has been created at #{cfg_path}." 81 | default 82 | end 83 | 84 | def expand_paths 85 | {% for p in %w(library library_cache db queue_db upload plugin) %} 86 | @{{p.id}}_path = File.expand_path @{{p.id}}_path, home: true 87 | {% end %} 88 | end 89 | 90 | def preprocess 91 | unless base_url.starts_with? "/" 92 | raise "base url (#{base_url}) should start with `/`" 93 | end 94 | unless base_url.ends_with? "/" 95 | @base_url += "/" 96 | end 97 | if disable_login && default_username.empty? 98 | raise "Login is disabled, but default username is not set. " \ 99 | "Please set a default username" 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /src/handlers/auth_handler.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "../storage" 3 | require "../util/*" 4 | 5 | class AuthHandler < Kemal::Handler 6 | # Some of the code is copied form kemalcr/kemal-basic-auth on GitHub 7 | 8 | BASIC = "Basic" 9 | BEARER = "Bearer" 10 | AUTH = "Authorization" 11 | AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \ 12 | "You have to login with proper credentials" 13 | HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\"" 14 | 15 | def require_basic_auth(env) 16 | env.response.status_code = 401 17 | env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED 18 | env.response.print AUTH_MESSAGE 19 | end 20 | 21 | def require_auth(env) 22 | if request_path_startswith env, ["/api"] 23 | # Do not redirect API requests 24 | env.response.status_code = 401 25 | send_text env, "Unauthorized" 26 | else 27 | env.session.string "callback", env.request.path 28 | redirect env, "/login" 29 | end 30 | end 31 | 32 | def validate_token(env) 33 | token = env.session.string? "token" 34 | !token.nil? && Storage.default.verify_token token 35 | end 36 | 37 | def validate_token_admin(env) 38 | token = env.session.string? "token" 39 | !token.nil? && Storage.default.verify_admin token 40 | end 41 | 42 | def validate_auth_header(env) 43 | if env.request.headers[AUTH]? 44 | if value = env.request.headers[AUTH] 45 | if value.starts_with? BASIC 46 | token = verify_user value 47 | return false if token.nil? 48 | 49 | env.session.string "token", token 50 | return true 51 | end 52 | if value.starts_with? BEARER 53 | session_id = value.split(" ")[1] 54 | token = Kemal::Session.get(session_id).try &.string? "token" 55 | return !token.nil? && Storage.default.verify_token token 56 | end 57 | end 58 | end 59 | false 60 | end 61 | 62 | def verify_user(value) 63 | username, password = Base64.decode_string(value[BASIC.size + 1..-1]) 64 | .split(":") 65 | Storage.default.verify_user username, password 66 | end 67 | 68 | def call(env) 69 | # OPTIONS requests do not require authentication 70 | if env.request.method === "OPTIONS" 71 | return call_next(env) 72 | end 73 | # Skip all authentication if requesting /login, /logout, /api/login, 74 | # or a static file 75 | if request_path_startswith(env, ["/login", "/logout", "/api/login"]) || 76 | requesting_static_file env 77 | return call_next(env) 78 | end 79 | 80 | # Check user is logged in 81 | if validate_token(env) || validate_auth_header(env) 82 | # Skip if the request has a valid token (either from cookies or header) 83 | elsif Config.current.disable_login 84 | # Check default username if login is disabled 85 | unless Storage.default.username_exists Config.current.default_username 86 | Logger.warn "Default username #{Config.current.default_username} " \ 87 | "does not exist" 88 | return require_auth env 89 | end 90 | elsif !Config.current.auth_proxy_header_name.empty? 91 | # Check auth proxy if present 92 | username = env.request.headers[Config.current.auth_proxy_header_name]? 93 | unless username && Storage.default.username_exists username 94 | Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \ 95 | "or is not a valid username" 96 | return require_auth env 97 | end 98 | elsif request_path_startswith env, ["/opds"] 99 | # Check auth header if requesting an opds page 100 | unless validate_auth_header env 101 | return require_basic_auth env 102 | end 103 | else 104 | return require_auth env 105 | end 106 | 107 | # Check admin access when requesting an admin page 108 | if request_path_startswith env, %w(/admin /api/admin /download) 109 | unless is_admin? env 110 | env.response.status_code = 403 111 | return send_error_page "HTTP 403: You are not authorized to visit " \ 112 | "#{env.request.path}" 113 | end 114 | end 115 | 116 | # Let the request go through if it passes the above checks 117 | call_next env 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /src/handlers/cors_handler.cr: -------------------------------------------------------------------------------- 1 | class CORSHandler < Kemal::Handler 2 | def call(env) 3 | if request_path_startswith env, ["/api"] 4 | env.response.headers["Access-Control-Allow-Origin"] = "*" 5 | end 6 | call_next env 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/handlers/log_handler.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "../logger" 3 | 4 | class LogHandler < Kemal::BaseLogHandler 5 | def call(env) 6 | elapsed_time = Time.measure { call_next env } 7 | elapsed_text = elapsed_text elapsed_time 8 | msg = "#{env.response.status_code} #{env.request.method}" \ 9 | " #{env.request.resource} #{elapsed_text}" 10 | Logger.debug msg 11 | env 12 | end 13 | 14 | def write(msg) 15 | Logger.debug msg 16 | end 17 | 18 | private def elapsed_text(elapsed) 19 | millis = elapsed.total_milliseconds 20 | return "#{millis.round(2)}ms" if millis >= 1 21 | "#{(millis * 1000).round(2)}µs" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /src/handlers/static_handler.cr: -------------------------------------------------------------------------------- 1 | require "baked_file_system" 2 | require "kemal" 3 | require "../util/*" 4 | 5 | class FS 6 | extend BakedFileSystem 7 | {% if flag?(:release) %} 8 | {% if read_file? "#{__DIR__}/../../dist/favicon.ico" %} 9 | {% puts "baking ../../dist" %} 10 | bake_folder "../../dist" 11 | {% else %} 12 | {% puts "baking ../../public" %} 13 | bake_folder "../../public" 14 | {% end %} 15 | {% end %} 16 | end 17 | 18 | class StaticHandler < Kemal::Handler 19 | def call(env) 20 | if requesting_static_file env 21 | file = FS.get? env.request.path 22 | return call_next env if file.nil? 23 | 24 | slice = Bytes.new file.size 25 | file.read slice 26 | return send_file env, slice, MIME.from_filename file.path 27 | end 28 | call_next env 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /src/handlers/upload_handler.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "../util/*" 3 | 4 | class UploadHandler < Kemal::Handler 5 | def initialize(@upload_dir : String) 6 | end 7 | 8 | def call(env) 9 | unless request_path_startswith(env, [UPLOAD_URL_PREFIX]) && 10 | env.request.method == "GET" 11 | return call_next env 12 | end 13 | 14 | ary = env.request.path.split(File::SEPARATOR).select do |part| 15 | !part.empty? 16 | end 17 | ary[0] = @upload_dir 18 | path = File.join ary 19 | 20 | if File.exists? path 21 | send_file env, path 22 | else 23 | env.response.status_code = 404 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/library/archive_entry.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | require "./entry" 4 | 5 | class ArchiveEntry < Entry 6 | include YAML::Serializable 7 | 8 | getter zip_path : String 9 | 10 | def initialize(@zip_path, @book) 11 | storage = Storage.default 12 | @path = @zip_path 13 | @encoded_path = URI.encode @zip_path 14 | @title = File.basename @zip_path, File.extname @zip_path 15 | @encoded_title = URI.encode @title 16 | @size = (File.size @zip_path).humanize_bytes 17 | id = storage.get_entry_id @zip_path, File.signature(@zip_path) 18 | if id.nil? 19 | id = random_str 20 | storage.insert_entry_id({ 21 | path: @zip_path, 22 | id: id, 23 | signature: File.signature(@zip_path).to_s, 24 | }) 25 | end 26 | @id = id 27 | @mtime = File.info(@zip_path).modification_time 28 | 29 | unless File.readable? @zip_path 30 | @err_msg = "File #{@zip_path} is not readable." 31 | Logger.warn "#{@err_msg} Please make sure the " \ 32 | "file permission is configured correctly." 33 | return 34 | end 35 | 36 | archive_exception = validate_archive @zip_path 37 | unless archive_exception.nil? 38 | @err_msg = "Archive error: #{archive_exception}" 39 | Logger.warn "Unable to extract archive #{@zip_path}. " \ 40 | "Ignoring it. #{@err_msg}" 41 | return 42 | end 43 | 44 | file = ArchiveFile.new @zip_path 45 | @pages = file.entries.count do |e| 46 | SUPPORTED_IMG_TYPES.includes? \ 47 | MIME.from_filename? e.filename 48 | end 49 | file.close 50 | end 51 | 52 | private def sorted_archive_entries 53 | ArchiveFile.open @zip_path do |file| 54 | entries = file.entries 55 | .select { |e| 56 | SUPPORTED_IMG_TYPES.includes? \ 57 | MIME.from_filename? e.filename 58 | } 59 | .sort! { |a, b| 60 | compare_numerically a.filename, b.filename 61 | } 62 | yield file, entries 63 | end 64 | end 65 | 66 | def read_page(page_num) 67 | raise "Unreadble archive. #{@err_msg}" if @err_msg 68 | img = nil 69 | begin 70 | sorted_archive_entries do |file, entries| 71 | page = entries[page_num - 1] 72 | data = file.read_entry page 73 | if data 74 | img = Image.new data, MIME.from_filename(page.filename), 75 | page.filename, data.size 76 | end 77 | end 78 | rescue e 79 | Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}" 80 | end 81 | img 82 | end 83 | 84 | def page_dimensions 85 | sizes = [] of Hash(String, Int32) 86 | sorted_archive_entries do |file, entries| 87 | entries.each_with_index do |e, i| 88 | begin 89 | data = file.read_entry(e).not_nil! 90 | size = ImageSize.get data 91 | sizes << { 92 | "width" => size.width, 93 | "height" => size.height, 94 | } 95 | rescue e 96 | Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}" 97 | sizes << {"width" => 1000_i32, "height" => 1000_i32} 98 | end 99 | end 100 | end 101 | sizes 102 | end 103 | 104 | def examine : Bool 105 | File.exists? @zip_path 106 | end 107 | 108 | def self.is_valid?(path : String) : Bool 109 | is_supported_file path 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /src/library/cache.cr: -------------------------------------------------------------------------------- 1 | require "digest" 2 | 3 | require "./entry" 4 | require "./title" 5 | require "./types" 6 | 7 | # Base class for an entry in the LRU cache. 8 | # There are two ways to use it: 9 | # 1. Use it as it is by instantiating with the appropriate `SaveT` and 10 | # `ReturnT`. Note that in this case, `SaveT` and `ReturnT` must be the 11 | # same type. That is, the input value will be stored as it is without 12 | # any transformation. 13 | # 2. You can also subclass it and provide custom implementations for 14 | # `to_save_t` and `to_return_t`. This allows you to transform and store 15 | # the input value to a different type. See `SortedEntriesCacheEntry` as 16 | # an example. 17 | private class CacheEntry(SaveT, ReturnT) 18 | getter key : String, atime : Time 19 | 20 | @value : SaveT 21 | 22 | def initialize(@key : String, value : ReturnT) 23 | @atime = @ctime = Time.utc 24 | @value = self.class.to_save_t value 25 | end 26 | 27 | def value 28 | @atime = Time.utc 29 | self.class.to_return_t @value 30 | end 31 | 32 | def self.to_save_t(value : ReturnT) 33 | value 34 | end 35 | 36 | def self.to_return_t(value : SaveT) 37 | value 38 | end 39 | 40 | def instance_size 41 | instance_sizeof(CacheEntry(SaveT, ReturnT)) + # sizeof itself 42 | instance_sizeof(String) + @key.bytesize + # allocated memory for @key 43 | @value.instance_size 44 | end 45 | end 46 | 47 | class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry)) 48 | def self.to_save_t(value : Array(Entry)) 49 | value.map &.id 50 | end 51 | 52 | def self.to_return_t(value : Array(String)) 53 | ids_to_entries value 54 | end 55 | 56 | private def self.ids_to_entries(ids : Array(String)) 57 | e_map = Library.default.deep_entries.to_h { |entry| {entry.id, entry} } 58 | entries = [] of Entry 59 | begin 60 | ids.each do |id| 61 | entries << e_map[id] 62 | end 63 | return entries if ids.size == entries.size 64 | rescue 65 | end 66 | end 67 | 68 | def instance_size 69 | instance_sizeof(SortedEntriesCacheEntry) + # sizeof itself 70 | instance_sizeof(String) + @key.bytesize + # allocated memory for @key 71 | @value.size * (instance_sizeof(String) + sizeof(String)) + 72 | @value.sum(&.bytesize) # elements in Array(String) 73 | end 74 | 75 | def self.gen_key(book_id : String, username : String, 76 | entries : Array(Entry), opt : SortOptions?) 77 | entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s 78 | user_context = opt && opt.method == SortMethod::Progress ? username : "" 79 | sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context + 80 | (opt ? opt.to_tuple.to_s : "nil")) 81 | "#{sig}:sorted_entries" 82 | end 83 | end 84 | 85 | class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title)) 86 | def self.to_save_t(value : Array(Title)) 87 | value.map &.id 88 | end 89 | 90 | def self.to_return_t(value : Array(String)) 91 | value.map { |title_id| Library.default.title_hash[title_id].not_nil! } 92 | end 93 | 94 | def instance_size 95 | instance_sizeof(SortedTitlesCacheEntry) + # sizeof itself 96 | instance_sizeof(String) + @key.bytesize + # allocated memory for @key 97 | @value.size * (instance_sizeof(String) + sizeof(String)) + 98 | @value.sum(&.bytesize) # elements in Array(String) 99 | end 100 | 101 | def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?) 102 | titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s 103 | user_context = opt && opt.method == SortMethod::Progress ? username : "" 104 | sig = Digest::SHA1.hexdigest(titles_sig + user_context + 105 | (opt ? opt.to_tuple.to_s : "nil")) 106 | "#{sig}:sorted_titles" 107 | end 108 | end 109 | 110 | class String 111 | def instance_size 112 | instance_sizeof(String) + bytesize 113 | end 114 | end 115 | 116 | struct Tuple(*T) 117 | def instance_size 118 | sizeof(T) + # total size of non-reference types 119 | self.sum do |e| 120 | next 0 unless e.is_a? Reference 121 | if e.responds_to? :instance_size 122 | e.instance_size 123 | else 124 | instance_sizeof(typeof(e)) 125 | end 126 | end 127 | end 128 | end 129 | 130 | alias CacheableType = Array(Entry) | Array(Title) | String | 131 | Tuple(String, Int32) 132 | alias CacheEntryType = SortedEntriesCacheEntry | 133 | SortedTitlesCacheEntry | 134 | CacheEntry(String, String) | 135 | CacheEntry(Tuple(String, Int32), Tuple(String, Int32)) 136 | 137 | def generate_cache_entry(key : String, value : CacheableType) 138 | if value.is_a? Array(Entry) 139 | SortedEntriesCacheEntry.new key, value 140 | elsif value.is_a? Array(Title) 141 | SortedTitlesCacheEntry.new key, value 142 | else 143 | CacheEntry(typeof(value), typeof(value)).new key, value 144 | end 145 | end 146 | 147 | # LRU Cache 148 | class LRUCache 149 | @@limit : Int128 = Int128.new 0 150 | @@should_log = true 151 | # key => entry 152 | @@cache = {} of String => CacheEntryType 153 | 154 | def self.enabled 155 | Config.current.cache_enabled 156 | end 157 | 158 | def self.init 159 | cache_size = Config.current.cache_size_mbs 160 | @@limit = Int128.new cache_size * 1024 * 1024 if enabled 161 | @@should_log = Config.current.cache_log_enabled 162 | end 163 | 164 | def self.get(key : String) 165 | return unless enabled 166 | entry = @@cache[key]? 167 | if @@should_log 168 | Logger.debug "LRUCache #{entry.nil? ? "miss" : "hit"} #{key}" 169 | end 170 | return entry.value unless entry.nil? 171 | end 172 | 173 | def self.set(cache_entry : CacheEntryType) 174 | return unless enabled 175 | key = cache_entry.key 176 | @@cache[key] = cache_entry 177 | Logger.debug "LRUCache cached #{key}" if @@should_log 178 | remove_least_recent_access 179 | end 180 | 181 | def self.invalidate(key : String) 182 | return unless enabled 183 | @@cache.delete key 184 | end 185 | 186 | def self.print 187 | return unless @@should_log 188 | sum = @@cache.sum { |_, entry| entry.instance_size } 189 | Logger.debug "---- LRU Cache ----" 190 | Logger.debug "Size: #{sum} Bytes" 191 | Logger.debug "List:" 192 | @@cache.each do |k, v| 193 | Logger.debug "#{k} | #{v.atime} | #{v.instance_size}" 194 | end 195 | Logger.debug "-------------------" 196 | end 197 | 198 | private def self.is_cache_full 199 | sum = @@cache.sum { |_, entry| entry.instance_size } 200 | sum > @@limit 201 | end 202 | 203 | private def self.remove_least_recent_access 204 | if @@should_log && is_cache_full 205 | Logger.debug "Removing entries from LRUCache" 206 | end 207 | while is_cache_full && @@cache.size > 0 208 | min_tuple = @@cache.min_by { |_, entry| entry.atime } 209 | min_key = min_tuple[0] 210 | min_entry = min_tuple[1] 211 | 212 | Logger.debug " \ 213 | Target: #{min_key}, \ 214 | Last Access Time: #{min_entry.atime}" if @@should_log 215 | invalidate min_key 216 | end 217 | end 218 | end 219 | -------------------------------------------------------------------------------- /src/library/dir_entry.cr: -------------------------------------------------------------------------------- 1 | require "yaml" 2 | 3 | require "./entry" 4 | 5 | class DirEntry < Entry 6 | include YAML::Serializable 7 | 8 | getter dir_path : String 9 | 10 | @[YAML::Field(ignore: true)] 11 | @sorted_files : Array(String)? 12 | 13 | @signature : String 14 | 15 | def initialize(@dir_path, @book) 16 | storage = Storage.default 17 | @path = @dir_path 18 | @encoded_path = URI.encode @dir_path 19 | @title = File.basename @dir_path 20 | @encoded_title = URI.encode @title 21 | 22 | unless File.readable? @dir_path 23 | @err_msg = "Directory #{@dir_path} is not readable." 24 | Logger.warn "#{@err_msg} Please make sure the " \ 25 | "file permission is configured correctly." 26 | return 27 | end 28 | 29 | unless DirEntry.is_valid? @dir_path 30 | @err_msg = "Directory #{@dir_path} is not valid directory entry." 31 | Logger.warn "#{@err_msg} Please make sure the " \ 32 | "directory has valid images." 33 | return 34 | end 35 | 36 | size_sum = 0 37 | sorted_files.each do |file_path| 38 | size_sum += File.size file_path 39 | end 40 | @size = size_sum.humanize_bytes 41 | 42 | @signature = Dir.directory_entry_signature @dir_path 43 | id = storage.get_entry_id @dir_path, @signature 44 | if id.nil? 45 | id = random_str 46 | storage.insert_entry_id({ 47 | path: @dir_path, 48 | id: id, 49 | signature: @signature, 50 | }) 51 | end 52 | @id = id 53 | 54 | @mtime = sorted_files.map do |file_path| 55 | File.info(file_path).modification_time 56 | end.max 57 | @pages = sorted_files.size 58 | end 59 | 60 | def read_page(page_num) 61 | img = nil 62 | begin 63 | files = sorted_files 64 | file_path = files[page_num - 1] 65 | data = File.read(file_path).to_slice 66 | if data 67 | img = Image.new data, MIME.from_filename(file_path), 68 | File.basename(file_path), data.size 69 | end 70 | rescue e 71 | Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}" 72 | end 73 | img 74 | end 75 | 76 | def page_dimensions 77 | sizes = [] of Hash(String, Int32) 78 | sorted_files.each_with_index do |path, i| 79 | data = File.read(path).to_slice 80 | begin 81 | data.not_nil! 82 | size = ImageSize.get data 83 | sizes << { 84 | "width" => size.width, 85 | "height" => size.height, 86 | } 87 | rescue e 88 | Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}" 89 | sizes << {"width" => 1000_i32, "height" => 1000_i32} 90 | end 91 | end 92 | sizes 93 | end 94 | 95 | def examine : Bool 96 | existence = File.exists? @dir_path 97 | return false unless existence 98 | files = DirEntry.image_files @dir_path 99 | signature = Dir.directory_entry_signature @dir_path 100 | existence = files.size > 0 && @signature == signature 101 | @sorted_files = nil unless existence 102 | 103 | # For more efficient, update a directory entry with new property 104 | # and return true like Title.examine 105 | existence 106 | end 107 | 108 | def sorted_files 109 | cached_sorted_files = @sorted_files 110 | return cached_sorted_files if cached_sorted_files 111 | @sorted_files = DirEntry.sorted_image_files @dir_path 112 | @sorted_files.not_nil! 113 | end 114 | 115 | def self.image_files(dir_path) 116 | Dir.entries(dir_path) 117 | .reject(&.starts_with? ".") 118 | .map { |fn| File.join dir_path, fn } 119 | .select { |fn| is_supported_image_file fn } 120 | .reject { |fn| File.directory? fn } 121 | .select { |fn| File.readable? fn } 122 | end 123 | 124 | def self.sorted_image_files(dir_path) 125 | self.image_files(dir_path) 126 | .sort { |a, b| compare_numerically a, b } 127 | end 128 | 129 | def self.is_valid?(path : String) : Bool 130 | image_files(path).size > 0 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /src/library/entry.cr: -------------------------------------------------------------------------------- 1 | require "image_size" 2 | 3 | private def node_has_key(node : YAML::Nodes::Mapping, key : String) 4 | node.nodes 5 | .map_with_index { |n, i| {n, i} } 6 | .select(&.[1].even?) 7 | .map(&.[0]) 8 | .select(YAML::Nodes::Scalar) 9 | .map(&.as(YAML::Nodes::Scalar).value) 10 | .includes? key 11 | end 12 | 13 | abstract class Entry 14 | getter id : String, book : Title, title : String, path : String, 15 | size : String, pages : Int32, mtime : Time, 16 | encoded_path : String, encoded_title : String, err_msg : String? 17 | 18 | def initialize( 19 | @id, @title, @book, @path, 20 | @size, @pages, @mtime, 21 | @encoded_path, @encoded_title, @err_msg 22 | ) 23 | end 24 | 25 | def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node) 26 | unless node.is_a? YAML::Nodes::Mapping 27 | raise "Unexpected node type in YAML" 28 | end 29 | # Doing YAML::Any.new(ctx, node) here causes a weird error, so 30 | # instead we are using a more hacky approach (see `node_has_key`). 31 | # TODO: Use a more elegant approach 32 | if node_has_key node, "zip_path" 33 | ArchiveEntry.new ctx, node 34 | elsif node_has_key node, "dir_path" 35 | DirEntry.new ctx, node 36 | else 37 | raise "Unknown entry found in YAML cache. Try deleting the " \ 38 | "`library.yml.gz` file" 39 | end 40 | end 41 | 42 | def build_json(*, slim = false) 43 | JSON.build do |json| 44 | json.object do 45 | {% for str in %w(path title size id) %} 46 | json.field {{str}}, {{str.id}} 47 | {% end %} 48 | if err_msg 49 | json.field "err_msg", err_msg 50 | end 51 | json.field "zip_path", path # for API backward compatability 52 | json.field "path", path 53 | json.field "title_id", @book.id 54 | json.field "title_title", @book.title 55 | json.field "sort_title", sort_title 56 | json.field "pages" { json.number @pages } 57 | unless slim 58 | json.field "display_name", @book.display_name @title 59 | json.field "cover_url", cover_url 60 | json.field "mtime" { json.number @mtime.to_unix } 61 | end 62 | end 63 | end 64 | end 65 | 66 | @[YAML::Field(ignore: true)] 67 | @sort_title : String? 68 | 69 | def sort_title 70 | sort_title_cached = @sort_title 71 | return sort_title_cached if sort_title_cached 72 | sort_title = @book.entry_sort_title_db id 73 | if sort_title 74 | @sort_title = sort_title 75 | return sort_title 76 | end 77 | @sort_title = @title 78 | @title 79 | end 80 | 81 | def set_sort_title(sort_title : String | Nil, username : String) 82 | Storage.default.set_entry_sort_title id, sort_title 83 | if sort_title == "" || sort_title.nil? 84 | @sort_title = nil 85 | else 86 | @sort_title = sort_title 87 | end 88 | 89 | @book.entry_sort_title_cache = nil 90 | @book.remove_sorted_entries_cache [SortMethod::Auto, SortMethod::Title], 91 | username 92 | end 93 | 94 | def sort_title_db 95 | @book.entry_sort_title_db @id 96 | end 97 | 98 | def display_name 99 | @book.display_name @title 100 | end 101 | 102 | def encoded_display_name 103 | URI.encode display_name 104 | end 105 | 106 | def cover_url 107 | return "#{Config.current.base_url}img/icons/icon_x192.png" if @err_msg 108 | 109 | unless @book.entry_cover_url_cache 110 | TitleInfo.new @book.dir do |info| 111 | @book.entry_cover_url_cache = info.entry_cover_url 112 | end 113 | end 114 | entry_cover_url = @book.entry_cover_url_cache 115 | 116 | url = "#{Config.current.base_url}api/cover/#{@book.id}/#{@id}" 117 | if entry_cover_url 118 | info_url = entry_cover_url[@title]? 119 | unless info_url.nil? || info_url.empty? 120 | url = File.join Config.current.base_url, info_url 121 | end 122 | end 123 | url 124 | end 125 | 126 | def next_entry(username) 127 | entries = @book.sorted_entries username 128 | idx = entries.index self 129 | return nil if idx.nil? || idx == entries.size - 1 130 | entries[idx + 1] 131 | end 132 | 133 | def previous_entry(username) 134 | entries = @book.sorted_entries username 135 | idx = entries.index self 136 | return nil if idx.nil? || idx == 0 137 | entries[idx - 1] 138 | end 139 | 140 | # For backward backward compatibility with v0.1.0, we save entry titles 141 | # instead of IDs in info.json 142 | def save_progress(username, page) 143 | LRUCache.invalidate "#{@book.id}:#{username}:progress_sum" 144 | @book.parents.each do |parent| 145 | LRUCache.invalidate "#{parent.id}:#{username}:progress_sum" 146 | end 147 | @book.remove_sorted_caches [SortMethod::Progress], username 148 | 149 | TitleInfo.new @book.dir do |info| 150 | if info.progress[username]?.nil? 151 | info.progress[username] = {@title => page} 152 | else 153 | info.progress[username][@title] = page 154 | end 155 | # save last_read timestamp 156 | if info.last_read[username]?.nil? 157 | info.last_read[username] = {@title => Time.utc} 158 | else 159 | info.last_read[username][@title] = Time.utc 160 | end 161 | info.save 162 | end 163 | end 164 | 165 | def load_progress(username) 166 | progress = 0 167 | TitleInfo.new @book.dir do |info| 168 | unless info.progress[username]?.nil? || 169 | info.progress[username][@title]?.nil? 170 | progress = info.progress[username][@title] 171 | end 172 | end 173 | [progress, @pages].min 174 | end 175 | 176 | def load_percentage(username) 177 | page = load_progress username 178 | page / @pages 179 | end 180 | 181 | def load_last_read(username) 182 | last_read = nil 183 | TitleInfo.new @book.dir do |info| 184 | unless info.last_read[username]?.nil? || 185 | info.last_read[username][@title]?.nil? 186 | last_read = info.last_read[username][@title] 187 | end 188 | end 189 | last_read 190 | end 191 | 192 | def finished?(username) 193 | load_progress(username) == @pages 194 | end 195 | 196 | def started?(username) 197 | load_progress(username) > 0 198 | end 199 | 200 | def generate_thumbnail : Image? 201 | return if @err_msg 202 | 203 | img = read_page(1).not_nil! 204 | begin 205 | size = ImageSize.get img.data 206 | if size.height > size.width 207 | thumbnail = ImageSize.resize img.data, width: 200 208 | else 209 | thumbnail = ImageSize.resize img.data, height: 300 210 | end 211 | img.data = thumbnail 212 | img.size = thumbnail.size 213 | unless img.mime == "image/webp" 214 | # image_size.cr resizes non-webp images to jpg 215 | img.mime = "image/jpeg" 216 | end 217 | Storage.default.save_thumbnail @id, img 218 | rescue e 219 | Logger.warn "Failed to generate thumbnail for file #{path}. #{e}" 220 | end 221 | 222 | img 223 | end 224 | 225 | def get_thumbnail : Image? 226 | Storage.default.get_thumbnail @id 227 | end 228 | 229 | def date_added : Time 230 | date_added = Time::UNIX_EPOCH 231 | TitleInfo.new @book.dir do |info| 232 | info_da = info.date_added[@title]? 233 | if info_da.nil? 234 | date_added = info.date_added[@title] = ctime path 235 | info.save 236 | else 237 | date_added = info_da 238 | end 239 | end 240 | date_added 241 | end 242 | 243 | # Hack to have abstract class methods 244 | # https://github.com/crystal-lang/crystal/issues/5956 245 | private module ClassMethods 246 | abstract def is_valid?(path : String) : Bool 247 | end 248 | 249 | macro inherited 250 | extend ClassMethods 251 | end 252 | 253 | abstract def read_page(page_num) 254 | 255 | abstract def page_dimensions 256 | 257 | abstract def examine : Bool? 258 | end 259 | -------------------------------------------------------------------------------- /src/library/types.cr: -------------------------------------------------------------------------------- 1 | enum SortMethod 2 | Auto 3 | Title 4 | Progress 5 | TimeModified 6 | TimeAdded 7 | end 8 | 9 | class SortOptions 10 | property method : SortMethod, ascend : Bool 11 | 12 | def initialize(in_method : String? = nil, @ascend = true) 13 | @method = SortMethod::Auto 14 | SortMethod.each do |m, _| 15 | if in_method && m.to_s.underscore == in_method 16 | @method = m 17 | return 18 | end 19 | end 20 | end 21 | 22 | def initialize(in_method : SortMethod? = nil, @ascend = true) 23 | if in_method 24 | @method = in_method 25 | else 26 | @method = SortMethod::Auto 27 | end 28 | end 29 | 30 | def self.from_tuple(tp : Tuple(String, Bool)) 31 | method, ascend = tp 32 | self.new method, ascend 33 | end 34 | 35 | def self.from_info_json(dir, username) 36 | opt = SortOptions.new 37 | TitleInfo.new dir do |info| 38 | if info.sort_by.has_key? username 39 | opt = SortOptions.from_tuple info.sort_by[username] 40 | end 41 | end 42 | opt 43 | end 44 | 45 | def to_tuple 46 | {@method.to_s.underscore, ascend} 47 | end 48 | 49 | def to_json 50 | { 51 | "method" => method.to_s.underscore, 52 | "ascend" => ascend, 53 | }.to_json 54 | end 55 | end 56 | 57 | struct Image 58 | property data : Bytes 59 | property mime : String 60 | property filename : String 61 | property size : Int32 62 | 63 | def initialize(@data, @mime, @filename, @size) 64 | end 65 | 66 | def self.from_db(res : DB::ResultSet) 67 | img = Image.allocate 68 | res.read String 69 | img.data = res.read Bytes 70 | img.filename = res.read String 71 | img.mime = res.read String 72 | img.size = res.read Int32 73 | img 74 | end 75 | end 76 | 77 | class TitleInfo 78 | include JSON::Serializable 79 | 80 | property comment = "Generated by Mango. DO NOT EDIT!" 81 | property progress = {} of String => Hash(String, Int32) 82 | property display_name = "" 83 | property entry_display_name = {} of String => String 84 | property cover_url = "" 85 | property entry_cover_url = {} of String => String 86 | property last_read = {} of String => Hash(String, Time) 87 | property date_added = {} of String => Time 88 | property sort_by = {} of String => Tuple(String, Bool) 89 | 90 | @[JSON::Field(ignore: true)] 91 | property dir : String = "" 92 | 93 | @@mutex_hash = {} of String => Mutex 94 | 95 | def self.new(dir, &) 96 | key = "#{dir}:info.json" 97 | info = LRUCache.get key 98 | if info.is_a? String 99 | begin 100 | instance = TitleInfo.from_json info 101 | instance.dir = dir 102 | yield instance 103 | return 104 | rescue 105 | end 106 | end 107 | 108 | if @@mutex_hash[dir]? 109 | mutex = @@mutex_hash[dir] 110 | else 111 | mutex = Mutex.new 112 | @@mutex_hash[dir] = mutex 113 | end 114 | mutex.synchronize do 115 | instance = TitleInfo.allocate 116 | json_path = File.join dir, "info.json" 117 | if File.exists? json_path 118 | instance = TitleInfo.from_json File.read json_path 119 | end 120 | instance.dir = dir 121 | LRUCache.set generate_cache_entry key, instance.to_json 122 | yield instance 123 | end 124 | end 125 | 126 | def save 127 | json_path = File.join @dir, "info.json" 128 | File.write json_path, self.to_pretty_json 129 | key = "#{@dir}:info.json" 130 | LRUCache.set generate_cache_entry key, self.to_json 131 | end 132 | end 133 | 134 | alias ExamineContext = NamedTuple( 135 | cached_contents_signature: Hash(String, String), 136 | deleted_title_ids: Array(String), 137 | deleted_entry_ids: Array(String)) 138 | -------------------------------------------------------------------------------- /src/logger.cr: -------------------------------------------------------------------------------- 1 | require "log" 2 | require "colorize" 3 | 4 | class Logger 5 | LEVELS = ["debug", "error", "fatal", "info", "warn"] 6 | SEVERITY_IDS = [0, 4, 5, 2, 3] 7 | COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta] 8 | 9 | getter raw_log = Log.for "" 10 | 11 | @@severity : Log::Severity = :info 12 | 13 | use_default 14 | 15 | def initialize 16 | @@severity = Logger.get_severity 17 | @backend = Log::IOBackend.new 18 | 19 | format_proc = ->(entry : Log::Entry, io : IO) do 20 | color = :default 21 | {% begin %} 22 | case entry.severity.label.to_s().downcase 23 | {% for lvl, i in LEVELS %} 24 | when {{lvl}}, "#{{{lvl}}}ing" 25 | color = COLORS[{{i}}] 26 | {% end %} 27 | else 28 | end 29 | {% end %} 30 | 31 | io << "[#{entry.severity.label}]".ljust(10).colorize(color) 32 | io << entry.timestamp.to_s("%Y/%m/%d %H:%M:%S") << " | " 33 | io << entry.message 34 | end 35 | 36 | @backend.formatter = Log::Formatter.new &format_proc 37 | 38 | Log.setup do |c| 39 | c.bind "*", @@severity, @backend 40 | c.bind "db.*", :error, @backend 41 | c.bind "duktape", :none, @backend 42 | end 43 | end 44 | 45 | def self.get_severity(level = "") : Log::Severity 46 | if level.empty? 47 | level = Config.current.log_level 48 | end 49 | {% begin %} 50 | case level.downcase 51 | when "off" 52 | return Log::Severity::None 53 | {% for lvl, i in LEVELS %} 54 | when {{lvl}} 55 | return Log::Severity.new SEVERITY_IDS[{{i}}] 56 | {% end %} 57 | else 58 | raise "Unknown log level #{level}" 59 | end 60 | {% end %} 61 | end 62 | 63 | # Ignores @@severity and always log msg 64 | def log(msg) 65 | @backend.write Log::Entry.new "", Log::Severity::None, msg, 66 | Log::Metadata.empty, nil 67 | end 68 | 69 | def self.log(msg) 70 | default.log msg 71 | end 72 | 73 | {% for lvl in LEVELS %} 74 | def {{lvl.id}}(msg) 75 | raw_log.{{lvl.id}} { msg } 76 | end 77 | def self.{{lvl.id}}(msg) 78 | default.not_nil!.{{lvl.id}} msg 79 | end 80 | {% end %} 81 | end 82 | -------------------------------------------------------------------------------- /src/main_fiber.cr: -------------------------------------------------------------------------------- 1 | # On ARM, connecting to the SQLite DB from a spawned fiber would crash 2 | # https://github.com/crystal-lang/crystal-sqlite3/issues/30 3 | # This is a temporary workaround that forces the relevant code to run in the 4 | # main fiber 5 | 6 | class MainFiber 7 | @@channel = Channel(-> Nil).new 8 | @@done = Channel(Bool).new 9 | @@main_fiber = Fiber.current 10 | 11 | def self.start_and_block 12 | loop do 13 | if proc = @@channel.receive 14 | begin 15 | proc.call 16 | ensure 17 | @@done.send true 18 | end 19 | end 20 | Fiber.yield 21 | end 22 | end 23 | 24 | def self.run(&block : -> Nil) 25 | if @@main_fiber == Fiber.current 26 | block.call 27 | else 28 | @@channel.send block 29 | until @@done.receive 30 | Fiber.yield 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /src/mango.cr: -------------------------------------------------------------------------------- 1 | require "./config" 2 | require "./queue" 3 | require "./server" 4 | require "./main_fiber" 5 | require "./plugin/*" 6 | require "option_parser" 7 | require "clim" 8 | require "tallboy" 9 | 10 | MANGO_VERSION = "0.27.0" 11 | 12 | # From http://www.network-science.de/ascii/ 13 | BANNER = %{ 14 | 15 | _| _| 16 | _|_| _|_| _|_|_| _|_|_| _|_|_| _|_| 17 | _| _| _| _| _| _| _| _| _| _| _| 18 | _| _| _| _| _| _| _| _| _| _| 19 | _| _| _|_|_| _| _| _|_|_| _|_| 20 | _| 21 | _|_| 22 | 23 | 24 | } 25 | 26 | DESCRIPTION = "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}" 27 | 28 | macro common_option 29 | option "-c PATH", "--config=PATH", type: String, 30 | desc: "Path to the config file" 31 | end 32 | 33 | macro throw(msg) 34 | puts "ERROR: #{{{msg}}}" 35 | puts 36 | puts "Please see the `--help`." 37 | exit 1 38 | end 39 | 40 | class CLI < Clim 41 | main do 42 | desc DESCRIPTION 43 | usage "mango [sub_command] [options]" 44 | help short: "-h" 45 | version "Version #{MANGO_VERSION}", short: "-v" 46 | common_option 47 | run do |opts| 48 | puts BANNER 49 | puts DESCRIPTION 50 | puts 51 | 52 | # empty ARGV so it won't be passed to Kemal 53 | ARGV.clear 54 | 55 | Config.load(opts.config).set_current 56 | 57 | # Initialize main components 58 | LRUCache.init 59 | Storage.default 60 | Queue.default 61 | Library.load_instance 62 | Library.default 63 | Plugin::Downloader.default 64 | Plugin::Updater.default 65 | 66 | spawn do 67 | begin 68 | Server.new.start 69 | rescue e 70 | Logger.fatal e 71 | Process.exit 1 72 | end 73 | end 74 | 75 | MainFiber.start_and_block 76 | end 77 | 78 | sub "admin" do 79 | desc "Run admin tools" 80 | usage "mango admin [tool]" 81 | help short: "-h" 82 | run do |opts| 83 | puts opts.help_string 84 | end 85 | sub "user" do 86 | desc "User management tool" 87 | usage "mango admin user [arguments] [options]" 88 | help short: "-h" 89 | argument "action", type: String, 90 | desc: "Action to perform. Can be add/delete/update/list" 91 | argument "username", type: String, 92 | desc: "Username to update or delete" 93 | option "-u USERNAME", "--username=USERNAME", type: String, 94 | desc: "Username" 95 | option "-p PASSWORD", "--password=PASSWORD", type: String, 96 | desc: "Password" 97 | option "-a", "--admin", desc: "Admin flag", type: Bool, default: false 98 | common_option 99 | run do |opts, args| 100 | Config.load(opts.config).set_current 101 | storage = Storage.new nil, false 102 | 103 | case args.action 104 | when "add" 105 | throw "Options `-u` and `-p` required." if opts.username.nil? || 106 | opts.password.nil? 107 | storage.new_user opts.username.not_nil!, 108 | opts.password.not_nil!, opts.admin 109 | when "delete" 110 | throw "Argument `username` required." if args.username.nil? 111 | storage.delete_user args.username 112 | when "update" 113 | throw "Argument `username` required." if args.username.nil? 114 | username = opts.username || args.username 115 | password = opts.password || "" 116 | storage.update_user args.username, username.not_nil!, 117 | password.not_nil!, opts.admin 118 | when "list" 119 | users = storage.list_users 120 | table = Tallboy.table do 121 | header ["username", "admin access"] 122 | users.each do |name, admin| 123 | row [name, admin] 124 | end 125 | end 126 | puts table 127 | when nil 128 | puts opts.help_string 129 | else 130 | throw "Unknown action \"#{args.action}\"." 131 | end 132 | end 133 | end 134 | end 135 | end 136 | end 137 | 138 | CLI.start(ARGV) 139 | -------------------------------------------------------------------------------- /src/plugin/downloader.cr: -------------------------------------------------------------------------------- 1 | class Plugin 2 | class Downloader < Queue::Downloader 3 | use_default 4 | 5 | def initialize 6 | super 7 | end 8 | 9 | def pop : Queue::Job? 10 | job = nil 11 | MainFiber.run do 12 | DB.open "sqlite3://#{@queue.path}" do |db| 13 | begin 14 | db.query_one "select * from queue where id like '%-%' " \ 15 | "and (status = 0 or status = 1) " \ 16 | "order by time limit 1" do |res| 17 | job = Queue::Job.from_query_result res 18 | end 19 | rescue 20 | end 21 | end 22 | end 23 | job 24 | end 25 | 26 | private def download(job : Queue::Job) 27 | @downloading = true 28 | @queue.set_status Queue::JobStatus::Downloading, job 29 | 30 | begin 31 | unless job.plugin_id 32 | raise "Job does not have a plugin ID specificed" 33 | end 34 | 35 | plugin = Plugin.new job.plugin_id.not_nil! 36 | info = plugin.select_chapter job.plugin_chapter_id.not_nil! 37 | 38 | pages = info["pages"].as_i 39 | 40 | manga_title = sanitize_filename job.manga_title 41 | chapter_title = sanitize_filename info["title"].as_s 42 | 43 | @queue.set_pages pages, job 44 | lib_dir = @library_path 45 | manga_dir = File.join lib_dir, manga_title 46 | unless File.exists? manga_dir 47 | Dir.mkdir_p manga_dir 48 | end 49 | 50 | zip_path = File.join manga_dir, "#{chapter_title}.cbz.part" 51 | writer = Compress::Zip::Writer.new zip_path 52 | rescue e 53 | @queue.set_status Queue::JobStatus::Error, job 54 | unless e.message.nil? 55 | @queue.add_message e.message.not_nil!, job 56 | end 57 | @downloading = false 58 | raise e 59 | end 60 | 61 | fail_count = 0 62 | 63 | while page = plugin.next_page 64 | break unless @queue.exists? job 65 | 66 | fn = sanitize_filename page["filename"].as_s 67 | url = page["url"].as_s 68 | headers = HTTP::Headers.new 69 | 70 | if page["headers"]? 71 | page["headers"].as_h.each do |k, v| 72 | headers.add k, v.as_s 73 | end 74 | end 75 | 76 | page_success = false 77 | tries = 4 78 | 79 | loop do 80 | sleep plugin.info.wait_seconds.seconds 81 | Logger.debug "downloading #{url}" 82 | tries -= 1 83 | 84 | begin 85 | HTTP::Client.get url, headers do |res| 86 | unless res.success? 87 | raise "Failed to download page #{url}. " \ 88 | "[#{res.status_code}] #{res.status_message}" 89 | end 90 | writer.add fn, res.body_io 91 | end 92 | rescue e 93 | @queue.add_fail job 94 | fail_count += 1 95 | msg = "Failed to download page #{url}. Error: #{e}" 96 | @queue.add_message msg, job 97 | Logger.error msg 98 | Logger.debug "[failed] #{url}" 99 | else 100 | @queue.add_success job 101 | Logger.debug "[success] #{url}" 102 | page_success = true 103 | end 104 | 105 | break if page_success || tries < 0 106 | end 107 | end 108 | 109 | unless @queue.exists? job 110 | Logger.debug "Download cancelled" 111 | @downloading = false 112 | return 113 | end 114 | 115 | Logger.debug "Download completed. #{fail_count}/#{pages} failed" 116 | writer.close 117 | filename = File.join File.dirname(zip_path), File.basename(zip_path, 118 | ".part") 119 | File.rename zip_path, filename 120 | Logger.debug "cbz File created at #{filename}" 121 | 122 | zip_exception = validate_archive filename 123 | if !zip_exception.nil? 124 | @queue.add_message "The downloaded archive is corrupted. " \ 125 | "Error: #{zip_exception}", job 126 | @queue.set_status Queue::JobStatus::Error, job 127 | elsif fail_count > 0 128 | @queue.set_status Queue::JobStatus::MissingPages, job 129 | else 130 | @queue.set_status Queue::JobStatus::Completed, job 131 | end 132 | 133 | @downloading = false 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /src/plugin/subscriptions.cr: -------------------------------------------------------------------------------- 1 | require "uuid" 2 | require "big" 3 | 4 | enum FilterType 5 | String 6 | NumMin 7 | NumMax 8 | DateMin 9 | DateMax 10 | Array 11 | 12 | def self.from_string(str) 13 | case str 14 | when "string" 15 | String 16 | when "number-min" 17 | NumMin 18 | when "number-max" 19 | NumMax 20 | when "date-min" 21 | DateMin 22 | when "date-max" 23 | DateMax 24 | when "array" 25 | Array 26 | else 27 | raise "Unknown filter type with string #{str}" 28 | end 29 | end 30 | end 31 | 32 | struct Filter 33 | include JSON::Serializable 34 | 35 | property key : String 36 | property value : String | Int32 | Int64 | Float32 | Nil 37 | property type : FilterType 38 | 39 | def initialize(@key, @value, @type) 40 | end 41 | 42 | def self.from_json(str) : Filter 43 | json = JSON.parse str 44 | key = json["key"].as_s 45 | type = FilterType.from_string json["type"].as_s 46 | _value = json["value"] 47 | value = _value.as_s? || _value.as_i? || _value.as_i64? || 48 | _value.as_f32? || nil 49 | self.new key, value, type 50 | end 51 | 52 | def match_chapter(obj : JSON::Any) : Bool 53 | return true if value.nil? || value.to_s.empty? 54 | raw_value = obj[key] 55 | case type 56 | when FilterType::String 57 | raw_value.as_s.downcase == value.to_s.downcase 58 | when FilterType::NumMin, FilterType::DateMin 59 | BigFloat.new(raw_value.as_s) >= BigFloat.new value.not_nil!.to_f32 60 | when FilterType::NumMax, FilterType::DateMax 61 | BigFloat.new(raw_value.as_s) <= BigFloat.new value.not_nil!.to_f32 62 | when FilterType::Array 63 | return true if value == "all" 64 | raw_value.as_s.downcase.split(",") 65 | .map(&.strip).includes? value.to_s.downcase.strip 66 | else 67 | false 68 | end 69 | end 70 | end 71 | 72 | # We use class instead of struct so we can update `last_checked` from 73 | # `SubscriptionList` 74 | class Subscription 75 | include JSON::Serializable 76 | 77 | property id : String 78 | property plugin_id : String 79 | property manga_id : String 80 | property manga_title : String 81 | property name : String 82 | property created_at : Int64 83 | property last_checked : Int64 84 | property filters = [] of Filter 85 | 86 | def initialize(@plugin_id, @manga_id, @manga_title, @name) 87 | @id = UUID.random.to_s 88 | @created_at = Time.utc.to_unix 89 | @last_checked = Time.utc.to_unix 90 | end 91 | 92 | def match_chapter(obj : JSON::Any) : Bool 93 | filters.all? &.match_chapter(obj) 94 | end 95 | end 96 | 97 | struct SubscriptionList 98 | @dir : String 99 | @path : String 100 | 101 | getter ary = [] of Subscription 102 | 103 | forward_missing_to @ary 104 | 105 | def initialize(@dir) 106 | @path = Path[@dir, "subscriptions.json"].to_s 107 | if File.exists? @path 108 | @ary = Array(Subscription).from_json File.read @path 109 | end 110 | end 111 | 112 | def save 113 | File.write @path, @ary.to_pretty_json 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /src/plugin/updater.cr: -------------------------------------------------------------------------------- 1 | class Plugin 2 | class Updater 3 | use_default 4 | 5 | def initialize 6 | interval = Config.current.plugin_update_interval_hours 7 | return if interval <= 0 8 | spawn do 9 | loop do 10 | Plugin.list.map(&.["id"]).each do |pid| 11 | check_updates pid 12 | end 13 | sleep interval.hours 14 | end 15 | end 16 | end 17 | 18 | def check_updates(plugin_id : String) 19 | Logger.debug "Checking plugin #{plugin_id} for updates" 20 | 21 | plugin = Plugin.new plugin_id 22 | if plugin.info.version == 1 23 | Logger.debug "Plugin #{plugin_id} is targeting API version 1. " \ 24 | "Skipping update check" 25 | return 26 | end 27 | 28 | subscriptions = plugin.list_subscriptions_raw 29 | subscriptions.each do |sub| 30 | check_subscription plugin, sub 31 | end 32 | subscriptions.save 33 | rescue e 34 | Logger.error "Error checking plugin #{plugin_id} for updates: " \ 35 | "#{e.message}" 36 | end 37 | 38 | def check_subscription(plugin : Plugin, sub : Subscription) 39 | Logger.debug "Checking subscription #{sub.name} for updates" 40 | matches = plugin.new_chapters(sub.manga_id, sub.last_checked) 41 | .as_a.select do |chapter| 42 | sub.match_chapter chapter 43 | end 44 | if matches.empty? 45 | Logger.debug "No new chapters found." 46 | sub.last_checked = Time.utc.to_unix 47 | return 48 | end 49 | Logger.debug "Found #{matches.size} new chapters. " \ 50 | "Pushing to download queue" 51 | jobs = matches.map { |ch| 52 | Queue::Job.new( 53 | "#{plugin.info.id}-#{Base64.encode ch["id"].as_s}", 54 | "", # manga_id 55 | ch["title"].as_s, 56 | sub.manga_title, 57 | Queue::JobStatus::Pending, 58 | Time.utc 59 | ) 60 | } 61 | inserted_count = Queue.default.push jobs 62 | Logger.info "#{inserted_count}/#{matches.size} new chapters added " \ 63 | "to the download queue. Plugin ID #{plugin.info.id}, " \ 64 | "subscription name #{sub.name}" 65 | if inserted_count != matches.size 66 | Logger.error "Failed to add #{matches.size - inserted_count} " \ 67 | "chapters to download queue" 68 | end 69 | sub.last_checked = Time.utc.to_unix 70 | rescue e 71 | Logger.error "Error when checking updates for subscription " \ 72 | "#{sub.name}: #{e.message}" 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /src/rename.cr: -------------------------------------------------------------------------------- 1 | module Rename 2 | alias VHash = Hash(String, String) 3 | 4 | abstract class Base(T) 5 | @ary = [] of T 6 | 7 | def push(var) 8 | @ary.push var 9 | end 10 | 11 | abstract def render(hash : VHash) 12 | end 13 | 14 | class Variable < Base(String) 15 | property id : String 16 | 17 | def initialize(@id) 18 | end 19 | 20 | def render(hash : VHash) 21 | hash[@id]? || "" 22 | end 23 | end 24 | 25 | class Pattern < Base(Variable) 26 | def render(hash : VHash) 27 | @ary.each do |v| 28 | if hash.has_key? v.id 29 | return v.render hash 30 | end 31 | end 32 | "" 33 | end 34 | end 35 | 36 | class Group < Base(Pattern | String) 37 | def render(hash : VHash) 38 | return "" if @ary.select(Pattern) 39 | .any? &.as(Pattern).render(hash).empty? 40 | @ary.join do |e| 41 | if e.is_a? Pattern 42 | e.render hash 43 | else 44 | e 45 | end 46 | end 47 | end 48 | end 49 | 50 | class Rule < Base(Group | String | Pattern) 51 | ESCAPE = ['/'] 52 | 53 | def initialize(str : String) 54 | parse! str 55 | rescue e 56 | raise "Failed to parse rename rule #{str}. Error: #{e}" 57 | end 58 | 59 | private def parse!(str : String) 60 | chars = [] of Char 61 | pattern : Pattern? = nil 62 | group : Group? = nil 63 | 64 | str.each_char_with_index do |char, i| 65 | if ['[', ']', '{', '}', '|'].includes?(char) && !chars.empty? 66 | string = chars.join 67 | if !pattern.nil? 68 | pattern.push Variable.new string.strip 69 | elsif !group.nil? 70 | group.push string 71 | else 72 | @ary.push string 73 | end 74 | chars = [] of Char 75 | end 76 | 77 | case char 78 | when '[' 79 | if !group.nil? || !pattern.nil? 80 | raise "nested groups are not allowed" 81 | end 82 | group = Group.new 83 | when ']' 84 | if group.nil? 85 | raise "unmatched ] at position #{i}" 86 | end 87 | if !pattern.nil? 88 | raise "patterns (`{}`) should be closed before closing the " \ 89 | "group (`[]`)" 90 | end 91 | @ary.push group 92 | group = nil 93 | when '{' 94 | if !pattern.nil? 95 | raise "nested patterns are not allowed" 96 | end 97 | pattern = Pattern.new 98 | when '}' 99 | if pattern.nil? 100 | raise "unmatched } at position #{i}" 101 | end 102 | if !group.nil? 103 | group.push pattern 104 | else 105 | @ary.push pattern 106 | end 107 | pattern = nil 108 | when '|' 109 | if pattern.nil? 110 | chars.push char 111 | end 112 | else 113 | if ESCAPE.includes? char 114 | raise "the character #{char} at position #{i} is not allowed" 115 | end 116 | chars.push char 117 | end 118 | end 119 | 120 | unless chars.empty? 121 | @ary.push chars.join 122 | end 123 | if !pattern.nil? 124 | raise "unclosed pattern {" 125 | end 126 | if !group.nil? 127 | raise "unclosed group [" 128 | end 129 | end 130 | 131 | def render(hash : VHash) 132 | str = @ary.join do |e| 133 | if e.is_a? String 134 | e 135 | else 136 | e.render hash 137 | end 138 | end.strip 139 | post_process str 140 | end 141 | 142 | # Post-processes the generated file/folder name 143 | # - Handles the rare case where the string is `..` 144 | # - Removes trailing spaces and periods 145 | # - Replace illegal characters with `_` 146 | private def post_process(str) 147 | return "_" if str == ".." 148 | str.rstrip(" .").gsub /[\/?<>\\:*|"^]/, "_" 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /src/routes/admin.cr: -------------------------------------------------------------------------------- 1 | require "sanitize" 2 | 3 | struct AdminRouter 4 | def initialize 5 | get "/admin" do |env| 6 | storage = Storage.default 7 | missing_count = storage.missing_titles.size + 8 | storage.missing_entries.size 9 | layout "admin" 10 | end 11 | 12 | get "/admin/user" do |env| 13 | users = Storage.default.list_users 14 | username = get_username env 15 | layout "user" 16 | end 17 | 18 | get "/admin/user/edit" do |env| 19 | sanitizer = Sanitize::Policy::Text.new 20 | username = env.params.query["username"]?.try { |s| sanitizer.process s } 21 | admin = env.params.query["admin"]? 22 | if admin 23 | admin = admin == "true" 24 | end 25 | error = env.params.query["error"]?.try { |s| sanitizer.process s } 26 | new_user = username.nil? && admin.nil? 27 | layout "user-edit" 28 | end 29 | 30 | post "/admin/user/edit" do |env| 31 | # creating new user 32 | username = env.params.body["username"] 33 | password = env.params.body["password"] 34 | # if `admin` is unchecked, the body hash 35 | # would not contain `admin` 36 | admin = !env.params.body["admin"]?.nil? 37 | 38 | Storage.default.new_user username, password, admin 39 | 40 | redirect env, "/admin/user" 41 | rescue e 42 | Logger.error e 43 | redirect_url = URI.new \ 44 | path: "/admin/user/edit", 45 | query: hash_to_query({"error" => e.message}) 46 | redirect env, redirect_url.to_s 47 | end 48 | 49 | post "/admin/user/edit/:original_username" do |env| 50 | # editing existing user 51 | username = env.params.body["username"] 52 | password = env.params.body["password"] 53 | # if `admin` is unchecked, the body hash would not contain `admin` 54 | admin = !env.params.body["admin"]?.nil? 55 | original_username = env.params.url["original_username"] 56 | 57 | Storage.default.update_user \ 58 | original_username, username, password, admin 59 | 60 | redirect env, "/admin/user" 61 | rescue e 62 | Logger.error e 63 | redirect_url = URI.new \ 64 | path: "/admin/user/edit", 65 | query: hash_to_query({"username" => original_username, \ 66 | "admin" => admin, "error" => e.message}) 67 | redirect env, redirect_url.to_s 68 | end 69 | 70 | get "/admin/downloads" do |env| 71 | layout "download-manager" 72 | end 73 | 74 | get "/admin/subscriptions" do |env| 75 | layout "subscription-manager" 76 | end 77 | 78 | get "/admin/missing" do |env| 79 | layout "missing-items" 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/routes/main.cr: -------------------------------------------------------------------------------- 1 | struct MainRouter 2 | def initialize 3 | get "/login" do |env| 4 | base_url = Config.current.base_url 5 | render "src/views/login.html.ecr" 6 | end 7 | 8 | get "/logout" do |env| 9 | begin 10 | env.session.delete_string "token" 11 | rescue e 12 | Logger.error "Error when attempting to log out: #{e}" 13 | ensure 14 | redirect env, "/login" 15 | end 16 | end 17 | 18 | post "/login" do |env| 19 | begin 20 | username = env.params.body["username"] 21 | password = env.params.body["password"] 22 | token = Storage.default.verify_user(username, password).not_nil! 23 | 24 | env.session.string "token", token 25 | 26 | callback = env.session.string? "callback" 27 | if callback 28 | env.session.delete_string "callback" 29 | redirect env, callback 30 | else 31 | redirect env, "/" 32 | end 33 | rescue e 34 | Logger.error e 35 | redirect env, "/login" 36 | end 37 | end 38 | 39 | get "/library" do |env| 40 | begin 41 | username = get_username env 42 | 43 | sort_opt = SortOptions.from_info_json Library.default.dir, username 44 | get_and_save_sort_opt Library.default.dir 45 | 46 | titles = Library.default.sorted_titles username, sort_opt 47 | percentage = titles.map &.load_percentage username 48 | 49 | layout "library" 50 | rescue e 51 | Logger.error e 52 | env.response.status_code = 500 53 | end 54 | end 55 | 56 | get "/book/:title" do |env| 57 | begin 58 | title = (Library.default.get_title env.params.url["title"]).not_nil! 59 | username = get_username env 60 | 61 | sort_opt = SortOptions.from_info_json title.dir, username 62 | get_and_save_sort_opt title.dir 63 | 64 | sorted_titles = title.sorted_titles username, sort_opt 65 | entries = title.sorted_entries username, sort_opt 66 | percentage = title.load_percentage_for_all_entries username, sort_opt 67 | title_percentage = title.titles.map &.load_percentage username 68 | title_percentage_map = {} of String => Float64 69 | title_percentage.each_with_index do |tp, i| 70 | t = title.titles[i] 71 | title_percentage_map[t.id] = tp 72 | end 73 | 74 | layout "title" 75 | rescue e 76 | Logger.error e 77 | env.response.status_code = 500 78 | end 79 | end 80 | 81 | get "/download/plugins" do |env| 82 | begin 83 | layout "plugin-download" 84 | rescue e 85 | Logger.error e 86 | env.response.status_code = 500 87 | end 88 | end 89 | 90 | get "/" do |env| 91 | begin 92 | username = get_username env 93 | continue_reading = Library.default 94 | .get_continue_reading_entries username 95 | recently_added = Library.default.get_recently_added_entries username 96 | start_reading = Library.default.get_start_reading_titles username 97 | titles = Library.default.titles 98 | new_user = !titles.any? &.load_percentage(username).> 0 99 | empty_library = titles.size == 0 100 | layout "home" 101 | rescue e 102 | Logger.error e 103 | env.response.status_code = 500 104 | end 105 | end 106 | 107 | get "/tags/:tag" do |env| 108 | begin 109 | username = get_username env 110 | tag = env.params.url["tag"] 111 | 112 | sort_opt = SortOptions.new 113 | get_sort_opt 114 | 115 | title_ids = Storage.default.get_tag_titles tag 116 | 117 | raise "Tag #{tag} not found" if title_ids.empty? 118 | 119 | titles = title_ids.map { |id| Library.default.get_title id } 120 | .select Title 121 | 122 | titles = sort_titles titles, sort_opt, username 123 | percentage = titles.map &.load_percentage username 124 | 125 | layout "tag" 126 | rescue e 127 | Logger.error e 128 | env.response.status_code = 404 129 | end 130 | end 131 | 132 | get "/tags" do |env| 133 | tags = Storage.default.list_tags.map do |tag| 134 | { 135 | tag: tag, 136 | encoded_tag: URI.encode_www_form(tag, space_to_plus: false), 137 | count: Storage.default.get_tag_titles(tag).size, 138 | } 139 | end 140 | # Sort by :count reversly, and then sort by :tag 141 | tags.sort! do |a, b| 142 | (b[:count] <=> a[:count]).or(a[:tag] <=> b[:tag]) 143 | end 144 | 145 | layout "tags" 146 | end 147 | 148 | get "/api" do |env| 149 | base_url = Config.current.base_url 150 | render "src/views/api.html.ecr" 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /src/routes/opds.cr: -------------------------------------------------------------------------------- 1 | struct OPDSRouter 2 | def initialize 3 | get "/opds" do |env| 4 | titles = Library.default.titles 5 | render_xml "src/views/opds/index.xml.ecr" 6 | end 7 | 8 | get "/opds/book/:title_id" do |env| 9 | begin 10 | title = Library.default.get_title(env.params.url["title_id"]).not_nil! 11 | render_xml "src/views/opds/title.xml.ecr" 12 | rescue e 13 | Logger.error e 14 | env.response.status_code = 404 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/routes/reader.cr: -------------------------------------------------------------------------------- 1 | struct ReaderRouter 2 | def initialize 3 | get "/reader/:title/:entry" do |env| 4 | begin 5 | username = get_username env 6 | 7 | title = (Library.default.get_title env.params.url["title"]).not_nil! 8 | entry = (title.get_entry env.params.url["entry"]).not_nil! 9 | 10 | next layout "reader-error" if entry.err_msg 11 | 12 | # load progress 13 | page_idx = [1, entry.load_progress username].max 14 | 15 | # start from page 1 if the user has finished reading the entry 16 | page_idx = 1 if entry.finished? username 17 | 18 | redirect env, "/reader/#{title.id}/#{entry.id}/#{page_idx}" 19 | rescue e 20 | Logger.error e 21 | env.response.status_code = 404 22 | end 23 | end 24 | 25 | get "/reader/:title/:entry/:page" do |env| 26 | begin 27 | base_url = Config.current.base_url 28 | 29 | username = get_username env 30 | 31 | title = (Library.default.get_title env.params.url["title"]).not_nil! 32 | entry = (title.get_entry env.params.url["entry"]).not_nil! 33 | 34 | sort_opt = SortOptions.from_info_json title.dir, username 35 | get_sort_opt 36 | entries = title.sorted_entries username, sort_opt 37 | 38 | page_idx = env.params.url["page"].to_i 39 | if page_idx > entry.pages || page_idx <= 0 40 | raise "Page #{page_idx} not found." 41 | end 42 | 43 | exit_url = "#{base_url}book/#{title.id}" 44 | 45 | next_entry_url = entry.next_entry(username).try do |e| 46 | "#{base_url}reader/#{title.id}/#{e.id}" 47 | end 48 | 49 | previous_entry_url = entry.previous_entry(username).try do |e| 50 | "#{base_url}reader/#{title.id}/#{e.id}" 51 | end 52 | 53 | render "src/views/reader.html.ecr" 54 | rescue e 55 | Logger.error e 56 | Logger.debug e.backtrace? 57 | env.response.status_code = 404 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /src/server.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "kemal-session" 3 | require "./library/*" 4 | require "./handlers/*" 5 | require "./util/*" 6 | require "./routes/*" 7 | 8 | class Server 9 | def initialize 10 | error 404 do |env| 11 | message = "HTTP 404: Mango cannot find the page #{env.request.path}" 12 | layout "message" 13 | end 14 | 15 | {% if flag?(:release) %} 16 | error 500 do |env| 17 | message = "HTTP 500: Internal server error. Please try again later." 18 | layout "message" 19 | end 20 | {% end %} 21 | 22 | MainRouter.new 23 | AdminRouter.new 24 | ReaderRouter.new 25 | APIRouter.new 26 | OPDSRouter.new 27 | 28 | {% for path in %w(/api/* /uploads/* /img/*) %} 29 | options {{path}} do |env| 30 | cors 31 | halt env 32 | end 33 | {% end %} 34 | 35 | static_headers do |response| 36 | response.headers.add("Access-Control-Allow-Origin", "*") 37 | end 38 | 39 | Kemal.config.logging = false 40 | add_handler LogHandler.new 41 | add_handler AuthHandler.new 42 | add_handler UploadHandler.new Config.current.upload_path 43 | {% if flag?(:release) %} 44 | # when building for relase, embed the static files in binary 45 | Logger.debug "We are in release mode. Using embedded static files." 46 | serve_static false 47 | add_handler StaticHandler.new 48 | {% end %} 49 | 50 | Kemal::Session.config do |c| 51 | c.timeout = 365.days 52 | c.secret = Config.current.session_secret 53 | c.cookie_name = "mango-sessid-#{Config.current.port}" 54 | c.path = Config.current.base_url 55 | end 56 | end 57 | 58 | def start 59 | Logger.debug "Starting Kemal server" 60 | {% if flag?(:release) %} 61 | Kemal.config.env = "production" 62 | {% end %} 63 | Kemal.config.host_binding = Config.current.host 64 | Kemal.config.port = Config.current.port 65 | Kemal.run 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /src/upload.cr: -------------------------------------------------------------------------------- 1 | require "./util/*" 2 | 3 | class Upload 4 | def initialize(@dir : String) 5 | unless Dir.exists? @dir 6 | Logger.info "The uploads directory #{@dir} does not exist. " \ 7 | "Attempting to create it" 8 | Dir.mkdir_p @dir 9 | end 10 | end 11 | 12 | # Writes IO to a file with random filename in the uploads directory and 13 | # returns the full path of created file 14 | # e.g., save("image", ".png", ) 15 | # ==> "~/mango/uploads/image/.png" 16 | def save(sub_dir : String, ext : String, io : IO) 17 | full_dir = File.join @dir, sub_dir 18 | filename = random_str + ext 19 | file_path = File.join full_dir, filename 20 | 21 | unless Dir.exists? full_dir 22 | Logger.debug "creating directory #{full_dir}" 23 | Dir.mkdir_p full_dir 24 | end 25 | 26 | File.open file_path, "w" do |f| 27 | IO.copy io, f 28 | end 29 | 30 | file_path 31 | end 32 | 33 | # Converts path to a file in the uploads directory to the URL path for 34 | # accessing the file. 35 | def path_to_url(path : String) 36 | dir_mathed = false 37 | ary = [] of String 38 | # We fill it with parts until it equals to @upload_dir 39 | dir_ary = [] of String 40 | 41 | Path.new(path).each_part do |part| 42 | if dir_mathed 43 | ary << part 44 | else 45 | dir_ary << part 46 | if File.same? @dir, File.join dir_ary 47 | dir_mathed = true 48 | end 49 | end 50 | end 51 | 52 | if ary.empty? 53 | Logger.warn "File #{path} is not in the upload directory #{@dir}" 54 | return 55 | end 56 | 57 | ary.unshift UPLOAD_URL_PREFIX 58 | File.join(ary).to_s 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /src/util/chapter_sort.cr: -------------------------------------------------------------------------------- 1 | # Helper method used to sort chapters in a folder 2 | # It respects the keywords like "Vol." and "Ch." in the filenames 3 | # This sorting method was initially implemented in JS and done in the frontend. 4 | # see https://github.com/hkalexling/Mango/blob/ 5 | # 07100121ef15260b5a8e8da0e5948c993df574c5/public/js/sort-items.js#L15-L87 6 | 7 | require "big" 8 | 9 | private class Item 10 | getter numbers : Hash(String, BigDecimal) 11 | 12 | def initialize(@numbers) 13 | end 14 | 15 | # Compare with another Item using keys 16 | def <=>(other : Item, keys : Array(String)) 17 | keys.each do |key| 18 | if !@numbers.has_key?(key) && !other.numbers.has_key?(key) 19 | next 20 | elsif !@numbers.has_key? key 21 | return 1 22 | elsif !other.numbers.has_key? key 23 | return -1 24 | elsif @numbers[key] == other.numbers[key] 25 | next 26 | else 27 | return @numbers[key] <=> other.numbers[key] 28 | end 29 | end 30 | 31 | 0 32 | end 33 | end 34 | 35 | private class KeyRange 36 | getter min : BigDecimal, max : BigDecimal, count : Int32 37 | 38 | def initialize(value : BigDecimal) 39 | @min = @max = value 40 | @count = 1 41 | end 42 | 43 | def update(value : BigDecimal) 44 | @min = value if value < @min 45 | @max = value if value > @max 46 | @count += 1 47 | end 48 | 49 | def range 50 | @max - @min 51 | end 52 | end 53 | 54 | class ChapterSorter 55 | @sorted_keys = [] of String 56 | 57 | def initialize(str_ary : Array(String)) 58 | keys = {} of String => KeyRange 59 | 60 | str_ary.each do |str| 61 | scan str do |k, v| 62 | if keys.has_key? k 63 | keys[k].update v 64 | else 65 | keys[k] = KeyRange.new v 66 | end 67 | end 68 | end 69 | 70 | # Get the array of keys string and sort them 71 | @sorted_keys = keys.keys 72 | # Only use keys that are present in over half of the strings 73 | .select do |key| 74 | keys[key].count >= str_ary.size / 2 75 | end 76 | .sort! do |a_key, b_key| 77 | a = keys[a_key] 78 | b = keys[b_key] 79 | # Sort keys by the number of times they appear 80 | count_compare = b.count <=> a.count 81 | if count_compare == 0 82 | # Then sort by value range 83 | b.range <=> a.range 84 | else 85 | count_compare 86 | end 87 | end 88 | end 89 | 90 | def compare(a : String, b : String) 91 | item_a = str_to_item a 92 | item_b = str_to_item b 93 | item_a.<=>(item_b, @sorted_keys) 94 | end 95 | 96 | private def scan(str, &) 97 | str.scan /([^0-9\n\r\ ]*)[ ]*([0-9]*\.*[0-9]+)/ do |match| 98 | key = match[1] 99 | num = match[2].to_big_d 100 | 101 | yield key, num 102 | end 103 | end 104 | 105 | private def str_to_item(str) 106 | numbers = {} of String => BigDecimal 107 | scan str do |k, v| 108 | numbers[k] = v 109 | end 110 | Item.new numbers 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /src/util/numeric_sort.cr: -------------------------------------------------------------------------------- 1 | # Properly sort alphanumeric strings 2 | # Used to sort the images files inside the archives 3 | # https://github.com/hkalexling/Mango/issues/12 4 | 5 | require "big" 6 | 7 | def is_numeric(str) 8 | /^\d+/.match(str) != nil 9 | end 10 | 11 | def split_by_alphanumeric(str) 12 | arr = [] of String 13 | str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match| 14 | arr += match.captures.select &.!= "" 15 | end 16 | arr 17 | end 18 | 19 | def compare_numerically(c, d) 20 | is_c_bigger = c.size <=> d.size 21 | if c.size > d.size 22 | d += [nil] * (c.size - d.size) 23 | elsif c.size < d.size 24 | c += [nil] * (d.size - c.size) 25 | end 26 | c.zip(d) do |a, b| 27 | return -1 if a.nil? 28 | return 1 if b.nil? 29 | if is_numeric(a) && is_numeric(b) 30 | compare = a.to_big_i <=> b.to_big_i 31 | return compare if compare != 0 32 | else 33 | compare = a <=> b 34 | return compare if compare != 0 35 | end 36 | end 37 | is_c_bigger 38 | end 39 | 40 | def compare_numerically(a : String, b : String) 41 | compare_numerically split_by_alphanumeric(a), split_by_alphanumeric(b) 42 | end 43 | -------------------------------------------------------------------------------- /src/util/proxy.cr: -------------------------------------------------------------------------------- 1 | require "http_proxy" 2 | 3 | # Monkey-patch `HTTP::Client` to make it respect the `*_PROXY` 4 | # environment variables 5 | module HTTP 6 | class Client 7 | private def self.exec(uri : URI, tls : TLSContext = nil) 8 | Logger.debug "Setting proxy" 9 | previous_def uri, tls do |client, path| 10 | client.set_proxy get_proxy uri 11 | yield client, path 12 | end 13 | end 14 | end 15 | end 16 | 17 | private def get_proxy(uri : URI) : HTTP::Proxy::Client? 18 | no_proxy = ENV["no_proxy"]? || ENV["NO_PROXY"]? 19 | return if no_proxy && 20 | no_proxy.split(",").any? &.== uri.hostname 21 | 22 | case uri.scheme 23 | when "http" 24 | env_to_proxy "http_proxy" 25 | when "https" 26 | env_to_proxy "https_proxy" 27 | else 28 | nil 29 | end 30 | end 31 | 32 | private def env_to_proxy(key : String) : HTTP::Proxy::Client? 33 | val = ENV[key.downcase]? || ENV[key.upcase]? 34 | return if val.nil? 35 | 36 | begin 37 | uri = URI.parse val 38 | HTTP::Proxy::Client.new uri.hostname.not_nil!, uri.port.not_nil!, 39 | username: uri.user, password: uri.password 40 | rescue 41 | nil 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /src/util/signature.cr: -------------------------------------------------------------------------------- 1 | require "./util" 2 | 3 | class File 4 | abstract struct Info 5 | def inode : UInt64 6 | @stat.st_ino.to_u64 7 | end 8 | end 9 | 10 | # Returns the signature of the file at filename. 11 | # When it is not a supported file, returns 0. Otherwise, uses the inode 12 | # number as its signature. On most file systems, the inode number is 13 | # preserved even when the file is renamed, moved or edited. 14 | # Some cases that would cause the inode number to change: 15 | # - Reboot/remount on some file systems 16 | # - Replaced with a copied file 17 | # - Moved to a different device 18 | # Since we are also using the relative paths to match ids, we won't lose 19 | # information as long as the above changes do not happen together with 20 | # a file/folder rename, with no library scan in between. 21 | def self.signature(filename) : UInt64 22 | if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename) 23 | File.info(filename).inode 24 | else 25 | 0u64 26 | end 27 | end 28 | end 29 | 30 | class Dir 31 | # Returns the signature of the directory at dirname. See the comments for 32 | # `File.signature` for more information. 33 | def self.signature(dirname) : UInt64 34 | signatures = [File.info(dirname).inode] 35 | self.open dirname do |dir| 36 | dir.entries.each do |fn| 37 | next if fn.starts_with? "." 38 | path = File.join dirname, fn 39 | if File.directory? path 40 | signatures << Dir.signature path 41 | else 42 | _sig = File.signature path 43 | # Only add its signature value to `signatures` when it is a 44 | # supported file 45 | signatures << _sig if _sig > 0 46 | end 47 | end 48 | end 49 | Digest::CRC32.checksum(signatures.sort.join).to_u64 50 | end 51 | 52 | # Returns the contents signature of the directory at dirname for checking 53 | # to rescan. 54 | # Rescan conditions: 55 | # - When a file added, moved, removed, renamed (including which in nested 56 | # directories) 57 | def self.contents_signature(dirname, cache = {} of String => String) : String 58 | return cache[dirname] if cache[dirname]? 59 | Fiber.yield 60 | signatures = [] of String 61 | self.open dirname do |dir| 62 | dir.entries.sort.each do |fn| 63 | next if fn.starts_with? "." 64 | path = File.join dirname, fn 65 | if File.directory? path 66 | signatures << Dir.contents_signature path, cache 67 | else 68 | # Only add its signature value to `signatures` when it is a 69 | # supported file 70 | if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn) 71 | signatures << fn 72 | end 73 | end 74 | Fiber.yield 75 | end 76 | end 77 | hash = Digest::SHA1.hexdigest(signatures.join) 78 | cache[dirname] = hash 79 | hash 80 | end 81 | 82 | def self.directory_entry_signature(dirname, cache = {} of String => String) 83 | return cache[dirname + "?entry"] if cache[dirname + "?entry"]? 84 | Fiber.yield 85 | signatures = [] of String 86 | image_files = DirEntry.sorted_image_files dirname 87 | if image_files.size > 0 88 | image_files.each do |path| 89 | signatures << File.signature(path).to_s 90 | end 91 | end 92 | hash = Digest::SHA1.hexdigest(signatures.join) 93 | cache[dirname + "?entry"] = hash 94 | hash 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /src/util/util.cr: -------------------------------------------------------------------------------- 1 | IMGS_PER_PAGE = 5 2 | ENTRIES_IN_HOME_SECTIONS = 8 3 | UPLOAD_URL_PREFIX = "/uploads" 4 | STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt 5 | /manifest.json) 6 | SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"] 7 | SUPPORTED_IMG_TYPES = %w( 8 | image/jpeg 9 | image/png 10 | image/webp 11 | image/apng 12 | image/avif 13 | image/gif 14 | image/svg+xml 15 | image/jxl 16 | ) 17 | 18 | def random_str 19 | UUID.random.to_s.gsub "-", "" 20 | end 21 | 22 | # Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/ 23 | # blob/master/src/crystal/system/unix/file_info.cr#L42-L48 24 | def ctime(file_path : String) : Time 25 | res = LibC.stat(file_path, out stat) 26 | raise "Unable to get ctime of file #{file_path}" if res != 0 27 | 28 | {% if flag?(:darwin) %} 29 | Time.new stat.st_ctimespec, Time::Location::UTC 30 | {% else %} 31 | Time.new stat.st_ctim, Time::Location::UTC 32 | {% end %} 33 | end 34 | 35 | def register_mime_types 36 | { 37 | # Comic Archives 38 | ".zip" => "application/zip", 39 | ".rar" => "application/x-rar-compressed", 40 | ".cbz" => "application/vnd.comicbook+zip", 41 | ".cbr" => "application/vnd.comicbook-rar", 42 | 43 | # Favicon 44 | ".ico" => "image/x-icon", 45 | 46 | # FontAwesome fonts 47 | ".woff" => "font/woff", 48 | ".woff2" => "font/woff2", 49 | 50 | # Supported image formats. JPG, PNG, GIF, WebP, and SVG are already 51 | # defiend by Crystal in `MIME.DEFAULT_TYPES` 52 | ".apng" => "image/apng", 53 | ".avif" => "image/avif", 54 | ".jxl" => "image/jxl", 55 | }.each do |k, v| 56 | MIME.register k, v 57 | end 58 | end 59 | 60 | def is_supported_file(path) 61 | SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase 62 | end 63 | 64 | def is_supported_image_file(path) 65 | SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path 66 | end 67 | 68 | struct Int 69 | def or(other : Int) 70 | if self == 0 71 | other 72 | else 73 | self 74 | end 75 | end 76 | end 77 | 78 | struct Nil 79 | def or(other : Int) 80 | other 81 | end 82 | end 83 | 84 | macro use_default 85 | def self.default : self 86 | unless @@default 87 | @@default = new 88 | end 89 | @@default.not_nil! 90 | end 91 | end 92 | 93 | class String 94 | def alphanumeric_underscore? 95 | self.chars.all? { |c| c.alphanumeric? || c == '_' } 96 | end 97 | end 98 | 99 | def env_is_true?(key : String, default : Bool = false) : Bool 100 | val = ENV[key.upcase]? || ENV[key.downcase]? 101 | return default unless val 102 | val.downcase.in? "1", "true" 103 | end 104 | 105 | def sort_titles(titles : Array(Title), opt : SortOptions, username : String) 106 | cache_key = SortedTitlesCacheEntry.gen_key username, titles, opt 107 | cached_titles = LRUCache.get cache_key 108 | return cached_titles if cached_titles.is_a? Array(Title) 109 | 110 | case opt.method 111 | when .time_modified? 112 | ary = titles.sort { |a, b| (a.mtime <=> b.mtime).or \ 113 | compare_numerically a.sort_title, b.sort_title } 114 | when .progress? 115 | ary = titles.sort do |a, b| 116 | (a.load_percentage(username) <=> b.load_percentage(username)).or \ 117 | compare_numerically a.sort_title, b.sort_title 118 | end 119 | when .title? 120 | ary = titles.sort do |a, b| 121 | compare_numerically a.sort_title, b.sort_title 122 | end 123 | else 124 | unless opt.method.auto? 125 | Logger.warn "Unknown sorting method #{opt.not_nil!.method}. Using " \ 126 | "Auto instead" 127 | end 128 | ary = titles.sort { |a, b| compare_numerically a.sort_title, b.sort_title } 129 | end 130 | 131 | ary.reverse! unless opt.not_nil!.ascend 132 | 133 | LRUCache.set generate_cache_entry cache_key, ary 134 | ary 135 | end 136 | 137 | def remove_sorted_titles_cache(titles : Array(Title), 138 | sort_methods : Array(SortMethod), 139 | username : String) 140 | [false, true].each do |ascend| 141 | sort_methods.each do |sort_method| 142 | sorted_titles_cache_key = SortedTitlesCacheEntry.gen_key username, 143 | titles, SortOptions.new(sort_method, ascend) 144 | LRUCache.invalidate sorted_titles_cache_key 145 | end 146 | end 147 | end 148 | 149 | class String 150 | # Returns the similarity (in [0, 1]) of two paths. 151 | # For the two paths, separate them into arrays of components, count the 152 | # number of matching components backwards, and divide the count by the 153 | # number of components of the shorter path. 154 | def components_similarity(other : String) : Float64 155 | s, l = [self, other] 156 | .map { |str| Path.new(str).parts } 157 | .sort_by! &.size 158 | 159 | match = s.reverse.zip(l.reverse).count { |a, b| a == b } 160 | match / s.size 161 | end 162 | end 163 | 164 | # Does the followings: 165 | # - turns space-like characters into the normal whitespaces ( ) 166 | # - strips and collapses spaces 167 | # - removes ASCII control characters 168 | # - replaces slashes (/) with underscores (_) 169 | # - removes leading dots (.) 170 | # - removes the following special characters: \:*?"<>| 171 | # 172 | # If the sanitized string is empty, returns a random string instead. 173 | def sanitize_filename(str : String) : String 174 | sanitized = str 175 | .gsub(/\s+/, " ") 176 | .strip 177 | .gsub(/\//, "_") 178 | .gsub(/^[\.\s]+/, "") 179 | .gsub(/[\177\000-\031\\:\*\?\"<>\|]/, "") 180 | sanitized.size > 0 ? sanitized : random_str 181 | end 182 | 183 | def delete_cache_and_exit(path : String) 184 | File.delete path 185 | Logger.fatal "Invalid library cache deleted. Mango needs to " \ 186 | "perform a full reset to recover from this. " \ 187 | "Pleae restart Mango. This is NOT a bug." 188 | Logger.fatal "Exiting" 189 | exit 1 190 | end 191 | -------------------------------------------------------------------------------- /src/util/validation.cr: -------------------------------------------------------------------------------- 1 | def validate_username(username) 2 | if username.size < 3 3 | raise "Username should contain at least 3 characters" 4 | end 5 | if (username =~ /^[a-zA-Z_][a-zA-Z0-9_\-]*$/).nil? 6 | raise "Username can only contain alphanumeric characters, " \ 7 | "underscores, and hyphens" 8 | end 9 | end 10 | 11 | def validate_password(password) 12 | if password.size < 6 13 | raise "Password should contain at least 6 characters" 14 | end 15 | if (password =~ /^[[:ascii:]]+$/).nil? 16 | raise "password should contain ASCII characters only" 17 | end 18 | end 19 | 20 | def validate_archive(path : String) : Exception? 21 | file = nil 22 | begin 23 | file = ArchiveFile.new path 24 | file.check 25 | file.close 26 | return 27 | rescue e 28 | file.close unless file.nil? 29 | e 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /src/util/web.cr: -------------------------------------------------------------------------------- 1 | # Web related helper functions/macros 2 | 3 | def is_admin?(env) : Bool 4 | is_admin = false 5 | if !Config.current.auth_proxy_header_name.empty? || 6 | Config.current.disable_login 7 | is_admin = Storage.default.username_is_admin get_username env 8 | end 9 | 10 | # The token (if exists) takes precedence over other authentication methods. 11 | if token = env.session.string? "token" 12 | is_admin = Storage.default.verify_admin token 13 | end 14 | 15 | is_admin 16 | end 17 | 18 | macro layout(name) 19 | base_url = Config.current.base_url 20 | is_admin = is_admin? env 21 | begin 22 | page = {{name}} 23 | render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr" 24 | rescue e 25 | message = e.to_s 26 | Logger.error message 27 | page = "Error" 28 | render "src/views/message.html.ecr", "src/views/layout.html.ecr" 29 | end 30 | end 31 | 32 | macro send_error_page(msg) 33 | message = {{msg}} 34 | base_url = Config.current.base_url 35 | is_admin = is_admin? env 36 | page = "Error" 37 | html = render "src/views/message.html.ecr", "src/views/layout.html.ecr" 38 | send_file env, html.to_slice, "text/html" 39 | end 40 | 41 | macro send_img(env, img) 42 | cors 43 | send_file {{env}}, {{img}}.data, {{img}}.mime 44 | end 45 | 46 | def get_token_from_auth_header(env) : String? 47 | value = env.request.headers["Authorization"] 48 | if value && value.starts_with? "Bearer" 49 | session_id = value.split(" ")[1] 50 | return Kemal::Session.get(session_id).try &.string? "token" 51 | end 52 | end 53 | 54 | macro get_username(env) 55 | begin 56 | # Check if we can get the session id from the cookie 57 | token = env.session.string? "token" 58 | if token.nil? 59 | # If not, check if we can get the session id from the auth header 60 | token = get_token_from_auth_header env 61 | end 62 | # If we still don't have a token, we handle it in `resuce` with `not_nil!` 63 | (Storage.default.verify_token token.not_nil!).not_nil! 64 | rescue e 65 | if Config.current.disable_login 66 | Config.current.default_username 67 | elsif (header = Config.current.auth_proxy_header_name) && !header.empty? 68 | env.request.headers[header] 69 | else 70 | raise e 71 | end 72 | end 73 | end 74 | 75 | macro cors 76 | env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \ 77 | "DELETE,OPTIONS" 78 | env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \ 79 | "X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \ 80 | "Authorization" 81 | env.response.headers["Access-Control-Allow-Origin"] = "*" 82 | end 83 | 84 | def send_json(env, json) 85 | cors 86 | env.response.content_type = "application/json" 87 | env.response.print json 88 | end 89 | 90 | def send_text(env, text) 91 | cors 92 | env.response.content_type = "text/plain" 93 | env.response.print text 94 | end 95 | 96 | def send_attachment(env, path) 97 | cors 98 | send_file env, path, filename: File.basename(path), disposition: "attachment" 99 | end 100 | 101 | def redirect(env, path) 102 | base = Config.current.base_url 103 | env.redirect File.join base, path 104 | end 105 | 106 | def hash_to_query(hash) 107 | hash.join "&" { |k, v| "#{k}=#{v}" } 108 | end 109 | 110 | def request_path_startswith(env, ary) 111 | ary.any? { |prefix| env.request.path.starts_with? prefix } 112 | end 113 | 114 | def requesting_static_file(env) 115 | request_path_startswith env, STATIC_DIRS 116 | end 117 | 118 | macro render_xml(path) 119 | base_url = Config.current.base_url 120 | send_file env, ECR.render({{path}}).to_slice, "application/xml" 121 | end 122 | 123 | macro render_component(filename) 124 | render "src/views/components/#{{{filename}}}.html.ecr" 125 | end 126 | 127 | macro get_sort_opt 128 | sort_method = env.params.query["sort"]? 129 | 130 | if sort_method 131 | is_ascending = true 132 | 133 | ascend = env.params.query["ascend"]? 134 | if ascend && ascend.to_i? == 0 135 | is_ascending = false 136 | end 137 | 138 | sort_opt = SortOptions.new sort_method, is_ascending 139 | end 140 | end 141 | 142 | macro get_and_save_sort_opt(dir) 143 | sort_method = env.params.query["sort"]? 144 | 145 | if sort_method 146 | is_ascending = true 147 | 148 | ascend = env.params.query["ascend"]? 149 | if ascend && ascend.to_i? == 0 150 | is_ascending = false 151 | end 152 | 153 | sort_opt = SortOptions.new sort_method, is_ascending 154 | 155 | TitleInfo.new {{dir}} do |info| 156 | info.sort_by[username] = sort_opt.to_tuple 157 | info.save 158 | end 159 | end 160 | end 161 | 162 | module HTTP 163 | class Client 164 | private def self.exec(uri : URI, tls : TLSContext = nil) 165 | previous_def uri, tls do |client, path| 166 | if client.tls? && env_is_true? "DISABLE_SSL_VERIFICATION" 167 | Logger.debug "Disabling SSL verification" 168 | client.tls.verify_mode = OpenSSL::SSL::VerifyMode::NONE 169 | end 170 | Logger.debug "Setting read timeout" 171 | client.read_timeout = Config.current.download_timeout_seconds.seconds 172 | Logger.debug "Requesting #{uri}" 173 | yield client, path 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /src/views/admin.html.ecr: -------------------------------------------------------------------------------- 1 | 37 | 38 |
    39 |

    版本: v<%= MANGO_VERSION %> 汉化:昭君

    40 | 登出 41 | 42 | <% content_for "script" do %> 43 | 44 | 45 | <% end %> 46 | -------------------------------------------------------------------------------- /src/views/api.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mango API 文档 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/views/components/card.html.ecr: -------------------------------------------------------------------------------- 1 | <% if item.is_a? NamedTuple(entry: Entry, percentage: Float64, grouped_count: Int32) %> 2 | <% grouped_count = item[:grouped_count] %> 3 | <% if grouped_count == 1 %> 4 | <% item = item[:entry] %> 5 | <% else %> 6 | <% item = item[:entry].book %> 7 | <% end %> 8 | <% else %> 9 | <% grouped_count = 1 %> 10 | <% end %> 11 | 12 |
    14 | id="<%= item.id %>" 15 | <% end %>> 16 | 17 |
    20 | <% end %> 21 | " 22 | <% if item.is_a? Entry %> 23 | <% if item.err_msg %> 24 | onclick="location='<%= base_url %>reader/<%= item.book.id %>/<%= item.id %>'" 25 | <% else %> 26 | data-encoded-path="<%= item.encoded_path %>" 27 | data-pages="<%= item.pages %>" 28 | data-progress="<%= (progress * 100).round(1) %>" 29 | data-encoded-book-title="<%= item.book.encoded_display_name %>" 30 | data-encoded-title="<%= item.encoded_display_name %>" 31 | data-book-id="<%= item.book.id %>" 32 | data-id="<%= item.id %>" 33 | <% end %> 34 | <% else %> 35 | onclick="location='<%= base_url %>book/<%= item.id %>'" 36 | <% end %>> 37 | 38 |
    40 | x-init="disabled = false" 41 | <% end %>> 42 |
    43 | 45 | class="grayscale" 46 | <% end %>> 47 |
    48 |
    49 |
    50 | 51 |
    52 |
    53 |
    54 | 55 |
    56 | <% unless progress < 0 || progress > 100 || progress.nan? %> 57 |
    <%= (progress * 100).round(1) %>%
    58 | <% end %> 59 | 60 |

    62 | <%= "uk-margin-remove-bottom" %> 63 | <% end %> 64 | " data-title="<%= HTML.escape(item.display_name) %>" 65 | data-file-title="<%= HTML.escape(item.title || "") %>" 66 | data-sort-title="<%= HTML.escape(item.sort_title_db || "") %>"><%= HTML.escape(item.display_name) %> 67 |

    68 | <% if page == "home" && item.is_a? Entry %> 69 | <%= HTML.escape(item.book.display_name) %> 70 | <% end %> 71 | <% if item.is_a? Entry %> 72 | <% if item.err_msg %> 73 |

    Error

    74 |
    <%= item.err_msg %>
    75 | <% else %> 76 |

    <%= item.pages %> 页

    77 | <% end %> 78 | <% end %> 79 | <% if item.is_a? Title %> 80 | <% if grouped_count == 1 %> 81 |

    <%= item.content_label %>

    82 | <% else %> 83 |

    <%= grouped_count %> 新条目

    84 | <% end %> 85 | <% end %> 86 |
    87 |
    88 |
    89 |
    90 | -------------------------------------------------------------------------------- /src/views/components/dots.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/views/components/entry-modal.html.ecr: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /src/views/components/head.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mango - <%= page.split("-").map(&.capitalize).join(" ") %> 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/views/components/jquery-ui.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/views/components/moment.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/views/components/sort-form.html.ecr: -------------------------------------------------------------------------------- 1 |
    2 | 14 |
    15 | -------------------------------------------------------------------------------- /src/views/components/uikit.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/views/download-manager.html.ecr: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 5 | 6 | 7 |
    8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 54 | 55 |
    章节漫画进度时间状态插件操作
    56 |
    57 |
    58 | 59 | <% content_for "script" do %> 60 | <%= render_component "moment" %> 61 | 62 | 63 | <% end %> 64 | -------------------------------------------------------------------------------- /src/views/home.html.ecr: -------------------------------------------------------------------------------- 1 | <%- if new_user && empty_library -%> 2 | 3 |
    4 | 5 |

    添加你的第一部漫画

    6 |

    我们还找不到任何文件。添加一些到您的库中,它们将出现在此处。

    7 |
    8 |
    当前资源库路径
    9 |
    <%= Config.current.library_path %>
    10 |
    想要更改您的资源库路径?
    11 |
    配置 config.yml 其路径为: <%= Config.current.path %>
    12 |
    还看不到您的文件?
    13 |
    14 | 您必须等待 <%= Config.current.scan_interval_minutes %> 分钟才能完成库扫描 15 | <% if is_admin %> 16 | , 或者从 管理员手动扫描 17 | <% end %>. 18 |
    19 |
    20 |
    21 | 22 | <%- elsif new_user && empty_library == false -%> 23 | 24 |
    25 | 26 |

    阅读你的第一部漫画

    27 |

    一旦你开始阅读,Mango 会记住你离开的地方 28 | 并在此处显示您的条目。

    29 | 查看库 30 |
    31 | 32 | <%- elsif new_user == false && empty_library == false -%> 33 | 34 | <%- if continue_reading.empty? && recently_added.empty? -%> 35 |
    36 | 37 |

    一个自托管的漫画服务器和阅读器

    38 | 查看库 39 |
    40 | <%- end -%> 41 | 42 | <%- unless continue_reading.empty? -%> 43 |

    继续阅读

    44 |
    45 | <%- continue_reading.each do |cr| -%> 46 | <% item = cr[:entry] %> 47 | <% progress = cr[:percentage] %> 48 | <%= render_component "card" %> 49 | <%- end -%> 50 |
    51 | <%- end -%> 52 | 53 | <%- unless start_reading.empty? -%> 54 |

    开始阅读

    55 |
    56 | <%- start_reading.each do |t| -%> 57 | <% item = t %> 58 | <% progress = 0.0 %> 59 | <%= render_component "card" %> 60 | <%- end -%> 61 |
    62 | <%- end -%> 63 | 64 | <%- unless recently_added.empty? -%> 65 |

    最近添加

    66 |
    67 | <%- recently_added.each do |ra| -%> 68 | <% item = ra %> 69 | <% progress = ra[:percentage] %> 70 | <%= render_component "card" %> 71 | <%- end -%> 72 |
    73 | <%- end -%> 74 | 75 | <%= render_component "entry-modal" %> 76 | 77 | <%- end -%> 78 | 79 | <% content_for "script" do %> 80 | <%= render_component "dots" %> 81 | 82 | 83 | <% end %> 84 | -------------------------------------------------------------------------------- /src/views/layout.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= render_component "head" %> 5 | 6 | 7 |
    8 |
    9 |
    10 |
    11 | 30 |
    31 |
    32 |
    33 |
    34 |
    35 |
    36 |
    37 |
    38 |
    39 |
    40 | 41 | 61 |
    62 |
    63 | 67 |
    68 |
    69 |
    70 |
    71 |
    72 |
    73 |
    74 |
    75 | <%= content %> 76 |
    77 | 78 |
    79 |
    80 |
    81 | 85 | <%= render_component "uikit" %> 86 | <%= yield_content "script" %> 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /src/views/library.html.ecr: -------------------------------------------------------------------------------- 1 |

    资料库

    2 |

    <%= titles.size %> 文件找到

    3 |
    4 |
    5 | 9 |
    10 |
    11 | <% hash = { 12 | "auto" => "自动", 13 | "title" => "名称", 14 | "time_modified" => "修改日期", 15 | "progress" => "进度" 16 | } %> 17 | <%= render_component "sort-form" %> 18 |
    19 |
    20 |
    21 | <% titles.each_with_index do |item, i| %> 22 | <% progress = percentage[i] %> 23 | <%= render_component "card" %> 24 | <% end %> 25 |
    26 | 27 | <% content_for "script" do %> 28 | <%= render_component "dots" %> 29 | 30 | 31 | <% end %> 32 | -------------------------------------------------------------------------------- /src/views/login.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <% page = "登录" %> 5 | <%= render_component "head" %> 6 | 7 | 8 |
    9 |
    10 |
    11 |
    12 |
    13 |
    14 |

    登录

    15 |
    16 |
    17 |
    18 |
    19 |
    20 |
    21 |
    22 |
    23 |
    24 |
    25 |
    26 |
    27 |
    28 |
    29 |
    30 | 33 | <%= render_component "uikit" %> 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/views/message.html.ecr: -------------------------------------------------------------------------------- 1 |

    <%= message %>

    2 | -------------------------------------------------------------------------------- /src/views/missing-items.html.ecr: -------------------------------------------------------------------------------- 1 |
    2 |

    没有找到丢失的条目.

    3 |
    4 |

    以下项目存在于您的资料库中,但现在我们找不到它们了。 如果您错误地删除了它们,请尝试恢复文件或文件夹,将它们放回原来的位置,然后重新扫描资料库。 除此之外,您可以使用下面的按钮安全地删除它们和相关的元数据以释放数据库空间。

    5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 24 | 32 | 33 |
    类型相对路径ID操作
    34 |
    35 |
    36 | 37 | <% content_for "script" do %> 38 | 39 | 40 | <% end %> 41 | -------------------------------------------------------------------------------- /src/views/opds/index.xml.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | urn:mango:index 4 | 5 | 6 | 7 | 8 | 资料库 9 | 10 | 11 | Mango 12 | https://github.com/hkalexling/Mango 13 | 14 | 15 | <% titles.each do |t| %> 16 | 17 | <%= HTML.escape(t.display_name) %> 18 | urn:mango:<%= t.id %> 19 | 20 | 21 | <% end %> 22 | 23 | -------------------------------------------------------------------------------- /src/views/opds/title.xml.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | urn:mango:<%= title.id %> 4 | 5 | 6 | 7 | 8 | <%= HTML.escape(title.display_name) %> 9 | 10 | 11 | Mango 12 | https://github.com/hkalexling/Mango 13 | 14 | 15 | <% title.titles.each do |t| %> 16 | 17 | <%= HTML.escape(t.display_name) %> 18 | urn:mango:<%= t.id %> 19 | 20 | 21 | <% end %> 22 | 23 | <% title.entries.each do |e| %> 24 | <% next if e.err_msg %> 25 | 26 | <%= HTML.escape(e.display_name) %> 27 | urn:mango:<%= e.id %> 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | <% end %> 38 | 39 | -------------------------------------------------------------------------------- /src/views/reader-error.html.ecr: -------------------------------------------------------------------------------- 1 | 21 | 22 | <% content_for "script" do %> 23 | 29 | <% end %> 30 | -------------------------------------------------------------------------------- /src/views/subscription-manager.html.ecr: -------------------------------------------------------------------------------- 1 |

    订阅管理器

    2 |
    3 |
    4 |
    5 |

    未找到插件

    6 |

    我们下列目录中找不到任何插件 <%= Config.current.plugin_path %>.

    7 |

    您可以从以下网址下载官方插件 Mango 插件库.

    8 |
    9 | 10 |
    11 |
    12 | 13 |
    14 | 19 |
    20 |
    21 | 22 |

    未找到订阅.

    23 | 24 |
    25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 50 | 51 |
    名称插件 ID漫画标题创建时间上次检查操作
    52 |
    53 |
    54 |
    55 | 56 |
    57 |
    58 |
    59 |

    订阅详情

    60 |
    61 |
    62 |
    63 |
    Name
    64 |
    65 |
    订阅 ID
    66 |
    67 |
    插件 ID
    68 |
    69 |
    漫画标题
    70 |
    71 |
    漫画 ID
    72 |
    73 |
    过滤器
    74 |
    75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 87 | 88 |
    关键词种类
    89 |

    90 | 91 |

    92 |
    93 |
    94 |
    95 |
    96 | 97 | <% content_for "script" do %> 98 | <%= render_component "moment" %> 99 | 100 | 101 | <% end %> 102 | -------------------------------------------------------------------------------- /src/views/tag.html.ecr: -------------------------------------------------------------------------------- 1 |

    标签: <%= tag %>

    2 |

    <%= titles.size %> <%= titles.size > 1 ? "titles" : "title" %> 标记

    3 |
    4 |
    5 | 9 |
    10 |
    11 | <% hash = { 12 | "auto" => "自动", 13 | "time_modified" => "修改日期", 14 | "progress" => "进度" 15 | } %> 16 | <%= render_component "sort-form" %> 17 |
    18 |
    19 |
    20 | <% titles.each_with_index do |item, i| %> 21 | <% progress = percentage[i] %> 22 | <%= render_component "card" %> 23 | <% end %> 24 |
    25 | 26 | <% content_for "script" do %> 27 | <%= render_component "dots" %> 28 | 29 | 30 | <% end %> 31 | -------------------------------------------------------------------------------- /src/views/tags.html.ecr: -------------------------------------------------------------------------------- 1 |

    标签

    2 |

    <%= tags.size %> <%= tags.size > 1 ? "标签" : "标签" %> 找到

    3 | 4 | <% tags.each do |tag| %> 5 | 6 | <%= tag[:tag] %> (<%= tag[:count] %> <%= tag[:count] > 1 ? "titles" : "title" %>) 7 | 8 | <% end %> 9 | -------------------------------------------------------------------------------- /src/views/title.html.ecr: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 |

    6 |
    7 | 15 |
    16 | 17 | 18 |
    19 |
    20 |
    21 |

    "> 22 | <%= title.display_name %> 23 |   24 | <% if is_admin %> 25 | 26 | <% end %> 27 |

    28 |
    29 | 36 |

    <%= title.content_label %> 找到

    37 | 38 |
    39 | 41 |
    42 | 43 |
    44 |
    45 | 49 |
    50 |
    51 | <% hash = { 52 | "auto" => "自动", 53 | "title" => "名称", 54 | "time_modified" => "修改日期", 55 | "time_added" => "添加日期", 56 | "progress" => "进度" 57 | } %> 58 | <%= render_component "sort-form" %> 59 |
    60 |
    61 | 62 |
    63 | <% sorted_titles.each do |item| %> 64 | <% progress = title_percentage_map[item.id] %> 65 | <%= render_component "card" %> 66 | <% end %> 67 |
    68 |
    69 | <% entries.each_with_index do |item, i| %> 70 | <% progress = percentage[i] %> 71 | <%= render_component "card" %> 72 | <% end %> 73 |
    74 | 75 | <%= render_component "entry-modal" %> 76 | 77 |
    78 |
    79 | 80 |
    81 |
    82 |

    编辑

    83 |
    84 |
    85 |
    86 |
    87 | 88 |
    89 | 90 | 91 |
    92 |
    93 |
    94 | 95 |
    96 | 97 | 98 |
    99 |
    100 |
    101 | 102 |
    103 |
    104 | 105 |
    106 |
    107 |
    108 |
    109 | 110 | 上传封面图片,将其拖放到此处或 111 |
    112 | "> 113 | 选择一个 114 |
    115 |
    116 |
    117 | 118 |
    119 |
    120 | 121 |
    122 | 129 |
    130 |
    131 |
    132 | 133 | <% content_for "script" do %> 134 | <%= render_component "dots" %> 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | <% end %> 143 | -------------------------------------------------------------------------------- /src/views/user-edit.html.ecr: -------------------------------------------------------------------------------- 1 |
    2 | 3 |
    4 | 5 | value=<%= username %> <%- end -%>> 6 |
    7 | <%- if new_user -%> 8 |
    9 | 10 | 11 |
    12 | <%- end -%> 13 |
    14 | 15 | checked <%- end -%>> 16 |
    17 | 18 | <%- unless new_user -%> 19 |
    20 | 21 | 25 |
    26 | <%- end -%> 27 | 28 |
    29 | 30 | 31 |
    32 | 33 | <% content_for "script" do %> 34 | 44 | 45 | 46 | <% end %> 47 | -------------------------------------------------------------------------------- /src/views/user.html.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%- users.each do |u| -%> 11 | 12 | 13 | 14 | 20 | 21 | <%- end -%> 22 | 23 |
    用户名管理员权限操作
    <%= u[0] %><%= u[1] %> 15 | 16 | <%- if u[0] != username %> 17 | 18 | <%- end %> 19 |
    24 | 25 | 新用户 26 | 27 | 28 | <% content_for "script" do %> 29 | 30 | 31 | <% end %> 32 | --------------------------------------------------------------------------------