aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Duncan <pabs@pablotron.org>2016-07-16 21:21:33 -0400
committerPaul Duncan <pabs@pablotron.org>2016-07-16 21:21:33 -0400
commitd1cbae09bf46999e023a7dd2291a534b9737c804 (patch)
treeb14aa9cafbe115484186d409a2c7d5ca243e7c3e
parent5192d6a0678c2d7ffc0e579cfe760dfa169f0100 (diff)
downloadguff-d1cbae09bf46999e023a7dd2291a534b9737c804.tar.bz2
guff-d1cbae09bf46999e023a7dd2291a534b9737c804.zip
add file handling and libmagic wrapper
-rw-r--r--data/assets/js/admin/tabs/files.js231
-rw-r--r--src/guff/apis.cr34
-rw-r--r--src/guff/handlers.cr93
-rw-r--r--src/guff/libmagic.cr80
-rw-r--r--src/guff/magic.cr49
-rw-r--r--src/guff/model-set.cr1
-rw-r--r--src/guff/models/file.cr100
-rw-r--r--src/views/admin-page.ecr113
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