Compare commits

..

42 Commits

Author SHA1 Message Date
Alex Ling
cdfc9f3a93 Show manga title on subscription manager 2022-03-19 11:39:20 +00:00
Alex Ling
2cc1a06b4e Reset table sort options 2022-03-19 11:23:23 +00:00
Alex Ling
95eb208901 Confirmation before deleting subscriptions 2022-03-19 11:13:48 +00:00
Alex Ling
acefa00b12 Merge branch 'dev' into feature/plugin-v2 2022-03-18 11:57:49 +00:00
Alex Ling
8dfe580e39 Merge branch 'dev' into feature/plugin-v2 2022-03-15 13:58:48 +00:00
Alex Ling
c290eee90b Fix BigFloat conversion issue 2022-03-15 13:58:16 +00:00
Alex Ling
2ade6ebd8c Document the subscription endpoints 2022-02-20 06:09:16 +00:00
Alex Ling
b56a9cb50c Clean up 2022-02-20 04:48:05 +00:00
Alex Ling
fd650bdf45 Fix null pid 2022-02-20 03:58:36 +00:00
Alex Ling
c80855bb5d Merge branch 'dev' into feature/plugin-v2 2022-02-20 02:46:53 +00:00
Alex Ling
0adcd3a634 Update last checked even when no chapters found 2022-01-23 12:24:19 +00:00
Alex Ling
f6bd3fa15d Update last checked from manager page 2022-01-23 12:23:58 +00:00
Alex Ling
be3babda37 Show target API version 2022-01-23 11:06:07 +00:00
Alex Ling
cae832911d Fix timestamp precision issue in plugin 2022-01-23 09:47:00 +00:00
Alex Ling
2c7c29fef9 Trigger subscription update from manager page 2022-01-23 09:45:45 +00:00
Alex Ling
968f6246de Fix actions on download manager 2022-01-23 08:46:19 +00:00
Alex Ling
d862c386f1 Base64 encode chapter IDs 2022-01-23 06:08:20 +00:00
Alex Ling
031df3a2bc Finish plugin updater 2022-01-22 14:50:37 +00:00
Alex Ling
5fac8c6a60 Merge branch 'dev' into feature/plugin-v2 2022-01-21 13:20:23 +00:00
Alex Ling
748386f0af WIP 2021-12-31 14:49:37 +00:00
Alex Ling
031ea7ef16 Finish subscription manager page 2021-11-25 12:31:17 +00:00
Alex Ling
b76d4645cc Add subscription manager page (WIP) 2021-11-21 04:08:17 +00:00
Alex Ling
fe6f884d94 Store manga ID with subscriptions 2021-11-20 11:08:36 +00:00
Alex Ling
5442d124af Subscription management API endpoints 2021-11-20 09:47:18 +00:00
Alex Ling
352236ab65 Delete unnecessary raise for debugging 2021-11-20 08:50:06 +00:00
Alex Ling
e44213960f Refactor date filtering and use native date picker 2021-11-20 08:10:51 +00:00
Alex Ling
87e54aa89c Merge branch 'dev' into feature/plugin-v2
Fixes #244 again in this branch
2021-11-18 14:55:15 +00:00
Alex Ling
a45bea01c9 Fix linter 2021-09-04 02:32:46 +00:00
Alex Ling
198913db3e Remove MangaDex files that are no longer needed 2021-09-04 02:31:30 +00:00
Alex Ling
238860d52d Simplify subscription JSON parsing 2021-09-04 02:26:18 +00:00
Alex Ling
ae1c36263b Merge branch 'dev' into feature/plugin-v2 2021-09-04 02:25:19 +00:00
Alex Ling
b7aee1e903 WIP 2021-08-17 04:11:58 +00:00
Alex Ling
f56ce2313c WIP 2021-07-11 11:19:08 +00:00
Alex Ling
259f6cb285 Add endpoint for plugin subscription 2021-07-11 02:45:14 +00:00
Alex Ling
3b19883dde Use auto overflow tables
cherry-picked from a612500b0f
2021-06-07 07:35:44 +00:00
Alex Ling
6844860065 Revert "Subscription manager"
This reverts commit a612500b0f.
2021-06-07 07:32:32 +00:00
Alex Ling
9eb699ea3b Add plugin subscription types 2021-06-07 07:04:49 +00:00
Alex Ling
59bcb4db3b WIP 2021-06-05 10:53:45 +00:00
Alex Ling
87c479bf42 WIP 2021-05-30 10:54:27 +00:00
Alex Ling
e0713ccde8 WIP 2021-05-22 07:24:30 +00:00
Alex Ling
a571d21cba WIP 2021-05-15 13:37:11 +00:00
Alex Ling
23541f457e Add "title_title" to slim JSON 2021-05-15 06:54:12 +00:00
35 changed files with 263 additions and 1095 deletions

View File

@@ -12,4 +12,3 @@ Layout/LineLength:
MaxLength: 80
Excluded:
- src/routes/api.cr
- spec/plugin_spec.cr

View File

