Compare commits

...

19 Commits

Author SHA1 Message Date
Alex Ling
5645f272df When an invalid zip file is found, instead of ignoring it silently, log
a warning message
2020-02-28 18:08:56 +00:00
Alex Ling
dc3bbd10d6 Close zip file after listing entries to prevent leaking 2020-02-28 17:55:45 +00:00
Alex Ling
c89c74c71b Bump version to 0.1.2 2020-02-28 17:53:35 +00:00
Alex Ling
cb76a96126 Removing the logging of the library json on scan 2020-02-28 17:52:50 +00:00
Alex Ling
73b38492ba Remove console in minified JS files 2020-02-28 17:52:14 +00:00
Alex Ling
bf37c4aa10 Sorting in library and title page 2020-02-28 17:51:26 +00:00
Alex Ling
f837be0718 Ignore invalid zip files in library 2020-02-28 17:45:30 +00:00
Alex Ling
98baf63b0c Add gitter to README 2020-02-24 22:19:31 -05:00
Alex Ling
bf0f5270f0 Merge branch 'v0.1.1' 2020-02-23 21:16:23 +00:00
Alex Ling
ac620e1f2a Update docker-compose example 2020-02-23 21:09:50 +00:00
Alex Ling
a7519a791e Fix the problem that minified assets in dist/ are not used. 2020-02-23 19:18:30 +00:00
Alex Ling
7a21f4dc9b Sort titles in library by name by default 2020-02-23 18:36:10 +00:00
Alex Ling
650ebc7f9d Fix #6 2020-02-23 18:35:27 +00:00
Alex Ling
5b34c05243 Use Babel, so I can write modern JS and save my sanity 2020-02-23 18:26:57 +00:00
Alex Ling
803fc8c44b Split routes in server.cr into small files 2020-02-23 18:24:32 +00:00
Alex Ling
dd49f75079 Update README.md 2020-02-21 21:22:55 -05:00
Alex Ling
6be9c3eac6 Update README.md 2020-02-21 21:21:34 -05:00
Alex Ling
5a2f80b5e1 Add issue templates 2020-02-21 16:42:12 -05:00
Alex Ling
5b4d79220c Provide pre-built binary (amd64) in README 2020-02-21 14:08:55 -05:00
21 changed files with 578 additions and 345 deletions

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: Bug report
about: I found a bug in Mango!
title: "[Bug Report]"
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Environment (please complete the following information):**
- OS: [e.g. Ubuntu 18.04]
- Browser [e.g. chrome, safari, if applicable]
- Mango Version [e.g. v0.1.0]
**Docker (if you are running Mango in a Docker container)**
- The `docker-compose.yml` file you are using
**Additional context**
Add any other context about the problem here. Add screenshots if applicable.

View File

@@ -0,0 +1,17 @@
---
name: Feature request
about: Suggest a feature for Mango
title: "[Feature Request]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -0,0 +1,10 @@
---
name: General Question
about: I have a question about Mango
title: "[Question]"
labels: general question
assignees: ''
---

View File

