Skip to content

Commit

Permalink
Fixed #15 #14 and #13.
Browse files Browse the repository at this point in the history
Fixed error handling for deserialization of invalid tokens.
Added desription of upload feature to README.md.
  • Loading branch information
b0wter committed Jan 21, 2019
1 parent 5ab66e6 commit 9b9ed8a
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 46 deletions.
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ abcdef
12345678:2019-01-01
```

The first value `abcdef` has not been used and therefor does not have an expiration date. The second value has been used and expires on the first of January 2019. The colon is not part of the token value. Expiration dates are set when a download of the given file is attempted. If you want to, you can set an expiration date manually or edit a previously written date using a text editor.
The first value `abcdef` has not been used and therefor does not have an expiration date. The second value has been used and expires on the first of January 2019. The colon is not part of the token value. Expiration dates are set when a download of the given file is attempted. If you want to, you can set an expiration date manually or edit a previously written date using a text editor. Expiration can be given as:

```
yyyy-MM-dd HH:mm:ss
yyyy-MM-dd
yyyy-MM-ddTHH\\:mm\\:ss.fffffffzzz (ISO 8601)
```

You can add comments as well:

Expand Down Expand Up @@ -76,6 +82,45 @@ This means that you can create multiple download urls per file, e.h. for sharing

This lifetime is a workaround for the fact that the webserver has no idea if a file download was successful. There might be any number of problems that's why a successful download cannot be tracked. That's why this lifetime gives people multiple chances to try and download the file.

Setup Uploads
-------------
You can enable uploads for remote users. To do so you have to set `UploadsEnabled` to true. You may optionally define a maximum upload size by setting `MaxUploadSize` (bytes). Please note that there is a hardcoded size limit of ~1.5GB for remote uploads. This is because of the way ASP.NET Core and Giraffe handle uploads. Please note that uploads are *not* checked for malicious content!

Uploads use the same `BasePath` as downloads. To enable uploads to a folder create a file named `.token`. This file follows the same syntax as the regular download token files.

Upload files
------------
To upload a file you need to give an upload token to a remote user. The user needs to go to:

```
http://myserver/upload
```

and enter the token. The Upload-button is not enabled until the user validates his token. You can also use a tool like curl or wget to upload the file directly:

```
http://myserver/api/upload
Method: POST
Content: multipart/form-data
Form parameter: token = $YOUR_TOKEN
```

Please note that the token is only verified *after* the file finished uploading. That's why the GUI requires the user to validate the token before starting the upload. If you want to upload a token in a script and want to check a token, you can do so by sending a request the following way:

```
const Http = new XMLHttpRequest();
const url = "/api/upload/validate";
const form = new FormData();
form.append("token", token);
Http.open("POST", url);
Http.send(form);
```

You will either receive "true" or "false" as result.

Configuration
=============
The application needs a configuration file to work properly. The file is written in json and looks like this:
Expand All @@ -85,7 +130,9 @@ The application needs a configuration file to work properly. The file is written
"DownloadLifetime": "7.00:00:00",
"TokenLifetime": "2.00:00:00",
"CleanExpiredDownloads": false,
"CronIntervalInHours": 24
"CronIntervalInHours": 24,
"UploadsEnabled": false,
"MaxUploadSize": 1073741824
}
```
Expand All @@ -95,6 +142,8 @@ The application needs a configuration file to work properly. The file is written
* *TokenLifetime*: Expiration time of a single token value. This is set the moment the first user uses this token. Given in the format $DAYS:HOURS:MINUTES:SECONDS.
* *CleanExpiredDownloads*: Makes Torpedo periodically check all downloads and delete the token and content files for expired downloads.
* *CronIntervalInHours*: Interval in which the aforementioned action is performed. Given in hours.
* *UploadsEnabled*: Enables the upload feature for remote users. Note that you will still need to create upload tokens manually.
* *MaxUploadSize*: Maximum size of uploads, given in bytes. Please note that the framework currently limits Torpedo to an upload size of about 1.5 GB.

