Compare commits

..

51 Commits

Author SHA1 Message Date
Alex Ling
a0e5a03052 DRY html modal and head 2020-06-09 10:34:24 +00:00
Alex Ling
e53641add1 Handle the rare case when renamed string is ".." 2020-06-09 09:42:28 +00:00
Alex Ling
45cdfd5306 Merge branch 'fix/mangadex-slash' into dev 2020-06-09 09:31:17 +00:00
Alex Ling
3d352ed062 Add test for slash escaping 2020-06-09 09:28:37 +00:00
Alex Ling
bac7be5163 Escape slash in filename when downloading (#62) 2020-06-09 09:25:20 +00:00
Alex Ling
717d44e029 Refactor get_recently_added_entries method 2020-06-09 05:37:10 +00:00
Alex Ling
8da4475a74 Remove duplicate title ID (#56) 2020-06-08 15:55:40 +00:00
Alex Ling
680504779f Use component template on home page 2020-06-08 15:51:42 +00:00
Alex Ling
926d0e66a5 Formatting 2020-06-08 15:29:05 +00:00
Alex Ling
0f3dd51d6b Respect base URL 2020-06-08 15:24:35 +00:00
Alex Ling
53c3798691 Merge branch 'feature/home' into dev 2020-06-08 15:11:09 +00:00
Jared Turner
6d4e8ea544 Show config path for empty libraries and link to Admin for manual re-scan 2020-06-08 15:24:51 +01:00
Jared Turner
0bd94a2290 Add config path to Config 2020-06-08 15:24:17 +01:00
Jared Turner
cff599f688 refactor get_recently_added_entries, new_user and empty_library 2020-06-08 15:23:36 +01:00
Jared Turner
fa85d9834f Onboarding for new libraries and new users 2020-06-07 18:40:31 +01:00
Jared Turner
aaf0a3c6af Group Recently Added by neighbouring Title 2020-06-07 18:39:05 +01:00
Jared Turner
5ed2a8affa Add Library link to mobile nav 2020-06-07 18:36:51 +01:00
Alex Ling
de690fbf29 Store token and callback URI in memory session 2020-06-07 16:18:34 +00:00
Alex Ling
12c3c3f356 Bump version to v0.6.0 2020-06-06 15:45:44 +00:00
Alex Ling
1ddcabcc12 Use component templates 2020-06-06 12:00:02 +00:00
Alex Ling
8b04f2c96b Remove comment in the OPDS xml file [skip ci] 2020-06-05 16:41:55 +00:00
Alex Ling
66e2fc138a Mention OPDS support in README [skip ci] 2020-06-05 16:15:55 +00:00
Alex Ling
6817113523 Clean up 2020-06-05 15:25:41 +00:00
Alex Ling
6ad4385b18 Respect base URL in OPDS feed 2020-06-05 15:18:46 +00:00
Alex Ling
012fd71ab4 Use a helper function to set token cookie 2020-06-05 14:31:12 +00:00
Alex Ling
373ff6520a Merge branch 'feature/opds' into dev 2020-06-05 14:28:36 +00:00
Alex Ling
8a0e9250c8 Finish OPDS 2020-06-05 14:21:47 +00:00
Alex Ling
871a5fe755 Add render_xml helper function 2020-06-05 14:21:47 +00:00
Alex Ling
1493c3de90 Set token cookie after successful basic auth 2020-06-05 14:21:47 +00:00
Jared Turner
808074e478 Add Recently Added to home 2020-06-05 15:13:19 +01:00
Jared Turner
49193b9b00 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-06-04 19:44:07 +01:00
jaredlt
1cb470fb2d Merge pull request #57 from hkalexling/feature/home-ctime
Add `ctime` helper function
2020-06-04 19:43:46 +01:00
Alex Ling
e443176a79 Add ctime helper function 2020-06-04 16:31:49 +00:00
Jared Turner
4f5e05c008 refactor continue reading into Library class 2020-06-03 13:48:49 +01:00
Jared Turner
13c0878357 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-06-01 15:29:36 +01:00
Jared Turner
3ef6a7bfc4 continue reading sorted by last read 2020-06-01 15:29:18 +01:00
Alex Ling
60100c51fe Add send_attachment function for direct download 2020-06-01 13:21:10 +00:00
Alex Ling
27c111d273 Handle basic auth for OPDS 2020-06-01 13:20:05 +00:00
Alex Ling
4841f90cc1 Remove edit buttons from home 2020-05-29 15:51:01 +00:00
Jared Turner
e99d7b8b29 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-05-29 13:31:00 +01:00
Jared Turner
d2ad7fef77 WIP last_read property for Entries 2020-05-29 13:26:47 +01:00
Jared Turner
ddb6a860ae add 'jump to title' to home modal 2020-05-24 10:35:35 +01:00
Alex Ling
6a9105605d Fix library link in the breadcrumb menu 2020-05-23 12:16:08 +00:00
Alex Ling
c74a01f546 Remove unnecessary JS files from home.ecr 2020-05-21 09:10:46 +00:00
Alex Ling
2aeb38a271 Remove edit button from home screen 2020-05-21 09:06:50 +00:00
Jared Turner
a2c7638141 refactor on deck to continue reading and show percentages on home 2020-05-20 10:38:23 +01:00
Alex Ling
c35e840694 Refactor the / route 2020-05-19 12:16:32 +00:00
Alex Ling
ff6e64f12a Refactor get_on_deck_entry 2020-05-19 12:05:15 +00:00
Jared Turner
16fa27e4f6 update comments 2020-05-18 21:09:11 +01:00
Jared Turner
16734c2c59 rename root to library and add home with on deck WIP 2020-05-18 21:06:14 +01:00
Jared Turner
392b3d8339 fix load_percetage method name typo 2020-05-18 20:32:09 +01:00
27 changed files with 683 additions and 227 deletions

View File

@@ -1,6 +1,3 @@
![banner](./public/img/banner-paddings.png)
# Mango
@@ -10,6 +7,7 @@
Mango is a self-hosted manga server and reader. Its features include
- Multi-user support
- OPDS support
- Dark/light mode switch
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- Supports nested folders in library
@@ -50,7 +48,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango - Manga Server and Web Reader. Version 0.5.2
Mango - Manga Server and Web Reader. Version 0.6.0
Usage:

View File

@@ -2,33 +2,36 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function(){
$('#modal button, #modal a').each(function() {
$(this).removeAttr('hidden');
});
if (percentage === 0) {
$('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', '');
}
else {
} else {
$('#continue-btn').text('Continue from ' + percentage + '%');
}
if (percentage === 100) {
$('#read-btn').attr('hidden', '');
}
$('#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);
$('#modal-title-link').text(title);
$('#modal-title-link').attr('href', `${base_url}book/${titleID}`);
$('#modal-entry-title').find('span').text(entry);
$('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-entry-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
$('#read-btn').click(function(){
$('#read-btn').click(function() {
updateProgress(titleID, entryID, pages);
});
$('#unread-btn').click(function(){
$('#unread-btn').click(function() {
updateProgress(titleID, entryID, 0);
});
@@ -40,14 +43,15 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({entry: eid});
const query = $.param({
entry: eid
});
if (eid)
url += `?${query}`;
$.post(url, (data) => {
if (data.success) {
location.reload();
}
else {
} else {
error = data.error;
alert('danger', error);
}
@@ -65,27 +69,29 @@ const renameSubmit = (name, eid) => {
return;
}
const query = $.param({ entry: eid });
const query = $.param({
entry: eid
});
let url = `${base_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}`);
});
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) => {
@@ -98,8 +104,7 @@ const edit = (eid) => {
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
$('#title-progress-control').attr('hidden', '');
}
else {
} else {
$('#title-progress-control').removeAttr('hidden');
}
@@ -126,7 +131,9 @@ const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {title: titleId};
const queryObj = {
title: titleId
};
if (eid)
queryObj['entry'] = eid;
const query = $.param(queryObj);

View File

@@ -28,6 +28,10 @@ shards:
github: kemalcr/kemal
version: 0.26.1
kemal-session:
github: kemalcr/kemal-session
version: 0.12.1
kilt:
github: jeromegn/kilt
version: 0.4.0

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.5.2
version: 0.6.0
authors:
- Alex Ling <hkalexling@gmail.com>
@@ -15,6 +15,8 @@ license: MIT
dependencies:
kemal:
github: kemalcr/kemal
kemal-session:
github: kemalcr/kemal-session
sqlite3:
github: crystal-lang/crystal-sqlite3
baked_file_system:

View File

@@ -68,4 +68,9 @@ describe Rule do
.should eq "Ch. CH ID testing"
rule.render({} of String => String).should eq "testing"
end
it "escapes slash" do
rule = Rule.new "{id}"
rule.render({"id" => "/hello/world"}).should eq "_hello_world"
end
end

View File

@@ -3,8 +3,11 @@ require "yaml"
class Config
include YAML::Serializable
@[YAML::Field(ignore: true)]
property path : String = ""
property port : Int32 = 9000
property base_url : String = "/"
property session_secret : String = "mango-session-secret"
property library_path : String = File.expand_path "~/mango/library",
home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true
@@ -43,6 +46,7 @@ class Config
if File.exists? cfg_path
config = self.from_yaml File.read cfg_path
config.preprocess
config.path = path
config.fill_defaults
return config
end
@@ -53,6 +57,7 @@ class Config
abort "Aborting..."
end
default = self.allocate
default.path = path
default.fill_defaults
cfg_dir = File.dirname cfg_path
unless Dir.exists? cfg_dir

View File

@@ -3,25 +3,87 @@ require "../storage"
require "../util"
class AuthHandler < Kemal::Handler
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
BASIC = "Basic"
AUTH = "Authorization"
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
"You have to login with proper credentials"
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
def initialize(@storage : Storage)
end
def call(env)
def require_basic_auth(env)
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
call_next env
end
def validate_token(env)
token = env.session.string? "token"
!token.nil? && @storage.verify_token token
end
def validate_token_admin(env)
token = env.session.string? "token"
!token.nil? && @storage.verify_admin token
end
def validate_auth_header(env)
if env.request.headers[AUTH]?
if value = env.request.headers[AUTH]
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
end
end
false
end
def verify_user(value)
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
.split(":")
@storage.verify_user username, password
end
def handle_opds_auth(env)
if validate_token(env) || validate_auth_header(env)
call_next env
else
env.response.status_code = 401
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
env.response.print AUTH_MESSAGE
end
end
def handle_auth(env)
return call_next(env) if request_path_startswith env, ["/login", "/logout"]
cookie = env.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end
if cookie.nil? || !@storage.verify_token cookie.value
unless validate_token env
env.session.string "callback", env.request.path
return redirect env, "/login"
end
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
unless @storage.verify_admin cookie.value
unless validate_token_admin env
env.response.status_code = 403
end
end
call_next env
end
def call(env)
if request_path_startswith env, ["/opds"]
handle_opds_auth env
else
handle_auth env
end
end
end

View File

@@ -17,7 +17,8 @@ end
class Entry
property zip_path : String, book : Title, title : String,
size : String, pages : Int32, id : String, title_id : String,
encoded_path : String, encoded_title : String, mtime : Time
encoded_path : String, encoded_title : String, mtime : Time,
date_added : Time
def initialize(path, @book, @title_id, storage)
@zip_path = path
@@ -33,6 +34,7 @@ class Entry
file.close
@id = storage.get_id @zip_path, false
@mtime = File.info(@zip_path).modification_time
@date_added = load_date_added
end
def to_json(json : JSON::Builder)
@@ -87,6 +89,20 @@ class Entry
end
img
end
private def load_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
end
class Title
@@ -289,6 +305,12 @@ class Title
else
info.progress[username][entry] = page
end
# save last_read timestamp
if info.last_read[username]?.nil?
info.last_read[username] = {entry => Time.utc}
else
info.last_read[username][entry] = Time.utc
end
info.save
end
end
@@ -304,14 +326,14 @@ class Title
progress
end
def load_percetage(username, entry)
def load_percentage(username, entry)
page = load_progress username, entry
entry_obj = @entries.find { |e| e.title == entry }
return 0.0 if entry_obj.nil?
page / entry_obj.pages
end
def load_percetage(username)
def load_percentage(username)
return 0.0 if @entries.empty?
read_pages = total_pages = 0
@entries.each do |e|
@@ -321,11 +343,54 @@ class Title
read_pages / total_pages
end
def load_last_read(username, entry)
last_read = nil
TitleInfo.new @dir do |info|
unless info.last_read[username]?.nil? ||
info.last_read[username][entry]?.nil?
last_read = info.last_read[username][entry]
end
end
last_read
end
def next_entry(current_entry_obj)
idx = @entries.index current_entry_obj
return nil if idx.nil? || idx == @entries.size - 1
@entries[idx + 1]
end
def previous_entry(current_entry_obj)
idx = @entries.index current_entry_obj
return nil if idx.nil? || idx == 0
@entries[idx - 1]
end
def get_continue_reading_entry(username)
in_progress_entries = @entries.select do |e|
load_progress(username, e.title) > 0
end
return nil if in_progress_entries.empty?
latest_read_entry = in_progress_entries[-1]
if load_progress(username, latest_read_entry.title) ==
latest_read_entry.pages
next_entry latest_read_entry
else
latest_read_entry
end
end
# TODO: More concise title?
def get_last_read_for_continue_reading(username, entry_obj)
last_read = load_last_read username, entry_obj.title
# grab from previous entry if current entry hasn't been started yet
if last_read.nil?
previous_entry = previous_entry(entry_obj)
return load_last_read username, previous_entry.title if previous_entry
end
last_read
end
end
class TitleInfo
@@ -337,6 +402,8 @@ class TitleInfo
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
property last_read = {} of String => Hash(String, Time)
property date_added = {} of String => Time
@[JSON::Field(ignore: true)]
property dir : String = ""
@@ -440,4 +507,91 @@ class Library
end
Logger.debug "Scan completed"
end
def get_continue_reading_entries(username)
# map: get the continue-reading entry or nil for each Title
# select: select only entries (and ignore Nil's) from the array
# produced by map
continue_reading_entries = titles.map { |t|
get_continue_reading_entry username, t
}.select Entry
continue_reading = continue_reading_entries.map { |e|
{
entry: e,
percentage: e.book.load_percentage(username, e.title),
last_read: get_relevant_last_read(username, e),
}
}
# Sort by by last_read, most recent first (nils at the end)
continue_reading.sort! { |a, b|
next 0 if a[:last_read].nil? && b[:last_read].nil?
next 1 if a[:last_read].nil?
next -1 if b[:last_read].nil?
b[:last_read].not_nil! <=> a[:last_read].not_nil!
}[0..11]
end
def get_recently_added_entries(username)
# Get all entries added within the last three months
entries = titles.map { |t| t.entries }
.flatten
.select { |e| e.date_added > 3.months.ago }
# Group entries in a Hash by title ID
grouped_entries = {} of String => Array(Entry)
entries.each do |e|
if grouped_entries.has_key? e.title_id
grouped_entries[e.title_id].push e
else
grouped_entries[e.title_id] = [e]
end
end
# Cast the Hash to an Array of Tuples and sort it by date_added
grouped_ary = grouped_entries.to_a.sort do |a, b|
date_added_a = a[1].map { |e| e.date_added }.max
date_added_b = b[1].map { |e| e.date_added }.max
date_added_b <=> date_added_a
end
recently_added = grouped_ary.map do |_, ary|
# Get the most recently added entry in the group
entry = ary.sort { |a, b| a.date_added <=> b.date_added }.last
{
entry: entry,
percentage: entry.book.load_percentage(username, entry.title),
grouped_count: ary.size,
}
end
recently_added[0..11]
end
private def get_continue_reading_entry(username, title)
in_progress_entries = title.entries.select do |e|
title.load_progress(username, e.title) > 0
end
return nil if in_progress_entries.empty?
latest_read_entry = in_progress_entries[-1]
if title.load_progress(username, latest_read_entry.title) ==
latest_read_entry.pages
title.next_entry latest_read_entry
else
latest_read_entry
end
end
private def get_relevant_last_read(username, entry_obj)
last_read = entry_obj.book.load_last_read username, entry_obj.title
# grab from previous entry if current entry hasn't been started yet
if last_read.nil?
previous_entry = entry_obj.book.previous_entry(entry_obj)
return entry_obj.book.load_last_read username, previous_entry.title \
if previous_entry
end
last_read
end
end

View File

@@ -4,7 +4,7 @@ require "./mangadex/*"
require "option_parser"
require "clim"
MANGO_VERSION = "0.5.2"
MANGO_VERSION = "0.6.0"
macro common_option
option "-c PATH", "--config=PATH", type: String,

View File

@@ -129,13 +129,19 @@ module Rename
end
def render(hash : VHash)
@ary.map do |e|
str = @ary.map do |e|
if e.is_a? String
e
else
e.render hash
end
end.join.strip
post_process str
end
private def post_process(str)
return "_" if str == ".."
str.gsub "/", "_"
end
end
end

View File

@@ -9,10 +9,7 @@ class MainRouter < Router
get "/logout" do |env|
begin
cookie = env.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end.not_nil!
@context.storage.logout cookie.value
env.session.delete_string "token"
rescue e
@context.error "Error when attempting to log out: #{e}"
ensure
@@ -26,22 +23,26 @@ class MainRouter < Router
password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil!
cookie = HTTP::Cookie.new "token-#{Config.current.port}", token
cookie.path = Config.current.base_url
cookie.expires = Time.local.shift years: 1
env.response.cookies << cookie
redirect env, "/"
env.session.string "token", token
callback = env.session.string? "callback"
if callback
env.session.delete_string "callback"
redirect env, callback
else
redirect env, "/"
end
rescue
redirect env, "/login"
end
end
get "/" do |env|
get "/library" do |env|
begin
titles = @context.library.titles
username = get_username env
percentage = titles.map &.load_percetage username
layout "index"
percentage = titles.map &.load_percentage username
layout "library"
rescue e
@context.error e
env.response.status_code = 500
@@ -53,7 +54,7 @@ class MainRouter < Router
title = (@context.library.get_title env.params.url["title"]).not_nil!
username = get_username env
percentage = title.entries.map { |e|
title.load_percetage username, e.title
title.load_percentage username, e.title
}
layout "title"
rescue e
@@ -66,5 +67,21 @@ class MainRouter < Router
mangadex_base_url = Config.current.mangadex["base_url"]
layout "download"
end
get "/" do |env|
begin
username = get_username env
continue_reading = @context
.library.get_continue_reading_entries username
recently_added = @context.library.get_recently_added_entries username
titles = @context.library.titles
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
empty_library = titles.size == 0
layout "home"
rescue e
@context.error e
env.response.status_code = 500
end
end
end
end

32
src/routes/opds.cr Normal file
View File

@@ -0,0 +1,32 @@
require "./router"
class OPDSRouter < Router
def initialize
get "/opds" do |env|
titles = @context.library.titles
render_xml "src/views/opds/index.ecr"
end
get "/opds/book/:title_id" do |env|
begin
title = @context.library.get_title(env.params.url["title_id"]).not_nil!
render_xml "src/views/opds/title.ecr"
rescue e
@context.error e
env.response.status_code = 404
end
end
get "/opds/download/:title/:entry" do |env|
begin
title = (@context.library.get_title env.params.url["title"]).not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
send_attachment env, entry.zip_path
rescue e
@context.error e
env.response.status_code = 404
end
end
end
end

View File

@@ -1,4 +1,5 @@
require "kemal"
require "kemal-session"
require "./library"
require "./handlers/*"
require "./util"
@@ -53,6 +54,7 @@ class Server
AdminRouter.new
ReaderRouter.new
APIRouter.new
OPDSRouter.new
Kemal.config.logging = false
add_handler LogHandler.new
@@ -64,6 +66,13 @@ class Server
serve_static false
add_handler StaticHandler.new
{% end %}
Kemal::Session.config do |c|
c.timeout = 365.days
c.secret = Config.current.session_secret
c.cookie_name = "mango-sessid-#{Config.current.port}"
c.path = Config.current.base_url
end
end
def start

View File

@@ -6,13 +6,11 @@ UPLOAD_URL_PREFIX = "/uploads"
macro layout(name)
base_url = Config.current.base_url
begin
cookie = env.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end
is_admin = false
unless cookie.nil?
is_admin = @context.storage.verify_admin cookie.value
if token = env.session.string? "token"
is_admin = @context.storage.verify_admin token
end
page = {{name}}
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
rescue e
message = e.to_s
@@ -28,10 +26,8 @@ end
macro get_username(env)
# if the request gets here, it has gone through the auth handler, and
# we can be sure that a valid token exists, so we can use not_nil! here
cookie = {{env}}.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end.not_nil!
(@context.storage.verify_token cookie.value).not_nil!
token = env.session.string "token"
(@context.storage.verify_token token).not_nil!
end
def send_json(env, json)
@@ -39,6 +35,12 @@ def send_json(env, json)
env.response.print json
end
def send_attachment(env, path)
MIME.register ".cbz", "application/vnd.comicbook+zip"
MIME.register ".cbr", "application/vnd.comicbook-rar"
send_file env, path, filename: File.basename(path), disposition: "attachment"
end
def hash_to_query(hash)
hash.map { |k, v| "#{k}=#{v}" }.join("&")
end
@@ -125,3 +127,25 @@ def validate_password(password)
raise "password should contain ASCII characters only"
end
end
macro render_xml(path)
base_url = Config.current.base_url
send_file env, ECR.render({{path}}).to_slice, "application/xml"
end
macro render_component(filename)
render "src/views/components/#{{{filename}}}.ecr"
end
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
def ctime(file_path : String) : Time
res = LibC.stat(file_path, out stat)
raise "Unable to get ctime of file #{file_path}" if res != 0
{% if flag?(:darwin) %}
Time.new stat.st_ctimespec, Time::Location::UTC
{% else %}
Time.new stat.st_ctim, Time::Location::UTC
{% end %}
end

View File

@@ -0,0 +1,49 @@
<% if item.is_a? NamedTuple(entry: Entry, percentage: Float64, grouped_count: Int32) %>
<% grouped_count = item[:grouped_count] %>
<% if grouped_count == 1 %>
<% item = item[:entry] %>
<% else %>
<% item = item[:entry].book %>
<% end %>
<% else %>
<% grouped_count = 1 %>
<% end %>
<div class="item" data-mtime="<%= item.mtime.to_unix %>" data-progress="<%= progress || 0.0 %>"
<% if item.is_a? Entry %>
id="<%= item.id %>"
<% end %>>
<a class="acard"
<% unless item.is_a? Entry %>
href="<%= base_url %>book/<%= item.id %>"
<% end %>>
<div class="uk-card uk-card-default"
<% if item.is_a? Entry %>
onclick="showModal(&quot;<%= item.encoded_path %>&quot;, '<%= item.pages %>', <%= (progress.not_nil! * 100).round(1) %>, &quot;<%= item.book.encoded_display_name %>&quot;, &quot;<%= item.encoded_display_name %>&quot;, '<%= item.title_id %>', '<%= item.id %>')"
<% end %>>
<div class="uk-card-media-top">
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<% unless (item.is_a? Title && item.entries.size == 0) || progress.nil? %>
<div class="uk-card-badge uk-label"><%= (progress * 100).round(1) %>%</div>
<% end %>
<h3 class="uk-card-title break-word" data-title="<%= item.display_name.gsub("\"", "&quot;") %>"><%= item.display_name %></h3>
<% if item.is_a? Entry %>
<p><%= item.pages %> pages</p>
<% end %>
<% if item.is_a? Title %>
<% if grouped_count == 1 %>
<p><%= item.size %> entries</p>
<% else %>
<p><%= grouped_count %> new entries</p>
<% end %>
<% end %>
</div>
</div>
</a>
</div>

View File

@@ -0,0 +1,34 @@
<div id="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>
<% if page == "home" %>
<h4 class="uk-margin-remove-bottom"><a id="modal-title-link"></a></h4>
<% end %>
<h3 class="uk-modal-title break-word uk-margin-remove-top" id="modal-entry-title"><span></span>
<% unless page == "home" %>
&nbsp;
<% if is_admin %>
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
<% 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-top" id="pages-text"></p>
</div>
<div class="uk-modal-body">
<p>Read</p>
<p uk-margin>
<a id="beginning-btn" class="uk-button uk-button-default">From beginning</a>
<a id="continue-btn" class="uk-button uk-button-primary"></a>
</p>
<p>Progress</p>
<p uk-margin>
<button id="read-btn" class="uk-button uk-button-default">Mark as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default">Mark as unread (0%)</button>
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico">
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="<%= base_url %>js/theme.js"></script>
</head>

View File

@@ -0,0 +1,8 @@
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<% hash.each do |k, v| %>
<option id="<%= k %>-up">â–˛ <%= v %></option>
<option id="<%= k %>-down">â–Ľ <%= v %></option>
<% end %>
</select>
</div>

69
src/views/home.ecr Normal file
View File

@@ -0,0 +1,69 @@
<%- if new_user && empty_library -%>
<div class="uk-container uk-text-center">
<i class="fas fa-plus" style="font-size: 80px;"></i>
<h2>Add your first manga</h2>
<p style="margin-bottom: 40px;">We can't find any files yet. Add some to your library and they'll appear here.</p>
<dl class="uk-description-list">
<dt style="font-weight: 500;">Current library path</dt>
<dd><code><%= Config.current.library_path %></code></dd>
<dt style="font-weight: 500;">Want to change your library path?</dt>
<dd>Update <code>config.yml</code> located at: <code><%= Config.current.path %></code></dd>
<dt style="font-weight: 500;">Can't see your files yet?</dt>
<dd>You must wait <%= Config.current.scan_interval %> minutes for the library scan to complete
<% if is_admin %>, or manually re-scan from <a href="<%= base_url %>admin">Admin</a><% end %>.</dd>
</dl>
</div>
<%- elsif new_user && empty_library == false -%>
<div class="uk-container uk-text-center">
<i class="fas fa-book-open" style="font-size: 80px;"></i>
<h2>Read your first manga</h2>
<p>Once you start reading, Mango will remember where you left off
and show your entries here.</p>
<a href="<%= base_url %>library" class="uk-button uk-button-default">View library</a>
</div>
<%- elsif new_user == false && empty_library == false -%>
<%- if continue_reading.empty? && recently_added.empty? -%>
<div class="uk-container uk-text-center">
<img src="<%= base_url %>img/banner.png" style="max-width: 400px; padding: 0 20px;">
<p>A self-hosted manga server and reader</p>
<a href="<%= base_url %>library" class="uk-button uk-button-default">View library</a>
</div>
<%- end -%>
<%- unless continue_reading.empty? -%>
<h2 class="uk-title home-headings">Continue Reading</h2>
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- continue_reading.each do |cr| -%>
<% item = cr[:entry] %>
<% progress = cr[:percentage] %>
<%= render_component "card" %>
<%- end -%>
</div>
<%- end -%>
<%- unless recently_added.empty? -%>
<h2 class="uk-title home-headings">Recently Added</h2>
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- recently_added.each do |ra| -%>
<% item = ra %>
<% progress = ra[:percentage] %>
<%= render_component "card" %>
<%- end -%>
</div>
<%- end -%>
<%= render_component "entry-modal" %>
<%- end -%>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/title.js"></script>
<% end %>

View File

@@ -1,49 +0,0 @@
<h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<option id="name-up">â–˛ Name</option>
<option id="name-down">â–Ľ Name</option>
<option id="date-up">â–˛ Date Modified</option>
<option id="date-down">â–Ľ Date Modified</option>
<option id="progress-up">â–˛ Progress</option>
<option id="progress-down">â–Ľ Progress</option>
</select>
</div>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard" href="<%= base_url %>book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<%- if t.entries.size > 0 -%>
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<%- end -%>
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

View File

@@ -1,20 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango - Manga Server and Web Reader">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
<link rel="icon" href="<%= base_url %>favicon.ico">
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
<script defer src="<%= base_url %>js/solid.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="<%= base_url %>js/theme.js"></script>
</head>
<%= render_component "head" %>
<body>
<div class="uk-offcanvas-content">
@@ -23,6 +10,7 @@
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li><a href="<%= base_url %>download">Download</a></li>
@@ -44,6 +32,7 @@
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
<ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li>
<li><a href="<%= base_url %>download">Download</a></li>

31
src/views/library.ecr Normal file
View File

@@ -0,0 +1,31 @@
<h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-grid-small" uk-grid>
<div class="uk-margin-bottom uk-width-3-4@s">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<% hash = {
"name" => "Name",
"date" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<% titles.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="<%= base_url %>js/dots.js"></script>
<script src="<%= base_url %>js/search.js"></script>
<script src="<%= base_url %>js/sort-items.js"></script>
<% end %>

View File

@@ -1,17 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="<%= base_url %>js/theme.js"></script>
</head>
<%= render_component "head" %>
<body>
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
@@ -43,4 +33,4 @@
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
</body>
</html>
</html>

22
src/views/opds/index.ecr Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:mango:index</id>
<link rel="self" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title>Library</title>
<author>
<name>Mango</name>
<uri>https://github.com/hkalexling/Mango</uri>
</author>
<% titles.each do |t| %>
<entry>
<title><%= t.display_name %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
<% end %>
</feed>

38
src/views/opds/title.ecr Normal file
View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>urn:mango:<%= title.id %></id>
<link rel="self" href="<%= base_url %>opds/book/<%= title.id %>" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
<title><%= title.display_name %></title>
<author>
<name>Mango</name>
<uri>https://github.com/hkalexling/Mango</uri>
</author>
<% title.titles.each do |t| %>
<entry>
<title><%= t.display_name %></title>
<id>urn:mango:<%= t.id %></id>
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
</entry>
<% end %>
<% title.entries.each do |e| %>
<entry>
<title><%= e.display_name %></title>
<id>urn:mango:<%= e.id %></id>
<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 %>opds/download/<%= e.title_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.title_id %>/<%= e.id %>" />
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.title_id %>" />
</entry>
<% end %>
</feed>

View File

@@ -1,15 +1,7 @@
<!DOCTYPE html>
<html class="reader-bg">
<head>
<meta charset="utf-8">
<meta name="X-UA-Compatible" content="IE=edge">
<title>Mango</title>
<meta name="description" content="Mango Manga Server">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
</head>
<%= render_component "head" %>
<body>
<script src="<%= base_url %>js/theme.js"></script>
@@ -67,4 +59,4 @@
<script src="<%= base_url %>js/reader.js"></script>
</body>
</html>
</html>

View File

@@ -7,7 +7,7 @@
</h2>
</div>
<ul class="uk-breadcrumb">
<li><a href="<%= base_url %>">Library</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<%- title.parents.each do |t| -%>
<li><a href="<%= base_url %>book/<%= t.id %>"><%= t.display_name %></a></li>
<%- end -%>
@@ -22,83 +22,27 @@
</form>
</div>
<div class="uk-margin-bottom uk-width-1-4@s">
<div class="uk-form-horizontal">
<select class="uk-select" id="sort-select">
<option id="auto-up">â–˛ Auto</option>
<option id="auto-down">â–Ľ Auto</option>
<option id="name-up">â–˛ Name</option>
<option id="name-down">â–Ľ Name</option>
<option id="date-up">â–˛ Date Modified</option>
<option id="date-down">â–Ľ Date Modified</option>
<option id="progress-up">â–˛ Progress</option>
<option id="progress-down">â–Ľ Progress</option>
</select>
</div>
<% hash = {
"auto" => "Auto",
"name" => "Name",
"date" => "Date Modified",
"progress" => "Progress"
} %>
<%= render_component "sort-form" %>
</div>
</div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- title.titles.each_with_index do |t, i| -%>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0">
<a class="acard" href="<%= base_url %>book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= t.display_name %></h3>
<p><%= t.size %> entries</p>
</div>
</div>
</a>
</div>
<%- end -%>
<%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>" id="<%= e.id %>">
<a class="acard">
<div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_display_name %>&quot;, &quot;<%= e.encoded_display_name %>&quot;, '<%= e.title_id %>', '<%= e.id %>')">
<div class="uk-card-media-top">
<img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img>
</div>
<div class="uk-card-body">
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
<h3 class="uk-card-title break-word" data-title="<%= e.display_name.gsub("\"", "&quot;") %>"><%= e.display_name %></h3>
<p><%= e.pages %> pages</p>
</div>
</div>
</a>
</div>
<%- end -%>
<% title.titles.each_with_index do |item, i| %>
<% progress = nil %>
<%= render_component "card" %>
<% end %>
<% title.entries.each_with_index do |item, i| %>
<% progress = percentage[i] %>
<%= render_component "card" %>
<% end %>
</div>
<div id="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"><span></span>
&nbsp;
<% if is_admin %>
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h3>
</div>
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
</div>
<div class="uk-modal-body">
<p>Read</p>
<p uk-margin>
<a id="beginning-btn" class="uk-button uk-button-default">From beginning</a>
<a id="continue-btn" class="uk-button uk-button-primary"></a>
</p>
<p>Progress</p>
<p uk-margin>
<button id="read-btn" class="uk-button uk-button-default">Mark as read (100%)</button>
<button id="unread-btn" class="uk-button uk-button-default">Mark as unread (0%)</button>
</p>
</div>
</div>
</div>
<%= render_component "entry-modal" %>
<div id="edit-modal" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-margin-auto-vertical">