mirror of
https://github.com/hkalexling/Mango.git
synced 2025-08-02 19:05:32 -04:00
Merge branch 'dev'
This commit is contained in:
commit
a354d811d9
9
.ameba.yml
Normal file
9
.ameba.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
Lint/UselessAssign:
|
||||||
|
Excluded:
|
||||||
|
- src/routes/*
|
||||||
|
- src/server.cr
|
||||||
|
Lint/UnusedArgument:
|
||||||
|
Excluded:
|
||||||
|
- src/routes/*
|
||||||
|
Metrics/CyclomaticComplexity:
|
||||||
|
Enabled: false
|
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
6
Makefile
6
Makefile
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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%;
|
||||||
|
}
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -2,7 +2,7 @@ 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
|
||||||
|
@ -4,7 +4,7 @@ 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
|
||||||
@ -102,4 +102,3 @@ describe Queue do
|
|||||||
State.reset
|
State.reset
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -28,22 +28,22 @@ def get_tempfile(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
|
||||||
@ -54,7 +54,7 @@ def with_storage
|
|||||||
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
|
||||||
|
@ -2,7 +2,7 @@ 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
|
||||||
|
@ -3,7 +3,7 @@ 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"]
|
||||||
@ -11,7 +11,7 @@ describe "compare_alphanumerically" do
|
|||||||
|
|
||||||
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"]
|
||||||
@ -21,7 +21,7 @@ describe "compare_alphanumerically" do
|
|||||||
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
|
||||||
@ -29,7 +29,7 @@ describe "compare_alphanumerically" do
|
|||||||
# 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
|
||||||
|
@ -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
|
|
@ -4,14 +4,15 @@ 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 mangadex = Hash(String, String|Int32).new
|
property upload_path : String = File.expand_path "~/mango/uploads",
|
||||||
|
home: true
|
||||||
|
property mangadex = Hash(String, String | Int32).new
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@mangadex_defaults = {
|
@mangadex_defaults = {
|
||||||
@ -19,8 +20,8 @@ class Config
|
|||||||
"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?)
|
||||||
|
@ -7,13 +7,13 @@ 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
|
||||||
|
25
src/handlers/auth_handler.cr
Normal file
25
src/handlers/auth_handler.cr
Normal 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
|
26
src/handlers/log_handler.cr
Normal file
26
src/handlers/log_handler.cr
Normal 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
|
32
src/handlers/static_handler.cr
Normal file
32
src/handlers/static_handler.cr
Normal 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
|
24
src/handlers/upload_handler.cr
Normal file
24
src/handlers/upload_handler.cr
Normal 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
|
258
src/library.cr
258
src/library.cr
@ -15,42 +15,58 @@ struct Image
|
|||||||
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
|
||||||
.size
|
|
||||||
file.close
|
file.close
|
||||||
@id = storage.get_id @zip_path, false
|
@id = storage.get_id @zip_path, false
|
||||||
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
|
|
||||||
@mtime = File.info(@zip_path).modification_time
|
@mtime = File.info(@zip_path).modification_time
|
||||||
end
|
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 "pages" {json.number @pages}
|
json.field "display_name", @book.display_name @title
|
||||||
json.field "mtime" {json.number @mtime.to_unix}
|
json.field "cover_url", cover_url
|
||||||
|
json.field "pages" { json.number @pages }
|
||||||
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
@book.display_name @title
|
||||||
|
end
|
||||||
|
|
||||||
|
def encoded_display_name
|
||||||
|
URI.encode display_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def cover_url
|
||||||
|
url = "/api/page/#{@title_id}/#{@id}/1"
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
info_url = info.entry_cover_url[@title]?
|
||||||
|
unless info_url.nil? || info_url.empty?
|
||||||
|
url = info_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
url
|
||||||
|
end
|
||||||
|
|
||||||
def read_page(page_num)
|
def read_page(page_num)
|
||||||
Zip::File.open @zip_path do |file|
|
Zip::File.open @zip_path do |file|
|
||||||
page = file.entries
|
page = file.entries
|
||||||
@ -68,7 +84,7 @@ class Entry
|
|||||||
unless bytes_read
|
unless bytes_read
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
return Image.new slice, MIME.from_filename(page.filename),\
|
return Image.new slice, MIME.from_filename(page.filename),
|
||||||
page.filename, bytes_read
|
page.filename, bytes_read
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -80,14 +96,14 @@ class Title
|
|||||||
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? "."
|
||||||
@ -100,12 +116,23 @@ class Title
|
|||||||
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?
|
||||||
|
@logger.warn "File #{path} is corrupted or is not a valid zip " \
|
||||||
|
"archive. Ignoring it."
|
||||||
|
@logger.debug "Zip error: #{zip_exception}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
entry = Entry.new path, self, @id, storage
|
||||||
@entries << entry if entry.pages > 0
|
@entries << entry if entry.pages > 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
mtimes = [@mtime]
|
||||||
|
mtimes += @title_ids.map { |e| @library.title_hash[e].mtime }
|
||||||
|
mtimes += @entries.map { |e| e.mtime }
|
||||||
|
@mtime = mtimes.max
|
||||||
|
|
||||||
@title_ids.sort! do |a, b|
|
@title_ids.sort! do |a, b|
|
||||||
compare_alphanumerically @library.title_hash[a].title,
|
compare_alphanumerically @library.title_hash[a].title,
|
||||||
@library.title_hash[b].title
|
@library.title_hash[b].title
|
||||||
@ -113,11 +140,6 @@ class Title
|
|||||||
@entries.sort! do |a, b|
|
@entries.sort! do |a, b|
|
||||||
compare_alphanumerically a.title, b.title
|
compare_alphanumerically a.title, b.title
|
||||||
end
|
end
|
||||||
|
|
||||||
mtimes = [File.info(dir).modification_time]
|
|
||||||
mtimes += @title_ids.map{|e| @library.title_hash[e].mtime}
|
|
||||||
mtimes += @entries.map{|e| e.mtime}
|
|
||||||
@mtime = mtimes.max
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
@ -125,7 +147,9 @@ class Title
|
|||||||
{% 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 "cover_url", cover_url
|
||||||
|
json.field "mtime" { json.number @mtime.to_unix }
|
||||||
json.field "titles" do
|
json.field "titles" do
|
||||||
json.raw self.titles.to_json
|
json.raw self.titles.to_json
|
||||||
end
|
end
|
||||||
@ -146,7 +170,7 @@ class Title
|
|||||||
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
|
||||||
@ -164,53 +188,127 @@ class Title
|
|||||||
@entries.size + @title_ids.size
|
@entries.size + @title_ids.size
|
||||||
end
|
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
|
|
||||||
private def valid_zip(path : String)
|
|
||||||
begin
|
|
||||||
file = Zip::File.new path
|
|
||||||
file.close
|
|
||||||
return true
|
|
||||||
rescue
|
|
||||||
@logger.warn "File #{path} is corrupted or is not a valid zip "\
|
|
||||||
"archive. Ignoring it."
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
def get_entry(eid)
|
def get_entry(eid)
|
||||||
@entries.find { |e| e.id == eid }
|
@entries.find { |e| e.id == eid }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def display_name
|
||||||
|
dn = @title
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_dn = info.display_name
|
||||||
|
dn = info_dn unless info_dn.empty?
|
||||||
|
end
|
||||||
|
dn
|
||||||
|
end
|
||||||
|
|
||||||
|
def encoded_display_name
|
||||||
|
URI.encode display_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_name(entry_name)
|
||||||
|
dn = entry_name
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_dn = info.entry_display_name[entry_name]?
|
||||||
|
unless info_dn.nil? || info_dn.empty?
|
||||||
|
dn = info_dn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
dn
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_display_name(dn)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.display_name = dn
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_display_name(entry_name : String, dn)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.entry_display_name[entry_name] = dn
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def cover_url
|
||||||
|
url = "img/icon.png"
|
||||||
|
if @entries.size > 0
|
||||||
|
url = @entries[0].cover_url
|
||||||
|
end
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info_url = info.cover_url
|
||||||
|
unless info_url.nil? || info_url.empty?
|
||||||
|
url = info_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
url
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_cover_url(url : String)
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
info.cover_url = url
|
||||||
|
info.save
|
||||||
|
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
|
# For backward backward compatibility with v0.1.0, we save entry titles
|
||||||
# instead of IDs in info.json
|
# instead of IDs in info.json
|
||||||
def save_progress(username, entry, page)
|
def save_progress(username, entry, page)
|
||||||
info = TitleInfo.new @dir
|
TitleInfo.new @dir do |info|
|
||||||
if info.progress[username]?.nil?
|
if info.progress[username]?.nil?
|
||||||
info.progress[username] = {entry => page}
|
info.progress[username] = {entry => page}
|
||||||
info.save @dir
|
else
|
||||||
return
|
|
||||||
end
|
|
||||||
info.progress[username][entry] = page
|
info.progress[username][entry] = page
|
||||||
info.save @dir
|
|
||||||
end
|
end
|
||||||
|
info.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def load_progress(username, entry)
|
def load_progress(username, entry)
|
||||||
info = TitleInfo.new @dir
|
progress = 0
|
||||||
if info.progress[username]?.nil?
|
TitleInfo.new @dir do |info|
|
||||||
return 0
|
unless info.progress[username]?.nil? ||
|
||||||
|
info.progress[username][entry]?.nil?
|
||||||
|
progress = info.progress[username][entry]
|
||||||
end
|
end
|
||||||
if info.progress[username][entry]?.nil?
|
|
||||||
return 0
|
|
||||||
end
|
end
|
||||||
info.progress[username][entry]
|
progress
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_percetage(username, entry)
|
def load_percetage(username, entry)
|
||||||
info = TitleInfo.new @dir
|
|
||||||
page = load_progress username, entry
|
page = load_progress username, entry
|
||||||
entry_obj = @entries.find{|e| e.title == entry}
|
entry_obj = @entries.find { |e| e.title == entry }
|
||||||
return 0.0 if entry_obj.nil?
|
return 0.0 if entry_obj.nil?
|
||||||
page / entry_obj.pages
|
page / entry_obj.pages
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_percetage(username)
|
def load_percetage(username)
|
||||||
return 0.0 if @entries.empty?
|
return 0.0 if @entries.empty?
|
||||||
read_pages = total_pages = 0
|
read_pages = total_pages = 0
|
||||||
@ -220,6 +318,7 @@ class Title
|
|||||||
end
|
end
|
||||||
read_pages / total_pages
|
read_pages / total_pages
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_entry(current_entry_obj)
|
def next_entry(current_entry_obj)
|
||||||
idx = @entries.index current_entry_obj
|
idx = @entries.index current_entry_obj
|
||||||
return nil if idx.nil? || idx == @entries.size - 1
|
return nil if idx.nil? || idx == @entries.size - 1
|
||||||
@ -228,33 +327,47 @@ class Title
|
|||||||
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
|
def self.new(dir, &)
|
||||||
|
if @@mutex_hash[dir]?
|
||||||
|
mutex = @@mutex_hash[dir]
|
||||||
else
|
else
|
||||||
info = TitleInfo.from_json "{\"progress\": {}}"
|
mutex = Mutex.new
|
||||||
|
@@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
|
end
|
||||||
|
|
||||||
@progress = info.progress.clone
|
def save
|
||||||
end
|
json_path = File.join @dir, "info.json"
|
||||||
def save(title_dir)
|
|
||||||
json_path = File.join title_dir, "info.json"
|
|
||||||
File.write json_path, self.to_pretty_json
|
File.write json_path, self.to_pretty_json
|
||||||
end
|
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
|
||||||
@ -273,9 +386,11 @@ class Library
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def titles
|
def titles
|
||||||
@title_ids.map {|tid| self.get_title!(tid) }
|
@title_ids.map { |tid| self.get_title!(tid) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
json.object do
|
json.object do
|
||||||
json.field "dir", @dir
|
json.field "dir", @dir
|
||||||
@ -284,12 +399,15 @@ class Library
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_title(tid)
|
def get_title(tid)
|
||||||
@title_hash[tid]?
|
@title_hash[tid]?
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_title!(tid)
|
def get_title!(tid)
|
||||||
@title_hash[tid]
|
@title_hash[tid]
|
||||||
end
|
end
|
||||||
|
|
||||||
def scan
|
def scan
|
||||||
unless Dir.exists? @dir
|
unless Dir.exists? @dir
|
||||||
@logger.info "The library directory #{@dir} does not exist. " \
|
@logger.info "The library directory #{@dir} does not exist. " \
|
||||||
|
@ -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
|
|
@ -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
|
|
||||||
log_level = config.log_level
|
|
||||||
if log_level == "off"
|
|
||||||
@log_off = true
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
|
def initialize(level : String)
|
||||||
{% begin %}
|
{% begin %}
|
||||||
case log_level
|
case level.downcase
|
||||||
{% for lvl in LEVELS %}
|
when "off"
|
||||||
when {{lvl}}
|
@@severity = :none
|
||||||
@logger.level = Logger::{{lvl.upcase.id}}
|
|
||||||
{% end %}
|
|
||||||
else
|
|
||||||
raise "Unknown log level #{log_level}"
|
|
||||||
end
|
|
||||||
{% end %}
|
|
||||||
|
|
||||||
@logger.formatter = Logger::Formatter.new do \
|
|
||||||
|severity, datetime, progname, message, io|
|
|
||||||
|
|
||||||
color = :default
|
|
||||||
{% begin %}
|
|
||||||
case severity.to_s().downcase
|
|
||||||
{% for lvl, i in LEVELS %}
|
{% for lvl, i in LEVELS %}
|
||||||
when {{lvl}}
|
when {{lvl}}
|
||||||
color = COLORS[{{i}}]
|
@@severity = Log::Severity.new SEVERITY_IDS[{{i}}]
|
||||||
{% end %}
|
{% end %}
|
||||||
|
else
|
||||||
|
raise "Unknown log level #{level}"
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
io << "[#{severity}]".ljust(8).colorize(color)
|
@log = Log.for("")
|
||||||
io << datetime.to_s("%Y/%m/%d %H:%M:%S") << " | "
|
|
||||||
io << message
|
@backend = Log::IOBackend.new
|
||||||
|
@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
|
||||||
|
{% end %}
|
||||||
|
|
||||||
|
io << "[#{entry.severity.label}]".ljust(10).colorize(color)
|
||||||
|
io << entry.timestamp.to_s("%Y/%m/%d %H:%M:%S") << " | "
|
||||||
|
io << entry.message
|
||||||
|
end
|
||||||
|
|
||||||
|
Log.builder.bind "*", @@severity, @backend
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ignores @@severity and always log msg
|
||||||
|
def log(msg)
|
||||||
|
@backend.write Log::Entry.new "", Log::Severity::None, msg, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
{% for lvl in LEVELS %}
|
{% for lvl in LEVELS %}
|
||||||
def {{lvl.id}}(msg)
|
def {{lvl.id}}(msg)
|
||||||
return if @log_off
|
@log.{{lvl.id}} { msg }
|
||||||
@logger.{{lvl.id}} msg
|
|
||||||
end
|
end
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
|
||||||
json.string self
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
@ -2,13 +2,13 @@ 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 %}
|
||||||
@ -25,8 +25,8 @@ module MangaDex
|
|||||||
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
|
||||||
|
|
||||||
@ -52,7 +52,6 @@ module MangaDex
|
|||||||
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]?
|
||||||
@ -76,10 +75,9 @@ module MangaDex
|
|||||||
raise "failed to parse json: #{e}"
|
raise "failed to parse json: #{e}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
class Manga
|
class Manga
|
||||||
string_properties ["cover_url", "description", "title", "author",
|
string_properties ["cover_url", "description", "title", "author", "artist"]
|
||||||
"artist"]
|
|
||||||
property chapters = [] of Chapter
|
property chapters = [] of Chapter
|
||||||
property id : String
|
property id : String
|
||||||
|
|
||||||
@ -90,8 +88,8 @@ module MangaDex
|
|||||||
def to_info_json(with_chapters = true)
|
def to_info_json(with_chapters = true)
|
||||||
JSON.build do |json|
|
JSON.build do |json|
|
||||||
json.object do
|
json.object do
|
||||||
{% for name in ["id", "title", "description",
|
{% for name in ["id", "title", "description", "author", "artist",
|
||||||
"author", "artist", "cover_url"] %}
|
"cover_url"] %}
|
||||||
json.field {{name}}, @{{name.id}}
|
json.field {{name}}, @{{name.id}}
|
||||||
{% end %}
|
{% end %}
|
||||||
if with_chapters
|
if with_chapters
|
||||||
@ -108,14 +106,13 @@ module MangaDex
|
|||||||
end
|
end
|
||||||
|
|
||||||
def parse_json(obj)
|
def parse_json(obj)
|
||||||
begin
|
parse_strings_from_json ["cover_url", "description", "title", "author",
|
||||||
parse_strings_from_json ["cover_url", "description", "title",
|
"artist"]
|
||||||
"author", "artist"]
|
|
||||||
rescue e
|
rescue e
|
||||||
raise "failed to parse json: #{e}"
|
raise "failed to parse json: #{e}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
class API
|
class API
|
||||||
def initialize(@base_url = "https://mangadex.org/api/")
|
def initialize(@base_url = "https://mangadex.org/api/")
|
||||||
@lang = {} of String => String
|
@lang = {} of String => String
|
||||||
@ -125,11 +122,11 @@ module MangaDex
|
|||||||
end
|
end
|
||||||
|
|
||||||
def get(url)
|
def get(url)
|
||||||
headers = HTTP::Headers {
|
headers = HTTP::Headers{
|
||||||
"User-agent" => "Mangadex.cr"
|
"User-agent" => "Mangadex.cr",
|
||||||
}
|
}
|
||||||
res = HTTP::Client.get url, headers
|
res = HTTP::Client.get url, headers
|
||||||
raise "Failed to get #{url}. [#{res.status_code}] "\
|
raise "Failed to get #{url}. [#{res.status_code}] " \
|
||||||
"#{res.status_message}" if !res.success?
|
"#{res.status_message}" if !res.success?
|
||||||
JSON.parse res.body
|
JSON.parse res.body
|
||||||
end
|
end
|
||||||
@ -137,8 +134,7 @@ module MangaDex
|
|||||||
def get_manga(id)
|
def get_manga(id)
|
||||||
obj = self.get File.join @base_url, "manga/#{id}"
|
obj = self.get File.join @base_url, "manga/#{id}"
|
||||||
if obj["status"]? != "OK"
|
if obj["status"]? != "OK"
|
||||||
raise "Expecting `OK` in the `status` field. " \
|
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||||
"Got `#{obj["status"]?}`"
|
|
||||||
end
|
end
|
||||||
begin
|
begin
|
||||||
manga = Manga.new id, obj["manga"]
|
manga = Manga.new id, obj["manga"]
|
||||||
@ -146,7 +142,7 @@ module MangaDex
|
|||||||
chapter = Chapter.new k, v, manga, @lang
|
chapter = Chapter.new k, v, manga, @lang
|
||||||
manga.chapters << chapter
|
manga.chapters << chapter
|
||||||
end
|
end
|
||||||
return manga
|
manga
|
||||||
rescue
|
rescue
|
||||||
raise "Failed to parse JSON"
|
raise "Failed to parse JSON"
|
||||||
end
|
end
|
||||||
@ -160,8 +156,7 @@ module MangaDex
|
|||||||
"external chapters."
|
"external chapters."
|
||||||
end
|
end
|
||||||
if obj["status"]? != "OK"
|
if obj["status"]? != "OK"
|
||||||
raise "Expecting `OK` in the `status` field. " \
|
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||||
"Got `#{obj["status"]?}`"
|
|
||||||
end
|
end
|
||||||
begin
|
begin
|
||||||
server = obj["server"].as_s
|
server = obj["server"].as_s
|
||||||
@ -169,7 +164,7 @@ module MangaDex
|
|||||||
chapter.pages = obj["page_array"].as_a.map do |fn|
|
chapter.pages = obj["page_array"].as_a.map do |fn|
|
||||||
{
|
{
|
||||||
fn.as_s,
|
fn.as_s,
|
||||||
"#{server}#{hash}/#{fn.as_s}"
|
"#{server}#{hash}/#{fn.as_s}",
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
@ -185,8 +180,7 @@ module MangaDex
|
|||||||
"external chapters."
|
"external chapters."
|
||||||
end
|
end
|
||||||
if obj["status"]? != "OK"
|
if obj["status"]? != "OK"
|
||||||
raise "Expecting `OK` in the `status` field. " \
|
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
||||||
"Got `#{obj["status"]?}`"
|
|
||||||
end
|
end
|
||||||
manga_id = ""
|
manga_id = ""
|
||||||
begin
|
begin
|
||||||
@ -195,9 +189,9 @@ module MangaDex
|
|||||||
raise "Failed to parse JSON"
|
raise "Failed to parse JSON"
|
||||||
end
|
end
|
||||||
manga = self.get_manga manga_id
|
manga = self.get_manga manga_id
|
||||||
chapter = manga.chapters.find {|c| c.id == id}.not_nil!
|
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
|
||||||
self.get_chapter chapter
|
self.get_chapter chapter
|
||||||
return chapter
|
chapter
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -8,6 +8,7 @@ module MangaDex
|
|||||||
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)
|
def initialize(@url, @filename, @writer, @tries_remaning)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -51,7 +52,7 @@ module MangaDex
|
|||||||
def self.from_query_result(res : DB::ResultSet)
|
def self.from_query_result(res : DB::ResultSet)
|
||||||
job = Job.allocate
|
job = Job.allocate
|
||||||
job.parse_query_result res
|
job.parse_query_result res
|
||||||
return job
|
job
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(@id, @manga_id, @title, @manga_title, @status, @time)
|
def initialize(@id, @manga_id, @title, @manga_title, @status, @time)
|
||||||
@ -79,7 +80,7 @@ module MangaDex
|
|||||||
class Queue
|
class Queue
|
||||||
property downloader : Downloader?
|
property downloader : Downloader?
|
||||||
|
|
||||||
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 queue DB directory #{dir} does not exist. " \
|
@logger.info "The queue DB directory #{dir} does not exist. " \
|
||||||
@ -112,14 +113,14 @@ module MangaDex
|
|||||||
job = nil
|
job = nil
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
begin
|
begin
|
||||||
db.query_one "select * from queue where status = 0 "\
|
db.query_one "select * from queue where status = 0 " \
|
||||||
"or status = 1 order by time limit 1" do |res|
|
"or status = 1 order by time limit 1" do |res|
|
||||||
job = Job.from_query_result res
|
job = Job.from_query_result res
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return job
|
job
|
||||||
end
|
end
|
||||||
|
|
||||||
# Push an array of jobs into the queue, and return the number of jobs
|
# Push an array of jobs into the queue, and return the number of jobs
|
||||||
@ -128,7 +129,7 @@ module MangaDex
|
|||||||
start_count = self.count
|
start_count = self.count
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
jobs.each do |job|
|
jobs.each do |job|
|
||||||
db.exec "insert or ignore into queue values "\
|
db.exec "insert or ignore into queue values " \
|
||||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
job.id, job.manga_id, job.title, job.manga_title,
|
job.id, job.manga_id, job.title, job.manga_title,
|
||||||
job.status.to_i, job.status_message, job.pages,
|
job.status.to_i, job.status_message, job.pages,
|
||||||
@ -146,7 +147,7 @@ module MangaDex
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset (job : Job)
|
def reset(job : Job)
|
||||||
self.reset job.id
|
self.reset job.id
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -176,16 +177,20 @@ module MangaDex
|
|||||||
end
|
end
|
||||||
|
|
||||||
def count_status(status : JobStatus)
|
def count_status(status : JobStatus)
|
||||||
|
num = 0
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
return db.query_one "select count(*) from queue where "\
|
num = db.query_one "select count(*) from queue where " \
|
||||||
"status = (?)", status.to_i, as: Int32
|
"status = (?)", status.to_i, as: Int32
|
||||||
end
|
end
|
||||||
|
num
|
||||||
end
|
end
|
||||||
|
|
||||||
def count
|
def count
|
||||||
|
num = 0
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
return db.query_one "select count(*) from queue", as: Int32
|
num = db.query_one "select count(*) from queue", as: Int32
|
||||||
end
|
end
|
||||||
|
num
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_status(status : JobStatus, job : Job)
|
def set_status(status : JobStatus, job : Job)
|
||||||
@ -198,11 +203,11 @@ module MangaDex
|
|||||||
def get_all
|
def get_all
|
||||||
jobs = [] of Job
|
jobs = [] of Job
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
jobs = db.query_all "select * from queue order by time", do |rs|
|
jobs = db.query_all "select * from queue order by time" do |rs|
|
||||||
Job.from_query_result rs
|
Job.from_query_result rs
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return jobs
|
jobs
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_success(job : Job)
|
def add_success(job : Job)
|
||||||
@ -253,7 +258,7 @@ module MangaDex
|
|||||||
|
|
||||||
def initialize(@queue : Queue, @api : API, @library_path : String,
|
def initialize(@queue : Queue, @api : API, @library_path : String,
|
||||||
@wait_seconds : Int32, @retries : Int32,
|
@wait_seconds : Int32, @retries : Int32,
|
||||||
@logger : MLogger)
|
@logger : Logger)
|
||||||
@queue.downloader = self
|
@queue.downloader = self
|
||||||
|
|
||||||
spawn do
|
spawn do
|
||||||
@ -337,15 +342,21 @@ module MangaDex
|
|||||||
@logger.error msg
|
@logger.error msg
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
fail_count = page_jobs.select{|j| !j.success}.size
|
fail_count = page_jobs.count { |j| !j.success }
|
||||||
@logger.debug "Download completed. "\
|
@logger.debug "Download completed. " \
|
||||||
"#{fail_count}/#{page_jobs.size} failed"
|
"#{fail_count}/#{page_jobs.size} failed"
|
||||||
writer.close
|
writer.close
|
||||||
@logger.debug "cbz File created at #{zip_path}"
|
@logger.debug "cbz File created at #{zip_path}"
|
||||||
if fail_count == 0
|
|
||||||
@queue.set_status JobStatus::Completed, job
|
zip_exception = validate_zip zip_path
|
||||||
else
|
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
|
@queue.set_status JobStatus::MissingPages, job
|
||||||
|
else
|
||||||
|
@queue.set_status JobStatus::Completed, job
|
||||||
end
|
end
|
||||||
@downloading = false
|
@downloading = false
|
||||||
end
|
end
|
||||||
@ -353,8 +364,8 @@ module MangaDex
|
|||||||
|
|
||||||
private def download_page(job : PageJob)
|
private def download_page(job : PageJob)
|
||||||
@logger.debug "downloading #{job.url}"
|
@logger.debug "downloading #{job.url}"
|
||||||
headers = HTTP::Headers {
|
headers = HTTP::Headers{
|
||||||
"User-agent" => "Mangadex.cr"
|
"User-agent" => "Mangadex.cr",
|
||||||
}
|
}
|
||||||
begin
|
begin
|
||||||
HTTP::Client.get job.url, headers do |res|
|
HTTP::Client.get job.url, headers do |res|
|
||||||
|
12
src/mango.cr
12
src/mango.cr
@ -3,11 +3,11 @@ 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
|
||||||
@ -18,20 +18,20 @@ parser = OptionParser.parse do |parser|
|
|||||||
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
|
||||||
|
|
||||||
|
@ -26,7 +26,6 @@ class AdminRouter < Router
|
|||||||
|
|
||||||
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
|
||||||
@ -37,7 +36,7 @@ class AdminRouter < Router
|
|||||||
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
|
||||||
@ -53,19 +52,16 @@ class AdminRouter < Router
|
|||||||
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
|
# if `admin` is unchecked, the body hash would not contain `admin`
|
||||||
# hash would not contain `admin`
|
|
||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
original_username = env.params.url["original_username"]
|
original_username = env.params.url["original_username"]
|
||||||
|
|
||||||
@ -73,7 +69,7 @@ class AdminRouter < Router
|
|||||||
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
|
||||||
|
|
||||||
@ -93,15 +89,14 @@ class AdminRouter < Router
|
|||||||
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
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
require "./router"
|
require "./router"
|
||||||
require "../mangadex/*"
|
require "../mangadex/*"
|
||||||
|
require "../upload"
|
||||||
|
|
||||||
class APIRouter < Router
|
class APIRouter < Router
|
||||||
def setup
|
def setup
|
||||||
@ -12,8 +13,7 @@ class APIRouter < Router
|
|||||||
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?
|
||||||
@ -50,7 +50,7 @@ class APIRouter < Router
|
|||||||
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
|
||||||
|
|
||||||
@ -62,28 +62,58 @@ class APIRouter < Router
|
|||||||
@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"]?
|
||||||
|
|
||||||
|
if !entry_id.nil?
|
||||||
|
entry = title.get_entry(entry_id).not_nil!
|
||||||
raise "incorrect page value" if page < 0 || page > entry.pages
|
raise "incorrect page value" if page < 0 || page > entry.pages
|
||||||
title.save_progress username, entry.title, page
|
title.save_progress username, entry.title, page
|
||||||
|
elsif page == 0
|
||||||
|
title.unread_all username
|
||||||
|
else
|
||||||
|
title.read_all username
|
||||||
|
end
|
||||||
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
|
||||||
|
else
|
||||||
|
send_json env, {"success" => true}.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post "/api/admin/display_name/:title/:name" do |env|
|
||||||
|
begin
|
||||||
|
title = (@context.library.get_title env.params.url["title"])
|
||||||
|
.not_nil!
|
||||||
|
name = env.params.url["name"]
|
||||||
|
entry = env.params.query["entry"]?
|
||||||
|
if entry.nil?
|
||||||
|
title.set_display_name name
|
||||||
|
else
|
||||||
|
eobj = title.get_entry entry
|
||||||
|
title.set_display_name eobj.not_nil!.title, name
|
||||||
|
end
|
||||||
|
rescue e
|
||||||
|
@context.error e
|
||||||
|
send_json env, {
|
||||||
|
"success" => false,
|
||||||
|
"error" => e.message,
|
||||||
}.to_json
|
}.to_json
|
||||||
else
|
else
|
||||||
send_json env, {"success" => true}.to_json
|
send_json env, {"success" => true}.to_json
|
||||||
@ -93,8 +123,7 @@ class APIRouter < Router
|
|||||||
get "/api/admin/mangadex/manga/:id" do |env|
|
get "/api/admin/mangadex/manga/:id" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.url["id"]
|
id = env.params.url["id"]
|
||||||
api = MangaDex::API.new \
|
api = MangaDex::API.new @context.config.mangadex["api_url"].to_s
|
||||||
@context.config.mangadex["api_url"].to_s
|
|
||||||
manga = api.get_manga id
|
manga = api.get_manga id
|
||||||
send_json env, manga.to_info_json
|
send_json env, manga.to_info_json
|
||||||
rescue e
|
rescue e
|
||||||
@ -105,8 +134,8 @@ class APIRouter < Router
|
|||||||
|
|
||||||
post "/api/admin/mangadex/download" do |env|
|
post "/api/admin/mangadex/download" do |env|
|
||||||
begin
|
begin
|
||||||
chapters = env.params.json["chapters"].as(Array).map{|c| c.as_h}
|
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
||||||
jobs = chapters.map {|chapter|
|
jobs = chapters.map { |chapter|
|
||||||
MangaDex::Job.new(
|
MangaDex::Job.new(
|
||||||
chapter["id"].as_s,
|
chapter["id"].as_s,
|
||||||
chapter["manga_id"].as_s,
|
chapter["manga_id"].as_s,
|
||||||
@ -119,7 +148,7 @@ class APIRouter < Router
|
|||||||
inserted_count = @context.queue.push jobs
|
inserted_count = @context.queue.push jobs
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success": inserted_count,
|
"success": inserted_count,
|
||||||
"fail": jobs.size - inserted_count
|
"fail": jobs.size - inserted_count,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
@context.error e
|
||||||
@ -133,12 +162,12 @@ class APIRouter < Router
|
|||||||
send_json env, {
|
send_json env, {
|
||||||
"jobs" => jobs,
|
"jobs" => jobs,
|
||||||
"paused" => @context.queue.paused?,
|
"paused" => @context.queue.paused?,
|
||||||
"success" => true
|
"success" => true,
|
||||||
}.to_json
|
}.to_json
|
||||||
rescue e
|
rescue e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message
|
"error" => e.message,
|
||||||
}.to_json
|
}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -172,7 +201,61 @@ class APIRouter < Router
|
|||||||
rescue e
|
rescue e
|
||||||
send_json env, {
|
send_json env, {
|
||||||
"success" => false,
|
"success" => false,
|
||||||
"error" => e.message
|
"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
|
}.to_json
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -8,8 +8,7 @@ class MainRouter < Router
|
|||||||
|
|
||||||
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}"
|
||||||
@ -22,8 +21,7 @@ class MainRouter < Router
|
|||||||
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
|
||||||
@ -35,19 +33,24 @@ class MainRouter < Router
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/" do |env|
|
get "/" do |env|
|
||||||
|
begin
|
||||||
titles = @context.library.titles
|
titles = @context.library.titles
|
||||||
username = get_username env
|
username = get_username env
|
||||||
percentage = titles.map &.load_percetage username
|
percentage = titles.map &.load_percetage username
|
||||||
layout "index"
|
layout "index"
|
||||||
|
rescue e
|
||||||
|
@context.error e
|
||||||
|
env.response.status_code = 500
|
||||||
|
end
|
||||||
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
|
||||||
@ -56,7 +59,7 @@ class MainRouter < Router
|
|||||||
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
|
||||||
|
@ -4,8 +4,7 @@ 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
|
||||||
@ -25,8 +24,7 @@ class ReaderRouter < Router
|
|||||||
|
|
||||||
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
|
||||||
@ -37,16 +35,21 @@ class ReaderRouter < Router
|
|||||||
|
|
||||||
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_urls = pages.map { |idx|
|
||||||
"/reader/#{title.id}/#{entry.id}/#{idx}" }
|
"/reader/#{title.id}/#{entry.id}/#{idx}"
|
||||||
|
}
|
||||||
next_page = page + IMGS_PER_PAGE
|
next_page = page + IMGS_PER_PAGE
|
||||||
next_url = next_page > entry.pages ? nil :
|
next_url = next_entry_url = nil
|
||||||
"/reader/#{title.id}/#{entry.id}/#{next_page}"
|
|
||||||
exit_url = "/book/#{title.id}"
|
exit_url = "/book/#{title.id}"
|
||||||
next_entry = title.next_entry entry
|
next_entry = title.next_entry entry
|
||||||
next_entry_url = next_entry.nil? ? nil : \
|
unless next_page > entry.pages
|
||||||
"/reader/#{title.id}/#{next_entry.id}"
|
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
|
||||||
|
@ -1,17 +1,13 @@
|
|||||||
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|
|
error 403 do |env|
|
||||||
message = "HTTP 403: You are not authorized to visit " \
|
message = "HTTP 403: You are not authorized to visit #{env.request.path}"
|
||||||
"#{env.request.path}"
|
|
||||||
layout "message"
|
layout "message"
|
||||||
end
|
end
|
||||||
error 404 do |env|
|
error 404 do |env|
|
||||||
@ -31,6 +27,7 @@ class Server
|
|||||||
Kemal.config.logging = false
|
Kemal.config.logging = false
|
||||||
add_handler LogHandler.new @context.logger
|
add_handler LogHandler.new @context.logger
|
||||||
add_handler AuthHandler.new @context.storage
|
add_handler AuthHandler.new @context.storage
|
||||||
|
add_handler UploadHandler.new @context.config.upload_path
|
||||||
{% if flag?(:release) %}
|
{% if flag?(:release) %}
|
||||||
# when building for relase, embed the static files in binary
|
# when building for relase, embed the static files in binary
|
||||||
@context.debug "We are in release mode. Using embedded static files."
|
@context.debug "We are in release mode. Using embedded static files."
|
||||||
|
@ -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
|
|
@ -2,6 +2,7 @@ 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
|
||||||
@ -11,12 +12,8 @@ def verify_password(hash, pw)
|
|||||||
(Crypto::Bcrypt::Password.new hash).verify pw
|
(Crypto::Bcrypt::Password.new hash).verify pw
|
||||||
end
|
end
|
||||||
|
|
||||||
def random_str
|
|
||||||
UUID.random.to_s.gsub "-", ""
|
|
||||||
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. " \
|
||||||
@ -48,7 +45,7 @@ class Storage
|
|||||||
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
|
||||||
@ -57,8 +54,8 @@ class Storage
|
|||||||
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"
|
||||||
@ -79,28 +76,29 @@ class Storage
|
|||||||
end
|
end
|
||||||
|
|
||||||
def verify_token(token)
|
def verify_token(token)
|
||||||
|
username = nil
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
begin
|
begin
|
||||||
username = db.query_one "select username from users where " \
|
username = db.query_one "select username from users where " \
|
||||||
"token = (?)", token, as: String
|
"token = (?)", token, as: String
|
||||||
return username
|
|
||||||
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)
|
||||||
|
is_admin = false
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
begin
|
begin
|
||||||
return db.query_one "select admin from users where " \
|
is_admin = db.query_one "select admin from users where " \
|
||||||
"token = (?)", token, as: Bool
|
"token = (?)", token, as: Bool
|
||||||
rescue e
|
rescue e
|
||||||
@logger.debug "Unable to verify user as admin"
|
@logger.debug "Unable to verify user as admin"
|
||||||
return false
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
is_admin
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_users
|
def list_users
|
||||||
@ -128,13 +126,13 @@ class Storage
|
|||||||
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
|
||||||
@ -149,26 +147,23 @@ class Storage
|
|||||||
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)
|
||||||
|
id = random_str
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
begin
|
begin
|
||||||
id = db.query_one "select id from ids where path = (?)",
|
id = db.query_one "select id from ids where path = (?)", path,
|
||||||
path, as: {String}
|
as: {String}
|
||||||
return id
|
|
||||||
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,
|
|
||||||
is_title ? 1 : 0
|
|
||||||
return id
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
id
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
|
60
src/upload.cr
Normal file
60
src/upload.cr
Normal 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
|
38
src/util.cr
38
src/util.cr
@ -1,9 +1,21 @@
|
|||||||
require "big"
|
require "big"
|
||||||
|
|
||||||
IMGS_PER_PAGE = 5
|
IMGS_PER_PAGE = 5
|
||||||
|
UPLOAD_URL_PREFIX = "/uploads"
|
||||||
|
|
||||||
macro layout(name)
|
macro layout(name)
|
||||||
|
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"
|
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)
|
||||||
@ -17,9 +29,9 @@ macro get_username(env)
|
|||||||
(@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)
|
||||||
@ -32,7 +44,7 @@ def request_path_startswith(env, ary)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def is_numeric(str)
|
def is_numeric(str)
|
||||||
@ -42,7 +54,7 @@ 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
|
||||||
@ -71,3 +83,19 @@ 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
|
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
|
||||||
|
@ -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("\"", """) %>"><%= t.title %></h3>
|
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", """) %>"><%= t.display_name %></h3>
|
||||||
<p><%= t.size %> entries</p>
|
<p><%= t.size %> entries</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
|
<% if is_admin %>
|
||||||
<li><a href="/admin">Admin</a></li>
|
<li><a href="/admin">Admin</a></li>
|
||||||
<li><a href="/download">Download</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>
|
||||||
|
<% if is_admin %>
|
||||||
<li><a href="/admin">Admin</a></li>
|
<li><a href="/admin">Admin</a></li>
|
||||||
<li><a href="/download">Download</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">
|
||||||
|
@ -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>
|
||||||
|
@ -1,10 +1,17 @@
|
|||||||
<h2 class=uk-title><%= title.title %></h2>
|
<div>
|
||||||
|
<h2 class=uk-title><span><%= title.display_name %></span>
|
||||||
|
|
||||||
|
<% 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("\"", """) %>"><%= t.title %></h3>
|
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", """) %>"><%= 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("<%= e.encoded_path %>", '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, "<%= title.encoded_title %>", "<%= e.encoded_title %>", '<%= e.title_id %>', '<%= e.id %>')">
|
<div class="uk-card uk-card-default" onclick="showModal("<%= e.encoded_path %>", '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, "<%= title.encoded_display_name %>", "<%= e.encoded_display_name %>", '<%= 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("\"", """) %>"><%= e.title %></h3>
|
<h3 class="uk-card-title break-word" data-title="<%= e.display_name.gsub("\"", """) %>"><%= 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>
|
||||||
|
|
||||||
|
<% 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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user