@@ -1,7 +1,12 @@
# Mango
![banner](./public/img/banner-paddings.png)
# Mango
[![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)
Mango is a self-hosted manga server and reader. Its features include
- Multi-user support
@@ -12,6 +17,10 @@ Mango is a self-hosted manga server and reader. Its features include
## Installation
### Pre-built Binary
1. Simply download the pre-built binary file `mango` for the latest [release](https://github.com/hkalexling/Mango/releases). All the dependencies are statically linked, and it should work with most Linux systems on amd64.
### Docker
1. Make sure you have docker installed and running. You will also need `docker-compose`
@@ -21,10 +30,9 @@ Mango is a self-hosted manga server and reader. Its features include
5. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside
6. Head over to `localhost:9000` to log in
### Build from source
1. Make sure you have Crystal, Node and Yarn installed
1. Make sure you have Crystal, Node and Yarn installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
2. Clone the repository
3. `make && sudo make install`
4. Start Mango by running the command `mango`

View File

@@ -11,5 +11,5 @@ services:
ports:
- 9000:9000
volumes:
- ./mango:/root/mango
- ./config:/root/.config/mango
- ~/mango:/root/mango
- ~/.config/mango:/root/.config/mango

View File

@@ -1,10 +1,12 @@
const gulp = require('gulp');
const uglify = require('gulp-uglify');
const minify = require("gulp-babel-minify");
const minifyCss = require('gulp-minify-css');
gulp.task('minify-js', () => {
return gulp.src('public/js/*.js')
.pipe(uglify())
.pipe(minify({
removeConsole: true
}))
.pipe(gulp.dest('dist/js'));
});

View File

@@ -7,8 +7,8 @@
"license": "MIT",
"devDependencies": {
"gulp": "^4.0.2",
"gulp-minify-css": "^1.2.4",
"gulp-uglify": "^3.0.2"
"gulp-babel-minify": "^0.5.1",
"gulp-minify-css": "^1.2.4"
},
"scripts": {
"uglify": "gulp"

35
public/js/sort-items.js Normal file
View File

@@ -0,0 +1,35 @@
$(() => {
$('option#name-up').attr('selected', '');
$('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id');
const ary = sort.split('-');
const by = ary[0];
const dir = ary[1];
const items = $('.item');
items.remove();
items.sort((a, b) => {
var res;
if (by === 'name')
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
else if (by === 'date')
res = $(a).attr('data-mtime') > $(b).attr('data-mtime');
else {
const ap = $(a).attr('data-progress');
const bp = $(b).attr('data-progress');
if (ap === bp)
// if progress is the same, we compare by name
res = $(a).find('.uk-card-title').text() > $(b).find('.uk-card-title').text();
else
res = ap > bp;
}
if (dir === 'up')
return res;
else
return !res;
});
var html = '';
$('#item-container').append(items);
});
});

View File

@@ -1,4 +1,7 @@
function showModal(title, zipPath, pages, percentage, title, entry) {
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function(){
$(this).removeAttr('hidden');
});
@@ -16,20 +19,20 @@ function showModal(title, zipPath, pages, percentage, title, entry) {
$('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', '/reader/' + title + '/' + entry + '/1');
$('#continue-btn').attr('href', '/reader/' + title + '/' + entry);
$('#beginning-btn').attr('href', '/reader/' + titleID + '/' + entryID + '/1');
$('#continue-btn').attr('href', '/reader/' + titleID + '/' + entryID);
$('#read-btn').click(function(){
updateProgress(title, entry, pages);
updateProgress(titleID, entryID, pages);
});
$('#unread-btn').click(function(){
updateProgress(title, entry, 0);
updateProgress(titleID, entryID, 0);
});
UIkit.modal($('#modal')).show();
}
function updateProgress(title, entry, page) {
$.post('/api/progress/' + title + '/' + entry + '/' + page, function(data) {
function updateProgress(titleID, entryID, page) {
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) {
if (data.success) {
location.reload();
}

View File

@@ -1,6 +1,7 @@
require "zip"
require "mime"
require "json"
require "uri"
struct Image
property data : Bytes
@@ -14,19 +15,27 @@ end
class Entry
JSON.mapping zip_path: String, book_title: String, title: String, \
size: String, pages: Int32, cover_url: String
size: String, pages: Int32, cover_url: String, id: String, \
title_id: String, encoded_path: String, encoded_title: String,
mtime: Time
def initialize(path, @book_title)
def initialize(path, @book_title, @title_id, storage)
@zip_path = path
@encoded_path = URI.encode path
@title = File.basename path, File.extname path
@encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes
@pages = Zip::File.new(path).entries
file = Zip::File.new path
@pages = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
}
.size
@cover_url = "/api/page/#{@book_title}/#{title}/1"
file.close
@id = storage.get_id @zip_path, false
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
@mtime = File.info(@zip_path).modification_time
end
def read_page(page_num)
Zip::File.open @zip_path do |file|
@@ -51,20 +60,47 @@ class Entry
end
class Title
JSON.mapping dir: String, entries: Array(Entry), title: String
JSON.mapping dir: String, entries: Array(Entry), title: String,
id: String, encoded_title: String, mtime: Time, logger: MLogger
def initialize(dir : String)
def initialize(dir : String, storage, @logger : MLogger)
@dir = dir
@id = storage.get_id @dir, true
@title = File.basename dir
@encoded_title = URI.encode @title
@entries = (Dir.entries dir)
.select { |path| [".zip", ".cbz"].includes? File.extname path }
.map { |path| Entry.new File.join(dir, path), @title }
.map { |path| File.join dir, path }
.select { |path| valid_zip path }
.map { |path|
Entry.new path, @title, @id, storage
}
.select { |e| e.pages > 0 }
.sort { |a, b| a.title <=> b.title }
mtimes = [File.info(dir).modification_time]
mtimes += @entries.map{|e| e.mtime}
@mtime = mtimes.max
end
def get_entry(name)
@entries.find { |e| e.title == name }
# When downloading from MangaDex, the zip/cbz file would not be valid
# before the download is completed. If we scan the zip file,
# Entry.new would throw, so we use this method to check before
# constructing Entry
private def valid_zip(path : String)
begin
file = Zip::File.new path
file.close
return true
rescue
@logger.warn "File #{path} is corrupted or is not a valid zip "\
"archive. Ignoring it."
return false
end
end
def get_entry(eid)
@entries.find { |e| e.id == eid }
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, entry, page)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
@@ -75,7 +111,7 @@ class Title
info.progress[username][entry] = page
info.save @dir
end
def load_progress(username, entry : String)
def load_progress(username, entry)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
return 0
@@ -85,10 +121,10 @@ class Title
end
info.progress[username][entry]
end
def load_percetage(username, entry : String)
def load_percetage(username, entry)
info = TitleInfo.new @dir
page = load_progress username, entry
entry_obj = get_entry entry
entry_obj = @entries.find{|e| e.title == entry}
return 0 if entry_obj.nil?
page / entry_obj.pages
end
@@ -136,9 +172,10 @@ class TitleInfo
end
class Library
JSON.mapping dir: String, titles: Array(Title), scan_interval: Int32, logger: MLogger
JSON.mapping dir: String, titles: Array(Title), scan_interval: Int32,
logger: MLogger, storage: Storage
def initialize(@dir, @scan_interval, @logger)
def initialize(@dir, @scan_interval, @logger, @storage)
# explicitly initialize @titles to bypass the compiler check. it will
# be filled with actual Titles in the `scan` call below
@titles = [] of Title
@@ -154,8 +191,8 @@ class Library
end
end
end
def get_title(name)
@titles.find { |t| t.title == name }
def get_title(tid)
@titles.find { |t| t.id == tid }
end
def scan
unless Dir.exists? @dir
@@ -165,9 +202,9 @@ class Library
end
@titles = (Dir.entries @dir)
.select { |path| File.directory? File.join @dir, path }
.map { |path| Title.new File.join @dir, path }
.map { |path| Title.new File.join(@dir, path), @storage, @logger }
.select { |title| !title.entries.empty? }
.sort { |a, b| a.title <=> b.title }
@logger.debug "Scan completed"
@logger.debug "Scanned library: \n#{self.to_pretty_json}"
end
end

View File

@@ -2,7 +2,7 @@ require "./server"
require "./context"
require "option_parser"
VERSION = "0.1.0"
VERSION = "0.1.2"
config_path = nil
@@ -25,8 +25,8 @@ end
config = Config.load config_path
logger = MLogger.new config
library = Library.new config.library_path, config.scan_interval, logger
storage = Storage.new config.db_path, logger
library = Library.new config.library_path, config.scan_interval, logger, storage
context = Context.new config, logger, library, storage

103
src/routes/admin.cr Normal file
View File

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

92
src/routes/api.cr Normal file
View File

@@ -0,0 +1,92 @@
require "./router"
class APIRouter < Router
def setup
get "/api/page/:tid/:eid/:page" do |env|
begin
tid = env.params.url["tid"]
eid = env.params.url["eid"]
page = env.params.url["page"].to_i
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
entry = title.get_entry eid
raise "Entry ID `#{eid}` of `#{title.title}` not found" if \
entry.nil?
img = entry.read_page page
raise "Failed to load page #{page} of " \
"`#{title.title}/#{entry.title}`" if img.nil?
send_img env, img
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
get "/api/book/:title" do |env|
begin
tid = env.params.url["tid"]
title = @context.library.get_title tid
raise "Title ID `#{tid}` not found" if title.nil?
send_json env, title.to_json
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
get "/api/book" do |env|
send_json env, @context.library.to_json
end
post "/api/admin/scan" do |env|
start = Time.utc
@context.library.scan
ms = (Time.utc - start).total_milliseconds
send_json env, {
"milliseconds" => ms,
"titles" => @context.library.titles.size
}.to_json
end
post "/api/admin/user/delete/:username" do |env|
begin
username = env.params.url["username"]
@context.storage.delete_user username
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/progress/:title/:entry/:page" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["title"])
.not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "incorrect page value" if page < 0 || page > entry.pages
title.save_progress username, entry.title, page
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
end
end

56
src/routes/main.cr Normal file
View File

@@ -0,0 +1,56 @@
require "./router"
class MainRouter < Router
def setup
get "/login" do |env|
render "src/views/login.ecr"
end
get "/logout" do |env|
begin
cookie = env.request.cookies
.find { |c| c.name == "token" }.not_nil!
@context.storage.logout cookie.value
rescue e
@context.error "Error when attempting to log out: #{e}"
ensure
env.redirect "/login"
end
end
post "/login" do |env|
begin
username = env.params.body["username"]
password = env.params.body["password"]
token = @context.storage.verify_user(username, password)
.not_nil!
cookie = HTTP::Cookie.new "token", token
env.response.cookies << cookie
env.redirect "/"
rescue
env.redirect "/login"
end
end
get "/" do |env|
titles = @context.library.titles
username = get_username env
percentage = titles.map &.load_percetage username
layout "index"
end
get "/book/:title" do |env|
begin
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 }
layout "title"
rescue e
@context.error e
env.response.status_code = 404
end
end
end
end

58
src/routes/reader.cr Normal file
View File

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

6
src/routes/router.cr Normal file
View File

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

View File

@@ -4,6 +4,7 @@ require "./auth_handler"
require "./static_handler"
require "./log_handler"
require "./util"
require "./routes/*"
class Server
def initialize(@context : Context)
@@ -13,296 +14,10 @@ class Server
layout "message"
end
get "/" do |env|
titles = @context.library.titles
username = get_username env
percentage = titles.map &.load_percetage username
layout "index"
end
get "/book/:title" do |env|
begin
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 }
layout "title"
rescue e
@context.error e
env.response.status_code = 404
end
end
get "/admin" do |env|
layout "admin"
end
get "/admin/user" do |env|
users = @context.storage.list_users
username = get_username env
layout "user"
end
get "/admin/user/edit" do |env|
username = env.params.query["username"]?
admin = env.params.query["admin"]?
if admin
admin = admin == "true"
end
error = env.params.query["error"]?
current_user = get_username env
new_user = username.nil? && admin.nil?
layout "user-edit"
end
post "/admin/user/edit" do |env|
# creating new user
begin
username = env.params.body["username"]
password = env.params.body["password"]
# if `admin` is unchecked, the body hash
# would not contain `admin`
admin = !env.params.body["admin"]?.nil?
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters "\
"and underscores only"
end
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
@context.storage.new_user username, password, admin
env.redirect "/admin/user"
rescue e
@context.error e
redirect_url = URI.new \
path: "/admin/user/edit",\
query: hash_to_query({"error" => e.message})
env.redirect redirect_url.to_s
end
end
post "/admin/user/edit/:original_username" do |env|
# editing existing user
begin
username = env.params.body["username"]
password = env.params.body["password"]
# if `admin` is unchecked, the body
# hash would not contain `admin`
admin = !env.params.body["admin"]?.nil?
original_username = env.params.url["original_username"]
if username.size < 3
raise "Username should contain at least 3 characters"
end
if (username =~ /^[A-Za-z0-9_]+$/).nil?
raise "Username should contain alphanumeric characters "\
"and underscores only"
end
if password.size != 0
if password.size < 6
raise "Password should contain at least 6 characters"
end
if (password =~ /^[[:ascii:]]+$/).nil?
raise "password should contain ASCII characters only"
end
end
@context.storage.update_user \
original_username, username, password, admin
env.redirect "/admin/user"
rescue e
@context.error e
redirect_url = URI.new \
path: "/admin/user/edit",\
query: hash_to_query({"username" => original_username, \
"admin" => admin, "error" => e.message})
env.redirect redirect_url.to_s
end
end
get "/reader/: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!
# load progress
username = get_username env
page = title.load_progress username, entry.title
# we go back 2 * `IMGS_PER_PAGE` pages. the infinite scroll
# library perloads a few pages in advance, and the user
# might not have actually read them
page = [page - 2 * IMGS_PER_PAGE, 1].max
env.redirect "/reader/#{title.title}/#{entry.title}/#{page}"
rescue e
@context.error e
env.response.status_code = 404
end
end
get "/reader/:title/:entry/:page" do |env|
begin
title = (@context.library.get_title env.params.url["title"])
.not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "" if page > entry.pages || page <= 0
# save progress
username = get_username env
title.save_progress username, entry.title, page
pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min)
urls = pages.map { |idx|
"/api/page/#{title.title}/#{entry.title}/#{idx}" }
reader_urls = pages.map { |idx|
"/reader/#{title.title}/#{entry.title}/#{idx}" }
next_page = page + IMGS_PER_PAGE
next_url = next_page > entry.pages ? nil :
"/reader/#{title.title}/#{entry.title}/#{next_page}"
exit_url = "/book/#{title.title}"
next_entry = title.next_entry entry
next_entry_url = next_entry.nil? ? nil : \
"/reader/#{title.title}/#{next_entry.title}"
render "src/views/reader.ecr"
rescue e
@context.error e
env.response.status_code = 404
end
end
get "/login" do |env|
render "src/views/login.ecr"
end
get "/logout" do |env|
begin
cookie = env.request.cookies
.find { |c| c.name == "token" }.not_nil!
@context.storage.logout cookie.value
rescue e
@context.error "Error when attempting to log out: #{e}"
ensure
env.redirect "/login"
end
end
post "/login" do |env|
begin
username = env.params.body["username"]
password = env.params.body["password"]
token = @context.storage.verify_user(username, password)
.not_nil!
cookie = HTTP::Cookie.new "token", token
env.response.cookies << cookie
env.redirect "/"
rescue
env.redirect "/login"
end
end
get "/api/page/:title/:entry/:page" do |env|
begin
title = env.params.url["title"]
entry = env.params.url["entry"]
page = env.params.url["page"].to_i
t = @context.library.get_title title
raise "Title `#{title}` not found" if t.nil?
e = t.get_entry entry
raise "Entry `#{entry}` of `#{title}` not found" if e.nil?
img = e.read_page page
raise "Failed to load page #{page} of `#{title}/#{entry}`"\
if img.nil?
send_img env, img
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
get "/api/book/:title" do |env|
begin
title = env.params.url["title"]
t = @context.library.get_title title
raise "Title `#{title}` not found" if t.nil?
send_json env, t.to_json
rescue e
@context.error e
env.response.status_code = 500
e.message
end
end
get "/api/book" do |env|
send_json env, @context.library.to_json
end
post "/api/admin/scan" do |env|
start = Time.utc
@context.library.scan
ms = (Time.utc - start).total_milliseconds
send_json env, {
"milliseconds" => ms,
"titles" => @context.library.titles.size
}.to_json
end
post "/api/admin/user/delete/:username" do |env|
begin
username = env.params.url["username"]
@context.storage.delete_user username
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
post "/api/progress/:title/:entry/:page" do |env|
begin
username = get_username env
title = (@context.library.get_title env.params.url["title"])
.not_nil!
entry = (title.get_entry env.params.url["entry"]).not_nil!
page = env.params.url["page"].to_i
raise "incorrect page value" if page < 0 || page > entry.pages
title.save_progress username, entry.title, page
rescue e
@context.error e
send_json env, {
"success" => false,
"error" => e.message
}.to_json
else
send_json env, {"success" => true}.to_json
end
end
MainRouter.new(@context).setup
AdminRouter.new(@context).setup
ReaderRouter.new(@context).setup
APIRouter.new(@context).setup
Kemal.config.logging = false
add_handler LogHandler.new @context.logger