Docker
======
Expand All @@ -120,7 +169,7 @@ Images are auto-generated from this repository and can be found on [Docker Hub](

HTTPS
=====
Currently there is no native support for certificates. I recommend running this app behind a reverse proxy (which may offer https).
Currently there is no native support for certificates. I recommend running this app behind a reverse proxy (which may offer https). Either Caddy or Traefic are perfect choices to do so as they offer Let's Encrypt support out of the box.

Creating downloads
==================
Expand Down
10 changes: 9 additions & 1 deletion src/webapi/Configuration.fs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Configuration() =
let mutable cleanExpiredDownloads: bool = false
let mutable cronIntervalInHours: int = 24
let mutable uploadsEnabled: bool = false
let mutable maxUploadSize: Int64 = Int64.MaxValue

/// <summary>
/// Use this Singleton to access the configuration from anywhere.
Expand Down Expand Up @@ -65,4 +66,11 @@ type Configuration() =
/// </summary>
member this.UploadsEnabled
with get() = uploadsEnabled
and set(value) = uploadsEnabled <- value
and set(value) = uploadsEnabled <- value

/// <summary>
/// Sets the maximum size for file uploads in bytes.
/// </summary>
member this.MaxUploadSize
with get() = maxUploadSize
and set(value) = maxUploadSize <- value
2 changes: 1 addition & 1 deletion src/webapi/DownloadHandler.fs
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,5 @@ let downloadWorkflow (basePath: string) (downloadLifeTime: TimeSpan) (tokenLifeT

match d with
| Ok token -> return! (getFileStreamResponseAsync basePath filename filename ctx next)
| Error e -> return! next ctx//(failWithStatusCodeAndMessage ctx next e.StatusCode e.Reason)
| Error e -> return! (failWithStatusCodeAndMessage ctx next e.StatusCode e.Reason next ctx)
}
2 changes: 1 addition & 1 deletion src/webapi/Helpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let errorCodeToView (statusCode: int) =
let failWithStatusCodeAndMessage (ctx: HttpContext) (next: HttpFunc) (statusCode: int) (message: string) =
do ctx.SetStatusCode(statusCode)
do ctx.Items.Add("errormessage", message)
(statusCode |> errorCodeToView) "message" |> htmlView
(statusCode |> errorCodeToView) message |> htmlView

/// <summary>
/// Maps items of a collection and runs a filter afterwards. The original item is returned for all mapped items that passed the filter.
Expand Down
15 changes: 7 additions & 8 deletions src/webapi/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,6 @@ let uploadFile : HttpFunc -> Microsoft.AspNetCore.Http.HttpContext -> HttpFuncRe
uploadWorkflow
Configuration.Configuration.Instance.BasePath

(*
let validateTokenInContextItems : HttpFunc -> Microsoft.AspNetCore.Http.HttpContext -> HttpFuncResult =
validateTokenInContextItems
Configuration.Configuration.Instance.BasePath
*)

let validateUploadTokenAndExit : HttpHandler =
validateUploadToken
Configuration.Instance.BasePath
Expand Down Expand Up @@ -91,7 +85,7 @@ let webApp =
>=> (Views.badRequestView "Your request is missing the 'filename' query parameter." |> htmlView)
route "/api/download"
>=> (Views.badRequestView "Your request is missing the 'filename' as well as the 'token' query parameters." |> htmlView)
route "/"
route "/download"
>=> (Views.indexView |> htmlView)

(* Route for the upload api. *)
Expand All @@ -113,6 +107,11 @@ let webApp =
route "/upload"
>=> (Views.featureNotEnabledview "file upload" |> htmlView)

(* general routes *)
route "/"
>=> redirectTo true "/download"
route "/about"
>=> (Views.aboutView |> htmlView)
setStatusCode 404 >=> (Views.notFoundView "Page not found :(" |> htmlView)
]

Expand Down Expand Up @@ -163,7 +162,7 @@ let configureServices (services : IServiceCollection) =
services.AddCors() |> ignore
services.AddGiraffe() |> ignore
services.Configure<FormOptions>(fun (x: FormOptions) -> x.ValueLengthLimit <- Int32.MaxValue
x.MultipartBodyLengthLimit <- Int64.MaxValue)
x.MultipartBodyLengthLimit <- Configuration.Instance.MaxUploadSize)
|> ignore

let configureLogging (builder : ILoggingBuilder) =
Expand Down
40 changes: 32 additions & 8 deletions src/webapi/TokenSerializer.fs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
module WebApi.TokenSerializer
open System
open System.Globalization
open System.Globalization
open System.IO
open WebApi.Tokens
open WebApi.Helpers
Expand Down Expand Up @@ -27,10 +29,10 @@ let serializeTokenValue (tokenvalue: TokenValue): string =

let noneDateAsEmpty (prefix: string) (suffix: string) (d: DateTime option) =
match d with
| Some date -> sprintf "%s%s%s" prefix (date.ToString("yyyy-MM-dd")) suffix
| Some date -> sprintf "%s%s%s" prefix (date.ToString("yyyy-MM-dd HH:mm:ss")) suffix
| None -> ""

sprintf "%s%s%s" (tokenvalue.Value) (tokenvalue.ExpirationDate |> noneDateAsEmpty ":" "") (tokenvalue.Comment |> noneStringAsEmpty " # " "")
sprintf "%s%s%s" (tokenvalue.Value) (tokenvalue.ExpirationDate |> noneDateAsEmpty ";" "") (tokenvalue.Comment |> noneStringAsEmpty " # " "")

