# frozen_string_literal: true require "json" module Logster module Middleware class Viewer PATH_INFO = "PATH_INFO".freeze SCRIPT_NAME = "SCRIPT_NAME".freeze REQUEST_METHOD = "REQUEST_METHOD".freeze def initialize(app) @app = app @logs_path = Logster.config.subdirectory @path_regex = Regexp.new("^(#{@logs_path}$)|^(#{@logs_path}(/.*))$") (@store = Logster.store) || raise(ArgumentError.new("store")) @assets_path = File.expand_path("../../../../assets", __FILE__) @fileserver = Rack::Files.new(@assets_path) end def call(env) path = env[PATH_INFO] script_name = env[SCRIPT_NAME] path = script_name + path if script_name && script_name.length > 0 if resource = resolve_path(path) if resource =~ /\.ico$|\.js$|\.png|\.handlebars$|\.css$|\.woff$|\.ttf$|\.woff2$|\.svg$|\.otf$|\.eot$/ serve_file(env, resource) elsif resource.start_with?("/messages.json") && env[REQUEST_METHOD] == "POST" serve_messages(Rack::Request.new(env)) elsif resource =~ %r{/message/([0-9a-f]+)$} return method_not_allowed("DELETE") if env[REQUEST_METHOD] != "DELETE" key = $1 message = Logster.store.get(key) return 404, {}, ["Message not found"] unless message Logster.store.delete(message) [301, { "Location" => "#{@logs_path}/" }, []] elsif resource =~ %r{/(un)?protect/([0-9a-f]+)$} off = $1 == "un" key = $2 message = Logster.store.get(key) return 404, {}, ["Message not found"] unless message if off if Logster.store.unprotect(key) [301, { "Location" => "#{@logs_path}/show/#{key}?protected=false" }, []] else [500, {}, ["Failed"]] end else if Logster.store.protect(key) [301, { "Location" => "#{@logs_path}/show/#{key}?protected=true" }, []] else [500, {}, ["Failed"]] end end elsif resource =~ %r{/solve/([0-9a-f]+)$} key = $1 message = Logster.store.get(key) return 404, {}, ["Message not found"] unless message Logster.store.solve(key) [301, { "Location" => "#{@logs_path}" }, []] elsif resource =~ %r{/clear$} return method_not_allowed("POST") if env[REQUEST_METHOD] != "POST" Logster.store.clear [200, {}, ["Messages cleared"]] elsif resource =~ %r{/show/([0-9a-f]+)(\.json)?$} key = $1 json = $2 == ".json" message = Logster.store.get(key) return 404, {}, ["Message not found"] unless message if json [200, { "content-type" => "application/json; charset=utf-8" }, [message.to_json]] else preload = { "/show/#{key}" => message } js_app(preload) end elsif resource =~ %r{/settings(\.json)?$} json = $1 == ".json" if json ignore_count = Logster.store.get_all_ignore_count suppression = [] Logster.store.ignore&.each do |pattern| string_pattern = Regexp === pattern ? pattern.inspect : pattern.to_s count = ignore_count[string_pattern] || 0 suppression << { value: string_pattern, count: count, hard: true } end Logster::SuppressionPattern .find_all(raw: true) .each do |pattern| count = ignore_count[pattern] || 0 suppression << { value: pattern, count: count } end grouping = Logster::GroupingPattern.find_all(raw: true).map { |pattern| { value: pattern } } [ 200, { "content-type" => "application/json; charset=utf-8" }, [JSON.generate(suppression: suppression, grouping: grouping)], ] else js_app end elsif resource =~ %r{/patterns/([a-zA-Z0-9_]+)\.json$} unless Logster.config.enable_custom_patterns_via_ui return( not_allowed( "Custom patterns via the UI is disabled. You can enable it by committing this line to your app source code:\nLogster.config.enable_custom_patterns_via_ui = true", ) ) end set_name = $1 req = Rack::Request.new(env) return method_not_allowed(%w[POST PUT DELETE]) if req.request_method == "GET" update_patterns(set_name, req) elsif resource == "/reset-count.json" req = Rack::Request.new(env) return method_not_allowed("PUT") if req.request_method != "PUT" pattern = nil if [true, "true"].include?(req.params["hard"]) pattern = Logster.store.ignore.find do |patt| str = Regexp === patt ? patt.inspect : patt.to_s str == req.params["pattern"] end else pattern = Logster::SuppressionPattern .find_all(raw: true) .find { |patt| patt == req.params["pattern"] } end return not_found("Pattern not found") unless pattern pattern = Regexp === pattern ? pattern.inspect : pattern.to_s Logster.store.remove_ignore_count(pattern) [200, {}, ["OK"]] elsif resource == "/" js_app elsif resource =~ %r{/fetch-env/([0-9a-f]+)\.json$} key = $1 env = Logster.store.get_env(key) if env [200, { "content-type" => "application/json; charset=utf-8" }, [JSON.generate(env)]] else not_found end elsif resource == "/solve-group" return not_allowed unless Logster.config.enable_custom_patterns_via_ui req = Rack::Request.new(env) return method_not_allowed("POST") if req.request_method != "POST" group = Logster.store.find_pattern_groups { |patt| patt.inspect == req.params["regex"] }.first return not_found("No such pattern group exists") if !group group.messages_keys.each { |k| Logster.store.solve(k) } [200, {}, []] elsif resource == "/development-preload.json" && ENV["LOGSTER_ENV"] == "development" [ 200, { "content-type" => "application/json; charset=utf-8" }, [JSON.generate(preloaded_data)], ] else not_found end else @app.call(env) end end protected def serve_file(env, path) env[PATH_INFO] = path # accl redirect is going to be trouble, ensure its bypassed env["sendfile.type"] = "" @fileserver.call(env) end def serve_messages(req) params = req.params opts = { before: params["before"], after: params["after"] } if (filter = params["filter"]) filter = filter.split("_").map { |s| s.to_i } opts[:severity] = filter end if search = params["search"] search = (parse_regex(search) || search) if params["regex_search"] == "true" opts[:search] = search end search = opts[:search] opts[:known_groups] = params["known_groups"] if params["known_groups"] opts[:with_env] = (String === search && search.size > 0) || Regexp === search payload = { messages: @store.latest(opts), total: @store.count, search: params["search"] || "", filter: filter || "", } json = JSON.generate(payload) [200, { "content-type" => "application/json" }, [json]] end def update_patterns(set_name, req) klass = get_class(set_name) return not_found("Unknown set name") unless klass request_method = req.request_method pattern = req.params["pattern"] record = request_method == "POST" ? klass.new(pattern) : klass.find(pattern) return not_found unless record case request_method when "POST" args = {} if Logster::SuppressionPattern === record && [true, "true"].include?(req.params["retroactive"]) args[:retroactive] = true end record.save(args) when "PUT" record.modify(req.params["new_pattern"]) when "DELETE" record.destroy else return method_not_allowed(%w[POST PUT DELETE]) end [200, { "content-type" => "application/json" }, [JSON.generate(pattern: record.to_s)]] rescue => err error_message = err.message unless Logster::Pattern::PatternError === err # likely a bug, give us the backtrace error_message += "\n\n#{err.backtrace.join("\n")}" return 500, {}, [error_message] end [400, {}, [error_message]] end def get_class(set_name) case set_name when "suppression" Logster::SuppressionPattern when "grouping" Logster::GroupingPattern else nil end end def not_found(message = "Not found") [404, {}, [message]] end def not_allowed(message = "Not allowed") [403, {}, [message]] end def method_not_allowed(allowed_methods) allowed_methods = allowed_methods.join(", ") if Array === allowed_methods [405, { "allow" => allowed_methods }, []] end def parse_regex(string) if string =~ %r{/(.+)/(.*)} s = $1 flags = Regexp::IGNORECASE if $2 && $2.include?("i") begin Regexp.new(s, flags) rescue StandardError nil end end end def resolve_path(path) $3 || "/" if path =~ @path_regex end def css(name, csp_nonce) "" end def script(name, csp_nonce) "" end def to_json_and_escape(payload) Rack::Utils.escape_html(JSON.fast_generate(payload)) end def preload_backtrace_data gems_data = [] Gem::Specification.find_all do |gem| url = gem.metadata["source_code_uri"] || gem.homepage gems_data << { name: gem.name, url: url } if url && url.match(%r{^https?://github.com/}) end { gems_data: gems_data, directories: Logster.config.project_directories } end def preloaded_data preload = { env_expandable_keys: Logster.config.env_expandable_keys, patterns_enabled: Logster.config.enable_custom_patterns_via_ui, application_version: Logster.config.application_version, } backtrace_links_enabled = Logster.config.enable_backtrace_links gems_dir = Logster.config.gems_dir gems_dir += "/" if gems_dir[-1] != "/" preload.merge!(gems_dir: gems_dir, backtrace_links_enabled: backtrace_links_enabled) preload.merge!(preload_backtrace_data) if backtrace_links_enabled if Logster.config.back_to_site_link_text && Logster.config.back_to_site_link_path preload.merge!( back_to_site_link_text: Logster.config.back_to_site_link_text, back_to_site_link_path: Logster.config.back_to_site_link_path, ) end preload end def js_app(preload = {}) csp_nonce = SecureRandom.hex preload = preloaded_data.merge(preload) root_url = @logs_path root_url += "/" if root_url[-1] != "/" body = <<~HTML #{Logster.config.web_title || "Logs"} #{css("vendor.css", csp_nonce)} #{css("client-app.css", csp_nonce)} #{script("vendor.js", csp_nonce)} #{script("client-app.js", csp_nonce)} HTML [ 200, { "content-type" => "text/html; charset=utf-8", "content-security-policy" => "script-src 'nonce-#{csp_nonce}'; style-src 'nonce-#{csp_nonce}'; object-src 'none'; base-uri 'none';", }, [body], ] end end end end