View File

@@ -5,9 +5,11 @@ require "./util"
class FS
extend BakedFileSystem
{% if read_file? "./dist" %}
{% if read_file? "#{__DIR__}/../dist/favicon.ico" %}
{% puts "baking ../dist" %}
bake_folder "../dist"
{% else %}
{% puts "baking ../public" %}
bake_folder "../public"
{% end %}
end

View File

@@ -12,7 +12,7 @@ def verify_password(hash, pw)
end
def random_str
Base64.strict_encode UUID.random().to_s
UUID.random.to_s.gsub "-", ""
end
class Storage
@@ -25,10 +25,18 @@ class Storage
end
DB.open "sqlite3://#{path}" do |db|
begin
# We create the `ids` table first. even if the uses has an
# early version installed and has the `user` table only,
# we will still be able to create `ids`
db.exec "create table ids" \
"(path text, id text, is_title integer)"
db.exec "create unique index path_idx on ids (path)"
db.exec "create unique index id_idx on ids (id)"
db.exec "create table users" \
"(username text, password text, token text, admin integer)"
rescue e
unless e.message == "table users already exists"
unless e.message.not_nil!.ends_with? "already exists"
@logger.fatal "Error when checking tables in DB: #{e}"
raise e
end
@@ -147,4 +155,23 @@ class Storage
end
end
end
def get_id(path, is_title)
DB.open "sqlite3://#{@path}" do |db|
begin
id = db.query_one "select id from ids where path = (?)",
path, as: {String}
return id
rescue
id = random_str
db.exec "insert into ids values (?, ?, ?)", path, id,
is_title ? 1 : 0
return id
end
end
end
def to_json(json : JSON::Builder)
json.string self
end
end