/// <summary>
/// Serializes a Token by serializing each TokenValue using the serializeTokenValue method.
Expand All @@ -53,18 +55,40 @@ let private trimTokenValue (token: string) =
.TrimStart(' ', '\t')
.TrimEnd(' ', '\t')

/// <summary>
/// Tries to parse the given string in a specific format. If that fails uses framework defaults to parse the date.
/// If that fails returns None. Returns Some DateTime otherweise.
/// </summary>
let private parseStringAsDateTime (s: string) : DateTime option =
match DateTime.TryParseExact(s, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None) with
| (true, date) -> Some date
| (false, _) ->
match DateTime.TryParse(s) with
| (true, date) -> Some date
| (false, _) -> None

/// <summary>
/// Deserializes a TokenValue.
/// (See serializeTokenValue for format details.)
/// </summary>
let deserializeTokenValue (content: string) : Result<TokenValue, string> =
let content = content.Replace("#", ":")
let content = content.Replace("#", ";")
(*
| [| value ; expiration; comment |] -> ( trimTokenValue value; TokenValue.ExpirationDate = Some (DateTime.ParseExact(expiration, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); TokenValue.Comment = Some (comment.TrimStart(' ', '\t')) }
| [| value ; expiration |] -> ( TokenValue.Value = trimTokenValue value; TokenValue.ExpirationDate = Some (DateTime.ParseExact(expiration, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); TokenValue.Comment = None }
| [| value |] -> ( TokenValue.Value = trimTokenValue value; TokenValue.ExpirationDate = None; TokenValue.Comment = None }
*)

match content.Split(":") with
| [| value ; expiration; comment |] -> Ok { TokenValue.Value = trimTokenValue value; TokenValue.ExpirationDate = Some (DateTime.Parse(expiration)); TokenValue.Comment = Some (comment.TrimStart(' ', '\t')) }
| [| value ; expiration |] -> Ok { TokenValue.Value = trimTokenValue value; TokenValue.ExpirationDate = Some (DateTime.Parse(expiration)); TokenValue.Comment = None }
| [| value |] -> Ok { TokenValue.Value = trimTokenValue value; TokenValue.ExpirationDate = None; TokenValue.Comment = None }
| _ -> Error "String split delivered zero or more than two parts."
let (value, expiration, comment, shouldHaveDate, isValid) = match content.Split(";") with
| [| value ; expiration; comment |] -> ( trimTokenValue value, (parseStringAsDateTime expiration), Some (comment.TrimStart(' ', '\t')), true, true )
| [| value ; expiration |] -> ( trimTokenValue value, (parseStringAsDateTime expiration), None, true, true )
| [| value |] -> ( trimTokenValue value, None, None, false, true )
| _ -> ( "", None, None, false, false)

if isValid && (if shouldHaveDate && expiration.IsNone then false else true) then
Ok { Value = value; ExpirationDate = expiration; Comment = comment }
else
Error "The string split returnd either to many or too few arguments or a token file contains an invalid date."

/// <summary>
/// Deserializes a Token by deserializing each line using deserializeTokenValue.
Expand Down
78 changes: 57 additions & 21 deletions src/webapi/Views.fs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ let private headTags (scripts: string list) =
]
@ (scripts |> List.map (fun s -> script [ _src (sprintf "/js/%s" s) ] []))

let private headerView =
div [ _id "header" ] [
a [ _href "/about"; _class "centered-text margin-top-05em link-text" ] [ str "About"]
]

let private footerView =
div [ _id "footer" ] [
img [ _src "/images/logo_white.png"; _id "footer-image" ]
Expand All @@ -48,38 +53,46 @@ let private footerView =
let private masterView (scripts: string list) (content: XmlNode list) =
html [ _id "background" ] [
head [] (scripts |> headTags)

body [ _class "transparent" ] [

div [ _id "outer" ] [
div [ _id "middle" ] [
div [ _id "inner" ]
content
body [ _class "transparent" ] [
headerView
div [] [
div [ _id "outer" ] [
div [ _id "middle" ] [
div [ _id "inner" ]
content
]
]
]

footerView
]
]

let private inputBoxView =
div [ _class "input-group vertical" ] [
input [ _type "text"; _class "transparent button-margin input-field"; _id "filename"; _name "filename" ; _placeholder "Filename" ]
input [ _type "text"; _class "transparent button-margin input-field"; _id "token"; _name "token"; _placeholder "Token" ]
button [ _type "button"; _onclick "readAndRedirect()"; _id "action-button"; _class "action-button round-button" ] [ str "Download" ]
a [ _href "upload"; _class "centered-text margin-top-05em link-text" ] [ str "If you have an upload token, click here."]
let private inputBoxView =
div [] [
script [ _src "/js/download.js" ] []
div [ _class "input-group vertical" ] [
input [ _type "text"; _class "transparent button-margin input-field"; _id "filename"; _name "filename" ; _placeholder "Filename" ]
input [ _type "text"; _class "transparent button-margin input-field"; _id "token"; _name "token"; _placeholder "Token" ]
button [ _type "button"; _onclick "readAndRedirect()"; _id "action-button"; _class "action-button round-button" ] [ str "Download" ]
a [ _href "/upload"; _class "centered-text margin-top-05em link-text" ] [ str "If you have an upload token, click here."]
]
]

let private uploadInputBoxView =
form [ _enctype "multipart/form-data"; _action "/api/upload"; _method "post"; _class "invisible-form" ] [
div [ _class "input-group vertical invisible-form" ] [
div [ _class "inputAndButton" ] [
input [ _type "text"; _class "transparent button-margin input-field width100percent"; _id "token"; _name "token"; _placeholder "Token" ]
button [ _type "button"; _id "subaction-button"; _onclick "onValidationButtonClick()"; _class "action-button round-button" ] [ str "Validate" ]
div [] [
script [ _src "/js/upload.js" ] []
form [ _enctype "multipart/form-data"; _action "/api/upload"; _method "post"; _class "invisible-form" ] [
div [ _class "input-group vertical invisible-form" ] [
div [ _class "inputAndButton" ] [
input [ _type "text"; _class "transparent button-margin input-field width100percent"; _id "token"; _name "token"; _placeholder "Token" ]
button [ _type "button"; _id "subaction-button"; _onclick "onValidationButtonClick()"; _class "action-button round-button" ] [ str "Validate" ]
]
input [ _type "file"; _id "subaction-button"; _placeholder "Filename"; _name "file"; _class "file-chooser-margin" ]
button [ _type "submit"; _id "action-button"; _disabled; _class "action-button round-button"] [ str "Upload" ]
a [ _href "/download"; _class "centered-text margin-top-05em link-text" ] [ str "If you have a download token and filename, click here."]
]
input [ _type "file"; _onclick "readAndRedirect()"; _id "subaction-button"; _placeholder "Filename"; _name "file"; _class "file-chooser-margin" ]
button [ _type "submit"; _id "action-button"; _disabled; _class "action-button round-button"] [ str "Upload" ]
a [ _href "/"; _class "centered-text margin-top-05em link-text" ] [ str "If you have a download token and filename, click here."]
]
]

Expand Down Expand Up @@ -120,7 +133,7 @@ let internalErrorView (message: string) =

let uploadView =
[
h1 [] [ str "File Upload" ]
h1 [] [ str "Welcome to Torpedo" ]
p [] [ str "If you have an upload token you can use it here to upload files."]
uploadInputBoxView
]
Expand All @@ -141,4 +154,27 @@ let featureNotEnabledview (name: string) =
h1 [] [ str (sprintf "The %s feature is not enabled." name) ]
p [] [ str "If you think this should be enabled, please contact the administrator." ]
]
|> masterView []

let aboutView =
[
h1 [] [ str "Written with F# and ❤️" ]
div [] [
p [] [
span [] [ str "If you have feedback please email " ]
a [ _href "mailto:[email protected]"; _class "centered-text margin-top-05em link-text" ] [ str "me"]
span [] [ str "." ]
]
p [] [
span [] [ str "Fork me on " ]
a [ _href "https://github.com/b0wter/torpedo"; _class "centered-text margin-top-05em link-text" ] [ str "GitHub"]
span [] [ str "." ]
]
br []
p [] [
a [ _href "javascript:history.back()"; _class "centered-text margin-top-05em link-text" ] [ str "BACK"]
]

]
]
|> masterView []
3 changes: 2 additions & 1 deletion src/webapi/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"TokenLifetime": "3.00:00:00",
"CleanExpiredDownloads": false,
"CronIntervalInHours": 24,
"UploadsEnabled": true
"UploadsEnabled": true,
"MaxUploadSize": 9223372036854775807
}
9 changes: 7 additions & 2 deletions src/webapi/wwwroot/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@
#outer {
display: table;
position: absolute;
top: 0;
top: 40px;
left: 0;
height: 100%;
height: calc(100% - 40px);
width: 100%;
}

Expand Down Expand Up @@ -154,4 +154,9 @@ a:active {
.error {
background: darkred !important;
color: white;
}

#header {
padding: 10px;
position: absolute;
}

0 comments on commit 9b9ed8a

Please sign in to comment.