Merge branch 'dev'

This commit is contained in:
Alex Ling 2020-06-02 15:45:11 +00:00
commit 14bf4da06c
17 changed files with 265 additions and 105 deletions

View File

@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Install dependencies
run: apk add --no-cache yarn yaml sqlite-static
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static
- name: Build
run: make static
- name: Linter

View File

@ -4,7 +4,7 @@ WORKDIR /Mango
COPY . .
COPY package*.json .
RUN apk add --no-cache yarn yaml sqlite-static \
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \
&& make static
FROM library/alpine

View File

@ -14,7 +14,7 @@ static: uglify | libs
crystal build src/mango.cr --release --progress --static
libs:
shards install
shards install --production
run:
crystal run src/mango.cr --error-trace

View File

@ -11,7 +11,7 @@ Mango is a self-hosted manga server and reader. Its features include
- Multi-user support
- Dark/light mode switch
- Supports both `.zip` and `.cbz` formats
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- Supports nested folders in library
- Automatically stores reading progress
- Built-in [MangaDex](https://mangadex.org/) downloader
@ -39,7 +39,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### Build from source
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers of some libraries. Please see the [Dockerfile](https://github.com/hkalexling/Mango/blob/master/Dockerfile) for the full list of dependencies
2. Clone the repository
3. `make && sudo make install`
4. Start Mango by running the command `mango`
@ -50,11 +50,21 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango e-manga server/reader. Version 0.4.0
Mango - Manga Server and Web Reader. Version 0.5.0
-v, --version Show version
-h, --help Show help
-c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
Usage:
mango [sub_command] [options]
Options:
-c PATH, --config=PATH Path to the config file [type:String]
-h, --help Show this help.
-v, --version Show version.
Sub Commands:
admin Run admin tools
```
### Config
@ -84,7 +94,7 @@ mangadex:
### Library Structure
You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
You can organize your archive files in nested folders in the library directory. Here's an example:
```
.

View File

@ -242,7 +242,10 @@ const buildTable = () => {
Object.entries(filters).forEach(([k, v]) => {
if (v === 'All') return;
if (k === 'group') {
chapters = chapters.filter(c => v in c.groups);
chapters = chapters.filter(c => {
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
return unescaped_groups.indexOf(v) >= 0;
});
return;
}
if (k === 'lang') {
@ -297,3 +300,9 @@ const buildTable = () => {
});
$('#selection-controls').removeAttr('hidden');
};
const unescapeHTML = (str) => {
var elt = document.createElement("span");
elt.innerHTML = str;
return elt.innerText;
};

View File

@ -2,12 +2,20 @@ version: 1.0
shards:
ameba:
github: crystal-ameba/ameba
version: 0.12.0
version: 0.12.1
archive:
github: hkalexling/archive.cr
version: 0.2.0
baked_file_system:
github: schovi/baked_file_system
version: 0.9.8
clim:
github: at-grandpa/clim
version: 0.12.0
db:
github: crystal-lang/crystal-db
version: 0.9.0

View File

@ -1,5 +1,5 @@
name: mango
version: 0.4.0
version: 0.5.0
authors:
- Alex Ling <hkalexling@gmail.com>
@ -19,7 +19,9 @@ dependencies:
github: crystal-lang/crystal-sqlite3
baked_file_system:
github: schovi/baked_file_system
development_dependencies:
archive:
github: hkalexling/archive.cr
ameba:
github: crystal-ameba/ameba
clim:
github: at-grandpa/clim

View File

@ -45,7 +45,7 @@ end
def with_storage
with_default_config do
temp_db = get_tempfile "mango-test-db"
storage = Storage.new temp_db.path
storage = Storage.new temp_db.path, false
clear = yield storage, temp_db.path
if clear == true
temp_db.delete

59
src/archive.cr Normal file
View File

@ -0,0 +1,59 @@
require "zip"
require "archive"
# A unified class to handle all supported archive formats. It uses the ::Zip
# module in crystal standard library if the target file is a zip archive.
# Otherwise it uses `archive.cr`.
class ArchiveFile
def initialize(@filename : String)
if [".cbz", ".zip"].includes? File.extname filename
@archive_file = Zip::File.new filename
else
@archive_file = Archive::File.new filename
end
end
def self.open(filename : String, &)
s = self.new filename
yield s
s.close
end
def close
if @archive_file.is_a? Zip::File
@archive_file.as(Zip::File).close
end
end
# Lists all file entries
def entries
ary = [] of Zip::File::Entry | Archive::Entry
@archive_file.entries.map do |e|
if (e.is_a? Zip::File::Entry && e.file?) ||
(e.is_a? Archive::Entry && e.info.file?)
ary.push e
end
end
ary
end
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes?
if e.is_a? Zip::File::Entry
data = nil
e.open do |io|
slice = Bytes.new e.uncompressed_size
bytes_read = io.read_fully? slice
data = slice if bytes_read
end
data
else
e.read
end
end
def check
if @archive_file.is_a? Archive::File
@archive_file.as(Archive::File).check
end
end
end

View File

@ -9,7 +9,9 @@ class AuthHandler < Kemal::Handler
def call(env)
return call_next(env) if request_path_startswith env, ["/login", "/logout"]
cookie = env.request.cookies.find { |c| c.name == "token" }
cookie = env.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end
if cookie.nil? || !@storage.verify_token cookie.value
return redirect env, "/login"
end

View File

@ -1,8 +1,8 @@
require "zip"
require "mime"
require "json"
require "uri"
require "./util"
require "./archive"
struct Image
property data : Bytes
@ -25,7 +25,7 @@ class Entry
@title = File.basename path, File.extname path
@encoded_title = URI.encode @title
@size = (File.size path).humanize_bytes
file = Zip::File.new path
file = ArchiveFile.new path
@pages = file.entries.count do |e|
["image/jpeg", "image/png"].includes? \
MIME.from_filename? e.filename
@ -68,7 +68,8 @@ class Entry
end
def read_page(page_num)
Zip::File.open @zip_path do |file|
img = nil
ArchiveFile.open @zip_path do |file|
page = file.entries
.select { |e|
["image/jpeg", "image/png"].includes? \
@ -78,16 +79,13 @@ class Entry
compare_alphanumerically a.filename, b.filename
}
.[page_num - 1]
page.open do |io|
slice = Bytes.new page.uncompressed_size
bytes_read = io.read_fully? slice
unless bytes_read
return nil
end
return Image.new slice, MIME.from_filename(page.filename),
page.filename, bytes_read
data = file.read_entry page
if data
img = Image.new data, MIME.from_filename(page.filename), page.filename,
data.size
end
end
img
end
end
@ -115,12 +113,16 @@ class Title
@title_ids << title.id
next
end
if [".zip", ".cbz"].includes? File.extname path
zip_exception = validate_zip path
unless zip_exception.nil?
Logger.warn "File #{path} is corrupted or is not a valid zip " \
"archive. Ignoring it."
Logger.debug "Zip error: #{zip_exception}"
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
unless File.readable? path
Logger.warn "File #{path} is not readable. Please make sure the " \
"file permission is configured correctly."
next
end
archive_exception = validate_archive path
unless archive_exception.nil?
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
"Archive error: #{archive_exception}"
next
end
entry = Entry.new path, self, @id, storage

View File

@ -371,7 +371,7 @@ module MangaDex
writer.close
Logger.debug "cbz File created at #{zip_path}"
zip_exception = validate_zip zip_path
zip_exception = validate_archive zip_path
if !zip_exception.nil?
@queue.add_message "The downloaded archive is corrupted. " \
"Error: #{zip_exception}", job

View File

@ -2,31 +2,100 @@ require "./config"
require "./server"
require "./mangadex/*"
require "option_parser"
require "clim"
VERSION = "0.4.0"
MANGO_VERSION = "0.5.0"
config_path = nil
macro common_option
option "-c PATH", "--config=PATH", type: String,
desc: "Path to the config file"
end
OptionParser.parse do |parser|
parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
macro throw(msg)
puts "ERROR: #{{{msg}}}"
puts
puts "Please see the `--help`."
exit 1
end
parser.on "-v", "--version", "Show version" do
puts "Version #{VERSION}"
exit
class CLI < Clim
main do
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
usage "mango [sub_command] [options]"
help short: "-h"
version "Version #{MANGO_VERSION}", short: "-v"
common_option
run do |opts|
Config.load(opts.config).set_current
MangaDex::Downloader.default
server = Server.new
server.start
end
sub "admin" do
desc "Run admin tools"
usage "mango admin [tool]"
help short: "-h"
run do |opts|
puts opts.help_string
end
sub "user" do
desc "User management tool"
usage "mango admin user [arguments] [options]"
help short: "-h"
argument "action", type: String,
desc: "Action to perform. Can be add/delete/update/list"
argument "username", type: String,
desc: "Username to update or delete"
option "-u USERNAME", "--username=USERNAME", type: String,
desc: "Username"
option "-p PASSWORD", "--password=PASSWORD", type: String,
desc: "Password"
option "-a", "--admin", desc: "Admin flag", type: Bool, default: false
common_option
run do |opts, args|
Config.load(opts.config).set_current
storage = Storage.new nil, false
case args.action
when "add"
throw "Options `-u` and `-p` required." if opts.username.nil? ||
opts.password.nil?
storage.new_user opts.username.not_nil!,
opts.password.not_nil!, opts.admin
when "delete"
throw "Argument `username` required." if args.username.nil?
storage.delete_user args.username
when "update"
throw "Argument `username` required." if args.username.nil?
username = opts.username || args.username
password = opts.password || ""
storage.update_user args.username, username.not_nil!,
password.not_nil!, opts.admin
when "list"
users = storage.list_users
name_length = users.map(&.[0].size).max
l_cell_width = ["username".size, name_length].max
r_cell_width = "admin access".size
header = " #{"username".ljust l_cell_width} | admin access "
puts "-" * header.size
puts header
puts "-" * header.size
users.each do |name, admin|
puts " #{name.ljust l_cell_width} | " \
"#{admin.to_s.ljust r_cell_width} "
end
puts "-" * header.size
when nil
puts opts.help_string
else
throw "Unknown action \"#{args.action}\"."
end
end
end
parser.on "-h", "--help", "Show help" do
puts parser
exit
end
parser.on "-c PATH", "--config=PATH",
"Path to the config file. " \
"Default is `~/.config/mango/config.yml`" do |path|
config_path = path
end
end
Config.load(config_path).set_current
MangaDex::Downloader.default
server = Server.new
server.start
CLI.start(ARGV)

View File

@ -32,20 +32,6 @@ class AdminRouter < Router
# 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
redirect env, "/admin/user"
@ -65,23 +51,6 @@ class AdminRouter < Router
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

View File

@ -9,7 +9,9 @@ class MainRouter < Router
get "/logout" do |env|
begin
cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
cookie = env.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end.not_nil!
@context.storage.logout cookie.value
rescue e
@context.error "Error when attempting to log out: #{e}"
@ -24,7 +26,8 @@ class MainRouter < Router
password = env.params.body["password"]
token = @context.storage.verify_user(username, password).not_nil!
cookie = HTTP::Cookie.new "token", token
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, "/"

View File

@ -22,7 +22,7 @@ class Storage
@@default.not_nil!
end
def initialize(db_path : String? = nil)
def initialize(db_path : String? = nil, init_user = true)
@path = db_path || Config.current.db_path
dir = File.dirname @path
unless Dir.exists? dir
@ -51,6 +51,8 @@ class Storage
Logger.debug "Creating DB file at #{@path}"
db.exec "create unique index username_idx on users (username)"
db.exec "create unique index token_idx on users (token)"
if init_user
random_pw = random_str
hash = hash_password random_pw
db.exec "insert into users values (?, ?, ?, ?)",
@ -60,6 +62,7 @@ class Storage
end
end
end
end
def verify_user(username, password)
DB.open "sqlite3://#{@path}" do |db|
@ -124,6 +127,8 @@ class Storage
end
def new_user(username, password, admin)
validate_username username
validate_password password
admin = (admin ? 1 : 0)
DB.open "sqlite3://#{@path}" do |db|
hash = hash_password password
@ -134,8 +139,10 @@ class Storage
def update_user(original_username, username, password, admin)
admin = (admin ? 1 : 0)
validate_username username
validate_password password unless password.empty?
DB.open "sqlite3://#{@path}" do |db|
if password.size == 0
if password.empty?
db.exec "update users set username = (?), admin = (?) " \
"where username = (?)",
username, admin, original_username

View File

@ -6,7 +6,9 @@ UPLOAD_URL_PREFIX = "/uploads"
macro layout(name)
base_url = Config.current.base_url
begin
cookie = env.request.cookies.find { |c| c.name == "token" }
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
@ -26,7 +28,9 @@ 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 { |c| c.name == "token" }.not_nil!
cookie = {{env}}.request.cookies.find do |c|
c.name == "token-#{Config.current.port}"
end.not_nil!
(@context.storage.verify_token cookie.value).not_nil!
end
@ -85,12 +89,9 @@ def compare_alphanumerically(a : String, b : String)
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
end
# When downloading from MangaDex, the zip/cbz file would not be valid
# before the download is completed. If we scan the zip file,
# Entry.new would throw, so we use this method to check before
# constructing Entry
def validate_zip(path : String) : Exception?
file = Zip::File.new path
def validate_archive(path : String) : Exception?
file = ArchiveFile.new path
file.check
file.close
return
rescue e
@ -105,3 +106,22 @@ def redirect(env, path)
base = Config.current.base_url
env.redirect File.join base, path
end
def validate_username(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
end
def validate_password(password)
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