From 8262a163db71d627413fbea5be3a9b1dc3d7261d Mon Sep 17 00:00:00 2001 From: Alex Ling Date: Wed, 8 Apr 2020 07:01:06 +0000 Subject: [PATCH] Finish the API endpoint for cover upload --- src/config.cr | 5 +- src/{ => handlers}/auth_handler.cr | 4 +- src/{ => handlers}/log_handler.cr | 2 +- src/{ => handlers}/static_handler.cr | 12 +-- src/handlers/upload_handler.cr | 28 ++++++ src/library.cr | 140 +++++++++++++++++++-------- src/routes/api.cr | 55 +++++++++++ src/server.cr | 5 +- src/storage.cr | 5 +- src/upload.cr | 60 ++++++++++++ src/util.cr | 13 ++- src/views/index.ecr | 6 +- src/views/title.ecr | 6 +- 13 files changed, 268 insertions(+), 73 deletions(-) rename src/{ => handlers}/auth_handler.cr (93%) rename src/{ => handlers}/log_handler.cr (96%) rename src/{ => handlers}/static_handler.cr (70%) create mode 100644 src/handlers/upload_handler.cr create mode 100644 src/upload.cr diff --git a/src/config.cr b/src/config.cr index b9caffc..5e57edf 100644 --- a/src/config.cr +++ b/src/config.cr @@ -4,11 +4,14 @@ class Config include YAML::Serializable property port : Int32 = 9000 - property library_path : String = File.expand_path "~/mango/library", home: true + property library_path : String = File.expand_path "~/mango/library", + home: true property db_path : String = File.expand_path "~/mango/mango.db", home: true @[YAML::Field(key: "scan_interval_minutes")] property scan_interval : Int32 = 5 property log_level : String = "info" + property upload_path : String = File.expand_path "~/mango/uploads", + home: true property mangadex = Hash(String, String | Int32).new @[YAML::Field(ignore: true)] diff --git a/src/auth_handler.cr b/src/handlers/auth_handler.cr similarity index 93% rename from src/auth_handler.cr rename to src/handlers/auth_handler.cr index 1a29b52..7ec0138 100644 --- a/src/auth_handler.cr +++ b/src/handlers/auth_handler.cr @@ -1,6 +1,6 @@ require "kemal" -require "./storage" -require "./util" +require "../storage" +require "../util" class AuthHandler < Kemal::Handler def initialize(@storage : Storage) diff --git a/src/log_handler.cr b/src/handlers/log_handler.cr similarity index 96% rename from src/log_handler.cr rename to src/handlers/log_handler.cr index e1a1f4f..105c9f1 100644 --- a/src/log_handler.cr +++ b/src/handlers/log_handler.cr @@ -1,5 +1,5 @@ require "kemal" -require "./logger" +require "../logger" class LogHandler < Kemal::BaseLogHandler def initialize(@logger : Logger) diff --git a/src/static_handler.cr b/src/handlers/static_handler.cr similarity index 70% rename from src/static_handler.cr rename to src/handlers/static_handler.cr index f03eda4..0287445 100644 --- a/src/static_handler.cr +++ b/src/handlers/static_handler.cr @@ -1,16 +1,16 @@ require "baked_file_system" require "kemal" -require "./util" +require "../util" class FS extend BakedFileSystem {% if flag?(:release) %} - {% if read_file? "#{__DIR__}/../dist/favicon.ico" %} - {% puts "baking ../dist" %} - bake_folder "../dist" + {% if read_file? "#{__DIR__}/../../dist/favicon.ico" %} + {% puts "baking ../../dist" %} + bake_folder "../../dist" {% else %} - {% puts "baking ../public" %} - bake_folder "../public" + {% puts "baking ../../public" %} + bake_folder "../../public" {% end %} {% end %} end diff --git a/src/handlers/upload_handler.cr b/src/handlers/upload_handler.cr new file mode 100644 index 0000000..1ef092b --- /dev/null +++ b/src/handlers/upload_handler.cr @@ -0,0 +1,28 @@ +require "kemal" +require "../util" + +class UploadHandler < Kemal::Handler + def initialize(@upload_dir : String) + end + + def call(env) + unless request_path_startswith(env, [UPLOAD_URL_PREFIX]) && + env.request.method == "GET" + return call_next env + end + + pp env.request.path + + ary = env.request.path.split(File::SEPARATOR).select { |part| !part.empty? } + ary[0] = @upload_dir + path = File.join ary + + pp path + + if File.exists? path + send_file env, path + else + env.response.status_code = 404 + end + end +end diff --git a/src/library.cr b/src/library.cr index 7ed5912..672111b 100644 --- a/src/library.cr +++ b/src/library.cr @@ -16,9 +16,8 @@ end class Entry property zip_path : String, book : Title, title : String, - size : String, pages : Int32, cover_url : String, id : String, - title_id : String, encoded_path : String, encoded_title : String, - mtime : Time + size : String, pages : Int32, id : String, title_id : String, + encoded_path : String, encoded_title : String, mtime : Time def initialize(path, @book, @title_id, storage) @zip_path = path @@ -33,17 +32,17 @@ class Entry end 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 to_json(json : JSON::Builder) json.object do - {% for str in ["zip_path", "title", "size", "cover_url", "id", - "title_id", "encoded_path", "encoded_title"] %} + {% for str in ["zip_path", "title", "size", "id", "title_id", + "encoded_path", "encoded_title"] %} json.field {{str}}, @{{str.id}} {% end %} json.field "display_name", @book.display_name @title + json.field "cover_url", cover_url json.field "pages" { json.number @pages } json.field "mtime" { json.number @mtime.to_unix } end @@ -57,6 +56,17 @@ class Entry URI.encode display_name end + def cover_url + url = "/api/page/#{@title_id}/#{@id}/1" + TitleInfo.new @book.dir do |info| + info_url = info.entry_cover_url[@title]? + unless info_url.nil? || info_url.empty? + url = info_url + end + end + url + end + def read_page(page_num) Zip::File.open @zip_path do |file| page = file.entries @@ -132,6 +142,7 @@ class Title json.field {{str}}, @{{str.id}} {% end %} json.field "display_name", display_name + json.field "cover_url", cover_url json.field "mtime" { json.number @mtime.to_unix } json.field "titles" do json.raw self.titles.to_json @@ -190,9 +201,12 @@ class Title end def display_name - info = TitleInfo.new @dir - dn = info.display_name - dn.empty? ? @title : dn + dn = @title + TitleInfo.new @dir do |info| + info_dn = info.display_name + dn = info_dn unless info_dn.empty? + end + dn end def encoded_display_name @@ -200,48 +214,80 @@ class Title end def display_name(entry_name) - info = TitleInfo.new @dir - dn = info.entry_display_name[entry_name]? - unless dn.nil? || dn.empty? - return dn + dn = entry_name + TitleInfo.new @dir do |info| + info_dn = info.entry_display_name[entry_name]? + unless info_dn.nil? || info_dn.empty? + dn = info_dn + end end - entry_name + dn end def set_display_name(dn) - info = TitleInfo.new @dir - info.display_name = dn - info.save + TitleInfo.new @dir do |info| + info.display_name = dn + info.save + end end def set_display_name(entry_name : String, dn) - info = TitleInfo.new @dir - info.entry_display_name[entry_name] = dn - info.save + TitleInfo.new @dir do |info| + info.entry_display_name[entry_name] = dn + info.save + end + end + + def cover_url + url = "img/icon.png" + if @entries.size > 0 + url = @entries[0].cover_url + end + TitleInfo.new @dir do |info| + info_url = info.cover_url + unless info_url.nil? || info_url.empty? + url = info_url + end + end + url + end + + def set_cover_url(url : String) + TitleInfo.new @dir do |info| + info.cover_url = url + info.save + end + end + + def set_cover_url(entry_name : String, url : String) + TitleInfo.new @dir do |info| + info.entry_cover_url[entry_name] = url + info.save + end 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? - info.progress[username] = {entry => page} + TitleInfo.new @dir do |info| + if info.progress[username]?.nil? + info.progress[username] = {entry => page} + else + info.progress[username][entry] = page + end info.save - return end - info.progress[username][entry] = page - info.save end def load_progress(username, entry) - info = TitleInfo.new @dir - if info.progress[username]?.nil? - return 0 + progress = 0 + TitleInfo.new @dir do |info| + unless info.progress[username]?.nil? || + info.progress[username][entry]?.nil? + progress = info.progress[username][entry] + end end - if info.progress[username][entry]?.nil? - return 0 - end - info.progress[username][entry] + progress end def load_percetage(username, entry) @@ -272,22 +318,32 @@ class TitleInfo include JSON::Serializable property comment = "Generated by Mango. DO NOT EDIT!" - # { user1: { entry1: 10, entry2: 0 } } property progress = {} of String => Hash(String, Int32) property display_name = "" - # { entry1 : "display name" } property entry_display_name = {} of String => String + property cover_url = "" + property entry_cover_url = {} of String => String @[JSON::Field(ignore: true)] property dir : String = "" - def initialize(@dir) - json_path = File.join @dir, "info.json" - if File.exists? json_path - info = TitleInfo.from_json File.read json_path - @progress = info.progress.clone - @display_name = info.display_name - @entry_display_name = info.entry_display_name.clone + @@mutex_hash = {} of String => Mutex + + def self.new(dir, &) + if @@mutex_hash[dir]? + mutex = @@mutex_hash[dir] + else + mutex = Mutex.new + @@mutex_hash[dir] = mutex + end + mutex.synchronize do + instance = TitleInfo.allocate + json_path = File.join dir, "info.json" + if File.exists? json_path + instance = TitleInfo.from_json File.read json_path + end + instance.dir = dir + yield instance end end diff --git a/src/routes/api.cr b/src/routes/api.cr index 71da618..3e75b41 100644 --- a/src/routes/api.cr +++ b/src/routes/api.cr @@ -1,5 +1,6 @@ require "./router" require "../mangadex/*" +require "../upload" class APIRouter < Router def setup @@ -197,5 +198,59 @@ class APIRouter < Router }.to_json end end + + post "/api/admin/upload/:target" do |env| + begin + target = env.params.url["target"] + + HTTP::FormData.parse env.request do |part| + next if part.name != "file" + + filename = part.filename + if filename.nil? + raise "No file uploaded" + end + + case target + when "cover" + title_id = env.params.query["title"] + entry_id = env.params.query["entry"]? + title = @context.library.get_title(title_id).not_nil! + + unless ["image/jpeg", "image/png"].includes? \ + MIME.from_filename? filename + raise "The uploaded image must be either JPEG or PNG" + end + + ext = File.extname filename + upload = Upload.new @context.config.upload_path, @context.logger + url = upload.path_to_url upload.save "img", ext, part.body + + if url.nil? + raise "Failed to generate a public URL for the uploaded file" + end + + if entry_id.nil? + title.set_cover_url url + else + entry_name = title.get_entry(entry_id).not_nil!.title + title.set_cover_url entry_name, url + end + else + raise "Unkown upload target #{target}" + end + + send_json env, {"success" => true}.to_json + env.response.close + end + + raise "No part with name `file` found" + rescue e + send_json env, { + "success" => false, + "error" => e.message, + }.to_json + end + end end end diff --git a/src/server.cr b/src/server.cr index 510542c..fef8412 100644 --- a/src/server.cr +++ b/src/server.cr @@ -1,8 +1,6 @@ require "kemal" require "./context" -require "./auth_handler" -require "./static_handler" -require "./log_handler" +require "./handlers/*" require "./util" require "./routes/*" @@ -29,6 +27,7 @@ class Server Kemal.config.logging = false add_handler LogHandler.new @context.logger add_handler AuthHandler.new @context.storage + add_handler UploadHandler.new @context.config.upload_path {% if flag?(:release) %} # when building for relase, embed the static files in binary @context.debug "We are in release mode. Using embedded static files." diff --git a/src/storage.cr b/src/storage.cr index c618cd3..6df6e63 100644 --- a/src/storage.cr +++ b/src/storage.cr @@ -2,6 +2,7 @@ require "sqlite3" require "crypto/bcrypt" require "uuid" require "base64" +require "./util" def hash_password(pw) Crypto::Bcrypt::Password.create(pw).to_s @@ -11,10 +12,6 @@ def verify_password(hash, pw) (Crypto::Bcrypt::Password.new hash).verify pw end -def random_str - UUID.random.to_s.gsub "-", "" -end - class Storage def initialize(@path : String, @logger : Logger) dir = File.dirname path diff --git a/src/upload.cr b/src/upload.cr new file mode 100644 index 0000000..c6d27be --- /dev/null +++ b/src/upload.cr @@ -0,0 +1,60 @@ +require "./util" + +class Upload + def initialize(@dir : String, @logger : Logger) + unless Dir.exists? @dir + @logger.info "The uploads directory #{@dir} does not exist. " \ + "Attempting to create it" + Dir.mkdir_p @dir + end + end + + # Writes IO to a file with random filename in the uploads directory and + # returns the full path of created file + # e.g., save("image", ".png", ) + # ==> "~/mango/uploads/image/.png" + def save(sub_dir : String, ext : String, io : IO) + full_dir = File.join @dir, sub_dir + filename = random_str + ext + file_path = File.join full_dir, filename + + unless Dir.exists? full_dir + @logger.debug "creating directory #{full_dir}" + Dir.mkdir_p full_dir + end + + File.open file_path, "w" do |f| + IO.copy io, f + end + + file_path + end + + # Converts path to a file in the uploads directory to the URL path for + # accessing the file. + def path_to_url(path : String) + dir_mathed = false + ary = [] of String + # We fill it with parts until it equals to @upload_dir + dir_ary = [] of String + + Path.new(path).each_part do |part| + if dir_mathed + ary << part + else + dir_ary << part + if File.same? @dir, File.join dir_ary + dir_mathed = true + end + end + end + + if ary.empty? + @logger.warn "File #{path} is not in the upload directory #{@dir}" + return + end + + ary.unshift UPLOAD_URL_PREFIX + File.join(ary).to_s + end +end diff --git a/src/util.cr b/src/util.cr index 761fb30..6f6dac3 100644 --- a/src/util.cr +++ b/src/util.cr @@ -1,6 +1,7 @@ require "big" -IMGS_PER_PAGE = 5 +IMGS_PER_PAGE = 5 +UPLOAD_URL_PREFIX = "/uploads" macro layout(name) begin @@ -27,9 +28,9 @@ macro get_username(env) (@context.storage.verify_token cookie.value).not_nil! end -macro send_json(env, json) - {{env}}.response.content_type = "application/json" - {{json}} +def send_json(env, json) + env.response.content_type = "application/json" + env.response.print json end def hash_to_query(hash) @@ -81,3 +82,7 @@ end def compare_alphanumerically(a : String, b : String) compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b) end + +def random_str + UUID.random.to_s.gsub "-", "" +end diff --git a/src/views/index.ecr b/src/views/index.ecr index 543394f..63508bb 100644 --- a/src/views/index.ecr +++ b/src/views/index.ecr @@ -26,11 +26,7 @@