diff --git a/README.md b/README.md index 38229ae..abb213b 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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: @@ -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 } ``` @@ -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 ====== @@ -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 ================== diff --git a/src/webapi/Configuration.fs b/src/webapi/Configuration.fs index a517c6b..c9f211d 100644 --- a/src/webapi/Configuration.fs +++ b/src/webapi/Configuration.fs @@ -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 /// /// Use this Singleton to access the configuration from anywhere. @@ -65,4 +66,11 @@ type Configuration() = /// member this.UploadsEnabled with get() = uploadsEnabled - and set(value) = uploadsEnabled <- value \ No newline at end of file + and set(value) = uploadsEnabled <- value + + /// + /// Sets the maximum size for file uploads in bytes. + /// + member this.MaxUploadSize + with get() = maxUploadSize + and set(value) = maxUploadSize <- value \ No newline at end of file diff --git a/src/webapi/DownloadHandler.fs b/src/webapi/DownloadHandler.fs index 3f63e30..86130ed 100644 --- a/src/webapi/DownloadHandler.fs +++ b/src/webapi/DownloadHandler.fs @@ -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) } diff --git a/src/webapi/Helpers.fs b/src/webapi/Helpers.fs index 9373630..62e180f 100644 --- a/src/webapi/Helpers.fs +++ b/src/webapi/Helpers.fs @@ -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 /// /// Maps items of a collection and runs a filter afterwards. The original item is returned for all mapped items that passed the filter. diff --git a/src/webapi/Program.fs b/src/webapi/Program.fs index 08cf24c..ea459f4 100644 --- a/src/webapi/Program.fs +++ b/src/webapi/Program.fs @@ -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 @@ -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. *) @@ -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) ] @@ -163,7 +162,7 @@ let configureServices (services : IServiceCollection) = services.AddCors() |> ignore services.AddGiraffe() |> ignore services.Configure(fun (x: FormOptions) -> x.ValueLengthLimit <- Int32.MaxValue - x.MultipartBodyLengthLimit <- Int64.MaxValue) + x.MultipartBodyLengthLimit <- Configuration.Instance.MaxUploadSize) |> ignore let configureLogging (builder : ILoggingBuilder) = diff --git a/src/webapi/TokenSerializer.fs b/src/webapi/TokenSerializer.fs index 1972c9a..edd5744 100644 --- a/src/webapi/TokenSerializer.fs +++ b/src/webapi/TokenSerializer.fs @@ -1,5 +1,7 @@ module WebApi.TokenSerializer open System +open System.Globalization +open System.Globalization open System.IO open WebApi.Tokens open WebApi.Helpers @@ -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 " # " "") /// /// Serializes a Token by serializing each TokenValue using the serializeTokenValue method. @@ -53,18 +55,40 @@ let private trimTokenValue (token: string) = .TrimStart(' ', '\t') .TrimEnd(' ', '\t') +/// +/// 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. +/// +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 + /// /// Deserializes a TokenValue. /// (See serializeTokenValue for format details.) /// let deserializeTokenValue (content: string) : Result = - 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." /// /// Deserializes a Token by deserializing each line using deserializeTokenValue. diff --git a/src/webapi/Views.fs b/src/webapi/Views.fs index dbaf03d..88b7040 100644 --- a/src/webapi/Views.fs +++ b/src/webapi/Views.fs @@ -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" ] @@ -48,13 +53,15 @@ 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 + ] ] ] @@ -62,24 +69,30 @@ let private masterView (scripts: string list) (content: XmlNode list) = ] ] -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."] ] ] @@ -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 ] @@ -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:torpedo.http@gmail.com"; _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 [] \ No newline at end of file diff --git a/src/webapi/config.json b/src/webapi/config.json index 4614d37..818e39d 100644 --- a/src/webapi/config.json +++ b/src/webapi/config.json @@ -4,5 +4,6 @@ "TokenLifetime": "3.00:00:00", "CleanExpiredDownloads": false, "CronIntervalInHours": 24, - "UploadsEnabled": true + "UploadsEnabled": true, + "MaxUploadSize": 9223372036854775807 } diff --git a/src/webapi/wwwroot/css/custom.css b/src/webapi/wwwroot/css/custom.css index 2d1e905..017acfc 100644 --- a/src/webapi/wwwroot/css/custom.css +++ b/src/webapi/wwwroot/css/custom.css @@ -28,9 +28,9 @@ #outer { display: table; position: absolute; - top: 0; + top: 40px; left: 0; - height: 100%; + height: calc(100% - 40px); width: 100%; } @@ -154,4 +154,9 @@ a:active { .error { background: darkred !important; color: white; +} + +#header { + padding: 10px; + position: absolute; } \ No newline at end of file