# -*- coding: utf-8 -*-
#
# Copyright 2013 whiteleaf. All rights reserved.
#
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 "../logger"
require_relative "../commandline"
require_relative "../inventory"
require_relative "worker"
require_relative "pushserver"
require_relative "settingmessages"
module Narou::ServerHelpers
#
# タグをHTMLで装飾する
#
def decorate_tags(tags)
tags.sort.map do |tag|
%!#{escape_html(tag)}!
end.join(" ")
end
#
# タグをHTMLで装飾する(除外タグ指定用)
#
def decorate_exclusion_tags(tags)
tags.sort.map do |tag|
%!^tag:#{escape_html(tag)}!
end.join(" ")
end
#
# Rubyバージョンを構築
#
def build_ruby_version
begin
`"#{RbConfig.ruby}" -v`.strip
rescue
config = RbConfig::CONFIG
"ruby #{RUBY_VERSION}p#{config["PATCHLEVEL"]} [#{RUBY_PLATFORM}]"
end
end
#
# 有効な novel ID だけの配列を生成する
# ID が指定されなかったか、1件も存在しない場合は nil を返す
#
def select_valid_novel_ids(ids)
return nil unless ids.kind_of?(Array)
result = ids.select do |id|
id =~ /^\d+$/
end
result.empty? ? nil : result
end
#
# フォーム情報の真偽値データを実際のデータに変換
#
def convert_on_off_to_boolean(str)
case str
when "on"
true
when "off"
false
else
nil
end
end
#
# nil true false を nil on off という文字列に変換
#
def convert_boolean_to_on_off(bool)
case bool
when TrueClass
"on"
when FalseClass
"off"
else
"nil"
end
end
#
# HTMLエスケープヘルパー
#
def h(text)
Rack::Utils.escape_html(text)
end
#
# 与えられたデータが真偽値だった場合、設定画面用に「はい」「いいえ」に変換する
# 真偽値ではなかった場合、そのまま返す
#
def value_to_msg(value)
case value
when TrueClass
"はい"
when FalseClass
"いいえ"
else
value
end
end
def notepad_text_path
File.join(Narou.local_setting_dir, "notepad.txt")
end
end
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.get_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
end
def puts_hello_messages
puts "Narou.rb version #{::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
# ===================================================================
# ルーティング
# ===================================================================
before do
@bootstrap_theme = case params["theme"]
when nil
Narou.get_theme
when "" # 環境設定画面で未設定が選択された時
nil
else
params["theme"]
end
Narou::Worker.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 = []
output = ""
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?
$stdout.silence do
setting = Command::Setting.new
setting.on(:error) do |msg, name|
if name
@error_list[name] = msg
end
end
begin
setting.execute(built_arguments)
rescue SystemExit
end
end
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
haml :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"
path = Narou.get_ebook_file_path(@id, ext)
if File.exist?(path)
send_file(path, filename: File.basename(path), type: "application/octet-stream")
else
not_found
end
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
database_values = Database.instance.get_object.values
json_objects = {
draw: 1, recordsTotal: database_values.count, recordsFiltered: database_values.count
}
json_objects[:data] =
database_values.map do |data|
tags = data["tags"] || []
id = data["id"]
{
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: (tags.empty? ? "" : decorate_tags(tags) + ' '),
status: [
Narou.novel_frozen?(id) ? "凍結" : nil,
tags.include?("end") ? "完結" : nil,
tags.include?("404") ? "削除" : nil,
].compact.join(", "),
download: %!!,
frozen: Narou.novel_frozen?(id),
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 },
}
end
json json_objects
end
post "/api/cancel" do
Narou::Worker.cancel
end
post "/api/convert" do
ids = select_valid_novel_ids(params["ids"]) or pass
Narou::Worker.push do
CommandLine.run!(["convert", "--no-open", ids])
end
end
post "/api/download" do
targets = params["targets"] or pass
targets = targets.kind_of?(Array) ? targets : targets.split
pass if targets.size == 0
Narou::Worker.push do
CommandLine.run!(["download"] + targets)
@@push_server.send_all(:"table.reload")
end
end
post "/api/download_force" do
ids = select_valid_novel_ids(params["ids"]) or pass
Narou::Worker.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::Worker.push do
CommandLine.run!(["mail", ids])
end
end
post "/api/update" do
ids = select_valid_novel_ids(params["ids"]) || []
opt_arguments = []
if params["force"] == "true"
opt_arguments << "--force"
end
Narou::Worker.push do
cmd = Command::Update.new
cmd.on(:success) do
@@push_server.send_all(:"table.reload")
end
cmd.execute!([ids, opt_arguments].flatten)
@@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::Worker.push do
cmd = Command::Update.new
cmd.on(:success) do
@@push_server.send_all(:"table.reload")
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::Worker.push do
CommandLine.run!(["send", ids])
end
end
post "/api/freeze" do
ids = select_valid_novel_ids(params["ids"]) or pass
Narou::Worker.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::Worker.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::Worker.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::Worker.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::Worker.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"
Narou::Worker.push do
# diff コマンドは1度に一つのIDしか受け取らないので
ids.each do |id|
# セキュリティ的にWEB UIでは独自の差分表示のみ使う
CommandLine.run!(["diff", "--no-tool", id, "--number", number])
Helper.print_horizontal_rule
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::Worker.push do
CommandLine.run!(%W(diff --clean #{id}))
end
end
post "/api/inspect" do
ids = select_valid_novel_ids(params["ids"]) or pass
Narou::Worker.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::Worker.push do
CommandLine.run!(["backup", ids])
end
end
post "/api/clear_history" do
Narou::PushServer.instance.clear_history
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
get "/api/taginfo.json" do
ids = select_valid_novel_ids(params["ids"]) or pass
ids.map!(&:to_i)
database = Database.instance
tag_info = {}
database.each 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 }
$stdout.silence do
invert_states.each do |state, tags|
case state.to_i
when 0
# タグを削除
CommandLine.run!(["tag", "--delete", tags.join(" "), ids])
when 1
# 現状を維持(何もしない)
when 2
# タグを追加
CommandLine.run!(["tag", "--add", tags.join(" "), ids])
end
end
end
@@push_server.send_all(:"table.reload")
@@push_server.send_all(:"tag.updateCanvas")
end
get "/api/get_queue_size" do
res = {
size: Narou::Worker.instance.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::Worker.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::Worker.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/download4ie" do
Narou::Worker.push do
CommandLine.run!(%W(download #{params["target"]}))
@@push_server.send_all(:"table.reload")
end
redirect "/resources/images/dl_button1.gif"
end
get "/api/validate_url_regexp_list" do
json Downloader.load_settings.map { |setting|
"(#{setting["url"].gsub(/\?<.+?>/, "?:").gsub("\\", "\\\\")})"
}
end
get "/api/version/current.json" do
json({ version: ::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::Worker.push do
do_eject.call
end
else
do_eject.call
end
""
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
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|
Downloader.load_settings.each 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を指定して下さい")
Narou::Worker.push do
CommandLine.run!(["download", target])
@@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