From 1e7ed83cbd2ddd58e8e60a4384eab011e23c22a6 Mon Sep 17 00:00:00 2001 From: Paul Duncan Date: Sat, 16 Jul 2016 22:00:42 -0400 Subject: add file api handler, enable admin page downloads --- src/guff/handlers.cr | 98 ++++++++++++++++++++++++++++++++++++++++++++++++- src/guff/models/file.cr | 2 +- 2 files changed, 97 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/guff/handlers.cr b/src/guff/handlers.cr index 3e51169..3ff58d0 100644 --- a/src/guff/handlers.cr +++ b/src/guff/handlers.cr @@ -576,9 +576,7 @@ module Guff::Handlers if context.request.method == "GET" # send body for GET requests File.open(abs_path) do |fh| - STDERR.puts "sending #{abs_path}" IO.copy(fh, context.response) - STDERR.puts "done sending #{abs_path}" end end end @@ -634,6 +632,95 @@ module Guff::Handlers end end + # FIXME: this is very similar to FilesHandler and AssetsHandler, so + # maybe combine them? + class FileAPIHandler < AuthenticatedHandler + def initialize(context : Context) + super(context, %w{admin editor}) + @magic = Magic.new + end + + def authenticated_call(context : HTTP::Server::Context) + req_path = context.request.path.not_nil! + + if matching_request?(context.request.method, req_path) && + valid_origin_headers?(context.request.headers) + # get expanded path to file + if abs_path = expand_path(req_path) + # get file digest + etag = get_file_etag(abs_path) + + # check for cache header + if context.request.headers["if-none-match"]? == etag + # cached, send 304 not modified + context.response.status_code = 304 + else + # not cached, set code and send headers + context.response.headers["x-frame-options"] = "SAMEORIGIN" + context.response.status_code = 200 + context.response.content_type = get_mime_type(abs_path) + context.response.content_length = File.size(abs_path) + context.response.headers["etag"] = etag + context.response.headers["content-disposition"] = "attachment" + + if context.request.method == "GET" + # send body for GET requests + File.open(abs_path) do |fh| + IO.copy(fh, context.response) + end + end + end + else + # expanded path does not exist + call_next(context) + end + else + # not a matching request + call_next(context) + end + end + + VALID_METHODS = %w{GET HEAD} + PATH_RE = %r{^/guff/api/file/download/} + + private def matching_request?(method, path) + VALID_METHODS.includes?(method) && PATH_RE.match(path) + end + + private def expand_path(req_path : String) : String? + # unescape path, check for nil byte + path = URI.unescape(req_path) + return nil if path.includes?('\0') + + # build absolute path + r = File.join( + @context.config.data_dir, + "files", + File.expand_path(path.gsub(PATH_RE, ""), "/") + ) + + # return path if file exists, or nil otherwise + File.file?(r) ? r : nil + end + + private def get_file_etag(path : String) : String + st = File.stat(path) + + # FIXME: rather than a hash this should be an HMAC + d = OpenSSL::Digest.new("SHA1") + + # FIXME: should this be inode rather than path? + d << "%s-%d-%d" % [path, st.size, st.mtime.epoch_ms] + + # return digest + d.hexdigest + end + + private def get_mime_type(path : String) : String + @magic.file(path) + end + end + HANDLERS = [{ :dev => true, @@ -671,6 +758,11 @@ module Guff::Handlers }, { :dev => false, :id => :session, + }, { + # NOTE: special handler for api/file, should load + # before api handler + :dev => false, + :id => :file_api, }, { :dev => false, :id => :api, @@ -705,6 +797,8 @@ module Guff::Handlers HTTP::DeflateHandler.new when :session SessionHandler.new(context) + when :file_api + FileAPIHandler.new(context) when :api APIHandler.new(context) when :assets diff --git a/src/guff/models/file.cr b/src/guff/models/file.cr index bcd6db2..b001c2f 100644 --- a/src/guff/models/file.cr +++ b/src/guff/models/file.cr @@ -12,7 +12,7 @@ class Guff::Models::FileModel < Guff::Models::Model # build base file path base_path = File.expand_path(path) - base_url = File.join("/files", base_path) + base_url = File.join("/guff/api/file/download", base_path) Dir.entries(abs_path).select { |file| # exclude hidden files -- cgit v1.2.3