diff options
author | Paul Duncan <pabs@pablotron.org> | 2016-07-16 21:21:33 -0400 |
---|---|---|
committer | Paul Duncan <pabs@pablotron.org> | 2016-07-16 21:21:33 -0400 |
commit | d1cbae09bf46999e023a7dd2291a534b9737c804 (patch) | |
tree | b14aa9cafbe115484186d409a2c7d5ca243e7c3e | |
parent | 5192d6a0678c2d7ffc0e579cfe760dfa169f0100 (diff) | |
download | guff-d1cbae09bf46999e023a7dd2291a534b9737c804.tar.bz2 guff-d1cbae09bf46999e023a7dd2291a534b9737c804.zip |
add file handling and libmagic wrapper
-rw-r--r-- | data/assets/js/admin/tabs/files.js | 231 | ||||
-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 |
8 files changed, 700 insertions, 1 deletions
diff --git a/data/assets/js/admin/tabs/files.js b/data/assets/js/admin/tabs/files.js new file mode 100644 index 0000000..c3737d0 --- /dev/null +++ b/data/assets/js/admin/tabs/files.js @@ -0,0 +1,231 @@ +jQuery(function($) { + "use strict"; + + var TEMPLATES = new LuigiTemplate.Cache({ + file: [ + "<a ", + "href='#' ", + "class='list-group-item' ", + "data-type='%{type|h}' ", + "data-name='%{name|h}' ", + "data-size='%{size|h}' ", + "data-path='%{path|h}' ", + ">", + "<i class='fa fa-file-o'></i>", + " ", + "%{name|h}", + " ", + "(%{size|h} bytes)", + "</a>", + ], + + dir: [ + "<a ", + "href='#' ", + "class='list-group-item' ", + "data-type='%{type|h}' ", + "data-name='%{name|h}' ", + "data-size='%{size|h}' ", + "data-path='%{path|h}' ", + ">", + "<i class='fa fa-folder-o'></i>", + " ", + "%{name|h}", + "</a>", + ], + + no_files: [ + "<span class='list-group-item disabled'>", + "<i class='fa fa-ban'></i>", + " ", + "No files.", + "</span>", + ], + + crumb: [ + "<div class='btn-group btn-group-sm'>", + "<a ", + "href='#' ", + "class='btn btn-default btn-sm' ", + "data-path='%{path|h}' ", + ">", + "%{name|h}", + "</a>", + "</div><!-- btn-group -->", + ], + }); + + function get_crumbs(path) { + var r = []; + + $.each(path.replace(/^\//, '').split(/\//), function(i, part) { + if (part.length > 0) + r.push('/' + r.concat([part]).join('/')); + }) + + // prepend root + r.unshift('/'); + + return r; + } + + var NO_FILES = TEMPLATES.run('no_files'); + + function reload() { + var btn = $('#files-reload'), + list = $('#files'); + + // show loading + btn.toggleClass('disabled').find('.loading').toggleClass('hidden'); + // list.html(TEMPLATES.run('loading')); + + send('file/list', { + path: $('#files').data('path') + }).always(function() { + btn.toggleClass('disabled').find('.loading').toggleClass('hidden'); + // list.html(''); + }).fail(function(r) { + var error = r.responseText; + + try { + var data = $.parseJSON(r.responseText); + if (data.error) + error = data.error; + } catch (e) {} + + alert("Error: " + error); + }).done(function(r) { + // disable action buttons + $('#file-actions li').addClass('disabled'); + + // draw crumbs + var crumbs = get_crumbs($('#files').data('path')); + $('#files-crumbs').html($.map(crumbs, function(crumb) { + return TEMPLATES.run('crumb', { + path: crumb, + name: crumb.replace(/^.*\/(.+)/, '$1'), + }); + }).join('')); + + // draw files + $('#files').html((r.length > 0) ? $.map(r, function(row) { + return TEMPLATES.run(row.type, row); + }).join('') : NO_FILES); + + // update file count + $('#files-count').text(r.length); + }); + + // stop event + return false; + } + + $('#files').on('dblclick', 'a.list-group-item', function() { + var data = $(this).data(); + + if (data.type == 'dir') { + // load directory + $('#files').data('path', data.path); + reload(); + } else { + // select and download file + $(this).click(); + $('#files-download').click(); + } + + // stop event + return false; + }).on('click', 'a.list-group-item', function() { + var data = $(this).data(); + + // disable file actions + $('#file-actions li').removeClass('disabled'); + + // update highlight + $('#files .active').removeClass('active'); + $(this).addClass('active'); + + // stop event + return false; + }); + + $('#files-crumbs').on('click', 'a.btn', function() { + $('#files').data('path', $(this).data('path')); + reload(); + + // stop event + return false; + }); + + $('#file-actions').on('click', 'a', function() { + var data = $(this).data(); + + // hide dropdown + $('body').trigger('click'); + + if (data.id == 'download') { + alert('TODO: download file'); + } else if (data.id == 'move') { + alert('TODO: move file'); + } else if (data.id == 'delete') { + alert('TODO: delete file'); + } + + // stop event + return false; + }); + + $('#files-upload').click(function() { + alert('TODO: upload'); + + // stop event + return false; + }); + + $('#files-mkdir').click(function() { + var me = $(this), + path = prompt('Name of new folder:', 'New Folder'); + + if (!path || !path.match(/\S/)) + return false; + + if (path.match(/\//)) { + alert("Error: Folder name cannot contain '/' characters"); + return false; + } + + // disable button, show spinner + me.toggleClass('disabled').find('.loading').toggleClass('hidden'); + + send('file/mkdir', { + path: ($('#files').data('path') + '/' + path).replace(/\/\/+/, '/') + }).always(function() { + // enable button, hide spinner + me.toggleClass('disabled').find('.loading').toggleClass('hidden'); + }).fail(function(r) { + var error = r.responseText; + + try { + var data = $.parseJSON(r.responseText); + if (data.error) + error = data.error; + } catch (e) {} + + alert("Error: " + error); + }).done(function(r) { + // reload file list + reload(); + }); + + // stop event + return false; + }); + + $('#files').data('path', '/'); + $('#files-reload').click(reload); + + // load users + $('#admin-tab-files').on('show.bs.tab', function() { + reload(); + }); +}); 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 |