diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8cad50d..37d86c6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index d763ddf..6ce10b2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index df5537a..4a075b5 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 54e15d0..00b08e3 100644 --- a/README.md +++ b/README.md @@ -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: ``` . diff --git a/public/js/download.js b/public/js/download.js index ab28fa9..5c848d7 100644 --- a/public/js/download.js +++ b/public/js/download.js @@ -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; +}; diff --git a/shard.lock b/shard.lock index c84ac06..baa3256 100644 --- a/shard.lock +++ b/shard.lock @@ -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 diff --git a/shard.yml b/shard.yml index 15e8fd3..52ad045 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: mango -version: 0.4.0 +version: 0.5.0 authors: - Alex Ling @@ -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 diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index a8bc5ab..578ae42 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -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 diff --git a/src/archive.cr b/src/archive.cr new file mode 100644 index 0000000..29dedfb --- /dev/null +++ b/src/archive.cr @@ -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 diff --git a/src/handlers/auth_handler.cr b/src/handlers/auth_handler.cr index afa17df..a64c83d 100644 --- a/src/handlers/auth_handler.cr +++ b/src/handlers/auth_handler.cr @@ -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 diff --git a/src/library.cr b/src/library.cr index 0af92ac..935e7ee 100644 --- a/src/library.cr +++ b/src/library.cr @@ -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 diff --git a/src/mangadex/downloader.cr b/src/mangadex/downloader.cr index 53c6776..6a62e59 100644 --- a/src/mangadex/downloader.cr +++ b/src/mangadex/downloader.cr @@ -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 diff --git a/src/mango.cr b/src/mango.cr index 26149a7..592d0ec 100644 --- a/src/mango.cr +++ b/src/mango.cr @@ -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 - 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 +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 + end end end -Config.load(config_path).set_current -MangaDex::Downloader.default - -server = Server.new -server.start +CLI.start(ARGV) diff --git a/src/routes/admin.cr b/src/routes/admin.cr index 1fbd978..c2f8fa9 100644 --- a/src/routes/admin.cr +++ b/src/routes/admin.cr @@ -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 diff --git a/src/routes/main.cr b/src/routes/main.cr index e739d82..3dc4b40 100644 --- a/src/routes/main.cr +++ b/src/routes/main.cr @@ -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, "/" diff --git a/src/storage.cr b/src/storage.cr index e6c0ad9..f577557 100644 --- a/src/storage.cr +++ b/src/storage.cr @@ -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,12 +51,15 @@ 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)" - random_pw = random_str - hash = hash_password random_pw - db.exec "insert into users values (?, ?, ?, ?)", - "admin", hash, nil, 1 - Logger.log "Initial user created. You can log in with " \ - "#{{"username" => "admin", "password" => random_pw}}" + + if init_user + random_pw = random_str + hash = hash_password random_pw + db.exec "insert into users values (?, ?, ?, ?)", + "admin", hash, nil, 1 + Logger.log "Initial user created. You can log in with " \ + "#{{"username" => "admin", "password" => random_pw}}" + end end end end @@ -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 diff --git a/src/util.cr b/src/util.cr index bb6cd0f..5146fde 100644 --- a/src/util.cr +++ b/src/util.cr @@ -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