Add and delete uploaded files (#2)
This commit is contained in:
parent
9307ace24a
commit
c957279162
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -262,3 +262,6 @@ src/MyWebLog/wwwroot/img/bit-badger
|
||||||
|
|
||||||
.ionide
|
.ionide
|
||||||
src/MyWebLog/appsettings.Production.json
|
src/MyWebLog/appsettings.Production.json
|
||||||
|
|
||||||
|
# SQLite database files
|
||||||
|
src/MyWebLog/*.db*
|
||||||
|
|
|
@ -205,6 +205,9 @@ type IUploadData =
|
||||||
/// Add an uploaded file
|
/// Add an uploaded file
|
||||||
abstract member add : Upload -> Task<unit>
|
abstract member add : Upload -> Task<unit>
|
||||||
|
|
||||||
|
/// Delete an uploaded file
|
||||||
|
abstract member delete : UploadId -> WebLogId -> Task<Result<string, string>>
|
||||||
|
|
||||||
/// Find an uploaded file by its path for the given web log
|
/// Find an uploaded file by its path for the given web log
|
||||||
abstract member findByPath : string -> WebLogId -> Task<Upload option>
|
abstract member findByPath : string -> WebLogId -> Task<Upload option>
|
||||||
|
|
||||||
|
|
|
@ -746,10 +746,30 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
|
||||||
write; withRetryDefault; ignoreResult conn
|
write; withRetryDefault; ignoreResult conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
member _.delete uploadId webLogId = backgroundTask {
|
||||||
|
let! upload =
|
||||||
|
rethink<Upload> {
|
||||||
|
withTable Table.Upload
|
||||||
|
get uploadId
|
||||||
|
resultOption; withRetryOptionDefault
|
||||||
|
}
|
||||||
|
|> verifyWebLog<Upload> webLogId (fun u -> u.webLogId) <| conn
|
||||||
|
match upload with
|
||||||
|
| Some up ->
|
||||||
|
do! rethink {
|
||||||
|
withTable Table.Upload
|
||||||
|
get uploadId
|
||||||
|
delete
|
||||||
|
write; withRetryDefault; ignoreResult conn
|
||||||
|
}
|
||||||
|
return Ok (Permalink.toString up.path)
|
||||||
|
| None -> return Result.Error $"Upload ID {UploadId.toString uploadId} not found"
|
||||||
|
}
|
||||||
|
|
||||||
member _.findByPath path webLogId =
|
member _.findByPath path webLogId =
|
||||||
rethink<Upload> {
|
rethink<Upload> {
|
||||||
withTable Table.Upload
|
withTable Table.Upload
|
||||||
getAll [ r.Array (path, webLogId) ] "webLogAndPath"
|
getAll [ r.Array (webLogId, path) ] "webLogAndPath"
|
||||||
resultCursor; withRetryCursorDefault; toList
|
resultCursor; withRetryCursorDefault; toList
|
||||||
}
|
}
|
||||||
|> tryFirst <| conn
|
|> tryFirst <| conn
|
||||||
|
|
|
@ -37,6 +37,27 @@ type SQLiteUploadData (conn : SqliteConnection) =
|
||||||
do! dataStream.CopyToAsync blobStream
|
do! dataStream.CopyToAsync blobStream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete an uploaded file by its ID
|
||||||
|
let delete uploadId webLogId = backgroundTask {
|
||||||
|
use cmd = conn.CreateCommand ()
|
||||||
|
cmd.CommandText <- """
|
||||||
|
SELECT id, web_log_id, path, updated_on
|
||||||
|
FROM upload
|
||||||
|
WHERE id = @id
|
||||||
|
AND web_log_id = @webLogId"""
|
||||||
|
addWebLogId cmd webLogId
|
||||||
|
cmd.Parameters.AddWithValue ("@id", UploadId.toString uploadId) |> ignore
|
||||||
|
let! rdr = cmd.ExecuteReaderAsync ()
|
||||||
|
if (rdr.Read ()) then
|
||||||
|
let upload = Map.toUpload false rdr
|
||||||
|
do! rdr.CloseAsync ()
|
||||||
|
cmd.CommandText <- "DELETE FROM upload WHERE id = @id AND web_log_id = @webLogId"
|
||||||
|
do! write cmd
|
||||||
|
return Ok (Permalink.toString upload.path)
|
||||||
|
else
|
||||||
|
return Error $"""Upload ID {cmd.Parameters["@id"]} not found"""
|
||||||
|
}
|
||||||
|
|
||||||
/// Find an uploaded file by its path for the given web log
|
/// Find an uploaded file by its path for the given web log
|
||||||
let findByPath (path : string) webLogId = backgroundTask {
|
let findByPath (path : string) webLogId = backgroundTask {
|
||||||
use cmd = conn.CreateCommand ()
|
use cmd = conn.CreateCommand ()
|
||||||
|
@ -72,6 +93,7 @@ type SQLiteUploadData (conn : SqliteConnection) =
|
||||||
|
|
||||||
interface IUploadData with
|
interface IUploadData with
|
||||||
member _.add upload = add upload
|
member _.add upload = add upload
|
||||||
|
member _.delete uploadId webLogId = delete uploadId webLogId
|
||||||
member _.findByPath path webLogId = findByPath path webLogId
|
member _.findByPath path webLogId = findByPath path webLogId
|
||||||
member _.findByWebLog webLogId = findByWebLog webLogId
|
member _.findByWebLog webLogId = findByWebLog webLogId
|
||||||
member _.findByWebLogWithData webLogId = findByWebLogWithData webLogId
|
member _.findByWebLogWithData webLogId = findByWebLogWithData webLogId
|
||||||
|
|
|
@ -169,13 +169,13 @@ type DisplayUpload =
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a display uploaded file
|
/// Create a display uploaded file
|
||||||
static member fromUpload source (upload : Upload) =
|
static member fromUpload webLog source (upload : Upload) =
|
||||||
let path = Permalink.toString upload.path
|
let path = Permalink.toString upload.path
|
||||||
let name = Path.GetFileName path
|
let name = Path.GetFileName path
|
||||||
{ id = UploadId.toString upload.id
|
{ id = UploadId.toString upload.id
|
||||||
name = name
|
name = name
|
||||||
path = path.Replace (name, "")
|
path = path.Replace (name, "")
|
||||||
updatedOn = Some upload.updatedOn
|
updatedOn = Some (WebLog.localTime webLog upload.updatedOn)
|
||||||
source = UploadDestination.toString source
|
source = UploadDestination.toString source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -232,7 +232,7 @@ let register () =
|
||||||
typeof<LogOnModel>; typeof<ManagePermalinksModel>; typeof<PostDisplay>; typeof<PostListItem>
|
typeof<LogOnModel>; typeof<ManagePermalinksModel>; typeof<PostDisplay>; typeof<PostListItem>
|
||||||
typeof<SettingsModel>; typeof<UserMessage>
|
typeof<SettingsModel>; typeof<UserMessage>
|
||||||
// Framework types
|
// Framework types
|
||||||
typeof<AntiforgeryTokenSet>; typeof<int option>; typeof<KeyValuePair>; typeof<MetaItem list>
|
typeof<AntiforgeryTokenSet>; typeof<DateTime option>; typeof<int option>; typeof<KeyValuePair>
|
||||||
typeof<string list>; typeof<string option>; typeof<TagMap list>
|
typeof<MetaItem list>; typeof<string list>; typeof<string option>; typeof<TagMap list>
|
||||||
]
|
]
|
||||||
|> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |]))
|
|> List.iter (fun it -> Template.RegisterSafeType (it, [| "*" |]))
|
||||||
|
|
|
@ -179,6 +179,8 @@ let router : HttpHandler = choose [
|
||||||
route "/theme/update" >=> Admin.updateTheme
|
route "/theme/update" >=> Admin.updateTheme
|
||||||
subRoute "/upload" (choose [
|
subRoute "/upload" (choose [
|
||||||
route "/save" >=> Upload.save
|
route "/save" >=> Upload.save
|
||||||
|
routexp "/delete/(.*)" Upload.deleteFromDisk
|
||||||
|
routef "/%s/delete" Upload.deleteFromDb
|
||||||
])
|
])
|
||||||
route "/user/save" >=> User.save
|
route "/user/save" >=> User.save
|
||||||
]
|
]
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
module MyWebLog.Handlers.Upload
|
module MyWebLog.Handlers.Upload
|
||||||
|
|
||||||
open System
|
open System
|
||||||
|
open System.IO
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
open Microsoft.Net.Http.Headers
|
open Microsoft.Net.Http.Headers
|
||||||
|
@ -22,6 +23,12 @@ module private Helpers =
|
||||||
hdr.MaxAge <- Some (TimeSpan.FromDays 30) |> Option.toNullable
|
hdr.MaxAge <- Some (TimeSpan.FromDays 30) |> Option.toNullable
|
||||||
hdr
|
hdr
|
||||||
|
|
||||||
|
/// Shorthand for the directory separator
|
||||||
|
let slash = Path.DirectorySeparatorChar
|
||||||
|
|
||||||
|
/// The base directory where uploads are stored, relative to the executable
|
||||||
|
let uploadDir = Path.Combine ("wwwroot", "upload")
|
||||||
|
|
||||||
|
|
||||||
/// Determine if the file has been modified since the date/time specified by the If-Modified-Since header
|
/// Determine if the file has been modified since the date/time specified by the If-Modified-Since header
|
||||||
let checkModified since (ctx : HttpContext) : HttpHandler option =
|
let checkModified since (ctx : HttpContext) : HttpHandler option =
|
||||||
|
@ -31,7 +38,6 @@ let checkModified since (ctx : HttpContext) : HttpHandler option =
|
||||||
| _ -> Some (setStatusCode 304 >=> setBodyFromString "Not Modified")
|
| _ -> Some (setStatusCode 304 >=> setBodyFromString "Not Modified")
|
||||||
|
|
||||||
|
|
||||||
open System.IO
|
|
||||||
open Microsoft.AspNetCore.Http.Headers
|
open Microsoft.AspNetCore.Http.Headers
|
||||||
|
|
||||||
/// Derive a MIME type based on the extension of the file
|
/// Derive a MIME type based on the extension of the file
|
||||||
|
@ -83,7 +89,7 @@ let list : HttpHandler = fun next ctx -> task {
|
||||||
let webLog = ctx.WebLog
|
let webLog = ctx.WebLog
|
||||||
let! dbUploads = ctx.Data.Upload.findByWebLog webLog.id
|
let! dbUploads = ctx.Data.Upload.findByWebLog webLog.id
|
||||||
let diskUploads =
|
let diskUploads =
|
||||||
let path = Path.Combine ("wwwroot", "upload", webLog.slug)
|
let path = Path.Combine (uploadDir, webLog.slug)
|
||||||
try
|
try
|
||||||
Directory.EnumerateFiles (path, "*", SearchOption.AllDirectories)
|
Directory.EnumerateFiles (path, "*", SearchOption.AllDirectories)
|
||||||
|> Seq.map (fun file ->
|
|> Seq.map (fun file ->
|
||||||
|
@ -94,7 +100,7 @@ let list : HttpHandler = fun next ctx -> task {
|
||||||
| _ -> None
|
| _ -> None
|
||||||
{ DisplayUpload.id = ""
|
{ DisplayUpload.id = ""
|
||||||
name = name
|
name = name
|
||||||
path = file.Replace($"{path}/", "").Replace (name, "")
|
path = file.Replace($"{path}{slash}", "").Replace(name, "").Replace (slash, '/')
|
||||||
updatedOn = create
|
updatedOn = create
|
||||||
source = UploadDestination.toString Disk
|
source = UploadDestination.toString Disk
|
||||||
})
|
})
|
||||||
|
@ -106,7 +112,7 @@ let list : HttpHandler = fun next ctx -> task {
|
||||||
[]
|
[]
|
||||||
let allFiles =
|
let allFiles =
|
||||||
dbUploads
|
dbUploads
|
||||||
|> List.map (DisplayUpload.fromUpload Database)
|
|> List.map (DisplayUpload.fromUpload webLog Database)
|
||||||
|> List.append diskUploads
|
|> List.append diskUploads
|
||||||
|> List.sortByDescending (fun file -> file.updatedOn, file.path)
|
|> List.sortByDescending (fun file -> file.updatedOn, file.path)
|
||||||
|
|
||||||
|
@ -130,11 +136,16 @@ let showNew : HttpHandler = fun next ctx -> task {
|
||||||
|> viewForTheme "admin" "upload-new" next ctx
|
|> viewForTheme "admin" "upload-new" next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redirect to the upload list
|
||||||
|
let showUploads : HttpHandler = fun next ctx -> task {
|
||||||
|
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/uploads")) next ctx
|
||||||
|
}
|
||||||
|
|
||||||
// POST /admin/upload/save
|
// POST /admin/upload/save
|
||||||
let save : HttpHandler = fun next ctx -> task {
|
let save : HttpHandler = fun next ctx -> task {
|
||||||
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
|
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
|
||||||
let upload = Seq.head ctx.Request.Form.Files
|
let upload = Seq.head ctx.Request.Form.Files
|
||||||
let fileName = String.Join ('.', makeSlug (Path.GetFileNameWithoutExtension upload.FileName),
|
let fileName = String.Concat (makeSlug (Path.GetFileNameWithoutExtension upload.FileName),
|
||||||
Path.GetExtension(upload.FileName).ToLowerInvariant ())
|
Path.GetExtension(upload.FileName).ToLowerInvariant ())
|
||||||
let webLog = ctx.WebLog
|
let webLog = ctx.WebLog
|
||||||
let localNow = WebLog.localTime webLog DateTime.Now
|
let localNow = WebLog.localTime webLog DateTime.Now
|
||||||
|
@ -155,13 +166,50 @@ let save : HttpHandler = fun next ctx -> task {
|
||||||
}
|
}
|
||||||
do! ctx.Data.Upload.add file
|
do! ctx.Data.Upload.add file
|
||||||
| Disk ->
|
| Disk ->
|
||||||
let fullPath = Path.Combine ("wwwroot", webLog.slug, year, month)
|
let fullPath = Path.Combine (uploadDir, webLog.slug, year, month)
|
||||||
let _ = Directory.CreateDirectory fullPath
|
let _ = Directory.CreateDirectory fullPath
|
||||||
use stream = new FileStream (Path.Combine (fullPath, fileName), FileMode.Create)
|
use stream = new FileStream (Path.Combine (fullPath, fileName), FileMode.Create)
|
||||||
do! upload.CopyToAsync stream
|
do! upload.CopyToAsync stream
|
||||||
|
|
||||||
do! addMessage ctx { UserMessage.success with message = $"File uploaded to {form.destination} successfully" }
|
do! addMessage ctx { UserMessage.success with message = $"File uploaded to {form.destination} successfully" }
|
||||||
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/uploads")) next ctx
|
return! showUploads next ctx
|
||||||
else
|
else
|
||||||
return! RequestErrors.BAD_REQUEST "Bad request; no file present" next ctx
|
return! RequestErrors.BAD_REQUEST "Bad request; no file present" next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /admin/upload/{id}/delete
|
||||||
|
let deleteFromDb upId : HttpHandler = fun next ctx -> task {
|
||||||
|
let uploadId = UploadId upId
|
||||||
|
let webLog = ctx.WebLog
|
||||||
|
let data = ctx.Data
|
||||||
|
match! data.Upload.delete uploadId webLog.id with
|
||||||
|
| Ok fileName ->
|
||||||
|
do! addMessage ctx { UserMessage.success with message = $"{fileName} deleted successfully" }
|
||||||
|
return! showUploads next ctx
|
||||||
|
| Error _ -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a directory tree if it is empty
|
||||||
|
let removeEmptyDirectories (webLog : WebLog) (filePath : string) =
|
||||||
|
let mutable path = Path.GetDirectoryName filePath
|
||||||
|
let mutable finished = false
|
||||||
|
while (not finished) && path > "" do
|
||||||
|
let fullPath = Path.Combine (uploadDir, webLog.slug, path)
|
||||||
|
if Directory.EnumerateFileSystemEntries fullPath |> Seq.isEmpty then
|
||||||
|
Directory.Delete fullPath
|
||||||
|
path <- String.Join(slash, path.Split slash |> Array.rev |> Array.skip 1 |> Array.rev)
|
||||||
|
else
|
||||||
|
finished <- true
|
||||||
|
|
||||||
|
// POST /admin/upload/delete/{**path}
|
||||||
|
let deleteFromDisk urlParts : HttpHandler = fun next ctx -> task {
|
||||||
|
let filePath = urlParts |> Seq.skip 1 |> Seq.head
|
||||||
|
let path = Path.Combine (uploadDir, ctx.WebLog.slug, filePath)
|
||||||
|
if File.Exists path then
|
||||||
|
File.Delete path
|
||||||
|
removeEmptyDirectories ctx.WebLog filePath
|
||||||
|
do! addMessage ctx { UserMessage.success with message = $"{filePath} deleted successfully" }
|
||||||
|
return! showUploads next ctx
|
||||||
|
else
|
||||||
|
return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
|
@ -46,7 +46,16 @@
|
||||||
</a>
|
</a>
|
||||||
{%- endunless %}
|
{%- endunless %}
|
||||||
<span class="text-muted"> Link • </span>
|
<span class="text-muted"> Link • </span>
|
||||||
<a href="#" onclick="" class="text-danger">Delete</a>
|
{%- capture delete_url -%}
|
||||||
|
{%- if file.source == "disk" -%}
|
||||||
|
admin/upload/delete/{{ file.path }}{{ file.name }}
|
||||||
|
{%- else -%}
|
||||||
|
admin/upload/{{ file.id }}/delete
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endcapture -%}
|
||||||
|
<a href="{{ delete_url | relative_link }}" hx-post="{{ delete_url | relative_link }}"
|
||||||
|
hx-confirm="Are you sure you want to delete {{ file.name }}? This action cannot be undone."
|
||||||
|
class="text-danger">Delete</a>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">{{ file.path }}</div>
|
<div class="col-3">{{ file.path }}</div>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user