View File

@@ -1,15 +1,29 @@
<h2 class=uk-title>Library</h2>
<p class="uk-text-meta"><%= titles.size %> titles found</p>
<div class="uk-margin">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
<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 class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<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">
<a class="acard" href="/book/<%= t.title %>">
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<img src="<%= t.entries[0].cover_url %>" alt="">
@@ -27,4 +41,5 @@
<% content_for "script" do %>
<script src="/js/search.js"></script>
<script src="/js/sort-items.js"></script>
<% end %>

View File

@@ -1,17 +1,31 @@
<div id="alert"></div>
<h2 class=uk-title><%= title.title %></h2>
<p class="uk-text-meta"><%= title.entries.size %> entries found</p>
<div class="uk-margin">
<form class="uk-search uk-search-default">
<span uk-search-icon></span>
<input class="uk-search-input" type="search" placeholder="Search">
</form>
<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 class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
<%- title.entries.each_with_index do |e, i| -%>
<div class="item">
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
<a class="acard">
<div class="uk-card uk-card-default" onclick="showModal('<%= e.title %>', '<%= e.zip_path %>', '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, '<%= title.title %>', '<%= e.title %>')">
<div class="uk-card uk-card-default" onclick="showModal(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_title %>&quot;, &quot;<%= e.encoded_title %>&quot;, '<%= e.title_id %>', '<%= e.id %>')">
<div class="uk-card-media-top">
<img src="<%= e.cover_url %>" alt="">
</div>
@@ -51,4 +65,5 @@
<% content_for "script" do %>
<script src="/js/title.js"></script>
<script src="/js/search.js"></script>
<script src="/js/sort-items.js"></script>
<% end %>