aboutsummaryrefslogtreecommitdiff
path: root/src
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 /src
parent5192d6a0678c2d7ffc0e579cfe760dfa169f0100 (diff)
downloadguff-d1cbae09bf46999e023a7dd2291a534b9737c804.tar.bz2
guff-d1cbae09bf46999e023a7dd2291a534b9737c804.zip
add file handling and libmagic wrapper
Diffstat (limited to 'src')
-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
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