diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5fa7659 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a2d88b7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/general-question.md b/.github/ISSUE_TEMPLATE/general-question.md new file mode 100644 index 0000000..470c7e1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general-question.md @@ -0,0 +1,10 @@ +--- +name: General Question +about: I have a question about Mango +title: "[Question]" +labels: general question +assignees: '' + +--- + + diff --git a/README.md b/README.md index 88c2a66..3a9326c 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,22 @@ 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` 2. Clone the repository -3. Copy `docker-compose.example.yml` to `docker-compose.yml` and modify it if necessary -4. Run `docker-compose up`. This should build the docker image and start the container with Mango running inside -5. Head over to `localhost:9000` to log in - +3. Copy `docker-compose.example.yml` to `docker-compose.yml` +4. Modify the `volumes` in `docker-compose.yml` to point the directories to desired locations on the host machine +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` @@ -43,7 +47,7 @@ Mango e-manga server/reader. Version 0.1.0 ### Config -The default config file location is `~/.config/mango/config.yml`. The config options and default values are given below +The default config file location is `~/.config/mango/config.yml`. It might be different if you are running Mango in a docker container. The config options and default values are given below ```yaml --- diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 79878ce..7ac0d6c 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -11,8 +11,5 @@ services: ports: - 9000:9000 volumes: - - library:/root/mango/library - - config:/root/.config/mango -volumes: - library: - config: + - ~/mango:/root/mango + - ~/.config/mango:/root/.config/mango diff --git a/public/js/title.js b/public/js/title.js index 077ee13..e2d236d 100644 --- a/public/js/title.js +++ b/public/js/title.js @@ -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(); } diff --git a/src/library.cr b/src/library.cr index e5d06ec..ab0f06f 100644 --- a/src/library.cr +++ b/src/library.cr @@ -1,6 +1,7 @@ require "zip" require "mime" require "json" +require "uri" struct Image property data : Bytes @@ -13,12 +14,16 @@ struct Image end class Entry - JSON.mapping zip_path: String, book_title: String, title: String, \ - size: String, pages: Int32, cover_url: String, mtime: Time + JSON.mapping zip_path: String, book_title: String, title: 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 file = Zip::File.new path @pages = file.entries @@ -28,7 +33,8 @@ class Entry } .size file.close - @cover_url = "/api/page/#{@book_title}/#{title}/1" + @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) @@ -54,23 +60,30 @@ class Entry end class Title - JSON.mapping dir: String, entries: Array(Entry), title: String, mtime: Time + JSON.mapping dir: String, entries: Array(Entry), title: String, + id: String, encoded_title: String, mtime: Time - def initialize(dir : String) + def initialize(dir : String, storage) @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| + Entry.new File.join(dir, 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 } + 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? @@ -81,7 +94,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 @@ -91,10 +104,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 @@ -142,9 +155,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 @@ -160,8 +174,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 @@ -171,7 +185,7 @@ 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 } .select { |title| !title.entries.empty? } .sort { |a, b| a.title <=> b.title } @logger.debug "Scan completed" diff --git a/src/mango.cr b/src/mango.cr index f45c496..38616ff 100644 --- a/src/mango.cr +++ b/src/mango.cr @@ -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 diff --git a/src/routes/api.cr b/src/routes/api.cr index c9cd1a0..be8e6d6 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -3,19 +3,20 @@ require "../mangadex/*" class APIRouter < Router def setup - get "/api/page/:title/:entry/:page" do |env| + get "/api/page/:tid/:eid/:page" do |env| begin - title = env.params.url["title"] - entry = env.params.url["entry"] + tid = env.params.url["tid"] + eid = env.params.url["eid"] 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? + 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 @@ -27,12 +28,11 @@ class APIRouter < Router get "/api/book/:title" do |env| begin - title = env.params.url["title"] + tid = env.params.url["tid"] + title = @context.library.get_title tid + raise "Title ID `#{tid}` not found" if title.nil? - t = @context.library.get_title title - raise "Title `#{title}` not found" if t.nil? - - send_json env, t.to_json + send_json env, title.to_json rescue e @context.error e env.response.status_code = 500 diff --git a/src/routes/reader.cr b/src/routes/reader.cr index 7c8984e..266396da 100644 --- a/src/routes/reader.cr +++ b/src/routes/reader.cr @@ -16,7 +16,7 @@ class ReaderRouter < Router # might not have actually read them page = [page - 2 * IMGS_PER_PAGE, 1].max - env.redirect "/reader/#{title.title}/#{entry.title}/#{page}" + env.redirect "/reader/#{title.id}/#{entry.id}/#{page}" rescue e @context.error e env.response.status_code = 404 @@ -37,16 +37,16 @@ class ReaderRouter < Router pages = (page...[entry.pages + 1, page + IMGS_PER_PAGE].min) urls = pages.map { |idx| - "/api/page/#{title.title}/#{entry.title}/#{idx}" } + "/api/page/#{title.id}/#{entry.id}/#{idx}" } reader_urls = pages.map { |idx| - "/reader/#{title.title}/#{entry.title}/#{idx}" } + "/reader/#{title.id}/#{entry.id}/#{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}" + "/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.title}/#{next_entry.title}" + "/reader/#{title.id}/#{next_entry.id}" render "src/views/reader.ecr" rescue e diff --git a/src/storage.cr b/src/storage.cr index 7e57199..8d20e15 100644 --- a/src/storage.cr +++ b/src/storage.cr @@ -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 diff --git a/src/views/index.ecr b/src/views/index.ecr index b099811..1aee880 100644 --- a/src/views/index.ecr +++ b/src/views/index.ecr @@ -23,7 +23,7 @@