diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/guff/apis.cr | 34 | ||||
-rw-r--r-- | src/guff/handlers.cr | 93 | ||||
-rw-r--r-- | src/guff/libmagic.cr | 80 | ||||
-rw-r--r-- | src/guff/magic.cr | 49 | ||||
-rw-r--r-- | src/guff/model-set.cr | 1 | ||||
-rw-r--r-- | src/guff/models/file.cr | 100 | ||||
-rw-r--r-- | src/views/admin-page.ecr | 113 |
7 files changed, 469 insertions, 1 deletions
diff --git a/src/guff/apis.cr b/src/guff/apis.cr index 13d0d44..dccc01c 100644 --- a/src/guff/apis.cr +++ b/src/guff/apis.cr @@ -186,4 +186,38 @@ module Guff::APIs @context.models.site.get_sites end end + + module FileAPI + def do_file_list(params : HTTP::Params) + @context.models.file.list(URI.unescape(params["path"])) + end + + def do_file_add(params : HTTP::Params) + # TODO: upload data will be cached in context, then retreived in + # file model + @context.models.file.add( + URI.unescape(params["path"].not_nil!), + params["upload_data"].not_nil!, + ) + end + + def do_file_mkdir(params : HTTP::Params) + # TODO: uploaded data is cached in context, then retreived in file + # model + @context.models.file.mkdir(URI.unescape(params["path"].not_nil!)) + end + + def do_file_move(params : HTTP::Params) + @context.models.file.move( + src_path: URI.unescape(params["src_path"].not_nil!), + dst_path: URI.unescape(params["dst_path"].not_nil!), + ) + end + + def do_file_delete(params : HTTP::Params) + @context.models.file.delete( + URI.unescape(params["path"].not_nil!), + ) + end + end end diff --git a/src/guff/handlers.cr b/src/guff/handlers.cr index d34a9a7..b0fc09a 100644 --- a/src/guff/handlers.cr +++ b/src/guff/handlers.cr @@ -101,6 +101,7 @@ module Guff::Handlers APIs::ProjectAPI, APIs::BlogAPI, APIs::SiteAPI, + APIs::FileAPI, ] include_api_modules(API_MODULES) @@ -545,6 +546,93 @@ module Guff::Handlers end end + # FIXME: this is very similar to AssetsHandler, so maybe combine them? + class FilesHandler < Handler + def call(context : HTTP::Server::Context) + req_path = context.request.path.not_nil! + + if matching_request?(context.request.method, req_path) && + true # 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 + + 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 + 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{^/files/} + + 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", + "default", # FIXME: make work with non-default sites + 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") + d << "%s-%d-%d" % [path, st.size, st.mtime.epoch_ms] + + # return digest + d.hexdigest + end + + private def get_mime_type(path : String) : String + puts "DEBUG: getting mime type" + r = Guff::Magic.get_mime_type(path) + puts "DEBUG: mime type = #{r}" + r + end + end + + HANDLERS = [{ :dev => true, :id => :stub, @@ -562,6 +650,9 @@ module Guff::Handlers :id => :assets, }, { :dev => false, + :id => :files, + }, { + :dev => false, :id => :page, }, { :dev => false, @@ -630,6 +721,8 @@ module Guff::Handlers BlogListHandler.new(context) when :project ProjectHandler.new(context) + when :files + FilesHandler.new(context) else raise "unknown handler id: #{handler_id}" end diff --git a/src/guff/libmagic.cr b/src/guff/libmagic.cr new file mode 100644 index 0000000..5c99d3a --- /dev/null +++ b/src/guff/libmagic.cr @@ -0,0 +1,80 @@ +module Guff + @[Link("magic")] + lib LibMagic + type MagicT = UInt8* + + enum Flags + NONE = 0x000000 # No flags + DEBUG = 0x000001 # Turn on debugging + SYMLINK = 0x000002 # Follow symlinks + COMPRESS = 0x000004 # Check inside compressed files + DEVICES = 0x000008 # Look at the contents of devices + MIME_TYPE = 0x000010 # Return the MIME type + CONTINUE = 0x000020 # Return all matches + CHECK = 0x000040 # Print warnings to stderr + PRESERVE_ATIME = 0x000080 # Restore access time on exit + RAW = 0x000100 # Don't translate unprintable chars + ERROR = 0x000200 # Handle ENOENT etc as real errors + MIME_ENCODING = 0x000400 # Return the MIME encoding + MIME = 0x000010 | 0x000400 # (MIME_TYPE|MIME_ENCODING) + APPLE = 0x000800 # Return the Apple creator and type + + NO_CHECK_COMPRESS = 0x001000 # Don't check for compressed files + NO_CHECK_TAR = 0x002000 # Don't check for tar files + NO_CHECK_SOFT = 0x004000 # Don't check magic entries + NO_CHECK_APPTYPE = 0x008000 # Don't check application type + NO_CHECK_ELF = 0x010000 # Don't check for elf details + NO_CHECK_TEXT = 0x020000 # Don't check for text files + NO_CHECK_CDF = 0x040000 # Don't check for cdf files + NO_CHECK_TOKENS = 0x100000 # Don't check tokens + NO_CHECK_ENCODING = 0x200000 # Don't check text encodings + end + + fun magic_open( + flags : UInt32 + ) : MagicT + + fun magic_close( + magic : MagicT + ) : Void + + fun magic_error( + magic : MagicT + ) : UInt8* + + fun magic_errno( + magic : MagicT + ) : Int32 + + fun magic_file( + magic : MagicT, + filename : UInt8* + ) : UInt8* + + fun magic_buffer( + magic : MagicT, + buf : UInt8*, + buf_len : UInt32 # FIXME: actually size_t + ) : UInt8* + + fun magic_setflags( + magic : MagicT, + flags : UInt32 + ) : Int32 + + fun magic_check( + magic : MagicT, + filename : UInt8* + ) : Int32 + + fun magic_compile( + magic : MagicT, + filename : UInt8* + ) : Int32 + + fun magic_load( + magic : MagicT, + filename : UInt8* + ) : Int32 + end +end diff --git a/src/guff/magic.cr b/src/guff/magic.cr new file mode 100644 index 0000000..98a0e5b --- /dev/null +++ b/src/guff/magic.cr @@ -0,0 +1,49 @@ +class Guff::Magic + def initialize( + flags : Int32 = LibMagic::Flags::MIME, + db_path : String? = nil + ) + @closed = false + @magic = LibMagic.magic_open(flags) + raise "magic_open() failed" unless @magic + + # load database + load(db_path) + end + + def self.get_mime_type(path : String) + magic = new + r = magic.file(path) + magic.close + + # return result + r + end + + private def load(path : String? = nil) : Void + err = LibMagic.magic_load(@magic, nil) + raise "magic_load() failed: " + error if err != 0 + end + + def file(path : String) : String + raise "closed" if closed? + r = LibMagic.magic_file(@magic, path) + raise "magic_file() failed: " + error unless r + String.new(r) + end + + def close + raise "closed" if closed? + LibMagic.magic_close(@magic) + @closed = true + end + + def closed? + @closed + end + + def error : String + raise "closed" if closed? + String.new(LibMagic.magic_error(@magic)) + end +end diff --git a/src/guff/model-set.cr b/src/guff/model-set.cr index e4c4616..afc7c5f 100644 --- a/src/guff/model-set.cr +++ b/src/guff/model-set.cr @@ -21,5 +21,6 @@ class Guff::ModelSet site: Models::SiteModel, role: Models::RoleModel, state: Models::StateModel, + file: Models::FileModel, }) end diff --git a/src/guff/models/file.cr b/src/guff/models/file.cr new file mode 100644 index 0000000..bcd6db2 --- /dev/null +++ b/src/guff/models/file.cr @@ -0,0 +1,100 @@ +require "./model" + +class Guff::Models::FileModel < Guff::Models::Model + def list(path : String) + # build absolute path + abs_path = expand_path(path) + + # make sure directory exists + unless Dir.exists?(abs_path) + raise "invalid path (missing directory)" + end + + # build base file path + base_path = File.expand_path(path) + base_url = File.join("/files", base_path) + + Dir.entries(abs_path).select { |file| + # exclude hidden files + file =~ /\A[^\.]/ + }.sort.map { |file| + # stat file + st = File.stat(File.join(abs_path, file)) + + # return row + { + name: file, + type: st.directory? ? "dir" : "file", + size: st.directory? ? 0 : st.size, + path: File.join(base_path, file), + + # FIXME: make work for non-default sites + url: File.join(base_url, file), + } + } + end + + def add(path : String, upload_data : String) + # TODO + nil + end + + def mkdir(path : String) + # build absolute path + abs_path = expand_path(path) + + # create directory + Dir.mkdir(abs_path) + + nil + end + + def move(src_path : String, dst_path : String) + # build absolute paths + abs_src_path = expand_path(src_path) + abs_dst_path = expand_path(dst_path) + + # make sure src path exists + unless File.exists?(abs_src_path) + raise "invalid source path" + end + + # make sure dst dir exists + unless Dir.exists?(File.dirname(abs_dst_path)) + raise "invalid dst path" + end + + # move file + File.rename(abs_src_path, abs_dst_path) + + # return success + nil + end + + def delete(path : String) + # build absolute path + abs_path = expand_path(path) + + if Dir.exists?(abs_path) + Dir.rmdir(abs_path) + elsif File.exists?(abs_path) + File.delete(abs_path) + else + raise "missing path" + end + + # return success + nil + end + + private def expand_path(path : String) + raise "invalid path" if path.includes?('\0') + + # return expanded path + File.join( + @context.config.data_dir, + "files", + File.expand_path(path) + ) + end +end diff --git a/src/views/admin-page.ecr b/src/views/admin-page.ecr index ba7dcaf..b441014 100644 --- a/src/views/admin-page.ecr +++ b/src/views/admin-page.ecr @@ -261,10 +261,120 @@ > <div class='panel panel-default'> <div class='panel-heading'> + <div class='btn-toolbar'> + <div class='btn-group btn-group-sm'> + <a + href='#' + id='files-upload' + class='btn btn-primary' + title='Upload file to current directory.' + > + <i class='fa fa-upload'></i> + Upload File... + </a><!-- btn --> + </div><!-- btn-group --> + + <div class='btn-group btn-group-sm'> + <a + href='#' + id='files-mkdir' + class='btn btn-default' + title='Create folder in current directory.' + > + <span class='loading'> + <i class='fa fa-folder'></i> + </span><!-- loading --> + + <span class='loading hidden'> + <i class='fa fa-spinner fa-spin'></i> + </span><!-- loading --> + + New Folder... + </a><!-- btn --> + </div><!-- btn-group --> + + <div class='btn-group btn-group-sm'> + <a + href='#' + class='btn btn-default' + title='View actions.' + data-toggle='dropdown' + > + <i class='fa fa-clone'></i> + File Actions + <i class='fa fa-caret-down'></i> + </a><!-- btn --> + + <ul id='file-actions' class='dropdown-menu'> + <li> + <a + href='#' + title='Download selected file.' + data-id='download' + > + <i class='fa fa-download'></i> + Download File + </a><!-- btn --> + </li> + + <li> + <a + href='#' + title='Move selected file or folder.' + data-id='move' + > + <i class='fa fa-folder-open-o'></i> + Move + </a><!-- btn --> + </li> + + <li> + <a + href='#' + title='Delete selected file or folder.' + data-id='delete' + > + <i class='fa fa-trash-o'></i> + Delete + </a><!-- btn --> + </li> + </ul><!-- dropdown-menu --> + </div><!-- btn-group --> + + <div class='btn-group btn-group-sm pull-right'> + <a + href='#' + id='files-reload' + class='btn btn-default' + title='Reload files.' + > + <span class='loading'> + <i class='fa fa-fw fa-refresh'></i> + </span> + + <span class='loading hidden'> + <i class='fa fa-fw fa-spinner fa-spin'></i> + </span> + </a><!-- btn --> + </div><!-- btn-group --> + + <div class='btn-group btn-group-sm pull-right'> + <span + class='btn' + title='Number of files in current directory.' + > + <span id='files-count'>0</span> Files + </span><!-- btn --> + </div><!-- btn-group --> + </div><!-- btn-toolbar --> + </div><!-- panel-heading --> + + <div class='panel-heading'> + <div id='files-crumbs' class='btn-toolbar'> + </div><!-- btn-toolbar --> </div><!-- panel-heading --> <div id='files' class='list-group'> - TODO: files </div><!-- panel-body --> </div><!-- panel --> </div><!-- tab-pane --> @@ -1349,6 +1459,7 @@ assets/js/dropdown.js assets/js/admin/tabs/users.js assets/js/admin/tabs/posts.js + assets/js/admin/tabs/files.js assets/js/admin/dialogs/user-add.js assets/js/admin/dialogs/user-edit.js assets/js/admin/dialogs/post-edit.js |