Merge branch 'dev'

This commit is contained in:
Alex Ling 2020-04-22 14:36:36 +00:00
commit a354d811d9
41 changed files with 2407 additions and 1882 deletions

9
.ameba.yml Normal file
View File

@ -0,0 +1,9 @@
Lint/UselessAssign:
Excluded:
- src/routes/*
- src/server.cr
Lint/UnusedArgument:
Excluded:
- src/routes/*
Metrics/CyclomaticComplexity:
Enabled: false

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: crystallang/crystal:0.32.1-alpine image: crystallang/crystal:0.34.0-alpine
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -20,5 +20,7 @@ jobs:
run: apk add --no-cache yarn yaml sqlite-static run: apk add --no-cache yarn yaml sqlite-static
- name: Build - name: Build
run: make run: make
- name: Linter
run: make check
- name: Run tests - name: Run tests
run: make test run: make test

View File

@ -1,4 +1,4 @@
FROM crystallang/crystal:0.32.1-alpine AS builder FROM crystallang/crystal:0.34.0-alpine AS builder
WORKDIR /Mango WORKDIR /Mango

View File

@ -1,4 +1,4 @@
PREFIX=/usr/local PREFIX ?= /usr/local
INSTALL_DIR=$(PREFIX)/bin INSTALL_DIR=$(PREFIX)/bin
all: uglify | build all: uglify | build
@ -22,6 +22,10 @@ run:
test: test:
crystal spec crystal spec
check:
crystal tool format --check
./bin/ameba
install: install:
cp mango $(INSTALL_DIR)/mango cp mango $(INSTALL_DIR)/mango

View File

@ -50,7 +50,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango e-manga server/reader. Version 0.2.0 Mango e-manga server/reader. Version 0.3.0
-v, --version Show version -v, --version Show version
-h, --help Show help -h, --help Show help
@ -64,8 +64,9 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
```yaml ```yaml
--- ---
port: 9000 port: 9000
library_path: ~/mango/library library_path: /home/alex_ling/mango/library
db_path: ~/mango/mango.db upload_path: /home/alex_ling/mango/uploads
db_path: /home/alex_ling/mango/mango.db
scan_interval_minutes: 5 scan_interval_minutes: 5
log_level: info log_level: info
mangadex: mangadex:
@ -73,7 +74,7 @@ mangadex:
api_url: https://mangadex.org/api api_url: https://mangadex.org/api
download_wait_seconds: 5 download_wait_seconds: 5
download_retries: 4 download_retries: 4
download_queue_db_path: ~/mango/queue.db download_queue_db_path: /home/alex_ling/mango/queue.db
``` ```
- `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan - `scan_interval_minutes` can be any non-negative integer. Setting it to `0` disables the periodic scan

View File

@ -56,3 +56,18 @@
td > .uk-dropdown { td > .uk-dropdown {
white-space: pre-line; white-space: pre-line;
} }
#edit-modal .uk-grid > div {
height: 300px;
}
#edit-modal #cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#edit-modal #cover-upload {
height: 100%;
box-sizing: border-box;
}
#edit-modal .uk-modal-body .uk-inline {
width: 100%;
}

View File

@ -15,7 +15,10 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
if (percentage === 100) { if (percentage === 100) {
$('#read-btn').attr('hidden', ''); $('#read-btn').attr('hidden', '');
} }
$('#modal-title').text(entry); $('#modal-title').find('span').text(entry);
$('#modal-title').next().attr('data-id', titleID);
$('#modal-title').next().attr('data-entry-id', entryID);
$('#modal-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath); $('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages'); $('#pages-text').text(pages + ' pages');
@ -29,11 +32,18 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
updateProgress(titleID, entryID, 0); updateProgress(titleID, entryID, 0);
}); });
$('.uk-modal-title.break-word > a').attr('onclick', `edit("${entryID}")`);
UIkit.modal($('#modal')).show(); UIkit.modal($('#modal')).show();
styleModal(); styleModal();
} }
function updateProgress(titleID, entryID, page) {
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) { const updateProgress = (tid, eid, page) => {
let url = `/api/progress/${tid}/${page}`
const query = $.param({entry: eid});
if (eid)
url += `?${query}`;
$.post(url, (data) => {
if (data.success) { if (data.success) {
location.reload(); location.reload();
} }
@ -42,4 +52,108 @@ function updateProgress(titleID, entryID, page) {
alert('danger', error); alert('danger', error);
} }
}); });
} };
const renameSubmit = (name, eid) => {
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
console.log(name);
if (name.length === 0) {
alert('danger', 'The display name should not be empty');
return;
}
const query = $.param({ entry: eid });
let url = `/api/admin/display_name/${titleId}/${name}`;
if (eid)
url += `?${query}`;
$.ajax({
type: 'POST',
url: url,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const edit = (eid) => {
const cover = $('#edit-modal #cover');
let url = cover.attr('data-title-cover');
let displayName = $('h2.uk-title > span').text();
if (eid) {
const item = $(`#${eid}`);
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
$('#title-progress-control').attr('hidden', '');
}
else {
$('#title-progress-control').removeAttr('hidden');
}
cover.attr('data-src', url);
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
displayNameField.keyup(event => {
if (event.keyCode === 13) {
renameSubmit(displayNameField.val(), eid);
}
});
displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val(), eid);
});
setupUpload(eid);
UIkit.modal($('#edit-modal')).show();
styleModal();
};
const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {title: titleId};
if (eid)
queryObj['entry'] = eid;
const query = $.param(queryObj);
const url = `/api/admin/upload/cover?${query}`;
console.log(url);
UIkit.upload('.upload-field', {
url: url,
name: 'file',
error: (e) => {
alert('danger', `Failed to upload cover image: ${e.toString()}`);
},
loadStart: (e) => {
$(bar).removeAttr('hidden');
bar.max = e.total;
bar.value = e.loaded;
},
progress: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
loadEnd: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
completeAll: () => {
$(bar).attr('hidden', '');
location.reload();
}
});
};

View File

@ -1,5 +1,9 @@
version: 1.0 version: 1.0
shards: shards:
ameba:
github: crystal-ameba/ameba
version: 0.12.0
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
version: 0.9.8 version: 0.9.8

View File

@ -1,5 +1,5 @@
name: mango name: mango
version: 0.2.5 version: 0.3.0
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@ -8,7 +8,7 @@ targets:
mango: mango:
main: src/mango.cr main: src/mango.cr
crystal: 0.32.1 crystal: 0.34.0
license: MIT license: MIT
@ -19,3 +19,7 @@ dependencies:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
baked_file_system: baked_file_system:
github: schovi/baked_file_system github: schovi/baked_file_system
development_dependencies:
ameba:
github: crystal-ameba/ameba

View File

@ -1,14 +1,14 @@
require "./spec_helper" require "./spec_helper"
describe Config do describe Config do
it "creates config if it does not exist" do it "creates config if it does not exist" do
with_default_config do |config, logger, path| with_default_config do |_, _, path|
File.exists?(path).should be_true File.exists?(path).should be_true
end end
end end
it "correctly loads config" do it "correctly loads config" do
config = Config.load "spec/asset/test-config.yml" config = Config.load "spec/asset/test-config.yml"
config.port.should eq 3000 config.port.should eq 3000
end end
end end

View File

@ -3,103 +3,102 @@ require "./spec_helper"
include MangaDex include MangaDex
describe Queue do describe Queue do
it "creates DB at given path" do it "creates DB at given path" do
with_queue do |queue, path| with_queue do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
end end
end end
it "pops nil when empty" do it "pops nil when empty" do
with_queue do |queue| with_queue do |queue|
queue.pop.should be_nil queue.pop.should be_nil
end end
end end
it "inserts multiple jobs" do it "inserts multiple jobs" do
with_queue do |queue| with_queue do |queue|
j1 = Job.new "1", "1", "title", "manga_title", JobStatus::Error, j1 = Job.new "1", "1", "title", "manga_title", JobStatus::Error,
Time.utc Time.utc
j2 = Job.new "2", "2", "title", "manga_title", JobStatus::Completed, j2 = Job.new "2", "2", "title", "manga_title", JobStatus::Completed,
Time.utc Time.utc
j3 = Job.new "3", "3", "title", "manga_title", JobStatus::Pending, j3 = Job.new "3", "3", "title", "manga_title", JobStatus::Pending,
Time.utc Time.utc
j4 = Job.new "4", "4", "title", "manga_title", j4 = Job.new "4", "4", "title", "manga_title",
JobStatus::Downloading, Time.utc JobStatus::Downloading, Time.utc
count = queue.push [j1, j2, j3, j4] count = queue.push [j1, j2, j3, j4]
count.should eq 4 count.should eq 4
end end
end end
it "pops pending job" do it "pops pending job" do
with_queue do |queue| with_queue do |queue|
job = queue.pop job = queue.pop
job.should_not be_nil job.should_not be_nil
job.not_nil!.id.should eq "3" job.not_nil!.id.should eq "3"
end end
end end
it "correctly counts jobs" do it "correctly counts jobs" do
with_queue do |queue| with_queue do |queue|
queue.count.should eq 4 queue.count.should eq 4
end end
end end
it "deletes job" do it "deletes job" do
with_queue do |queue| with_queue do |queue|
queue.delete "4" queue.delete "4"
queue.count.should eq 3 queue.count.should eq 3
end end
end end
it "sets status" do it "sets status" do
with_queue do |queue| with_queue do |queue|
job = queue.pop.not_nil! job = queue.pop.not_nil!
queue.set_status JobStatus::Downloading, job queue.set_status JobStatus::Downloading, job
job = queue.pop job = queue.pop
job.should_not be_nil job.should_not be_nil
job.not_nil!.status.should eq JobStatus::Downloading job.not_nil!.status.should eq JobStatus::Downloading
end end
end end
it "sets number of pages" do it "sets number of pages" do
with_queue do |queue| with_queue do |queue|
job = queue.pop.not_nil! job = queue.pop.not_nil!
queue.set_pages 100, job queue.set_pages 100, job
job = queue.pop job = queue.pop
job.should_not be_nil job.should_not be_nil
job.not_nil!.pages.should eq 100 job.not_nil!.pages.should eq 100
end end
end end
it "adds fail/success counts" do it "adds fail/success counts" do
with_queue do |queue| with_queue do |queue|
job = queue.pop.not_nil! job = queue.pop.not_nil!
queue.add_success job queue.add_success job
queue.add_success job queue.add_success job
queue.add_fail job queue.add_fail job
job = queue.pop job = queue.pop
job.should_not be_nil job.should_not be_nil
job.not_nil!.success_count.should eq 2 job.not_nil!.success_count.should eq 2
job.not_nil!.fail_count.should eq 1 job.not_nil!.fail_count.should eq 1
end end
end end
it "appends status message" do it "appends status message" do
with_queue do |queue| with_queue do |queue|
job = queue.pop.not_nil! job = queue.pop.not_nil!
queue.add_message "hello", job queue.add_message "hello", job
queue.add_message "world", job queue.add_message "world", job
job = queue.pop job = queue.pop
job.should_not be_nil job.should_not be_nil
job.not_nil!.status_message.should eq "\nhello\nworld" job.not_nil!.status_message.should eq "\nhello\nworld"
end end
end end
it "cleans up" do it "cleans up" do
with_queue do with_queue do
true true
end end
State.reset State.reset
end end
end end

View File

@ -3,63 +3,63 @@ require "../src/context"
require "../src/server" require "../src/server"
class State class State
@@hash = {} of String => String @@hash = {} of String => String
def self.get(key) def self.get(key)
@@hash[key]? @@hash[key]?
end end
def self.get!(key) def self.get!(key)
@@hash[key] @@hash[key]
end end
def self.set(key, value) def self.set(key, value)
return if value.nil? return if value.nil?
@@hash[key] = value @@hash[key] = value
end end
def self.reset def self.reset
@@hash.clear @@hash.clear
end end
end end
def get_tempfile(name) def get_tempfile(name)
path = State.get name path = State.get name
if path.nil? || !File.exists? path if path.nil? || !File.exists? path
file = File.tempfile name file = File.tempfile name
State.set name, file.path State.set name, file.path
return file file
else else
return File.new path File.new path
end end
end end
def with_default_config def with_default_config
temp_config = get_tempfile "mango-test-config" temp_config = get_tempfile "mango-test-config"
config = Config.load temp_config.path config = Config.load temp_config.path
logger = MLogger.new config logger = Logger.new config.log_level
yield config, logger, temp_config.path yield config, logger, temp_config.path
temp_config.delete temp_config.delete
end end
def with_storage def with_storage
with_default_config do |config, logger| with_default_config do |_, logger|
temp_db = get_tempfile "mango-test-db" temp_db = get_tempfile "mango-test-db"
storage = Storage.new temp_db.path, logger storage = Storage.new temp_db.path, logger
clear = yield storage, temp_db.path clear = yield storage, temp_db.path
if clear == true if clear == true
temp_db.delete temp_db.delete
end end
end end
end end
def with_queue def with_queue
with_default_config do |config, logger| with_default_config do |_, logger|
temp_queue_db = get_tempfile "mango-test-queue-db" temp_queue_db = get_tempfile "mango-test-queue-db"
queue = MangaDex::Queue.new temp_queue_db.path, logger queue = MangaDex::Queue.new temp_queue_db.path, logger
clear = yield queue, temp_queue_db.path clear = yield queue, temp_queue_db.path
if clear == true if clear == true
temp_queue_db.delete temp_queue_db.delete
end end
end end
end end

View File

@ -1,91 +1,91 @@
require "./spec_helper" require "./spec_helper"
describe Storage do describe Storage do
it "creates DB at given path" do it "creates DB at given path" do
with_storage do |storage, path| with_storage do |_, path|
File.exists?(path).should be_true File.exists?(path).should be_true
end end
end end
it "deletes user" do it "deletes user" do
with_storage do |storage| with_storage do |storage|
storage.delete_user "admin" storage.delete_user "admin"
end end
end end
it "creates new user" do it "creates new user" do
with_storage do |storage| with_storage do |storage|
storage.new_user "user", "123456", false storage.new_user "user", "123456", false
storage.new_user "admin", "123456", true storage.new_user "admin", "123456", true
end end
end end
it "verifies username/password combination" do it "verifies username/password combination" do
with_storage do |storage| with_storage do |storage|
user_token = storage.verify_user "user", "123456" user_token = storage.verify_user "user", "123456"
admin_token = storage.verify_user "admin", "123456" admin_token = storage.verify_user "admin", "123456"
user_token.should_not be_nil user_token.should_not be_nil
admin_token.should_not be_nil admin_token.should_not be_nil
State.set "user_token", user_token State.set "user_token", user_token
State.set "admin_token", admin_token State.set "admin_token", admin_token
end end
end end
it "rejects duplicate username" do it "rejects duplicate username" do
with_storage do |storage| with_storage do |storage|
expect_raises SQLite3::Exception, expect_raises SQLite3::Exception,
"UNIQUE constraint failed: users.username" do "UNIQUE constraint failed: users.username" do
storage.new_user "admin", "123456", true storage.new_user "admin", "123456", true
end end
end end
end end
it "verifies token" do it "verifies token" do
with_storage do |storage| with_storage do |storage|
user_token = State.get! "user_token" user_token = State.get! "user_token"
user = storage.verify_token user_token user = storage.verify_token user_token
user.should eq "user" user.should eq "user"
end end
end end
it "verfies admin token" do it "verfies admin token" do
with_storage do |storage| with_storage do |storage|
admin_token = State.get! "admin_token" admin_token = State.get! "admin_token"
storage.verify_admin(admin_token).should be_true storage.verify_admin(admin_token).should be_true
end end
end end
it "rejects non-admin token" do it "rejects non-admin token" do
with_storage do |storage| with_storage do |storage|
user_token = State.get! "user_token" user_token = State.get! "user_token"
storage.verify_admin(user_token).should be_false storage.verify_admin(user_token).should be_false
end end
end end
it "updates user" do it "updates user" do
with_storage do |storage| with_storage do |storage|
storage.update_user "admin", "admin", "654321", true storage.update_user "admin", "admin", "654321", true
token = storage.verify_user "admin", "654321" token = storage.verify_user "admin", "654321"
admin_token = State.get! "admin_token" admin_token = State.get! "admin_token"
token.should eq admin_token token.should eq admin_token
end end
end end
it "logs user out" do it "logs user out" do
with_storage do |storage| with_storage do |storage|
user_token = State.get! "user_token" user_token = State.get! "user_token"
admin_token = State.get! "admin_token" admin_token = State.get! "admin_token"
storage.logout user_token storage.logout user_token
storage.logout admin_token storage.logout admin_token
storage.verify_token(user_token).should be_nil storage.verify_token(user_token).should be_nil
storage.verify_token(admin_token).should be_nil storage.verify_token(admin_token).should be_nil
end end
end end
it "cleans up" do it "cleans up" do
with_storage do with_storage do
true true
end end
State.reset State.reset
end end
end end

View File

@ -1,36 +1,36 @@
require "./spec_helper" require "./spec_helper"
describe "compare_alphanumerically" do describe "compare_alphanumerically" do
it "sorts filenames with leading zeros correctly" do it "sorts filenames with leading zeros correctly" do
ary = ["010.jpg", "001.jpg", "002.png"] ary = ["010.jpg", "001.jpg", "002.png"]
ary.sort! {|a, b| ary.sort! { |a, b|
compare_alphanumerically a, b compare_alphanumerically a, b
} }
ary.should eq ["001.jpg", "002.png", "010.jpg"] ary.should eq ["001.jpg", "002.png", "010.jpg"]
end end
it "sorts filenames without leading zeros correctly" do it "sorts filenames without leading zeros correctly" do
ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"] ary = ["10.jpg", "1.jpg", "0.png", "0100.jpg"]
ary.sort! {|a, b| ary.sort! { |a, b|
compare_alphanumerically a, b compare_alphanumerically a, b
} }
ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"] ary.should eq ["0.png", "1.jpg", "10.jpg", "0100.jpg"]
end end
# https://ux.stackexchange.com/a/95441 # https://ux.stackexchange.com/a/95441
it "sorts like the stack exchange post" do it "sorts like the stack exchange post" do
ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2", ary = ["2", "12", "200000", "1000000", "a", "a12", "b2", "text2",
"text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"] "text2a", "text2a2", "text2a12", "text2ab", "text12", "text12a"]
ary.reverse.sort {|a, b| ary.reverse.sort { |a, b|
compare_alphanumerically a, b compare_alphanumerically a, b
}.should eq ary }.should eq ary
end end
# https://github.com/hkalexling/Mango/issues/22 # https://github.com/hkalexling/Mango/issues/22
it "handles numbers larger than Int32" do it "handles numbers larger than Int32" do
ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"] ary = ["14410155591588.jpg", "21410155591588.png", "104410155591588.jpg"]
ary.reverse.sort {|a, b| ary.reverse.sort { |a, b|
compare_alphanumerically a, b compare_alphanumerically a, b
}.should eq ary }.should eq ary
end end
end end

View File

@ -1,26 +0,0 @@
require "kemal"
require "./storage"
require "./util"
class AuthHandler < Kemal::Handler
def initialize(@storage : Storage)
end
def call(env)
return call_next(env) \
if request_path_startswith env, ["/login", "/logout"]
cookie = env.request.cookies.find { |c| c.name == "token" }
if cookie.nil? || ! @storage.verify_token cookie.value
return env.redirect "/login"
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless @storage.verify_admin cookie.value
env.response.status_code = 403
end
end
call_next env
end
end

View File

@ -1,60 +1,61 @@
require "yaml" require "yaml"
class Config class Config
include YAML::Serializable include YAML::Serializable
property port : Int32 = 9000 property port : Int32 = 9000
property library_path : String = \ property library_path : String = File.expand_path "~/mango/library",
File.expand_path "~/mango/library", home: true home: true
property db_path : String = \ property db_path : String = File.expand_path "~/mango/mango.db", home: true
File.expand_path "~/mango/mango.db", home: true @[YAML::Field(key: "scan_interval_minutes")]
@[YAML::Field(key: "scan_interval_minutes")] property scan_interval : Int32 = 5
property scan_interval : Int32 = 5 property log_level : String = "info"
property log_level : String = "info" property upload_path : String = File.expand_path "~/mango/uploads",
property mangadex = Hash(String, String|Int32).new home: true
property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)] @[YAML::Field(ignore: true)]
@mangadex_defaults = { @mangadex_defaults = {
"base_url" => "https://mangadex.org", "base_url" => "https://mangadex.org",
"api_url" => "https://mangadex.org/api", "api_url" => "https://mangadex.org/api",
"download_wait_seconds" => 5, "download_wait_seconds" => 5,
"download_retries" => 4, "download_retries" => 4,
"download_queue_db_path" => File.expand_path "~/mango/queue.db", "download_queue_db_path" => File.expand_path("~/mango/queue.db",
home: true home: true),
} }
def self.load(path : String?) def self.load(path : String?)
path = "~/.config/mango/config.yml" if path.nil? path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true cfg_path = File.expand_path path, home: true
if File.exists? cfg_path if File.exists? cfg_path
config = self.from_yaml File.read cfg_path config = self.from_yaml File.read cfg_path
config.fill_defaults config.fill_defaults
return config return config
end end
puts "The config file #{cfg_path} does not exist." \ puts "The config file #{cfg_path} does not exist." \
" Do you want mango to dump the default config there? [Y/n]" " Do you want mango to dump the default config there? [Y/n]"
input = gets input = gets
if input && input.downcase == "n" if input && input.downcase == "n"
abort "Aborting..." abort "Aborting..."
end end
default = self.allocate default = self.allocate
default.fill_defaults default.fill_defaults
cfg_dir = File.dirname cfg_path cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir unless Dir.exists? cfg_dir
Dir.mkdir_p cfg_dir Dir.mkdir_p cfg_dir
end end
File.write cfg_path, default.to_yaml File.write cfg_path, default.to_yaml
puts "The config file has been created at #{cfg_path}." puts "The config file has been created at #{cfg_path}."
default default
end end
def fill_defaults def fill_defaults
{% for hash_name in ["mangadex"] %} {% for hash_name in ["mangadex"] %}
@{{hash_name.id}}_defaults.map do |k, v| @{{hash_name.id}}_defaults.map do |k, v|
if @{{hash_name.id}}[k]?.nil? if @{{hash_name.id}}[k]?.nil?
@{{hash_name.id}}[k] = v @{{hash_name.id}}[k] = v
end end
end end
{% end %} {% end %}
end end
end end

View File

@ -4,18 +4,18 @@ require "./storage"
require "./logger" require "./logger"
class Context class Context
property config : Config property config : Config
property library : Library property library : Library
property storage : Storage property storage : Storage
property logger : MLogger property logger : Logger
property queue : MangaDex::Queue property queue : MangaDex::Queue
def initialize(@config, @logger, @library, @storage, @queue) def initialize(@config, @logger, @library, @storage, @queue)
end end
{% for lvl in LEVELS %} {% for lvl in Logger::LEVELS %}
def {{lvl.id}}(msg) def {{lvl.id}}(msg)
@logger.{{lvl.id}} msg @logger.{{lvl.id}} msg
end end
{% end %} {% end %}
end end

View File

@ -0,0 +1,25 @@
require "kemal"
require "../storage"
require "../util"
class AuthHandler < Kemal::Handler
def initialize(@storage : Storage)
end
def call(env)
return call_next(env) if request_path_startswith env, ["/login", "/logout"]
cookie = env.request.cookies.find { |c| c.name == "token" }
if cookie.nil? || !@storage.verify_token cookie.value
return env.redirect "/login"
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless @storage.verify_admin cookie.value
env.response.status_code = 403
end
end
call_next env
end
end

View File

@ -0,0 +1,26 @@
require "kemal"
require "../logger"
class LogHandler < Kemal::BaseLogHandler
def initialize(@logger : Logger)
end
def call(env)
elapsed_time = Time.measure { call_next env }
elapsed_text = elapsed_text elapsed_time
msg = "#{env.response.status_code} #{env.request.method}" \
" #{env.request.resource} #{elapsed_text}"
@logger.debug msg
env
end
def write(msg)
@logger.debug msg
end
private def elapsed_text(elapsed)
millis = elapsed.total_milliseconds
return "#{millis.round(2)}ms" if millis >= 1
"#{(millis * 1000).round(2)}µs"
end
end

View File

@ -0,0 +1,32 @@
require "baked_file_system"
require "kemal"
require "../util"
class FS
extend BakedFileSystem
{% if flag?(:release) %}
{% if read_file? "#{__DIR__}/../../dist/favicon.ico" %}
{% puts "baking ../../dist" %}
bake_folder "../../dist"
{% else %}
{% puts "baking ../../public" %}
bake_folder "../../public"
{% end %}
{% end %}
end
class StaticHandler < Kemal::Handler
@dirs = ["/css", "/js", "/img", "/favicon.ico"]
def call(env)
if request_path_startswith env, @dirs
file = FS.get? env.request.path
return call_next env if file.nil?
slice = Bytes.new file.size
file.read slice
return send_file env, slice, file.mime_type
end
call_next env
end
end

View File

@ -0,0 +1,24 @@
require "kemal"
require "../util"
class UploadHandler < Kemal::Handler
def initialize(@upload_dir : String)
end
def call(env)
unless request_path_startswith(env, [UPLOAD_URL_PREFIX]) &&
env.request.method == "GET"
return call_next env
end
ary = env.request.path.split(File::SEPARATOR).select { |part| !part.empty? }
ary[0] = @upload_dir
path = File.join ary
if File.exists? path
send_file env, path
else
env.response.status_code = 404
end
end
end

View File

@ -5,309 +5,427 @@ require "uri"
require "./util" require "./util"
struct Image struct Image
property data : Bytes property data : Bytes
property mime : String property mime : String
property filename : String property filename : String
property size : Int32 property size : Int32
def initialize(@data, @mime, @filename, @size) def initialize(@data, @mime, @filename, @size)
end end
end end
class Entry class Entry
property zip_path : String, book_title : String, title : String, property zip_path : String, book : Title, title : String,
size : String, pages : Int32, cover_url : String, id : String, size : String, pages : Int32, id : String, title_id : String,
title_id : String, encoded_path : String, encoded_title : String, encoded_path : String, encoded_title : String, mtime : Time
mtime : Time
def initialize(path, @book_title, @title_id, storage) def initialize(path, @book, @title_id, storage)
@zip_path = path @zip_path = path
@encoded_path = URI.encode path @encoded_path = URI.encode path
@title = File.basename path, File.extname path @title = File.basename path, File.extname path
@encoded_title = URI.encode @title @encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes @size = (File.size path).humanize_bytes
file = Zip::File.new path file = Zip::File.new path
@pages = file.entries @pages = file.entries.count do |e|
.select { |e| ["image/jpeg", "image/png"].includes? \
["image/jpeg", "image/png"].includes? \ MIME.from_filename? e.filename
MIME.from_filename? e.filename end
} file.close
.size @id = storage.get_id @zip_path, false
file.close @mtime = File.info(@zip_path).modification_time
@id = storage.get_id @zip_path, false end
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
@mtime = File.info(@zip_path).modification_time
end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["zip_path", "book_title", "title", "size", {% for str in ["zip_path", "title", "size", "id", "title_id",
"cover_url", "id", "title_id", "encoded_path", "encoded_path", "encoded_title"] %}
"encoded_title"] %} json.field {{str}}, @{{str.id}}
json.field {{str}}, @{{str.id}} {% end %}
{% end %} json.field "display_name", @book.display_name @title
json.field "pages" {json.number @pages} json.field "cover_url", cover_url
json.field "mtime" {json.number @mtime.to_unix} json.field "pages" { json.number @pages }
end json.field "mtime" { json.number @mtime.to_unix }
end end
end
def read_page(page_num) def display_name
Zip::File.open @zip_path do |file| @book.display_name @title
page = file.entries end
.select { |e|
["image/jpeg", "image/png"].includes? \ def encoded_display_name
MIME.from_filename? e.filename URI.encode display_name
} end
.sort { |a, b|
compare_alphanumerically a.filename, b.filename def cover_url
} url = "/api/page/#{@title_id}/#{@id}/1"
.[page_num - 1] TitleInfo.new @book.dir do |info|
page.open do |io| info_url = info.entry_cover_url[@title]?
slice = Bytes.new page.uncompressed_size unless info_url.nil? || info_url.empty?
bytes_read = io.read_fully? slice url = info_url
unless bytes_read end
return nil end
end url
return Image.new slice, MIME.from_filename(page.filename),\ end
page.filename, bytes_read
end def read_page(page_num)
end Zip::File.open @zip_path do |file|
end page = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
}
.sort { |a, b|
compare_alphanumerically a.filename, b.filename
}
.[page_num - 1]
page.open do |io|
slice = Bytes.new page.uncompressed_size
bytes_read = io.read_fully? slice
unless bytes_read
return nil
end
return Image.new slice, MIME.from_filename(page.filename),
page.filename, bytes_read
end
end
end
end end
class Title class Title
property dir : String, parent_id : String, title_ids : Array(String), property dir : String, parent_id : String, title_ids : Array(String),
entries : Array(Entry), title : String, id : String, entries : Array(Entry), title : String, id : String,
encoded_title : String, mtime : Time encoded_title : String, mtime : Time
def initialize(dir : String, @parent_id, storage, def initialize(@dir : String, @parent_id, storage,
@logger : MLogger, @library : Library) @logger : Logger, @library : Library)
@dir = dir @id = storage.get_id @dir, true
@id = storage.get_id @dir, true @title = File.basename dir
@title = File.basename dir @encoded_title = URI.encode @title
@encoded_title = URI.encode @title @title_ids = [] of String
@title_ids = [] of String @entries = [] of Entry
@entries = [] of Entry @mtime = File.info(dir).modification_time
Dir.entries(dir).each do |fn| Dir.entries(dir).each do |fn|
next if fn.starts_with? "." next if fn.starts_with? "."
path = File.join dir, fn path = File.join dir, fn
if File.directory? path if File.directory? path
title = Title.new path, @id, storage, @logger, library title = Title.new path, @id, storage, @logger, library
next if title.entries.size == 0 && title.titles.size == 0 next if title.entries.size == 0 && title.titles.size == 0
@library.title_hash[title.id] = title @library.title_hash[title.id] = title
@title_ids << title.id @title_ids << title.id
next next
end end
if [".zip", ".cbz"].includes? File.extname path if [".zip", ".cbz"].includes? File.extname path
next if !valid_zip path zip_exception = validate_zip path
entry = Entry.new path, @title, @id, storage unless zip_exception.nil?
@entries << entry if entry.pages > 0 @logger.warn "File #{path} is corrupted or is not a valid zip " \
end "archive. Ignoring it."
end @logger.debug "Zip error: #{zip_exception}"
next
end
entry = Entry.new path, self, @id, storage
@entries << entry if entry.pages > 0
end
end
@title_ids.sort! do |a, b| mtimes = [@mtime]
compare_alphanumerically @library.title_hash[a].title, mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
@library.title_hash[b].title mtimes += @entries.map { |e| e.mtime }
end @mtime = mtimes.max
@entries.sort! do |a, b|
compare_alphanumerically a.title, b.title
end
mtimes = [File.info(dir).modification_time] @title_ids.sort! do |a, b|
mtimes += @title_ids.map{|e| @library.title_hash[e].mtime} compare_alphanumerically @library.title_hash[a].title,
mtimes += @entries.map{|e| e.mtime} @library.title_hash[b].title
@mtime = mtimes.max end
end @entries.sort! do |a, b|
compare_alphanumerically a.title, b.title
end
end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.object do json.object do
{% for str in ["dir", "title", "id", "encoded_title"] %} {% for str in ["dir", "title", "id", "encoded_title"] %}
json.field {{str}}, @{{str.id}} json.field {{str}}, @{{str.id}}
{% end %} {% end %}
json.field "mtime" {json.number @mtime.to_unix} json.field "display_name", display_name
json.field "titles" do json.field "cover_url", cover_url
json.raw self.titles.to_json json.field "mtime" { json.number @mtime.to_unix }
end json.field "titles" do
json.field "entries" do json.raw self.titles.to_json
json.raw @entries.to_json end
end json.field "entries" do
json.field "parents" do json.raw @entries.to_json
json.array do end
self.parents.each do |title| json.field "parents" do
json.object do json.array do
json.field "title", title.title self.parents.each do |title|
json.field "id", title.id json.object do
end json.field "title", title.title
end json.field "id", title.id
end end
end end
end end
end end
end
end
def titles def titles
@title_ids.map {|tid| @library.get_title! tid} @title_ids.map { |tid| @library.get_title! tid }
end end
def parents def parents
ary = [] of Title ary = [] of Title
tid = @parent_id tid = @parent_id
while !tid.empty? while !tid.empty?
title = @library.get_title! tid title = @library.get_title! tid
ary << title ary << title
tid = title.parent_id tid = title.parent_id
end end
ary ary
end end
def size def size
@entries.size + @title_ids.size @entries.size + @title_ids.size
end end
# When downloading from MangaDex, the zip/cbz file would not be valid def get_entry(eid)
# before the download is completed. If we scan the zip file, @entries.find { |e| e.id == eid }
# Entry.new would throw, so we use this method to check before end
# constructing Entry
private def valid_zip(path : String) def display_name
begin dn = @title
file = Zip::File.new path TitleInfo.new @dir do |info|
file.close info_dn = info.display_name
return true dn = info_dn unless info_dn.empty?
rescue end
@logger.warn "File #{path} is corrupted or is not a valid zip "\ dn
"archive. Ignoring it." end
return false
end def encoded_display_name
end URI.encode display_name
def get_entry(eid) end
@entries.find { |e| e.id == eid }
end def display_name(entry_name)
# For backward backward compatibility with v0.1.0, we save entry titles dn = entry_name
# instead of IDs in info.json TitleInfo.new @dir do |info|
def save_progress(username, entry, page) info_dn = info.entry_display_name[entry_name]?
info = TitleInfo.new @dir unless info_dn.nil? || info_dn.empty?
if info.progress[username]?.nil? dn = info_dn
info.progress[username] = {entry => page} end
info.save @dir end
return dn
end end
info.progress[username][entry] = page
info.save @dir def set_display_name(dn)
end TitleInfo.new @dir do |info|
def load_progress(username, entry) info.display_name = dn
info = TitleInfo.new @dir info.save
if info.progress[username]?.nil? end
return 0 end
end
if info.progress[username][entry]?.nil? def set_display_name(entry_name : String, dn)
return 0 TitleInfo.new @dir do |info|
end info.entry_display_name[entry_name] = dn
info.progress[username][entry] info.save
end end
def load_percetage(username, entry) end
info = TitleInfo.new @dir
page = load_progress username, entry def cover_url
entry_obj = @entries.find{|e| e.title == entry} url = "img/icon.png"
return 0.0 if entry_obj.nil? if @entries.size > 0
page / entry_obj.pages url = @entries[0].cover_url
end end
def load_percetage(username) TitleInfo.new @dir do |info|
return 0.0 if @entries.empty? info_url = info.cover_url
read_pages = total_pages = 0 unless info_url.nil? || info_url.empty?
@entries.each do |e| url = info_url
read_pages += load_progress username, e.title end
total_pages += e.pages end
end url
read_pages / total_pages end
end
def next_entry(current_entry_obj) def set_cover_url(url : String)
idx = @entries.index current_entry_obj TitleInfo.new @dir do |info|
return nil if idx.nil? || idx == @entries.size - 1 info.cover_url = url
@entries[idx + 1] info.save
end end
end
def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url
info.save
end
end
# Set the reading progress of all entries and nested libraries to 100%
def read_all(username)
@entries.each do |e|
save_progress username, e.title, e.pages
end
titles.each do |t|
t.read_all username
end
end
# Set the reading progress of all entries and nested libraries to 0%
def unread_all(username)
@entries.each do |e|
save_progress username, e.title, 0
end
titles.each do |t|
t.unread_all username
end
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, entry, page)
TitleInfo.new @dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {entry => page}
else
info.progress[username][entry] = page
end
info.save
end
end
def load_progress(username, entry)
progress = 0
TitleInfo.new @dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][entry]?.nil?
progress = info.progress[username][entry]
end
end
progress
end
def load_percetage(username, entry)
page = load_progress username, entry
entry_obj = @entries.find { |e| e.title == entry }
return 0.0 if entry_obj.nil?
page / entry_obj.pages
end
def load_percetage(username)
return 0.0 if @entries.empty?
read_pages = total_pages = 0
@entries.each do |e|
read_pages += load_progress username, e.title
total_pages += e.pages
end
read_pages / total_pages
end
def next_entry(current_entry_obj)
idx = @entries.index current_entry_obj
return nil if idx.nil? || idx == @entries.size - 1
@entries[idx + 1]
end
end end
class TitleInfo class TitleInfo
# { user1: { entry1: 10, entry2: 0 } } include JSON::Serializable
include JSON::Serializable
property comment = "Generated by Mango. DO NOT EDIT!" property comment = "Generated by Mango. DO NOT EDIT!"
property progress : Hash(String, Hash(String, Int32)) property progress = {} of String => Hash(String, Int32)
property display_name = ""
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
def initialize(title_dir) @[JSON::Field(ignore: true)]
info = nil property dir : String = ""
json_path = File.join title_dir, "info.json" @@mutex_hash = {} of String => Mutex
if File.exists? json_path
info = TitleInfo.from_json File.read json_path
else
info = TitleInfo.from_json "{\"progress\": {}}"
end
@progress = info.progress.clone def self.new(dir, &)
end if @@mutex_hash[dir]?
def save(title_dir) mutex = @@mutex_hash[dir]
json_path = File.join title_dir, "info.json" else
File.write json_path, self.to_pretty_json mutex = Mutex.new
end @@mutex_hash[dir] = mutex
end
mutex.synchronize do
instance = TitleInfo.allocate
json_path = File.join dir, "info.json"
if File.exists? json_path
instance = TitleInfo.from_json File.read json_path
end
instance.dir = dir
yield instance
end
end
def save
json_path = File.join @dir, "info.json"
File.write json_path, self.to_pretty_json
end
end end
class Library class Library
property dir : String, title_ids : Array(String), scan_interval : Int32, property dir : String, title_ids : Array(String), scan_interval : Int32,
logger : MLogger, storage : Storage, title_hash : Hash(String, Title) logger : Logger, storage : Storage, title_hash : Hash(String, Title)
def initialize(@dir, @scan_interval, @logger, @storage) def initialize(@dir, @scan_interval, @logger, @storage)
# explicitly initialize @titles to bypass the compiler check. it will # explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below # be filled with actual Titles in the `scan` call below
@title_ids = [] of String @title_ids = [] of String
@title_hash = {} of String => Title @title_hash = {} of String => Title
return scan if @scan_interval < 1 return scan if @scan_interval < 1
spawn do spawn do
loop do loop do
start = Time.local start = Time.local
scan scan
ms = (Time.local - start).total_milliseconds ms = (Time.local - start).total_milliseconds
@logger.info "Scanned #{@title_ids.size} titles in #{ms}ms" @logger.info "Scanned #{@title_ids.size} titles in #{ms}ms"
sleep @scan_interval * 60 sleep @scan_interval * 60
end end
end end
end end
def titles
@title_ids.map {|tid| self.get_title!(tid) } def titles
end @title_ids.map { |tid| self.get_title!(tid) }
def to_json(json : JSON::Builder) end
json.object do
json.field "dir", @dir def to_json(json : JSON::Builder)
json.field "titles" do json.object do
json.raw self.titles.to_json json.field "dir", @dir
end json.field "titles" do
end json.raw self.titles.to_json
end end
def get_title(tid) end
@title_hash[tid]? end
end
def get_title!(tid) def get_title(tid)
@title_hash[tid] @title_hash[tid]?
end end
def scan
unless Dir.exists? @dir def get_title!(tid)
@logger.info "The library directory #{@dir} does not exist. " \ @title_hash[tid]
"Attempting to create it" end
Dir.mkdir_p @dir
end def scan
@title_ids.clear unless Dir.exists? @dir
(Dir.entries @dir) @logger.info "The library directory #{@dir} does not exist. " \
.select { |fn| !fn.starts_with? "." } "Attempting to create it"
.map { |fn| File.join @dir, fn } Dir.mkdir_p @dir
.select { |path| File.directory? path } end
.map { |path| Title.new path, "", @storage, @logger, self } @title_ids.clear
.select { |title| !(title.entries.empty? && title.titles.empty?) } (Dir.entries @dir)
.sort { |a, b| a.title <=> b.title } .select { |fn| !fn.starts_with? "." }
.each do |title| .map { |fn| File.join @dir, fn }
@title_hash[title.id] = title .select { |path| File.directory? path }
@title_ids << title.id .map { |path| Title.new path, "", @storage, @logger, self }
end .select { |title| !(title.entries.empty? && title.titles.empty?) }
@logger.debug "Scan completed" .sort { |a, b| a.title <=> b.title }
end .each do |title|
@title_hash[title.id] = title
@title_ids << title.id
end
@logger.debug "Scan completed"
end
end end

View File

@ -1,26 +0,0 @@
require "kemal"
require "./logger"
class LogHandler < Kemal::BaseLogHandler
def initialize(@logger : MLogger)
end
def call(env)
elapsed_time = Time.measure { call_next env }
elapsed_text = elapsed_text elapsed_time
msg = "#{env.response.status_code} #{env.request.method}" \
" #{env.request.resource} #{elapsed_text}"
@logger.debug(msg)
env
end
def write(msg)
@logger.debug(msg)
end
private def elapsed_text(elapsed)
millis = elapsed.total_milliseconds
return "#{millis.round(2)}ms" if millis >= 1
"#{(millis * 1000).round(2)}µs"
end
end

View File

@ -1,59 +1,58 @@
require "./config" require "log"
require "logger"
require "colorize" require "colorize"
LEVELS = ["debug", "error", "fatal", "info", "warn"] class Logger
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta] LEVELS = ["debug", "error", "fatal", "info", "warn"]
SEVERITY_IDS = [0, 4, 5, 2, 3]
COLORS = [:light_cyan, :light_red, :red, :light_yellow, :light_magenta]
class MLogger @@severity : Log::Severity = :info
def initialize(config : Config)
@logger = Logger.new STDOUT
@log_off = false def initialize(level : String)
log_level = config.log_level {% begin %}
if log_level == "off" case level.downcase
@log_off = true when "off"
return @@severity = :none
end {% for lvl, i in LEVELS %}
when {{lvl}}
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
{% end %}
else
raise "Unknown log level #{level}"
end
{% end %}
{% begin %} @log = Log.for("")
case log_level
{% for lvl in LEVELS %}
when {{lvl}}
@logger.level = Logger::{{lvl.upcase.id}}
{% end %}
else
raise "Unknown log level #{log_level}"
end
{% end %}
@logger.formatter = Logger::Formatter.new do \ @backend = Log::IOBackend.new
|severity, datetime, progname, message, io| @backend.formatter = ->(entry : Log::Entry, io : IO) do
color = :default
{% begin %}
case entry.severity.label.to_s().downcase
{% for lvl, i in LEVELS %}
when {{lvl}}, "#{{{lvl}}}ing"
color = COLORS[{{i}}]
{% end %}
else
end
{% end %}
color = :default io << "[#{entry.severity.label}]".ljust(10).colorize(color)
{% begin %} io << entry.timestamp.to_s("%Y/%m/%d %H:%M:%S") << " | "
case severity.to_s().downcase io << entry.message
{% for lvl, i in LEVELS %} end
when {{lvl}}
color = COLORS[{{i}}]
{% end %}
end
{% end %}
io << "[#{severity}]".ljust(8).colorize(color) Log.builder.bind "*", @@severity, @backend
io << datetime.to_s("%Y/%m/%d %H:%M:%S") << " | " end
io << message
end
end
{% for lvl in LEVELS %} # Ignores @@severity and always log msg
def {{lvl.id}}(msg) def log(msg)
return if @log_off @backend.write Log::Entry.new "", Log::Severity::None, msg, nil
@logger.{{lvl.id}} msg end
end
{% end %}
def to_json(json : JSON::Builder) {% for lvl in LEVELS %}
json.string self def {{lvl.id}}(msg)
end @log.{{lvl.id}} { msg }
end
{% end %}
end end

View File

@ -2,202 +2,196 @@ require "http/client"
require "json" require "json"
require "csv" require "csv"
macro string_properties (names) macro string_properties(names)
{% for name in names %} {% for name in names %}
property {{name.id}} = "" property {{name.id}} = ""
{% end %} {% end %}
end end
macro parse_strings_from_json (names) macro parse_strings_from_json(names)
{% for name in names %} {% for name in names %}
@{{name.id}} = obj[{{name}}].as_s @{{name.id}} = obj[{{name}}].as_s
{% end %} {% end %}
end end
module MangaDex module MangaDex
class Chapter class Chapter
string_properties ["lang_code", "title", "volume", "chapter"] string_properties ["lang_code", "title", "volume", "chapter"]
property manga : Manga property manga : Manga
property time = Time.local property time = Time.local
property id : String property id : String
property full_title = "" property full_title = ""
property language = "" property language = ""
property pages = [] of {String, String} # filename, url property pages = [] of {String, String} # filename, url
property groups = [] of {Int32, String} # group_id, group_name property groups = [] of {Int32, String} # group_id, group_name
def initialize(@id, json_obj : JSON::Any, @manga, lang : def initialize(@id, json_obj : JSON::Any, @manga,
Hash(String, String)) lang : Hash(String, String))
self.parse_json json_obj, lang self.parse_json json_obj, lang
end end
def to_info_json def to_info_json
JSON.build do |json| JSON.build do |json|
json.object do json.object do
{% for name in ["id", "title", "volume", "chapter", {% for name in ["id", "title", "volume", "chapter",
"language", "full_title"] %} "language", "full_title"] %}
json.field {{name}}, @{{name.id}} json.field {{name}}, @{{name.id}}
{% end %} {% end %}
json.field "time", @time.to_unix.to_s json.field "time", @time.to_unix.to_s
json.field "manga_title", @manga.title json.field "manga_title", @manga.title
json.field "manga_id", @manga.id json.field "manga_id", @manga.id
json.field "groups" do json.field "groups" do
json.object do json.object do
@groups.each do |gid, gname| @groups.each do |gid, gname|
json.field gname, gid json.field gname, gid
end end
end end
end end
end end
end end
end end
def parse_json(obj, lang) def parse_json(obj, lang)
begin parse_strings_from_json ["lang_code", "title", "volume",
parse_strings_from_json ["lang_code", "title", "volume", "chapter"]
"chapter"] language = lang[@lang_code]?
language = lang[@lang_code]? @language = language if language
@language = language if language @time = Time.unix obj["timestamp"].as_i
@time = Time.unix obj["timestamp"].as_i suffixes = ["", "_2", "_3"]
suffixes = ["", "_2", "_3"] suffixes.each do |s|
suffixes.each do |s| gid = obj["group_id#{s}"].as_i
gid = obj["group_id#{s}"].as_i next if gid == 0
next if gid == 0 gname = obj["group_name#{s}"].as_s
gname = obj["group_name#{s}"].as_s @groups << {gid, gname}
@groups << {gid, gname} end
end @full_title = @title
@full_title = @title unless @chapter.empty?
unless @chapter.empty? @full_title = "Ch.#{@chapter} " + @full_title
@full_title = "Ch.#{@chapter} " + @full_title end
end unless @volume.empty?
unless @volume.empty? @full_title = "Vol.#{@volume} " + @full_title
@full_title = "Vol.#{@volume} " + @full_title end
end rescue e
rescue e raise "failed to parse json: #{e}"
raise "failed to parse json: #{e}" end
end end
end
end
class Manga
string_properties ["cover_url", "description", "title", "author",
"artist"]
property chapters = [] of Chapter
property id : String
def initialize(@id, json_obj : JSON::Any) class Manga
self.parse_json json_obj string_properties ["cover_url", "description", "title", "author", "artist"]
end property chapters = [] of Chapter
property id : String
def to_info_json(with_chapters = true) def initialize(@id, json_obj : JSON::Any)
JSON.build do |json| self.parse_json json_obj
json.object do end
{% for name in ["id", "title", "description",
"author", "artist", "cover_url"] %}
json.field {{name}}, @{{name.id}}
{% end %}
if with_chapters
json.field "chapters" do
json.array do
@chapters.each do |c|
json.raw c.to_info_json
end
end
end
end
end
end
end
def parse_json(obj) def to_info_json(with_chapters = true)
begin JSON.build do |json|
parse_strings_from_json ["cover_url", "description", "title", json.object do
"author", "artist"] {% for name in ["id", "title", "description", "author", "artist",
rescue e "cover_url"] %}
raise "failed to parse json: #{e}" json.field {{name}}, @{{name.id}}
end {% end %}
end if with_chapters
end json.field "chapters" do
class API json.array do
def initialize(@base_url = "https://mangadex.org/api/") @chapters.each do |c|
@lang = {} of String => String json.raw c.to_info_json
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row| end
@lang[row[1]] = row[0] end
end end
end end
end
end
end
def get(url) def parse_json(obj)
headers = HTTP::Headers { parse_strings_from_json ["cover_url", "description", "title", "author",
"User-agent" => "Mangadex.cr" "artist"]
} rescue e
res = HTTP::Client.get url, headers raise "failed to parse json: #{e}"
raise "Failed to get #{url}. [#{res.status_code}] "\ end
"#{res.status_message}" if !res.success? end
JSON.parse res.body
end
def get_manga(id) class API
obj = self.get File.join @base_url, "manga/#{id}" def initialize(@base_url = "https://mangadex.org/api/")
if obj["status"]? != "OK" @lang = {} of String => String
raise "Expecting `OK` in the `status` field. " \ CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
"Got `#{obj["status"]?}`" @lang[row[1]] = row[0]
end end
begin end
manga = Manga.new id, obj["manga"]
obj["chapter"].as_h.map do |k, v|
chapter = Chapter.new k, v, manga, @lang
manga.chapters << chapter
end
return manga
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(chapter : Chapter) def get(url)
obj = self.get File.join @base_url, "chapter/#{chapter.id}" headers = HTTP::Headers{
if obj["status"]? == "external" "User-agent" => "Mangadex.cr",
raise "This chapter is hosted on an external site " \ }
"#{obj["external"]?}, and Mango does not support " \ res = HTTP::Client.get url, headers
"external chapters." raise "Failed to get #{url}. [#{res.status_code}] " \
end "#{res.status_message}" if !res.success?
if obj["status"]? != "OK" JSON.parse res.body
raise "Expecting `OK` in the `status` field. " \ end
"Got `#{obj["status"]?}`"
end
begin
server = obj["server"].as_s
hash = obj["hash"].as_s
chapter.pages = obj["page_array"].as_a.map do |fn|
{
fn.as_s,
"#{server}#{hash}/#{fn.as_s}"
}
end
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(id : String) def get_manga(id)
obj = self.get File.join @base_url, "chapter/#{id}" obj = self.get File.join @base_url, "manga/#{id}"
if obj["status"]? == "external" if obj["status"]? != "OK"
raise "This chapter is hosted on an external site " \ raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
"#{obj["external"]?}, and Mango does not support " \ end
"external chapters." begin
end manga = Manga.new id, obj["manga"]
if obj["status"]? != "OK" obj["chapter"].as_h.map do |k, v|
raise "Expecting `OK` in the `status` field. " \ chapter = Chapter.new k, v, manga, @lang
"Got `#{obj["status"]?}`" manga.chapters << chapter
end end
manga_id = "" manga
begin rescue
manga_id = obj["manga_id"].as_i.to_s raise "Failed to parse JSON"
rescue end
raise "Failed to parse JSON" end
end
manga = self.get_manga manga_id def get_chapter(chapter : Chapter)
chapter = manga.chapters.find {|c| c.id == id}.not_nil! obj = self.get File.join @base_url, "chapter/#{chapter.id}"
self.get_chapter chapter if obj["status"]? == "external"
return chapter raise "This chapter is hosted on an external site " \
end "#{obj["external"]?}, and Mango does not support " \
end "external chapters."
end
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
begin
server = obj["server"].as_s
hash = obj["hash"].as_s
chapter.pages = obj["page_array"].as_a.map do |fn|
{
fn.as_s,
"#{server}#{hash}/#{fn.as_s}",
}
end
rescue
raise "Failed to parse JSON"
end
end
def get_chapter(id : String)
obj = self.get File.join @base_url, "chapter/#{id}"
if obj["status"]? == "external"
raise "This chapter is hosted on an external site " \
"#{obj["external"]?}, and Mango does not support " \
"external chapters."
end
if obj["status"]? != "OK"
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
end
manga_id = ""
begin
manga_id = obj["manga_id"].as_i.to_s
rescue
raise "Failed to parse JSON"
end
manga = self.get_manga manga_id
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
self.get_chapter chapter
chapter
end
end
end end

View File

@ -2,373 +2,384 @@ require "./api"
require "sqlite3" require "sqlite3"
module MangaDex module MangaDex
class PageJob class PageJob
property success = false property success = false
property url : String property url : String
property filename : String property filename : String
property writer : Zip::Writer property writer : Zip::Writer
property tries_remaning : Int32 property tries_remaning : Int32
def initialize(@url, @filename, @writer, @tries_remaning)
end
end
enum JobStatus def initialize(@url, @filename, @writer, @tries_remaning)
Pending # 0 end
Downloading # 1 end
Error # 2
Completed # 3
MissingPages # 4
end
struct Job enum JobStatus
property id : String Pending # 0
property manga_id : String Downloading # 1
property title : String Error # 2
property manga_title : String Completed # 3
property status : JobStatus MissingPages # 4
property status_message : String = "" end
property pages : Int32 = 0
property success_count : Int32 = 0
property fail_count : Int32 = 0
property time : Time
def parse_query_result(res : DB::ResultSet) struct Job
@id = res.read String property id : String
@manga_id = res.read String property manga_id : String
@title = res.read String property title : String
@manga_title = res.read String property manga_title : String
status = res.read Int32 property status : JobStatus
@status_message = res.read String property status_message : String = ""
@pages = res.read Int32 property pages : Int32 = 0
@success_count = res.read Int32 property success_count : Int32 = 0
@fail_count = res.read Int32 property fail_count : Int32 = 0
time = res.read Int64 property time : Time
@status = JobStatus.new status
@time = Time.unix_ms time
end
# Raises if the result set does not contain the correct set of columns def parse_query_result(res : DB::ResultSet)
def self.from_query_result(res : DB::ResultSet) @id = res.read String
job = Job.allocate @manga_id = res.read String
job.parse_query_result res @title = res.read String
return job @manga_title = res.read String
end status = res.read Int32
@status_message = res.read String
@pages = res.read Int32
@success_count = res.read Int32
@fail_count = res.read Int32
time = res.read Int64
@status = JobStatus.new status
@time = Time.unix_ms time
end
def initialize(@id, @manga_id, @title, @manga_title, @status, @time) # Raises if the result set does not contain the correct set of columns
end def self.from_query_result(res : DB::ResultSet)
job = Job.allocate
job.parse_query_result res
job
end
def to_json(json) def initialize(@id, @manga_id, @title, @manga_title, @status, @time)
json.object do end
{% for name in ["id", "manga_id", "title", "manga_title",
"status_message"] %}
json.field {{name}}, @{{name.id}}
{% end %}
{% for name in ["pages", "success_count", "fail_count"] %}
json.field {{name}} do
json.number @{{name.id}}
end
{% end %}
json.field "status", @status.to_s
json.field "time" do
json.number @time.to_unix_ms
end
end
end
end
class Queue def to_json(json)
property downloader : Downloader? json.object do
{% for name in ["id", "manga_id", "title", "manga_title",
"status_message"] %}
json.field {{name}}, @{{name.id}}
{% end %}
{% for name in ["pages", "success_count", "fail_count"] %}
json.field {{name}} do
json.number @{{name.id}}
end
{% end %}
json.field "status", @status.to_s
json.field "time" do
json.number @time.to_unix_ms
end
end
end
end
def initialize(@path : String, @logger : MLogger) class Queue
dir = File.dirname path property downloader : Downloader?
unless Dir.exists? dir
@logger.info "The queue DB directory #{dir} does not exist. " \
"Attepmting to create it"
Dir.mkdir_p dir
end
DB.open "sqlite3://#{@path}" do |db|
begin
db.exec "create table if not exists queue " \
"(id text, manga_id text, title text, manga_title " \
"text, status integer, status_message text, " \
"pages integer, success_count integer, " \
"fail_count integer, time integer)"
db.exec "create unique index if not exists id_idx " \
"on queue (id)"
db.exec "create index if not exists manga_id_idx " \
"on queue (manga_id)"
db.exec "create index if not exists status_idx " \
"on queue (status)"
rescue e
@logger.error "Error when checking tables in DB: #{e}"
raise e
end
end
end
# Returns the earliest job in queue or nil if the job cannot be parsed. def initialize(@path : String, @logger : Logger)
# Returns nil if queue is empty dir = File.dirname path
def pop unless Dir.exists? dir
job = nil @logger.info "The queue DB directory #{dir} does not exist. " \
DB.open "sqlite3://#{@path}" do |db| "Attepmting to create it"
begin Dir.mkdir_p dir
db.query_one "select * from queue where status = 0 "\ end
"or status = 1 order by time limit 1" do |res| DB.open "sqlite3://#{@path}" do |db|
job = Job.from_query_result res begin
end db.exec "create table if not exists queue " \
rescue "(id text, manga_id text, title text, manga_title " \
end "text, status integer, status_message text, " \
end "pages integer, success_count integer, " \
return job "fail_count integer, time integer)"
end db.exec "create unique index if not exists id_idx " \
"on queue (id)"
db.exec "create index if not exists manga_id_idx " \
"on queue (manga_id)"
db.exec "create index if not exists status_idx " \
"on queue (status)"
rescue e
@logger.error "Error when checking tables in DB: #{e}"
raise e
end
end
end
# Push an array of jobs into the queue, and return the number of jobs # Returns the earliest job in queue or nil if the job cannot be parsed.
# inserted. Any job already exists in the queue will be ignored. # Returns nil if queue is empty
def push(jobs : Array(Job)) def pop
start_count = self.count job = nil
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
jobs.each do |job| begin
db.exec "insert or ignore into queue values "\ db.query_one "select * from queue where status = 0 " \
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "or status = 1 order by time limit 1" do |res|
job.id, job.manga_id, job.title, job.manga_title, job = Job.from_query_result res
job.status.to_i, job.status_message, job.pages, end
job.success_count, job.fail_count, job.time.to_unix_ms rescue
end end
end end
self.count - start_count job
end end
def reset(id : String) # Push an array of jobs into the queue, and return the number of jobs
DB.open "sqlite3://#{@path}" do |db| # inserted. Any job already exists in the queue will be ignored.
db.exec "update queue set status = 0, status_message = '', " \ def push(jobs : Array(Job))
"pages = 0, success_count = 0, fail_count = 0 " \ start_count = self.count
"where id = (?)", id DB.open "sqlite3://#{@path}" do |db|
end jobs.each do |job|
end db.exec "insert or ignore into queue values " \
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
job.id, job.manga_id, job.title, job.manga_title,
job.status.to_i, job.status_message, job.pages,
job.success_count, job.fail_count, job.time.to_unix_ms
end
end
self.count - start_count
end
def reset (job : Job) def reset(id : String)
self.reset job.id DB.open "sqlite3://#{@path}" do |db|
end db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \
"where id = (?)", id
end
end
# Reset all failed tasks (missing pages and error) def reset(job : Job)
def reset self.reset job.id
DB.open "sqlite3://#{@path}" do |db| end
db.exec "update queue set status = 0, status_message = '', " \
"pages = 0, success_count = 0, fail_count = 0 " \
"where status = 2 or status = 4"
end
end
def delete(id : String) # Reset all failed tasks (missing pages and error)
DB.open "sqlite3://#{@path}" do |db| def reset
db.exec "delete from queue where id = (?)", id DB.open "sqlite3://#{@path}" do |db|
end db.exec "update queue set status = 0, status_message = '', " \
end "pages = 0, success_count = 0, fail_count = 0 " \
"where status = 2 or status = 4"
end
end
def delete(job : Job) def delete(id : String)
self.delete job.id DB.open "sqlite3://#{@path}" do |db|
end db.exec "delete from queue where id = (?)", id
end
end
def delete_status(status : JobStatus) def delete(job : Job)
DB.open "sqlite3://#{@path}" do |db| self.delete job.id
db.exec "delete from queue where status = (?)", status.to_i end
end
end
def count_status(status : JobStatus) def delete_status(status : JobStatus)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
return db.query_one "select count(*) from queue where "\ db.exec "delete from queue where status = (?)", status.to_i
"status = (?)", status.to_i, as: Int32 end
end end
end
def count def count_status(status : JobStatus)
DB.open "sqlite3://#{@path}" do |db| num = 0
return db.query_one "select count(*) from queue", as: Int32 DB.open "sqlite3://#{@path}" do |db|
end num = db.query_one "select count(*) from queue where " \
end "status = (?)", status.to_i, as: Int32
end
num
end
def set_status(status : JobStatus, job : Job) def count
DB.open "sqlite3://#{@path}" do |db| num = 0
db.exec "update queue set status = (?) where id = (?)", DB.open "sqlite3://#{@path}" do |db|
status.to_i, job.id num = db.query_one "select count(*) from queue", as: Int32
end end
end num
end
def get_all def set_status(status : JobStatus, job : Job)
jobs = [] of Job DB.open "sqlite3://#{@path}" do |db|
DB.open "sqlite3://#{@path}" do |db| db.exec "update queue set status = (?) where id = (?)",
jobs = db.query_all "select * from queue order by time", do |rs| status.to_i, job.id
Job.from_query_result rs end
end end
end
return jobs
end
def add_success(job : Job) def get_all
DB.open "sqlite3://#{@path}" do |db| jobs = [] of Job
db.exec "update queue set success_count = success_count + 1 " \ DB.open "sqlite3://#{@path}" do |db|
"where id = (?)", job.id jobs = db.query_all "select * from queue order by time" do |rs|
end Job.from_query_result rs
end end
end
jobs
end
def add_fail(job : Job) def add_success(job : Job)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set fail_count = fail_count + 1 " \ db.exec "update queue set success_count = success_count + 1 " \
"where id = (?)", job.id "where id = (?)", job.id
end end
end end
def set_pages(pages : Int32, job : Job) def add_fail(job : Job)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set pages = (?), success_count = 0, " \ db.exec "update queue set fail_count = fail_count + 1 " \
"fail_count = 0 where id = (?)", pages, job.id "where id = (?)", job.id
end end
end end
def add_message(msg : String, job : Job) def set_pages(pages : Int32, job : Job)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "update queue set status_message = " \ db.exec "update queue set pages = (?), success_count = 0, " \
"status_message || (?) || (?) where id = (?)", "fail_count = 0 where id = (?)", pages, job.id
"\n", msg, job.id end
end end
end
def pause def add_message(msg : String, job : Job)
@downloader.not_nil!.stopped = true DB.open "sqlite3://#{@path}" do |db|
end db.exec "update queue set status_message = " \
"status_message || (?) || (?) where id = (?)",
"\n", msg, job.id
end
end
def resume def pause
@downloader.not_nil!.stopped = false @downloader.not_nil!.stopped = true
end end
def paused? def resume
@downloader.not_nil!.stopped @downloader.not_nil!.stopped = false
end end
end
class Downloader def paused?
property stopped = false @downloader.not_nil!.stopped
@downloading = false end
end
def initialize(@queue : Queue, @api : API, @library_path : String, class Downloader
@wait_seconds : Int32, @retries : Int32, property stopped = false
@logger : MLogger) @downloading = false
@queue.downloader = self
spawn do def initialize(@queue : Queue, @api : API, @library_path : String,
loop do @wait_seconds : Int32, @retries : Int32,
sleep 1.second @logger : Logger)
next if @stopped || @downloading @queue.downloader = self
begin
job = @queue.pop
next if job.nil?
download job
rescue e
@logger.error e
end
end
end
end
private def download(job : Job) spawn do
@downloading = true loop do
@queue.set_status JobStatus::Downloading, job sleep 1.second
begin next if @stopped || @downloading
chapter = @api.get_chapter(job.id) begin
rescue e job = @queue.pop
@logger.error e next if job.nil?
@queue.set_status JobStatus::Error, job download job
unless e.message.nil? rescue e
@queue.add_message e.message.not_nil!, job @logger.error e
end end
@downloading = false end
return end
end end
@queue.set_pages chapter.pages.size, job
lib_dir = @library_path
manga_dir = File.join lib_dir, chapter.manga.title
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{job.title}.cbz"
# Find the number of digits needed to store the number of pages private def download(job : Job)
len = Math.log10(chapter.pages.size).to_i + 1 @downloading = true
@queue.set_status JobStatus::Downloading, job
begin
chapter = @api.get_chapter(job.id)
rescue e
@logger.error e
@queue.set_status JobStatus::Error, job
unless e.message.nil?
@queue.add_message e.message.not_nil!, job
end
@downloading = false
return
end
@queue.set_pages chapter.pages.size, job
lib_dir = @library_path
manga_dir = File.join lib_dir, chapter.manga.title
unless File.exists? manga_dir
Dir.mkdir_p manga_dir
end
zip_path = File.join manga_dir, "#{job.title}.cbz"
writer = Zip::Writer.new zip_path # Find the number of digits needed to store the number of pages
# Create a buffered channel. It works as an FIFO queue len = Math.log10(chapter.pages.size).to_i + 1
channel = Channel(PageJob).new chapter.pages.size
spawn do
chapter.pages.each_with_index do |tuple, i|
fn, url = tuple
ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries
@logger.debug "Downloading #{url}"
loop do
sleep @wait_seconds.seconds
download_page page_job
break if page_job.success ||
page_job.tries_remaning <= 0
page_job.tries_remaning -= 1
@logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \
"#{page_job.tries_remaning}"
end
channel.send page_job writer = Zip::Writer.new zip_path
end # Create a buffered channel. It works as an FIFO queue
end channel = Channel(PageJob).new chapter.pages.size
spawn do
chapter.pages.each_with_index do |tuple, i|
fn, url = tuple
ext = File.extname fn
fn = "#{i.to_s.rjust len, '0'}#{ext}"
page_job = PageJob.new url, fn, writer, @retries
@logger.debug "Downloading #{url}"
loop do
sleep @wait_seconds.seconds
download_page page_job
break if page_job.success ||
page_job.tries_remaning <= 0
page_job.tries_remaning -= 1
@logger.warn "Failed to download page #{url}. " \
"Retrying... Remaining retries: " \
"#{page_job.tries_remaning}"
end
spawn do channel.send page_job
page_jobs = [] of PageJob end
chapter.pages.size.times do end
page_job = channel.receive
@logger.debug "[#{page_job.success ? "success" : "failed"}] " \
"#{page_job.url}"
page_jobs << page_job
if page_job.success
@queue.add_success job
else
@queue.add_fail job
msg = "Failed to download page #{page_job.url}"
@queue.add_message msg, job
@logger.error msg
end
end
fail_count = page_jobs.select{|j| !j.success}.size
@logger.debug "Download completed. "\
"#{fail_count}/#{page_jobs.size} failed"
writer.close
@logger.debug "cbz File created at #{zip_path}"
if fail_count == 0
@queue.set_status JobStatus::Completed, job
else
@queue.set_status JobStatus::MissingPages, job
end
@downloading = false
end
end
private def download_page(job : PageJob) spawn do
@logger.debug "downloading #{job.url}" page_jobs = [] of PageJob
headers = HTTP::Headers { chapter.pages.size.times do
"User-agent" => "Mangadex.cr" page_job = channel.receive
} @logger.debug "[#{page_job.success ? "success" : "failed"}] " \
begin "#{page_job.url}"
HTTP::Client.get job.url, headers do |res| page_jobs << page_job
unless res.success? if page_job.success
raise "Failed to download page #{job.url}. " \ @queue.add_success job
"[#{res.status_code}] #{res.status_message}" else
end @queue.add_fail job
job.writer.add job.filename, res.body_io msg = "Failed to download page #{page_job.url}"
end @queue.add_message msg, job
job.success = true @logger.error msg
rescue e end
@logger.error e end
job.success = false fail_count = page_jobs.count { |j| !j.success }
end @logger.debug "Download completed. " \
end "#{fail_count}/#{page_jobs.size} failed"
end writer.close
@logger.debug "cbz File created at #{zip_path}"
zip_exception = validate_zip zip_path
if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job
@queue.set_status JobStatus::Error, job
elsif fail_count > 0
@queue.set_status JobStatus::MissingPages, job
else
@queue.set_status JobStatus::Completed, job
end
@downloading = false
end
end
private def download_page(job : PageJob)
@logger.debug "downloading #{job.url}"
headers = HTTP::Headers{
"User-agent" => "Mangadex.cr",
}
begin
HTTP::Client.get job.url, headers do |res|
unless res.success?
raise "Failed to download page #{job.url}. " \
"[#{res.status_code}] #{res.status_message}"
end
job.writer.add job.filename, res.body_io
end
job.success = true
rescue e
@logger.error e
job.success = false
end
end
end
end end

View File

@ -3,37 +3,37 @@ require "./context"
require "./mangadex/*" require "./mangadex/*"
require "option_parser" require "option_parser"
VERSION = "0.2.5" VERSION = "0.3.0"
config_path = nil config_path = nil
parser = OptionParser.parse do |parser| OptionParser.parse do |parser|
parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n" parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
parser.on "-v", "--version", "Show version" do parser.on "-v", "--version", "Show version" do
puts "Version #{VERSION}" puts "Version #{VERSION}"
exit exit
end end
parser.on "-h", "--help", "Show help" do parser.on "-h", "--help", "Show help" do
puts parser puts parser
exit exit
end end
parser.on "-c PATH", "--config=PATH", "Path to the config file. " \ parser.on "-c PATH", "--config=PATH",
"Default is `~/.config/mango/config.yml`" do |path| "Path to the config file. Default is `~/.config/mango/config.yml`" do |path|
config_path = path config_path = path
end end
end end
config = Config.load config_path config = Config.load config_path
logger = MLogger.new config logger = Logger.new config.log_level
storage = Storage.new config.db_path, logger storage = Storage.new config.db_path, logger
library = Library.new config.library_path, config.scan_interval, logger, storage library = Library.new config.library_path, config.scan_interval, logger, storage
queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s, queue = MangaDex::Queue.new config.mangadex["download_queue_db_path"].to_s,
logger logger
api = MangaDex::API.new config.mangadex["api_url"].to_s api = MangaDex::API.new config.mangadex["api_url"].to_s
downloader = MangaDex::Downloader.new queue, api, config.library_path, MangaDex::Downloader.new queue, api, config.library_path,
config.mangadex["download_wait_seconds"].to_i, config.mangadex["download_wait_seconds"].to_i,
config.mangadex["download_retries"].to_i, logger config.mangadex["download_retries"].to_i, logger
context = Context.new config, logger, library, storage, queue context = Context.new config, logger, library, storage, queue

View File

@ -1,108 +1,103 @@
require "./router" require "./router"
class AdminRouter < Router class AdminRouter < Router
def setup def setup
get "/admin" do |env| get "/admin" do |env|
layout "admin" layout "admin"
end end
get "/admin/user" do |env| get "/admin/user" do |env|
users = @context.storage.list_users users = @context.storage.list_users
username = get_username env username = get_username env
layout "user" layout "user"
end end
get "/admin/user/edit" do |env| get "/admin/user/edit" do |env|
username = env.params.query["username"]? username = env.params.query["username"]?
admin = env.params.query["admin"]? admin = env.params.query["admin"]?
if admin if admin
admin = admin == "true" admin = admin == "true"
end end
error = env.params.query["error"]? error = env.params.query["error"]?
current_user = get_username env current_user = get_username env
new_user = username.nil? && admin.nil? new_user = username.nil? && admin.nil?
layout "user-edit" layout "user-edit"
end end
post "/admin/user/edit" do |env| post "/admin/user/edit" do |env|
# creating new user # creating new user
begin username = env.params.body["username"]
username = env.params.body["username"] password = env.params.body["password"]
password = env.params.body["password"] # if `admin` is unchecked, the body hash
# if `admin` is unchecked, the body hash # would not contain `admin`
# would not contain `admin` admin = !env.params.body["admin"]?.nil?
admin = !env.params.body["admin"]?.nil?
if username.size < 3 if username.size < 3
raise "Username should contain at least 3 characters" raise "Username should contain at least 3 characters"
end end
if (username =~ /^[A-Za-z0-9_]+$/).nil? if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters "\ raise "Username should contain alphanumeric characters " \
"and underscores only" "and underscores only"
end end
if password.size < 6 if password.size < 6
raise "Password should contain at least 6 characters" raise "Password should contain at least 6 characters"
end end
if (password =~ /^[[:ascii:]]+$/).nil? if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only" raise "password should contain ASCII characters only"
end end
@context.storage.new_user username, password, admin @context.storage.new_user username, password, admin
env.redirect "/admin/user" env.redirect "/admin/user"
rescue e rescue e
@context.error e @context.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit",\ path: "/admin/user/edit",
query: hash_to_query({"error" => e.message}) query: hash_to_query({"error" => e.message})
env.redirect redirect_url.to_s env.redirect redirect_url.to_s
end end
end
post "/admin/user/edit/:original_username" do |env| post "/admin/user/edit/:original_username" do |env|
# editing existing user # editing existing user
begin username = env.params.body["username"]
username = env.params.body["username"] password = env.params.body["password"]
password = env.params.body["password"] # if `admin` is unchecked, the body hash would not contain `admin`
# if `admin` is unchecked, the body admin = !env.params.body["admin"]?.nil?
# hash would not contain `admin` original_username = env.params.url["original_username"]
admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"]
if username.size < 3 if username.size < 3
raise "Username should contain at least 3 characters" raise "Username should contain at least 3 characters"
end end
if (username =~ /^[A-Za-z0-9_]+$/).nil? if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters "\ raise "Username should contain alphanumeric characters " \
"and underscores only" "and underscores only"
end end
if password.size != 0 if password.size != 0
if password.size < 6 if password.size < 6
raise "Password should contain at least 6 characters" raise "Password should contain at least 6 characters"
end end
if (password =~ /^[[:ascii:]]+$/).nil? if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only" raise "password should contain ASCII characters only"
end end
end end
@context.storage.update_user \ @context.storage.update_user \
original_username, username, password, admin original_username, username, password, admin
env.redirect "/admin/user" env.redirect "/admin/user"
rescue e rescue e
@context.error e @context.error e
redirect_url = URI.new \ redirect_url = URI.new \
path: "/admin/user/edit",\ path: "/admin/user/edit",
query: hash_to_query({"username" => original_username, \ query: hash_to_query({"username" => original_username, \
"admin" => admin, "error" => e.message}) "admin" => admin, "error" => e.message})
env.redirect redirect_url.to_s env.redirect redirect_url.to_s
end end
end
get "/admin/downloads" do |env| get "/admin/downloads" do |env|
base_url = @context.config.mangadex["base_url"]; base_url = @context.config.mangadex["base_url"]
layout "download-manager" layout "download-manager"
end end
end end
end end

View File

@ -1,180 +1,263 @@
require "./router" require "./router"
require "../mangadex/*" require "../mangadex/*"
require "../upload"
class APIRouter < Router class APIRouter < Router
def setup def setup
get "/api/page/:tid/:eid/:page" do |env| get "/api/page/:tid/:eid/:page" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
eid = env.params.url["eid"] eid = env.params.url["eid"]
page = env.params.url["page"].to_i page = env.params.url["page"].to_i
title = @context.library.get_title tid title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if \ raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
entry.nil? img = entry.read_page page
img = entry.read_page page raise "Failed to load page #{page} of " \
raise "Failed to load page #{page} of " \ "`#{title.title}/#{entry.title}`" if img.nil?
"`#{title.title}/#{entry.title}`" if img.nil?
send_img env, img send_img env, img
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 500 env.response.status_code = 500
e.message e.message
end end
end end
get "/api/book/:tid" do |env| get "/api/book/:tid" do |env|
begin begin
tid = env.params.url["tid"] tid = env.params.url["tid"]
title = @context.library.get_title tid title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil? raise "Title ID `#{tid}` not found" if title.nil?
send_json env, title.to_json send_json env, title.to_json
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 500 env.response.status_code = 500
e.message e.message
end end
end end
get "/api/book" do |env| get "/api/book" do |env|
send_json env, @context.library.to_json send_json env, @context.library.to_json
end end
post "/api/admin/scan" do |env| post "/api/admin/scan" do |env|
start = Time.utc start = Time.utc
@context.library.scan @context.library.scan
ms = (Time.utc - start).total_milliseconds ms = (Time.utc - start).total_milliseconds
send_json env, { send_json env, {
"milliseconds" => ms, "milliseconds" => ms,
"titles" => @context.library.titles.size "titles" => @context.library.titles.size,
}.to_json }.to_json
end end
post "/api/admin/user/delete/:username" do |env| post "/api/admin/user/delete/:username" do |env|
begin begin
username = env.params.url["username"] username = env.params.url["username"]
@context.storage.delete_user username @context.storage.delete_user username
rescue e rescue e
@context.error e @context.error e
send_json env, { send_json env, {
"success" => false, "success" => false,
"error" => e.message "error" => e.message,
}.to_json }.to_json
else else
send_json env, {"success" => true}.to_json send_json env, {"success" => true}.to_json
end end
end end
post "/api/progress/:title/:entry/:page" do |env| post "/api/progress/:title/:page" do |env|
begin begin
username = get_username env username = get_username env
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"])
.not_nil! .not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! page = env.params.url["page"].to_i
page = env.params.url["page"].to_i entry_id = env.params.query["entry"]?
raise "incorrect page value" if page < 0 || page > entry.pages if !entry_id.nil?
title.save_progress username, entry.title, page entry = title.get_entry(entry_id).not_nil!
rescue e raise "incorrect page value" if page < 0 || page > entry.pages
@context.error e title.save_progress username, entry.title, page
send_json env, { elsif page == 0
"success" => false, title.unread_all username
"error" => e.message else
}.to_json title.read_all username
else end
send_json env, {"success" => true}.to_json rescue e
end @context.error e
end send_json env, {
"success" => false,
"error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
get "/api/admin/mangadex/manga/:id" do |env| post "/api/admin/display_name/:title/:name" do |env|
begin begin
id = env.params.url["id"] title = (@context.library.get_title env.params.url["title"])
api = MangaDex::API.new \ .not_nil!
@context.config.mangadex["api_url"].to_s name = env.params.url["name"]
manga = api.get_manga id entry = env.params.query["entry"]?
send_json env, manga.to_info_json if entry.nil?
rescue e title.set_display_name name
@context.error e else
send_json env, {"error" => e.message}.to_json eobj = title.get_entry entry
end title.set_display_name eobj.not_nil!.title, name
end end
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/admin/mangadex/download" do |env| get "/api/admin/mangadex/manga/:id" do |env|
begin begin
chapters = env.params.json["chapters"].as(Array).map{|c| c.as_h} id = env.params.url["id"]
jobs = chapters.map {|chapter| api = MangaDex::API.new @context.config.mangadex["api_url"].to_s
MangaDex::Job.new( manga = api.get_manga id
chapter["id"].as_s, send_json env, manga.to_info_json
chapter["manga_id"].as_s, rescue e
chapter["full_title"].as_s, @context.error e
chapter["manga_title"].as_s, send_json env, {"error" => e.message}.to_json
MangaDex::JobStatus::Pending, end
Time.unix chapter["time"].as_s.to_i end
)
}
inserted_count = @context.queue.push jobs
send_json env, {
"success": inserted_count,
"fail": jobs.size - inserted_count
}.to_json
rescue e
@context.error e
send_json env, {"error" => e.message}.to_json
end
end
get "/api/admin/mangadex/queue" do |env| post "/api/admin/mangadex/download" do |env|
begin begin
jobs = @context.queue.get_all chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
send_json env, { jobs = chapters.map { |chapter|
"jobs" => jobs, MangaDex::Job.new(
"paused" => @context.queue.paused?, chapter["id"].as_s,
"success" => true chapter["manga_id"].as_s,
}.to_json chapter["full_title"].as_s,
rescue e chapter["manga_title"].as_s,
send_json env, { MangaDex::JobStatus::Pending,
"success" => false, Time.unix chapter["time"].as_s.to_i
"error" => e.message )
}.to_json }
end inserted_count = @context.queue.push jobs
end send_json env, {
"success": inserted_count,
"fail": jobs.size - inserted_count,
}.to_json
rescue e
@context.error e
send_json env, {"error" => e.message}.to_json
end
end
post "/api/admin/mangadex/queue/:action" do |env| get "/api/admin/mangadex/queue" do |env|
begin begin
action = env.params.url["action"] jobs = @context.queue.get_all
id = env.params.query["id"]? send_json env, {
case action "jobs" => jobs,
when "delete" "paused" => @context.queue.paused?,
if id.nil? "success" => true,
@context.queue.delete_status MangaDex::JobStatus::Completed }.to_json
else rescue e
@context.queue.delete id send_json env, {
end "success" => false,
when "retry" "error" => e.message,
if id.nil? }.to_json
@context.queue.reset end
else end
@context.queue.reset id
end
when "pause"
@context.queue.pause
when "resume"
@context.queue.resume
else
raise "Unknown queue action #{action}"
end
send_json env, {"success" => true}.to_json post "/api/admin/mangadex/queue/:action" do |env|
rescue e begin
send_json env, { action = env.params.url["action"]
"success" => false, id = env.params.query["id"]?
"error" => e.message case action
}.to_json when "delete"
end if id.nil?
end @context.queue.delete_status MangaDex::JobStatus::Completed
end else
@context.queue.delete id
end
when "retry"
if id.nil?
@context.queue.reset
else
@context.queue.reset id
end
when "pause"
@context.queue.pause
when "resume"
@context.queue.resume
else
raise "Unknown queue action #{action}"
end
send_json env, {"success" => true}.to_json
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
post "/api/admin/upload/:target" do |env|
begin
target = env.params.url["target"]
HTTP::FormData.parse env.request do |part|
next if part.name != "file"
filename = part.filename
if filename.nil?
raise "No file uploaded"
end
case target
when "cover"
title_id = env.params.query["title"]
entry_id = env.params.query["entry"]?
title = @context.library.get_title(title_id).not_nil!
unless ["image/jpeg", "image/png"].includes? \
MIME.from_filename? filename
raise "The uploaded image must be either JPEG or PNG"
end
ext = File.extname filename
upload = Upload.new @context.config.upload_path, @context.logger
url = upload.path_to_url upload.save "img", ext, part.body
if url.nil?
raise "Failed to generate a public URL for the uploaded file"
end
if entry_id.nil?
title.set_cover_url url
else
entry_name = title.get_entry(entry_id).not_nil!.title
title.set_cover_url entry_name, url
end
else
raise "Unkown upload target #{target}"
end
send_json env, {"success" => true}.to_json
env.response.close
end
raise "No part with name `file` found"
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
end
end end

View File

@ -1,63 +1,66 @@
require "./router" require "./router"
class MainRouter < Router class MainRouter < Router
def setup def setup
get "/login" do |env| get "/login" do |env|
render "src/views/login.ecr" render "src/views/login.ecr"
end end
get "/logout" do |env| get "/logout" do |env|
begin begin
cookie = env.request.cookies cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
.find { |c| c.name == "token" }.not_nil! @context.storage.logout cookie.value
@context.storage.logout cookie.value rescue e
rescue e @context.error "Error when attempting to log out: #{e}"
@context.error "Error when attempting to log out: #{e}" ensure
ensure env.redirect "/login"
env.redirect "/login" end
end end
end
post "/login" do |env| post "/login" do |env|
begin begin
username = env.params.body["username"] username = env.params.body["username"]
password = env.params.body["password"] password = env.params.body["password"]
token = @context.storage.verify_user(username, password) token = @context.storage.verify_user(username, password).not_nil!
.not_nil!
cookie = HTTP::Cookie.new "token", token cookie = HTTP::Cookie.new "token", token
cookie.expires = Time.local.shift years: 1 cookie.expires = Time.local.shift years: 1
env.response.cookies << cookie env.response.cookies << cookie
env.redirect "/" env.redirect "/"
rescue rescue
env.redirect "/login" env.redirect "/login"
end end
end end
get "/" do |env| get "/" do |env|
titles = @context.library.titles begin
username = get_username env titles = @context.library.titles
percentage = titles.map &.load_percetage username username = get_username env
layout "index" percentage = titles.map &.load_percetage username
end layout "index"
rescue e
@context.error e
env.response.status_code = 500
end
end
get "/book/:title" do |env| get "/book/:title" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"]).not_nil!
.not_nil! username = get_username env
username = get_username env percentage = title.entries.map { |e|
percentage = title.entries.map { |e| title.load_percetage username, e.title
title.load_percetage username, e.title } }
layout "title" layout "title"
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
get "/download" do |env| get "/download" do |env|
base_url = @context.config.mangadex["base_url"]; base_url = @context.config.mangadex["base_url"]
layout "download" layout "download"
end end
end end
end end

View File

@ -1,58 +1,61 @@
require "./router" require "./router"
class ReaderRouter < Router class ReaderRouter < Router
def setup def setup
get "/reader/:title/:entry" do |env| get "/reader/:title/:entry" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"]).not_nil!
.not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
# load progress # load progress
username = get_username env username = get_username env
page = title.load_progress username, entry.title page = title.load_progress username, entry.title
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll # we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
# library perloads a few pages in advance, and the user # library perloads a few pages in advance, and the user
# might not have actually read them # might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max page = [page - 2 * IMGS_PER_PAGE, 1].max
env.redirect "/reader/#{title.id}/#{entry.id}/#{page}" env.redirect "/reader/#{title.id}/#{entry.id}/#{page}"
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
get "/reader/:title/:entry/:page" do |env| get "/reader/:title/:entry/:page" do |env|
begin begin
title = (@context.library.get_title env.params.url["title"]) title = (@context.library.get_title env.params.url["title"]).not_nil!
.not_nil! entry = (title.get_entry env.params.url["entry"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil! page = env.params.url["page"].to_i
page = env.params.url["page"].to_i raise "" if page > entry.pages || page <= 0
raise "" if page > entry.pages || page <= 0
# save progress # save progress
username = get_username env username = get_username env
title.save_progress username, entry.title, page title.save_progress username, entry.title, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min) pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx| urls = pages.map { |idx|
"/api/page/#{title.id}/#{entry.id}/#{idx}" } "/api/page/#{title.id}/#{entry.id}/#{idx}"
reader_urls = pages.map { |idx| }
"/reader/#{title.id}/#{entry.id}/#{idx}" } reader_urls = pages.map { |idx|
next_page = page + IMGS_PER_PAGE "/reader/#{title.id}/#{entry.id}/#{idx}"
next_url = next_page > entry.pages ? nil : }
"/reader/#{title.id}/#{entry.id}/#{next_page}" next_page = page + IMGS_PER_PAGE
exit_url = "/book/#{title.id}" next_url = next_entry_url = nil
next_entry = title.next_entry entry exit_url = "/book/#{title.id}"
next_entry_url = next_entry.nil? ? nil : \ next_entry = title.next_entry entry
"/reader/#{title.id}/#{next_entry.id}" unless next_page > entry.pages
next_url = "/reader/#{title.id}/#{entry.id}/#{next_page}"
end
unless next_entry.nil?
next_entry_url = "/reader/#{title.id}/#{next_entry.id}"
end
render "src/views/reader.ecr" render "src/views/reader.ecr"
rescue e rescue e
@context.error e @context.error e
env.response.status_code = 404 env.response.status_code = 404
end end
end end
end end
end end

View File

@ -1,6 +1,6 @@
require "../context" require "../context"
class Router class Router
def initialize(@context : Context) def initialize(@context : Context)
end end
end end

View File

@ -1,50 +1,47 @@
require "kemal" require "kemal"
require "./context" require "./context"
require "./auth_handler" require "./handlers/*"
require "./static_handler"
require "./log_handler"
require "./util" require "./util"
require "./routes/*" require "./routes/*"
class Server class Server
def initialize(@context : Context) def initialize(@context : Context)
error 403 do |env|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
layout "message"
end
error 404 do |env|
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message"
end
error 500 do |env|
message = "HTTP 500: Internal server error. Please try again later."
layout "message"
end
error 403 do |env| MainRouter.new(@context).setup
message = "HTTP 403: You are not authorized to visit " \ AdminRouter.new(@context).setup
"#{env.request.path}" ReaderRouter.new(@context).setup
layout "message" APIRouter.new(@context).setup
end
error 404 do |env|
message = "HTTP 404: Mango cannot find the page #{env.request.path}"
layout "message"
end
error 500 do |env|
message = "HTTP 500: Internal server error. Please try again later."
layout "message"
end
MainRouter.new(@context).setup Kemal.config.logging = false
AdminRouter.new(@context).setup add_handler LogHandler.new @context.logger
ReaderRouter.new(@context).setup add_handler AuthHandler.new @context.storage
APIRouter.new(@context).setup add_handler UploadHandler.new @context.config.upload_path
{% if flag?(:release) %}
# when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embedded static files."
serve_static false
add_handler StaticHandler.new
{% end %}
end
Kemal.config.logging = false def start
add_handler LogHandler.new @context.logger @context.debug "Starting Kemal server"
add_handler AuthHandler.new @context.storage {% if flag?(:release) %}
{% if flag?(:release) %} Kemal.config.env = "production"
# when building for relase, embed the static files in binary {% end %}
@context.debug "We are in release mode. Using embedded static files." Kemal.config.port = @context.config.port
serve_static false Kemal.run
add_handler StaticHandler.new end
{% end %}
end
def start
@context.debug "Starting Kemal server"
{% if flag?(:release) %}
Kemal.config.env = "production"
{% end %}
Kemal.config.port = @context.config.port
Kemal.run
end
end end

View File

@ -1,32 +0,0 @@
require "baked_file_system"
require "kemal"
require "./util"
class FS
extend BakedFileSystem
{% if flag?(:release) %}
{% if read_file? "#{__DIR__}/../dist/favicon.ico" %}
{% puts "baking ../dist" %}
bake_folder "../dist"
{% else %}
{% puts "baking ../public" %}
bake_folder "../public"
{% end %}
{% end %}
end
class StaticHandler < Kemal::Handler
@dirs = ["/css", "/js", "/img", "/favicon.ico"]
def call(env)
if request_path_startswith env, @dirs
file = FS.get? env.request.path
return call_next env if file.nil?
slice = Bytes.new file.size
file.read slice
return send_file env, slice, file.mime_type
end
call_next env
end
end

View File

@ -2,176 +2,171 @@ require "sqlite3"
require "crypto/bcrypt" require "crypto/bcrypt"
require "uuid" require "uuid"
require "base64" require "base64"
require "./util"
def hash_password(pw) def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s Crypto::Bcrypt::Password.create(pw).to_s
end end
def verify_password(hash, pw) def verify_password(hash, pw)
(Crypto::Bcrypt::Password.new hash).verify pw (Crypto::Bcrypt::Password.new hash).verify pw
end
def random_str
UUID.random.to_s.gsub "-", ""
end end
class Storage class Storage
def initialize(@path : String, @logger : MLogger) def initialize(@path : String, @logger : Logger)
dir = File.dirname path dir = File.dirname path
unless Dir.exists? dir unless Dir.exists? dir
@logger.info "The DB directory #{dir} does not exist. " \ @logger.info "The DB directory #{dir} does not exist. " \
"Attepmting to create it" "Attepmting to create it"
Dir.mkdir_p dir Dir.mkdir_p dir
end end
DB.open "sqlite3://#{path}" do |db| DB.open "sqlite3://#{path}" do |db|
begin begin
# We create the `ids` table first. even if the uses has an # We create the `ids` table first. even if the uses has an
# early version installed and has the `user` table only, # early version installed and has the `user` table only,
# we will still be able to create `ids` # we will still be able to create `ids`
db.exec "create table ids" \ db.exec "create table ids" \
"(path text, id text, is_title integer)" "(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)" db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)" db.exec "create unique index id_idx on ids (id)"
db.exec "create table users" \ db.exec "create table users" \
"(username text, password text, token text, admin integer)" "(username text, password text, token text, admin integer)"
rescue e rescue e
unless e.message.not_nil!.ends_with? "already exists" unless e.message.not_nil!.ends_with? "already exists"
@logger.fatal "Error when checking tables in DB: #{e}" @logger.fatal "Error when checking tables in DB: #{e}"
raise e raise e
end end
else else
@logger.debug "Creating DB file at #{@path}" @logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)" db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)" db.exec "create unique index token_idx on users (token)"
random_pw = random_str random_pw = random_str
hash = hash_password random_pw hash = hash_password random_pw
db.exec "insert into users values (?, ?, ?, ?)", db.exec "insert into users values (?, ?, ?, ?)",
"admin", hash, nil, 1 "admin", hash, nil, 1
puts "Initial user created. You can log in with " \ @logger.log "Initial user created. You can log in with " \
"#{{"username" => "admin", "password" => random_pw}}" "#{{"username" => "admin", "password" => random_pw}}"
end end
end end
end end
def verify_user(username, password) def verify_user(username, password)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
hash, token = db.query_one "select password, token from "\ hash, token = db.query_one "select password, token from " \
"users where username = (?)", \ "users where username = (?)",
username, as: {String, String?} username, as: {String, String?}
unless verify_password hash, password unless verify_password hash, password
@logger.debug "Password does not match the hash" @logger.debug "Password does not match the hash"
return nil return nil
end end
@logger.debug "User #{username} verified" @logger.debug "User #{username} verified"
return token if token return token if token
token = random_str token = random_str
@logger.debug "Updating token for #{username}" @logger.debug "Updating token for #{username}"
db.exec "update users set token = (?) where username = (?)", db.exec "update users set token = (?) where username = (?)",
token, username token, username
return token return token
rescue e rescue e
@logger.error "Error when verifying user #{username}: #{e}" @logger.error "Error when verifying user #{username}: #{e}"
return nil return nil
end end
end end
end end
def verify_token(token) def verify_token(token)
DB.open "sqlite3://#{@path}" do |db| username = nil
begin DB.open "sqlite3://#{@path}" do |db|
username = db.query_one "select username from users where " \ begin
"token = (?)", token, as: String username = db.query_one "select username from users where " \
return username "token = (?)", token, as: String
rescue e rescue e
@logger.debug "Unable to verify token" @logger.debug "Unable to verify token"
return nil end
end end
end username
end end
def verify_admin(token) def verify_admin(token)
DB.open "sqlite3://#{@path}" do |db| is_admin = false
begin DB.open "sqlite3://#{@path}" do |db|
return db.query_one "select admin from users where " \ begin
"token = (?)", token, as: Bool is_admin = db.query_one "select admin from users where " \
rescue e "token = (?)", token, as: Bool
@logger.debug "Unable to verify user as admin" rescue e
return false @logger.debug "Unable to verify user as admin"
end end
end end
end is_admin
end
def list_users def list_users
results = Array(Tuple(String, Bool)).new results = Array(Tuple(String, Bool)).new
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.query "select username, admin from users" do |rs| db.query "select username, admin from users" do |rs|
rs.each do rs.each do
results << {rs.read(String), rs.read(Bool)} results << {rs.read(String), rs.read(Bool)}
end end
end end
end end
results results
end end
def new_user(username, password, admin) def new_user(username, password, admin)
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
hash = hash_password password hash = hash_password password
db.exec "insert into users values (?, ?, ?, ?)", db.exec "insert into users values (?, ?, ?, ?)",
username, hash, nil, admin username, hash, nil, admin
end end
end end
def update_user(original_username, username, password, admin) def update_user(original_username, username, password, admin)
admin = (admin ? 1 : 0) admin = (admin ? 1 : 0)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
if password.size == 0 if password.size == 0
db.exec "update users set username = (?), admin = (?) "\ db.exec "update users set username = (?), admin = (?) " \
"where username = (?)",\ "where username = (?)",
username, admin, original_username username, admin, original_username
else else
hash = hash_password password hash = hash_password password
db.exec "update users set username = (?), admin = (?),"\ db.exec "update users set username = (?), admin = (?)," \
"password = (?) where username = (?)",\ "password = (?) where username = (?)",
username, admin, hash, original_username username, admin, hash, original_username
end end
end end
end end
def delete_user(username) def delete_user(username)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
db.exec "delete from users where username = (?)", username db.exec "delete from users where username = (?)", username
end end
end end
def logout(token) def logout(token)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
begin begin
db.exec "update users set token = (?) where token = (?)", \ db.exec "update users set token = (?) where token = (?)", nil, token
nil, token rescue
rescue end
end end
end end
end
def get_id(path, is_title) def get_id(path, is_title)
DB.open "sqlite3://#{@path}" do |db| id = random_str
begin DB.open "sqlite3://#{@path}" do |db|
id = db.query_one "select id from ids where path = (?)", begin
path, as: {String} id = db.query_one "select id from ids where path = (?)", path,
return id as: {String}
rescue rescue
id = random_str db.exec "insert into ids values (?, ?, ?)", path, id, is_title ? 1 : 0
db.exec "insert into ids values (?, ?, ?)", path, id, end
is_title ? 1 : 0 end
return id id
end end
end
end
def to_json(json : JSON::Builder) def to_json(json : JSON::Builder)
json.string self json.string self
end end
end end

60
src/upload.cr Normal file
View File

@ -0,0 +1,60 @@
require "./util"
class Upload
def initialize(@dir : String, @logger : Logger)
unless Dir.exists? @dir
@logger.info "The uploads directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
end
# Writes IO to a file with random filename in the uploads directory and
# returns the full path of created file
# e.g., save("image", ".png", <io>)
# ==> "~/mango/uploads/image/<random string>.png"
def save(sub_dir : String, ext : String, io : IO)
full_dir = File.join @dir, sub_dir
filename = random_str + ext
file_path = File.join full_dir, filename
unless Dir.exists? full_dir
@logger.debug "creating directory #{full_dir}"
Dir.mkdir_p full_dir
end
File.open file_path, "w" do |f|
IO.copy io, f
end
file_path
end
# Converts path to a file in the uploads directory to the URL path for
# accessing the file.
def path_to_url(path : String)
dir_mathed = false
ary = [] of String
# We fill it with parts until it equals to @upload_dir
dir_ary = [] of String
Path.new(path).each_part do |part|
if dir_mathed
ary << part
else
dir_ary << part
if File.same? @dir, File.join dir_ary
dir_mathed = true
end
end
end
if ary.empty?
@logger.warn "File #{path} is not in the upload directory #{@dir}"
return
end
ary.unshift UPLOAD_URL_PREFIX
File.join(ary).to_s
end
end

View File

@ -1,73 +1,101 @@
require "big" require "big"
IMGS_PER_PAGE = 5 IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
macro layout(name) macro layout(name)
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr" begin
cookie = env.request.cookies.find { |c| c.name == "token" }
is_admin = false
unless cookie.nil?
is_admin = @context.storage.verify_admin cookie.value
end
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
rescue e
message = e.to_s
@context.error message
render "src/views/message.ecr", "src/views/layout.ecr"
end
end end
macro send_img(env, img) macro send_img(env, img)
send_file {{env}}, {{img}}.data, {{img}}.mime send_file {{env}}, {{img}}.data, {{img}}.mime
end end
macro get_username(env) macro get_username(env)
# if the request gets here, it has gone through the auth handler, and # if the request gets here, it has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here # we can be sure that a valid token exists, so we can use not_nil! here
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil! cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
(@context.storage.verify_token cookie.value).not_nil! (@context.storage.verify_token cookie.value).not_nil!
end end
macro send_json(env, json) def send_json(env, json)
{{env}}.response.content_type = "application/json" env.response.content_type = "application/json"
{{json}} env.response.print json
end end
def hash_to_query(hash) def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&") hash.map { |k, v| "#{k}=#{v}" }.join("&")
end end
def request_path_startswith(env, ary) def request_path_startswith(env, ary)
ary.each do |prefix| ary.each do |prefix|
if env.request.path.starts_with? prefix if env.request.path.starts_with? prefix
return true return true
end end
end end
return false false
end end
def is_numeric(str) def is_numeric(str)
/^\d+/.match(str) != nil /^\d+/.match(str) != nil
end end
def split_by_alphanumeric(str) def split_by_alphanumeric(str)
arr = [] of String arr = [] of String
str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match| str.scan(/([^\d\n\r]*)(\d*)([^\d\n\r]*)/) do |match|
arr += match.captures.select{|s| s != ""} arr += match.captures.select { |s| s != "" }
end end
arr arr
end end
def compare_alphanumerically(c, d) def compare_alphanumerically(c, d)
is_c_bigger = c.size <=> d.size is_c_bigger = c.size <=> d.size
if c.size > d.size if c.size > d.size
d += [nil] * (c.size - d.size) d += [nil] * (c.size - d.size)
elsif c.size < d.size elsif c.size < d.size
c += [nil] * (d.size - c.size) c += [nil] * (d.size - c.size)
end end
c.zip(d) do |a, b| c.zip(d) do |a, b|
return -1 if a.nil? return -1 if a.nil?
return 1 if b.nil? return 1 if b.nil?
if is_numeric(a) && is_numeric(b) if is_numeric(a) && is_numeric(b)
compare = a.to_big_i <=> b.to_big_i compare = a.to_big_i <=> b.to_big_i
return compare if compare != 0 return compare if compare != 0
else else
compare = a <=> b compare = a <=> b
return compare if compare != 0 return compare if compare != 0
end end
end end
is_c_bigger is_c_bigger
end end
def compare_alphanumerically(a : String, b : String) def compare_alphanumerically(a : String, b : String)
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b) compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end
# When downloading from MangaDex, the zip/cbz file would not be valid
# before the download is completed. If we scan the zip file,
# Entry.new would throw, so we use this method to check before
# constructing Entry
def validate_zip(path : String) : Exception?
file = Zip::File.new path
file.close
return
rescue e
e
end
def random_str
UUID.random.to_s.gsub "-", ""
end end

View File

@ -26,17 +26,13 @@
<a class="acard" href="/book/<%= t.id %>"> <a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default"> <div class="uk-card uk-card-default">
<div class="uk-card-media-top"> <div class="uk-card-media-top">
<%- if t.entries.size > 0 -%> <img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
<img data-src="<%= t.entries[0].cover_url %>" data-width data-height alt="" uk-img>
<%- else -%>
<img data-src="/img/icon.png" data-width data-height alt="" uk-img>
<%- end -%>
</div> </div>
<div class="uk-card-body"> <div class="uk-card-body">
<%- if t.entries.size > 0 -%> <%- if t.entries.size > 0 -%>
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div> <div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<%- end -%> <%- end -%>
<h3 class="uk-card-title break-word" data-title="<%= t.title.gsub("\"", "&quot;") %>"><%= t.title %></h3> <h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p> <p><%= t.size %> entries</p>
</div> </div>
</div> </div>

View File

@ -21,8 +21,10 @@
<div class="uk-offcanvas-bar uk-flex uk-flex-column"> <div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical"> <ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
<li><a href="/">Home</a></li> <li><a href="/">Home</a></li>
<li><a href="/admin">Admin</a></li> <% if is_admin %>
<li><a href="/download">Download</a></li> <li><a href="/admin">Admin</a></li>
<li><a href="/download">Download</a></li>
<% end %>
<hr uk-divider> <hr uk-divider>
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li> <li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
<li><a href="/logout">Logout</a></li> <li><a href="/logout">Logout</a></li>
@ -40,8 +42,10 @@
<a class="uk-navbar-item uk-logo" href="/"><img src="/img/icon.png"></a> <a class="uk-navbar-item uk-logo" href="/"><img src="/img/icon.png"></a>
<ul class="uk-navbar-nav"> <ul class="uk-navbar-nav">
<li><a href="/">Home</a></li> <li><a href="/">Home</a></li>
<li><a href="/admin">Admin</a></li> <% if is_admin %>
<li><a href="/download">Download</a></li> <li><a href="/admin">Admin</a></li>
<li><a href="/download">Download</a></li>
<% end %>
</ul> </ul>
</div> </div>
<div class="uk-navbar-right uk-visible@s"> <div class="uk-navbar-right uk-visible@s">

View File

@ -8,9 +8,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon"> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="/js/theme.js"></script>
</head> </head>
<body> <body>
<script src="/js/theme.js"></script>
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport=""> <div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
<div class="uk-width-1-1"> <div class="uk-width-1-1">
<div class="uk-container"> <div class="uk-container">
@ -33,6 +34,9 @@
</div> </div>
</div> </div>
</div> </div>
<script>
setTheme(getTheme());
</script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
</body> </body>

View File

@ -1,10 +1,17 @@
<h2 class=uk-title><%= title.title %></h2> <div>
<h2 class=uk-title><span><%= title.display_name %></span>
&nbsp;
<% if is_admin %>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h2>
</div>
<ul class="uk-breadcrumb"> <ul class="uk-breadcrumb">
<li><a href="/">Library</a></li> <li><a href="/">Library</a></li>
<%- title.parents.each do |t| -%> <%- title.parents.each do |t| -%>
<li><a href="/book/<%= t.id %>"><%= t.title %></a></li> <li><a href="/book/<%= t.id %>"><%= t.display_name %></a></li>
<%- end -%> <%- end -%>
<li class="uk-disabled"><a><%= title.title %></a></li> <li class="uk-disabled"><a><%= title.display_name %></a></li>
</ul> </ul>
<p class="uk-text-meta"><%= title.size %> entries found</p> <p class="uk-text-meta"><%= title.size %> entries found</p>
<div class="uk-grid-small" uk-grid> <div class="uk-grid-small" uk-grid>
@ -35,14 +42,10 @@
<a class="acard" href="/book/<%= t.id %>"> <a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default"> <div class="uk-card uk-card-default">
<div class="uk-card-media-top"> <div class="uk-card-media-top">
<%- if t.entries.size > 0 -%> <img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
<img data-src="<%= t.entries[0].cover_url %>" data-width data-height alt="" uk-img>
<%- else -%>
<img data-src="/img/icon.png" data-width data-height alt="" uk-img>
<%- end -%>
</div> </div>
<div class="uk-card-body"> <div class="uk-card-body">
<h3 class="uk-card-title break-word" data-title="<%= t.title.gsub("\"", "&quot;") %>"><%= t.title %></h3> <h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p> <p><%= t.size %> entries</p>
</div> </div>
</div> </div>
@ -50,15 +53,15 @@
</div> </div>
<%- end -%> <%- end -%>
<%- title.entries.each_with_index do |e, i| -%> <%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>"> <div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>" id="<%= e.id %>">
<a class="acard"> <a class="acard">
<div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_title %>&quot;, &quot;<%= e.encoded_title %>&quot;, '<%= e.title_id %>', '<%= e.id %>')"> <div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_display_name %>&quot;, &quot;<%= e.encoded_display_name %>&quot;, '<%= e.title_id %>', '<%= e.id %>')">
<div class="uk-card-media-top"> <div class="uk-card-media-top">
<img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img> <img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img>
</div> </div>
<div class="uk-card-body"> <div class="uk-card-body">
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div> <div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<h3 class="uk-card-title break-word" data-title="<%= e.title.gsub("\"", "&quot;") %>"><%= e.title %></h3> <h3 class="uk-card-title break-word" data-title="<%= e.display_name.gsub("\"", "&quot;") %>"><%= e.display_name %></h3>
<p><%= e.pages %> pages</p> <p><%= e.pages %> pages</p>
</div> </div>
</div> </div>
@ -71,7 +74,14 @@
<div class="uk-modal-dialog uk-margin-auto-vertical"> <div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button> <button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header"> <div class="uk-modal-header">
<h3 class="uk-modal-title break-word" id="modal-title"></h3> <div>
<h3 class="uk-modal-title break-word" id="modal-title"><span></span>
&nbsp;
<% if is_admin %>
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h3>
</div>
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p> <p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p> <p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
</div> </div>
@ -90,6 +100,55 @@
</div> </div>
</div> </div>
<div id="edit-modal" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical">
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<div>
<h3 class="uk-modal-title break-word" id="modal-title">Edit</h3>
</div>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<label class="uk-form-label" for="display-name">Display Name</label>
<div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
<input class="uk-input" type="text" name="display-name" id="display-name-field">
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">Cover Image</label>
<div class="uk-grid">
<div class="uk-width-1-2@s">
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
</div>
<div class="uk-width-1-2@s">
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
<div>
<span uk-icon="icon: cloud-upload"></span>
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
<div uk-form-custom>
<input type="file" accept="image/jpeg, image/png">
<span class="uk-link">selecting one</span>
</div>
</div>
</div>
</div>
</div>
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
</div>
<div id="title-progress-control" hidden>
<label class="uk-form-label">Progress</label>
<p class="uk-margin-remove-vertical">
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
</p>
</div>
</div>
</div>
</div>
<% content_for "script" do %> <% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="/js/dots.js"></script> <script src="/js/dots.js"></script>