@@ -4,9 +4,6 @@
[![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Discord](https://img.shields.io/discord/855633663425118228?label=discord)](http://discord.com/invite/ezKtacCp9Q)
> [!CAUTION]
> As of March 2025, Mango is no longer maintained. We are incredibly grateful to everyone who used it, contributed, or gave feedback along the way - thank you! Unfortunately, we just don't have the time to keep it going right now. That said, it's open source, so you're more than welcome to fork it, build on it, or maintain your own version. If you're looking for alternatives, check out the wiki for similar projects. We might return to it someday, but for now, we don't recommend using it as-is - running unmaintained software can introduce security risks.
Mango is a self-hosted manga server and reader. Its features include
- Multi-user support
@@ -54,7 +51,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango - Manga Server and Web Reader. Version 0.27.0
Mango - Manga Server and Web Reader. Version 0.25.0
Usage:
@@ -91,16 +88,15 @@ upload_path: ~/mango/uploads
plugin_path: ~/mango/plugins
download_timeout_seconds: 30
library_cache_path: ~/mango/library.yml.gz
cache_enabled: true
cache_enabled: false
cache_size_mbs: 50
cache_log_enabled: true
disable_login: false
default_username: ""
auth_proxy_header_name: ""
plugin_update_interval_hours: 24
```
- `scan_interval_minutes`, `thumbnail_generation_interval_hours`, and `plugin_update_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `scan_interval_minutes`, `thumbnail_generation_interval_hours` can be any non-negative integer. Setting them to `0` disables the periodic tasks
- `log_level` can be `debug`, `info`, `warn`, `error`, `fatal` or `off`. Setting it to `off` disables the logging
- You can disable authentication by setting `disable_login` to true. Note that `default_username` must be set to an existing username for this to work.
- By setting `cache_enabled` to `true`, you can enable an experimental feature where Mango caches library metadata to improve page load time. You can further fine-tune the feature with `cache_size_mbs` and `cache_log_enabled`.

View File

@@ -1,7 +1,6 @@
const component = () => {
return {
plugins: [],
subscribable: false,
info: undefined,
pid: undefined,
chapters: undefined, // undefined: not searched yet, []: empty
@@ -61,7 +60,6 @@ const component = () => {
.then((data) => {
if (!data.success) throw new Error(data.error);
this.info = data.info;
this.subscribable = data.subscribable;
this.pid = pid;
})
.catch((e) => {
@@ -72,9 +70,6 @@ const component = () => {
});
},
pluginChanged() {
this.manga = undefined;
this.chapters = undefined;
this.mid = undefined;
this.loadPlugin(this.pid);
localStorage.setItem("plugin", this.pid);
},
@@ -145,7 +140,6 @@ const component = () => {
if (!query) return;
this.manga = undefined;
this.mid = undefined;
if (this.info.version === 1) {
this.searchChapters(query);
} else {

View File

@@ -14,7 +14,6 @@ const readerComponent = () => {
margin: 30,
preloadLookahead: 3,
enableRightToLeft: false,
fitType: 'vert',
/**
* Initialize the component by fetching the page dimensions
@@ -30,16 +29,14 @@ const readerComponent = () => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width == 0 ? "100%" : d.width,
height: d.height == 0 ? "100%" : d.height,
width: d.width,
height: d.height,
};
});
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`.
// TODO: support more image types in image_size.cr
const avgRatio = dimensions.reduce((acc, cur) => {
const avgRatio = this.items.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / dimensions.length;
}, 0) / this.items.length;
console.log(avgRatio);
this.longPages = avgRatio > 2;
@@ -61,16 +58,11 @@ const readerComponent = () => {
// Preload Images
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
const limit = Math.min(page + this.preloadLookahead, this.items.length);
const limit = Math.min(page + this.preloadLookahead, this.items.length + 1);
for (let idx = page + 1; idx <= limit; idx++) {
this.preloadImage(this.items[idx - 1].url);
}
const savedFitType = localStorage.getItem('fitType');
if (savedFitType) {
this.fitType = savedFitType;
$('#fit-select').val(savedFitType);
}
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
@@ -143,11 +135,7 @@ const readerComponent = () => {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0) return;
if (newIdx > this.items.length) {
this.showControl(idx);
return;
}
if (newIdx <= 0 || newIdx > this.items.length) return;
if (newIdx + this.preloadLookahead < this.items.length + 1) {
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
@@ -265,20 +253,12 @@ const readerComponent = () => {
});
},
/**
* Handles clicked image
* Shows the control modal
*
* @param {Event} event - The triggering event
*/
clickImage(event) {
showControl(event) {
const idx = event.currentTarget.id;
this.showControl(idx);
},
/**
* Shows the control modal
*
* @param {number} idx - selected page index
*/
showControl(idx) {
this.selectedIndex = idx;
UIkit.modal($('#modal-sections')).show();
},
@@ -341,11 +321,6 @@ const readerComponent = () => {
this.toPage(this.selectedIndex);
},
fitChanged(){
this.fitType = $('#fit-select').val();
localStorage.setItem('fitType', this.fitType);
},
preloadLookaheadChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead);
},

View File

@@ -50,7 +50,7 @@ shards:
koa:
git: https://github.com/hkalexling/koa.git
version: 0.9.0
version: 0.8.0
mg:
git: https://github.com/hkalexling/mg.git
@@ -68,10 +68,6 @@ shards:
git: https://github.com/luislavena/radix.git
version: 0.4.1
sanitize:
git: https://github.com/hkalexling/sanitize.git
version: 0.1.0+git.commit.e09520e972d0d9b70b71bb003e6831f7c2c59dce
sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.18.0

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.27.0
version: 0.25.0
authors:
- Alex Ling <hkalexling@gmail.com>
@@ -42,5 +42,3 @@ dependencies:
branch: master
mg:
github: hkalexling/mg
sanitize:
github: hkalexling/sanitize

View File

@@ -1,6 +0,0 @@
{
"id": "test",
"title": "Test Plugin",
"placeholder": "placeholder",
"wait_seconds": 1
}

View File

@@ -1,31 +1,14 @@
require "./spec_helper"
describe Config do
it "creates default config if it does not exist" do
with_default_config do |config, path|
it "creates config if it does not exist" do
with_default_config do |_, path|
File.exists?(path).should be_true
config.port.should eq 9000
end
end
it "correctly loads config" do
config = Config.load "spec/asset/test-config.yml"
config.port.should eq 3000
config.base_url.should eq "/"
end
it "correctly reads config defaults from ENV" do
ENV["LOG_LEVEL"] = "debug"
config = Config.load "spec/asset/test-config.yml"
config.log_level.should eq "debug"
config.base_url.should eq "/"
end
it "correctly handles ENV truthiness" do
ENV["CACHE_ENABLED"] = "false"
config = Config.load "spec/asset/test-config.yml"
config.cache_enabled.should be_false
config.cache_log_enabled.should be_true
config.disable_login.should be_false
end
end

View File

@@ -1,70 +0,0 @@
require "./spec_helper"
describe Plugin do
describe "helper functions" do
it "mango.text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<a href="https://github.com">Click Me<a>');
JS
res.should eq "Click Me"
end
end
it "mango.text returns empty string when no text" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.text('<img src="https://github.com" />');
JS
res.should eq ""
end
end
it "mango.css" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.test');
JS
res.should eq ["<li class=\"test\">A</li>", "<li class=\"test\">B</li>"]
end
end
it "mango.css returns empty array when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.css('<ul><li class="test">A</li><li class="test">B</li><li>C</li></ul>', 'li.noclass');
JS
res.should eq [] of String
end
end
it "mango.attribute" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<a href="https://github.com">Click Me<a>', 'href');
JS
res.should eq "https://github.com"
end
end
it "mango.attribute returns undefined when no match" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<div />', 'href') === undefined;
JS
res.should be_true
end
end
# https://github.com/hkalexling/Mango/issues/320
it "mango.attribute handles tags in attribute values" do
with_plugin do |plugin|
res = plugin.eval <<-JS
mango.attribute('<div data-a="<img />" data-b="test" />', 'data-b');
JS
res.should eq "test"
end
end
end
end

View File

@@ -3,7 +3,6 @@ require "../src/queue"
require "../src/server"
require "../src/config"
require "../src/main_fiber"
require "../src/plugin/plugin"
class State
@@hash = {} of String => String
@@ -55,10 +54,3 @@ def with_storage
end
end
end
def with_plugin
with_default_config do
plugin = Plugin.new "test", "spec/asset/plugins"
yield plugin
end
end

View File

@@ -1,51 +1,31 @@
require "yaml"
class Config
private OPTIONS = {
"host" => "0.0.0.0",
"port" => 9000,
"base_url" => "/",
"session_secret" => "mango-session-secret",
"library_path" => "~/mango/library",
"library_cache_path" => "~/mango/library.yml.gz",
"db_path" => "~/mango.db",
"queue_db_path" => "~/mango/queue.db",
"scan_interval_minutes" => 5,
"thumbnail_generation_interval_hours" => 24,
"log_level" => "info",
"upload_path" => "~/mango/uploads",
"plugin_path" => "~/mango/plugins",
"download_timeout_seconds" => 30,
"cache_enabled" => true,
"cache_size_mbs" => 50,
"cache_log_enabled" => true,
"disable_login" => false,
"default_username" => "",
"auth_proxy_header_name" => "",
"plugin_update_interval_hours" => 24,
}
include YAML::Serializable
@[YAML::Field(ignore: true)]
property path : String = ""
# Go through the options constant above and define them as properties.
# Allow setting the default values through environment variables.
# Overall precedence: config file > environment variable > default value
{% begin %}
{% for k, v in OPTIONS %}
{% if v.is_a? StringLiteral %}
property {{k.id}} : String = ENV[{{k.upcase}}]? || {{ v }}
{% elsif v.is_a? NumberLiteral %}
property {{k.id}} : Int32 = (ENV[{{k.upcase}}]? || {{ v.id }}).to_i
{% elsif v.is_a? BoolLiteral %}
property {{k.id}} : Bool = env_is_true? {{ k.upcase }}, {{ v.id }}
{% else %}
raise "Unknown type in config option: {{ v.class_name.id }}"
{% end %}
{% end %}
{% end %}
property path = ""
property host = "0.0.0.0"
property port : Int32 = 9000
property base_url = "/"
property session_secret = "mango-session-secret"
property library_path = "~/mango/library"
property library_cache_path = "~/mango/library.yml.gz"
property db_path = "~/mango/mango.db"
property queue_db_path = "~/mango/queue.db"
property scan_interval_minutes : Int32 = 5
property thumbnail_generation_interval_hours : Int32 = 24
property log_level = "info"
property upload_path = "~/mango/uploads"
property plugin_path = "~/mango/plugins"
property download_timeout_seconds : Int32 = 30
property cache_enabled = true
property cache_size_mbs = 50
property cache_log_enabled = true
property disable_login = false
property default_username = ""
property auth_proxy_header_name = ""
property plugin_update_interval_hours : Int32 = 24
@@singlet : Config?
@@ -58,7 +38,7 @@ class Config
end
def self.load(path : String?)
path = (ENV["CONFIG_PATH"]? || "~/.config/mango/config.yml") if path.nil?
path = "~/.config/mango/config.yml" if path.nil?
cfg_path = File.expand_path path, home: true
if File.exists? cfg_path
config = self.from_yaml File.read cfg_path

View File

@@ -6,7 +6,6 @@ class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
BASIC = "Basic"
BEARER = "Bearer"
AUTH = "Authorization"
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
"You have to login with proper credentials"
@@ -19,14 +18,8 @@ class AuthHandler < Kemal::Handler
end
def require_auth(env)
if request_path_startswith env, ["/api"]
# Do not redirect API requests
env.response.status_code = 401
send_text env, "Unauthorized"
else
env.session.string "callback", env.request.path
redirect env, "/login"
end
env.session.string "callback", env.request.path
redirect env, "/login"
end
def validate_token(env)
@@ -42,18 +35,13 @@ class AuthHandler < Kemal::Handler
def validate_auth_header(env)
if env.request.headers[AUTH]?
if value = env.request.headers[AUTH]
if value.starts_with? BASIC
if value.size > 0 && value.starts_with?(BASIC)
token = verify_user value
return false if token.nil?
env.session.string "token", token
return true
end
if value.starts_with? BEARER
session_id = value.split(" ")[1]
token = Kemal::Session.get(session_id).try &.string? "token"
return !token.nil? && Storage.default.verify_token token
end
end
end
false
@@ -66,10 +54,6 @@ class AuthHandler < Kemal::Handler
end
def call(env)
# OPTIONS requests do not require authentication
if env.request.method === "OPTIONS"
return call_next(env)
end
# Skip all authentication if requesting /login, /logout, /api/login,
# or a static file
if request_path_startswith(env, ["/login", "/logout", "/api/login"]) ||
@@ -78,8 +62,8 @@ class AuthHandler < Kemal::Handler
end
# Check user is logged in
if validate_token(env) || validate_auth_header(env)
# Skip if the request has a valid token (either from cookies or header)
if validate_token env
# Skip if the request has a valid token
elsif Config.current.disable_login
# Check default username if login is disabled
unless Storage.default.username_exists Config.current.default_username

View File

@@ -1,8 +0,0 @@
class CORSHandler < Kemal::Handler
def call(env)
if request_path_startswith env, ["/api"]
env.response.headers["Access-Control-Allow-Origin"] = "*"
end
call_next env
end
end

View File

@@ -1,111 +0,0 @@
require "yaml"
require "./entry"
class ArchiveEntry < Entry
include YAML::Serializable
getter zip_path : String
def initialize(@zip_path, @book)
storage = Storage.default
@path = @zip_path
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end
@id = id
@mtime = File.info(@zip_path).modification_time
unless File.readable? @zip_path
@err_msg = "File #{@zip_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort! { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
begin
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename),
page.filename, data.size
end
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def examine : Bool
File.exists? @zip_path
end
def self.is_valid?(path : String) : Bool
is_supported_file path
end
end

View File

@@ -76,8 +76,8 @@ class SortedEntriesCacheEntry < CacheEntry(Array(String), Array(Entry))
entries : Array(Entry), opt : SortOptions?)
entries_sig = Digest::SHA1.hexdigest (entries.map &.id).to_s
user_context = opt && opt.method == SortMethod::Progress ? username : ""
sig = Digest::SHA1.hexdigest(book_id + entries_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil"))
sig = Digest::SHA1.hexdigest (book_id + entries_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil"))
"#{sig}:sorted_entries"
end
end
@@ -101,8 +101,8 @@ class SortedTitlesCacheEntry < CacheEntry(Array(String), Array(Title))
def self.gen_key(username : String, titles : Array(Title), opt : SortOptions?)
titles_sig = Digest::SHA1.hexdigest (titles.map &.id).to_s
user_context = opt && opt.method == SortMethod::Progress ? username : ""
sig = Digest::SHA1.hexdigest(titles_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil"))
sig = Digest::SHA1.hexdigest (titles_sig + user_context +
(opt ? opt.to_tuple.to_s : "nil"))
"#{sig}:sorted_titles"
end
end

View File

@@ -1,132 +0,0 @@
require "yaml"
require "./entry"
class DirEntry < Entry
include YAML::Serializable
getter dir_path : String
@[YAML::Field(ignore: true)]
@sorted_files : Array(String)?
@signature : String
def initialize(@dir_path, @book)
storage = Storage.default
@path = @dir_path
@encoded_path = URI.encode @dir_path
@title = File.basename @dir_path
@encoded_title = URI.encode @title
unless File.readable? @dir_path
@err_msg = "Directory #{@dir_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
unless DirEntry.is_valid? @dir_path
@err_msg = "Directory #{@dir_path} is not valid directory entry."
Logger.warn "#{@err_msg} Please make sure the " \
"directory has valid images."
return
end
size_sum = 0
sorted_files.each do |file_path|
size_sum += File.size file_path
end
@size = size_sum.humanize_bytes
@signature = Dir.directory_entry_signature @dir_path
id = storage.get_entry_id @dir_path, @signature
if id.nil?
id = random_str
storage.insert_entry_id({
path: @dir_path,
id: id,
signature: @signature,
})
end
@id = id
@mtime = sorted_files.map do |file_path|
File.info(file_path).modification_time
end.max
@pages = sorted_files.size
end
def read_page(page_num)
img = nil
begin
files = sorted_files
file_path = files[page_num - 1]
data = File.read(file_path).to_slice
if data
img = Image.new data, MIME.from_filename(file_path),
File.basename(file_path), data.size
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@dir_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_files.each_with_index do |path, i|
data = File.read(path).to_slice
begin
data.not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{@dir_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
sizes
end
def examine : Bool
existence = File.exists? @dir_path
return false unless existence
files = DirEntry.image_files @dir_path
signature = Dir.directory_entry_signature @dir_path
existence = files.size > 0 && @signature == signature
@sorted_files = nil unless existence
# For more efficient, update a directory entry with new property
# and return true like Title.examine
existence
end
def sorted_files
cached_sorted_files = @sorted_files
return cached_sorted_files if cached_sorted_files
@sorted_files = DirEntry.sorted_image_files @dir_path
@sorted_files.not_nil!
end
def self.image_files(dir_path)
Dir.entries(dir_path)
.reject(&.starts_with? ".")
.map { |fn| File.join dir_path, fn }
.select { |fn| is_supported_image_file fn }
.reject { |fn| File.directory? fn }
.select { |fn| File.readable? fn }
end
def self.sorted_image_files(dir_path)
self.image_files(dir_path)
.sort { |a, b| compare_numerically a, b }
end
def self.is_valid?(path : String) : Bool
image_files(path).size > 0
end
end

View File

@@ -1,55 +1,63 @@
require "image_size"
require "yaml"
private def node_has_key(node : YAML::Nodes::Mapping, key : String)
node.nodes
.map_with_index { |n, i| {n, i} }
.select(&.[1].even?)
.map(&.[0])
.select(YAML::Nodes::Scalar)
.map(&.as(YAML::Nodes::Scalar).value)
.includes? key
end
class Entry
include YAML::Serializable
abstract class Entry
getter id : String, book : Title, title : String, path : String,
size : String, pages : Int32, mtime : Time,
encoded_path : String, encoded_title : String, err_msg : String?
getter zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, encoded_path : String,
encoded_title : String, mtime : Time, err_msg : String?
def initialize(
@id, @title, @book, @path,
@size, @pages, @mtime,
@encoded_path, @encoded_title, @err_msg
)
end
@[YAML::Field(ignore: true)]
@sort_title : String?
def self.new(ctx : YAML::ParseContext, node : YAML::Nodes::Node)
unless node.is_a? YAML::Nodes::Mapping
raise "Unexpected node type in YAML"
def initialize(@zip_path, @book)
storage = Storage.default
@encoded_path = URI.encode @zip_path
@title = File.basename @zip_path, File.extname @zip_path
@encoded_title = URI.encode @title
@size = (File.size @zip_path).humanize_bytes
id = storage.get_entry_id @zip_path, File.signature(@zip_path)
if id.nil?
id = random_str
storage.insert_entry_id({
path: @zip_path,
id: id,
signature: File.signature(@zip_path).to_s,
})
end
# Doing YAML::Any.new(ctx, node) here causes a weird error, so
# instead we are using a more hacky approach (see `node_has_key`).
# TODO: Use a more elegant approach
if node_has_key node, "zip_path"
ArchiveEntry.new ctx, node
elsif node_has_key node, "dir_path"
DirEntry.new ctx, node
else
raise "Unknown entry found in YAML cache. Try deleting the " \
"`library.yml.gz` file"
@id = id
@mtime = File.info(@zip_path).modification_time
unless File.readable? @zip_path
@err_msg = "File #{@zip_path} is not readable."
Logger.warn "#{@err_msg} Please make sure the " \
"file permission is configured correctly."
return
end
archive_exception = validate_archive @zip_path
unless archive_exception.nil?
@err_msg = "Archive error: #{archive_exception}"
Logger.warn "Unable to extract archive #{@zip_path}. " \
"Ignoring it. #{@err_msg}"
return
end
file = ArchiveFile.new @zip_path
@pages = file.entries.count do |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
end
file.close
end
def build_json(*, slim = false)
JSON.build do |json|
json.object do
{% for str in %w(path title size id) %}
json.field {{str}}, {{str.id}}
{% for str in ["zip_path", "title", "size", "id"] %}
json.field {{str}}, @{{str.id}}
{% end %}
if err_msg
json.field "err_msg", err_msg
end
json.field "zip_path", path # for API backward compatability
json.field "path", path
json.field "title_id", @book.id
json.field "title_title", @book.title
json.field "sort_title", sort_title
@@ -63,9 +71,6 @@ abstract class Entry
end
end
@[YAML::Field(ignore: true)]
@sort_title : String?
def sort_title
sort_title_cached = @sort_title
return sort_title_cached if sort_title_cached
@@ -123,6 +128,58 @@ abstract class Entry
url
end
private def sorted_archive_entries
ArchiveFile.open @zip_path do |file|
entries = file.entries
.select { |e|
SUPPORTED_IMG_TYPES.includes? \
MIME.from_filename? e.filename
}
.sort! { |a, b|
compare_numerically a.filename, b.filename
}
yield file, entries
end
end
def read_page(page_num)
raise "Unreadble archive. #{@err_msg}" if @err_msg
img = nil
begin
sorted_archive_entries do |file, entries|
page = entries[page_num - 1]
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename),
page.filename, data.size
end
end
rescue e
Logger.warn "Unable to read page #{page_num} of #{@zip_path}. Error: #{e}"
end
img
end
def page_dimensions
sizes = [] of Hash(String, Int32)
sorted_archive_entries do |file, entries|
entries.each_with_index do |e, i|
begin
data = file.read_entry(e).not_nil!
size = ImageSize.get data
sizes << {
"width" => size.width,
"height" => size.height,
}
rescue e
Logger.warn "Failed to read page #{i} of entry #{zip_path}. #{e}"
sizes << {"width" => 1000_i32, "height" => 1000_i32}
end
end
end
sizes
end
def next_entry(username)
entries = @book.sorted_entries username
idx = entries.index self
@@ -137,6 +194,20 @@ abstract class Entry
entries[idx - 1]
end
def date_added
date_added = nil
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime @zip_path
info.save
else
date_added = info_da
end
end
date_added.not_nil! # is it ok to set not_nil! here?
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, page)
@@ -216,7 +287,7 @@ abstract class Entry
end
Storage.default.save_thumbnail @id, img
rescue e
Logger.warn "Failed to generate thumbnail for file #{path}. #{e}"
Logger.warn "Failed to generate thumbnail for file #{@zip_path}. #{e}"
end
img
@@ -225,34 +296,4 @@ abstract class Entry
def get_thumbnail : Image?
Storage.default.get_thumbnail @id
end
def date_added : Time
date_added = Time::UNIX_EPOCH
TitleInfo.new @book.dir do |info|
info_da = info.date_added[@title]?
if info_da.nil?
date_added = info.date_added[@title] = ctime path
info.save
else
date_added = info_da
end
end
date_added
end
# Hack to have abstract class methods
# https://github.com/crystal-lang/crystal/issues/5956
private module ClassMethods
abstract def is_valid?(path : String) : Bool
end
macro inherited
extend ClassMethods
end
abstract def read_page(page_num)
abstract def page_dimensions
abstract def examine : Bool?
end

View File

@@ -139,31 +139,14 @@ class Library
titles.flat_map &.deep_entries
end
def build_json(*, slim = false, depth = -1, sort_context = nil,
percentage = false)
_titles = if sort_context
sorted_titles sort_context[:username],
sort_context[:opt]
else
self.titles
end
def build_json(*, slim = false, depth = -1)
JSON.build do |json|
json.object do
json.field "dir", @dir
json.field "titles" do
json.array do
_titles.each do |title|
json.raw title.build_json(slim: slim, depth: depth,
sort_context: sort_context, percentage: percentage)
end
end
end
if percentage && sort_context
json.field "title_percentages" do
json.array do
_titles.each do |title|
json.number title.load_percentage sort_context[:username]
end
self.titles.each do |title|
json.raw title.build_json(slim: slim, depth: depth)
end
end
end

View File

@@ -49,18 +49,13 @@ class Title
path = File.join dir, fn
if File.directory? path
title = Title.new path, @id, cache
unless title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
end
if DirEntry.is_valid? path
entry = DirEntry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
next
end
if is_supported_file path
entry = ArchiveEntry.new path, self
entry = Entry.new path, self
@entries << entry if entry.pages > 0 || entry.err_msg
end
end
@@ -132,12 +127,12 @@ class Title
previous_entries_size = @entries.size
@entries.select! do |entry|
existence = entry.examine
existence = File.exists? entry.zip_path
Fiber.yield
context["deleted_entry_ids"] << entry.id unless existence
existence
end
remained_entry_paths = @entries.map &.path
remained_entry_zip_paths = @entries.map &.zip_path
is_titles_added = false
is_entries_added = false
@@ -145,43 +140,29 @@ class Title
next if fn.starts_with? "."
path = File.join dir, fn
if File.directory? path
unless remained_entry_paths.includes? path
if DirEntry.is_valid? path
entry = DirEntry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
context["deleted_entry_ids"].select! do |deleted_entry_id|
entry.id != deleted_entry_id
end
end
end
end
next if remained_title_dirs.includes? path
title = Title.new path, @id, context["cached_contents_signature"]
unless title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
is_titles_added = true
next if title.entries.size == 0 && title.titles.size == 0
Library.default.title_hash[title.id] = title
@title_ids << title.id
is_titles_added = true
# We think they are removed, but they are here!
# Cancel reserved jobs
revival_title_ids = [title.id] + title.deep_titles.map &.id
context["deleted_title_ids"].select! do |deleted_title_id|
!(revival_title_ids.includes? deleted_title_id)
end
revival_entry_ids = title.deep_entries.map &.id
context["deleted_entry_ids"].select! do |deleted_entry_id|
!(revival_entry_ids.includes? deleted_entry_id)
end
# We think they are removed, but they are here!
# Cancel reserved jobs
revival_title_ids = [title.id] + title.deep_titles.map &.id
context["deleted_title_ids"].select! do |deleted_title_id|
!(revival_title_ids.includes? deleted_title_id)
end
revival_entry_ids = title.deep_entries.map &.id
context["deleted_entry_ids"].select! do |deleted_entry_id|
!(revival_entry_ids.includes? deleted_entry_id)
end
next
end
if is_supported_file path
next if remained_entry_paths.includes? path
entry = ArchiveEntry.new path, self
next if remained_entry_zip_paths.includes? path
entry = Entry.new path, self
if entry.pages > 0 || entry.err_msg
@entries << entry
is_entries_added = true
@@ -221,21 +202,7 @@ class Title
alias SortContext = NamedTuple(username: String, opt: SortOptions)
def build_json(*, slim = false, depth = -1,
sort_context : SortContext? = nil,
percentage = false)
_titles = if sort_context
sorted_titles sort_context[:username],
sort_context[:opt]
else
self.titles
end
_entries = if sort_context
sorted_entries sort_context[:username],
sort_context[:opt]
else
@entries
end
sort_context : SortContext? = nil)
JSON.build do |json|
json.object do
{% for str in ["dir", "title", "id"] %}
@@ -251,39 +218,25 @@ class Title
unless depth == 0
json.field "titles" do
json.array do
_titles.each do |title|
self.titles.each do |title|
json.raw title.build_json(slim: slim,
depth: depth > 0 ? depth - 1 : depth,
sort_context: sort_context, percentage: percentage)
depth: depth > 0 ? depth - 1 : depth)
end
end
end
json.field "entries" do
json.array do
_entries = if sort_context
sorted_entries sort_context[:username],
sort_context[:opt]
else
@entries
end
_entries.each do |entry|
json.raw entry.build_json(slim: slim)
end
end
end
if percentage && sort_context
json.field "title_percentages" do
json.array do
_titles.each do |t|
json.number t.load_percentage sort_context[:username]
end
end
end
json.field "entry_percentages" do
json.array do
load_percentage_for_all_entries(
sort_context[:username],
sort_context[:opt]
).each do |p|
json.number p.nan? ? 0 : p
end
end
end
end
end
json.field "parents" do
json.array do
@@ -632,16 +585,6 @@ class Title
if last_read_entry && last_read_entry.finished? username
last_read_entry = last_read_entry.next_entry username
if last_read_entry.nil?
# The last entry is finished. Return the first unfinished entry
# (if any)
sorted_entries(username).each do |e|
unless e.finished? username
last_read_entry = e
break
end
end
end
end
last_read_entry
@@ -656,7 +599,7 @@ class Title
@entries.each do |e|
next if da.has_key? e.title
da[e.title] = ctime e.path
da[e.title] = ctime e.zip_path
end
TitleInfo.new @dir do |info|

View File

@@ -1,3 +1,13 @@
SUPPORTED_IMG_TYPES = %w(
image/jpeg
image/png
image/webp
image/apng
image/avif
image/gif
image/svg+xml
)
enum SortMethod
Auto
Title
@@ -45,13 +55,6 @@ class SortOptions
def to_tuple
{@method.to_s.underscore, ascend}
end
def to_json
{
"method" => method.to_s.underscore,
"ascend" => ascend,
}.to_json
end
end
struct Image

View File

@@ -38,7 +38,6 @@ class Logger
Log.setup do |c|
c.bind "*", @@severity, @backend
c.bind "db.*", :error, @backend
c.bind "duktape", :none, @backend
end
end

View File

@@ -7,7 +7,7 @@ require "option_parser"
require "clim"
require "tallboy"
MANGO_VERSION = "0.27.0"
MANGO_VERSION = "0.25.0"
# From http://www.network-science.de/ascii/
BANNER = %{

View File

@@ -105,10 +105,9 @@ class Plugin
getter js_path = ""
getter storage_path = ""
def self.build_info_ary(dir : String? = nil)
def self.build_info_ary
@@info_ary.clear
dir ||= Config.current.plugin_path
dir = Config.current.plugin_path
Dir.mkdir_p dir unless Dir.exists? dir
Dir.each_child dir do |f|
@@ -161,8 +160,8 @@ class Plugin
list.save
end
def initialize(id : String, dir : String? = nil)
Plugin.build_info_ary dir
def initialize(id : String)
Plugin.build_info_ary
@info = @@info_ary.find &.id.== id
if @info.nil?
@@ -224,10 +223,6 @@ class Plugin
raise Error.new "Missing required fields in the Page type"
end
def can_subscribe? : Bool
info.version > 1 && eval_exists?("newChapters")
end
def search_manga(query : String)
if info.version == 1
raise Error.new "Manga searching is only available for plugins " \
@@ -320,7 +315,7 @@ class Plugin
json
end
def eval(str)
private def eval(str)
@rt.eval str
rescue e : Duktape::SyntaxError
raise SyntaxError.new e.message
@@ -332,15 +327,6 @@ class Plugin
JSON.parse eval(str).as String
end
private def eval_exists?(str) : Bool
@rt.eval str
true
rescue e : Duktape::ReferenceError
false
rescue e : Duktape::Error
raise Error.new e.message
end
private def def_helper_functions(sbx)
sbx.push_object
@@ -449,15 +435,9 @@ class Plugin
env = Duktape::Sandbox.new ptr
html = env.require_string 0
begin
parser = Myhtml::Parser.new html
str = parser.body!.children.first.inner_text
env.push_string str
rescue
env.push_string ""
end
str = XML.parse(html).inner_text
env.push_string str
env.call_success
end
sbx.put_prop_string -2, "text"
@@ -468,9 +448,8 @@ class Plugin
name = env.require_string 1
begin
parser = Myhtml::Parser.new html
attr = parser.body!.children.first.attribute_by name
env.push_string attr.not_nil!
attr = XML.parse(html).first_element_child.not_nil![name]
env.push_string attr
rescue
env.push_undefined
end

View File

@@ -1,5 +1,3 @@
require "sanitize"
struct AdminRouter
def initialize
get "/admin" do |env|
@@ -16,13 +14,13 @@ struct AdminRouter
end
get "/admin/user/edit" do |env|
sanitizer = Sanitize::Policy::Text.new
username = env.params.query["username"]?.try { |s| sanitizer.process s }
username = env.params.query["username"]?
admin = env.params.query["admin"]?
if admin
admin = admin == "true"
end
error = env.params.query["error"]?.try { |s| sanitizer.process s }
error = env.params.query["error"]?
current_user = get_username env
new_user = username.nil? && admin.nil?
layout "user-edit"
end

View File

@@ -40,19 +40,14 @@ struct APIRouter
Koa.schema "entry", {
"pages" => Int32,
"mtime" => Int64,
}.merge(s %w(zip_path path title size id title_id display_name cover_url)),
}.merge(s %w(zip_path title size id title_id display_name cover_url)),
desc: "An entry in a book"
Koa.schema "title", {
"mtime" => Int64,
"entries" => ["entry"],
"titles" => ["title"],
"parents" => [{
"title" => String,
"id" => String,
}],
"title_percentages" => [Float64?],
"entry_percentages" => [Float64?],
"parents" => [String],
}.merge(s %w(dir title id display_name cover_url)),
desc: "A manga title (a collection of entries and sub-titles)"
@@ -85,12 +80,6 @@ struct APIRouter
"username" => String,
"password" => String,
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"session_id" => String?,
"is_admin" => Bool?,
}
Koa.tag "users"
post "/api/login" do |env|
begin
@@ -99,18 +88,11 @@ struct APIRouter
token = Storage.default.verify_user(username, password).not_nil!
env.session.string "token", token
send_json env, {
"success" => true,
"session_id" => env.session.id,
"is_admin" => Storage.default.username_is_admin username,
}.to_json
"Authenticated"
rescue e
Logger.error e
env.response.status_code = 403
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
e.message
end
end
@@ -142,19 +124,14 @@ struct APIRouter
env.response.status_code = 304
""
else
if entry.is_a? DirEntry
cache_control = "no-cache, max-age=86400"
else
cache_control = "public, max-age=86400"
end
env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = cache_control
env.response.headers["Cache-Control"] = "public, max-age=86400"
send_img env, img
end
rescue e
Logger.error e
env.response.status_code = 500
send_text env, e.message
e.message
end
end
@@ -191,13 +168,11 @@ struct APIRouter
rescue e
Logger.error e
env.response.status_code = 500
send_text env, e.message
e.message
end
end
Koa.describe "Returns the book with title `tid`", <<-MD
The entries and titles will be sorted by the default sorting method for the logged-in user.
- Supply the `percentage` query parameter to include the reading progress
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
- Supply the `depth` query parameter to control the depth of nested titles to return.
- When `depth` is 1, returns the top-level titles and sub-titles/entries one level in them
@@ -208,7 +183,8 @@ struct APIRouter
Koa.path "tid", desc: "Title ID"
Koa.query "slim"
Koa.query "depth"
Koa.query "percentage"
Koa.query "sort", desc: "Sorting option for entries. Can be one of 'auto', 'title', 'progress', 'time_added' and 'time_modified'"
Koa.query "ascend", desc: "Sorting direction for entries. Set to 0 for the descending order. Doesn't work without specifying 'sort'"
Koa.response 200, schema: "title"
Koa.response 404, "Title not found"
Koa.tag "library"
@@ -216,104 +192,29 @@ struct APIRouter
begin
username = get_username env
sort_opt = SortOptions.new
get_sort_opt
tid = env.params.url["tid"]
title = Library.default.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
sort_opt = SortOptions.from_info_json title.dir, username
slim = !env.params.query["slim"]?.nil?
depth = env.params.query["depth"]?.try(&.to_i?) || -1
percentage = !env.params.query["percentage"]?.nil?
send_json env, title.build_json(slim: slim, depth: depth,
sort_context: {username: username,
opt: sort_opt}, percentage: percentage)
opt: sort_opt})
rescue e
Logger.error e
env.response.status_code = 404
send_text env, e.message
e.message
end
end
Koa.describe "Returns the sorting option of a title or the library", <<-MD
- If the query parameter `tid` is supplied, returns the sorting option of the title identified by the `tid`.
- If the query parameter `tid` is missing, returns the sorting option of the library.
MD
Koa.query "tid"
Koa.response 200, schema: {
"method" => String?,
"ascend" => Bool?,
"error" => String?,
}
Koa.tag "library"
get "/api/sort_opt" do |env|
username = get_username env
tid = env.params.query["tid"]?
dir = if tid
(Library.default.get_title tid).not_nil!.dir
else
Library.default.dir
end
sort_opt = SortOptions.from_info_json dir, username
send_json env, sort_opt.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Updates the sorting option of a title or the library", <<-MD
- When the `tid` field is supplied in the body, updates the sorting option of the title identified by the `tid`.
- When the `tid` field is missing in the body, updates the sorting option of the library.
MD
Koa.body schema: {
"tid" => String?,
"method" => String,
"ascend" => Bool,
}
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
}
Koa.tag "library"
put "/api/sort_opt" do |env|
username = get_username env
tid = env.params.json["tid"]?.try &.as String
dir = if tid
(Library.default.get_title tid).not_nil!.dir
else
Library.default.dir
end
method = env.params.json["sort"].as String
ascend = env.params.json["ascend"].as Bool
sort_opt = SortOptions.new method, ascend
TitleInfo.new dir do |info|
info.sort_by[username] = sort_opt.to_tuple
info.save
end
send_json env, {
"success" => true,
}.to_json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the entire library with all titles and entries", <<-MD
The titles will be sorted by the default sorting method for the logged-in user.
- Supply the `slim` query parameter to strip away "display_name", "cover_url", and "mtime" from the returned object to speed up the loading time
- Supply the `dpeth` query parameter to control the depth of nested titles to return.
- Supply the `percentage` query parameter to include the reading progress
- When `depth` is 1, returns the requested title and sub-titles/entries one level in it
- When `depth` is 0, returns the requested title without its sub-titles/entries
- When `depth` is N, returns the requested title and sub-titles/entries N levels in it
@@ -321,162 +222,16 @@ struct APIRouter
MD
Koa.query "slim"
Koa.query "depth"
Koa.query "percentage"
Koa.response 200, schema: {
"dir" => String,
"titles" => ["title"],
"title_percentage" => [Float64?],
"dir" => String,
"titles" => ["title"],
}
Koa.tag "library"
get "/api/library" do |env|
username = get_username env
sort_opt = SortOptions.from_info_json Library.default.dir, username
slim = !env.params.query["slim"]?.nil?
depth = env.params.query["depth"]?.try(&.to_i?) || -1
percentage = !env.params.query["percentage"]?.nil?
send_json env, Library.default.build_json(slim: slim, depth: depth,
sort_context: {username: username,
opt: sort_opt}, percentage: percentage)
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the continue reading entries"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"entries" => ["entry"],
"entry_percentages" => [Float64],
}
Koa.tag "library"
get "/api/library/continue_reading" do |env|
username = get_username env
cr_entries = Library.default.get_continue_reading_entries username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "entries" do
j.array do
cr_entries.each do |e|
j.raw e[:entry].build_json
end
end
end
j.field "entry_percentages" do
j.array do
cr_entries.each do |e|
j.number e[:percentage]
end
end
end
end
end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the start reading titles"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"titles" => ["title"],
}
Koa.tag "library"
get "/api/library/start_reading" do |env|
username = get_username env
titles = Library.default.get_start_reading_titles username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "titles" do
j.array do
titles.each do |t|
j.raw t.build_json depth: 1
end
end
end
end
end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
Koa.describe "Returns the recently added items"
Koa.response 200, schema: {
"success" => Bool,
"error" => String?,
"items" => [{
"item" => "title | entry",
"percentage" => Float64,
"count" => Int32,
}],
}
Koa.tag "library"
get "/api/library/recently_added" do |env|
username = get_username env
ra_entries = Library.default.get_recently_added_entries username
json = JSON.build do |j|
j.object do
j.field "success" do
j.bool true
end
j.field "items" do
j.array do
ra_entries.each do |e|
j.object do
j.field "item" do
if e[:grouped_count] === 1
j.raw e[:entry].build_json
else
j.raw e[:entry].book.build_json depth: 0
end
end
j.field "percentage" do
j.number e[:percentage]
end
j.field "count" do
j.number e[:grouped_count]
end
end
end
end
end
end
end
send_json env, json
rescue e
Logger.error e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
send_json env, Library.default.build_json(slim: slim, depth: depth)
end
Koa.describe "Triggers a library scan"
@@ -512,7 +267,6 @@ struct APIRouter
spawn do
Library.default.generate_thumbnails
end
send_text env, ""
end
Koa.describe "Deletes a user with `username`"
@@ -871,15 +625,13 @@ struct APIRouter
"version" => Int32,
"settings" => {} of String => String,
},
"subscribable" => Bool,
}
get "/api/admin/plugin/info" do |env|
begin
plugin = Plugin.new env.params.query["plugin"].as String
send_json env, {
"success" => true,
"info" => plugin.info,
"subscribable" => plugin.can_subscribe?,
"success" => true,
"info" => plugin.info,
}.to_json
rescue e
Logger.error e
@@ -1145,24 +897,15 @@ struct APIRouter
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if entry.nil?
if entry.is_a? DirEntry
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s + entry.size)
else
file_hash = Digest::SHA1.hexdigest(entry.path + entry.mtime.to_s)
end
file_hash = Digest::SHA1.hexdigest (entry.zip_path + entry.mtime.to_s)
e_tag = "W/#{file_hash}"
if e_tag == prev_e_tag
env.response.status_code = 304
send_text env, ""
""
else
sizes = entry.page_dimensions
if entry.is_a? DirEntry
cache_control = "no-cache, max-age=86400"
else
cache_control = "public, max-age=86400"
end
env.response.headers["ETag"] = e_tag
env.response.headers["Cache-Control"] = cache_control
env.response.headers["Cache-Control"] = "public, max-age=86400"
send_json env, {
"success" => true,
"dimensions" => sizes,
@@ -1188,11 +931,10 @@ struct APIRouter
title = (Library.default.get_title env.params.url["tid"]).not_nil!
entry = (title.get_entry env.params.url["eid"]).not_nil!
send_attachment env, entry.path
send_attachment env, entry.zip_path
rescue e
Logger.error e
env.response.status_code = 404
send_text env, e.message
end
end

View File

@@ -53,7 +53,6 @@ struct ReaderRouter
render "src/views/reader.html.ecr"
rescue e
Logger.error e
Logger.debug e.backtrace?
env.response.status_code = 404
end
end

View File

@@ -25,17 +25,6 @@ class Server
APIRouter.new
OPDSRouter.new
{% for path in %w(/api/* /uploads/* /img/*) %}
options {{path}} do |env|
cors
halt env
end
{% end %}
static_headers do |response|
response.headers.add("Access-Control-Allow-Origin", "*")
end
Kemal.config.logging = false
add_handler LogHandler.new
add_handler AuthHandler.new

View File

@@ -19,7 +19,7 @@ class File
# information as long as the above changes do not happen together with
# a file/folder rename, with no library scan in between.
def self.signature(filename) : UInt64
if ArchiveEntry.is_valid?(filename) || is_supported_image_file(filename)
if is_supported_file filename
File.info(filename).inode
else
0u64
@@ -67,9 +67,7 @@ class Dir
else
# Only add its signature value to `signatures` when it is a
# supported file
if ArchiveEntry.is_valid?(fn) || is_supported_image_file(fn)
signatures << fn
end
signatures << fn if is_supported_file fn
end
Fiber.yield
end
@@ -78,19 +76,4 @@ class Dir
cache[dirname] = hash
hash
end
def self.directory_entry_signature(dirname, cache = {} of String => String)
return cache[dirname + "?entry"] if cache[dirname + "?entry"]?
Fiber.yield
signatures = [] of String
image_files = DirEntry.sorted_image_files dirname
if image_files.size > 0
image_files.each do |path|
signatures << File.signature(path).to_s
end
end
hash = Digest::SHA1.hexdigest(signatures.join)
cache[dirname + "?entry"] = hash
hash
end
end

View File

@@ -1,19 +1,8 @@
IMGS_PER_PAGE = 5
ENTRIES_IN_HOME_SECTIONS = 8
UPLOAD_URL_PREFIX = "/uploads"
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt
/manifest.json)
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
SUPPORTED_IMG_TYPES = %w(
image/jpeg
image/png
image/webp
image/apng
image/avif
image/gif
image/svg+xml
image/jxl
)
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
def random_str
UUID.random.to_s.gsub "-", ""
@@ -51,7 +40,6 @@ def register_mime_types
# defiend by Crystal in `MIME.DEFAULT_TYPES`
".apng" => "image/apng",
".avif" => "image/avif",
".jxl" => "image/jxl",
}.each do |k, v|
MIME.register k, v
end
@@ -61,10 +49,6 @@ def is_supported_file(path)
SUPPORTED_FILE_EXTNAMES.includes? File.extname(path).downcase
end
def is_supported_image_file(path)
SUPPORTED_IMG_TYPES.includes? MIME.from_filename? path
end
struct Int
def or(other : Int)
if self == 0
@@ -96,9 +80,9 @@ class String
end
end
def env_is_true?(key : String, default : Bool = false) : Bool
def env_is_true?(key : String) : Bool
val = ENV[key.upcase]? || ENV[key.downcase]?
return default unless val
return false unless val
val.downcase.in? "1", "true"
end

View File

@@ -39,28 +39,13 @@ macro send_error_page(msg)
end
macro send_img(env, img)
cors
send_file {{env}}, {{img}}.data, {{img}}.mime
end
def get_token_from_auth_header(env) : String?
value = env.request.headers["Authorization"]
if value && value.starts_with? "Bearer"
session_id = value.split(" ")[1]
return Kemal::Session.get(session_id).try &.string? "token"
end
end
macro get_username(env)
begin
# Check if we can get the session id from the cookie
token = env.session.string? "token"
if token.nil?
# If not, check if we can get the session id from the auth header
token = get_token_from_auth_header env
end
# If we still don't have a token, we handle it in `resuce` with `not_nil!`
(Storage.default.verify_token token.not_nil!).not_nil!
token = env.session.string "token"
(Storage.default.verify_token token).not_nil!
rescue e
if Config.current.disable_login
Config.current.default_username
@@ -72,29 +57,12 @@ macro get_username(env)
end
end
macro cors
env.response.headers["Access-Control-Allow-Methods"] = "HEAD,GET,PUT,POST," \
"DELETE,OPTIONS"
env.response.headers["Access-Control-Allow-Headers"] = "X-Requested-With," \
"X-HTTP-Method-Override, Content-Type, Cache-Control, Accept," \
"Authorization"
env.response.headers["Access-Control-Allow-Origin"] = "*"
end
def send_json(env, json)
cors
env.response.content_type = "application/json"
env.response.print json
end
def send_text(env, text)
cors
env.response.content_type = "text/plain"
env.response.print text
end
def send_attachment(env, path)
cors
send_file env, path, filename: File.basename(path), disposition: "attachment"
end

View File

@@ -29,7 +29,7 @@
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.path %>" />
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>api/download/<%= e.book.id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.book.id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.book.id %>" />

View File

@@ -133,10 +133,8 @@
</template>
<button class="uk-button uk-button-primary" @click.prevent="applyFilters()">Apply</button>
<button class="uk-button uk-button-default" @click.prevent="clearFilters()">Clear</button>
<span x-show="subscribable">
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
</span>
<span class="uk-divider-vertical uk-margin-left uk-margin-right"></span>
<button class="uk-button uk-button-default" @click.prevent="UIkit.modal($refs.modal).show()" :disable="subscribing">Subscribe</button>
</form>
<p class="uk-text-meta" x-show="chapters && chapters.length > chaptersLimit" x-text="`The manga has ${chapters ? chapters.length : 0} chapters, but Mango can only list up to ${chaptersLimit}. Please use the filters to narrow down your search.`"></p>

View File

@@ -5,7 +5,7 @@
<div>
<h3 class="uk-modal-title uk-margin-remove-top">Error</h3>
</div>
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.path %></p>
<p class="uk-text-meta uk-margin-remove-bottom"><%= entry.zip_path %></p>
<p class="uk-text-meta uk-margin-remove-top"><%= entry.err_msg %></p>
</div>
<div class="uk-modal-body">

View File

@@ -5,7 +5,7 @@
<%= render_component "head" %>
<body style="position:relative;" x-data="readerComponent()" x-init="init($nextTick)" @resize.window="resized()">
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0; position: relative;'">
<div class="uk-section uk-section-default uk-section-small reader-bg" :style="mode === 'continuous' ? '' : 'padding:0'">
<div @keydown.window.debounce="keyHandler($event)"></div>
@@ -19,7 +19,7 @@
</div>
<div
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}" style="width: fit-content;">
:class="{'uk-container': true, 'uk-container-small': mode === 'continuous', 'uk-container-expand': mode !== 'continuous'}">
<div x-show="!loading && mode === 'continuous'" x-cloak>
<template x-if="!loading && mode === 'continuous'" x-for="item in items">
<img
@@ -30,7 +30,7 @@
:height="item.height"
:id="item.id"
:style="`margin-top:${margin}px; margin-bottom:${margin}px`"
@click="clickImage($event)"
@click="showControl($event)"
/>
</template>
<%- if next_entry_url -%>
@@ -40,18 +40,18 @@
<%- end -%>
</div>
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" :style="`height:${fitType === 'vert' ? '100vh' : ''}; min-width: fit-content;`">
<div x-cloak x-show="!loading && mode !== 'continuous'" class="uk-flex uk-flex-middle" style="height:100vh">
<img uk-img :class="{
'uk-align-center': true,
'uk-animation-slide-left': flipAnimation === 'left',
'uk-animation-slide-right': flipAnimation === 'right'
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="clickImage($event)" :style="`
width:${fitType === 'horz' ? '100vw' : 'auto'};
height:${fitType === 'vert' ? '100vh' : 'auto'};
}" :data-src="curItem.url" :width="curItem.width" :height="curItem.height" :id="curItem.id" @click="showControl($event)" :style="`
width:${mode === 'width' ? '100vw' : 'auto'};
height:${mode === 'height' ? '100vh' : 'auto'};
margin-bottom:0;
max-width:${fitType === 'horz' ? '100%' : fitType === 'vert' ? '' : 'none' };
max-height:${fitType === 'vert' ? '100%' : fitType === 'horz' ? '' : 'none'};
max-width:100%;
max-height:100%;
object-fit: contain;
`" />
@@ -67,7 +67,7 @@
<button class="uk-modal-close-default" type="button" uk-close></button>
<div class="uk-modal-header">
<h3 class="uk-modal-title break-word"><%= entry.display_name %></h3>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.path %></p>
<p class="uk-text-meta uk-margin-remove-bottom break-word"><%= entry.zip_path %></p>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
@@ -94,17 +94,6 @@
</div>
</div>
<div class="uk-margin" x-show="mode !== 'continuous'">
<label class="uk-form-label" for="mode-select">Page fit</label>
<div class="uk-form-controls">
<select id="fit-select" class="uk-select" @change="fitChanged()">
<option value="vert">Fit height</option>
<option value="horz">Fit width</option>
<option value="real">Real size</option>
</select>
</div>
</div>
<div class="uk-margin" x-show="mode === 'continuous'">
<label class="uk-form-label" for="margin-range" x-text="`Page Margin: ${margin}px`"></label>
<div class="uk-form-controls">