# frozen_string_literal: true # # Copyright 2013 whiteleaf. All rights reserved. # # rubocop:disable Metrics/ClassLength # rubocop:disable Style/ClassAndModuleChildren require "socket" require "sinatra/base" require "sinatra/json" require "sinatra/reloader" if $development # require "better_errors" if $debug require "tilt/erubis" require "tilt/haml" require "tilt/sass" require_relative "../commandline" require_relative "../inventory" require_relative "web_worker" require_relative "pushserver" require_relative "settingmessages" require_relative "server_helpers" class Narou::AppServer < Sinatra::Base register Sinatra::Reloader if $development helpers Narou::ServerHelpers @@request_reboot = false @@already_update_system = false @@gem_update_last_log = "" configure do set :app_file, __FILE__ set :erb, trim: "-" enable :protection enable :sessions set(:version) do Command::Version.create_version_string end set :environment, :production unless $development set :server, :webrick if $debug use BetterErrors::Middleware BetterErrors.application_root = Narou.script_dir end end def self.push_server=(server) @@push_server = server end def self.push_server @@push_server end def self.request_reboot @@request_reboot = true end def self.request_reboot? @@request_reboot end # # サーバのアドレスを生成 # # portは初回起動時にランダムで設定する。次回からは同じ設定を引き継ぐ。 # bindは自分で設定する場合は narou s server-bind=address で行う。 # bindは設定しなかった場合は起動したPCのプライベートIPアドレスが設定される。 # この場合はLAN内からアクセス出来る。 # bindがlocalhostの場合は実際には127.0.0.1で処理される。(起動したPCでしか # アクセス出来ない) # 0.0.0.0 を指定した場合はアクセスに制限がかからない(外部からアクセス可能) # セキュリティ上オススメ出来ない。 # def self.create_address(user_port = nil) global_setting = Inventory.load("global_setting", :global) port, bind = global_setting["server-port"], global_setting["server-bind"] port = user_port if user_port ipaddress = my_ipaddress unless port port = rand(4000..65000) global_setting["server-port"] = port global_setting.save end bind = "127.0.0.1" if bind == "localhost" host = bind ? bind : ipaddress set :port, port set :bind, host { host: host, port: port } end # # 自分のIPアドレス取得 # # 参考:http://qiita.com/saltheads/items/cc49fcf2af37cb277c4f # def self.my_ipaddress @@__ipaddress ||= -> { udp = UDPSocket.new begin # 128.0.0.0 への送信に使用されるNICのアドレスを取得 udp.connect("128.0.0.0", 7) Socket.unpack_sockaddr_in(udp.getsockname)[1] rescue Errno::ENETUNREACH # 128.0.0.0 へのルーティングがないとき "127.0.0.1" ensure udp.close end }.call end def initialize super puts_hello_messages start_device_ejectable_event fill_general_all_no_in_database setup_server_authentication end def puts_hello_messages puts "Narou.rb version #{Narou::VERSION}".termcolor end def start_device_ejectable_event return unless Device.support_eject? Thread.new do loop do if @@push_server.connections.count > 0 device = Narou.get_device @@push_server.send_all(:"device.ejectable" => device && device.ejectable?) end sleep 2 end end end def general_all_no_by_toc(id) toc = Downloader.new(id).load_toc_file return nil unless toc toc["subtitles"].size end # 話数の設定されていない小説の話数を取得して埋める def fill_general_all_no_in_database modified = false Database.instance.each do |id, data| next if data["general_all_no"] data["general_all_no"] = general_all_no_by_toc(id) modified = true end Database.instance.save_database if modified end # サーバーの認証の設定 # とりあえずDigest認証のみ def setup_server_authentication auth = Inventory.load("global_setting", :global).group("server-digest-auth") user = auth.user hashed = auth.hashed_password passwd = hashed || auth.password # enableかつユーザー名とパスワードが設定されている時のみ認証を有効にする return unless auth.enable && user && passwd self.class.class_exec do use Rack::Auth::Digest::MD5, { realm: "narou.rb", opaque: "", passwords_hashed: hashed } do |username| passwd if username == user end end end # =================================================================== # ルーティング # =================================================================== before do headers "Cache-Control" => "no-cache" if $development @bootstrap_theme = case params["webui.theme"] when nil Narou.theme when "" # 環境設定画面で未設定が選択された時 nil else params["webui.theme"] end Narou::WebWorker.push_as_system_worker do Inventory.clear Database.instance.refresh Narou.load_global_replace_pattern end end get "/" do setting = Inventory.load("server_setting", :global) @is_first_access = !setting["already-accessed"] if @is_first_access setting["already-accessed"] = true setting.save end haml :index, layout: true end get "/style.css" do scss :style end before "/settings" do @title = "環境設定" @setting_variables = Command::Setting.get_setting_variables @error_list = {} @global_replace_pattern = @replace_pattern = Narou.global_replace_pattern end post "/settings" do built_arguments = [] device = params.delete("device") [:local, :global].each do |scope| @setting_variables[scope].each do |name, info| param_data = params[name] argument = "" if info[:type] == :boolean # :boolean 用のフォームデータは on, off, nil で渡される。 # ただしチェックボックスはチェックした時だけ on が渡されるので、 # 何もデータが無い=off を選択したと判断する。 # 隠しデータの場合は hidden として on, off, nil が必ず送信されるので、 # それで判断できる。 if param_data argument = convert_on_off_to_boolean(param_data).to_s else argument = "false" end elsif param_data.kind_of?(Array) argument = param_data.join(",") else argument = param_data end built_arguments << "#{name}=#{argument}" end end # device の項目だけ関連項目を変更するという挙動をするため、変更を上書き # されないように最後にまわす built_arguments << "device=#{device}" if device unless built_arguments.empty? setting = Command::Setting.new setting.on(:error) do |msg, name| if name @error_list[name] = msg end end setting.execute!(built_arguments, io: Narou::NullIO.new) Inventory.clear end # 置換設定保存 params_replace_pattern = params["replace_pattern"] @global_replace_pattern.clear if params_replace_pattern.kind_of?(Array) params_replace_pattern.each do |pattern| left, right = pattern["left"].strip, pattern["right"].strip next if left == "" @global_replace_pattern << [left, right] end end Narou.save_global_replace_pattern if @error_list.empty? session[:alert] = [ "保存が完了しました", "success" ] else session[:alert] = [ "#{@error_list.size}個の設定にエラーがありました", "danger" ] end redirect to "/settings" end get "/settings" do haml :settings end get "/help" do @title = "ヘルプ" haml :help end get "/about" do @narourb_version = settings.version @ruby_version = build_ruby_version haml :_about, layout: false end post "/shutdown" do self.class.quit! "シャットダウンしました。再起動するまで操作は出来ません" end post "/reboot" do self.class.request_reboot self.class.quit! haml :_rebooting, layout: false end post "/update_system" do Thread.new do buffer = `gem update --no-document narou` @@gem_update_last_log = buffer.strip! if buffer =~ /Nothing to update\z/ @@push_server.send_all("server.update.nothing" => buffer) elsif buffer.include?("Gems updated: narou") @@already_update_system = true @@push_server.send_all("server.update.success" => buffer) else @@push_server.send_all("server.update.failure" => buffer) end end end post "/gem_update_last_log" do content_type "text/plain" @@gem_update_last_log end post "/check_already_update_system" do json({ result: @@already_update_system }) end before "/novels/:id/*" do @id = params[:id] not_found unless @id =~ /^\d+$/ @data = Downloader.get_data_by_target(@id) not_found unless @data end before "/novels/:id/setting" do @novel_title = @data["title"] @title = "小説の変換設定 - #{h @novel_title}" @setting_variables = [] @error_list = {} @novel_setting = NovelSetting.new(@id, true, true) # 空っぽの設定を作成 @novel_setting.settings = @novel_setting.load_setting_ini["global"] @original_settings = NovelSetting.get_original_settings @force_settings = NovelSetting.load_force_settings @default_settings = NovelSetting.load_default_settings @replace_pattern = @novel_setting.load_replace_pattern end post "/novels/:id/setting" do # 変換設定保存 @original_settings.each do |info| name, type = info[:name], info[:type] param_data = params[name] value = nil begin if type == :boolean if param_data value = convert_on_off_to_boolean(param_data) else value = false end elsif param_data.kind_of?(Array) value = param_data.join(",") else if param_data.strip != "" value = Helper.string_cast_to_type(param_data, type) end end @novel_setting[name] = value rescue Helper::InvalidVariableType => e @error_list[name] = e.message end end @novel_setting.save_settings # 置換設定保存 params_replace_pattern = params["replace_pattern"] @novel_setting.replace_pattern.clear if params_replace_pattern.kind_of?(Array) params_replace_pattern.each do |pattern| left, right = pattern["left"].strip, pattern["right"].strip next if left == "" @novel_setting.replace_pattern << [left, right] end end @novel_setting.save_replace_pattern if @error_list.empty? session[:alert] = [ "保存が完了しました", "success" ] else session[:alert] = [ "#{@error_list.size}個の設定にエラーがありました", "danger" ] end haml :"novels/setting" end get "/novels/:id/setting" do haml :"novels/setting" end get "/novels/:id/download" do device = Narou.get_device ext = device ? device.ebook_file_ext : ".epub" paths = Narou.get_ebook_file_paths(@id, ext) if !paths.empty? && File.exist?(paths[0]) send_file(paths[0], filename: File.basename(paths[0]), type: "application/octet-stream") else not_found end end get "/novels/:id/author_comments" do downloader = Downloader.new(@id) toc = downloader.load_toc_file @comments = [] introductions_count = 0 postscripts_count = 0 toc["subtitles"].each do |sub| begin element = YAML.load_file(downloader.section_file_path(sub))["element"] data_type = element["data_type"] || "text" introduction = element["introduction"] || "" postscript = element["postscript"] || "" if data_type == "html" html = HTML.new html.strip_decoration_tag = true html.string = introduction introduction = html.to_aozora html.string = postscript postscript = html.to_aozora end @comments.push( sub: sub, introduction: introduction, postscript: postscript ) introductions_count += 1 unless introduction.empty? postscripts_count += 1 unless postscript.empty? rescue Errno::ENOENT end end total = toc["subtitles"].count.to_f @introductions_ratio = (introductions_count / total * 100).round(2) @postscripts_ratio = (postscripts_count / total * 100).round(2) haml :"novels/author_comments" end get "/notepad" do @title = "メモ帳" haml :notepad end get "/edit_menu" do @title = "個別メニューの編集" haml :edit_menu end not_found do "not found" end # ------------------------------------------------------------------------------- # API's # ------------------------------------------------------------------------------- get "/api/list" do view_frozen = query_to_boolean(params["view_frozen"], default: true) view_nonfrozen = query_to_boolean(params["view_nonfrozen"], default: true) database_values = Database.instance.get_object.values json_objects = { draw: 1 } json_objects[:data] = database_values.map do |data| id = data["id"] is_frozen = Narou.novel_frozen?(id) next nil if !view_frozen && is_frozen next nil if !view_nonfrozen && !is_frozen tags = data["tags"] || [] { id: id.to_s, last_update: data["last_update"].to_i, title: escape_html(data["title"]), author: escape_html(data["author"]), sitename: data["sitename"], toc_url: data["toc_url"], novel_type: data["novel_type"] == 2 ? "短編" : "連載", tags: if tags.empty? "" else %!#{decorate_tags(tags)}  ! end, status: [ is_frozen ? "凍結" : nil, tags.include?("end") ? "完結" : nil, tags.include?("404") ? "削除" : nil, data["suspend"] ? "中断" : nil ].compact.join(", "), download: %!!, frozen: is_frozen, new_arrivals_date: data["new_arrivals_date"].tap { |m| break m.to_i if m }, general_lastup: data["general_lastup"].tap { |m| break m.to_i if m }, # 掲載話数 general_all_no: data["general_all_no"], last_check_date: data["last_check_date"].tap { |m| break m.to_i if m }, length: data["length"], } end.compact json_objects[:recordsTotal] = json_objects[:data].size json_objects[:recordsFiltered] = json_objects[:recordsTotal] json json_objects end post "/api/cancel" do Narou::WebWorker.cancel Narou::Worker.cancel if Narou.concurrency_enabled? end post "/api/convert" do ids = select_valid_novel_ids(params["ids"]) or pass concurrency_push do CommandLine.run!("convert", "--no-open", ids) end end post "/api/download" do headers "Access-Control-Allow-Origin" => "*" targets = params["targets"] or error("need a parameter: `targets'") targets = targets.kind_of?(Array) ? targets : targets.split opt_mail = "--mail" if query_to_boolean(params["mail"]) pass if targets.size == 0 Narou::WebWorker.push do CommandLine.run!("download", targets, opt_mail) @@push_server.send_all(:"table.reload") end end post "/api/download_force" do ids = select_valid_novel_ids(params["ids"]) or pass Narou::WebWorker.push do CommandLine.run!("download", "--force", ids) @@push_server.send_all(:"table.reload") end end post "/api/mail" do ids = select_valid_novel_ids(params["ids"]) || [] Narou::WebWorker.push do Narou.concurrency_call do CommandLine.run!("mail", ids, io: $stdout2) end end end post "/api/update" do ids = select_valid_novel_ids(params["ids"]) || [] opt_arguments = [] if params["force"] == "true" opt_arguments << "--force" end Narou::WebWorker.push do puts "更新を開始します".termcolor cmd = Command::Update.new if table_reload_timing == "every" cmd.on(:success) do @@push_server.send_all(:"table.reload") end end cmd.execute!(ids, opt_arguments) @@push_server.send_all(:"table.reload") end end post "/api/update_by_tag" do tags = params["tags"] || [] exclusion_tags = params["exclusion_tags"] || [] tag_params = tags.map do |tag| "tag:#{tag}" end tag_params += exclusion_tags.map do |tag| "^tag:#{tag}" end pass if tag_params.empty? Narou::WebWorker.push do cmd = Command::Update.new if table_reload_timing == "every" cmd.on(:success) do @@push_server.send_all(:"table.reload") end end cmd.execute!(tag_params) @@push_server.send_all(:"table.reload") end end post "/api/send" do ids = select_valid_novel_ids(params["ids"]) || [] Narou::WebWorker.push do Narou.concurrency_call do CommandLine.run!("send", ids, io: $stdout2) end end end post "/api/backup_bookmark" do Narou::WebWorker.push do CommandLine.run!("send", "--backup-bookmark") end end post "/api/freeze" do ids = select_valid_novel_ids(params["ids"]) or pass Narou::WebWorker.push do CommandLine.run!("freeze", ids) @@push_server.send_all(:"table.reload") end end post "/api/freeze_on" do ids = select_valid_novel_ids(params["ids"]) or pass Narou::WebWorker.push do CommandLine.run!("freeze", "--on", ids) @@push_server.send_all(:"table.reload") end end post "/api/freeze_off" do ids = select_valid_novel_ids(params["ids"]) or pass Narou::WebWorker.push do CommandLine.run!("freeze", "--off", ids) @@push_server.send_all(:"table.reload") end end post "/api/remove" do ids = select_valid_novel_ids(params["ids"]) or pass opt_arguments = [] if params["with_file"] == "true" opt_arguments << "--with-file" end Narou::WebWorker.push do CommandLine.run!("remove", "--yes", ids, opt_arguments) @@push_server.send_all(:"table.reload") end end post "/api/remove_with_file" do ids = select_valid_novel_ids(params["ids"]) or pass Narou::WebWorker.push do CommandLine.run!("remove", "--yes", "--with-file", ids) @@push_server.send_all(:"table.reload") end end post "/api/diff" do ids = select_valid_novel_ids(params["ids"]) or pass number = params["number"] || "1" disabled_log_io = $stdout.dup_with_disabled_logging Narou::WebWorker.push do # diff コマンドは1度に一つのIDしか受け取らないので一つずつ表示する ids.each do |id| # セキュリティ的にWEB UIでは独自の差分表示のみ使う CommandLine.run!("diff", "--no-tool", id, "--number", number) Helper.print_horizontal_rule(disabled_log_io) end end end get "/api/diff_list" do target = params["target"] or return "" id = Downloader.get_id_by_target(target) or return "" @list = Command::Diff.new.get_diff_list(id) haml :_diff_list, layout: false end post "/api/diff_clean" do target = params["target"] or pass id = Downloader.get_id_by_target(target) or pass Narou::WebWorker.push do CommandLine.run!("diff", "--clean", id) end end post "/api/inspect" do ids = select_valid_novel_ids(params["ids"]) or pass Narou::WebWorker.push do CommandLine.run!("inspect", ids) end end post "/api/folder" do ids = select_valid_novel_ids(params["ids"]) or pass CommandLine.run!("folder", ids) end post "/api/backup" do ids = select_valid_novel_ids(params["ids"]) or pass Narou::WebWorker.push do CommandLine.run!("backup", ids) end end get "/api/history" do case params["stream"] when "stdout2" $stdout2.string else $stdout.string end end post "/api/clear_history" do Narou::PushServer.instance.clear_history $stdout.string.clear $stdout2.string.clear if Narou.concurrency_enabled? end get "/api/tag_list" do result = +'
タグ検索を解除
' \ '
Altキーを押しながらで除外検索
' tagname_list = Command::Tag.get_tag_list.keys tagname_list.sort.each do |tagname| result << "
#{decorate_tags([tagname])} " \ "" \ "a
" end result end post "/api/taginfo.json" do ids = select_valid_novel_ids(params["ids"]) or pass ids.map!(&:to_i) database = Database.instance tag_info = {} database.each_value do |data| tags = data["tags"] || [] tags.each do |tag| tag_info[tag] ||= { count: 0, tag: tag, html: decorate_tags([tag]), exclusion_html: params["with_exclusion"] ? decorate_exclusion_tags([tag]) : "" } if ids.include?(data["id"]) tag_info[tag][:count] += 1 end end end json Hash[tag_info.sort_by { |k, v| k }].values end post "/api/edit_tag" do ids = select_valid_novel_ids(params["ids"]) or pass # key と value を重複を維持したまま反転 invert_states = params["states"].inject({}) { |h,(k,v)| (h[v] ||= []) << k; h } invert_states.each do |state, tags| case state.to_i when 0 # タグを削除 Command::Tag.execute!("--delete", tags.join(" "), ids, io: Narou::NullIO.new) when 1 # 現状を維持(何もしない) when 2 # タグを追加 Command::Tag.execute!("--add", tags.join(" "), ids, io: Narou::NullIO.new) end end @@push_server.send_all(:"table.reload") @@push_server.send_all(:"tag.updateCanvas") end get "/api/get_queue_size" do res = [ Narou::WebWorker.instance.size, Narou::Worker.size ] json res end post "/api/update_general_lastup" do option = params["option"] option = nil if option == "all" is_update_modified = params["is_update_modified"] == "true" Narou::WebWorker.push do CommandLine.run!(["update", "--gl", option].compact) @@push_server.send_all(:"table.reload") @@push_server.send_all(:"tag.updateCanvas") if is_update_modified puts "#{Narou::MODIFIED_TAG} タグの付いた小説を更新します".termcolor CommandLine.run!("update", "tag:#{Narou::MODIFIED_TAG}") @@push_server.send_all(:"table.reload") @@push_server.send_all(:"tag.updateCanvas") end end end post "/api/setting_burn" do ids = select_valid_novel_ids(params["ids"]) or pass Narou::WebWorker.push do CommandLine.run!("setting", "--burn", ids) end end post "/api/change_tag_color" do tag = params["tag"] or pass color = params["color"] or pass tag_colors = Inventory.load("tag_colors") tag_colors[tag] = color tag_colors.save @@push_server.send_all(:"table.reload") @@push_server.send_all(:"tag.updateCanvas") end get "/api/csv/download" do content_type "application/csv" attachment "novels.csv" Command::Csv.new.generate end post "/api/csv/import" do files = params["files"] or pass csv = Command::Csv.new files.each do |file| csv.import(file[:tempfile]) end "" end # ダウンロード登録すると同時にグレーのボタン画像を返す get "/api/download4ssl" do target = params["target"] or error("need a parameter: `target'") opt_mail = "--mail" if query_to_boolean(params["mail"]) Narou::WebWorker.push do CommandLine.run!("download", target, opt_mail) @@push_server.send_all(:"table.reload") end redirect "/resources/images/dl_button1.gif" end # ダウンロード済みかどうかで表示が変わる画像 get "/api/downloadable.gif" do target = params["target"] # 0: 未ダウンロード, 1: ダウンロード済み, 2: ダウンロード出来ない number = if target Downloader.get_id_by_target(target) ? 1 : 0 else 2 end redirect "/resources/images/dl_button#{number}.gif" end get "/api/validate_url_regexp_list" do json SiteSetting.settings.values.map { |setting| Array(setting["url"]).map do |url| "(#{url.gsub(/\?<.+?>/, "?:").gsub("\\", "\\\\")})" end }.flatten end get "/api/version/current.json" do json({ version: Narou::VERSION }) end get "/api/version/latest.json" do json({ version: Narou.latest_version }) end get "/api/notepad/read" do content_type "text/plain" if File.exist?(notepad_text_path) File.read(notepad_text_path) else "" end end post "/api/notepad/save" do File.write(notepad_text_path, params["text"]) @@push_server.send_all("notepad.change" => { text: params["text"], object_id: params["object_id"] }) "" end post "/api/eject" do do_eject = proc do device = Narou.get_device device.eject if device puts "端末を取り外しました".termcolor end if params["enqueue"] == "true" Narou::WebWorker.push do Narou.concurrency_call(&do_eject) end else do_eject.call end "" end get "/api/story" do id = params["id"] or pass toc = Downloader.get_toc_by_target(id) story = toc["story"] || "" html = HTML.new json title: toc["title"], story: html.ln_to_br(story.strip) end # ------------------------------------------------------------------------------- # 一部分に表示するためのHTMLを取得する(パーシャル) # ------------------------------------------------------------------------------- get "/partial/csv_import" do haml :"partial/csv_import", layout: false end get "/partial/download_form" do haml :"partial/download_form", layout: false end # ------------------------------------------------------------------------------- # ウィジット関係 # ------------------------------------------------------------------------------- BOOKMARKLET_MODE = %w(download insert_button) get "/js/widget.js" do @params = params if BOOKMARKLET_MODE.include?(params["mode"]) content_type :js erb :"bookmarklet/#{params['mode']}.js" else error("invaid mode") end end ALLOW_HOSTS = [].tap do |hosts| SiteSetting.settings.each_value do |s| hosts << s["domain"] end hosts.freeze end before "/widget/*" do from = params["from"] if ALLOW_HOSTS.include?(from) headers "X-Frame-Options" => "ALLOW-FROM http://#{from}/" end end get "/widget/download" do target = params["target"] or error("targetを指定して下さい") mail = query_to_boolean(params["mail"]) ? "--mail" : nil Narou::WebWorker.push do CommandLine.run!("download", target, mail) @@push_server.send_all(:"table.reload") end haml :"widget/download", layout: nil end get "/widget/drag_and_drop" do haml :"widget/drag_and_drop", layout: nil end get "/widget/notepad" do haml :"widget/notepad", layout: nil end end