diff --git a/build/go-generate/minifyStaticContent.go b/build/go-generate/minifyStaticContent.go index ae9f53a..fd6c5ef 100644 --- a/build/go-generate/minifyStaticContent.go +++ b/build/go-generate/minifyStaticContent.go @@ -72,16 +72,16 @@ func getPaths() []converter { Name: "wasm_exec JS", }) result = append(result, converter{ - InputPath: pathPrefix + "js/dateformat.js", - OutputPath: pathPrefix + "js/min/dateformat.min.js", + InputPath: pathPrefix + "js/all_public.js", + OutputPath: pathPrefix + "js/min/all_public.min.js", Type: "text/javascript", - Name: "Dateformat JS", + Name: "Public functions JS", }) result = append(result, converter{ - InputPath: pathPrefix + "js/uuid.js", - OutputPath: pathPrefix + "js/min/uuid.min.js", + InputPath: pathPrefix + "js/public_upload.js", + OutputPath: pathPrefix + "js/min/public_upload.min.js", Type: "text/javascript", - Name: "UUID JS", + Name: "Public upload JS", }) return result } diff --git a/build/go-generate/updateApiRouting.go b/build/go-generate/updateApiRouting.go index 8536d85..4e62753 100644 --- a/build/go-generate/updateApiRouting.go +++ b/build/go-generate/updateApiRouting.go @@ -81,14 +81,28 @@ func hasRequiredTag(tags []string) bool { return false } -func headerExists(headerName string, required, isString bool) string { +func hasBase64Tag(tags []string) bool { + // Check if the tag contains "supportBase64:true" + for _, tag := range tags { + if strings.HasPrefix(tag, "supportBase64") { + return true + } + } + return false +} + +func headerExists(headerName string, required, isString, base64Support bool) string { + base64SupportEntry := "" + if base64Support { + base64SupportEntry = ", has base64support" + } return fmt.Sprintf("\n"+` - // RequestParser header value %s, required: %v + // RequestParser header value %s, required: %v%s exists, err = checkHeaderExists(r, %s, %v, %v) if err != nil { return err } - p.foundHeaders[%s] = exists`, headerName, required, headerName, required, isString, headerName) + p.foundHeaders[%s] = exists`, headerName, required, base64SupportEntry, headerName, required, isString, headerName) } func generateParseRequestMethod(typeName string, fields []*ast.Field) string { @@ -122,25 +136,41 @@ func generateParseRequestMethod(typeName string, fields []*ast.Field) string { // Check if the tag has the "header" key and extract its value tagParts := strings.Split(tag, " ") required := hasRequiredTag(tagParts) + base64Support := hasBase64Tag(tagParts) for _, part := range tagParts { if strings.HasPrefix(part, "header:") { - // Extract header name after 'header:' + // Extract the header name after 'header:' headerName := strings.TrimPrefix(part, "header:") fieldType := field.Type.(*ast.Ident).Name - // Use appropriate parsing function based on the field type + // Use the appropriate parsing function based on the field type switch fieldType { case "string": - method += headerExists(headerName, required, true) - method += fmt.Sprintf(` - if (exists) { - p.%s = r.Header.Get(%s) + method += headerExists(headerName, required, true, base64Support) + if !base64Support { + method += fmt.Sprintf(` + if (exists) { + p.%s = r.Header.Get(%s) + } + `, field.Names[0].Name, headerName) + } else { + method += fmt.Sprintf(` + if (exists) { + p.%s = r.Header.Get(%s) + if strings.HasPrefix(p.%s, "base64:") { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(p.%s, "base64:")) + if err != nil { + return err + } + p.%s = string(decoded) + } + } + `, field.Names[0].Name, headerName, field.Names[0].Name, field.Names[0].Name, field.Names[0].Name) } - `, field.Names[0].Name, headerName) case "bool": - method += headerExists(headerName, required, false) + method += headerExists(headerName, required, false, false) method += fmt.Sprintf(` if (exists) { p.%s, err = parseHeaderBool(r, %s) @@ -151,7 +181,7 @@ func generateParseRequestMethod(typeName string, fields []*ast.Field) string { `, field.Names[0].Name, headerName, strings.Replace(headerName, "\"", "", -1)) case "int": - method += headerExists(headerName, required, false) + method += headerExists(headerName, required, false, false) method += fmt.Sprintf(` if (exists) { p.%s, err = parseHeaderInt(r, %s) @@ -162,7 +192,7 @@ func generateParseRequestMethod(typeName string, fields []*ast.Field) string { `, field.Names[0].Name, headerName, strings.Replace(headerName, "\"", "", -1)) case "int64": - method += headerExists(headerName, required, false) + method += headerExists(headerName, required, false, false) method += fmt.Sprintf(` if (exists) { p.%s, err = parseHeaderInt64(r, %s) @@ -236,8 +266,10 @@ func main() { package api import ( + "encoding/base64" "fmt" "net/http" + "strings" ) // Do not modify: This is an automatically generated file created by updateApiRouting.go diff --git a/build/go-generate/updateEnvVariables.go b/build/go-generate/updateEnvVariables.go index 95f4fc2..ce86bd2 100644 --- a/build/go-generate/updateEnvVariables.go +++ b/build/go-generate/updateEnvVariables.go @@ -124,15 +124,15 @@ func extractEnvVars() ([]envVar, error) { }) result = append(result, - envVar{ - Name: "DOCKER_NONROOT", - Action: "DEPRECATED.\n\nDocker only: Runs the binary in the container as a non-root user, if set to \"true\"", - Default: "false", - }, envVar{ Name: "TMPDIR", Action: "Sets the path which contains temporary files", - Default: "Non-Docker: Default OS path\n\nDocker: [DATA_DIR]", + Default: "Non-Docker: Default OS path\nDocker: [DATA_DIR]", + }, + envVar{ + Name: "DOCKER_NONROOT", + Action: "DEPRECATED.\nDocker only: Runs the binary in the container as a non-root user, if set to \"true\"", + Default: "false", }, ) diff --git a/build/go.mod b/build/go.mod index 3643dff..abce8ab 100644 --- a/build/go.mod +++ b/build/go.mod @@ -19,24 +19,30 @@ require ( golang.org/x/oauth2 v0.27.0 golang.org/x/sync v0.11.0 golang.org/x/term v0.37.0 + golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.35.0 ) require ( github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect + github.com/djherbis/atime v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect - github.com/tdewolff/minify/v2 v2.24.2 // indirect - github.com/tdewolff/parse/v2 v2.8.3 // indirect + github.com/tdewolff/argp v0.0.0-20250430135133-0f54527d2b1e // indirect + github.com/tdewolff/minify/v2 v2.24.8 // indirect + github.com/tdewolff/parse/v2 v2.8.5 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect diff --git a/build/go.sum b/build/go.sum index 5e76c9d..79f8ec0 100644 --- a/build/go.sum +++ b/build/go.sum @@ -1,3 +1,4 @@ +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= @@ -8,21 +9,28 @@ github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5 github.com/cevatbarisyilmaz/ara v0.0.4/go.mod h1:BfFOxnUd6Mj6xmcvRxHN3Sr21Z1T3U2MYkYOmoQe4Ts= github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/johannesboyne/gofakes3 v0.0.0-20250106100439-5c39aecd6999/go.mod h1:t6osVdP++3g4v2awHz4+HFccij23BbdT1rX3W7IijqQ= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -34,12 +42,15 @@ github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/tdewolff/argp v0.0.0-20250430135133-0f54527d2b1e/go.mod h1:xw2b1X81m4zY1OGytzHNr/YKXbf/STHkK5idoNamlYE= github.com/tdewolff/minify/v2 v2.23.11 h1:cZqTVCtuVvPC8/GbCvYgIcdAQGmoxEObZzKeKIUixTE= github.com/tdewolff/minify/v2 v2.23.11/go.mod h1:vmkbfGQ5hp/eYB+TswNWKma67S0a+32HBL+mFWxjZ2Q= github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE= +github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw= github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781 h1:2qicgFovKg1XtX7Wf6GwexUdpb7q/jMIE2IgkYsVAvE= github.com/tdewolff/parse/v2 v2.8.2-0.20250806174018-50048bb39781/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= +github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -96,6 +107,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190829051458-42f498d34c4d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/cmd/cli-uploader/Main.go b/cmd/cli-uploader/Main.go index 2eb761b..e882fc5 100644 --- a/cmd/cli-uploader/Main.go +++ b/cmd/cli-uploader/Main.go @@ -5,6 +5,10 @@ import ( "encoding/json" "errors" "fmt" + "io" + "os" + "path/filepath" + "github.com/forceu/gokapi/cmd/cli-uploader/cliapi" "github.com/forceu/gokapi/cmd/cli-uploader/cliconfig" "github.com/forceu/gokapi/cmd/cli-uploader/cliconstants" @@ -12,9 +16,6 @@ import ( "github.com/forceu/gokapi/internal/environment" "github.com/forceu/gokapi/internal/helper" "github.com/schollz/progressbar/v3" - "io" - "os" - "path/filepath" ) func main() { @@ -25,9 +26,11 @@ func main() { case cliflags.ModeLogout: doLogout() case cliflags.ModeUpload: - processUpload(false) + processUpload(cliflags.ModeUpload) case cliflags.ModeArchive: - processUpload(true) + processUpload(cliflags.ModeArchive) + case cliflags.ModeDownload: + processDownload() case cliflags.ModeInvalid: os.Exit(3) } @@ -38,11 +41,11 @@ func doLogin() { cliconfig.CreateLogin() } -func processUpload(isArchive bool) { +func processUpload(mode int) { cliconfig.Load() - uploadParam := cliflags.GetUploadParameters(isArchive) + uploadParam := cliflags.GetUploadParameters(mode) - if isArchive { + if mode == cliflags.ModeArchive { zipPath, err := zipFolder(uploadParam.Directory, uploadParam.TmpFolder, !uploadParam.JsonOutput) if err != nil { fmt.Println(err) @@ -57,7 +60,7 @@ func processUpload(isArchive bool) { if err != nil { fmt.Println() if errors.Is(cliapi.ErrUnauthorised, err) { - fmt.Println("ERROR: Unauthorised API key. Please re-run login.") + fmt.Println("ERROR: Unauthorised API key. Please re-run login or make sure that the API key has the permission to upload files.") } else { fmt.Println("ERROR: Could not upload file") fmt.Println(err) @@ -77,6 +80,24 @@ func processUpload(isArchive bool) { fmt.Println("File Download URL: " + result.UrlDownload) } +func processDownload() { + cliconfig.Load() + uploadParam := cliflags.GetUploadParameters(cliflags.ModeDownload) + + // Perform the download + err := cliapi.DownloadFile(uploadParam) + if err != nil { + fmt.Println() + if errors.Is(cliapi.ErrUnauthorised, err) { + fmt.Println("ERROR: Unauthorised API key. Please re-run login or make sure that the API key has the permission to download files.") + } else { + fmt.Println("ERROR: Could not download file") + fmt.Println(err) + } + os.Exit(1) + } +} + func doLogout() { err := cliconfig.Delete() if err != nil { diff --git a/cmd/cli-uploader/cliapi/cliapi.go b/cmd/cli-uploader/cliapi/cliapi.go index d782ae2..967d246 100644 --- a/cmd/cli-uploader/cliapi/cliapi.go +++ b/cmd/cli-uploader/cliapi/cliapi.go @@ -6,12 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/forceu/gokapi/cmd/cli-uploader/cliflags" - "github.com/forceu/gokapi/internal/encryption" - "github.com/forceu/gokapi/internal/encryption/end2end" - "github.com/forceu/gokapi/internal/helper" - "github.com/forceu/gokapi/internal/models" - "github.com/schollz/progressbar/v3" "io" "mime/multipart" "net/http" @@ -20,6 +14,13 @@ import ( "strconv" "strings" "time" + + "github.com/forceu/gokapi/cmd/cli-uploader/cliflags" + "github.com/forceu/gokapi/internal/encryption" + "github.com/forceu/gokapi/internal/encryption/end2end" + "github.com/forceu/gokapi/internal/helper" + "github.com/forceu/gokapi/internal/models" + "github.com/schollz/progressbar/v3" ) var gokapiUrl string @@ -138,7 +139,7 @@ func getUrl(url string, headers []header, longTimeout bool) (string, error) { } // UploadFile uploads a file to the Gokapi server -func UploadFile(uploadParams cliflags.UploadConfig) (models.FileApiOutput, error) { +func UploadFile(uploadParams cliflags.FlagConfig) (models.FileApiOutput, error) { var progressBar *progressbar.ProgressBar file, err := os.OpenFile(uploadParams.File, os.O_RDONLY, 0664) if err != nil { @@ -146,6 +147,7 @@ func UploadFile(uploadParams cliflags.UploadConfig) (models.FileApiOutput, error fmt.Println(err) os.Exit(4) } + defer file.Close() maxSize, chunkSize, isE2e, err := GetConfig() if err != nil { return models.FileApiOutput{}, err @@ -222,17 +224,121 @@ func UploadFile(uploadParams cliflags.UploadConfig) (models.FileApiOutput, error return metaData, nil } -func nameToBase64(f *os.File, uploadParams cliflags.UploadConfig) string { +// DownloadFile downloads a file from the Gokapi server +func DownloadFile(downloadParams cliflags.FlagConfig) error { + var progressBar *progressbar.ProgressBar + + info, err := getFileInfo(downloadParams.DownloadId) + if err != nil { + fmt.Println("ERROR: Could not get file info or file does not exist") + return err + } + if downloadParams.OutputPath == "" { + downloadParams.OutputPath = "." + } + if downloadParams.FileName == "" { + downloadParams.FileName = info.Name + } + filename := downloadParams.OutputPath + "/" + downloadParams.FileName + exists, err := helper.FileExists(filename) + if err != nil { + fmt.Println("ERROR: Could not check if file already exists") + return err + } + if exists { + fmt.Println("ERROR: File already exists, please specify a different filename") + os.Exit(1) + } + if !helper.FolderExists(downloadParams.OutputPath) { + err = os.Mkdir(downloadParams.OutputPath, 0770) + if err != nil { + fmt.Println("ERROR: Could not create output directory") + return err + } + } + helper.CreateDir(downloadParams.OutputPath) + file, err := os.Create(downloadParams.OutputPath + "/" + downloadParams.FileName) + defer file.Close() + if err != nil { + fmt.Println("ERROR: Could not create new file") + return err + } + + if !downloadParams.JsonOutput { + progressBar = progressbar.DefaultBytes(info.SizeBytes, "Downloading") + } + + req, err := http.NewRequest("GET", gokapiUrl+"/files/download/"+downloadParams.DownloadId, nil) + if err != nil { + return err + } + req.Header.Add("apikey", apiKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Println("ERROR: Could not download file: Status code " + strconv.Itoa(resp.StatusCode)) + os.Exit(4) + } + + if !downloadParams.JsonOutput { + _, err = io.Copy(file, io.TeeReader(resp.Body, progressBar)) + } else { + _, err = io.Copy(file, resp.Body) + } + + if err != nil { + fmt.Println("ERROR: Could not download file") + return err + } + if downloadParams.RemoveRemote { + err = deleteRemoteFile(downloadParams.DownloadId) + if err != nil { + return err + } + } + if !downloadParams.JsonOutput { + fmt.Println("File downloaded successfully") + } else { + fmt.Println("{\"result\":\"OK\"}") + } + return nil +} + +func nameToBase64(f *os.File, uploadParams cliflags.FlagConfig) string { return "base64:" + base64.StdEncoding.EncodeToString([]byte(getFileName(f, uploadParams))) } -func getFileName(f *os.File, uploadParams cliflags.UploadConfig) string { +func getFileName(f *os.File, uploadParams cliflags.FlagConfig) string { if uploadParams.FileName != "" { return uploadParams.FileName } return filepath.Base(f.Name()) } +func getFileInfo(id string) (models.FileApiOutput, error) { + result, err := getUrl(gokapiUrl+"/files/list/"+id, []header{}, false) + if err != nil { + return models.FileApiOutput{}, err + } + var parsedResult models.FileApiOutput + err = json.Unmarshal([]byte(result), &parsedResult) + if err != nil { + return models.FileApiOutput{}, err + } + return parsedResult, nil +} + +func deleteRemoteFile(id string) error { + _, err := getUrl(gokapiUrl+"/files/delete", []header{{"id", id}}, false) + return err +} + func uploadChunk(f io.Reader, uuid string, offset, chunkSize, filesize int64, progressBar *progressbar.ProgressBar) error { body := new(bytes.Buffer) writer := multipart.NewWriter(body) @@ -299,7 +405,7 @@ func uploadChunk(f io.Reader, uuid string, offset, chunkSize, filesize int64, pr return nil } -func completeChunk(uid, filename string, filesize, realsize int64, useE2e bool, uploadParams cliflags.UploadConfig, progressBar *progressbar.ProgressBar) (models.FileApiOutput, error) { +func completeChunk(uid, filename string, filesize, realsize int64, useE2e bool, uploadParams cliflags.FlagConfig, progressBar *progressbar.ProgressBar) (models.FileApiOutput, error) { type expectedFormat struct { FileInfo models.FileApiOutput `json:"FileInfo"` } diff --git a/cmd/cli-uploader/cliconstants/cliconstants.go b/cmd/cli-uploader/cliconstants/cliconstants.go index 6807537..a494c6b 100644 --- a/cmd/cli-uploader/cliconstants/cliconstants.go +++ b/cmd/cli-uploader/cliconstants/cliconstants.go @@ -1,10 +1,10 @@ package cliconstants // MinGokapiVersionInt is the minimum version of the gokapi server that is supported by the cli -const MinGokapiVersionInt = 20100 +const MinGokapiVersionInt = 20200 // MinGokapiVersionStr is the minimum version of the gokapi server that is supported by the cli -const MinGokapiVersionStr = "2.1.0" +const MinGokapiVersionStr = "2.2.0" // DefaultConfigFileName is the default config file name const DefaultConfigFileName = "gokapi-cli.json" diff --git a/cmd/cli-uploader/cliflags/cliflags.go b/cmd/cli-uploader/cliflags/cliflags.go index 5f8738e..112a3fc 100644 --- a/cmd/cli-uploader/cliflags/cliflags.go +++ b/cmd/cli-uploader/cliflags/cliflags.go @@ -2,13 +2,14 @@ package cliflags import ( "fmt" - "github.com/forceu/gokapi/cmd/cli-uploader/cliconstants" - "github.com/forceu/gokapi/internal/environment" "os" "path/filepath" "regexp" "strconv" "strings" + + "github.com/forceu/gokapi/cmd/cli-uploader/cliconstants" + "github.com/forceu/gokapi/internal/environment" ) const ( @@ -20,20 +21,25 @@ const ( ModeUpload // ModeArchive is the mode for the archive command ModeArchive + //ModeDownload is the mode for the download command + ModeDownload // ModeInvalid is the mode for an invalid command ModeInvalid ) -const version = "v1.0.0" +const version = "v1.1.0" -// UploadConfig contains the parameters for the upload command. -type UploadConfig struct { +// FlagConfig contains the parameters for the upload command. +type FlagConfig struct { File string Directory string TmpFolder string FileName string + OutputPath string + DownloadId string JsonOutput bool DisableE2e bool + RemoveRemote bool ExpiryDays int ExpiryDownloads int Password string @@ -54,6 +60,8 @@ func Parse() int { return ModeUpload case "upload-dir": return ModeArchive + case "download": + return ModeDownload case "help": printUsage(0) default: @@ -63,8 +71,8 @@ func Parse() int { } // GetUploadParameters parses the command line arguments and returns the parameters for the upload command. -func GetUploadParameters(isArchive bool) UploadConfig { - result := UploadConfig{} +func GetUploadParameters(mode int) FlagConfig { + result := FlagConfig{} for i := 2; i < len(os.Args); i++ { switch os.Args[i] { case "-j": @@ -103,6 +111,22 @@ func GetUploadParameters(isArchive bool) UploadConfig { fallthrough case "--name": result.FileName = getParameter(&i) + case "-i": + fallthrough + case "--id": + result.DownloadId = getParameter(&i) + case "-o": + fallthrough + case "--output": + result.FileName = getParameter(&i) + case "-k": + fallthrough + case "--ouput-path": + result.OutputPath = getParameter(&i) + case "-r": + fallthrough + case "--remove": + result.RemoveRemote = true case "-h": fallthrough case "--help": @@ -116,14 +140,14 @@ func GetUploadParameters(isArchive bool) UploadConfig { result.ExpiryDays = 0 } sanitiseFilename(&result) - if !checkRequiredUploadParameter(&result, isArchive) { + if !checkRequiredUploadParameter(&result, mode) { os.Exit(2) } return result } -func sanitiseFilename(config *UploadConfig) { +func sanitiseFilename(config *FlagConfig) { if config.FileName == "" { return } @@ -136,26 +160,31 @@ func sanitiseFilename(config *UploadConfig) { config.FileName = illegalChars.ReplaceAllString(config.FileName, "_") } -func checkRequiredUploadParameter(config *UploadConfig, isArchive bool) bool { - if isArchive && config.Directory != "" { +func checkRequiredUploadParameter(config *FlagConfig, mode int) bool { + if mode == ModeArchive && config.Directory != "" { return true } - if !isArchive && config.File != "" { + if mode == ModeUpload && config.File != "" { return true } + if mode == ModeDownload && config.DownloadId == "" { + fmt.Println("ERROR: Missing parameter --id") + return false + } if !environment.IsDockerInstance() { - if isArchive { + if mode == ModeArchive { fmt.Println("ERROR: Missing parameter --directory") - } else { + } + if mode == ModeUpload { fmt.Println("ERROR: Missing parameter --file") } return false } - ok, uploadPath := getDockerUpload(isArchive) + ok, uploadPath := getDockerUpload(mode == ModeArchive) if !ok { - if isArchive { + if mode == ModeArchive { fmt.Println("ERROR: Missing parameter --file and no file found in " + cliconstants.DockerFolderUpload) } else { fmt.Println("ERROR: Missing parameter --file and no file or more than one file found in " + cliconstants.DockerFolderUpload) @@ -163,7 +192,7 @@ func checkRequiredUploadParameter(config *UploadConfig, isArchive bool) bool { return false } - if isArchive { + if mode == ModeArchive { config.File = cliconstants.DockerFolderUpload } else { config.File = uploadPath @@ -244,6 +273,7 @@ func printUsage(exitCode int) { fmt.Println("Commands:") fmt.Println(" login Save login credentials") + fmt.Println(" download Download a file from the Gokapi instance without increasing its download counter") fmt.Println(" upload Upload a file to the Gokapi instance") fmt.Println(" upload-dir Upload a folder as a zip file to the Gokapi instance") fmt.Println(" logout Delete login credentials") @@ -252,6 +282,7 @@ func printUsage(exitCode int) { fmt.Println("Options:") fmt.Println(" -f, --file File to upload (required for \"upload\")") fmt.Println(" -D, --directory Folder to upload (required for \"upload-dir\")") + fmt.Println(" -i, --id File ID to download (required for \"download\")") fmt.Println(" -c, --configuration Path to configuration file (default: gokapi-cli.json)") fmt.Println(" -j, --json Output the result in JSON only") fmt.Println(" -x, --disable-e2e Disable end-to-end encryption") @@ -259,6 +290,9 @@ func printUsage(exitCode int) { fmt.Println(" -d, --expiry-downloads Set max allowed downloads (default: unlimited)") fmt.Println(" -p, --password Set a password for the file") fmt.Println(" -n, --name Change final filename for uploaded file") + fmt.Println(" -o, --output Change the filename of the file to download") + fmt.Println(" -k, --output-path The folder to download the file to (default: current folder)") + fmt.Println(" -r, --remove Remove remote file after download") fmt.Println(" -t, --tmpfolder Folder for temporary Zip file when uploading a directory") fmt.Println(" -h, --help Show this help message") fmt.Println() @@ -268,6 +302,7 @@ func printUsage(exitCode int) { fmt.Println(" gokapi-cli logout -c /path/to/config") fmt.Println(" gokapi-cli upload -f /file/to/upload --expiry-days 7 --json") fmt.Println(" gokapi-cli upload-dir -D /path/to/upload -t /mnt/tmp") + fmt.Println(" gokapi-cli download --remove -i chuTheishaipa9o -o myfile.zip") fmt.Println() os.Exit(exitCode) } diff --git a/cmd/gokapi/Main.go b/cmd/gokapi/Main.go index 6ee8477..dee32e2 100644 --- a/cmd/gokapi/Main.go +++ b/cmd/gokapi/Main.go @@ -34,7 +34,7 @@ import ( // versionGokapi is the current version in readable form. // Other version numbers can be modified in /build/go-generate/updateVersionNumbers.go -const versionGokapi = "2.1.0" +const versionGokapi = "2.2.0-dev" // The following calls update the version numbers, update documentation, minify Js/CSS and build the WASM modules //go:generate go run "../../build/go-generate/updateVersionNumbers.go" @@ -66,7 +66,7 @@ func main() { authentication.Init(configuration.Get().Authentication) createSsl(passedFlags) initCloudConfig(passedFlags) - go storage.CleanUp(true) + storage.CleanUp(true) logging.LogStartup() showDeprecationWarnings() go webserver.Start() @@ -110,7 +110,7 @@ func showVersion(passedFlags flagparser.MainFlags) { } func showDeprecationWarnings() { - for _, dep := range configuration.Environment.ActiveDeprecations { + for _, dep := range configuration.GetEnvironment().ActiveDeprecations { fmt.Println() fmt.Println("WARNING, deprecated feature: " + dep.Name) fmt.Println(dep.Description) diff --git a/docs/advanced.rst b/docs/advanced.rst index 3d3a6e8..53e2363 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -57,59 +57,73 @@ Available environment variables ================================== -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| Name | Action | Persistent [*]_ | Default | -+==============================+=====================================================================================+=================+=============================+ -| GOKAPI_CHUNK_SIZE_MB | Sets the size of chunks that are uploaded in MB | Yes | 45 | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_CONFIG_DIR | Sets the directory for the config file | No | config | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_CONFIG_FILE | Sets the name of the config file | No | config.json | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_DATA_DIR | Sets the directory for the data | Yes | data | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_DISABLE_CORS_CHECK | Disables the CORS check on startup and during setup, if set to true | No | false | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_ENABLE_HOTLINK_VIDEOS | Allow hotlinking of videos. Note: Due to buffering, playing a video might count as | No | false | -| | | | | -| | multiple downloads. It is only recommended to use video hotlinking for uploads with | | | -| | | | | -| | unlimited downloads enabled | | | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_LENGTH_HOTLINK_ID | Sets the length of the hotlink IDs. Value must be 8 or greater | No | 40 | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_LENGTH_ID | Sets the length of the download IDs. Value must be 5 or greater | No | 15 | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_LOG_STDOUT | Also outputs all log file entries to the console output, if set to true | No | false | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_MAX_FILESIZE | Sets the maximum allowed file size in MB | Yes | 102400 | -| | | | | -| | Default 102400 = 100GB | | | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_MAX_MEMORY_UPLOAD | Sets the amount of RAM in MB that can be allocated for an upload chunk or file | Yes | 50 | -| | | | | -| | Any chunk or file with a size greater than that will be written to a temporary file | | | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_MAX_PARALLEL_UPLOADS | Set the number of chunks that are uploaded in parallel for a single file | Yes | 3 | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_MIN_FREE_SPACE | Sets the minium free space on the disk in MB for accepting an upload | No | 400 | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_MIN_LENGTH_PASSWORD | Sets the minium password length. Value must be 6 or greater | No | 8 | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| GOKAPI_PORT | Sets the webserver port | Yes | 53842 | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| DOCKER_NONROOT | DEPRECATED. | No | false | -| | | | | -| | | | | -| | | | | -| | Docker only: Runs the binary in the container as a non-root user, if set to "true" | | | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ -| TMPDIR | Sets the path which contains temporary files | No | Non-Docker: Default OS path | -| | | | | -| | | | | -| | | | | -| | | | Docker: [DATA_DIR] | -+------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| Name | Action | Persistent [*]_ | Default | ++================================+=====================================================================================+=================+=============================+ +| GOKAPI_CHUNK_SIZE_MB | Sets the size of chunks that are uploaded in MB | Yes | 45 | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_CONFIG_DIR | Sets the directory for the config file | No | config | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_CONFIG_FILE | Sets the name of the config file | No | config.json | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_DATA_DIR | Sets the directory for the data | Yes | data | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_DISABLE_CORS_CHECK | Disables the CORS check on startup and during setup, if set to true | No | false | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_ENABLE_HOTLINK_VIDEOS | Allow hotlinking of videos. Note: Due to buffering, playing a video might count as | No | false | +| | | | | +| | multiple downloads. It is only recommended to use video hotlinking for uploads with | | | +| | | | | +| | unlimited downloads enabled | | | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_GUEST_UPLOAD_BY_DEFAULT | Allows all users by default to create file requests, if set to true | No | false | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_LENGTH_HOTLINK_ID | Sets the length of the hotlink IDs. Value must be 8 or greater | No | 40 | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_LENGTH_ID | Sets the length of the download IDs. Value must be 5 or greater | No | 15 | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_LOG_STDOUT | Also outputs all log file entries to the console output, if set to true | No | false | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_MAX_FILESIZE | Sets the maximum allowed file size in MB | Yes | 102400 | +| | | | | +| | Default 102400 = 100GB | | | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_MAX_FILES_GUESTUPLOAD | Sets the maximum number of files that can be uploaded per file requests created by | No | 100 | +| | | | | +| | non-admin users | | | +| | | | | +| | Set to 0 to allow unlimited file count for all users | | | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_MAX_MEMORY_UPLOAD | Sets the amount of RAM in MB that can be allocated for an upload chunk or file | Yes | 50 | +| | | | | +| | Any chunk or file with a size greater than that will be written to a temporary file | | | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_MAX_PARALLEL_UPLOADS | Set the number of chunks that are uploaded in parallel for a single file | Yes | 3 | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_MAX_SIZE_GUESTUPLOAD | Sets the maximum file size for file requests created by | No | 10240 | +| | | | | +| | non-admin users | | | +| | | | | +| | Set to 0 to allow files with a size of up to a value set with GOKAPI_MAX_FILESIZE | | | +| | | | | +| | for all users | | | +| | | | | +| | Default 10240 = 10GB | | | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_MIN_FREE_SPACE | Sets the minium free space on the disk in MB for accepting an upload | No | 400 | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_MIN_LENGTH_PASSWORD | Sets the minium password length. Value must be 6 or greater | No | 8 | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| GOKAPI_PORT | Sets the webserver port | Yes | 53842 | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| TMPDIR | Sets the path which contains temporary files | No | Non-Docker: Default OS path | +| | | | | +| | | | Docker: [DATA_DIR] | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ +| DOCKER_NONROOT | DEPRECATED. | No | false | +| | | | | +| | Docker only: Runs the binary in the container as a non-root user, if set to "true" | | | ++--------------------------------+-------------------------------------------------------------------------------------+-----------------+-----------------------------+ .. [*] Variables that are persistent must be submitted during the first start when Gokapi creates a new config file. They can be omitted afterwards. Non-persistent variables need to be set on every start. @@ -216,81 +230,96 @@ Migrating Redis (``127.0.0.1:6379, User: test, Password: 1234, Prefix: gokapi_, .. _clitool: - -******************************** +******** CLI Tool -******************************** +******** -Gokapi also has a CLI tool that allows uploads from the command line. Binaries are avaible on the `Github release page `_ for Linux, Windows and MacOS. To compile it yourself, download the repository and run ``make build-cli`` in the top directory. +The Gokapi CLI tool enables seamless file uploads and downloads directly from the command line. -Alternatively you can use the tool with Docker, although it will be slightly less user-friendly. +Installation +============ + +Official binaries for Linux, Windows, and macOS are available on the `GitHub releases page `_. + +To build the tool from source: + +1. Download the repository. +2. Run ``make build-cli`` from the root directory. .. note:: + Gokapi v2.1.0 or newer is required for CLI functionality. For file downloads, version v2.2.0 or newer is required. - Gokapi v2.1.0 or newer is required to use the CLI tool. +Authentication +============== -Login -================================= +To begin, authenticate your session using the following command: -First you need to login with the command ``gokapi-cli login``. You will then be asked for your server URL and a valid API key with upload permission. If end-to-end encryption is enabled, you will also need to enter your encyption key. By default the login data is saved to ``gokapi-cli.json``, but you can define a different location with the ``-c`` parameter. +.. code-block:: bash + gokapi-cli login -To logout, either delete the configuration file or run ``gokapi-cli logout``. +You will be prompted to provide your server URL, an API key with upload permissions, and your end-to-end encryption key (if applicable). + +* **Storage:** By default, credentials are saved in plain text to ``gokapi-cli.json``. You may specify a custom path using the ``-c`` parameter. +* **Logout:** To logout, run ``gokapi-cli logout`` or manually delete the configuration file. .. warning:: + The configuration file stores login credentials in plain text. Ensure the file is stored in a secure environment. - The configuration file contains the login data as plain text. +Docker Usage +------------ +While the native binary is recommended for the best experience, the CLI can be run via Docker. By default, the configuration is stored at ``/app/config/config.json``. -Docker ---------------------------------- +To persist your login session, mount a volume as shown below: -If you are using Docker, your config will be saved to ``/app/config/config.json`` by default, but the location can be changed. To login, execute the following command: -:: - - docker run -it --rm -v gokapi-cli-config:/app/config docker.io/f0rc3/gokapi-cli:latest login - -The volume ``gokapi-cli-config:/app/config`` is not required if you re-use the container, but it is still highly recommended. If a volume is not mounted, you will need to log in again after every new container creation. - +.. code-block:: bash + docker run -it --rm -v gokapi-cli-config:/app/config docker.io/f0rc3/gokapi-cli:latest login .. _clitool-upload-file: -Uploading a file -================================= +Uploading Files +=============== +To upload a file, use the ``upload`` command with the ``-f`` flag: -To upload a file, simply run ``gokapi-cli upload -f /path/to/file``. By default the files are encrypted (if enabled) and stored without any expiration. These additional parameters are available: +.. code-block:: bash + + gokapi-cli upload -f /path/to/file + +By default, files are encrypted (if enabled) and have no expiration date. The following parameters are available to customize the upload: +------------------------------------+---------------------------------------------------+ -| Parameter | Effect | +| Parameter | Description | +====================================+===================================================+ -| \-\-json, -j | Only outputs in JSON format, unless upload failed | +| ``--file, -f [path]`` | **(Required)** Path to the file to be uploaded. | +------------------------------------+---------------------------------------------------+ -| \-\-disable-e2e, -x | Disables end-to-end encryption for this upload | +| ``--expiry-days, -e [int]`` | Sets the file expiration in days. | +------------------------------------+---------------------------------------------------+ -| \-\-expiry-days, -e [number] | Sets the expiry date of the file in days | +| ``--expiry-downloads, -d [int]`` | Limits the number of allowed downloads. | +------------------------------------+---------------------------------------------------+ -| \-\-expiry-downloads, -d [number] | Sets the allowed downloads | +| ``--password, -p [string]`` | Protects the download with a password. | +------------------------------------+---------------------------------------------------+ -| \-\-password, -p [string] | Sets a password | +| ``--name, -n [string]`` | Assigns a custom filename on the server. | +------------------------------------+---------------------------------------------------+ -| \-\-name, -n [string] | Sets a different filename for uploaded file | +| ``--disable-e2e, -x`` | Disables end-to-end encryption for this upload. | +------------------------------------+---------------------------------------------------+ -| \-\-configuration, -c [path] | Use the configuration file specified | +| ``--json, -j`` | Returns output in JSON format (unless failed). | ++------------------------------------+---------------------------------------------------+ +| ``--configuration, -c [path]`` | Uses a specific configuration file. | +------------------------------------+---------------------------------------------------+ -**Example:** Uploading the file ``/tmp/example``. It will expire in 10 days, has unlimited downloads and requires the password ``abcd``: -:: +**Example:** +Upload a file that expires in 10 days, has no download limit, and is protected by the password "abcd": + +.. code-block:: bash + + gokapi-cli upload -f /tmp/example --expiry-days 10 --password abcd - gokapi-cli upload -f /tmp/example --expiry-days 10 --password abcd - - .. warning:: + To avoid race conditions, do not initiate multiple simultaneous uploads if end-to-end encryption is enabled. - If you are using end-to-end encryption, do not upload other encrypted files simultaneously to avoid race conditions. - - Docker --------------------------------- @@ -358,6 +387,35 @@ As a Docker container cannot access your host files without a volume, you will n docker run --rm -v gokapi-cli-config:/app/config -v /tmp/another/example:/upload/ docker.io/f0rc3/gokapi-cli:latest upload-dir -n "example.zip" +Downloading Files +================= + +To retrieve a file from the server, use the ``download`` command followed by the file ID. Files downloaded with the CLI tool do not increase the download count. + +.. code-block:: bash + + gokapi-cli download -i [FILE_ID] + +Available parameters for downloads: + ++------------------------------------+---------------------------------------------------+ +| Parameter | Description | ++====================================+===================================================+ +| ``--id, -i [id]`` | **(Required)** The unique ID of the file. | ++------------------------------------+---------------------------------------------------+ +| ``--output, -o [string]`` | Renames the file upon download. | ++------------------------------------+---------------------------------------------------+ +| ``--output-path, -k [path]`` | Target directory (defaults to current folder). | ++------------------------------------+---------------------------------------------------+ +| ``--remove, -r`` | Deletes the file from the server after download. | ++------------------------------------+---------------------------------------------------+ + +**Example:** +Download the file with ID ``Eukohc6r`` to the ``/home/user/downloads`` folder and delete it from the server after a successful transfer: + +.. code-block:: bash + + gokapi-cli download -i Eukohc6r --output-path /home/user/downloads --remove .. _api: diff --git a/docs/setup.rst b/docs/setup.rst index 613c4d7..f6bba81 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -285,7 +285,9 @@ This option disables Gokapis internal authentication completely, except for API - ``/apiKeys`` - ``/auth/token`` - ``/changePassword`` +- ``/downloadPresigned`` - ``/e2eSetup`` +- ``/filerequests`` - ``/logs`` - ``/uploadChunk`` - ``/uploadStatus`` diff --git a/docs/usage.rst b/docs/usage.rst index 7513d51..7477924 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -4,14 +4,14 @@ Usage ===== -Admin Menu +Upload Menu ================ General ---------------- -After you have started the Gokapi server, you can login using the your admin credentials by going to `http(s)://your.gokapi.url/admin`` +After you have started the Gokapi server, you can login using the your credentials by going to `http(s)://your.gokapi.url/admin`` There you can list and manage files and upload new files. You will also see three fields: @@ -22,25 +22,336 @@ There you can list and manage files and upload new files. You will also see thre Uploading new files --------------------- -To upload, drag and drop a file, folder or multiple files to the Upload Zone. You can also directly paste an image from the clipboard. If you want to change the default expiry conditions, this has to be done before uploading. For each file an entry in the table will appear with a download link. +To upload, drag and drop a file, folder or multiple files to the Upload Zone. You can also directly paste an image or text from the clipboard. If you want to change the default expiry conditions, this has to be done before uploading. For each file an entry in the table will appear with a download link. Identical files are deduplicated, which means if you upload a file twice, it will only be stored once. Sharing files --------------- -Once you uploaded an file, you will see the options *Copy URL* and *Copy Hotlink*. By clicking on *Copy URL*, you copy the URL for the Download page to your clipboard. A user can then download the file from that page. +Once you uploaded an file, you will see a button with the options *Copy URL* and *Copy Hotlink*. By clicking on *Copy URL*, you copy the URL for the Download page to your clipboard. A user can then download the file from that page. If a file does not require client-side decryption, you can also use the *Copy Hotlink* button. The hotlink URL is a direct link to the file and can for example be posted as an image on a forum or on a website. Each view counts as a download. Although Gokapi sets a Header to explicitly disallow caching, some browsers or external caches may still cache the image if they are not compliant. +The second button lets you share the regular URL easily. If you are accessing Gokapi with a mobile device, a tap on the button will open your device's share menu. Otherwise you can click on the drop down element and select to either share the link via email or generate a QR code. + +Downloading files +------------------ + +The upload menu has a button which lets you download a file without increasing the download counter. You can also click on the file ID to go to the regular download page, which increases the counter. + +Editing files +--------------- + +By clicking on the edit button, you can change limits like the maximum download count or replace the file with the contents of a different uploaded file. File deletion --------------- -Every hour Gokapi runs a cleanup routine which deletes all files from the storage that have been expired. If you click on the *Delete* button in the list, that file will be deleted from the disk immediately. AWS files are deleted after 24 hours, as of right now there is no proper way to find out if a download has been completed. +Every hour Gokapi runs a cleanup routine which deletes all files from the storage that have been expired. If you click on the *Delete* button in the list, that file will be deleted from the disk immediately. Unproxied AWS files are deleted after 24 hours, as of right now there is no proper way to find out if a download has been completed. + + +File Request Menu +=================== + + +General +---------------- + +The File Requests page allows you to create secure, invitation-only upload links. These links enable external users to send files directly to your server without needing an account. + + +.. note:: + **Security Note:** If End-to-End Encryption is enabled globally, please note that **File Requests bypass this**. All files uploaded through the upload request page will be in plain text. This does only affect servers with end-to-end encryption, regular file encryption is still in place. + +Overview of the Dashboard +--------------------------- + +The main dashboard provides a summary of all active and expired file requests. + +* **Name**: The friendly name of the request. Clicking this link opens the public upload page in a new tab. +* **Uploaded Files**: Displays the number of files currently received. + + * **+X**: Indicates "active" uploads currently in progress. + * **X / Max**: Shows the current count against a set file limit. +* **Total Size**: The combined storage footprint of all files in that request. +* **Last Upload**: The date and time the most recent file was added. +* **Expiry**: When the link will stop accepting new uploads. +* **Actions**: Quick tools to manage, download, or delete the request. + +Managing Files +--------------------------- + +Each row in the table can be expanded to view and manage individual files. + +Viewing Files +^^^^^^^^^^^^^^ +If a request has files, a *chevron (down arrow)* icon will appear next to the file count. Clicking this will expand a list showing: + +* Individual file names. +* File sizes and upload dates. +* Direct download buttons for single files. + +Downloading Content +^^^^^^^^^^^^^^^^^^^^ +You can download files in two ways: + +1. **Single File**: Click the file name or the download icon within the expanded list. +2. **Batch Download**: Click the download icon in the *Actions* column. If multiple files exist, the system will automatically package them into a ``.zip`` archive. + +Creating and Editing Requests +--------------------------------- + +To create a new request, click the *Plus* icon at the top right. To modify an existing one, click the *Pencil* icon. + +.. list-table:: + :widths: 20 80 + :header-rows: 1 + + * - Field + - Description + * - **Title** + - A friendly name to identify the request (e.g., "Project Assets"). + * - **Max Files** + - Limit how many files users can upload to this link. + * - **Max Size** + - Set a maximum total size (in MB) for the entire request. + * - **Expiry** + - Set a date after which the link will no longer function. + * - **Notes** + - Public notes that are shown on the upload page + +Sharing and Deletion +-------------------- + +Sharing the Request +^^^^^^^^^^^^^^^^^^^^^^ +1. Locate the request in the table. +2. Click the *Copy (Clipboard)* icon. +3. A notification will confirm the URL is copied. You can now paste this into an email or chat. + +Deleting Requests +^^^^^^^^^^^^^^^^^^^ +To remove a request, click the *Trash* icon. + +.. warning:: + Deleting a File Request is permanent. This action also deletes all associated files currently stored on the server. This cannot be undone. + + + + +User Management +================= + +The **Users** page provides administrators with tools to create accounts, manage permissions, and oversee user activity. This interface ensures you can delegate responsibilities while maintaining system security. + +Overview of the Dashboard +---------------------------- + +The user table displays a high-level summary of all accounts on the server: + +* **User**: The display name or username of the account. +* **Group**: The account type (e.g., "Admin" or "User"). +* **Last Online**: A timestamp indicating the last time the user logged into the system. +* **Uploads**: The total number of files currently owned by that user. +* **Permissions**: A quick-view grid of icons representing specific rights. +* **Actions**: Tools to reset passwords, promote/demote ranks, or delete accounts. + +Managing Permissions +--------------------- + +Permissions are granular and can be toggled by clicking the icons in the **Permissions** column. + +.. list-table:: + :widths: 30 60 + :header-rows: 1 + + * - Name + - Description + * - Create File Requests + - Allows the user to generate external upload links. + * - Replace Own Uploads + - Allows the user to overwrite files they previously uploaded. + * - List Other Uploads + - Grant visibility to files uploaded by other system users. + * - Edit Other Uploads + - Allows editing files owned by others. + * - Delete Other Uploads + - Allows permanent removal of files owned by other users. + * - Manage Logs + - Grants access to view and clear system activity logs. + * - Manage Users + - Grants access to this User Management page. + * - Manage API Keys + - Allows management of API keys of belonging to any user + +.. note:: + Permissions for the Super Admin and your own account cannot be modified from this screen to prevent accidental lockouts. + +User Account Actions +==================== + +Adding a New User +----------------- +1. Click the *Plus (+)* icon at the top right of the Users card. +2. Enter a unique username. +3. The user will be created with default permissions and will need a password assigned or reset. + +Resetting Passwords +------------------- +If using internal authentication, click the *Key* icon: + +* **Force Reset**: The user must choose a new password the next time they log in. +* **Generate Random**: The system provides a temporary password. You can copy it to your clipboard to give to the user. + + +User Ranks +------------------ +There are three different user ranks: + +* **Super Admin**: A single person with all access which cannot be modified by other users. +* **Admin**: Has all rights by default. Is able to delete system logs and can change file owners. +* **User**: Has less rights by default. + + + +Changing User Rank +------------------ +Use the *Chevron Up/Down* icons to change a user's group: + +* **Promote**: Upgrades a standard User to an Admin. +* **Demote**: Downgrades an Admin to a standard User. + + + +Deleting Users +-------------- +Click the **Trash** icon to remove an account. + +.. warning:: + When deleting a user, you will be asked if you also want to **permanently delete all files** uploaded by them. If unchecked, the files will remain on the server and change the ownership to the user who initiated the deletion. + + + + + + API Menu =============== -In the API menu you can create API keys, which can be used for API access. Please refer to :ref:`api`. +The API Keys page allows you to generate and manage credentials for programmatic access to the server. These keys are used to authenticate scripts, third-party applications, or CLI tools. + +.. note:: + For technical implementation details and endpoint definitions, please refer to the integrated API Documentation and the section :ref:`api` + +Overview of API Keys +--------------------- + +The API table provides a summary of all active credentials: + +* **Name**: A descriptive label for the key (e.g., ``Internal Upload Tool``). You can click the name at any time to rename it. +* **API Key**: A redacted version of the key for security. When a new key is created, the full string will be displayed once - ensure you copy it immediately. +* **Last Used**: The timestamp of the most recent request made using this key. +* **Permissions**: A grid of icons representing what the key is authorized to do. +* **User**: (Admin only) Displays which system user owns the specific API key. + +Managing Key Permissions +-------------------------- + +Permissions for API keys are granular. You can enable or disable a right by clicking its corresponding icon. + +.. list-table:: + :widths: 30 60 + :header-rows: 1 + + * - Name + - Description + * - List Uploads + - View a list of files currently on the server. + * - Upload + - Permission to push new files to the server. + * - Edit Uploads + - Modify metadata of existing files. + * - Delete Uploads + - Permanently remove files via the API. + * - Replace + - Overwrite existing files with new versions. + * - Download + - Retrieve file contents programmatically without increasing the download counter + * - File Requests + - Create and manage external "File Request" links. + * - Manage Users + - Create or modify user accounts via API calls. + * - Manage Keys + - Use this key to create or delete other API keys. + +.. note:: + Some permissions may appear greyed out. This happens if the user who owns the key does not have that specific permission assigned to their account. An API key cannot grant more power than its owner possesses. + +Key Operations +---------------- + +Creating a New Key +^^^^^^^^^^^^^^^^^^^ + +1. Click the *Plus (+)* icon in the top right corner. +2. A new key will be generated. +3. **Copy the key immediately.** For security reasons, the full key cannot be displayed again once you navigate away from the page. + +Deleting a Key +^^^^^^^^^^^^^^^^^^^ +To revoke access immediately, click the *Trash* icon in the Actions column. Any application using this key will instantly receive an ``Unauthorized`` error. + + + +System Logs +========================== + +The **Log File** page provides a view of system activity, security events, and file operations. + +Filtering Logs +----------------- + +To help you find specific information quickly, you can use the *Log Filter* dropdown menu. Selecting a category will parse the log file and display only the relevant lines. + +.. list-table:: + :widths: 25 75 + :header-rows: 1 + + * - Category + - Description + * - **Warning** + - Non-critical errors or alerts that may require attention. + * - **Auth** + - Login attempts, password resets, and permission changes. + * - **Download** + - Records of files being accessed or downloaded by users/guests. + * - **Upload** + - New file creations and completed upload sessions. + * - **Edit** + - Metadata changes, file renames, and setting updates. + * - **Info** + - General operational status messages. + +Log Maintenance and Cleanup +------------------------------ + +Over time, log files can become quite large. Administrators have access to the *Delete Logs* utility to manage storage and keep the logs readable. + +.. note:: + The log deletion tool is restricted to users with *Administrator* privileges. For standard users, this menu will be disabled. + +Retention Options +----------------- + +You can clear logs based on their age using the following presets: + +* **Older than 2/7/14/30 days**: Retains recent history while purging stale data. +* **Delete all logs**: Completely clears the log file. + +.. warning:: + Log deletion is a permanent action. Once logs are cleared, the data cannot be recovered via the web interface. It is recommended to keep at least 7 days of logs for security auditing purposes. + + diff --git a/go.mod b/go.mod index 3643dff..abce8ab 100644 --- a/go.mod +++ b/go.mod @@ -19,24 +19,30 @@ require ( golang.org/x/oauth2 v0.27.0 golang.org/x/sync v0.11.0 golang.org/x/term v0.37.0 + golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.35.0 ) require ( github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect + github.com/djherbis/atime v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect - github.com/tdewolff/minify/v2 v2.24.2 // indirect - github.com/tdewolff/parse/v2 v2.8.3 // indirect + github.com/tdewolff/argp v0.0.0-20250430135133-0f54527d2b1e // indirect + github.com/tdewolff/minify/v2 v2.24.8 // indirect + github.com/tdewolff/parse/v2 v2.8.5 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect diff --git a/go.sum b/go.sum index c860eb4..e26d5b5 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9 h1:1ltqoej5GtaWF8jaiA49HwsZD459jqm9YFz9ZtMFpQA= github.com/Kodeworks/golang-image-ico v0.0.0-20141118225523-73f0f4cfade9/go.mod h1:7uhhqiBaR4CpN0k9rMjOtjpcfGd6DG2m04zQxKnWQ0I= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= @@ -20,10 +21,15 @@ github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDh github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= +github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s= github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -38,6 +44,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/johannesboyne/gofakes3 v0.0.0-20250106100439-5c39aecd6999 h1:CMbkEl1h9JvRURFFprSbyy2f4Gf71SFz9h74iSAETGo= github.com/johannesboyne/gofakes3 v0.0.0-20250106100439-5c39aecd6999/go.mod h1:t6osVdP++3g4v2awHz4+HFccij23BbdT1rX3W7IijqQ= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= @@ -47,14 +55,18 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -74,10 +86,13 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo= -github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE= -github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I= -github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= +github.com/tdewolff/argp v0.0.0-20250430135133-0f54527d2b1e h1:2jfHhbjBKS2wfyvcz5W2eOkQVKv57DKM1C/QYhTovhs= +github.com/tdewolff/argp v0.0.0-20250430135133-0f54527d2b1e/go.mod h1:xw2b1X81m4zY1OGytzHNr/YKXbf/STHkK5idoNamlYE= +github.com/tdewolff/minify/v2 v2.24.8 h1:58/VjsbevI4d5FGV0ZSuBrHMSSkH4MCH0sIz/eKIauE= +github.com/tdewolff/minify/v2 v2.24.8/go.mod h1:0Ukj0CRpo/sW/nd8uZ4ccXaV1rEVIWA3dj8U7+Shhfw= +github.com/tdewolff/parse/v2 v2.8.5 h1:ZmBiA/8Do5Rpk7bDye0jbbDUpXXbCdc3iah4VeUvwYU= +github.com/tdewolff/parse/v2 v2.8.5/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= +github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= @@ -88,8 +103,6 @@ go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d/go.mod h1:92Uoe3 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= @@ -127,8 +140,6 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -136,8 +147,6 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -146,6 +155,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190829051458-42f498d34c4d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/internal/configuration/Configuration.go b/internal/configuration/Configuration.go index a335c01..4156dc1 100644 --- a/internal/configuration/Configuration.go +++ b/internal/configuration/Configuration.go @@ -25,8 +25,8 @@ import ( "github.com/forceu/gokapi/internal/storage/filesystem" ) -// Environment is an object containing the environment variables -var Environment environment.Environment +// parsedEnvironment is an object containing the environment variables +var parsedEnvironment environment.Environment // ServerSettings is an object containing the server configuration var serverSettings models.Configuration @@ -72,14 +72,14 @@ func loadFromFile(path string) (models.Configuration, error) { // Load loads the configuration or creates the folder structure and a default configuration func Load() { - Environment = environment.New() + parsedEnvironment = environment.New() // No check if file exists, as this was checked earlier - settings, err := loadFromFile(Environment.ConfigPath) + settings, err := loadFromFile(parsedEnvironment.ConfigPath) helper.Check(err) serverSettings = settings usesHttps = strings.HasPrefix(strings.ToLower(serverSettings.ServerUrl), "https://") - if configupgrade.DoUpgrade(&serverSettings, &Environment) { + if configupgrade.DoUpgrade(&serverSettings, &parsedEnvironment) { save() } if serverSettings.PublicName == "" { @@ -91,12 +91,9 @@ func Load() { if serverSettings.ChunkSize == 0 { serverSettings.ChunkSize = 45 } - serverSettings.MinLengthPassword = Environment.MinLengthPassword - serverSettings.LengthId = Environment.LengthId - serverSettings.LengthHotlinkId = Environment.LengthHotlinkId helper.CreateDir(serverSettings.DataDir) filesystem.Init(serverSettings.DataDir) - logging.Init(Environment.DataDir) + logging.Init(parsedEnvironment.DataDir) } // ConnectDatabase loads the database that is defined in the configuration @@ -119,7 +116,7 @@ func Get() *models.Configuration { // Save the configuration as a json file func save() { - file, err := os.OpenFile(Environment.ConfigPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + file, err := os.OpenFile(parsedEnvironment.ConfigPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { fmt.Println("Error writing configuration:", err) os.Exit(1) @@ -136,8 +133,8 @@ func save() { // LoadFromSetup creates a new configuration file after a user completed the setup. If cloudConfig is not nil, a new // cloud config file is created. If it is nil an existing cloud config file will be deleted. func LoadFromSetup(config models.Configuration, cloudConfig *cloudconfig.CloudConfig, e2eConfig End2EndReconfigParameters, passwordHash string) { - Environment = environment.New() - helper.CreateDir(Environment.ConfigDir) + parsedEnvironment = environment.New() + helper.CreateDir(parsedEnvironment.ConfigDir) serverSettings = config if cloudConfig != nil { @@ -172,6 +169,13 @@ func LoadFromSetup(config models.Configuration, cloudConfig *cloudconfig.CloudCo } } +func GetEnvironment() environment.Environment { + if !parsedEnvironment.IsParsed() { + panic("Environment is not parsed yet") + } + return parsedEnvironment +} + func deleteAllEncryptedStorage() { files := database.GetAllMetadata() for _, file := range files { @@ -185,8 +189,8 @@ func deleteAllEncryptedStorage() { // SetDeploymentPassword sets a new password. This should only be used for non-interactive deployment, but is not enforced func SetDeploymentPassword(newPassword string) { - if len(newPassword) < serverSettings.MinLengthPassword { - fmt.Printf("Password needs to be at least %d characters long\n", serverSettings.MinLengthPassword) + if len(newPassword) < parsedEnvironment.MinLengthPassword { + fmt.Printf("Password needs to be at least %d characters long\n", parsedEnvironment.MinLengthPassword) os.Exit(1) } serverSettings.Authentication.SaltAdmin = helper.GenerateRandomString(30) diff --git a/internal/configuration/Configuration_test.go b/internal/configuration/Configuration_test.go index e6cb207..4386ddf 100644 --- a/internal/configuration/Configuration_test.go +++ b/internal/configuration/Configuration_test.go @@ -21,7 +21,7 @@ func TestMain(m *testing.M) { func TestLoad(t *testing.T) { test.IsEqualBool(t, Exists(), true) Load() - test.IsEqualString(t, Environment.ConfigDir, "test") + test.IsEqualString(t, parsedEnvironment.ConfigDir, "test") test.IsEqualString(t, serverSettings.Port, "127.0.0.1:53843") test.IsEqualString(t, serverSettings.Authentication.Username, "test") test.IsEqualString(t, serverSettings.ServerUrl, "http://127.0.0.1:53843/") @@ -31,8 +31,8 @@ func TestLoad(t *testing.T) { _ = os.Setenv("GOKAPI_LENGTH_ID", "20") _ = os.Setenv("GOKAPI_LENGTH_HOTLINK_ID", "25") Load() - test.IsEqualInt(t, serverSettings.LengthId, 20) - test.IsEqualInt(t, serverSettings.LengthHotlinkId, 25) + test.IsEqualInt(t, parsedEnvironment.LengthId, 20) + test.IsEqualInt(t, parsedEnvironment.LengthHotlinkId, 25) _ = os.Unsetenv("GOKAPI_LENGTH_ID") _ = os.Unsetenv("GOKAPI_LENGTH_HOTLINK_ID") test.IsEqualInt(t, serverSettings.ConfigVersion, configupgrade.CurrentConfigVersion) @@ -60,7 +60,6 @@ func TestLoadFromSetup(t *testing.T) { ServerUrl: "serverurl", RedirectUrl: "redirect", ConfigVersion: configupgrade.CurrentConfigVersion, - LengthId: 10, DataDir: "test", MaxMemory: 10, UseSsl: true, diff --git a/internal/configuration/database/Database.go b/internal/configuration/database/Database.go index a9a1b7f..e075044 100644 --- a/internal/configuration/database/Database.go +++ b/internal/configuration/database/Database.go @@ -88,6 +88,10 @@ func Migrate(configOld, configNew models.DbConnection) { dbNew.SaveHotlink(file) } } + requests := dbOld.GetAllFileRequests() + for _, request := range requests { + dbNew.SaveFileRequest(request) + } dbOld.Close() dbNew.Close() } @@ -132,6 +136,16 @@ func GetApiKey(id string) (models.ApiKey, bool) { return db.GetApiKey(id) } +// GetApiKeyByPublicKey returns an API key by using the public key +func GetApiKeyByPublicKey(publicKey string) (string, bool) { + return db.GetApiKeyByPublicKey(publicKey) +} + +// GetApiKeyByFileRequest returns an API key used for a file request +func GetApiKeyByFileRequest(request models.FileRequest) (string, bool) { + return db.GetApiKeyByFileRequest(request) +} + // SaveApiKey saves the API key to the database func SaveApiKey(apikey models.ApiKey) { db.SaveApiKey(apikey) @@ -147,11 +161,6 @@ func DeleteApiKey(id string) { db.DeleteApiKey(id) } -// GetApiKeyByPublicKey returns an API key by using the public key -func GetApiKeyByPublicKey(publicKey string) (string, bool) { - return db.GetApiKeyByPublicKey(publicKey) -} - // E2E Section // SaveEnd2EndInfo stores the encrypted e2e info @@ -290,7 +299,7 @@ func DeleteUser(id int) { func GetSuperAdmin() (models.User, bool) { users := db.GetAllUsers() for _, user := range users { - if user.UserLevel == models.UserLevelSuperAdmin { + if user.IsSuperAdmin() { return user, true } } @@ -323,3 +332,42 @@ func EditSuperAdmin(username, passwordHash string) error { db.SaveUser(user, false) return nil } + +// File Requests + +// GetFileRequest returns the FileRequest or false if not found +func GetFileRequest(id string) (models.FileRequest, bool) { + return db.GetFileRequest(id) +} + +// GetAllFileRequests returns an array with all file requests, ordered by creation date +func GetAllFileRequests() []models.FileRequest { + return db.GetAllFileRequests() +} + +// SaveFileRequest stores the file request associated with the file in the database +func SaveFileRequest(request models.FileRequest) { + db.SaveFileRequest(request) +} + +// DeleteFileRequest deletes a file request with the given ID +func DeleteFileRequest(request models.FileRequest) { + db.DeleteFileRequest(request) +} + +// Presigned URLs + +// GetPresignedUrl returns the presigned url with the given ID or false if not a valid ID +func GetPresignedUrl(id string) (models.Presign, bool) { + return db.GetPresignedUrl(id) +} + +// DeletePresignedUrl deletes the presigned url with the given ID +func DeletePresignedUrl(id string) { + db.DeletePresignedUrl(id) +} + +// SavePresignedUrl saves the presigned url +func SavePresignedUrl(presign models.Presign) { + db.SavePresignedUrl(presign) +} diff --git a/internal/configuration/database/dbabstraction/DbAbstraction.go b/internal/configuration/database/dbabstraction/DbAbstraction.go index 981e3e3..b3c4053 100644 --- a/internal/configuration/database/dbabstraction/DbAbstraction.go +++ b/internal/configuration/database/dbabstraction/DbAbstraction.go @@ -38,6 +38,8 @@ type Database interface { GetAllApiKeys() map[string]models.ApiKey // GetApiKey returns a models.ApiKey if valid or false if the ID is not valid GetApiKey(id string) (models.ApiKey, bool) + // GetApiKeyByFileRequest returns an API key used for a file request + GetApiKeyByFileRequest(request models.FileRequest) (string, bool) // SaveApiKey saves the API key to the database SaveApiKey(apikey models.ApiKey) // UpdateTimeApiKey writes the content of LastUsage to the database @@ -97,6 +99,22 @@ type Database interface { UpdateUserLastOnline(id int) // DeleteUser deletes a user with the given ID DeleteUser(id int) + + // GetFileRequest returns the FileRequest or false if not found + GetFileRequest(id string) (models.FileRequest, bool) + // GetAllFileRequests returns an array with all file requests, ordered by creation date + GetAllFileRequests() []models.FileRequest + // SaveFileRequest stores the file request associated with the file in the database + SaveFileRequest(request models.FileRequest) + // DeleteFileRequest deletes a file request with the given ID + DeleteFileRequest(request models.FileRequest) + + // GetPresignedUrl returns the presigned url with the given ID or false if not a valid ID + GetPresignedUrl(id string) (models.Presign, bool) + // DeletePresignedUrl deletes the presigned url with the given ID + DeletePresignedUrl(id string) + // SavePresignedUrl saves the presigned url + SavePresignedUrl(presign models.Presign) } // GetNew connects to the given database and initialises it diff --git a/internal/configuration/database/provider/redis/Redis.go b/internal/configuration/database/provider/redis/Redis.go index be5b565..8bccd8a 100644 --- a/internal/configuration/database/provider/redis/Redis.go +++ b/internal/configuration/database/provider/redis/Redis.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/forceu/gokapi/internal/environment" "github.com/forceu/gokapi/internal/helper" "github.com/forceu/gokapi/internal/models" redigo "github.com/gomodule/redigo/redis" @@ -20,7 +21,7 @@ type DatabaseProvider struct { } // DatabaseSchemeVersion contains the version number to be expected from the current database. If lower, an upgrade will be performed -const DatabaseSchemeVersion = 5 +const DatabaseSchemeVersion = 6 // New returns an instance func New(dbConfig models.DbConnection) (DatabaseProvider, error) { @@ -117,10 +118,25 @@ func newPool(config models.DbConnection) *redigo.Pool { func (p DatabaseProvider) Upgrade(currentDbVersion int) { // < v2.0.0 if currentDbVersion < 5 { - fmt.Println("Error: Gokapi runs >=v2.0.0, but Database is =v2.0.0, but Database is =v2.0.0, but Database is =v2.0.0, but Database is f.LastUpload { + f.LastUpload = file.UploadDate + } + } + } + f.CombinedMaxSize = f.MaxSize + if f.MaxSize == 0 || f.MaxSize > maxServerSize { + f.CombinedMaxSize = maxServerSize + } + f.UploadedFiles = len(f.FileIdList) + f.ReservedUploads = chunkreservation.GetCount(f.Id) +} + +// GetReadableDateLastUpdate returns the last update date as YYYY-MM-DD HH:MM:SS +func (f *FileRequest) GetReadableDateLastUpdate() string { + if f.LastUpload == 0 { + return "None" + } + return time.Unix(f.LastUpload, 0).Format("2006-01-02 15:04:05") +} + +func (f *FileRequest) GetReadableTotalSize() string { + return helper.ByteCountSI(f.TotalFileSize) +} + +func (f *FileRequest) GetFilesAsString() string { + return strings.Join(f.FileIdList, ",") +} + +func (f *FileRequest) IsUnlimitedSize() bool { + return f.MaxSize == 0 +} + +func (f *FileRequest) IsUnlimitedFiles() bool { + return f.MaxFiles == 0 +} + +func (f *FileRequest) IsUnlimitedTime() bool { + return f.Expiry == 0 +} + +func (f *FileRequest) IsExpired() bool { + return !f.IsUnlimitedTime() && time.Now().Unix() > f.Expiry +} + +func (f *FileRequest) HasRestrictions() bool { + return !(f.IsUnlimitedSize() && f.IsUnlimitedFiles() && f.IsUnlimitedTime()) +} + +func (f *FileRequest) FilesRemaining() int { + result := f.MaxFiles - f.UploadedFiles - f.ReservedUploads + if result < 0 { + return 0 + } + return result +} diff --git a/internal/models/FileUpload.go b/internal/models/FileUpload.go index 7b58ee1..63291e3 100644 --- a/internal/models/FileUpload.go +++ b/internal/models/FileUpload.go @@ -1,7 +1,7 @@ package models -// UploadRequest is used to set an upload request -type UploadRequest struct { +// UploadParameters is used to set parameters for a new upload +type UploadParameters struct { UserId int AllowedDownloads int Expiry int @@ -13,4 +13,5 @@ type UploadRequest struct { IsEndToEndEncrypted bool Password string ExternalUrl string + FileRequestId string } diff --git a/internal/models/Presign.go b/internal/models/Presign.go new file mode 100644 index 0000000..dcde61b --- /dev/null +++ b/internal/models/Presign.go @@ -0,0 +1,8 @@ +package models + +type Presign struct { + Id string + FileIds []string + Expiry int64 + Filename string +} diff --git a/internal/models/User.go b/internal/models/User.go index 526506e..f0cff07 100644 --- a/internal/models/User.go +++ b/internal/models/User.go @@ -34,7 +34,7 @@ func (u *User) GetReadableUserLevel() string { } } -// ToJson returns the user as a JSon object +// ToJson returns the user as a JSON object func (u *User) ToJson() string { result, err := json.Marshal(u) helper.Check(err) @@ -50,7 +50,7 @@ const UserLevelAdmin UserRank = 1 // UserLevelUser indicates that this user has only basic permissions by default const UserLevelUser UserRank = 2 -// UserRank indicates the rank that is assigned to the user +// UserRank indicates the rank assigned to the user type UserRank uint8 // IsSuperAdmin returns true if the user has the Rank UserLevelSuperAdmin @@ -58,35 +58,42 @@ func (u *User) IsSuperAdmin() bool { return u.UserLevel == UserLevelSuperAdmin } +// IsAdmin returns true if the user has the Rank UserLevelSuperAdmin or UserLevelAdmin +func (u *User) IsAdmin() bool { + return u.UserLevel == UserLevelAdmin || u.UserLevel == UserLevelSuperAdmin +} + // IsSameUser returns true, if the user has the same ID func (u *User) IsSameUser(userId int) bool { return u.Id == userId } const ( - // UserPermReplaceUploads allows to replace uploads + // UserPermReplaceUploads allows replacing uploads PERM_REPLACE UserPermReplaceUploads UserPermission = 1 << iota - // UserPermListOtherUploads allows to also list uploads by other users + // UserPermListOtherUploads allows also listing uploads by other users PERM_LIST UserPermListOtherUploads - // UserPermEditOtherUploads allows editing of uploads by other users + // UserPermEditOtherUploads allows editing of uploads by other users PERM_EDIT UserPermEditOtherUploads - // UserPermReplaceOtherUploads allows replacing of uploads by other users + // UserPermReplaceOtherUploads allows replacing of uploads by other users PERM_REPLACE_OTHER UserPermReplaceOtherUploads - // UserPermDeleteOtherUploads allows deleting uploads by other users + // UserPermDeleteOtherUploads allows deleting uploads by other users PERM_DELETE UserPermDeleteOtherUploads - // UserPermManageLogs allows viewing and deleting logs + // UserPermManageLogs allows viewing and deleting logs PERM_LOGS UserPermManageLogs - // UserPermManageApiKeys allows editing and deleting of API keys by other users + // UserPermManageApiKeys allows editing and deleting of API keys by other users PERM_API UserPermManageApiKeys - // UserPermManageUsers allows creating and editing of users, including granting and revoking permissions + // UserPermManageUsers allows creating and editing of users, including granting and revoking permissions PERM_USERS UserPermManageUsers + // UserPermGuestUploads allows creating file requests PERM_GUEST_UPLOAD + UserPermGuestUploads ) // UserPermissionNone means that the user has no permissions const UserPermissionNone UserPermission = 0 // UserPermissionAll means that the user has all permissions -const UserPermissionAll UserPermission = 255 +const UserPermissionAll UserPermission = 511 // GrantPermission grants one or more permissions func (u *User) GrantPermission(permission UserPermission) { @@ -145,3 +152,8 @@ func (u *User) HasPermissionManageApi() bool { func (u *User) HasPermissionManageUsers() bool { return u.HasPermission(UserPermManageUsers) } + +// HasPermissionCreateFileRequests returns true if the user has the permission UserPermGuestUploads +func (u *User) HasPermissionCreateFileRequests() bool { + return u.HasPermission(UserPermGuestUploads) +} diff --git a/internal/models/User_test.go b/internal/models/User_test.go index 280cf71..11d9067 100644 --- a/internal/models/User_test.go +++ b/internal/models/User_test.go @@ -249,5 +249,5 @@ func TestUser_ToJson(t *testing.T) { Password: "1234", ResetPassword: true, } - test.IsEqualString(t, user.ToJson(), `{"id":4,"name":"Test User","permissions":255,"userLevel":1,"lastOnline":1337,"resetPassword":true}`) + test.IsEqualString(t, user.ToJson(), `{"id":4,"name":"Test User","permissions":511,"userLevel":1,"lastOnline":1337,"resetPassword":true}`) } diff --git a/internal/storage/FileServing.go b/internal/storage/FileServing.go index ace6e95..3893969 100644 --- a/internal/storage/FileServing.go +++ b/internal/storage/FileServing.go @@ -5,6 +5,7 @@ Serving and processing uploaded files */ import ( + "archive/zip" "bytes" "crypto/sha1" "encoding/hex" @@ -16,7 +17,6 @@ import ( "net/http" "os" "path/filepath" - "strconv" "strings" "time" @@ -37,19 +37,25 @@ import ( "github.com/jinzhu/copier" ) -// ErrorFileTooLarge is an error that is called when a file larger than the set maximum is uploaded +// ErrorFileTooLarge is an error which is raised when a file larger than the set maximum is uploaded var ErrorFileTooLarge = errors.New("upload limit exceeded") +// ErrorChunkTooSmall is an error which is raised when a chunk is smaller than 5MB +var ErrorChunkTooSmall = errors.New("chunk is too small") + // ErrorReplaceE2EFile is caused when an end-to-end encrypted file is replaced var ErrorReplaceE2EFile = errors.New("end-to-end encrypted files cannot be replaced") // ErrorFileNotFound is raised when an invalid ID is passed or the file has expired var ErrorFileNotFound = errors.New("file not found") +// ErrorInvalidPresign is raised when an invalid presign key has been passed or it has expired +var ErrorInvalidPresign = errors.New("invalid presign") + // NewFile creates a new file in the system. Called after an upload from the API has been completed. If a file with the same sha1 hash // already exists, it is deduplicated. This function gathers information about the file, creates an ID and saves // it into the global configuration. It is now only used by the API, the web UI uses NewFileFromChunk -func NewFile(fileContent io.Reader, fileHeader *multipart.FileHeader, userId int, uploadRequest models.UploadRequest) (models.File, error) { +func NewFile(fileContent io.Reader, fileHeader *multipart.FileHeader, userId int, uploadRequest models.UploadParameters) (models.File, error) { if !isAllowedFileSize(fileHeader.Size) { return models.File{}, ErrorFileTooLarge } @@ -150,7 +156,7 @@ func GetUploadCounts() map[int]int { // NewFileFromChunk creates a new file in the system after a chunk upload has fully completed. If a file with the same sha1 hash // already exists, it is deduplicated. This function gathers information about the file, creates an ID and saves // it into the global configuration. -func NewFileFromChunk(chunkId string, fileHeader chunking.FileHeader, userId int, uploadRequest models.UploadRequest) (models.File, error) { +func NewFileFromChunk(chunkId string, fileHeader chunking.FileHeader, userId int, uploadRequest models.UploadParameters) (models.File, error) { file, err := chunking.GetFileByChunkId(chunkId) if err != nil { return models.File{}, err @@ -287,7 +293,7 @@ func encryptChunkFile(file *os.File, metadata *models.File) (*os.File, error) { return tempFileEnc, nil } -func createNewMetaData(hash string, fileHeader chunking.FileHeader, userId int, uploadRequest models.UploadRequest) models.File { +func createNewMetaData(hash string, fileHeader chunking.FileHeader, userId int, params models.UploadParameters) models.File { file := models.File{ Id: createNewId(), Name: fileHeader.Filename, @@ -295,17 +301,18 @@ func createNewMetaData(hash string, fileHeader chunking.FileHeader, userId int, Size: helper.ByteCountSI(fileHeader.Size), SizeBytes: fileHeader.Size, ContentType: fileHeader.ContentType, - ExpireAt: uploadRequest.ExpiryTimestamp, + ExpireAt: params.ExpiryTimestamp, UploadDate: time.Now().Unix(), - DownloadsRemaining: uploadRequest.AllowedDownloads, - UnlimitedTime: uploadRequest.UnlimitedTime, - UnlimitedDownloads: uploadRequest.UnlimitedDownload, - PasswordHash: configuration.HashPassword(uploadRequest.Password, true), + DownloadsRemaining: params.AllowedDownloads, + UnlimitedTime: params.UnlimitedTime, + UnlimitedDownloads: params.UnlimitedDownload, + PasswordHash: configuration.HashPassword(params.Password, true), UserId: userId, + UploadRequestId: params.FileRequestId, } - if uploadRequest.IsEndToEndEncrypted { + if params.IsEndToEndEncrypted { file.Encryption = models.EncryptionInfo{IsEndToEndEncrypted: true, IsEncrypted: true} - file.Size = helper.ByteCountSI(uploadRequest.RealSize) + file.Size = helper.ByteCountSI(params.RealSize) } if isEncryptionRequested() { file.Encryption.IsEncrypted = true @@ -321,7 +328,7 @@ func createNewMetaData(hash string, fileHeader chunking.FileHeader, userId int, // createNewId returns a random ID func createNewId() string { - return helper.GenerateRandomString(configuration.Get().LengthId) + return helper.GenerateRandomString(configuration.GetEnvironment().LengthId) } func getEncInfoFromExistingFile(hash string) (models.EncryptionInfo, bool) { @@ -393,7 +400,7 @@ func isChangeRequested(parametersToChange, parameter int) bool { } // DuplicateFile creates a copy of an existing file with new parameters -func DuplicateFile(file models.File, parametersToChange int, newFileName string, fileParameters models.UploadRequest) (models.File, error) { +func DuplicateFile(file models.File, parametersToChange int, newFileName string, fileParameters models.UploadParameters) (models.File, error) { // apiDuplicateFile expects fileParameters.IsEndToEndEncrypted and fileParameters.RealSize not to be used, // change in apiDuplicateFile if using in this function! @@ -516,7 +523,7 @@ func AddHotlink(file *models.File) { if !IsAbleHotlink(*file) { return } - link := helper.GenerateRandomString(configuration.Get().LengthHotlinkId) + getFileExtension(file.Name) + link := helper.GenerateRandomString(configuration.GetEnvironment().LengthHotlinkId) + getFileExtension(file.Name) file.HotlinkId = link database.SaveHotlink(*file) } @@ -600,18 +607,20 @@ func GetFileByHotlink(id string) (models.File, bool) { } // ServeFile subtracts a download allowance and serves the file to the browser -func ServeFile(file models.File, w http.ResponseWriter, r *http.Request, forceDownload bool) { - file.DownloadsRemaining = file.DownloadsRemaining - 1 - file.DownloadCount = file.DownloadCount + 1 - database.IncreaseDownloadCount(file.Id, !file.UnlimitedDownloads) +func ServeFile(file models.File, w http.ResponseWriter, r *http.Request, forceDownload, increaseCounter, forceDecryption bool) { + if increaseCounter { + file.DownloadsRemaining = file.DownloadsRemaining - 1 + file.DownloadCount = file.DownloadCount + 1 + database.IncreaseDownloadCount(file.Id, !file.UnlimitedDownloads) + go sse.PublishDownloadCount(file) + } logging.LogDownload(file, r, configuration.Get().SaveIp) - go sse.PublishDownloadCount(file) if !file.IsLocalStorage() { // If non-blocking, we are not setting a download complete status as there is no reliable way to // confirm that the file has been completely downloaded. It expires automatically after 24 hours. statusId := downloadstatus.SetDownload(file) - isBlocking, err := aws.ServeFile(w, r, file, forceDownload) + isBlocking, err := aws.ServeFile(w, r, file, forceDownload, forceDecryption) // TODO chances are high that an error is returned here, we should consider proper output helper.Check(err) if isBlocking { @@ -619,29 +628,108 @@ func ServeFile(file models.File, w http.ResponseWriter, r *http.Request, forceDo } return } - fileData, size := getFileHandler(file, configuration.Get().DataDir) + fileData, _ := getFileHandler(file, configuration.Get().DataDir) if file.Encryption.IsEncrypted && !file.RequiresClientDecryption() { if !encryption.IsCorrectKey(file.Encryption, fileData) { - w.Write([]byte("Internal error - Error decrypting file, source data might be damaged or an incorrect key has been used")) + _, _ = w.Write([]byte("Internal error - Error decrypting file, source data might be damaged or an incorrect key has been used")) return } } statusId := downloadstatus.SetDownload(file) - headers.Write(file, w, forceDownload) + headers.Write(file, w, forceDownload, false) if file.Encryption.IsEncrypted && !file.RequiresClientDecryption() { err := encryption.DecryptReader(file.Encryption, fileData, w) if err != nil { - w.Write([]byte("Error decrypting file")) + _, _ = w.Write([]byte("Error decrypting file")) fmt.Println(err) return } } else { - w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) http.ServeContent(w, r, file.Name, time.Now(), fileData) } downloadstatus.SetComplete(statusId) } +// Returns the filename if unique or a new filename in the format "Name (x).ext" +func makeFilenameUnique(filename string, nameMap *map[string]bool) string { + ext := filepath.Ext(filename) + base := strings.TrimSuffix(filename, ext) + if !(*nameMap)[filename] { + (*nameMap)[filename] = true + return filename + } + + count := 2 + for { + newName := fmt.Sprintf("%s (%d)%s", base, count, ext) + if !(*nameMap)[newName] { + (*nameMap)[newName] = true + return newName + } + count++ + } +} + +func ServeFilesAsZip(files []models.File, filename string, w http.ResponseWriter, r *http.Request) { + if filename == "" { + filename = "Gokapi" + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", filename)) + w.WriteHeader(http.StatusOK) + + saveIp := configuration.Get().SaveIp + zipWriter := zip.NewWriter(w) + defer zipWriter.Close() + filenames := make(map[string]bool) + for _, file := range files { + file.Name = makeFilenameUnique(file.Name, &filenames) + header := &zip.FileHeader{ + Name: file.Name, + Method: zip.Store, + Modified: time.Unix(file.UploadDate, 0), + } + entryWriter, err := zipWriter.CreateHeader(header) + helper.Check(err) + logging.LogDownload(file, r, saveIp) + if !file.IsLocalStorage() { + statusId := downloadstatus.SetDownload(file) + err = aws.Stream(entryWriter, file) + helper.Check(err) + downloadstatus.SetComplete(statusId) + _ = zipWriter.Flush() + flushingWriter, ok := w.(http.Flusher) + if ok { + flushingWriter.Flush() + } + continue + } + fileData, _ := getFileHandler(file, configuration.Get().DataDir) + statusId := downloadstatus.SetDownload(file) + if file.Encryption.IsEncrypted { + if !encryption.IsCorrectKey(file.Encryption, fileData) { + _, _ = w.Write([]byte("Internal error - Error decrypting file, source data might be damaged or an incorrect key has been used")) + return + } + err = encryption.DecryptReader(file.Encryption, fileData, entryWriter) + if err != nil { + _, _ = w.Write([]byte("Error decrypting file")) + fmt.Println(err) + return + } + } else { + _, err = io.Copy(entryWriter, fileData) + helper.Check(err) + } + downloadstatus.SetComplete(statusId) + _ = zipWriter.Flush() + flushingWriter, ok := w.(http.Flusher) + if ok { + flushingWriter.Flush() + } + } +} + func getFileHandler(file models.File, dataDir string) (*os.File, int64) { storageData, err := os.OpenFile(dataDir+"/"+file.SHA1, os.O_RDONLY, 0644) helper.Check(err) @@ -703,6 +791,8 @@ func CleanUp(periodic bool) { } cleanOldTempFiles() cleanHotlinks() + cleanInvalidApiKeys() + cleanInvalidFileRequests() database.RunGarbageCollection() if periodic { @@ -715,6 +805,55 @@ func CleanUp(periodic bool) { } } +func getUserMap() map[int]models.User { + result := make(map[int]models.User) + users := database.GetAllUsers() + for _, user := range users { + result[user.Id] = user + } + return result +} + +// cleanInvalidApiKeys removes all API keys that are not associated with a user anymore +// Normally this should not be a problem, but if a user was manually deleted from the database, +// this could cause issues otherwise. +func cleanInvalidApiKeys() { + users := getUserMap() + for _, apiKey := range database.GetAllApiKeys() { + _, exists := users[apiKey.UserId] + if !exists { + database.DeleteApiKey(apiKey.Id) + continue + } + if apiKey.IsUploadRequestKey() { + _, exists = database.GetFileRequest(apiKey.UploadRequestId) + if !exists { + database.DeleteApiKey(apiKey.Id) + } + } + } +} + +// cleanInvalidFileRequests removes file requests and the associated files from the database if their associated owner is not a valid user. +// Normally this should not be a problem, but if a user was manually deleted from the database, +// this could cause issues otherwise. +func cleanInvalidFileRequests() { + users := getUserMap() + for _, fileRequest := range database.GetAllFileRequests() { + _, exists := users[fileRequest.UserId] + if !exists { + files := database.GetAllMetadata() + for _, file := range files { + if file.UploadRequestId == fileRequest.Id { + } + DeleteFile(file.Id, true) + } + database.DeleteFileRequest(fileRequest) + } + + } +} + // cleanHotlinks removes hotlinks from the database where the file has expired func cleanHotlinks() { hotlinks := database.GetAllHotlinks() @@ -817,6 +956,18 @@ func DeleteFile(fileId string, deleteSource bool) bool { return true } +// DeleteFiles deletes multiple files at once. This avoids race conditions when CleanUp is called multiple times +// deleteSource forces a clean-up and will delete the source if it is not +// used by a different file +func DeleteFiles(files []models.File, deleteSource bool) { + for _, file := range files { + DeleteFile(file.Id, false) + } + if deleteSource { + go CleanUp(false) + } +} + // DeleteFileSchedule schedules a file for deletion after a specified delay and optionally deletes its source. // Returns true if scheduling is successful, false otherwise. func DeleteFileSchedule(fileId string, delayMs int, deleteSource bool) bool { diff --git a/internal/storage/FileServing_test.go b/internal/storage/FileServing_test.go index 2223626..13f53de 100644 --- a/internal/storage/FileServing_test.go +++ b/internal/storage/FileServing_test.go @@ -153,13 +153,13 @@ func TestAddHotlink(t *testing.T) { type testFile struct { File models.File - Request models.UploadRequest + Request models.UploadParameters Header multipart.FileHeader UserId int Content []byte } -func createRawTestFile(content []byte) (multipart.FileHeader, models.UploadRequest) { +func createRawTestFile(content []byte) (multipart.FileHeader, models.UploadParameters) { os.Setenv("TZ", "UTC") mimeHeader := make(textproto.MIMEHeader) mimeHeader.Set("Content-Disposition", "form-data; name=\"file\"; filename=\"test.dat\"") @@ -169,7 +169,7 @@ func createRawTestFile(content []byte) (multipart.FileHeader, models.UploadReque Header: mimeHeader, Size: int64(len(content)), } - request := models.UploadRequest{ + request := models.UploadParameters{ AllowedDownloads: 1, Expiry: 999, ExpiryTimestamp: 2147483600, @@ -191,7 +191,7 @@ func createTestFile() (testFile, error) { }, err } -func createTestChunk() (string, chunking.FileHeader, models.UploadRequest, error) { +func createTestChunk() (string, chunking.FileHeader, models.UploadParameters, error) { content := []byte("This is a file for chunk testing purposes") header, request := createRawTestFile(content) chunkId := helper.GenerateRandomString(15) @@ -202,7 +202,7 @@ func createTestChunk() (string, chunking.FileHeader, models.UploadRequest, error } err := os.WriteFile("test/data/chunk-"+chunkId, content, 0600) if err != nil { - return "", chunking.FileHeader{}, models.UploadRequest{}, err + return "", chunking.FileHeader{}, models.UploadParameters{}, err } return chunkId, fileheader, request, nil } @@ -260,7 +260,7 @@ func TestNewFile(t *testing.T) { Header: mimeHeader, Size: int64(20) * 1024 * 1024, } - request = models.UploadRequest{ + request = models.UploadParameters{ AllowedDownloads: 1, Expiry: 999, ExpiryTimestamp: 2147483600, @@ -293,7 +293,7 @@ func TestNewFile(t *testing.T) { Header: mimeHeader, Size: int64(50) * 1024 * 1024, } - request = models.UploadRequest{ + request = models.UploadParameters{ AllowedDownloads: 1, Expiry: 999, ExpiryTimestamp: 2147483600, @@ -351,7 +351,7 @@ func TestNewFile(t *testing.T) { Header: mimeHeader, Size: int64(20) * 1024 * 1024, } - request = models.UploadRequest{ + request = models.UploadParameters{ AllowedDownloads: 1, Expiry: 999, ExpiryTimestamp: 2147483600, @@ -464,7 +464,7 @@ func TestDuplicateFile(t *testing.T) { retrievedFile.DownloadCount = 5 database.SaveMetaData(retrievedFile) - newFile, err := DuplicateFile(retrievedFile, 0, "123", models.UploadRequest{}) + newFile, err := DuplicateFile(retrievedFile, 0, "123", models.UploadParameters{}) test.IsNil(t, err) test.IsEqualInt(t, newFile.DownloadCount, 0) test.IsEqualInt(t, newFile.DownloadsRemaining, 1) @@ -474,7 +474,7 @@ func TestDuplicateFile(t *testing.T) { test.IsEqualBool(t, newFile.UnlimitedTime, false) test.IsEqualString(t, newFile.Name, "test.dat") - uploadRequest := models.UploadRequest{ + uploadRequest := models.UploadParameters{ AllowedDownloads: 5, Expiry: 5, ExpiryTimestamp: 200000, @@ -573,7 +573,7 @@ func TestServeFile(t *testing.T) { test.IsEqualBool(t, result, true) r := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() - ServeFile(file, w, r, true) + ServeFile(file, w, r, true, true, false) _, result = GetFile(idNewFile) test.IsEqualBool(t, result, false) @@ -594,7 +594,7 @@ func TestServeFile(t *testing.T) { w = httptest.NewRecorder() file, result = GetFile("awsTest1234567890123") test.IsEqualBool(t, result, true) - ServeFile(file, w, r, false) + ServeFile(file, w, r, false, true, false) if aws.IsMockApi { test.ResponseBodyContains(t, w, "https://redirect.url") } else { @@ -619,7 +619,7 @@ func TestServeFile(t *testing.T) { file.Encryption.Nonce = nonce r = httptest.NewRequest("GET", "/", nil) w = httptest.NewRecorder() - ServeFile(file, w, r, true) + ServeFile(file, w, r, true, true, false) test.ResponseBodyContains(t, w, "Error decrypting file") } diff --git a/internal/storage/chunking/Chunking.go b/internal/storage/chunking/Chunking.go index afabeaf..571e5cf 100644 --- a/internal/storage/chunking/Chunking.go +++ b/internal/storage/chunking/Chunking.go @@ -186,6 +186,14 @@ func GetFileByChunkId(id string) (*os.File, error) { return file, nil } +// DeleteChunk deletes the chunk file +func DeleteChunk(id string) error { + if id == "" { + return errors.New("empty chunk id provided") + } + return os.Remove(getChunkFilePath(sanitiseUuid(id))) +} + // FileExists returns true if a file exists for the given chunk ID func FileExists(id string) bool { exists, err := helper.FileExists(getChunkFilePath(id)) diff --git a/internal/storage/chunking/chunkreservation/ChunkReservation.go b/internal/storage/chunking/chunkreservation/ChunkReservation.go new file mode 100644 index 0000000..9c22719 --- /dev/null +++ b/internal/storage/chunking/chunkreservation/ChunkReservation.go @@ -0,0 +1,92 @@ +package chunkreservation + +import ( + "sync" + "time" + + "github.com/forceu/gokapi/internal/helper" +) + +var reservedChunks = make(map[string]map[string]reservation) +var reservationMutex sync.RWMutex +var gcIsRunning = false + +const timeReservationWithoutUpload = 4 * 60 +const timeReservationWithUpload = 23 * 60 * 60 + +type reservation struct { + Uuid string + Expiry int64 +} + +func GetCount(id string) int { + reservationMutex.RLock() + defer reservationMutex.RUnlock() + length := len(reservedChunks[id]) + return length +} + +func New(id string) string { + reservationMutex.Lock() + defer reservationMutex.Unlock() + + uuid := helper.GenerateRandomString(32) + if reservedChunks[id] == nil { + reservedChunks[id] = make(map[string]reservation) + } + reservedChunks[id][uuid] = reservation{ + Uuid: uuid, + Expiry: time.Now().Unix() + timeReservationWithoutUpload, + } + + if !gcIsRunning { + gcIsRunning = true + go cleanUp(true) + } + return uuid +} + +func SetComplete(id, uuid string) { + reservationMutex.Lock() + delete(reservedChunks[id], uuid) + reservationMutex.Unlock() +} + +func SetUploading(id string, uuid string) bool { + reservationMutex.Lock() + defer reservationMutex.Unlock() + + if reservedChunks[id] == nil { + return false + } + chunk, ok := reservedChunks[id][uuid] + if !ok { + return false + } + if chunk.Expiry < time.Now().Unix() { + return false + } + chunk.Expiry = time.Now().Unix() + timeReservationWithUpload + reservedChunks[id][uuid] = chunk + return true +} + +func cleanUp(isPeriodic bool) { + reservationMutex.Lock() + for id, chunks := range reservedChunks { + now := time.Now().Unix() + for uuid, reservedChunk := range chunks { + if reservedChunk.Expiry < now { + delete(reservedChunks[id], uuid) + } + } + } + reservationMutex.Unlock() + + if isPeriodic { + go func() { + time.Sleep(time.Minute * 5) + cleanUp(true) + }() + } +} diff --git a/internal/storage/filerequest/Filerequest.go b/internal/storage/filerequest/Filerequest.go new file mode 100644 index 0000000..d10ecfa --- /dev/null +++ b/internal/storage/filerequest/Filerequest.go @@ -0,0 +1,64 @@ +package filerequest + +import ( + "time" + + "github.com/forceu/gokapi/internal/configuration" + "github.com/forceu/gokapi/internal/configuration/database" + "github.com/forceu/gokapi/internal/helper" + "github.com/forceu/gokapi/internal/models" + "github.com/forceu/gokapi/internal/storage" +) + +// New creates a new file request object. It is not stored yet, +// and an API key has to be generated manually +func New(user models.User) models.FileRequest { + return models.FileRequest{ + Id: helper.GenerateRandomString(15), + UserId: user.Id, + CreationDate: time.Now().Unix(), + Name: "Unnamed file request", + } +} + +func Get(id string) (models.FileRequest, bool) { + result, ok := database.GetFileRequest(id) + if !ok { + return models.FileRequest{}, false + } + result.Populate(database.GetAllMetadata(), configuration.Get().MaxFileSizeMB) + return result, true +} + +func GetAll() []models.FileRequest { + result := database.GetAllFileRequests() + if len(result) == 0 { + return result + } + allFiles := database.GetAllMetadata() + maxServerSize := configuration.Get().MaxFileSizeMB + for i, request := range result { + request.Populate(allFiles, maxServerSize) + result[i] = request + } + return result +} + +// Delete all files associated with a file request and the request itself +func Delete(request models.FileRequest) { + files := GetAllFiles(request) + storage.DeleteFiles(files, true) + database.DeleteFileRequest(request) +} + +// GetAllFiles returns a list of all files associated with a file request +func GetAllFiles(request models.FileRequest) []models.File { + var result []models.File + files := database.GetAllMetadata() + for _, file := range files { + if file.UploadRequestId == request.Id { + result = append(result, file) + } + } + return result +} diff --git a/internal/storage/filerequest/ratelimiter/RateLimiter.go b/internal/storage/filerequest/ratelimiter/RateLimiter.go new file mode 100644 index 0000000..d4354f6 --- /dev/null +++ b/internal/storage/filerequest/ratelimiter/RateLimiter.go @@ -0,0 +1,71 @@ +package ratelimiter + +import ( + "sync" + "time" + + "golang.org/x/time/rate" +) + +var uuidLimiter = newLimiter() + +// Currently unused +var byteLimiter = newLimiter() + +type limiterEntry struct { + limiter *rate.Limiter + lastSeen time.Time +} + +type Store struct { + mu sync.Mutex + limiters map[string]*limiterEntry + cleanupStarted bool +} + +func newLimiter() *Store { + return &Store{ + limiters: make(map[string]*limiterEntry), + } +} + +func IsAllowedNewUuid(key string) bool { + return uuidLimiter.Get(key, 1, 4).Allow() +} + +func (s *Store) Get(key string, r rate.Limit, burst int) *rate.Limiter { + s.mu.Lock() + defer s.mu.Unlock() + + e, ok := s.limiters[key] + if !ok { + e = &limiterEntry{ + limiter: rate.NewLimiter(r, burst), + } + } + + e.lastSeen = time.Now() + s.limiters[key] = e + s.StartCleanup(12 * time.Hour) + return e.limiter +} + +func (s *Store) StartCleanup(maxIdle time.Duration) { + if s.cleanupStarted { + return + } + s.cleanupStarted = true + go func() { + ticker := time.NewTicker(30 * time.Minute) + for range ticker.C { + now := time.Now() + s.mu.Lock() + for k, v := range s.limiters { + if now.Sub(v.lastSeen) > maxIdle { + delete(s.limiters, k) + } + } + s.mu.Unlock() + } + }() +} diff --git a/internal/storage/filesystem/s3filesystem/aws/Aws.go b/internal/storage/filesystem/s3filesystem/aws/Aws.go index 9df8566..7b45151 100644 --- a/internal/storage/filesystem/s3filesystem/aws/Aws.go +++ b/internal/storage/filesystem/s3filesystem/aws/Aws.go @@ -6,6 +6,12 @@ import ( "context" "errors" "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/credentials" @@ -13,13 +19,9 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/s3/s3manager" + "github.com/forceu/gokapi/internal/encryption" "github.com/forceu/gokapi/internal/models" "github.com/forceu/gokapi/internal/webserver/headers" - "io" - "net/http" - "net/url" - "strings" - "time" ) var awsConfig models.AwsConfig @@ -106,11 +108,10 @@ func Upload(input io.Reader, file models.File) (string, error) { return result.Location, nil } -// Download downloads a file from AWS, used for encrypted files and testing -func Download(writer io.WriterAt, file models.File) (int64, error) { +// download downloads a file from AWS, used for testing +func download(writer io.WriterAt, file models.File) (int64, error) { sess := createSession() downloader := s3manager.NewDownloader(sess) - size, err := downloader.Download(writer, &s3.GetObjectInput{ Bucket: aws.String(file.AwsBucket), Key: aws.String(file.SHA1), @@ -121,13 +122,39 @@ func Download(writer io.WriterAt, file models.File) (int64, error) { return size, nil } -// ServeFile either redirects the user to a pre-signed download url (default) or downloads the file and serves it as a proxy (depending -// on configuration). Returns true if blocking operation (in order to set download status) or false if non-blocking. -func ServeFile(w http.ResponseWriter, r *http.Request, file models.File, forceDownload bool) (bool, error) { - if !awsConfig.ProxyDownload { - return false, redirectToDownload(w, r, file, forceDownload) +// Stream downloads a file from AWS sequentially, used for saving to a Zip file +func Stream(writer io.Writer, file models.File) error { + sess := createSession() + s3svc := s3.New(sess) + + obj, err := s3svc.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(file.AwsBucket), + Key: aws.String(file.SHA1), + }) + if err != nil { + return err } - return true, proxyDownload(w, file, forceDownload) + defer obj.Body.Close() + + var reader io.Reader = obj.Body + + if file.Encryption.IsEncrypted { + return encryption.DecryptReader(file.Encryption, obj.Body, writer) + } + _, err = io.Copy(writer, reader) + return err +} + +// ServeFile either redirects the user to a pre-signed download url (default) or downloads the file and serves it as a proxy (depending +// on configuration). Returns true if blocking operation (to set download status) or false if non-blocking. +func ServeFile(w http.ResponseWriter, r *http.Request, file models.File, forceDownload, forceDecryption bool) (bool, error) { + if forceDecryption { + return true, serveDecryptedFile(w, file) + } + if awsConfig.ProxyDownload { + return true, proxyDownload(w, file, forceDownload) + } + return false, redirectToDownload(w, r, file, forceDownload) } func getPresignedUrl(file models.File, forceDownload bool) (string, error) { @@ -176,11 +203,29 @@ func proxyDownload(w http.ResponseWriter, file models.File, forceDownload bool) return err } defer resp.Body.Close() - headers.Write(file, w, forceDownload) + headers.Write(file, w, forceDownload, false) _, _ = io.Copy(w, resp.Body) return nil } +func serveDecryptedFile(w http.ResponseWriter, file models.File) error { + sess := createSession() + s3svc := s3.New(sess) + + // 1. Get the object from S3 + obj, err := s3svc.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(file.AwsBucket), + Key: aws.String(file.SHA1), + }) + if err != nil { + return err + } + defer obj.Body.Close() + + headers.Write(file, w, true, true) + return encryption.DecryptReader(file.Encryption, obj.Body, w) +} + func getTimeoutContext() (context.Context, context.CancelFunc) { ctx := context.Background() rContext, rCancel := context.WithTimeout(ctx, 5*time.Second) diff --git a/internal/storage/filesystem/s3filesystem/aws/Aws_mock.go b/internal/storage/filesystem/s3filesystem/aws/Aws_mock.go index cc225f3..85c47ad 100644 --- a/internal/storage/filesystem/s3filesystem/aws/Aws_mock.go +++ b/internal/storage/filesystem/s3filesystem/aws/Aws_mock.go @@ -5,12 +5,13 @@ package aws import ( "bytes" "errors" - "github.com/forceu/gokapi/internal/models" "io" "net/http" "os" "strconv" "strings" + + "github.com/forceu/gokapi/internal/models" ) var uploadedFiles []models.File @@ -107,7 +108,7 @@ func Upload(input io.Reader, file models.File) (string, error) { } // Download downloads a file from AWS -func Download(writer io.WriterAt, file models.File) (int64, error) { +func download(writer io.WriterAt, file models.File) (int64, error) { if !isValidCredentials() { return 0, errors.New("invalid credentials / invalid bucket / invalid region") } @@ -129,7 +130,7 @@ func isUploaded(file models.File) bool { // ServeFile either redirects the user to a pre-signed download url (default) or downloads the file and serves it as a proxy (depending // on configuration). Returns true if blocking operation (in order to set download status) or false if non-blocking. -func ServeFile(w http.ResponseWriter, r *http.Request, file models.File, forceDownload bool) (bool, error) { +func ServeFile(w http.ResponseWriter, r *http.Request, file models.File, forceDownload bool, forceDecryption bool) (bool, error) { // TODO implement proxy as well return false, RedirectToDownload(w, r, file, forceDownload) } @@ -199,3 +200,21 @@ func IsCorsCorrectlySet(bucket, gokapiUrl string) (bool, error) { func GetDefaultBucketName() string { return bucketName } + +// Stream downloads a file from AWS sequentially, used for saving to a Zip file +func Stream(writer io.Writer, file models.File) error { + if !isValidCredentials() { + return errors.New("invalid credentials / invalid bucket / invalid region") + } + + if isUploaded(file) { + data, err := os.Open("data/" + file.SHA1) + if err != nil { + return err + } + defer data.Close() + _, err = io.Copy(writer, data) + return err + } + return errors.New("file not found") +} diff --git a/internal/storage/filesystem/s3filesystem/aws/Aws_slim.go b/internal/storage/filesystem/s3filesystem/aws/Aws_slim.go index d97b0a7..2f887cf 100644 --- a/internal/storage/filesystem/s3filesystem/aws/Aws_slim.go +++ b/internal/storage/filesystem/s3filesystem/aws/Aws_slim.go @@ -4,9 +4,10 @@ package aws import ( "errors" - "github.com/forceu/gokapi/internal/models" "io" "net/http" + + "github.com/forceu/gokapi/internal/models" ) const errorString = "AWS not supported in this build" @@ -43,7 +44,7 @@ func Upload(input io.Reader, file models.File) (string, error) { } // Download downloads a file from AWS -func Download(writer io.WriterAt, file models.File) (int64, error) { +func download(writer io.WriterAt, file models.File) (int64, error) { return 0, errors.New(errorString) } @@ -59,7 +60,7 @@ func RedirectToDownload(w http.ResponseWriter, r *http.Request, file models.File // ServeFile either redirects the user to a pre-signed download url (default) or downloads the file and serves it as a proxy (depending // on configuration). Returns true if blocking operation (in order to set download status) or false if non-blocking. -func ServeFile(w http.ResponseWriter, r *http.Request, file models.File, forceDownload bool) (bool, error) { +func ServeFile(w http.ResponseWriter, r *http.Request, file models.File, forceDownload bool, forceDecryption bool) (bool, error) { return false, errors.New(errorString) } @@ -82,3 +83,8 @@ func IsCorsCorrectlySet(bucket, gokapiUrl string) (bool, error) { func GetDefaultBucketName() string { return "" } + +// Stream downloads a file from AWS sequentially, used for saving to a Zip file +func Stream(writer io.Writer, file models.File) error { + return errors.New(errorString) +} diff --git a/internal/storage/filesystem/s3filesystem/aws/Aws_test.go b/internal/storage/filesystem/s3filesystem/aws/Aws_test.go index 38ff457..9651489 100644 --- a/internal/storage/filesystem/s3filesystem/aws/Aws_test.go +++ b/internal/storage/filesystem/s3filesystem/aws/Aws_test.go @@ -3,17 +3,18 @@ package aws import ( - "github.com/forceu/gokapi/internal/configuration/cloudconfig" - "github.com/forceu/gokapi/internal/models" - "github.com/forceu/gokapi/internal/test" - "github.com/johannesboyne/gofakes3" - "github.com/johannesboyne/gofakes3/backend/s3mem" "io" "net/http" "net/http/httptest" "os" "strings" "testing" + + "github.com/forceu/gokapi/internal/configuration/cloudconfig" + "github.com/forceu/gokapi/internal/models" + "github.com/forceu/gokapi/internal/test" + "github.com/johannesboyne/gofakes3" + "github.com/johannesboyne/gofakes3/backend/s3mem" ) var testFile, invalidFile, invalidBucket, invalidAll models.File @@ -86,7 +87,7 @@ func TestUploadToAws(t *testing.T) { func TestDownloadFromAws(t *testing.T) { test.FileDoesNotExist(t, "test") file, _ := os.Create("test") - size, err := Download(file, testFile) + size, err := download(file, testFile) test.IsNil(t, err) test.IsEqualBool(t, size == 16, true) test.FileExists(t, "test") @@ -110,8 +111,8 @@ func testServing(t *testing.T, expectRedirect, forceDownload bool) { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/download", nil) - isBlockng, err := ServeFile(w, r, testFile, forceDownload) - test.IsEqualBool(t, isBlockng, !expectRedirect) + isBlocking, err := ServeFile(w, r, testFile, forceDownload, false) + test.IsEqualBool(t, isBlocking, !expectRedirect) test.IsNil(t, err) response, err := io.ReadAll(w.Result().Body) diff --git a/internal/storage/processingstatus/pstatusdb/PStatusDb.go b/internal/storage/processingstatus/pstatusdb/PStatusDb.go index 0884427..ffe2d7e 100644 --- a/internal/storage/processingstatus/pstatusdb/PStatusDb.go +++ b/internal/storage/processingstatus/pstatusdb/PStatusDb.go @@ -1,9 +1,10 @@ package pstatusdb import ( - "github.com/forceu/gokapi/internal/models" "sync" "time" + + "github.com/forceu/gokapi/internal/models" ) var statusMap = make(map[string]models.UploadStatus) diff --git a/internal/test/TestHelper.go b/internal/test/TestHelper.go index eb8bd05..f5634ba 100644 --- a/internal/test/TestHelper.go +++ b/internal/test/TestHelper.go @@ -37,7 +37,7 @@ func IsEqualString(t MockT, got, want string) { } } -// ResponseBodyContains fails test if http response does contain string +// ResponseBodyContains fails test if http response does not contain the string func ResponseBodyContains(t MockT, got *httptest.ResponseRecorder, want string) { t.Helper() result, err := io.ReadAll(got.Result().Body) @@ -47,6 +47,31 @@ func ResponseBodyContains(t MockT, got *httptest.ResponseRecorder, want string) } } +// ResponseBodyIs fails test if http response is not the exact string +func ResponseBodyIs(t MockT, got *httptest.ResponseRecorder, want string) { + t.Helper() + result, err := io.ReadAll(got.Result().Body) + IsNil(t, err) + IsEqualString(t, string(result), want) +} + +// ResponseBodyIsWithAlternate fails test if http response is not the exact string of one of the supplied string +func ResponseBodyIsWithAlternate(t MockT, got *httptest.ResponseRecorder, want []string) { + t.Helper() + result, err := io.ReadAll(got.Result().Body) + IsNil(t, err) + found := false + for _, wantedString := range want { + if string(result) == wantedString { + found = true + break + } + } + if !found { + t.Errorf("Assertion failed, got: %v \n want: %s.\n\n", got, want) + } +} + // IsNotEqualString fails test if got and want are not identical func IsNotEqualString(t MockT, got, want string) { t.Helper() diff --git a/internal/test/testconfiguration/TestConfiguration.go b/internal/test/testconfiguration/TestConfiguration.go index 9e36132..2f3b1c4 100644 --- a/internal/test/testconfiguration/TestConfiguration.go +++ b/internal/test/testconfiguration/TestConfiguration.go @@ -262,14 +262,14 @@ func writeApiKeys() { database.SaveApiKey(models.ApiKey{ Id: "validkey", FriendlyName: "First Key", - Permissions: models.ApiPermAll, // TODO + Permissions: models.ApiPermView, UserId: 5, PublicId: "taiyeo6uLie6nu6eip0ieweiM5mahv", }) database.SaveApiKey(models.ApiKey{ Id: "validkeyid7", FriendlyName: "Key for uid 7", - Permissions: models.ApiPermAll, // TODO + Permissions: models.ApiPermUpload, UserId: 7, PublicId: "vu0eemi8eehaisuth3pahDai2eo6ze", }) @@ -277,21 +277,21 @@ func writeApiKeys() { Id: "GAh1IhXDvYnqfYLazWBqMB9HSFmNPO", FriendlyName: "Second Key", LastUsed: 1620671580, - Permissions: models.ApiPermAll, // TODO + Permissions: models.ApiPermNone, UserId: 5, PublicId: "yaeVohng1ohNohsh1vailizeil5ka5", }) database.SaveApiKey(models.ApiKey{ Id: "jiREglQJW0bOqJakfjdVfe8T1EM8n8", FriendlyName: "Unnamed Key", - Permissions: models.ApiPermAll, // TODO + Permissions: models.ApiPermNone, UserId: 5, PublicId: "ahYie4ophoo5OoGhahCe1neic6thah", }) database.SaveApiKey(models.ApiKey{ Id: "okeCMWqhVMZSpt5c1qpCWhKvJJPifb", FriendlyName: "Unnamed Key", - Permissions: models.ApiPermAll, // TODO + Permissions: models.ApiPermNone, UserId: 5, PublicId: "ugoo0roowoanahthei7ohSail5OChu", }) diff --git a/internal/webserver/Webserver.go b/internal/webserver/Webserver.go index bcc9ea3..7da4cc8 100644 --- a/internal/webserver/Webserver.go +++ b/internal/webserver/Webserver.go @@ -7,6 +7,7 @@ Handling of webserver and requests / uploads import ( "bytes" "context" + "crypto/subtle" "embed" "encoding/base64" "errors" @@ -30,6 +31,7 @@ import ( "github.com/forceu/gokapi/internal/logging" "github.com/forceu/gokapi/internal/models" "github.com/forceu/gokapi/internal/storage" + "github.com/forceu/gokapi/internal/storage/filerequest" "github.com/forceu/gokapi/internal/webserver/api" "github.com/forceu/gokapi/internal/webserver/authentication" "github.com/forceu/gokapi/internal/webserver/authentication/oauth" @@ -100,11 +102,13 @@ func Start() { mux.HandleFunc("/changePassword", requireLogin(changePassword, true, true)) mux.HandleFunc("/d", showDownload) mux.HandleFunc("/downloadFile", downloadFile) + mux.HandleFunc("/downloadPresigned", requireLogin(downloadPresigned, false, false)) mux.HandleFunc("/e2eSetup", requireLogin(showE2ESetup, true, false)) mux.HandleFunc("/error", showError) mux.HandleFunc("/error-auth", showErrorAuth) mux.HandleFunc("/error-header", showErrorHeader) mux.HandleFunc("/error-oauth", showErrorIntOAuth) + mux.HandleFunc("/filerequests", requireLogin(showUploadRequest, true, false)) mux.HandleFunc("/forgotpw", forgotPassword) mux.HandleFunc("/h/", showHotlink) mux.HandleFunc("/hotlink/", showHotlink) // backward compatibility @@ -112,6 +116,7 @@ func Start() { mux.HandleFunc("/login", showLogin) mux.HandleFunc("/logs", requireLogin(showLogs, true, false)) mux.HandleFunc("/logout", doLogout) + mux.HandleFunc("/publicUpload", showPublicUpload) mux.HandleFunc("/uploadChunk", requireLogin(uploadChunk, false, false)) mux.HandleFunc("/uploadStatus", requireLogin(sse.GetStatusSSE, false, false)) mux.HandleFunc("/users", requireLogin(showUserAdmin, true, false)) @@ -228,7 +233,7 @@ type redirectValues struct { PasswordRequired bool } -// Handling of /id/?/? - used when filename shall be displayed, will redirect to regular download URL +// Handling of /id/?/? - used when filename shall be displayed, will redirect to the regular download URL func redirectFromFilename(w http.ResponseWriter, r *http.Request) { addNoCacheHeader(w) id := r.PathValue("id") @@ -330,9 +335,10 @@ func changePassword(w http.ResponseWriter, r *http.Request) { return } } + config := configuration.Get() err = templateFolder.ExecuteTemplate(w, "changepw", - genericView{PublicName: configuration.Get().PublicName, - MinPasswordLength: configuration.Environment.MinLengthPassword, + genericView{PublicName: config.PublicName, + MinPasswordLength: configuration.GetEnvironment().MinLengthPassword, ErrorMessage: errMessage, CustomContent: customStaticInfo}) helper.CheckIgnoreTimeout(err) @@ -342,7 +348,7 @@ func validateNewPassword(newPassword string, user models.User) (string, string, if len(newPassword) == 0 { return "", user.Password, false } - if len(newPassword) < configuration.Environment.MinLengthPassword { + if len(newPassword) < configuration.GetEnvironment().MinLengthPassword { return "Password is too short", user.Password, false } newPasswordHash := configuration.HashPassword(newPassword, false) @@ -354,21 +360,32 @@ func validateNewPassword(newPassword string, user models.User) (string, string, // Handling of /error func showError(w http.ResponseWriter, r *http.Request) { - const invalidFile = 0 - const noCipherSupplied = 1 - const wrongCipher = 2 + const ( + invalidFile = iota + noCipherSupplied + wrongCipher + invalidFileRequest + ) errorReason := invalidFile + cardWidth := 18 if r.URL.Query().Has("e2e") { errorReason = noCipherSupplied + cardWidth = 25 } if r.URL.Query().Has("key") { errorReason = wrongCipher + cardWidth = 25 + } + if r.URL.Query().Has("fr") { + errorReason = invalidFileRequest + cardWidth = 30 } err := templateFolder.ExecuteTemplate(w, "error", genericView{ - ErrorId: errorReason, - PublicName: configuration.Get().PublicName, - CustomContent: customStaticInfo}) + ErrorId: errorReason, + ErrorCardWidth: cardWidth, + PublicName: configuration.Get().PublicName, + CustomContent: customStaticInfo}) helper.CheckIgnoreTimeout(err) } @@ -408,8 +425,19 @@ func forgotPassword(w http.ResponseWriter, r *http.Request) { helper.CheckIgnoreTimeout(err) } +// Handling of /filerequest +func showUploadRequest(w http.ResponseWriter, r *http.Request) { + userId, err := authentication.GetUserFromRequest(r) + if err != nil { + panic(err) + } + view := (&AdminView{}).convertGlobalConfig(ViewFileRequests, userId) + err = templateFolder.ExecuteTemplate(w, "uploadreq", view) + helper.CheckIgnoreTimeout(err) +} + // Handling of /api -// If user is authenticated, this menu lists all uploads and enables uploading new files +// If the user is authenticated, this menu lists all uploads and enables uploading new files func showApiAdmin(w http.ResponseWriter, r *http.Request) { userId, err := authentication.GetUserFromRequest(r) if err != nil { @@ -509,9 +537,9 @@ type LoginView struct { // If it exists, a download form is shown, or a password needs to be entered. func showDownload(w http.ResponseWriter, r *http.Request) { addNoCacheHeader(w) - keyId := queryUrl(w, r, "error") + keyId := queryUrl(w, r, "id", "error") file, ok := storage.GetFile(keyId) - if !ok { + if !ok || file.IsFileRequest() { redirect(w, "error") return } @@ -573,19 +601,19 @@ func showHotlink(w http.ResponseWriter, r *http.Request) { hotlinkId = strings.Replace(hotlinkId, "/h/", "", 1) addNoCacheHeader(w) file, ok := storage.GetFileByHotlink(hotlinkId) - if !ok { + if !ok || file.IsFileRequest() { w.Header().Set("Content-Type", "image/svg+xml") _, _ = w.Write(imageExpiredPicture) return } - storage.ServeFile(file, w, r, false) + storage.ServeFile(file, w, r, false, true, false) } // Checks if a file is associated with the GET parameter from the current URL // Stops for 500ms to limit brute forcing if invalid key and redirects to redirectUrl -func queryUrl(w http.ResponseWriter, r *http.Request, redirectUrl string) string { - keys, ok := r.URL.Query()["id"] - if !ok || len(keys[0]) < configuration.Get().LengthId { +func queryUrl(w http.ResponseWriter, r *http.Request, keyword string, redirectUrl string) string { + keys, ok := r.URL.Query()[keyword] + if !ok || len(keys[0]) < configuration.GetEnvironment().LengthId { select { case <-time.After(500 * time.Millisecond): } @@ -613,8 +641,8 @@ func showAdminMenu(w http.ResponseWriter, r *http.Request) { } view := (&AdminView{}).convertGlobalConfig(ViewMain, user) - if len(configuration.Environment.ActiveDeprecations) > 0 { - if user.UserLevel == models.UserLevelSuperAdmin { + if len(configuration.GetEnvironment().ActiveDeprecations) > 0 { + if user.IsSuperAdmin() { view.ShowDeprecationNotice = true } } @@ -683,11 +711,12 @@ type e2ESetupView struct { CustomContent customStatic } -// AdminView contains parameters for all admin related pages +// AdminView contains parameters for all admin-related pages type AdminView struct { Items []models.FileApiOutput ApiKeys []models.ApiKey Users []userInfo + FileRequests []models.FileRequest ActiveUser models.User UserMap map[int]*models.User ServerUrl string @@ -707,11 +736,13 @@ type AdminView struct { ChunkSize int MaxParallelUploads int MinLengthPassword int + FileRequestMaxFiles int + FileRequestMaxSize int TimeNow int64 CustomContent customStatic } -// getUserMap needs to return the map with pointers, otherwise template cannot call +// getUserMap needs to return the map with pointers; otherwise template cannot call // functions associated with it func getUserMap() map[int]*models.User { result := make(map[int]*models.User) @@ -731,6 +762,8 @@ const ( ViewAPI // ViewUsers is the identifier for the user management menu ViewUsers + // ViewFileRequests is the identifier for the file request menu + ViewFileRequests ) // Converts the globalConfig variable to an AdminView struct to pass the infos to @@ -754,17 +787,17 @@ func (u *AdminView) convertGlobalConfig(view int, user models.User) *AdminView { helper.Check(err) metaDataList = append(metaDataList, fileInfo) } - metaDataList = sortMetaData(metaDataList) + metaDataList = sortMetaDataApi(metaDataList) case ViewAPI: for _, apiKey := range database.GetAllApiKeys() { - // Double-checking if user of API key exists + // Double-checking if the owner of the API key exists // If the user was manually deleted from the database, this could lead to a crash // in the API view _, ok := u.UserMap[apiKey.UserId] if !ok { continue } - if !apiKey.IsSystemKey { + if !apiKey.IsSystemKey && !apiKey.IsUploadRequestKey() { if apiKey.UserId == user.Id || user.HasPermissionManageApi() { apiKeyList = append(apiKeyList, apiKey) } @@ -787,6 +820,25 @@ func (u *AdminView) convertGlobalConfig(view int, user models.User) *AdminView { } u.Users = append(u.Users, userWithUploads) } + case ViewFileRequests: + for _, fileRequest := range filerequest.GetAll() { + // Double-checking if the owner of the file request exists + // If the user was manually deleted from the database, this could lead to a crash + // in the file request view + _, ok := u.UserMap[fileRequest.UserId] + if !ok { + continue + } + if fileRequest.UserId != user.Id && !user.HasPermissionListOtherUploads() { + continue + } + fileRequest.Files = sortMetaData(fileRequest.Files) + u.FileRequests = append(u.FileRequests, fileRequest) + if !user.IsAdmin() { + u.FileRequestMaxFiles = configuration.GetEnvironment().MaxFilesGuestUpload + u.FileRequestMaxSize = configuration.GetEnvironment().MaxSizeGuestUploadMb + } + } } u.ServerUrl = config.ServerUrl @@ -801,15 +853,14 @@ func (u *AdminView) convertGlobalConfig(view int, user models.User) *AdminView { u.IsUserTabAvailable = config.Authentication.Method != models.AuthenticationDisabled u.EndToEndEncryption = config.Encryption.Level == encryption.EndToEndEncryption u.MaxParallelUploads = config.MaxParallelUploads - u.MinLengthPassword = config.MinLengthPassword u.ChunkSize = config.ChunkSize u.IncludeFilename = config.IncludeFilename return u } -// sortMetaData arranges the provided array so that Fies are sorted by most recent upload first and if that is equal +// sortMetaDataApi arranges the provided array so that Fies are sorted by the most recent upload first and if that is equal, // then by most time remaining first. If that is equal, then sort by ID. -func sortMetaData(input []models.FileApiOutput) []models.FileApiOutput { +func sortMetaDataApi(input []models.FileApiOutput) []models.FileApiOutput { sort.Slice(input[:], func(i, j int) bool { if input[i].UploadDate != input[j].UploadDate { return input[i].UploadDate > input[j].UploadDate @@ -822,6 +873,18 @@ func sortMetaData(input []models.FileApiOutput) []models.FileApiOutput { return input } +// sortMetaData arranges the provided array so that Fies are sorted by the most recent upload first then sort by ID. +// Currently only used for the files of File Requests, all others use sortMetaDataApi +func sortMetaData(input []models.File) []models.File { + sort.Slice(input[:], func(i, j int) bool { + if input[i].UploadDate != input[j].UploadDate { + return input[i].UploadDate > input[j].UploadDate + } + return input[i].Id > input[j].Id + }) + return input +} + // sortApiKeys arranges the provided array so that API keys are sorted by most recent usage first and if that is equal // then by ID func sortApiKeys(input []models.ApiKey) []models.ApiKey { @@ -839,6 +902,43 @@ type userInfo struct { User models.User } +// Handling of /publicUpload +func showPublicUpload(w http.ResponseWriter, r *http.Request) { + addNoCacheHeader(w) + fileRequestId := queryUrl(w, r, "id", "error?fr") + request, ok := filerequest.Get(fileRequestId) + if !ok { + redirect(w, "error?fr") + return + } + if !request.IsUnlimitedTime() && request.Expiry < time.Now().Unix() { + redirect(w, "error?fr") + return + } + if !request.IsUnlimitedFiles() && request.UploadedFiles >= request.MaxFiles { + redirect(w, "error?fr") + return + } + apiKey := queryUrl(w, r, "key", "error?fr") + if subtle.ConstantTimeCompare([]byte(request.ApiKey), []byte(apiKey)) != 1 { + redirect(w, "error?fr") + return + } + + config := configuration.Get() + + view := publicUploadView{ + PublicName: config.PublicName, + ChunkSize: config.ChunkSize, + MaxServerSize: config.MaxFileSizeMB, + FileRequest: &request, + CustomContent: customStaticInfo, + } + + err := templateFolder.ExecuteTemplate(w, "publicUpload", view) + helper.CheckIgnoreTimeout(err) +} + // Handling of /uploadChunk // If the user is authenticated, this parses the uploaded chunk and stores it func uploadChunk(w http.ResponseWriter, r *http.Request) { @@ -848,7 +948,7 @@ func uploadChunk(w http.ResponseWriter, r *http.Request) { responseError(w, storage.ErrorFileTooLarge) } r.Body = http.MaxBytesReader(w, r.Body, maxUpload) - err := fileupload.ProcessNewChunk(w, r, false) + err, _ := fileupload.ProcessNewChunk(w, r, false, "") responseError(w, err) } @@ -873,14 +973,46 @@ func downloadFileWithNameInUrl(w http.ResponseWriter, r *http.Request) { // Handling of /downloadFile // Outputs the file to the user and reduces the download remaining count for the file func downloadFile(w http.ResponseWriter, r *http.Request) { - id := queryUrl(w, r, "error") + id := queryUrl(w, r, "id", "error") serveFile(id, true, w, r) } +// Handling of /downloadPresigned +// Outputs the file to the user and reduces the download remaining count for the file, if requested +func downloadPresigned(w http.ResponseWriter, r *http.Request) { + addNoCacheHeader(w) + presignKey, ok := r.URL.Query()["key"] + if !ok { + responseError(w, storage.ErrorInvalidPresign) + return + } + presign, ok := database.GetPresignedUrl(presignKey[0]) + if !ok || presign.Expiry < time.Now().Unix() { + responseError(w, storage.ErrorInvalidPresign) + return + } + files := make([]models.File, 0) + for _, file := range presign.FileIds { + storedFile, ok := storage.GetFile(file) + if !ok { + responseError(w, storage.ErrorFileNotFound) + return + } + files = append(files, storedFile) + } + database.DeletePresignedUrl(presign.Id) + + if len(files) == 1 { + storage.ServeFile(files[0], w, r, true, false, true) + return + } + storage.ServeFilesAsZip(files, presign.Filename, w, r) +} + func serveFile(id string, isRootUrl bool, w http.ResponseWriter, r *http.Request) { addNoCacheHeader(w) savedFile, ok := storage.GetFile(id) - if !ok { + if !ok || savedFile.IsFileRequest() { if isRootUrl { redirect(w, "error") } else { @@ -898,7 +1030,7 @@ func serveFile(id string, isRootUrl bool, w http.ResponseWriter, r *http.Request return } } - storage.ServeFile(savedFile, w, r, true) + storage.ServeFile(savedFile, w, r, true, true, false) } func requireLogin(next http.HandlerFunc, isUiCall, isPwChangeView bool) http.HandlerFunc { @@ -975,6 +1107,7 @@ type genericView struct { RedirectUrl string ErrorMessage string ErrorId int + ErrorCardWidth int MinPasswordLength int CustomContent customStatic } @@ -990,3 +1123,14 @@ type oauthErrorView struct { ErrorProvidedMessage string CustomContent customStatic } + +// A view containing parameters for the public upload page +type publicUploadView struct { + IsAdminView bool + IsDownloadView bool + PublicName string + ChunkSize int + MaxServerSize int + CustomContent customStatic + FileRequest *models.FileRequest +} diff --git a/internal/webserver/Webserver_test.go b/internal/webserver/Webserver_test.go index 3b794f2..042f01c 100644 --- a/internal/webserver/Webserver_test.go +++ b/internal/webserver/Webserver_test.go @@ -6,6 +6,13 @@ import ( "bufio" "encoding/json" "errors" + "html/template" + "net/http" + "os" + "strings" + "testing" + "time" + "github.com/forceu/gokapi/internal/configuration" "github.com/forceu/gokapi/internal/configuration/database" "github.com/forceu/gokapi/internal/models" @@ -13,12 +20,6 @@ import ( "github.com/forceu/gokapi/internal/test" "github.com/forceu/gokapi/internal/test/testconfiguration" "github.com/forceu/gokapi/internal/webserver/authentication" - "html/template" - "net/http" - "os" - "strings" - "testing" - "time" ) func TestMain(m *testing.M) { @@ -236,17 +237,22 @@ func TestError(t *testing.T) { t.Parallel() test.HttpPageResult(t, test.HttpTestConfig{ Url: "http://localhost:53843/error", - RequiredContent: []string{"Sorry, this file cannot be found"}, + RequiredContent: []string{"The link may have expired or the file has been downloaded too many times"}, IsHtml: true, }) test.HttpPageResult(t, test.HttpTestConfig{ Url: "http://localhost:53843/error?e2e", - RequiredContent: []string{"This file is encrypted and no key has been passed"}, + RequiredContent: []string{"This file is encrypted, but no key was provided"}, IsHtml: true, }) test.HttpPageResult(t, test.HttpTestConfig{ Url: "http://localhost:53843/error?key", - RequiredContent: []string{"This file is encrypted and an incorrect key has been passed"}, + RequiredContent: []string{"This file is encrypted, but the provided key is incorrect"}, + IsHtml: true, + }) + test.HttpPageResult(t, test.HttpTestConfig{ + Url: "http://localhost:53843/error?fr", + RequiredContent: []string{"The file limit for this upload request has been reached"}, IsHtml: true, }) } @@ -586,7 +592,7 @@ func TestProcessApi(t *testing.T) { // Not authorised test.HttpPageResult(t, test.HttpTestConfig{ Url: "http://127.0.0.1:53843/api/files/list", - RequiredContent: []string{"{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}"}, + RequiredContent: []string{`{"Result":"error","ErrorMessage":"Unauthorized","ErrorCode":2}`}, ExcludedContent: []string{"smallfile2"}, ResultCode: 401, Cookies: []test.Cookie{{ @@ -596,7 +602,7 @@ func TestProcessApi(t *testing.T) { }) test.HttpPageResult(t, test.HttpTestConfig{ Url: "http://127.0.0.1:53843/api/files/list", - RequiredContent: []string{"{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}"}, + RequiredContent: []string{`{"Result":"error","ErrorMessage":"Unauthorized","ErrorCode":2}`}, ExcludedContent: []string{"smallfile2"}, ResultCode: 401, Headers: []test.Header{{"apikey", "invalid"}}, @@ -605,7 +611,7 @@ func TestProcessApi(t *testing.T) { // Valid session does not grant API access test.HttpPageResult(t, test.HttpTestConfig{ Url: "http://127.0.0.1:53843/api/files/list", - RequiredContent: []string{"{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}"}, + RequiredContent: []string{`{"Result":"error","ErrorMessage":"Unauthorized","ErrorCode":2}`}, ExcludedContent: []string{"smallfile2"}, ResultCode: 401, Cookies: []test.Cookie{{ diff --git a/internal/webserver/api/Api.go b/internal/webserver/api/Api.go index 3f3d537..d63d44b 100644 --- a/internal/webserver/api/Api.go +++ b/internal/webserver/api/Api.go @@ -15,12 +15,17 @@ import ( "github.com/forceu/gokapi/internal/logging" "github.com/forceu/gokapi/internal/models" "github.com/forceu/gokapi/internal/storage" + "github.com/forceu/gokapi/internal/storage/chunking" + "github.com/forceu/gokapi/internal/storage/chunking/chunkreservation" + "github.com/forceu/gokapi/internal/storage/filerequest" + "github.com/forceu/gokapi/internal/storage/filerequest/ratelimiter" + "github.com/forceu/gokapi/internal/webserver/api/errorcodes" + "github.com/forceu/gokapi/internal/webserver/authentication/users" "github.com/forceu/gokapi/internal/webserver/fileupload" ) const LengthPublicId = 35 const LengthApiKey = 30 -const minLengthUser = 2 // Process parses the request and executes the API call or returns an error message to the sender func Process(w http.ResponseWriter, r *http.Request) { @@ -30,17 +35,17 @@ func Process(w http.ResponseWriter, r *http.Request) { routing, ok := getRouting(requestUrl) if !ok { - sendError(w, http.StatusBadRequest, "Invalid request") + sendError(w, http.StatusBadRequest, errorcodes.InvalidUrl, "Invalid request") return } var user models.User user, ok = isAuthorisedForApi(r, routing) if !ok { - sendError(w, http.StatusUnauthorized, "Unauthorized") + sendError(w, http.StatusUnauthorized, errorcodes.InvalidApiKey, "Unauthorized") return } - if routing.AdminOnly && (user.UserLevel != models.UserLevelAdmin && user.UserLevel != models.UserLevelSuperAdmin) { - sendError(w, http.StatusUnauthorized, "Unauthorized") + if routing.AdminOnly && !user.IsAdmin() { + sendError(w, http.StatusUnauthorized, errorcodes.AdminOnly, "Unauthorized") return } if routing.RequestParser == nil { @@ -50,7 +55,7 @@ func Process(w http.ResponseWriter, r *http.Request) { parser := routing.RequestParser.New() err := parser.ParseRequest(r) if err != nil { - sendError(w, http.StatusBadRequest, err.Error()) + sendError(w, http.StatusBadRequest, errorcodes.CannotParse, err.Error()) return } routing.Continue(w, parser, user) @@ -67,11 +72,11 @@ func apiEditFile(w http.ResponseWriter, r requestParser, user models.User) { } file, ok := database.GetMetaDataById(request.Id) if !ok { - sendError(w, http.StatusNotFound, "Invalid file ID provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid file ID provided.") return } if file.UserId != user.Id && !user.HasPermission(models.UserPermEditOtherUploads) { - sendError(w, http.StatusUnauthorized, "No permission to edit file.") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to edit file.") return } if request.UnlimitedDownloads { @@ -108,17 +113,18 @@ func apiEditFile(w http.ResponseWriter, r requestParser, user models.User) { } // generateNewKey generates and saves a new API key -func generateNewKey(defaultPermissions bool, userId int, friendlyName string) models.ApiKey { +func generateNewKey(defaultPermissions bool, userId int, friendlyName, filerequstId string) models.ApiKey { if friendlyName == "" { friendlyName = "Unnamed key" } newKey := models.ApiKey{ - Id: helper.GenerateRandomString(LengthApiKey), - PublicId: helper.GenerateRandomString(LengthPublicId), - FriendlyName: friendlyName, - Permissions: models.ApiPermDefault, - IsSystemKey: false, - UserId: userId, + Id: helper.GenerateRandomString(LengthApiKey), + PublicId: helper.GenerateRandomString(LengthPublicId), + FriendlyName: friendlyName, + Permissions: models.ApiPermDefault, + IsSystemKey: false, + UserId: userId, + UploadRequestId: filerequstId, } if !defaultPermissions { newKey.Permissions = models.ApiPermNone @@ -134,11 +140,11 @@ func apiDeleteKey(w http.ResponseWriter, r requestParser, user models.User) { } apiKeyOwner, apiKey, ok := isValidKeyForEditing(request.KeyId) if !ok { - sendError(w, http.StatusNotFound, "Invalid key ID provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid key ID provided.") return } if apiKeyOwner.Id != user.Id && !user.HasPermission(models.UserPermManageApiKeys) { - sendError(w, http.StatusUnauthorized, "No permission to delete this API key") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to delete this API key") return } database.DeleteApiKey(apiKey.Id) @@ -151,28 +157,33 @@ func apiModifyApiKey(w http.ResponseWriter, r requestParser, user models.User) { } apiKeyOwner, apiKey, ok := isValidKeyForEditing(request.KeyId) if !ok { - sendError(w, http.StatusNotFound, "Invalid key ID provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid key ID provided.") return } if apiKeyOwner.Id != user.Id && !user.HasPermission(models.UserPermManageApiKeys) { - sendError(w, http.StatusUnauthorized, "No permission to delete this API key") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to delete this API key") return } switch request.Permission { case models.ApiPermReplace: if !apiKeyOwner.HasPermissionReplace() { - sendError(w, http.StatusUnauthorized, "Insufficient user permission for owner to set this API permission") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "Insufficient user permission for owner to set this API permission") return } case models.ApiPermManageUsers: if !apiKeyOwner.HasPermissionManageUsers() { - sendError(w, http.StatusUnauthorized, "Insufficient user permission for owner to set this API permission") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "Insufficient user permission for owner to set this API permission") return } case models.ApiPermManageLogs: if !apiKeyOwner.HasPermissionManageLogs() { - sendError(w, http.StatusUnauthorized, "Insufficient user permission for owner to set this API permission") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "Insufficient user permission for owner to set this API permission") + return + } + case models.ApiPermManageFileRequests: + if !apiKeyOwner.HasPermissionCreateFileRequests() { + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "Insufficient user permission for owner to set this API permission") return } default: @@ -203,7 +214,7 @@ func isValidKeyForEditing(apiKey string) (models.User, models.ApiKey, bool) { func isValidUserForEditing(w http.ResponseWriter, userId int) (models.User, bool) { user, ok := database.GetUser(userId) if !ok { - sendError(w, http.StatusNotFound, "Invalid user id provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid user id provided.") return models.User{}, false } return user, true @@ -214,7 +225,7 @@ func apiCreateApiKey(w http.ResponseWriter, r requestParser, user models.User) { if !ok { panic("invalid parameter passed") } - key := generateNewKey(request.BasicPermissions, user.Id, request.FriendlyName) + key := generateNewKey(request.BasicPermissions, user.Id, request.FriendlyName, "") output := models.ApiKeyOutput{ Result: "OK", Id: key.Id, @@ -230,23 +241,16 @@ func apiCreateUser(w http.ResponseWriter, r requestParser, user models.User) { if !ok { panic("invalid parameter passed") } - if len(request.Username) < minLengthUser { - sendError(w, http.StatusBadRequest, "Invalid username provided.") - return - } - _, ok = database.GetUserByName(request.Username) - if ok { - sendError(w, http.StatusConflict, "User already exists.") - return - } - newUser := models.User{ - Name: request.Username, - UserLevel: models.UserLevelUser, - } - database.SaveUser(newUser, true) - newUser, ok = database.GetUserByName(request.Username) - if !ok { - sendError(w, http.StatusInternalServerError, "Could not save user") + newUser, err := users.Create(request.Username) + if err != nil { + switch { + case errors.Is(err, users.ErrorNameToShort): + sendError(w, http.StatusBadRequest, errorcodes.NoPermission, "Invalid username provided.") + case errors.Is(err, users.ErrorUserExists): + sendError(w, http.StatusConflict, errorcodes.AlreadyExists, "User already exists.") + default: + sendError(w, http.StatusInternalServerError, errorcodes.InternalServer, err.Error()) + } return } logging.LogUserCreation(newUser, user) @@ -260,16 +264,16 @@ func apiChangeFriendlyName(w http.ResponseWriter, r requestParser, user models.U } ownerApiKey, apiKey, ok := isValidKeyForEditing(request.KeyId) if !ok { - sendError(w, http.StatusNotFound, "Invalid key ID provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid key ID provided.") return } if ownerApiKey.Id != user.Id && !user.HasPermission(models.UserPermManageApiKeys) { - sendError(w, http.StatusUnauthorized, "No permission to edit this key") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to edit this API key") return } err := renameApiKeyFriendlyName(apiKey.Id, request.FriendlyName) if err != nil { - sendError(w, http.StatusInternalServerError, err.Error()) + sendError(w, http.StatusInternalServerError, errorcodes.InternalServer, err.Error()) return } } @@ -296,11 +300,11 @@ func apiDeleteFile(w http.ResponseWriter, r requestParser, user models.User) { } file, ok := database.GetMetaDataById(request.Id) if !ok { - sendError(w, http.StatusNotFound, "Invalid file ID provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid file ID provided.") return } if file.UserId != user.Id && !user.HasPermission(models.UserPermDeleteOtherUploads) { - sendError(w, http.StatusUnauthorized, "No permission to delete this file") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to delete this file") return } logging.LogDelete(file, user) @@ -318,16 +322,16 @@ func apiRestoreFile(w http.ResponseWriter, r requestParser, user models.User) { } file, ok := database.GetMetaDataById(request.Id) if !ok { - sendError(w, http.StatusNotFound, "Invalid file ID provided or file has already been deleted.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid file ID provided or file has already been deleted.") return } if file.UserId != user.Id && !user.HasPermission(models.UserPermDeleteOtherUploads) { - sendError(w, http.StatusUnauthorized, "No permission to restore this file") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to restore this file") return } file, ok = storage.CancelPendingFileDeletion(file.Id) if !ok { - sendError(w, http.StatusNotFound, "Invalid file ID provided or file has already been deleted.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid file ID provided or file has already been deleted.") return } logging.LogRestore(file, user) @@ -339,18 +343,109 @@ func apiChunkAdd(w http.ResponseWriter, r requestParser, _ models.User) { if !ok { panic("invalid parameter passed") } - maxUpload := int64(configuration.Get().MaxFileSizeMB) * 1024 * 1024 - if request.Request.ContentLength > maxUpload { - sendError(w, http.StatusBadRequest, storage.ErrorFileTooLarge.Error()) - return + statusCode, errCode, errString := processNewChunk(w, request, configuration.Get().MaxFileSizeMB, "") + if statusCode != http.StatusOK { + sendError(w, statusCode, errCode, errString) } +} - request.Request.Body = http.MaxBytesReader(w, request.Request.Body, maxUpload) - err := fileupload.ProcessNewChunk(w, request.Request, true) - if err != nil { - sendError(w, http.StatusBadRequest, err.Error()) +func apiChunkReserve(w http.ResponseWriter, r requestParser, _ models.User) { + request, ok := r.(*paramChunkReserve) + if !ok { + panic("invalid parameter passed") + } + fileRequest, ok, status, errorCode, errorMsg := checkFileRequestAndApiKey(request.Id, request.ApiKey) + if !ok { + sendError(w, status, errorCode, errorMsg) return } + if fileRequest.FilesRemaining() <= 0 && !fileRequest.IsUnlimitedFiles() { + sendError(w, http.StatusBadRequest, errorcodes.CannotUploadMoreFiles, "No more files can be uploaded for this file request") + return + } + if fileRequest.IsUnlimitedFiles() && !ratelimiter.IsAllowedNewUuid(fileRequest.Id) { + sendError(w, http.StatusTooManyRequests, errorcodes.RateLimited, "Too many reservations for this file request. Please wait a few seconds before reserving a new uuid.") + return + } + uuid := chunkreservation.New(fileRequest.Id) + result, err := json.Marshal(struct { + Result string `json:"Result"` + Uuid string `json:"Uuid"` + }{"OK", uuid}) + helper.Check(err) + _, _ = w.Write(result) +} + +func apiChunkUnreserve(w http.ResponseWriter, r requestParser, _ models.User) { + request, ok := r.(*paramChunkUnreserve) + if !ok { + panic("invalid parameter passed") + } + fileRequest, ok, status, errorCode, errorMsg := checkFileRequestAndApiKey(request.Id, request.ApiKey) + if !ok { + sendError(w, status, errorCode, errorMsg) + return + } + chunkreservation.SetComplete(fileRequest.Id, request.Uuid) + _ = chunking.DeleteChunk(request.Uuid) + _, _ = w.Write([]byte(`{"Result":"OK"}`)) +} + +func apiChunkUploadRequestAdd(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramChunkUploadRequestAdd) + if !ok { + panic("invalid parameter passed") + } + fileRequest, ok, status, errorCode, errorMsg := checkFileRequestAndApiKey(request.FileRequestId, request.ApiKey) + if !ok { + sendError(w, status, errorCode, errorMsg) + return + } + maxUpload := configuration.Get().MaxFileSizeMB + if !user.IsAdmin() && configuration.GetEnvironment().MaxSizeGuestUploadMb != 0 { + maxUpload = min(maxUpload, configuration.GetEnvironment().MaxSizeGuestUploadMb) + } + if !fileRequest.IsUnlimitedSize() { + maxUpload = min(maxUpload, fileRequest.MaxSize) + } + statusCode, errorCode, errString := processNewChunk(w, request, maxUpload, fileRequest.Id) + if statusCode != http.StatusOK { + sendError(w, statusCode, errorCode, errString) + } +} + +func checkFileRequestAndApiKey(fileRequestId, apiKey string) (models.FileRequest, bool, int, int, string) { + fileRequest, ok := filerequest.Get(fileRequestId) + if !ok { + return models.FileRequest{}, false, http.StatusNotFound, errorcodes.NotFound, "FileRequest does not exist with the given ID" + } + if fileRequest.ApiKey != apiKey { + return models.FileRequest{}, false, http.StatusUnauthorized, errorcodes.InvalidApiKey, "Invalid API key" + } + if !fileRequest.IsUnlimitedTime() && fileRequest.Expiry < time.Now().Unix() { + return models.FileRequest{}, false, http.StatusUnauthorized, errorcodes.RequestExpired, "Filerequest has expired" + } + if !fileRequest.IsUnlimitedFiles() && fileRequest.UploadedFiles >= fileRequest.MaxFiles { + return models.FileRequest{}, false, http.StatusUnauthorized, errorcodes.CannotUploadMoreFiles, "Max file count has already been reached for this file request" + } + return fileRequest, true, 0, 0, "" +} + +type chunkParams interface { + GetRequest() *http.Request +} + +func processNewChunk(w http.ResponseWriter, request chunkParams, maxFileSizeMb int, filerequestId string) (int, int, string) { + maxUpload := int64(maxFileSizeMb) * 1024 * 1024 + if request.GetRequest().ContentLength > maxUpload { + return http.StatusBadRequest, errorcodes.FileTooLarge, storage.ErrorFileTooLarge.Error() + } + request.GetRequest().Body = http.MaxBytesReader(w, request.GetRequest().Body, maxUpload) + err, errCode := fileupload.ProcessNewChunk(w, request.GetRequest(), true, filerequestId) + if err != nil { + return http.StatusBadRequest, errCode, err.Error() + } + return http.StatusOK, 0, "" } func apiChunkComplete(w http.ResponseWriter, r requestParser, user models.User) { @@ -358,31 +453,57 @@ func apiChunkComplete(w http.ResponseWriter, r requestParser, user models.User) if !ok { panic("invalid parameter passed") } - if request.IsNonBlocking { - go doBlockingPartCompleteChunk(nil, request, user) - _, _ = io.WriteString(w, "{\"result\":\"OK\"}") - return - } - doBlockingPartCompleteChunk(w, request, user) -} - -func doBlockingPartCompleteChunk(w http.ResponseWriter, request *paramChunkComplete, user models.User) { - uploadRequest := fileupload.CreateUploadConfig(request.AllowedDownloads, + uploadParams := fileupload.CreateUploadConfig(request.AllowedDownloads, request.ExpiryDays, request.Password, request.UnlimitedTime, request.UnlimitedDownloads, request.IsE2E, - request.FileSize) - file, err := fileupload.CompleteChunk(request.Uuid, request.FileHeader, user.Id, uploadRequest) - if err != nil { - sendError(w, http.StatusBadRequest, err.Error()) + request.FileSize, + "") + if request.IsNonBlocking { + go doBlockingPartCompleteChunk(nil, request.Uuid, request.FileHeader, user, uploadParams) + _, _ = io.WriteString(w, "{\"result\":\"OK\"}") return } - logging.LogUpload(file, user) + doBlockingPartCompleteChunk(w, request.Uuid, request.FileHeader, user, uploadParams) +} + +func doBlockingPartCompleteChunk(w http.ResponseWriter, uuid string, fileHeader chunking.FileHeader, user models.User, uploadParameters models.UploadParameters) { + file, err := fileupload.CompleteChunk(uuid, fileHeader, user.Id, uploadParameters) + if err != nil { + sendError(w, http.StatusBadRequest, errorcodes.UnspecifiedError, err.Error()) + return + } + if uploadParameters.FileRequestId != "" { + chunkreservation.SetComplete(uploadParameters.FileRequestId, uuid) + } + fr, _ := filerequest.Get(uploadParameters.FileRequestId) + logging.LogUpload(file, user, fr) outputFileJson(w, file) } +func apiChunkUploadRequestComplete(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramChunkUploadRequestComplete) + if !ok { + panic("invalid parameter passed") + } + fileRequest, ok, status, errorCode, errorMsg := checkFileRequestAndApiKey(request.FileRequestId, request.ApiKey) + if !ok { + sendError(w, status, errorCode, errorMsg) + return + } + uploadParams := fileupload.CreateUploadConfig(0, + 0, "", true, true, + false, request.FileSize, fileRequest.Id) + if request.IsNonBlocking { + go doBlockingPartCompleteChunk(nil, request.Uuid, request.FileHeader, user, uploadParams) + _, _ = io.WriteString(w, "{\"result\":\"OK\"}") + return + } + doBlockingPartCompleteChunk(w, request.Uuid, request.FileHeader, user, uploadParams) +} + func apiVersionInfo(w http.ResponseWriter, _ requestParser, _ models.User) { type versionInfo struct { Version string @@ -408,18 +529,25 @@ func apiConfigInfo(w http.ResponseWriter, _ requestParser, _ models.User) { _, _ = w.Write(result) } -func apiList(w http.ResponseWriter, _ requestParser, user models.User) { - validFiles := getFilesForUser(user) +func apiList(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramFilesListAll) + if !ok { + panic("invalid parameter passed") + } + validFiles := getFilesForUser(user, request.ShowFileRequests) result, err := json.Marshal(validFiles) helper.Check(err) _, _ = w.Write(result) } -func getFilesForUser(user models.User) []models.FileApiOutput { +func getFilesForUser(user models.User, includeUploadRequests bool) []models.FileApiOutput { var validFiles []models.FileApiOutput timeNow := time.Now().Unix() config := configuration.Get() for _, element := range database.GetAllMetadata() { + if !includeUploadRequests && element.IsFileRequest() { + continue + } if element.UserId == user.Id || user.HasPermission(models.UserPermListOtherUploads) { if !storage.IsExpiredFile(element, timeNow) { file, err := element.ToFileApiOutput(config.ServerUrl, config.IncludeFilename) @@ -436,14 +564,13 @@ func apiListSingle(w http.ResponseWriter, r requestParser, user models.User) { if !ok { panic("invalid parameter passed") } - id := strings.TrimPrefix(request.RequestUrl, "/files/list/") - file, ok := storage.GetFile(id) + file, ok := storage.GetFile(request.Id) if !ok { - sendError(w, http.StatusNotFound, "File not found") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "File not found") return } if file.UserId != user.Id && !user.HasPermission(models.UserPermListOtherUploads) { - sendError(w, http.StatusUnauthorized, "No permission to view file") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to view file") return } config := configuration.Get() @@ -454,6 +581,77 @@ func apiListSingle(w http.ResponseWriter, r requestParser, user models.User) { _, _ = w.Write(result) } +func apiDownloadSingle(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramFilesDownloadSingle) + if !ok { + panic("invalid parameter passed") + } + file, statusCode, errCode, errMessage := checkDownloadAllowed(request.Id, user) + if statusCode != 0 { + sendError(w, statusCode, errCode, errMessage) + return + } + if !request.PresignUrl { + storage.ServeFile(file, w, request.WebRequest, true, request.IncreaseCounter, true) + return + } + createAndOutputPresignedUrl([]string{file.Id}, w, "") +} + +func apiDownloadZip(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramFilesDownloadZip) + if !ok { + panic("invalid parameter passed") + } + requestedFiles := make([]models.File, 0) + requestedFileIds := make([]string, 0) + for _, fileId := range request.Ids { + file, statusCode, errCode, errMessage := checkDownloadAllowed(fileId, user) + if statusCode != 0 { + sendError(w, statusCode, errCode, errMessage) + return + } + requestedFiles = append(requestedFiles, file) + requestedFileIds = append(requestedFileIds, file.Id) + } + if !request.PresignUrl { + storage.ServeFilesAsZip(requestedFiles, request.Filename, w, request.WebRequest) + return + } + createAndOutputPresignedUrl(requestedFileIds, w, request.Filename) +} + +func checkDownloadAllowed(fileId string, user models.User) (models.File, int, int, string) { + file, ok := storage.GetFile(fileId) + if !ok { + return models.File{}, http.StatusNotFound, errorcodes.NotFound, "file not found" + } + if file.UserId != user.Id && !user.HasPermission(models.UserPermListOtherUploads) { + return models.File{}, http.StatusUnauthorized, errorcodes.NoPermission, "no permission to download file" + } + if file.Encryption.IsEndToEndEncrypted { + return models.File{}, http.StatusBadRequest, errorcodes.EndToEndNotSupported, "End-to-end encrypted files cannot be downloaded" + } + return file, 0, 0, "" +} + +func createAndOutputPresignedUrl(ids []string, w http.ResponseWriter, filename string) { + presignUrl := models.Presign{ + Id: helper.GenerateRandomString(60), + FileIds: ids, + Expiry: time.Now().Add(time.Second * 30).Unix(), + Filename: filename, + } + database.SavePresignedUrl(presignUrl) + response := struct { + Result string `json:"Result"` + DownloadUrl string `json:"downloadUrl"` + }{"OK", configuration.Get().ServerUrl + "downloadPresigned?key=" + presignUrl.Id} + result, err := json.Marshal(response) + helper.Check(err) + _, _ = w.Write(result) +} + func apiUploadFile(w http.ResponseWriter, r requestParser, user models.User) { request, ok := r.(*paramFilesAdd) if !ok { @@ -461,14 +659,14 @@ func apiUploadFile(w http.ResponseWriter, r requestParser, user models.User) { } maxUpload := int64(configuration.Get().MaxFileSizeMB) * 1024 * 1024 if request.Request.ContentLength > maxUpload { - sendError(w, http.StatusBadRequest, storage.ErrorFileTooLarge.Error()) + sendError(w, http.StatusBadRequest, errorcodes.FileTooLarge, storage.ErrorFileTooLarge.Error()) return } request.Request.Body = http.MaxBytesReader(w, request.Request.Body, maxUpload) err := fileupload.ProcessCompleteFile(w, request.Request, user.Id, configuration.Get().MaxMemory) if err != nil { - sendError(w, http.StatusBadRequest, err.Error()) + sendError(w, http.StatusBadRequest, errorcodes.UnspecifiedError, err.Error()) return } } @@ -480,11 +678,11 @@ func apiDuplicateFile(w http.ResponseWriter, r requestParser, user models.User) } file, ok := storage.GetFile(request.Id) if !ok { - sendError(w, http.StatusNotFound, "Invalid id provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid id provided.") return } if file.UserId != user.Id && !user.HasPermission(models.UserPermListOtherUploads) { - sendError(w, http.StatusUnauthorized, "No permission to duplicate this file") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to duplicate this file") return } uploadConfig := fileupload.CreateUploadConfig(request.AllowedDownloads, @@ -493,11 +691,12 @@ func apiDuplicateFile(w http.ResponseWriter, r requestParser, user models.User) request.UnlimitedTime, request.UnlimitedDownloads, false, // is not being used by storage.DuplicateFile - 0) // is not being used by storage.DuplicateFile + 0, // is not being used by storage.DuplicateFile + "") uploadConfig.UserId = user.Id newFile, err := storage.DuplicateFile(file, request.RequestedChanges, request.FileName, uploadConfig) if err != nil { - sendError(w, http.StatusInternalServerError, err.Error()) + sendError(w, http.StatusInternalServerError, errorcodes.InternalServer, err.Error()) return } outputFileApiInfo(w, newFile) @@ -510,16 +709,16 @@ func apiChangeFileOwner(w http.ResponseWriter, r requestParser, user models.User } file, ok := storage.GetFile(request.Id) if !ok { - sendError(w, http.StatusNotFound, "Invalid id provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid id provided.") return } if !user.HasPermission(models.UserPermEditOtherUploads) { - sendError(w, http.StatusUnauthorized, "No permission to edit this file") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to edit this file") return } _, exists := database.GetUser(request.NewOwner) if !exists { - sendError(w, http.StatusBadRequest, "User does not exist") + sendError(w, http.StatusBadRequest, errorcodes.NotFound, "User does not exist") return } file.UserId = request.NewOwner @@ -534,21 +733,25 @@ func apiReplaceFile(w http.ResponseWriter, r requestParser, user models.User) { } fileOriginal, ok := storage.GetFile(request.Id) if !ok { - sendError(w, http.StatusNotFound, "Invalid id provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid id provided.") return } if fileOriginal.UserId != user.Id && !user.HasPermission(models.UserPermReplaceOtherUploads) { - sendError(w, http.StatusUnauthorized, "No permission to replace this file") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to replace this file") return } + if fileOriginal.IsFileRequest() { + sendError(w, http.StatusBadRequest, errorcodes.UnsupportedFile, "Cannot replace a file request upload") + return + } fileNewContent, ok := storage.GetFile(request.IdNewContent) if !ok { - sendError(w, http.StatusNotFound, "Invalid id provided.") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "Invalid id provided.") return } if fileNewContent.UserId != user.Id && !user.HasPermission(models.UserPermListOtherUploads) { - sendError(w, http.StatusUnauthorized, "No permission to duplicate this file") + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to duplicate this file") return } @@ -556,11 +759,11 @@ func apiReplaceFile(w http.ResponseWriter, r requestParser, user models.User) { if err != nil { switch { case errors.Is(err, storage.ErrorReplaceE2EFile): - sendError(w, http.StatusBadRequest, "End-to-End encrypted files cannot be replaced") + sendError(w, http.StatusBadRequest, errorcodes.EndToEndNotSupported, "End-to-End encrypted files cannot be replaced") case errors.Is(err, storage.ErrorFileNotFound): - sendError(w, http.StatusNotFound, "A file with such an ID could not be found") + sendError(w, http.StatusNotFound, errorcodes.NotFound, "A file with such an ID could not be found") default: - sendError(w, http.StatusBadRequest, err.Error()) + sendError(w, http.StatusBadRequest, errorcodes.InvalidUserInput, err.Error()) } return } @@ -595,11 +798,11 @@ func apiModifyUser(w http.ResponseWriter, r requestParser, user models.User) { return } if userEdit.IsSuperAdmin() { - sendError(w, http.StatusBadRequest, "Cannot modify super admin") + sendError(w, http.StatusBadRequest, errorcodes.ResourceCanNotBeEdited, "Cannot modify super admin") return } if userEdit.IsSameUser(user.Id) { - sendError(w, http.StatusBadRequest, "Cannot modify yourself") + sendError(w, http.StatusBadRequest, errorcodes.ResourceCanNotBeEdited, "Cannot modify yourself") return } logging.LogUserEdit(userEdit, user) @@ -628,11 +831,11 @@ func apiChangeUserRank(w http.ResponseWriter, r requestParser, user models.User) return } if userEdit.IsSameUser(user.Id) { - sendError(w, http.StatusBadRequest, "Cannot modify yourself") + sendError(w, http.StatusBadRequest, errorcodes.ResourceCanNotBeEdited, "Cannot modify yourself") return } if userEdit.IsSuperAdmin() { - sendError(w, http.StatusBadRequest, "Cannot modify super admin") + sendError(w, http.StatusBadRequest, errorcodes.ResourceCanNotBeEdited, "Cannot modify super admin") return } userEdit.UserLevel = request.NewRank @@ -646,7 +849,7 @@ func apiChangeUserRank(w http.ResponseWriter, r requestParser, user models.User) updateApiKeyPermsOnUserPermChange(userEdit.Id, models.UserPermReplaceUploads, false) updateApiKeyPermsOnUserPermChange(userEdit.Id, models.UserPermManageUsers, false) default: - sendError(w, http.StatusBadRequest, "invalid rank sent") + sendError(w, http.StatusBadRequest, errorcodes.InvalidUserInput, "invalid rank sent") return } logging.LogUserEdit(userEdit, user) @@ -662,6 +865,8 @@ func updateApiKeyPermsOnUserPermChange(userId int, userPerm models.UserPermissio affectedPermission = models.ApiPermReplace case models.UserPermManageLogs: affectedPermission = models.ApiPermManageLogs + case models.UserPermGuestUploads: + affectedPermission = models.ApiPermManageFileRequests default: return } @@ -691,17 +896,17 @@ func apiResetPassword(w http.ResponseWriter, r requestParser, user models.User) return } if userToEdit.IsSuperAdmin() { - sendError(w, http.StatusBadRequest, "Cannot reset pw of super admin") + sendError(w, http.StatusBadRequest, errorcodes.ResourceCanNotBeEdited, "Cannot reset password of super admin") return } if userToEdit.IsSameUser(user.Id) { - sendError(w, http.StatusBadRequest, "Cannot reset password of yourself") + sendError(w, http.StatusBadRequest, errorcodes.ResourceCanNotBeEdited, "Cannot reset password of yourself") return } userToEdit.ResetPassword = true password := "" if request.NewPassword { - password = helper.GenerateRandomString(configuration.Environment.MinLengthPassword + 2) + password = helper.GenerateRandomString(configuration.GetEnvironment().MinLengthPassword + 2) userToEdit.Password = configuration.HashPassword(password, false) } database.DeleteAllSessionsByUser(userToEdit.Id) @@ -719,15 +924,27 @@ func apiDeleteUser(w http.ResponseWriter, r requestParser, user models.User) { return } if userToDelete.IsSuperAdmin() { - sendError(w, http.StatusBadRequest, "Cannot delete super admin") + sendError(w, http.StatusBadRequest, errorcodes.ResourceCanNotBeEdited, "Cannot delete super admin") return } if userToDelete.IsSameUser(user.Id) { - sendError(w, http.StatusBadRequest, "Cannot delete yourself") + sendError(w, http.StatusBadRequest, errorcodes.ResourceCanNotBeEdited, "Cannot delete yourself") return } logging.LogUserDeletion(userToDelete, user) database.DeleteUser(userToDelete.Id) + + for _, fRequest := range database.GetAllFileRequests() { + if fRequest.UserId == userToDelete.Id { + if request.DeleteFiles { + filerequest.Delete(fRequest) + } else { + fRequest.UserId = user.Id + database.SaveFileRequest(fRequest) + } + } + } + for _, file := range database.GetAllMetadata() { if file.UserId == userToDelete.Id { if request.DeleteFiles { @@ -757,7 +974,8 @@ func apiLogsDelete(_ http.ResponseWriter, r requestParser, user models.User) { func apiE2eGet(w http.ResponseWriter, _ requestParser, user models.User) { info := database.GetEnd2EndInfo(user.Id) - files := getFilesForUser(user) + // If e2e is supported for upload requests at some point, this needs to be changed + files := getFilesForUser(user, false) ids := make([]string, len(files)) for i, file := range files { ids[i] = file.Id @@ -774,25 +992,170 @@ func apiE2eSet(w http.ResponseWriter, r requestParser, user models.User) { panic("invalid parameter passed") } database.SaveEnd2EndInfo(request.EncryptedInfo, user.Id) - _, _ = w.Write([]byte("\"result\":\"OK\"")) + _, _ = w.Write([]byte("{\"result\":\"OK\"}")) +} + +func apiURequestDelete(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramURequestDelete) + if !ok { + panic("invalid parameter passed") + } + + uploadRequest, ok := database.GetFileRequest(request.Id) + if !ok { + sendError(w, http.StatusNotFound, errorcodes.NotFound, "FileRequest does not exist with the given ID") + return + } + if uploadRequest.UserId != user.Id && !user.HasPermission(models.UserPermDeleteOtherUploads) { + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to delete this upload request") + return + } + filerequest.Delete(uploadRequest) + logging.LogDeleteFileRequest(uploadRequest, user) + _, _ = w.Write([]byte("{\"result\":\"OK\"}")) +} + +func isUserAllowedUnlimited(request *paramURequestSave, isNewRequest bool, user models.User) bool { + if user.IsAdmin() { + return true + } + isServerLimitMaxSize := configuration.GetEnvironment().MaxSizeGuestUploadMb != 0 + isServerLimitMaxFiles := configuration.GetEnvironment().MaxFilesGuestUpload != 0 + if isServerLimitMaxSize { + if (request.IsMaxSizeSet || isNewRequest) && + (request.MaxSizeMb == 0 || request.MaxSizeMb > configuration.GetEnvironment().MaxSizeGuestUploadMb) { + return false + } + } + if isServerLimitMaxFiles { + if (request.IsMaxFilesSet || isNewRequest) && + (request.MaxFiles == 0 || request.MaxFiles > configuration.GetEnvironment().MaxFilesGuestUpload) { + return false + } + } + + return true +} + +func apiURequestSave(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramURequestSave) + if !ok { + panic("invalid parameter passed") + } + uploadRequest := models.FileRequest{} + isNewRequest := request.Id == "" + + if !isUserAllowedUnlimited(request, isNewRequest, user) { + sendError(w, http.StatusBadRequest, errorcodes.AdminOnly, "Only admin users can create requests with unlimited size / file count"+ + " or values larger than the server's max size / file count") + return + } + + if !isNewRequest { + uploadRequest, ok = database.GetFileRequest(request.Id) + if !ok { + sendError(w, http.StatusNotFound, errorcodes.NotFound, "FileRequest does not exist with the given ID") + return + } + if uploadRequest.UserId != user.Id && !user.HasPermission(models.UserPermEditOtherUploads) { + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to edit this upload request") + return + } + } else { + uploadRequest = filerequest.New(user) + apiKey := generateNewKey(false, user.Id, "File Request Public Access", uploadRequest.Id) + uploadRequest.ApiKey = apiKey.Id + } + + if request.Name == "" { + if request.IsNameSet || uploadRequest.Name == "" { + uploadRequest.Name = "Unnamed Request" + } + } else { + uploadRequest.Name = request.Name + } + if request.IsExpirySet { + uploadRequest.Expiry = request.Expiry + } + if request.IsMaxFilesSet { + uploadRequest.MaxFiles = request.MaxFiles + } + if request.IsMaxSizeSet { + uploadRequest.MaxSize = request.MaxSizeMb + } + if request.IsNotesSet { + uploadRequest.Notes = request.Notes + } + database.SaveFileRequest(uploadRequest) + uploadRequest, ok = filerequest.Get(uploadRequest.Id) + if isNewRequest { + logging.LogCreateFileRequest(uploadRequest, user) + } else { + logging.LogEditFileRequest(uploadRequest, user) + } + result, err := json.Marshal(uploadRequest) + helper.Check(err) + _, _ = w.Write(result) +} + +func apiUploadRequestList(w http.ResponseWriter, _ requestParser, user models.User) { + userRequests := make([]models.FileRequest, 0) + for _, request := range filerequest.GetAll() { + if request.UserId == user.Id || user.HasPermission(models.UserPermListOtherUploads) { + userRequests = append(userRequests, request) + } + } + result, err := json.Marshal(userRequests) + helper.Check(err) + _, _ = w.Write(result) +} + +func apiUploadRequestListSingle(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramURequestListSingle) + if !ok { + panic("invalid parameter passed") + } + + uploadRequest, ok := filerequest.Get(request.Id) + if !ok { + sendError(w, http.StatusNotFound, errorcodes.NotFound, "FileRequest does not exist with the given ID") + return + } + if uploadRequest.UserId != user.Id && !user.HasPermission(models.UserPermDeleteOtherUploads) { + sendError(w, http.StatusUnauthorized, errorcodes.NoPermission, "No permission to delete this upload request") + return + } + result, err := json.Marshal(uploadRequest) + helper.Check(err) + _, _ = w.Write(result) } func isAuthorisedForApi(r *http.Request, routing apiRoute) (models.User, bool) { - apiKey := r.Header.Get("apikey") - user, _, ok := isValidApiKey(apiKey, true, routing.ApiPerm) + keyId := r.Header.Get("apikey") + user, apiKey, ok := isValidApiKey(keyId, true, routing.ApiPerm) if !ok { return models.User{}, false } + // Returns false if a public upload key is used for non-public api call or vice versa + if routing.IsFileRequestApi != apiKey.IsUploadRequestKey() { + return models.User{}, false + } return user, true } -// Probably from new API permission system -func sendError(w http.ResponseWriter, errorInt int, errorMessage string) { +func sendError(w http.ResponseWriter, statusCode, errorCode int, errorMessage string) { if w == nil { return } - w.WriteHeader(errorInt) - _, _ = w.Write([]byte("{\"Result\":\"error\",\"ErrorMessage\":\"" + errorMessage + "\"}")) + w.WriteHeader(statusCode) + output := struct { + Result string `json:"Result"` + Message string `json:"ErrorMessage"` + Code int `json:"ErrorCode"` + }{Result: "error", Message: errorMessage, Code: errorCode} + outputBytes, err := json.Marshal(output) + helper.Check(err) + _, _ = w.Write(outputBytes) } // publicKeyToApiKey tries to convert a (possible) public key to a private key diff --git a/internal/webserver/api/Api_test.go b/internal/webserver/api/Api_test.go index 74037a9..763bb03 100644 --- a/internal/webserver/api/Api_test.go +++ b/internal/webserver/api/Api_test.go @@ -73,14 +73,14 @@ func generateTestData() { Id: idApiKeyAdmin, PublicId: idApiKeyAdmin, FriendlyName: "Admin", - Permissions: models.ApiPermAll, + Permissions: models.ApiPermNone, UserId: idAdmin, }) database.SaveApiKey(models.ApiKey{ Id: idApiKeySuperAdmin, PublicId: idPublicApiKeySuperAdmin, FriendlyName: "SuperAdmin", - Permissions: models.ApiPermAll, + Permissions: models.ApiPermNone, UserId: idSuperAdmin, }) database.SaveMetaData(models.File{ @@ -120,24 +120,23 @@ func getRecorderWithBody(url, apikey, method string, headers []test.Header, body } func testAuthorisation(t *testing.T, url string, requiredPermission models.ApiPermission) models.ApiKey { - t.Helper() w, r := getRecorder(url, "", []test.Header{{}}) Process(w, r) test.IsEqualBool(t, w.Code != 200, true) - test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"Unauthorized"}`) + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"Unauthorized","ErrorCode":2}`) w, r = getRecorder(url, "invalid", []test.Header{{}}) Process(w, r) test.IsEqualBool(t, w.Code != 200, true) - test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"Unauthorized"}`) + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"Unauthorized","ErrorCode":2}`) - newApiKeyUser := generateNewKey(false, idUser, "") + newApiKeyUser := generateNewKey(false, idUser, "", "") w, r = getRecorder(url, newApiKeyUser.Id, []test.Header{{}}) Process(w, r) test.IsEqualBool(t, w.Code != 200, true) - test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"Unauthorized"}`) + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"Unauthorized","ErrorCode":2}`) - for _, permission := range getAvailableApiPermissions(t) { + for _, permission := range getAvailableApiPermissions() { if permission == requiredPermission { continue } @@ -145,16 +144,16 @@ func testAuthorisation(t *testing.T, url string, requiredPermission models.ApiPe w, r = getRecorder(url, newApiKeyUser.Id, []test.Header{{}}) Process(w, r) test.IsEqualBool(t, w.Code != 200, true) - test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"Unauthorized"}`) + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"Unauthorized","ErrorCode":2}`) removePermissionApikey(t, newApiKeyUser.Id, permission) } - newApiKeyUser.Permissions = models.ApiPermAll + newApiKeyUser.Permissions = getPermissionAll() newApiKeyUser.RemovePermission(requiredPermission) database.SaveApiKey(newApiKeyUser) w, r = getRecorder(url, newApiKeyUser.Id, []test.Header{{}}) Process(w, r) test.IsEqualBool(t, w.Code != 200, true) - test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"Unauthorized"}`) + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"Unauthorized","ErrorCode":2}`) newApiKeyUser.Permissions = models.ApiPermNone newApiKeyUser.GrantPermission(requiredPermission) database.SaveApiKey(newApiKeyUser) @@ -162,9 +161,10 @@ func testAuthorisation(t *testing.T, url string, requiredPermission models.ApiPe } type invalidParameterValue struct { - Value string - ErrorMessage string - StatusCode int + Value string + ErrorMessage string + ErrorMessages []string + StatusCode int } func testInvalidParameters(t *testing.T, url, apiKey string, validHeaders []test.Header, headerName string, invalidValues []invalidParameterValue) { @@ -179,12 +179,16 @@ func testInvalidParameters(t *testing.T, url, apiKey string, validHeaders []test w, r := getRecorderWithBody(url, apiKey, "GET", headers, nil) Process(w, r) test.IsEqualInt(t, w.Code, invalidHeader.StatusCode) - test.ResponseBodyContains(t, w, invalidHeader.ErrorMessage) + if len(invalidHeader.ErrorMessages) > 0 { + test.ResponseBodyIsWithAlternate(t, w, invalidHeader.ErrorMessages) + } else { + test.ResponseBodyIs(t, w, invalidHeader.ErrorMessage) + } if invalidHeader.Value == "" { w, r = getRecorder(url, apiKey, validHeaders) Process(w, r) test.IsEqualInt(t, w.Code, invalidHeader.StatusCode) - test.ResponseBodyContains(t, w, invalidHeader.ErrorMessage) + test.ResponseBodyIs(t, w, invalidHeader.ErrorMessage) } } } @@ -196,28 +200,32 @@ func testInvalidUserId(t *testing.T, url, apiKey string, validHeaders []test.Hea var invalidParameter = []invalidParameterValue{ { Value: "", - ErrorMessage: `{"Result":"error","ErrorMessage":"header userid is required"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"header userid is required","ErrorCode":4}`, StatusCode: 400, }, { Value: strconv.Itoa(idInvalidUser), - ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid user id provided."}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid user id provided.","ErrorCode":5}`, StatusCode: 404, }, { Value: "invalid", - ErrorMessage: `{"Result":"error","ErrorMessage":"invalid value in header userid supplied"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"invalid value in header userid supplied","ErrorCode":4}`, StatusCode: 400, }, { - Value: strconv.Itoa(idUser), - ErrorMessage: `{"Result":"error","ErrorMessage":"Cannot`, - StatusCode: 400, + Value: strconv.Itoa(idUser), + ErrorMessages: []string{`{"Result":"error","ErrorMessage":"Cannot modify yourself","ErrorCode":19}`, + `{"Result":"error","ErrorMessage":"Cannot delete yourself","ErrorCode":19}`, + `{"Result":"error","ErrorMessage":"Cannot reset password of yourself","ErrorCode":19}`}, + StatusCode: 400, }, { - Value: strconv.Itoa(idSuperAdmin), - ErrorMessage: `{"Result":"error","ErrorMessage":"Cannot`, - StatusCode: 400, + Value: strconv.Itoa(idSuperAdmin), + ErrorMessages: []string{`{"Result":"error","ErrorMessage":"Cannot modify super admin","ErrorCode":19}`, + `{"Result":"error","ErrorMessage":"Cannot delete super admin","ErrorCode":19}`, + `{"Result":"error","ErrorMessage":"Cannot reset password of super admin","ErrorCode":19}`}, + StatusCode: 400, }, } testInvalidParameters(t, url, apiKey, validHeaders, headerUserId, invalidParameter) @@ -230,28 +238,31 @@ func testInvalidApiKey(t *testing.T, url, apiKey string, validHeaders []test.Hea var invalidParameter = []invalidParameterValue{ { Value: "", - ErrorMessage: `{"Result":"error","ErrorMessage":"header targetKey is required"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"header targetKey is required","ErrorCode":4}`, StatusCode: 400, }, { Value: "invalid", - ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid key ID provided."}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid key ID provided.","ErrorCode":5}`, StatusCode: 404, }, { - Value: idApiKeySuperAdmin, - ErrorMessage: `{"Result":"error","ErrorMessage":"No permission to `, - StatusCode: 401, + Value: idApiKeySuperAdmin, + ErrorMessages: []string{`{"Result":"error","ErrorMessage":"No permission to delete this API key","ErrorCode":6}`, + `{"Result":"error","ErrorMessage":"No permission to edit this API key","ErrorCode":6}`}, + StatusCode: 401, }, { - Value: idPublicApiKeySuperAdmin, - ErrorMessage: `{"Result":"error","ErrorMessage":"No permission to `, - StatusCode: 401, + Value: idPublicApiKeySuperAdmin, + ErrorMessages: []string{`{"Result":"error","ErrorMessage":"No permission to delete this API key","ErrorCode":6}`, + `{"Result":"error","ErrorMessage":"No permission to edit this API key","ErrorCode":6}`}, + StatusCode: 401, }, { - Value: idApiKeyAdmin, - ErrorMessage: `{"Result":"error","ErrorMessage":"No permission to `, - StatusCode: 401, + Value: idApiKeyAdmin, + ErrorMessages: []string{`{"Result":"error","ErrorMessage":"No permission to delete this API key","ErrorCode":6}`, + `{"Result":"error","ErrorMessage":"No permission to edit this API key","ErrorCode":6}`}, + StatusCode: 401, }, } testInvalidParameters(t, url, apiKey, validHeaders, headerApiKey, invalidParameter) @@ -270,17 +281,17 @@ func testInvalidFileId(t *testing.T, url, apiKey string, isReplacingCall bool) { var invalidParameter = []invalidParameterValue{ { Value: "", - ErrorMessage: `{"Result":"error","ErrorMessage":"header id is required"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"header id is required","ErrorCode":4}`, StatusCode: 400, }, { Value: "invalidFile", - ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid id provided."}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid id provided.","ErrorCode":5}`, StatusCode: 404, }, { Value: idFileAdmin, - ErrorMessage: `{"Result":"error","ErrorMessage":"No permission to `, + ErrorMessage: `{"Result":"error","ErrorMessage":"No permission to duplicate this file","ErrorCode":6}`, StatusCode: 401, }, } @@ -293,7 +304,7 @@ func TestInvalidRouting(t *testing.T) { w, r := getRecorder(apiUrl, "invalid", []test.Header{{}}) Process(w, r) test.IsEqualInt(t, w.Code, 400) - test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"Invalid request"}`) + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"Invalid request","ErrorCode":1}`) } // ## /user/## @@ -309,22 +320,22 @@ func TestUserCreate(t *testing.T) { Value: "1234", }}) Process(w, r) - test.ResponseBodyContains(t, w, `{"id":103,"name":"1234","permissions":0,"userLevel":2,"lastOnline":0,"resetPassword":false}`) + test.ResponseBodyIs(t, w, `{"id":103,"name":"1234","permissions":0,"userLevel":2,"lastOnline":0,"resetPassword":false}`) var invalidParameter = []invalidParameterValue{ { Value: "", - ErrorMessage: `{"Result":"error","ErrorMessage":"header username is required"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"header username is required","ErrorCode":4}`, StatusCode: 400, }, { Value: "1", - ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid username provided."}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid username provided.","ErrorCode":6}`, StatusCode: 400, }, { Value: "1234", - ErrorMessage: `{"Result":"error","ErrorMessage":"User already exists."}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"User already exists.","ErrorCode":7}`, StatusCode: 409, }, } @@ -350,12 +361,12 @@ func TestUserChangeRank(t *testing.T) { invalidParameter := []invalidParameterValue{ { Value: "", - ErrorMessage: `{"Result":"error","ErrorMessage":"header newRank is required"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"header newRank is required","ErrorCode":4}`, StatusCode: 400, }, { Value: "invalid", - ErrorMessage: `{"Result":"error","ErrorMessage":"invalid rank"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"invalid rank","ErrorCode":4}`, StatusCode: 400, }, } @@ -432,7 +443,7 @@ func testDeleteUserCall(t *testing.T, apiKey string, mode int) { database.SaveSession("sessionApiDelete", session) _, ok = database.GetSession("sessionApiDelete") test.IsEqualBool(t, ok, true) - userApiKey := generateNewKey(false, retrievedUser.Id, "") + userApiKey := generateNewKey(false, retrievedUser.Id, "", "") _, ok = database.GetApiKey(userApiKey.Id) test.IsEqualBool(t, ok, true) testFile := models.File{ @@ -510,17 +521,17 @@ func TestUserModify(t *testing.T) { invalidParameter := []invalidParameterValue{ { Value: "", - ErrorMessage: `{"Result":"error","ErrorMessage":"header userpermission is required"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"header userpermission is required","ErrorCode":4}`, StatusCode: 400, }, { Value: "invalid", - ErrorMessage: `{"Result":"error","ErrorMessage":"invalid permission"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"invalid permission","ErrorCode":4}`, StatusCode: 400, }, { Value: "PERM_REPLACEE", - ErrorMessage: `{"Result":"error","ErrorMessage":"invalid permission"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"invalid permission","ErrorCode":4}`, StatusCode: 400, }, } @@ -563,7 +574,7 @@ func TestUserPasswordReset(t *testing.T) { test.IsEqualBool(t, ok, true) test.IsEqualBool(t, user.ResetPassword, true) test.IsEqualString(t, user.Password, "1234") - test.ResponseBodyContains(t, w, `{"Result":"ok","password":""}`) + test.ResponseBodyIs(t, w, `{"Result":"ok","password":""}`) user.ResetPassword = false database.SaveUser(user, false) @@ -699,16 +710,16 @@ func TestIsValidApiKey(t *testing.T) { test.IsEqualBool(t, ok, true) test.IsEqualBool(t, key.LastUsed == 0, false) - newApiKey := generateNewKey(false, 5, "") + newApiKey := generateNewKey(false, 5, "", "") user, _, isValid = isValidApiKey(newApiKey.Id, true, models.ApiPermNone) test.IsEqualBool(t, isValid, true) - for _, permission := range getAvailableApiPermissions(t) { + for _, permission := range getAvailableApiPermissions() { _, _, isValid = isValidApiKey(newApiKey.Id, true, permission) test.IsEqualBool(t, isValid, false) } - for _, newPermission := range getAvailableApiPermissions(t) { + for _, newPermission := range getAvailableApiPermissions() { setPermissionApikey(t, newApiKey.Id, newPermission) - for _, permission := range getAvailableApiPermissions(t) { + for _, permission := range getAvailableApiPermissions() { _, _, isValid = isValidApiKey(newApiKey.Id, true, permission) test.IsEqualBool(t, isValid, permission == newPermission) } @@ -717,7 +728,7 @@ func TestIsValidApiKey(t *testing.T) { setPermissionApikey(t, newApiKey.Id, models.ApiPermEdit|models.ApiPermDelete) _, _, isValid = isValidApiKey(newApiKey.Id, true, models.ApiPermEdit) test.IsEqualBool(t, isValid, true) - _, _, isValid = isValidApiKey(newApiKey.Id, true, models.ApiPermAll) + _, _, isValid = isValidApiKey(newApiKey.Id, true, getPermissionAll()) test.IsEqualBool(t, isValid, false) _, _, isValid = isValidApiKey(newApiKey.Id, true, models.ApiPermView) test.IsEqualBool(t, isValid, false) @@ -736,7 +747,7 @@ func removePermissionApikey(t *testing.T, key string, newPermission models.ApiPe database.SaveApiKey(apiKey) } -func getAvailableApiPermissions(t *testing.T) []models.ApiPermission { +func getAvailableApiPermissions() []models.ApiPermission { result := []models.ApiPermission{ models.ApiPermView, models.ApiPermUpload, @@ -745,17 +756,21 @@ func getAvailableApiPermissions(t *testing.T) []models.ApiPermission { models.ApiPermEdit, models.ApiPermReplace, models.ApiPermManageUsers, - models.ApiPermManageLogs} - sum := 0 - for _, perm := range result { - sum = sum + int(perm) - } - if sum != int(models.ApiPermAll) { - t.Fatal("List of permissions are incorrect") + models.ApiPermManageLogs, + models.ApiPermManageFileRequests, + models.ApiPermDownload, } return result } +func getPermissionAll() models.ApiPermission { + allPermissions := models.ApiPermNone + for _, permission := range getAvailableApiPermissions() { + allPermissions += permission + } + return allPermissions +} + func getApiPermMap(t *testing.T) map[models.ApiPermission]string { result := make(map[models.ApiPermission]string) result[models.ApiPermView] = "PERM_VIEW" @@ -766,12 +781,14 @@ func getApiPermMap(t *testing.T) map[models.ApiPermission]string { result[models.ApiPermReplace] = "PERM_REPLACE" result[models.ApiPermManageUsers] = "PERM_MANAGE_USERS" result[models.ApiPermManageLogs] = "PERM_MANAGE_LOGS" + result[models.ApiPermManageFileRequests] = "PERM_MANAGE_FILE_REQUESTS" + result[models.ApiPermDownload] = "PERM_DOWNLOAD" sum := 0 for perm := range result { sum = sum + int(perm) } - if sum != int(models.ApiPermAll) { + if sum != int(getPermissionAll()) { t.Fatal("List of permissions are incorrect") } @@ -829,12 +846,12 @@ func TestDeleteApiKey(t *testing.T) { invalidParameter := []invalidParameterValue{ { Value: "", - ErrorMessage: `{"Result":"error","ErrorMessage":"header targetKey is required"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"header targetKey is required","ErrorCode":4}`, StatusCode: 400, }, { Value: "invalid", - ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid key ID provided."}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"Invalid key ID provided.","ErrorCode":5}`, StatusCode: 404, }, } @@ -926,27 +943,32 @@ func TestApikeyModify(t *testing.T) { invalidParameter := []invalidParameterValue{ { Value: "", - ErrorMessage: `{"Result":"error","ErrorMessage":"header permission is required"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"header permission is required","ErrorCode":4}`, StatusCode: 400, }, { Value: "invalid", - ErrorMessage: `{"Result":"error","ErrorMessage":"invalid permission"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"invalid permission","ErrorCode":4}`, StatusCode: 400, }, { Value: "PERM_VIEWW", - ErrorMessage: `{"Result":"error","ErrorMessage":"invalid permission"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"invalid permission","ErrorCode":4}`, StatusCode: 400, }, { Value: "PERM_REPLACE", - ErrorMessage: `{"Result":"error","ErrorMessage":"Insufficient user permission for owner to set this API permission"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"Insufficient user permission for owner to set this API permission","ErrorCode":6}`, StatusCode: 401, }, { Value: "PERM_MANAGE_USERS", - ErrorMessage: `{"Result":"error","ErrorMessage":"Insufficient user permission for owner to set this API permission"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"Insufficient user permission for owner to set this API permission","ErrorCode":6}`, + StatusCode: 401, + }, + { + Value: "PERM_MANAGE_FILE_REQUESTS", + ErrorMessage: `{"Result":"error","ErrorMessage":"Insufficient user permission for owner to set this API permission","ErrorCode":6}`, StatusCode: 401, }, } @@ -955,6 +977,7 @@ func TestApikeyModify(t *testing.T) { grantUserPermission(t, idUser, models.UserPermReplaceUploads) grantUserPermission(t, idUser, models.UserPermManageUsers) grantUserPermission(t, idUser, models.UserPermManageLogs) + grantUserPermission(t, idUser, models.UserPermGuestUploads) for permissionUint, permissionString := range getApiPermMap(t) { test.IsEqualBool(t, retrievedApiKey.HasPermission(permissionUint), false) @@ -970,6 +993,7 @@ func TestApikeyModify(t *testing.T) { removeUserPermission(t, idUser, models.UserPermReplaceUploads) removeUserPermission(t, idUser, models.UserPermManageUsers) removeUserPermission(t, idUser, models.UserPermManageLogs) + removeUserPermission(t, idUser, models.UserPermGuestUploads) } func testApiModifyCall(t *testing.T, apiKey, targetKey string, permission string, grant bool) { @@ -1034,12 +1058,12 @@ func TestDeleteFile(t *testing.T) { test.IsEqualBool(t, ok, true) apiKey := testAuthorisation(t, "/files/delete", models.ApiPermDelete) - testDeleteFileCall(t, apiKey.Id, "", "", 400, `{"Result":"error","ErrorMessage":"header id is required"}`) - testDeleteFileCall(t, apiKey.Id, "invalid", "", 404, `{"Result":"error","ErrorMessage":"Invalid file ID provided."}`) - testDeleteFileCall(t, apiKey.Id, "smalltestfile1", "invalid", 400, `{"Result":"error","ErrorMessage":"invalid value in header delay supplied"}`) + testDeleteFileCall(t, apiKey.Id, "", "", 400, `{"Result":"error","ErrorMessage":"header id is required","ErrorCode":4}`) + testDeleteFileCall(t, apiKey.Id, "invalid", "", 404, `{"Result":"error","ErrorMessage":"Invalid file ID provided.","ErrorCode":5}`) + testDeleteFileCall(t, apiKey.Id, "smalltestfile1", "invalid", 400, `{"Result":"error","ErrorMessage":"invalid value in header delay supplied","ErrorCode":4}`) testDeleteFileCall(t, apiKey.Id, "smalltestfile1", "", 200, "") testDeleteFileCall(t, apiKey.Id, "smalltestfileDelay", "1", 200, "") - testDeleteFileCall(t, apiKey.Id, "smalltestfile2", "", 401, `{"Result":"error","ErrorMessage":"No permission to delete this file"}`) + testDeleteFileCall(t, apiKey.Id, "smalltestfile2", "", 401, `{"Result":"error","ErrorMessage":"No permission to delete this file","ErrorCode":6}`) _, ok = database.GetMetaDataById("smalltestfile2") test.IsEqualBool(t, ok, true) grantUserPermission(t, idUser, models.UserPermDeleteOtherUploads) @@ -1076,7 +1100,7 @@ func testDeleteFileCall(t *testing.T, apiKey, fileId, delay string, resultCode i Process(w, r) test.IsEqualInt(t, w.Code, resultCode) if expectedResponse != "" { - test.ResponseBodyContains(t, w, expectedResponse) + test.ResponseBodyIs(t, w, expectedResponse) } defer test.ExpectPanic(t) @@ -1109,10 +1133,10 @@ func TestRestoreFile(t *testing.T) { test.IsEqualBool(t, ok, true) apiKey := testAuthorisation(t, "/files/restore", models.ApiPermDelete) - testRestoreFileCall(t, apiKey.Id, "", 400, `{"Result":"error","ErrorMessage":"header id is required"}`) - testRestoreFileCall(t, apiKey.Id, "invalid", 404, `{"Result":"error","ErrorMessage":"Invalid file ID provided or file has already been deleted."}`) + testRestoreFileCall(t, apiKey.Id, "", 400, `{"Result":"error","ErrorMessage":"header id is required","ErrorCode":4}`) + testRestoreFileCall(t, apiKey.Id, "invalid", 404, `{"Result":"error","ErrorMessage":"Invalid file ID provided or file has already been deleted.","ErrorCode":5}`) testRestoreFileCall(t, apiKey.Id, fileUser.Id, 200, fileUser.ToJsonResult(config.ServerUrl, config.IncludeFilename)) - testRestoreFileCall(t, apiKey.Id, fileAdmin.Id, 401, `{"Result":"error","ErrorMessage":"No permission to restore this file"}`) + testRestoreFileCall(t, apiKey.Id, fileAdmin.Id, 401, `{"Result":"error","ErrorMessage":"No permission to restore this file","ErrorCode":6}`) storage.DeleteFileSchedule(fileUser.Id, 500, true) storage.DeleteFileSchedule(fileAdmin.Id, 500, true) @@ -1125,7 +1149,7 @@ func TestRestoreFile(t *testing.T) { test.IsEqualBool(t, file.PendingDeletion != 0, true) testRestoreFileCall(t, apiKey.Id, fileUser.Id, 200, fileUser.ToJsonResult(config.ServerUrl, config.IncludeFilename)) - testRestoreFileCall(t, apiKey.Id, fileAdmin.Id, 401, `{"Result":"error","ErrorMessage":"No permission to restore this file"}`) + testRestoreFileCall(t, apiKey.Id, fileAdmin.Id, 401, `{"Result":"error","ErrorMessage":"No permission to restore this file","ErrorCode":6}`) file, ok = database.GetMetaDataById(fileUser.Id) test.IsEqualBool(t, ok, true) @@ -1181,7 +1205,7 @@ func testRestoreFileCall(t *testing.T, apiKey, fileId string, resultCode int, ex Process(w, r) test.IsEqualInt(t, w.Code, resultCode) if expectedResponse != "" { - test.ResponseBodyContains(t, w, expectedResponse) + test.ResponseBodyIs(t, w, expectedResponse) } defer test.ExpectPanic(t) @@ -1196,7 +1220,7 @@ func TestList(t *testing.T) { w, r := getRecorder(apiUrl, apiKey.Id, []test.Header{}) Process(w, r) test.IsEqualInt(t, w.Code, 200) - test.ResponseBodyContains(t, w, "null") + test.ResponseBodyIs(t, w, "null") generateTestData() var result []models.FileApiOutput @@ -1234,11 +1258,11 @@ func TestListSingle(t *testing.T) { w, r = getRecorder(apiUrl+"e4TjE7CokWK0giiLNxDL", apiKey.Id, []test.Header{}) Process(w, r) test.IsEqualInt(t, w.Code, 401) - test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"No permission to view file"}`) + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"No permission to view file","ErrorCode":6}`) w, r = getRecorder(apiUrl+"invalid", apiKey.Id, []test.Header{}) Process(w, r) test.IsEqualInt(t, w.Code, 404) - test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"File not found"}`) + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"File not found","ErrorCode":5}`) grantUserPermission(t, idUser, models.UserPermListOtherUploads) w, r = getRecorder(apiUrl+"e4TjE7CokWK0giiLNxDL", apiKey.Id, []test.Header{}) @@ -1254,6 +1278,9 @@ func TestListSingle(t *testing.T) { } func TestUpload(t *testing.T) { + apiKey := generateNewKey(false, idUser, "", "") + apiKey.GrantPermission(models.ApiPermUpload) + database.SaveApiKey(apiKey) result, body := uploadNewFile(t) test.IsEqualString(t, result.Result, "OK") test.IsEqualString(t, result.FileInfo.Size, "3 B") @@ -1263,10 +1290,10 @@ func TestUpload(t *testing.T) { // newFileId := result.FileInfo.Id w, r := test.GetRecorder("POST", "/api/files/add", nil, []test.Header{{ Name: "apikey", - Value: "validkey", + Value: apiKey.Id, }}, body) Process(w, r) - test.ResponseBodyContains(t, w, "Content-Type isn't multipart/form-data") + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"request Content-Type isn't multipart/form-data","ErrorCode":0}`) test.IsEqualInt(t, w.Code, 400) defer test.ExpectPanic(t) @@ -1291,7 +1318,7 @@ func uploadNewFile(t *testing.T) (models.Result, *bytes.Buffer) { test.IsNil(t, err) err = writer.Close() test.IsNil(t, err) - newApiKeyUser := generateNewKey(true, idUser, "") + newApiKeyUser := generateNewKey(true, idUser, "", "") w, r := test.GetRecorder("POST", "/api/files/add", nil, []test.Header{{ Name: "apikey", Value: newApiKeyUser.Id, @@ -1320,7 +1347,7 @@ func TestDuplicate(t *testing.T) { invalidParameter := []invalidParameterValue{ { Value: "invalid", - ErrorMessage: `{"Result":"error","ErrorMessage":"invalid value in header allowedDownloads supplied"}`, + ErrorMessage: `{"Result":"error","ErrorMessage":"invalid value in header allowedDownloads supplied","ErrorCode":4}`, StatusCode: 400, }, } @@ -1407,6 +1434,9 @@ func TestDuplicate(t *testing.T) { } func TestChunkUpload(t *testing.T) { + apiKey := generateNewKey(false, idUser, "", "") + apiKey.GrantPermission(models.ApiPermUpload) + database.SaveApiKey(apiKey) err := os.WriteFile("test/tmpupload", []byte("chunktestfile"), 0600) test.IsNil(t, err) body, formcontent := test.FileToMultipartFormBody(t, test.HttpTestConfig{ @@ -1425,12 +1455,12 @@ func TestChunkUpload(t *testing.T) { }) w, r := test.GetRecorder("POST", "/api/chunk/add", nil, []test.Header{{ Name: "apikey", - Value: "validkey", + Value: apiKey.Id, }}, body) r.Header.Add("Content-Type", formcontent) Process(w, r) test.IsEqualInt(t, w.Code, 200) - test.ResponseBodyContains(t, w, "OK") + test.ResponseBodyIs(t, w, `{"result":"OK"}`) body, formcontent = test.FileToMultipartFormBody(t, test.HttpTestConfig{ UploadFileName: "test/tmpupload", @@ -1448,20 +1478,24 @@ func TestChunkUpload(t *testing.T) { }) w, r = test.GetRecorder("POST", "/api/chunk/add", nil, []test.Header{{ Name: "apikey", - Value: "validkey", + Value: apiKey.Id, }}, body) r.Header.Add("Content-Type", formcontent) Process(w, r) test.IsEqualInt(t, w.Code, 400) - test.ResponseBodyContains(t, w, "error") + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"strconv.ParseInt: parsing \"\": invalid syntax","ErrorCode":10}`) defer test.ExpectPanic(t) apiChunkAdd(w, ¶mAuthCreate{}, models.User{Id: 7}) } func TestChunkComplete(t *testing.T) { + apiKey := generateNewKey(false, idUser, "", "") + apiKey.GrantPermission(models.ApiPermUpload) + database.SaveApiKey(apiKey) + w, r := test.GetRecorder("POST", "/api/chunk/complete", nil, []test.Header{ - {Name: "apikey", Value: "validkey"}, + {Name: "apikey", Value: apiKey.Id}, {Name: "uuid", Value: "tmpupload123"}, {Name: "filename", Value: "test.upload"}, {Name: "filesize", Value: "13"}}, @@ -1483,13 +1517,13 @@ func TestChunkComplete(t *testing.T) { // data.Set("filesize", "15") w, r = test.GetRecorder("POST", "/api/chunk/complete", nil, []test.Header{ - {Name: "apikey", Value: "validkey"}, + {Name: "apikey", Value: apiKey.Id}, {Name: "uuid", Value: "tmpupload123"}, {Name: "filename", Value: "test.upload"}, {Name: "filesize", Value: "15"}}, nil) Process(w, r) test.IsEqualInt(t, w.Code, 400) - test.ResponseBodyContains(t, w, "error") + test.ResponseBodyIs(t, w, `{"Result":"error","ErrorMessage":"chunk file does not exist","ErrorCode":0}`) defer test.ExpectPanic(t) apiChunkComplete(w, ¶mAuthCreate{}, models.User{Id: 7}) @@ -1497,7 +1531,7 @@ func TestChunkComplete(t *testing.T) { func TestMinorFunctions(t *testing.T) { outputFileJson(nil, models.File{}) - sendError(nil, 0, "none") + sendError(nil, 0, 0, "none") } func testReplaceFileCall(t *testing.T, apiKey string, fileTarget, fileOrigin string, deleteFile bool, resultCode int, expectedResponse string) { @@ -1520,7 +1554,7 @@ func testReplaceFileCall(t *testing.T, apiKey string, fileTarget, fileOrigin str Process(w, r) test.IsEqualInt(t, w.Code, resultCode) if expectedResponse != "" { - test.ResponseBodyContains(t, w, expectedResponse) + test.ResponseBodyIs(t, w, expectedResponse) } defer test.ExpectPanic(t) @@ -1612,13 +1646,13 @@ func TestFileReplace(t *testing.T) { test.IsEqualBool(t, ok, true) apiKey := testAuthorisation(t, "/files/replace", models.ApiPermReplace) - testReplaceFileCall(t, apiKey.Id, "", "invalid", false, 400, `{"Result":"error","ErrorMessage":"header id is required"}`) - testReplaceFileCall(t, apiKey.Id, "invalid", "", false, 400, `{"Result":"error","ErrorMessage":"header idNewContent is required"}`) - testReplaceFileCall(t, apiKey.Id, "invalid", originalFile.Id, false, 404, `{"Result":"error","ErrorMessage":"Invalid id provided."}`) - testReplaceFileCall(t, apiKey.Id, originalFile.Id, "invalid", false, 404, `{"Result":"error","ErrorMessage":"Invalid id provided."}`) - testReplaceFileCall(t, apiKey.Id, originalFile.Id, adminFile.Id, false, 401, `{"Result":"error","ErrorMessage":"No permission to duplicate this file"}`) - testReplaceFileCall(t, apiKey.Id, adminFile.Id, originalFile.Id, false, 401, `{"Result":"error","ErrorMessage":"No permission to replace this file"}`) - testReplaceFileCall(t, apiKey.Id, e2eFile.Id, originalFile.Id, false, 400, `{"Result":"error","ErrorMessage":"End-to-End encrypted files cannot be replaced"}`) + testReplaceFileCall(t, apiKey.Id, "", "invalid", false, 400, `{"Result":"error","ErrorMessage":"header id is required","ErrorCode":4}`) + testReplaceFileCall(t, apiKey.Id, "invalid", "", false, 400, `{"Result":"error","ErrorMessage":"header idNewContent is required","ErrorCode":4}`) + testReplaceFileCall(t, apiKey.Id, "invalid", originalFile.Id, false, 404, `{"Result":"error","ErrorMessage":"Invalid id provided.","ErrorCode":5}`) + testReplaceFileCall(t, apiKey.Id, originalFile.Id, "invalid", false, 404, `{"Result":"error","ErrorMessage":"Invalid id provided.","ErrorCode":5}`) + testReplaceFileCall(t, apiKey.Id, originalFile.Id, adminFile.Id, false, 401, `{"Result":"error","ErrorMessage":"No permission to duplicate this file","ErrorCode":6}`) + testReplaceFileCall(t, apiKey.Id, adminFile.Id, originalFile.Id, false, 401, `{"Result":"error","ErrorMessage":"No permission to replace this file","ErrorCode":6}`) + testReplaceFileCall(t, apiKey.Id, e2eFile.Id, originalFile.Id, false, 400, `{"Result":"error","ErrorMessage":"End-to-End encrypted files cannot be replaced","ErrorCode":17}`) testReplaceFileCall(t, apiKey.Id, originalFile.Id, newFile.Id, false, 200, "") file, ok := database.GetMetaDataById(originalFile.Id) diff --git a/internal/webserver/api/VersionNumbers.go b/internal/webserver/api/VersionNumbers.go index 1ec7dc7..f8b45f6 100644 --- a/internal/webserver/api/VersionNumbers.go +++ b/internal/webserver/api/VersionNumbers.go @@ -1,5 +1,5 @@ // Code generated by updateApiRouting.go - DO NOT EDIT. package api -const versionReadable = "2.1.0" -const versionInt = 20100 +const versionReadable = "2.2.0-dev" +const versionInt = 20200 diff --git a/internal/webserver/api/errorcodes/Errorcodes.go b/internal/webserver/api/errorcodes/Errorcodes.go new file mode 100644 index 0000000..e9c6842 --- /dev/null +++ b/internal/webserver/api/errorcodes/Errorcodes.go @@ -0,0 +1,24 @@ +package errorcodes + +const ( + UnspecifiedError = iota + InvalidUrl + InvalidApiKey + AdminOnly + CannotParse + NotFound + NoPermission + AlreadyExists + InternalServer + FileTooLarge + InvalidUserInput + ChunkTooSmall + InvalidChunkReservation + CannotAllocateFile + RequestExpired + CannotUploadMoreFiles + RateLimited + EndToEndNotSupported + UnsupportedFile + ResourceCanNotBeEdited +) diff --git a/internal/webserver/api/routing.go b/internal/webserver/api/routing.go index 2f76931..30427bf 100644 --- a/internal/webserver/api/routing.go +++ b/internal/webserver/api/routing.go @@ -14,16 +14,15 @@ import ( ) type apiRoute struct { - Url string // The API endpoint - HasWildcard bool // True if the endpoint contains the ID as a sub-URL - AdminOnly bool // True if the endpoint requires admin/superadmin permissions - ApiPerm models.ApiPermission // Required permission to access the endpoint - RequestParser requestParser // Parser for the supplied parameters - execution apiFunc // Execution function for the endpoint + Url string // The API endpoint + HasWildcard bool // True if the endpoint contains the ID as a sub-URL + IsFileRequestApi bool // True if the endpoint is used for public uploads + AdminOnly bool // True if the endpoint requires admin/superadmin permissions + ApiPerm models.ApiPermission // Required permission to access the endpoint + RequestParser requestParser // Parser for the supplied parameters + execution apiFunc // Execution function for the endpoint } -const base64Prefix = "base64:" - func (r apiRoute) Continue(w http.ResponseWriter, request requestParser, user models.User) { r.execution(w, request, user) } @@ -43,6 +42,20 @@ var routes = []apiRoute{ execution: apiConfigInfo, RequestParser: nil, }, + { + Url: "/files/download/", + ApiPerm: models.ApiPermDownload, + execution: apiDownloadSingle, + HasWildcard: true, + RequestParser: ¶mFilesDownloadSingle{}, + }, + { + Url: "/files/downloadzip", + ApiPerm: models.ApiPermDownload, + execution: apiDownloadZip, + HasWildcard: true, + RequestParser: ¶mFilesDownloadZip{}, + }, { Url: "/files/changeOwner", ApiPerm: models.ApiPermEdit, @@ -54,7 +67,7 @@ var routes = []apiRoute{ Url: "/files/list", ApiPerm: models.ApiPermView, execution: apiList, - RequestParser: nil, + RequestParser: ¶mFilesListAll{}, }, { Url: "/files/list/", @@ -165,6 +178,59 @@ var routes = []apiRoute{ execution: apiResetPassword, RequestParser: ¶mUserResetPw{}, }, + { + Url: "/uploadrequest/list", + ApiPerm: models.ApiPermManageFileRequests, + execution: apiUploadRequestList, + RequestParser: nil, + }, + { + Url: "/uploadrequest/list/", + ApiPerm: models.ApiPermManageFileRequests, + execution: apiUploadRequestListSingle, + HasWildcard: true, + RequestParser: ¶mURequestListSingle{}, + }, + { + Url: "/uploadrequest/save", + ApiPerm: models.ApiPermManageFileRequests, + execution: apiURequestSave, + RequestParser: ¶mURequestSave{}, + }, + { + Url: "/uploadrequest/delete", + ApiPerm: models.ApiPermManageFileRequests, + execution: apiURequestDelete, + RequestParser: ¶mURequestDelete{}, + }, + { + Url: "/uploadrequest/chunk/add", + ApiPerm: models.ApiPermNone, + execution: apiChunkUploadRequestAdd, + IsFileRequestApi: true, + RequestParser: ¶mChunkUploadRequestAdd{}, + }, + { + Url: "/uploadrequest/chunk/complete", + ApiPerm: models.ApiPermNone, + IsFileRequestApi: true, + execution: apiChunkUploadRequestComplete, + RequestParser: ¶mChunkUploadRequestComplete{}, + }, + { + Url: "/uploadrequest/chunk/reserve", + ApiPerm: models.ApiPermNone, + IsFileRequestApi: true, + execution: apiChunkReserve, + RequestParser: ¶mChunkReserve{}, + }, + { + Url: "/uploadrequest/chunk/unreserve", + ApiPerm: models.ApiPermNone, + IsFileRequestApi: true, + execution: apiChunkUnreserve, + RequestParser: ¶mChunkUnreserve{}, + }, { Url: "/logs/delete", ApiPerm: models.ApiPermManageLogs, @@ -205,12 +271,53 @@ type requestParser interface { New() requestParser } +type paramFilesListAll struct { + ShowFileRequests bool `header:"showFileRequests"` + foundHeaders map[string]bool +} + +func (p *paramFilesListAll) ProcessParameter(_ *http.Request) error { + return nil +} + type paramFilesListSingle struct { - RequestUrl string + Id string } func (p *paramFilesListSingle) ProcessParameter(r *http.Request) error { - p.RequestUrl = parseRequestUrl(r) + url := parseRequestUrl(r) + p.Id = strings.TrimPrefix(url, "/files/list/") + return nil +} + +type paramFilesDownloadSingle struct { + Id string + WebRequest *http.Request + IncreaseCounter bool `header:"increaseCounter"` + PresignUrl bool `header:"presignUrl"` + foundHeaders map[string]bool +} + +func (p *paramFilesDownloadSingle) ProcessParameter(r *http.Request) error { + p.WebRequest = r + url := parseRequestUrl(r) + p.Id = strings.TrimPrefix(url, "/files/download/") + return nil +} + +type paramFilesDownloadZip struct { + Ids []string + WebRequest *http.Request + FileIds string `header:"ids" required:"true"` + Filename string `header:"filename" supportBase64:"true"` + IncreaseCounter bool `header:"increaseCounter"` + PresignUrl bool `header:"presignUrl"` + foundHeaders map[string]bool +} + +func (p *paramFilesDownloadZip) ProcessParameter(r *http.Request) error { + p.Ids = strings.Split(p.FileIds, ",") + p.WebRequest = r return nil } @@ -427,6 +534,8 @@ func (p *paramUserModify) ProcessParameter(_ *http.Request) error { p.Permission = models.UserPermManageApiKeys case "PERM_USERS": p.Permission = models.UserPermManageUsers + case "PERM_GUEST_UPLOAD": + p.Permission = models.UserPermGuestUploads default: return errors.New("invalid permission") } @@ -491,9 +600,28 @@ func (p *paramChunkAdd) ProcessParameter(r *http.Request) error { return nil } +func (p *paramChunkAdd) GetRequest() *http.Request { + return p.Request +} + +type paramChunkUploadRequestAdd struct { + Request *http.Request + FileRequestId string `header:"fileRequestId" required:"true"` + ApiKey string `header:"apikey"` // not published in API documentation + foundHeaders map[string]bool +} + +func (p *paramChunkUploadRequestAdd) ProcessParameter(r *http.Request) error { + p.Request = r + return nil +} +func (p *paramChunkUploadRequestAdd) GetRequest() *http.Request { + return p.Request +} + type paramChunkComplete struct { Uuid string `header:"uuid" required:"true"` - FileName string `header:"filename" required:"true"` + FileName string `header:"filename" required:"true" supportBase64:"true"` FileSize int64 `header:"filesize" required:"true"` RealSize int64 `header:"realsize"` // not published in API documentation ContentType string `header:"contenttype"` @@ -538,14 +666,6 @@ func (p *paramChunkComplete) ProcessParameter(_ *http.Request) error { } } - if strings.HasPrefix(p.FileName, base64Prefix) { - decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(p.FileName, base64Prefix)) - if err != nil { - return err - } - p.FileName = string(decoded) - } - if p.ContentType == "" { p.ContentType = "application/octet-stream" } @@ -557,6 +677,105 @@ func (p *paramChunkComplete) ProcessParameter(_ *http.Request) error { return nil } +type paramChunkReserve struct { + Id string `header:"id" required:"true"` + ApiKey string `header:"apikey"` // not published in API documentation + foundHeaders map[string]bool +} + +func (p *paramChunkReserve) ProcessParameter(_ *http.Request) error { + return nil +} + +type paramChunkUnreserve struct { + Id string `header:"id" required:"true"` + Uuid string `header:"uuid" required:"true"` + ApiKey string `header:"apikey"` // not published in API documentation + foundHeaders map[string]bool +} + +func (p *paramChunkUnreserve) ProcessParameter(_ *http.Request) error { + return nil +} + +type paramChunkUploadRequestComplete struct { + Uuid string `header:"uuid" required:"true"` + FileName string `header:"filename" required:"true" supportBase64:"true"` + FileRequestId string `header:"fileRequestId" required:"true"` + FileSize int64 `header:"filesize" required:"true"` + ContentType string `header:"contenttype"` + IsNonBlocking bool `header:"nonblocking"` + ApiKey string `header:"apikey"` // not published in API documentation + FileHeader chunking.FileHeader + foundHeaders map[string]bool +} + +func (p *paramChunkUploadRequestComplete) ProcessParameter(_ *http.Request) error { + if p.ContentType == "" { + p.ContentType = "application/octet-stream" + } + p.FileHeader = chunking.FileHeader{ + Filename: p.FileName, + ContentType: p.ContentType, + Size: p.FileSize, + } + return nil +} + +type paramURequestDelete struct { + Id string `header:"id" required:"true"` + foundHeaders map[string]bool +} + +func (p *paramURequestDelete) ProcessParameter(_ *http.Request) error { + return nil +} + +type paramURequestSave struct { + Id string `header:"id"` + Name string `header:"name" supportBase64:"true"` + Notes string `header:"notes" supportBase64:"true"` + Expiry int64 `header:"expiry"` + MaxFiles int `header:"maxfiles"` + MaxSizeMb int `header:"maxsize"` + IsNameSet bool + IsExpirySet bool + IsMaxFilesSet bool + IsMaxSizeSet bool + IsNotesSet bool + + foundHeaders map[string]bool +} + +func (p *paramURequestSave) ProcessParameter(_ *http.Request) error { + if p.foundHeaders["name"] { + p.IsNameSet = true + } + if p.foundHeaders["expiry"] { + p.IsExpirySet = true + } + if p.foundHeaders["maxfiles"] { + p.IsMaxFilesSet = true + } + if p.foundHeaders["maxsize"] { + p.IsMaxSizeSet = true + } + if p.foundHeaders["notes"] { + p.IsNotesSet = true + } + return nil +} + +type paramURequestListSingle struct { + Id string +} + +func (p *paramURequestListSingle) ProcessParameter(r *http.Request) error { + url := parseRequestUrl(r) + p.Id = strings.TrimPrefix(url, "/uploadrequest/list/") + return nil +} + func checkHeaderExists(r *http.Request, key string, isRequired, isString bool) (bool, error) { if r.Header.Get(key) != "" { return true, nil diff --git a/internal/webserver/api/routingParsing.go b/internal/webserver/api/routingParsing.go index f86b15e..e42c88d 100644 --- a/internal/webserver/api/routingParsing.go +++ b/internal/webserver/api/routingParsing.go @@ -2,13 +2,43 @@ package api import ( + "encoding/base64" "fmt" "net/http" + "strings" ) // Do not modify: This is an automatically generated file created by updateApiRouting.go // It contains the code that is used to parse the headers submitted in an API request +// ParseRequest reads r and saves the passed header values in the paramFilesListAll struct +// In the end, ProcessParameter() is called +func (p *paramFilesListAll) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "showFileRequests", required: false + exists, err = checkHeaderExists(r, "showFileRequests", false, false) + if err != nil { + return err + } + p.foundHeaders["showFileRequests"] = exists + if exists { + p.ShowFileRequests, err = parseHeaderBool(r, "showFileRequests") + if err != nil { + return fmt.Errorf("invalid value in header showFileRequests supplied") + } + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramFilesListAll struct +func (p *paramFilesListAll) New() requestParser { + return ¶mFilesListAll{} +} + // ParseRequest parses the header file. As paramFilesListSingle has no fields with the // tag header, this method does nothing, except calling ProcessParameter() func (p *paramFilesListSingle) ParseRequest(r *http.Request) error { @@ -20,6 +50,115 @@ func (p *paramFilesListSingle) New() requestParser { return ¶mFilesListSingle{} } +// ParseRequest reads r and saves the passed header values in the paramFilesDownloadSingle struct +// In the end, ProcessParameter() is called +func (p *paramFilesDownloadSingle) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "increaseCounter", required: false + exists, err = checkHeaderExists(r, "increaseCounter", false, false) + if err != nil { + return err + } + p.foundHeaders["increaseCounter"] = exists + if exists { + p.IncreaseCounter, err = parseHeaderBool(r, "increaseCounter") + if err != nil { + return fmt.Errorf("invalid value in header increaseCounter supplied") + } + } + + // RequestParser header value "presignUrl", required: false + exists, err = checkHeaderExists(r, "presignUrl", false, false) + if err != nil { + return err + } + p.foundHeaders["presignUrl"] = exists + if exists { + p.PresignUrl, err = parseHeaderBool(r, "presignUrl") + if err != nil { + return fmt.Errorf("invalid value in header presignUrl supplied") + } + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramFilesDownloadSingle struct +func (p *paramFilesDownloadSingle) New() requestParser { + return ¶mFilesDownloadSingle{} +} + +// ParseRequest reads r and saves the passed header values in the paramFilesDownloadZip struct +// In the end, ProcessParameter() is called +func (p *paramFilesDownloadZip) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "ids", required: true + exists, err = checkHeaderExists(r, "ids", true, true) + if err != nil { + return err + } + p.foundHeaders["ids"] = exists + if exists { + p.FileIds = r.Header.Get("ids") + } + + // RequestParser header value "filename", required: false, has base64support + exists, err = checkHeaderExists(r, "filename", false, true) + if err != nil { + return err + } + p.foundHeaders["filename"] = exists + if exists { + p.Filename = r.Header.Get("filename") + if strings.HasPrefix(p.Filename, "base64:") { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(p.Filename, "base64:")) + if err != nil { + return err + } + p.Filename = string(decoded) + } + } + + // RequestParser header value "increaseCounter", required: false + exists, err = checkHeaderExists(r, "increaseCounter", false, false) + if err != nil { + return err + } + p.foundHeaders["increaseCounter"] = exists + if exists { + p.IncreaseCounter, err = parseHeaderBool(r, "increaseCounter") + if err != nil { + return fmt.Errorf("invalid value in header increaseCounter supplied") + } + } + + // RequestParser header value "presignUrl", required: false + exists, err = checkHeaderExists(r, "presignUrl", false, false) + if err != nil { + return err + } + p.foundHeaders["presignUrl"] = exists + if exists { + p.PresignUrl, err = parseHeaderBool(r, "presignUrl") + if err != nil { + return fmt.Errorf("invalid value in header presignUrl supplied") + } + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramFilesDownloadZip struct +func (p *paramFilesDownloadZip) New() requestParser { + return ¶mFilesDownloadZip{} +} + // ParseRequest parses the header file. As paramFilesAdd has no fields with the // tag header, this method does nothing, except calling ProcessParameter() func (p *paramFilesAdd) ParseRequest(r *http.Request) error { @@ -724,6 +863,41 @@ func (p *paramChunkAdd) New() requestParser { return ¶mChunkAdd{} } +// ParseRequest reads r and saves the passed header values in the paramChunkUploadRequestAdd struct +// In the end, ProcessParameter() is called +func (p *paramChunkUploadRequestAdd) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "fileRequestId", required: true + exists, err = checkHeaderExists(r, "fileRequestId", true, true) + if err != nil { + return err + } + p.foundHeaders["fileRequestId"] = exists + if exists { + p.FileRequestId = r.Header.Get("fileRequestId") + } + + // RequestParser header value "apikey", required: false + exists, err = checkHeaderExists(r, "apikey", false, true) + if err != nil { + return err + } + p.foundHeaders["apikey"] = exists + if exists { + p.ApiKey = r.Header.Get("apikey") + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramChunkUploadRequestAdd struct +func (p *paramChunkUploadRequestAdd) New() requestParser { + return ¶mChunkUploadRequestAdd{} +} + // ParseRequest reads r and saves the passed header values in the paramChunkComplete struct // In the end, ProcessParameter() is called func (p *paramChunkComplete) ParseRequest(r *http.Request) error { @@ -741,7 +915,7 @@ func (p *paramChunkComplete) ParseRequest(r *http.Request) error { p.Uuid = r.Header.Get("uuid") } - // RequestParser header value "filename", required: true + // RequestParser header value "filename", required: true, has base64support exists, err = checkHeaderExists(r, "filename", true, true) if err != nil { return err @@ -749,6 +923,13 @@ func (p *paramChunkComplete) ParseRequest(r *http.Request) error { p.foundHeaders["filename"] = exists if exists { p.FileName = r.Header.Get("filename") + if strings.HasPrefix(p.FileName, "base64:") { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(p.FileName, "base64:")) + if err != nil { + return err + } + p.FileName = string(decoded) + } } // RequestParser header value "filesize", required: true @@ -856,3 +1037,315 @@ func (p *paramChunkComplete) ParseRequest(r *http.Request) error { func (p *paramChunkComplete) New() requestParser { return ¶mChunkComplete{} } + +// ParseRequest reads r and saves the passed header values in the paramChunkReserve struct +// In the end, ProcessParameter() is called +func (p *paramChunkReserve) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "id", required: true + exists, err = checkHeaderExists(r, "id", true, true) + if err != nil { + return err + } + p.foundHeaders["id"] = exists + if exists { + p.Id = r.Header.Get("id") + } + + // RequestParser header value "apikey", required: false + exists, err = checkHeaderExists(r, "apikey", false, true) + if err != nil { + return err + } + p.foundHeaders["apikey"] = exists + if exists { + p.ApiKey = r.Header.Get("apikey") + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramChunkReserve struct +func (p *paramChunkReserve) New() requestParser { + return ¶mChunkReserve{} +} + +// ParseRequest reads r and saves the passed header values in the paramChunkUnreserve struct +// In the end, ProcessParameter() is called +func (p *paramChunkUnreserve) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "id", required: true + exists, err = checkHeaderExists(r, "id", true, true) + if err != nil { + return err + } + p.foundHeaders["id"] = exists + if exists { + p.Id = r.Header.Get("id") + } + + // RequestParser header value "uuid", required: true + exists, err = checkHeaderExists(r, "uuid", true, true) + if err != nil { + return err + } + p.foundHeaders["uuid"] = exists + if exists { + p.Uuid = r.Header.Get("uuid") + } + + // RequestParser header value "apikey", required: false + exists, err = checkHeaderExists(r, "apikey", false, true) + if err != nil { + return err + } + p.foundHeaders["apikey"] = exists + if exists { + p.ApiKey = r.Header.Get("apikey") + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramChunkUnreserve struct +func (p *paramChunkUnreserve) New() requestParser { + return ¶mChunkUnreserve{} +} + +// ParseRequest reads r and saves the passed header values in the paramChunkUploadRequestComplete struct +// In the end, ProcessParameter() is called +func (p *paramChunkUploadRequestComplete) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "uuid", required: true + exists, err = checkHeaderExists(r, "uuid", true, true) + if err != nil { + return err + } + p.foundHeaders["uuid"] = exists + if exists { + p.Uuid = r.Header.Get("uuid") + } + + // RequestParser header value "filename", required: true, has base64support + exists, err = checkHeaderExists(r, "filename", true, true) + if err != nil { + return err + } + p.foundHeaders["filename"] = exists + if exists { + p.FileName = r.Header.Get("filename") + if strings.HasPrefix(p.FileName, "base64:") { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(p.FileName, "base64:")) + if err != nil { + return err + } + p.FileName = string(decoded) + } + } + + // RequestParser header value "fileRequestId", required: true + exists, err = checkHeaderExists(r, "fileRequestId", true, true) + if err != nil { + return err + } + p.foundHeaders["fileRequestId"] = exists + if exists { + p.FileRequestId = r.Header.Get("fileRequestId") + } + + // RequestParser header value "filesize", required: true + exists, err = checkHeaderExists(r, "filesize", true, false) + if err != nil { + return err + } + p.foundHeaders["filesize"] = exists + if exists { + p.FileSize, err = parseHeaderInt64(r, "filesize") + if err != nil { + return fmt.Errorf("invalid value in header filesize supplied") + } + } + + // RequestParser header value "contenttype", required: false + exists, err = checkHeaderExists(r, "contenttype", false, true) + if err != nil { + return err + } + p.foundHeaders["contenttype"] = exists + if exists { + p.ContentType = r.Header.Get("contenttype") + } + + // RequestParser header value "nonblocking", required: false + exists, err = checkHeaderExists(r, "nonblocking", false, false) + if err != nil { + return err + } + p.foundHeaders["nonblocking"] = exists + if exists { + p.IsNonBlocking, err = parseHeaderBool(r, "nonblocking") + if err != nil { + return fmt.Errorf("invalid value in header nonblocking supplied") + } + } + + // RequestParser header value "apikey", required: false + exists, err = checkHeaderExists(r, "apikey", false, true) + if err != nil { + return err + } + p.foundHeaders["apikey"] = exists + if exists { + p.ApiKey = r.Header.Get("apikey") + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramChunkUploadRequestComplete struct +func (p *paramChunkUploadRequestComplete) New() requestParser { + return ¶mChunkUploadRequestComplete{} +} + +// ParseRequest reads r and saves the passed header values in the paramURequestDelete struct +// In the end, ProcessParameter() is called +func (p *paramURequestDelete) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "id", required: true + exists, err = checkHeaderExists(r, "id", true, true) + if err != nil { + return err + } + p.foundHeaders["id"] = exists + if exists { + p.Id = r.Header.Get("id") + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramURequestDelete struct +func (p *paramURequestDelete) New() requestParser { + return ¶mURequestDelete{} +} + +// ParseRequest reads r and saves the passed header values in the paramURequestSave struct +// In the end, ProcessParameter() is called +func (p *paramURequestSave) ParseRequest(r *http.Request) error { + var err error + var exists bool + p.foundHeaders = make(map[string]bool) + + // RequestParser header value "id", required: false + exists, err = checkHeaderExists(r, "id", false, true) + if err != nil { + return err + } + p.foundHeaders["id"] = exists + if exists { + p.Id = r.Header.Get("id") + } + + // RequestParser header value "name", required: false, has base64support + exists, err = checkHeaderExists(r, "name", false, true) + if err != nil { + return err + } + p.foundHeaders["name"] = exists + if exists { + p.Name = r.Header.Get("name") + if strings.HasPrefix(p.Name, "base64:") { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(p.Name, "base64:")) + if err != nil { + return err + } + p.Name = string(decoded) + } + } + + // RequestParser header value "notes", required: false, has base64support + exists, err = checkHeaderExists(r, "notes", false, true) + if err != nil { + return err + } + p.foundHeaders["notes"] = exists + if exists { + p.Notes = r.Header.Get("notes") + if strings.HasPrefix(p.Notes, "base64:") { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(p.Notes, "base64:")) + if err != nil { + return err + } + p.Notes = string(decoded) + } + } + + // RequestParser header value "expiry", required: false + exists, err = checkHeaderExists(r, "expiry", false, false) + if err != nil { + return err + } + p.foundHeaders["expiry"] = exists + if exists { + p.Expiry, err = parseHeaderInt64(r, "expiry") + if err != nil { + return fmt.Errorf("invalid value in header expiry supplied") + } + } + + // RequestParser header value "maxfiles", required: false + exists, err = checkHeaderExists(r, "maxfiles", false, false) + if err != nil { + return err + } + p.foundHeaders["maxfiles"] = exists + if exists { + p.MaxFiles, err = parseHeaderInt(r, "maxfiles") + if err != nil { + return fmt.Errorf("invalid value in header maxfiles supplied") + } + } + + // RequestParser header value "maxsize", required: false + exists, err = checkHeaderExists(r, "maxsize", false, false) + if err != nil { + return err + } + p.foundHeaders["maxsize"] = exists + if exists { + p.MaxSizeMb, err = parseHeaderInt(r, "maxsize") + if err != nil { + return fmt.Errorf("invalid value in header maxsize supplied") + } + } + + return p.ProcessParameter(r) +} + +// New returns a new instance of paramURequestSave struct +func (p *paramURequestSave) New() requestParser { + return ¶mURequestSave{} +} + +// ParseRequest parses the header file. As paramURequestListSingle has no fields with the +// tag header, this method does nothing, except calling ProcessParameter() +func (p *paramURequestListSingle) ParseRequest(r *http.Request) error { + return p.ProcessParameter(r) +} + +// New returns a new instance of paramURequestListSingle struct +func (p *paramURequestListSingle) New() requestParser { + return ¶mURequestListSingle{} +} diff --git a/internal/webserver/authentication/users/Users.go b/internal/webserver/authentication/users/Users.go new file mode 100644 index 0000000..db33942 --- /dev/null +++ b/internal/webserver/authentication/users/Users.go @@ -0,0 +1,37 @@ +package users + +import ( + "errors" + + "github.com/forceu/gokapi/internal/configuration" + "github.com/forceu/gokapi/internal/configuration/database" + "github.com/forceu/gokapi/internal/models" +) + +const minLengthUser = 2 + +var ErrorNameToShort = errors.New("username too short") +var ErrorUserExists = errors.New("user already exists") + +func Create(name string) (models.User, error) { + if len(name) < minLengthUser { + return models.User{}, ErrorNameToShort + } + _, ok := database.GetUserByName(name) + if ok { + return models.User{}, ErrorUserExists + } + newUser := models.User{ + Name: name, + UserLevel: models.UserLevelUser, + } + if configuration.GetEnvironment().PermRequestGrantedByDefault { + newUser.GrantPermission(models.UserPermGuestUploads) + } + database.SaveUser(newUser, true) + newUser, ok = database.GetUserByName(name) + if !ok { + return models.User{}, errors.New("user could not be created") + } + return newUser, nil +} diff --git a/internal/webserver/fileupload/FileUpload.go b/internal/webserver/fileupload/FileUpload.go index 3ca0f4e..cda7a38 100644 --- a/internal/webserver/fileupload/FileUpload.go +++ b/internal/webserver/fileupload/FileUpload.go @@ -1,18 +1,25 @@ package fileupload import ( + "errors" + "io" + "net/http" + "strconv" + "time" + "github.com/forceu/gokapi/internal/configuration" "github.com/forceu/gokapi/internal/configuration/database" "github.com/forceu/gokapi/internal/logging" "github.com/forceu/gokapi/internal/models" "github.com/forceu/gokapi/internal/storage" "github.com/forceu/gokapi/internal/storage/chunking" - "io" - "net/http" - "strconv" - "time" + "github.com/forceu/gokapi/internal/storage/chunking/chunkreservation" + "github.com/forceu/gokapi/internal/webserver/api/errorcodes" ) +const minChunkSize = 5 * 1024 * 1024 +const minChunkSizeLowMaxChunk = 1 * 1024 * 1024 + // ProcessCompleteFile processes a file upload request // This is only used when a complete file is uploaded through the API with /files/add // Normally a file is created from a chunk @@ -37,66 +44,92 @@ func ProcessCompleteFile(w http.ResponseWriter, r *http.Request, userId, maxMemo return err } user, _ := database.GetUser(userId) - logging.LogUpload(result, user) + // Returns empty fr if the file is not related to a file request + fr, _ := database.GetFileRequest(config.FileRequestId) + logging.LogUpload(result, user, fr) _, _ = io.WriteString(w, result.ToJsonResult(config.ExternalUrl, configuration.Get().IncludeFilename)) return nil } +func isChunkMinChunkSize(r *http.Request, offset, fileSize int64) bool { + minReqChunkSize := minChunkSize + if configuration.Get().ChunkSize < 5 { + minReqChunkSize = minChunkSizeLowMaxChunk + } + if r.ContentLength >= int64(minReqChunkSize) { + return true + } + if r.ContentLength >= (fileSize - offset) { + return true + } + return false +} + // ProcessNewChunk processes a file chunk upload request -func ProcessNewChunk(w http.ResponseWriter, r *http.Request, isApiCall bool) error { +func ProcessNewChunk(w http.ResponseWriter, r *http.Request, isApiCall bool, filerequestId string) (error, int) { err := r.ParseMultipartForm(int64(configuration.Get().MaxMemory) * 1024 * 1024) if err != nil { - return err + return err, errorcodes.CannotParse } defer r.MultipartForm.RemoveAll() chunkInfo, err := chunking.ParseChunkInfo(r, isApiCall) if err != nil { - return err + return err, errorcodes.InvalidUserInput } file, header, err := r.FormFile("file") if err != nil { - return err + return err, errorcodes.InvalidUserInput + } + + if !isChunkMinChunkSize(r, chunkInfo.Offset, chunkInfo.TotalFilesizeBytes) { + return storage.ErrorChunkTooSmall, errorcodes.ChunkTooSmall + } + + if filerequestId != "" { + if !chunkreservation.SetUploading(filerequestId, chunkInfo.UUID) { + return errors.New("chunk reservation has expired or was not requested"), errorcodes.InvalidChunkReservation + } } err = chunking.NewChunk(file, header, chunkInfo) defer file.Close() if err != nil { - return err + return err, errorcodes.CannotAllocateFile } _, _ = io.WriteString(w, "{\"result\":\"OK\"}") - return nil + return nil, 0 } // ParseFileHeader parses the parameters for CompleteChunk() // This is done as two operations, as CompleteChunk can be blocking too long // for an HTTP request, by calling this function first, r can be closed afterwards -func ParseFileHeader(r *http.Request) (string, chunking.FileHeader, models.UploadRequest, error) { +func ParseFileHeader(r *http.Request) (string, chunking.FileHeader, models.UploadParameters, error) { err := r.ParseForm() if err != nil { - return "", chunking.FileHeader{}, models.UploadRequest{}, err + return "", chunking.FileHeader{}, models.UploadParameters{}, err } chunkId := r.Form.Get("chunkid") config, err := parseConfig(r.Form) if err != nil { - return "", chunking.FileHeader{}, models.UploadRequest{}, err + return "", chunking.FileHeader{}, models.UploadParameters{}, err } header, err := chunking.ParseFileHeader(r) if err != nil { - return "", chunking.FileHeader{}, models.UploadRequest{}, err + return "", chunking.FileHeader{}, models.UploadParameters{}, err } return chunkId, header, config, nil } // CompleteChunk processes a file after all the chunks have been completed // The parameters can be generated with ParseFileHeader() -func CompleteChunk(chunkId string, header chunking.FileHeader, userId int, config models.UploadRequest) (models.File, error) { +func CompleteChunk(chunkId string, header chunking.FileHeader, userId int, config models.UploadParameters) (models.File, error) { return storage.NewFileFromChunk(chunkId, header, userId, config) } -// CreateUploadConfig populates a new models.UploadRequest struct -func CreateUploadConfig(allowedDownloads, expiryDays int, password string, unlimitedTime, unlimitedDownload, isEnd2End bool, realSize int64) models.UploadRequest { +// CreateUploadConfig populates a new models.UploadParameters struct +func CreateUploadConfig(allowedDownloads, expiryDays int, password string, unlimitedTime, unlimitedDownload, isEnd2End bool, realSize int64, fileRequestId string) models.UploadParameters { settings := configuration.Get() - return models.UploadRequest{ + return models.UploadParameters{ AllowedDownloads: allowedDownloads, Expiry: expiryDays, ExpiryTimestamp: time.Now().Add(time.Duration(expiryDays) * time.Hour * 24).Unix(), @@ -107,10 +140,16 @@ func CreateUploadConfig(allowedDownloads, expiryDays int, password string, unlim UnlimitedDownload: unlimitedDownload, IsEndToEndEncrypted: isEnd2End, RealSize: realSize, + FileRequestId: fileRequestId, } } -func parseConfig(values formOrHeader) (models.UploadRequest, error) { +func parseConfig(values formOrHeader) (models.UploadParameters, error) { + fileRequestId := values.Get("fileRequestId") + if fileRequestId != "" { + return CreateUploadConfig(0, 0, "", + true, true, false, 0, fileRequestId), nil + } allowedDownloads := values.Get("allowedDownloads") expiryDays := values.Get("expiryDays") password := values.Get("password") @@ -140,10 +179,10 @@ func parseConfig(values formOrHeader) (models.UploadRequest, error) { realSizeStr := values.Get("realSize") realSize, err = strconv.ParseInt(realSizeStr, 10, 64) if err != nil { - return models.UploadRequest{}, err + return models.UploadParameters{}, err } } - return CreateUploadConfig(allowedDownloadsInt, expiryDaysInt, password, unlimitedTime, unlimitedDownload, isEnd2End, realSize), nil + return CreateUploadConfig(allowedDownloadsInt, expiryDaysInt, password, unlimitedTime, unlimitedDownload, isEnd2End, realSize, ""), nil } type formOrHeader interface { diff --git a/internal/webserver/fileupload/FileUpload_test.go b/internal/webserver/fileupload/FileUpload_test.go index 3d58a98..1b18088 100644 --- a/internal/webserver/fileupload/FileUpload_test.go +++ b/internal/webserver/fileupload/FileUpload_test.go @@ -3,10 +3,6 @@ package fileupload import ( "bytes" "encoding/json" - "github.com/forceu/gokapi/internal/configuration" - "github.com/forceu/gokapi/internal/models" - "github.com/forceu/gokapi/internal/test" - "github.com/forceu/gokapi/internal/test/testconfiguration" "io" "mime/multipart" "net/http" @@ -16,6 +12,11 @@ import ( "reflect" "strings" "testing" + + "github.com/forceu/gokapi/internal/configuration" + "github.com/forceu/gokapi/internal/models" + "github.com/forceu/gokapi/internal/test" + "github.com/forceu/gokapi/internal/test/testconfiguration" ) func TestMain(m *testing.M) { @@ -98,17 +99,17 @@ func TestProcess(t *testing.T) { func TestProcessNewChunk(t *testing.T) { w, r := test.GetRecorder("POST", "/uploadChunk", nil, nil, strings.NewReader("invalidยง$%&%ยง")) - err := ProcessNewChunk(w, r, false) + err, _ := ProcessNewChunk(w, r, false, "") test.IsNotNil(t, err) w = httptest.NewRecorder() r = getFileUploadRecorder(false) - err = ProcessNewChunk(w, r, false) + err, _ = ProcessNewChunk(w, r, false, "") test.IsNotNil(t, err) w = httptest.NewRecorder() r = getFileUploadRecorder(true) - err = ProcessNewChunk(w, r, false) + err, _ = ProcessNewChunk(w, r, false, "") test.IsNil(t, err) response, err := io.ReadAll(w.Result().Body) test.IsNil(t, err) diff --git a/internal/webserver/headers/Headers.go b/internal/webserver/headers/Headers.go index ec038d1..3de38e1 100644 --- a/internal/webserver/headers/Headers.go +++ b/internal/webserver/headers/Headers.go @@ -1,20 +1,27 @@ package headers import ( - "github.com/forceu/gokapi/internal/models" "net/http" + "strconv" "time" + + "github.com/forceu/gokapi/internal/models" ) // Write sets headers to either display the file inline or to force download, the content type // and if the file is encrypted, the creation timestamp to now -func Write(file models.File, w http.ResponseWriter, forceDownload bool) { +func Write(file models.File, w http.ResponseWriter, forceDownload, serveDecrypted bool) { if forceDownload { w.Header().Set("Content-Disposition", "attachment; filename=\""+file.Name+"\"") } else { w.Header().Set("Content-Disposition", "inline; filename=\""+file.Name+"\"") } - w.Header().Set("Content-Type", file.ContentType) + if !file.RequiresClientDecryption() || serveDecrypted { + w.Header().Set("Content-Type", file.ContentType) + w.Header().Set("Content-Length", strconv.FormatInt(file.SizeBytes, 10)) + } else { + w.Header().Set("Content-Type", "application/octet-stream") + } if file.Encryption.IsEncrypted { w.Header().Set("Accept-Ranges", "bytes") diff --git a/internal/webserver/headers/Headers_test.go b/internal/webserver/headers/Headers_test.go index 9312134..701dc0e 100644 --- a/internal/webserver/headers/Headers_test.go +++ b/internal/webserver/headers/Headers_test.go @@ -1,22 +1,23 @@ package headers import ( + "testing" + "github.com/forceu/gokapi/internal/models" "github.com/forceu/gokapi/internal/test" - "testing" ) func TestWriteDownloadHeaders(t *testing.T) { file := models.File{Name: "testname", ContentType: "testtype"} w, _ := test.GetRecorder("GET", "/test", nil, nil, nil) - Write(file, w, true) + Write(file, w, true, false) test.IsEqualString(t, w.Result().Header.Get("Content-Disposition"), "attachment; filename=\"testname\"") w, _ = test.GetRecorder("GET", "/test", nil, nil, nil) - Write(file, w, false) + Write(file, w, false, false) test.IsEqualString(t, w.Result().Header.Get("Content-Disposition"), "inline; filename=\"testname\"") test.IsEqualString(t, w.Result().Header.Get("Content-Type"), "testtype") file.Encryption.IsEncrypted = true w, _ = test.GetRecorder("GET", "/test", nil, nil, nil) - Write(file, w, false) + Write(file, w, false, false) test.IsEqualString(t, w.Result().Header.Get("Accept-Ranges"), "bytes") } diff --git a/internal/webserver/web/static/apidocumentation/openapi.json b/internal/webserver/web/static/apidocumentation/openapi.json index e55613d..82c5477 100644 --- a/internal/webserver/web/static/apidocumentation/openapi.json +++ b/internal/webserver/web/static/apidocumentation/openapi.json @@ -31,6 +31,9 @@ { "name": "auth" }, + { + "name": "uploadrequest" + }, { "name": "user" }, @@ -105,6 +108,183 @@ } } }, + "/files/downloadzip": { + "get": { + "tags": [ + "files" + ], + "summary": "Downloads files as ZIP file with optionally increasing the download counter", + "description": "This API call downloads multiple file that are not expired and increasing their download counter is disabled by default. Can be set up to return a pre-signed URL instead of the zip file itself, which is valid for 30 seconds and can be accessed by any registered user. End-to-end encrypted files and encrypted files stored on cloud servers cannot be downloaded. Returns 404 if an invalid/expired ID was passed. Requires API permission DOWNLOAD. To download files that were not uploaded by the user, the user needs to have the user permission LIST", + "operationId": "downloadzip", + "parameters": [ + { + "name": "ids", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "IDs of files to be downloaded seperated by comma" + }, + { + "name": "filename", + "in": "header", + "required": false, + "schema": { + "type": "string" + }, + "description": "The filename for the new Zip file. If the filename includes non-ANSI characters, you can encode them with base64, by adding 'base64:' at the beginning, e.g. 'base64:ZmlsZW5hbWU='" + }, + { + "name": "increaseCounter", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Increase counter if set to true" + }, + { + "name": "presignUrl", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Return a pre-signed URL instead of the actual file. Valid for one download within 30 seconds and can only be used by logged in users. When this option is set, download counter cannot be increased." + } + ], + "security": [ + { + "apikey": [ + "DOWNLOAD" + ] + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/octet-stream": { + "schema": { + "type": "object", + "format": "binary" + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "Result": { + "type": "string", + "example": "OK" + }, + "downloadUrl": { + "type": "string", + "format": "uri", + "example": "http://gokapi.local:53842/downloadPresigned?key=xieph5ae1leph6Heel0Hoo9uth1eiY9xei8IiboPoothie0ahm6tutufoo2s" + } + } + } + } + } + }, + "400": { + "description": "Invalid input or trying to download an end-to-end encrypted file" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Invalid ID provided or file has expired" + } + } + } + }, + "/files/download/{id}": { + "get": { + "tags": [ + "files" + ], + "summary": "Downloads file with optionally increasing the download counter", + "description": "This API call downloads a file that is not expired and increasing its download counter is disabled by default. Can be set up to return a pre-signed URL instead of the file itself, which is valid for 30 seconds and can be accessed by any registered user. End-to-end encrypted files and encrypted files stored on cloud servers cannot be downloaded. Returns 404 if an invalid/expired ID was passed. Requires API permission DOWNLOAD. To download files that were not uploaded by the user, the user needs to have the user permission LIST", + "operationId": "downloadsingle", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of file to be downloaded" + }, + { + "name": "increaseCounter", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Increase counter if set to true" + }, + { + "name": "presignUrl", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Return a pre-signed URL instead of the actual file. Valid for one download within 30 seconds and can only be used by logged in users. When this option is set, download counter cannot be increased." + } + ], + "security": [ + { + "apikey": [ + "DOWNLOAD" + ] + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/octet-stream": { + "schema": { + "type": "object", + "format": "binary" + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "Result": { + "type": "string", + "example": "OK" + }, + "downloadUrl": { + "type": "string", + "format": "uri", + "example": "http://gokapi.local:53842/downloadPresigned?key=xieph5ae1leph6Heel0Hoo9uth1eiY9xei8IiboPoothie0ahm6tutufoo2s" + } + } + } + } + } + }, + "400": { + "description": "Invalid input or trying to download an end-to-end encrypted file" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Invalid ID provided or file has expired" + } + } + } + }, "/files/list": { "get": { "tags": [ @@ -120,6 +300,17 @@ ] } ], + "parameters": [ + { + "name": "showFileRequests", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Set to true, to include files uploaded through file requests" + } + ], "responses": { "200": { "description": "Operation successful", @@ -199,7 +390,7 @@ "chunk" ], "summary": "Uploads a new chunk", - "description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text! To upload an end-to-end encrypted file, use gokapi-cli. Requires API permission UPLOAD", + "description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text! To upload an end-to-end encrypted file, use gokapi-cli. Chunks must be at least 5MB in size, unless last chunk of file. Requires API permission UPLOAD", "operationId": "chunkadd", "security": [ { @@ -316,6 +507,264 @@ "schema": { "type": "string" } + }, + { + "name": "nonblocking", + "in": "header", + "description": "If set to true, the call returns without waiting for the file processing to finish.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadResult" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + } + } + } + }, + "/uploadrequest/chunk/reserve": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Requests a UUID for uploading a new file for a file request", + "description": "Requests an UUID that can be used for uplading a new file. The chunks for the new file have to use this UUID. The first chunk needs to be uploaded latest 4 minutes after requesting the UUID. Requires API key associated with the file request", + "operationId": "chunkreserve", + "security": [ + { + "apikey": [ + "FileRequest" + ] + } + ], + "parameters": [ + { + "name": "id", + "in": "header", + "description": "The file request ID", + "required": true, + "schema": { + "type": "string" + } + }], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/chunkReserveResult" + } + } + } + }, + "400": { + "description": "Invalid ID or the file request does not accept any more files" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "429": { + "description": "If too many chunks are currently requested, the caller has to wait a couple of seconds and try again. The rate limit is only for file requests that are not limited in file count" + } + } + } + }, + "/uploadrequest/chunk/unreserve": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Frees a reserved UUID if upload was cancelled", + "description": "This call frees a reserved UUID, so that it does not count towards the quota anymore. Used if an upload was cancelled or failed. Requires API key associated with the file request", + "operationId": "chunkunreserve", + "security": [ + { + "apikey": [ + "FileRequest" + ] + } + ], + "parameters": [ + { + "name": "id", + "in": "header", + "description": "The file request ID", + "required": true, + "schema": { + "type": "string" + } + },{ + "name": "uuid", + "in": "header", + "description": "The reserved UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + } + } + }, + "400": { + "description": "Invalid ID or the file request does not accept any more files" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "429": { + "description": "If too many chunks are currently requested, the caller has to wait a couple of seconds and try again. The rate limit is only for file requests that are not limited in file count" + } + } + } + }, + "/uploadrequest/chunk/add": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Uploads a new chunk for a file request", + "description": "Uploads a file in chunks. Parallel uploading is supported. Must call /uploadrequest/chunk/reserve to request an UUID first and must call /uploadrequest/chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text! Chunks must be at least 5MB in size, unless last chunk of file. Requires API key associated with the file request", + "operationId": "chunkaddur", + "security": [ + { + "apikey": [ + "FileRequest" + ] + } + ], + "parameters": [ + { + "name": "fileRequestId", + "in": "header", + "description": "The ID of the upload request", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "nonblocking", + "in": "header", + "description": "If set to true, the call returns without waiting for the file processing to finish.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/chunking" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/chunkUploadResult" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + } + } + } + }, + "/uploadrequest/chunk/complete": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Finalises uploaded chunks", + "description": "Needs to be called after all chunks have been uploaded. Adds the uploaded file to Gokapi. Requires API permission UPLOAD", + "operationId": "chunkurcomplete", + "security": [ + { + "apikey": [ + "FileRequest" + ] + } + ], + "parameters": [ + { + "name": "uuid", + "in": "header", + "description": "The unique ID that was used for the uploaded chunks", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "fileRequestId", + "in": "header", + "description": "The file request ID that was used for the uploaded chunks", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filename", + "in": "header", + "description": "The filename of the uploaded file. If the filename includes non-ANSI characters, you can encode them with base64, by adding 'base64:' at the beginning, e.g. 'base64:ZmlsZW5hbWU='", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filesize", + "in": "header", + "description": "The total filesize of the uploaded file in bytes", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "contenttype", + "in": "header", + "description": "The MIME content type. If empty, application/octet-stream will be used.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -343,7 +792,7 @@ "tags": [ "logs" ], - "summary": "Deletes entries from the logfilek", + "summary": "Deletes entries from the logfile", "description": "This API call deletes all lines before older than a cutoff date. Requires API permission MANAGE_LOGS and user needs to be admin or super-admin.", "operationId": "logsdelete", "security": [ @@ -971,6 +1420,7 @@ "PERM_EDIT", "PERM_DELETE", "PERM_REPLACE", + "PERM_MANAGE_FILE_REQUESTS", "PERM_MANAGE_LOGS", "PERM_MANAGE_USERS", "PERM_API_MOD" @@ -1050,6 +1500,244 @@ } } }, + "/uploadrequest/list": { + "get": { + "tags": [ + "uploadrequest" + ], + "summary": "Lists all file requests", + "description": "This API call lists all file requests. Requires API permission GUEST_UPLOAD. To view file requests created by a different user, the user needs to have the user permission LIST", + "operationId": "ulist", + "security": [ + { + "apikey": [ + "PERM_GUEST_UPLOAD" + ] + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "type": "array", + "nullable": false, + "items": { + "$ref": "#/components/schemas/FileRequest" + } + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + } + } + } + }, + "/uploadrequest/list/{id}": { + "get": { + "tags": [ + "uploadrequest" + ], + "summary": "Get file request by ID", + "description": "This API call lists a specific file request. Returns 404 if an invalid ID was passed. Requires API permission GUEST_UPLOAD. To view file requests from a different user, the user needs to have the user permission LIST", + "operationId": "ulistbyid", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of file request" + } + ], + "security": [ + { + "apikey": [ + "PERM_GUEST_UPLOAD" + ] + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileRequest" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Invalid ID provided" + } + } + } + }, + "/uploadrequest/save": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Creates a new or saves an existing upload request", + "description": "This API call creates a new upload request if the parameter ID is not submitted. If editing a request, only the submitted parameters will be changed. To save a request of a different user, the user requires the user permission EDIT to execute this call. Requires API permission GUEST_UPLOAD", + "operationId": "uploadrequestsave", + "security": [ + { + "apikey": [ + "PERM_GUEST_UPLOAD" + ] + } + ], + "parameters": [ + { + "name": "id", + "in": "header", + "description": "The request to be saved. If empty, a new request will be created", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "header", + "description": "The given name for the request. If the name includes non-ANSI characters, you can encode them with base64, by adding 'base64:' at the beginning, e.g. 'base64:ZmlsZW5hbWU='", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + }, + { + "name": "notes", + "in": "header", + "description": "The public notes for the request. If the notes includes non-ANSI characters, you can encode them with base64, by adding 'base64:' at the beginning, e.g. 'base64:ZmlsZW5hbWU='", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + }, + { + "name": "expiry", + "in": "header", + "description": "The expiry as a UTC unix timestamp. No expiry if 0", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "integer" + } + }, + { + "name": "maxfiles", + "in": "header", + "description": "The amount of files that can be uploaded. No limit if 0", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "integer" + } + }, + { + "name": "maxsize", + "in": "header", + "description": "The maximum size in Megabytes per file. No limit if 0", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileRequest" + } + } + } + }, + "400": { + "description": "Invalid ID or parameters supplied" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Upload request not found" + } + } + } + }, + "/uploadrequest/delete": { + "delete": { + "tags": [ + "uploadrequest" + ], + "summary": "Deletes the upload request and all associated files", + "description": "This API call deletes the given file requests. If files are associated with the request, they will also be deleted. To delete a request of a different user, the user requires the user permission DELETE to execute this call. Requires API permission GUEST_UPLOAD", + "operationId": "uploadrequestdelete", + "security": [ + { + "apikey": [ + "PERM_GUEST_UPLOAD" + ] + } + ], + "parameters": [ + { + "name": "id", + "in": "header", + "description": "The request to be deleted", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Operation successful" + }, + "400": { + "description": "Invalid ID or parameters supplied" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Upload request not found" + } + } + } + }, "/user/create": { "post": { "tags": [ @@ -1156,7 +1844,8 @@ "PERM_DELETE", "PERM_LOGS", "PERM_API", - "PERM_USERS" + "PERM_USERS", + "PERM_GUEST_UPLOAD" ] } }, @@ -1257,7 +1946,7 @@ "user" ], "summary": "Deletes the selected user", - "description": "This API call changes deletes the given user. If files are associated with the user, they will be linked with the user that initiated the deletion. If deleteFiles is \"true\", the files will be deleted instead. Requires API permission MANAGE_USERS", + "description": "This API call deletes the given user. If files are associated with the user, they will be linked with the user that initiated the deletion. If deleteFiles is \"true\", the files will be deleted instead. Requires API permission MANAGE_USERS", "operationId": "userdelete", "security": [ { @@ -1397,6 +2086,11 @@ "description": "The public hotlink URL for the file", "example": "https://gokapi.server/h/tDMs0U8MvRFwK69PfjagI7F87C13UVeQuOGDvtCG.jpg" }, + "FileRequestId": { + "type": "string", + "description": "If the file belongs to an upload request, the ID is set in this field", + "example": "cnMEWsrMwSx1wyr" + }, "UploadDate": { "type": "integer", "description": "UTC timestamp of file upload", @@ -1467,6 +2161,11 @@ "type": "boolean", "example": "false" }, + "IsFileRequest": { + "description": "True if the file belongs to an upload request", + "type": "boolean", + "example": "true" + }, "UploaderId": { "description": "The user ID of the uploader", "type": "integer", @@ -1476,6 +2175,104 @@ "description": "File is a struct used for saving information about an uploaded file", "x-go-package": "Gokapi/internal/models" }, + "FileRequest": { + "type": "object", + "description": "Represents a file upload request and its associated metadata.", + "properties": { + "id": { + "type": "string", + "description": "The internal ID of the file request", + "example": "caep3Ooquu6phoo" + }, + "userid": { + "type": "integer", + "format": "int32", + "description": "The user ID of the owner", + "example": "2" + }, + "maxfiles": { + "type": "integer", + "format": "int32", + "description": "The maximum number of files allowed or 0 if unlimited", + "example": "20" + }, + "maxsize": { + "type": "integer", + "format": "int32", + "description": "The maximum file size allowed in MB or 0 if unlimited", + "example": "0" + }, + "CombinedMaxSize": { + "type": "integer", + "format": "int32", + "description": "The lesser of MaxSize and the server's max upload size.", + "example": "0" + }, + "expiry": { + "type": "integer", + "format": "int64", + "description": "The expiry time of the file request as a Unix timestamp or 0 if no expiry", + "example": "1767022842" + }, + "creationdate": { + "type": "integer", + "format": "int64", + "description": "The timestamp when the file request was created", + "example": "1767021842" + }, + "name": { + "type": "string", + "description": "The given name for the file request", + "example": "Book list entries" + }, + "notes": { + "type": "string", + "description": "The public notes for the file request", + "example": "Please make sure to upload revision 1 files" + }, + "apikey": { + "type": "string", + "description": "The API key that is used for uploading files for this request", + "example": "wrg5L7ldIUiXd27mIH1Fh0gGIyrekC" + }, + "uploadedfiles": { + "type": "integer", + "format": "int32", + "description": "The number of uploaded files for this request", + "example": "3" + }, + "reserveduploads": { + "type": "integer", + "format": "int32", + "description": "The number of current uploads, which have not been finalised yet", + "example": "1" + }, + "lastupload": { + "type": "integer", + "format": "int64", + "description": "The timestamp of the last upload", + "example": "1767022002" + }, + "totalfilesize": { + "type": "integer", + "format": "int64", + "description": "The total size of all uploaded files in bytes", + "example": "544332214" + }, + "fileidlist": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of the IDs of all uploaded files", + "example": [ + "cohng2weGh", + "see5Ohng9y", + "EoYiog4Che" + ] + } + } + }, "chunkUploadResult": { "type": "object", "properties": { @@ -1487,6 +2284,21 @@ "description": "Result after uploading a chunk", "x-go-package": "Gokapi/internal/models" }, + "chunkReserveResult": { + "type": "object", + "properties": { + "Result": { + "type": "string", + "example": "OK" + }, + "Uuid": { + "type": "string", + "example": "naPh9athuyeimie3uu8pingoyi2Sho" + } + }, + "description": "Result after uploading a chunk", + "x-go-package": "Gokapi/internal/models" + }, "UploadResult": { "type": "object", "properties": { @@ -1640,7 +2452,7 @@ "properties": { "file": { "type": "string", - "description": "The file to be uploaded", + "description": "The chunk to be uploaded", "format": "binary" }, "uuid": { diff --git a/internal/webserver/web/static/assets/dist/js/base64.min.js b/internal/webserver/web/static/assets/dist/js/base64.min.js deleted file mode 100644 index 744ecb0..0000000 --- a/internal/webserver/web/static/assets/dist/js/base64.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/** -* -* Base64 encode / decode -* http://www.webtoolkit.info/ -* -**/ -var Base64={_keyStr:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",encode:function(r){var t,e,o,a,h,n,c,d="",C=0;for(r=Base64._utf8_encode(r);C>2,h=(3&t)<<4|(e=r.charCodeAt(C++))>>4,n=(15&e)<<2|(o=r.charCodeAt(C++))>>6,c=63&o,isNaN(e)?n=c=64:isNaN(o)&&(c=64),d=d+this._keyStr.charAt(a)+this._keyStr.charAt(h)+this._keyStr.charAt(n)+this._keyStr.charAt(c);return d},decode:function(r){var t,e,o,a,h,n,c="",d=0;for(r=r.replace(/[^A-Za-z0-9\+\/\=]/g,"");d>4,e=(15&a)<<4|(h=this._keyStr.indexOf(r.charAt(d++)))>>2,o=(3&h)<<6|(n=this._keyStr.indexOf(r.charAt(d++))),c+=String.fromCharCode(t),64!=h&&(c+=String.fromCharCode(e)),64!=n&&(c+=String.fromCharCode(o));return c=Base64._utf8_decode(c)},_utf8_encode:function(r){r=r.replace(/\r\n/g,"\n");for(var t="",e=0;e127&&o<2048?(t+=String.fromCharCode(o>>6|192),t+=String.fromCharCode(63&o|128)):(t+=String.fromCharCode(o>>12|224),t+=String.fromCharCode(o>>6&63|128),t+=String.fromCharCode(63&o|128))}return t},_utf8_decode:function(r){for(var t="",e=0,o=c1=c2=0;e191&&o<224?(c2=r.charCodeAt(e+1),t+=String.fromCharCode((31&o)<<6|63&c2),e+=2):(c2=r.charCodeAt(e+1),c3=r.charCodeAt(e+2),t+=String.fromCharCode((15&o)<<12|(63&c2)<<6|63&c3),e+=3);return t}}; diff --git a/internal/webserver/web/static/css/cover.css b/internal/webserver/web/static/css/cover.css index bbfd3db..fed5109 100644 --- a/internal/webserver/web/static/css/cover.css +++ b/internal/webserver/web/static/css/cover.css @@ -28,6 +28,20 @@ body { -webkit-box-pack: center; justify-content: center; } +body::after { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + /* This creates the inset border effect */ + box-shadow: inset 0 0 5rem rgba(0, 0, 0, 0.5); + /* Essential: allows clicking buttons/inputs through the shadow */ + pointer-events: none; + /* Ensures it stays above the background but below modals if needed */ + z-index: 10; +} td { vertical-align: middle; @@ -175,7 +189,7 @@ a:hover { } .toastdeprecation { - background-color: #8b0000; + background-color: #8b0000; } .toastnotification.show { @@ -243,9 +257,17 @@ a:hover { } @keyframes perm-pulse { - 0% { opacity: 1; } - 50% { opacity: 0.5; } - 100% { opacity: 1; } + 0% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } } .perm-nochange:hover { @@ -257,9 +279,20 @@ a:hover { } @keyframes perm-nowgranted-pulse { - 0% { transform: scale(1.15); color: #4dff4d; } - 50% { transform: scale(1.3); color: #008800; } - 100% { transform: scale(1.15); color: #0edf00; } + 0% { + transform: scale(1.15); + color: #4dff4d; + } + + 50% { + transform: scale(1.3); + color: #008800; + } + + 100% { + transform: scale(1.15); + color: #0edf00; + } } .perm-nownotgranted { @@ -267,15 +300,29 @@ a:hover { } @keyframes perm-nownotgranted-pulse { - 0% { transform: scale(1.15); color: #ff4d4d; } - 50% { transform: scale(1.3); color: #ff0000; } - 100% { transform: scale(1.15); color: ##9f9999; } + 0% { + transform: scale(1.15); + color: #ff4d4d; + } + + 50% { + transform: scale(1.3); + color: #ff0000; + } + + 100% { + transform: scale(1.15); + color: ##9f9999; + } } .prevent-select { - -webkit-user-select: none; /* Safari */ - -ms-user-select: none; /* IE 10 and IE 11 */ - user-select: none; /* Standard syntax */ + -webkit-user-select: none; + /* Safari */ + -ms-user-select: none; + /* IE 10 and IE 11 */ + user-select: none; + /* Standard syntax */ } @@ -287,65 +334,234 @@ a:hover { /* Define a subtle animation */ @keyframes subtleHighlight { - 0% { - background-color: #444950; /* Light gray for dark background */ - } - 100% { - background-color: transparent; /* Original background */ - } + 0% { + background-color: #444950; + /* Light gray for dark background */ + } + + 100% { + background-color: transparent; + /* Original background */ + } } @keyframes subtleHighlightNewJson { - 0% { - background-color: green; /* Pale green for new items */ - } - 100% { - background-color: transparent; - } + 0% { + background-color: green; + /* Pale green for new items */ + } + + 100% { + background-color: transparent; + } } /* Apply the animation to the updated table cells */ .updatedDownloadCount { - animation: subtleHighlight 0.5s ease-out; + animation: subtleHighlight 0.5s ease-out; } +.newFileRequest { + animation: subtleHighlightNewJson 0.7s ease-out; +} + .newApiKey { - animation: subtleHighlightNewJson 0.7s ease-out; + animation: subtleHighlightNewJson 0.7s ease-out; } .newUser { - animation: subtleHighlightNewJson 0.7s ease-out; + animation: subtleHighlightNewJson 0.7s ease-out; } .newItem { - animation: subtleHighlightNewJson 1.5s ease-out; + animation: subtleHighlightNewJson 1.5s ease-out; } @keyframes fadeOut { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } } .rowDeleting { - animation: fadeOut 0.3s ease-out forwards; + animation: fadeOut 0.3s ease-out forwards; } .highlighted-password { - background-color: #444; /* Dark gray background for subtle contrast */ - color: #ddd; /* Light gray text */ - padding: 2px 6px; - border-radius: 4px; - font-weight: bold; - font-family: monospace; - display: inline-block; /* Keeps the styling inline but ensures proper padding */ - margin-left: 8px; /* Adds space between the label and the password */ - border: 1px solid #555; /* Slight border to define the element */ + background-color: #444; + /* Dark gray background for subtle contrast */ + color: #ddd; + /* Light gray text */ + padding: 2px 6px; + border-radius: 4px; + font-weight: bold; + font-family: monospace; + display: inline-block; + /* Keeps the styling inline but ensures proper padding */ + margin-left: 8px; + /* Adds space between the label and the password */ + border: 1px solid #555; + /* Slight border to define the element */ +} + +/* Slightly lighter than table-dark */ +.filelist-item { + background-color: rgba(255, 255, 255, 0.04); +} + +.filelist-item:hover { + background-color: rgba(255, 255, 255, 0.08); +} + + +tr.no-bottom-border td { + border-bottom: none +} + +.filerequest-item:hover>td { + background-color: rgba(255, 255, 255, 0.08); +} + +.filerequest-item>td { + transition: background-color 0.15s ease-in-out; +} + +.collapse-toggle i { + display: inline-block; + transition: transform 0.2s ease; +} + +.collapse-toggle[aria-expanded="true"] i { + transform: rotate(180deg); +} + +.collapse-toggle:hover { + opacity: 0.8; +} + +.collapse-toggle { + padding: 0.25rem; +} + +.remove-entry-btn:hover { + opacity: 0.8; +} + + +.upload-box { + border: 2px dashed #6c757d; + border-radius: 8px; + padding: 2rem; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.upload-box:hover { + background-color: rgba(255, 255, 255, 0.05); +} + + +.info-box { + background-color: rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 1rem; + margin-bottom: 1.5rem; + text-align: left; +} + +.info-box h6 { + margin-bottom: 0.5rem; +} + +.info-box ul { + margin-bottom: 0; + padding-left: 1.2rem; +} + +.callout { + padding: 20px; + margin: 10px 20px; + border: 1px solid #eee; + border-left-width: 5px; + border-radius: 3px; + + h4 { + margin-top: 0; + margin-bottom: 5px; + } + + p:last-child { + margin-bottom: 0; + } + + code { + border-radius: 3px; + } + + &+.bs-callout { + margin-top: -5px; + } +} + +.upload-box { + border: 2px dashed rgba(255, 255, 255, 0.2); + padding: 2rem; + transition: all 0.2s ease; + cursor: pointer; + display: block; +} + +.upload-box.highlight { + border-color: #0d6efd; + background-color: rgba(13, 110, 253, 0.05); +} + + +.pu-file-list { + margin-top: 1.5rem; +} + +.pu-file-item { + display: flex; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + font-size: 0.95rem; +} + +.pu-file-item .file-name { + flex: 1; + text-align: left; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin-right: 1rem; +} + + +.pu-file-item .upload-status { + width: 350px; + text-align: right; + margin-right: 1rem; + flex-shrink: 0; + opacity: 0.75; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pu-file-item .file-size { + width: 80px; + text-align: right; + margin-right: 12px; + flex-shrink: 0; + opacity: 0.75; } diff --git a/internal/webserver/web/static/css/min/gokapi.min.5.css b/internal/webserver/web/static/css/min/gokapi.min.5.css index a1df7dd..b15e87b 100644 --- a/internal/webserver/web/static/css/min/gokapi.min.5.css +++ b/internal/webserver/web/static/css/min/gokapi.min.5.css @@ -1 +1 @@ -.btn-secondary,.btn-secondary:hover,.btn-secondary:focus{color:#333;text-shadow:none}body{background:url(../../assets/background.jpg)no-repeat 50% fixed;-webkit-background-size:cover;-moz-background-size:cover;-o-background-size:cover;background-size:cover;display:-ms-flexbox;display:-webkit-box;display:flex;-ms-flex-pack:center;-webkit-box-pack:center;justify-content:center}td{vertical-align:middle;position:relative}a{color:inherit}a:hover{color:inherit;filter:brightness(80%)}.dropzone{background:#2f343a!important;color:#fff;border-radius:5px}.dropzone:hover{background:#33393f!important;color:#fff;border-radius:5px}.card{margin:0 auto;float:none;margin-bottom:10px;border:2px solid #33393f}.card-body{background-color:#212529;color:#ddd}.card-title{font-weight:900}.admin-input{text-align:center}.form-control:disabled{background:#bababa}.break{flex-basis:100%;height:0}.bd-placeholder-img{font-size:1.125rem;text-anchor:middle;-webkit-user-select:none;-moz-user-select:none;user-select:none}@media(min-width:768px){.bd-placeholder-img-lg{font-size:3.5rem}.break{flex-basis:0}}.masthead{margin-bottom:2rem}.masthead-brand{margin-bottom:0}.nav-masthead .nav-link{padding:.25rem 0;font-weight:700;color:rgba(255,255,255,.5);background-color:transparent;border-bottom:.25rem solid transparent}.nav-masthead .nav-link:hover,.nav-masthead .nav-link:focus{border-bottom-color:rgba(255,255,255,.25)}.nav-masthead .nav-link+.nav-link{margin-left:1rem}.nav-masthead .active{color:#fff;border-bottom-color:#fff}#qroverlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,.3)}#qrcode{position:absolute;top:50%;left:50%;margin-top:-105px;margin-left:-105px;width:210px;height:210px;border:5px solid #fff}.toastnotification{pointer-events:none;position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background-color:#333;color:#fff;padding:15px;border-radius:5px;box-shadow:0 2px 5px rgba(0,0,0,.3);opacity:0;transition:opacity .3s ease-in-out;z-index:9999}.toastdeprecation{background-color:#8b0000}.toastnotification.show{opacity:1;pointer-events:auto}.toast-undo{margin-left:20px;color:#4fc3f7;cursor:pointer;text-decoration:underline;font-weight:700;pointer-events:auto}.toast-undo:hover{color:#81d4fa}.toastnotification:not(.show){pointer-events:none!important}.toastnotification:not(.show) .toast-undo{pointer-events:none}.perm-granted{cursor:pointer;color:#0edf00}.perm-notgranted{cursor:pointer;color:#9f9999}.perm-unavailable{color:#525252}.perm-processing{pointer-events:none;color:#e5eb00;animation:perm-pulse 1s infinite}.perm-nochange{cursor:default}.perm-granted:not(.perm-nochange):hover{color:#ff4d4d}.perm-notgranted:not(.perm-nochange):hover{color:#4dff4d}.perm-granted:not(.perm-nochange),.perm-notgranted:not(.perm-nochange){transition:color .15s ease,transform .1s ease}@keyframes perm-pulse{0%{opacity:1}50%{opacity:.5}100%{opacity:1}}.perm-nochange:hover{transform:none}.perm-nowgranted{animation:perm-nowgranted-pulse .5s ease forwards}@keyframes perm-nowgranted-pulse{0%{transform:scale(1.15);color:#4dff4d}50%{transform:scale(1.3);color:#080}100%{transform:scale(1.15);color:#0edf00}}.perm-nownotgranted{animation:perm-nownotgranted-pulse .5s ease forwards}@keyframes perm-nownotgranted-pulse{0%{transform:scale(1.15);color:#ff4d4d}50%{transform:scale(1.3);color:red}100%{transform:scale(1.15);color:##9f9999}}.prevent-select{-webkit-user-select:none;-ms-user-select:none;user-select:none}.gokapi-dialog{background-color:#212529;color:#ddd}@keyframes subtleHighlight{0%{background-color:#444950}100%{background-color:transparent}}@keyframes subtleHighlightNewJson{0%{background-color:green}100%{background-color:transparent}}.updatedDownloadCount{animation:subtleHighlight .5s ease-out}.newApiKey{animation:subtleHighlightNewJson .7s ease-out}.newUser{animation:subtleHighlightNewJson .7s ease-out}.newItem{animation:subtleHighlightNewJson 1.5s ease-out}@keyframes fadeOut{0%{opacity:1}100%{opacity:0}}.rowDeleting{animation:fadeOut .3s ease-out forwards}.highlighted-password{background-color:#444;color:#ddd;padding:2px 6px;border-radius:4px;font-weight:700;font-family:monospace;display:inline-block;margin-left:8px;border:1px solid #555}.filename{font-weight:700;font-size:14px;margin-bottom:5px}.upload-progress-container{display:flex;align-items:center}.upload-progress-bar{position:relative;height:10px;background-color:#eee;flex:1;margin-right:10px;border-radius:4px}.upload-progress-bar-progress{position:absolute;top:0;left:0;height:100%;background-color:#0a0;border-radius:4px;transition:width .2s ease-in-out}.upload-progress-info{font-size:12px}.us-container{margin-top:10px;margin-bottom:20px}.uploaderror{font-weight:700;color:red;margin-bottom:5px}.uploads-container{background-color:#2f343a;border:2px solid rgba(0,0,0,.3);border-radius:5px;margin-left:0;margin-right:0;max-width:none;visibility:hidden} \ No newline at end of file +.btn-secondary,.btn-secondary:hover,.btn-secondary:focus{color:#333;text-shadow:none}body{background:url(../../assets/background.jpg)no-repeat 50% fixed;-webkit-background-size:cover;-moz-background-size:cover;-o-background-size:cover;background-size:cover;display:-ms-flexbox;display:-webkit-box;display:flex;-ms-flex-pack:center;-webkit-box-pack:center;justify-content:center}body::after{content:"";position:fixed;top:0;left:0;width:100%;height:100%;box-shadow:inset 0 0 5rem rgba(0,0,0,.5);pointer-events:none;z-index:10}td{vertical-align:middle;position:relative}a{color:inherit}a:hover{color:inherit;filter:brightness(80%)}.dropzone{background:#2f343a!important;color:#fff;border-radius:5px}.dropzone:hover{background:#33393f!important;color:#fff;border-radius:5px}.card{margin:0 auto;float:none;margin-bottom:10px;border:2px solid #33393f}.card-body{background-color:#212529;color:#ddd}.card-title{font-weight:900}.admin-input{text-align:center}.form-control:disabled{background:#bababa}.break{flex-basis:100%;height:0}.bd-placeholder-img{font-size:1.125rem;text-anchor:middle;-webkit-user-select:none;-moz-user-select:none;user-select:none}@media(min-width:768px){.bd-placeholder-img-lg{font-size:3.5rem}.break{flex-basis:0}}.masthead{margin-bottom:2rem}.masthead-brand{margin-bottom:0}.nav-masthead .nav-link{padding:.25rem 0;font-weight:700;color:rgba(255,255,255,.5);background-color:initial;border-bottom:.25rem solid transparent}.nav-masthead .nav-link:hover,.nav-masthead .nav-link:focus{border-bottom-color:rgba(255,255,255,.25)}.nav-masthead .nav-link+.nav-link{margin-left:1rem}.nav-masthead .active{color:#fff;border-bottom-color:#fff}#qroverlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,.3)}#qrcode{position:absolute;top:50%;left:50%;margin-top:-105px;margin-left:-105px;width:210px;height:210px;border:5px solid #fff}.toastnotification{pointer-events:none;position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background-color:#333;color:#fff;padding:15px;border-radius:5px;box-shadow:0 2px 5px rgba(0,0,0,.3);opacity:0;transition:opacity .3s ease-in-out;z-index:9999}.toastdeprecation{background-color:#8b0000}.toastnotification.show{opacity:1;pointer-events:auto}.toast-undo{margin-left:20px;color:#4fc3f7;cursor:pointer;text-decoration:underline;font-weight:700;pointer-events:auto}.toast-undo:hover{color:#81d4fa}.toastnotification:not(.show){pointer-events:none!important}.toastnotification:not(.show) .toast-undo{pointer-events:none}.perm-granted{cursor:pointer;color:#0edf00}.perm-notgranted{cursor:pointer;color:#9f9999}.perm-unavailable{color:#525252}.perm-processing{pointer-events:none;color:#e5eb00;animation:perm-pulse 1s infinite}.perm-nochange{cursor:default}.perm-granted:not(.perm-nochange):hover{color:#ff4d4d}.perm-notgranted:not(.perm-nochange):hover{color:#4dff4d}.perm-granted:not(.perm-nochange),.perm-notgranted:not(.perm-nochange){transition:color .15s ease,transform .1s ease}@keyframes perm-pulse{0%{opacity:1}50%{opacity:.5}100%{opacity:1}}.perm-nochange:hover{transform:none}.perm-nowgranted{animation:perm-nowgranted-pulse .5s ease forwards}@keyframes perm-nowgranted-pulse{0%{transform:scale(1.15);color:#4dff4d}50%{transform:scale(1.3);color:#080}100%{transform:scale(1.15);color:#0edf00}}.perm-nownotgranted{animation:perm-nownotgranted-pulse .5s ease forwards}@keyframes perm-nownotgranted-pulse{0%{transform:scale(1.15);color:#ff4d4d}50%{transform:scale(1.3);color:red}100%{transform:scale(1.15);color:##9f9999}}.prevent-select{-webkit-user-select:none;-ms-user-select:none;user-select:none}.gokapi-dialog{background-color:#212529;color:#ddd}@keyframes subtleHighlight{0%{background-color:#444950}100%{background-color:initial}}@keyframes subtleHighlightNewJson{0%{background-color:green}100%{background-color:initial}}.updatedDownloadCount{animation:subtleHighlight .5s ease-out}.newFileRequest{animation:subtleHighlightNewJson .7s ease-out}.newApiKey{animation:subtleHighlightNewJson .7s ease-out}.newUser{animation:subtleHighlightNewJson .7s ease-out}.newItem{animation:subtleHighlightNewJson 1.5s ease-out}@keyframes fadeOut{0%{opacity:1}100%{opacity:0}}.rowDeleting{animation:fadeOut .3s ease-out forwards}.highlighted-password{background-color:#444;color:#ddd;padding:2px 6px;border-radius:4px;font-weight:700;font-family:monospace;display:inline-block;margin-left:8px;border:1px solid #555}.filelist-item{background-color:rgba(255,255,255,4%)}.filelist-item:hover{background-color:rgba(255,255,255,8%)}tr.no-bottom-border td{border-bottom:none}.filerequest-item:hover>td{background-color:rgba(255,255,255,8%)}.filerequest-item>td{transition:background-color .15s ease-in-out}.collapse-toggle i{display:inline-block;transition:transform .2s ease}.collapse-toggle[aria-expanded=true] i{transform:rotate(180deg)}.collapse-toggle:hover{opacity:.8}.collapse-toggle{padding:.25rem}.remove-entry-btn:hover{opacity:.8}.upload-box{border:2px dashed #6c757d;border-radius:8px;padding:2rem;cursor:pointer;transition:background-color .2s ease}.upload-box:hover{background-color:rgba(255,255,255,5%)}.info-box{background-color:rgba(255,255,255,5%);border-radius:6px;padding:1rem;margin-bottom:1.5rem;text-align:left}.info-box h6{margin-bottom:.5rem}.info-box ul{margin-bottom:0;padding-left:1.2rem}.callout{padding:20px;margin:10px 20px;border:1px solid #eee;border-left-width:5px;border-radius:3px;h4 { margin-top: 0; margin-bottom: 5px; } p:last-child { margin-bottom: 0; } code { border-radius: 3px; } &+.bs-callout { margin-top: -5px; }}.upload-box{border:2px dashed rgba(255,255,255,.2);padding:2rem;transition:all .2s ease;cursor:pointer;display:block}.upload-box.highlight{border-color:#0d6efd;background-color:rgba(13,110,253,5%)}.pu-file-list{margin-top:1.5rem}.pu-file-item{display:flex;align-items:center;padding:.5rem 0;border-bottom:1px solid rgba(255,255,255,.1);font-size:.95rem}.pu-file-item .file-name{flex:1;text-align:left;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;margin-right:1rem}.pu-file-item .upload-status{width:350px;text-align:right;margin-right:1rem;flex-shrink:0;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.pu-file-item .file-size{width:80px;text-align:right;margin-right:12px;flex-shrink:0;opacity:.75}.filename{font-weight:700;font-size:14px;margin-bottom:5px}.upload-progress-container{display:flex;align-items:center}.upload-progress-bar{position:relative;height:10px;background-color:#eee;flex:1;margin-right:10px;border-radius:4px}.upload-progress-bar-progress{position:absolute;top:0;left:0;height:100%;background-color:#0a0;border-radius:4px;transition:width .2s ease-in-out}.upload-progress-info{font-size:12px}.us-container{margin-top:10px;margin-bottom:20px}.uploaderror{font-weight:700;color:red;margin-bottom:5px}.uploads-container{background-color:#2f343a;border:2px solid rgba(0,0,0,.3);border-radius:5px;margin-left:0;margin-right:0;max-width:none;visibility:hidden} \ No newline at end of file diff --git a/internal/webserver/web/static/js/admin_api.js b/internal/webserver/web/static/js/admin_api.js index 829a9d4..b36dece 100644 --- a/internal/webserver/web/static/js/admin_api.js +++ b/internal/webserver/web/static/js/admin_api.js @@ -51,7 +51,7 @@ async function getToken(permission, forceRenewal) { async function apiAuthModify(apiKey, permission, modifier) { const apiUrl = './api/auth/modify'; const reqPerm = 'PERM_API_MOD'; - + let token; try { @@ -88,7 +88,7 @@ async function apiAuthModify(apiKey, permission, modifier) { async function apiAuthFriendlyName(apiKey, newName) { const apiUrl = './api/auth/friendlyname'; const reqPerm = 'PERM_API_MOD'; - + let token; try { @@ -124,7 +124,7 @@ async function apiAuthFriendlyName(apiKey, newName) { async function apiAuthDelete(apiKey) { const apiUrl = './api/auth/delete'; const reqPerm = 'PERM_API_MOD'; - + let token; try { @@ -158,7 +158,7 @@ async function apiAuthDelete(apiKey) { async function apiAuthCreate() { const apiUrl = './api/auth/create'; const reqPerm = 'PERM_API_MOD'; - + let token; try { @@ -199,7 +199,7 @@ async function apiAuthCreate() { async function apiChunkComplete(uuid, filename, filesize, realsize, contenttype, allowedDownloads, expiryDays, password, isE2E, nonblocking) { const apiUrl = './api/chunk/complete'; const reqPerm = 'PERM_UPLOAD'; - + let token; try { @@ -258,7 +258,7 @@ async function apiChunkComplete(uuid, filename, filesize, realsize, contenttype, async function apiFilesReplace(id, newId) { const apiUrl = './api/files/replace'; const reqPerm = 'PERM_REPLACE'; - + let token; try { @@ -295,7 +295,7 @@ async function apiFilesReplace(id, newId) { async function apiFilesListById(fileId) { const apiUrl = './api/files/list/' + fileId; const reqPerm = 'PERM_VIEW'; - + let token; try { @@ -304,13 +304,12 @@ async function apiFilesListById(fileId) { console.error("Unable to gain permission token:", error); throw error; } - + const requestOptions = { method: 'GET', headers: { 'Content-Type': 'application/json', - 'apikey': token, - + 'apikey': token }, }; @@ -328,10 +327,84 @@ async function apiFilesListById(fileId) { } +async function apiFilesListDownloadSingle(fileId) { + const apiUrl = './api/files/download/' + fileId; + const reqPerm = 'PERM_DOWNLOAD'; + + let token; + + try { + token = await getToken(reqPerm, false); + } catch (error) { + console.error("Unable to gain permission token:", error); + throw error; + } + + const requestOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'apikey': token, + 'presignUrl': true + }, + }; + + try { + const response = await fetch(apiUrl, requestOptions); + if (!response.ok) { + throw new Error(`Request failed with status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error) { + console.error("Error in apiFilesListDownloadSingle:", error); + throw error; + } +} + + +async function apiFilesListDownloadZip(fileIds, filename) { + const apiUrl = './api/files/downloadzip'; + const reqPerm = 'PERM_DOWNLOAD'; + + let token; + + try { + token = await getToken(reqPerm, false); + } catch (error) { + console.error("Unable to gain permission token:", error); + throw error; + } + + const requestOptions = { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'apikey': token, + 'ids': fileIds, + 'filename': 'base64:' + Base64.encode(filename), + 'presignUrl': true + }, + }; + + try { + const response = await fetch(apiUrl, requestOptions); + if (!response.ok) { + throw new Error(`Request failed with status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error) { + console.error("Error in apiFilesListDownloadZip:", error); + throw error; + } +} + + async function apiFilesModify(id, allowedDownloads, expiry, password, originalPw) { const apiUrl = './api/files/modify'; const reqPerm = 'PERM_EDIT'; - + let token; try { @@ -371,7 +444,7 @@ async function apiFilesModify(id, allowedDownloads, expiry, password, originalPw async function apiFilesDelete(id, delay) { const apiUrl = './api/files/delete'; const reqPerm = 'PERM_DELETE'; - + let token; try { @@ -406,7 +479,7 @@ async function apiFilesDelete(id, delay) { async function apiFilesRestore(id) { const apiUrl = './api/files/restore'; const reqPerm = 'PERM_DELETE'; - + let token; try { @@ -446,7 +519,7 @@ async function apiFilesRestore(id) { async function apiUserCreate(userName) { const apiUrl = './api/user/create'; const reqPerm = 'PERM_MANAGE_USERS'; - + let token; try { @@ -486,7 +559,7 @@ async function apiUserCreate(userName) { async function apiUserModify(userId, permission, modifier) { const apiUrl = './api/user/modify'; const reqPerm = 'PERM_MANAGE_USERS'; - + let token; try { @@ -523,7 +596,7 @@ async function apiUserModify(userId, permission, modifier) { async function apiUserChangeRank(userId, newRank) { const apiUrl = './api/user/changeRank'; const reqPerm = 'PERM_MANAGE_USERS'; - + let token; try { @@ -558,7 +631,7 @@ async function apiUserChangeRank(userId, newRank) { async function apiUserDelete(id, deleteFiles) { const apiUrl = './api/user/delete'; const reqPerm = 'PERM_MANAGE_USERS'; - + let token; try { @@ -594,7 +667,7 @@ async function apiUserDelete(id, deleteFiles) { async function apiUserResetPassword(id, generatePw) { const apiUrl = './api/user/resetPassword'; const reqPerm = 'PERM_MANAGE_USERS'; - + let token; try { @@ -632,7 +705,7 @@ async function apiUserResetPassword(id, generatePw) { async function apiLogsDelete(timestamp) { const apiUrl = './api/logs/delete'; const reqPerm = 'PERM_MANAGE_LOGS'; - + let token; try { @@ -668,7 +741,7 @@ async function apiLogsDelete(timestamp) { async function apiE2eGet() { const apiUrl = './api/e2e/get'; const reqPerm = 'PERM_UPLOAD'; - + let token; try { @@ -703,7 +776,7 @@ async function apiE2eGet() { async function apiE2eStore(content) { const apiUrl = './api/e2e/set'; const reqPerm = 'PERM_UPLOAD'; - + let token; try { @@ -720,7 +793,7 @@ async function apiE2eStore(content) { 'apikey': token }, body: JSON.stringify({ - content: content + 'content': content }), }; @@ -734,3 +807,81 @@ async function apiE2eStore(content) { throw error; } } + + +// Upload Requests + +async function apiURequestDelete(id) { + const apiUrl = './api/uploadrequest/delete'; + const reqPerm = 'PERM_MANAGE_FILE_REQUESTS'; + + let token; + + try { + token = await getToken(reqPerm, false); + } catch (error) { + console.error("Unable to gain permission token:", error); + throw error; + } + + const requestOptions = { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'apikey': token, + 'id': id + }, + }; + + try { + const response = await fetch(apiUrl, requestOptions); + if (!response.ok) { + throw new Error(`Request failed with status: ${response.status}`); + } + } catch (error) { + console.error("Error in apiURequestDelete:", error); + throw error; + } +} + + + +async function apiURequestSave(id, name, maxfiles, maxsize, expiry, notes) { + const apiUrl = './api/uploadrequest/save'; + const reqPerm = 'PERM_MANAGE_FILE_REQUESTS'; + + let token; + + try { + token = await getToken(reqPerm, false); + } catch (error) { + console.error("Unable to gain permission token:", error); + throw error; + } + + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': token, + 'id': id, + 'name': 'base64:' + Base64.encode(name), + 'expiry': expiry, + 'maxfiles': maxfiles, + 'maxsize': maxsize, + 'notes': 'base64:' + Base64.encode(notes), + }, + }; + + try { + const response = await fetch(apiUrl, requestOptions); + if (!response.ok) { + throw new Error(`Request failed with status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error) { + console.error("Error in apiURequestDelete:", error); + throw error; + } +} diff --git a/internal/webserver/web/static/js/admin_ui_allPages.js b/internal/webserver/web/static/js/admin_ui_allPages.js index 79a06e4..a40f705 100644 --- a/internal/webserver/web/static/js/admin_ui_allPages.js +++ b/internal/webserver/web/static/js/admin_ui_allPages.js @@ -5,8 +5,7 @@ try { var clipboard = new ClipboardJS('.copyurl'); -} catch (ignored) { -} +} catch (ignored) {} var toastId; @@ -28,3 +27,80 @@ function hideToast() { document.getElementById("toastnotification").classList.remove("show"); } + +var calendarInstance = null; + +function createCalendar(element, timestamp) { + const expiryDate = new Date(timestamp * 1000); + + calendarInstance = flatpickr(document.getElementById(element), { + enableTime: true, + dateFormat: 'U', // Unix timestamp + altInput: true, + altFormat: 'Y-m-d H:i', + allowInput: true, + time_24hr: true, + defaultDate: expiryDate, + minDate: 'today', + }); +} + + +function handleEditCheckboxChange(checkbox) { + var targetElement = document.getElementById(checkbox.getAttribute("data-toggle-target")); + var timestamp = checkbox.getAttribute("data-timestamp"); + + if (checkbox.checked) { + targetElement.classList.remove("disabled"); + targetElement.removeAttribute("disabled"); + if (timestamp != null) { + calendarInstance._input.disabled = false; + } + } else { + if (timestamp != null) { + calendarInstance._input.disabled = true; + } + targetElement.classList.add("disabled"); + targetElement.setAttribute("disabled", true); + } +} + +function downloadFileWithPresign(id) { + apiFilesListDownloadSingle(id) + .then(data => { + if (!data.hasOwnProperty("downloadUrl")) { + throw new Error("Unable to get presigned key"); + } + const a = document.createElement('a'); + a.href = data.downloadUrl; + a.style.display = 'none'; + + document.body.appendChild(a); + a.click(); + a.remove(); + }) + .catch(error => { + alert("Unable to download: " + error); + console.error('Error:', error); + }); +} + +function downloadFilesZipWithPresign(ids, filename) { + apiFilesListDownloadZip(ids, filename) + .then(data => { + if (!data.hasOwnProperty("downloadUrl")) { + throw new Error("Unable to get presigned key"); + } + const a = document.createElement('a'); + a.href = data.downloadUrl; + a.style.display = 'none'; + + document.body.appendChild(a); + a.click(); + a.remove(); + }) + .catch(error => { + alert("Unable to download: " + error); + console.error('Error:', error); + }); +} diff --git a/internal/webserver/web/static/js/admin_ui_api.js b/internal/webserver/web/static/js/admin_ui_api.js index f5a0d6e..40a9fa1 100644 --- a/internal/webserver/web/static/js/admin_ui_api.js +++ b/internal/webserver/web/static/js/admin_ui_api.js @@ -209,7 +209,7 @@ function addRowApi(apiKey, publicId) { }, { perm: 'PERM_UPLOAD', - icon: 'bi-file-earmark-arrow-up', + icon: 'bi-file-earmark-plus', granted: true, title: 'Upload' }, @@ -231,6 +231,18 @@ function addRowApi(apiKey, publicId) { granted: false, title: 'Replace Uploads' }, + { + perm: 'PERM_DOWNLOAD', + icon: 'bi-box-arrow-in-down', + granted: false, + title: 'Download Files' + }, + { + perm: 'PERM_MANAGE_FILE_REQUESTS', + icon: 'bi-file-earmark-arrow-up', + granted: false, + title: 'Manage File Requests' + }, { perm: 'PERM_MANAGE_USERS', icon: 'bi-people', @@ -283,6 +295,11 @@ function addRowApi(apiKey, publicId) { cell.classList.add("perm-unavailable"); cell.classList.add("perm-nochange"); } + if (!canCreateFileRequest) { + let cell = document.getElementById("perm_manage_file_requests_" + publicId); + cell.classList.add("perm-unavailable"); + cell.classList.add("perm-nochange"); + } setTimeout(() => { cellFriendlyName.classList.remove("newApiKey"); diff --git a/internal/webserver/web/static/js/admin_ui_filerequest.js b/internal/webserver/web/static/js/admin_ui_filerequest.js new file mode 100644 index 0000000..0561521 --- /dev/null +++ b/internal/webserver/web/static/js/admin_ui_filerequest.js @@ -0,0 +1,363 @@ +// This file contains JS code for the API view +// All files named admin_*.js will be merged together and minimised by calling +// go generate ./... + +function deleteFileRequest(requestId) { + document.getElementById("delete-" + requestId).disabled = true; + + apiURequestDelete(requestId) + .then(data => { + const mainRow = document.getElementById("row-" + requestId); + const fileListRow = document.getElementById("filelist-" + requestId); + mainRow.classList.add("rowDeleting"); + if (fileListRow !== null) { + fileListRow.classList.add("rowDeleting"); + } + + setTimeout(() => { + mainRow.remove(); + if (fileListRow !== null) { + fileListRow.remove(); + } + }, 290); + }) + .catch(error => { + alert("Unable to delete file request: " + error); + console.error('Error:', error); + }); +} + +function deleteOrShowModal(requestId, requestName, count) { + if (count === 0) { + deleteFileRequest(requestId); + } else { + showDeleteFRequestModal(requestId, requestName, count); + } +} + +function showDeleteFRequestModal(requestId, requestName, count) { + document.getElementById("deleteModalBodyName").innerText = requestName; + document.getElementById("deleteModalBodyCount").innerText = count; + $('#deleteModal').modal('show'); + + document.getElementById("buttonDelete").onclick = function() { + $('#deleteModal').modal('hide'); + deleteFileRequest(requestId); + }; +} + + +function newFileRequest() { + loadFileRequestDefaults(); + document.getElementById("m_urequestlabel").innerText = "New File Request"; + $('#addEditModal').modal('show'); + + document.getElementById("b_fr_save").onclick = function() { + saveFileRequestDefaults(); + saveFileRequest(); + $('#addEditModal').modal('hide'); + }; +} + +function saveFileRequestDefaults() { + if (document.getElementById("mc_maxfiles").checked) { + localStorage.setItem("fr_maxfiles", document.getElementById("mi_maxfiles").value); + } else { + localStorage.setItem("fr_maxfiles", 0); + } + if (document.getElementById("mc_maxsize").checked) { + localStorage.setItem("fr_maxsize", document.getElementById("mi_maxsize").value); + } else { + localStorage.setItem("fr_maxsize", 0); + } + if (document.getElementById("mc_expiry").checked) { + let diff = document.getElementById("mi_expiry").value - Math.round(Date.now() / 1000); + localStorage.setItem("fr_expiry", diff); + } else { + localStorage.setItem("fr_expiry", 0); + } +} + +function loadFileRequestDefaults() { + const defaultMaxFiles = localStorage.getItem("fr_maxfiles"); + const defaultMaxSize = localStorage.getItem("fr_maxsize"); + let defaultExpiry = localStorage.getItem("fr_expiry"); + + if (defaultExpiry !== "0" && defaultExpiry !== null) { + let defaultDate = new Date(Date.now() + Number((defaultExpiry) * 1000)); + defaultDate.setHours(12, 0, 0, 0); + defaultExpiry = Math.floor(defaultDate.getTime() / 1000); + } + + setModalValues("", "", defaultMaxFiles, defaultMaxSize, defaultExpiry, ""); +} + +function setModalValues(id, name, maxFiles, maxSize, expiry, notes) { + document.getElementById("freqId").value = id; + + if (name === null) { + document.getElementById("mFriendlyName").value = ""; + } else { + document.getElementById("mFriendlyName").value = name; + } + + + if (limitMaxFiles != 0) { + let checkbox = document.getElementById("mc_maxfiles"); + if (maxFiles === null || maxFiles == 0) { + maxFiles = limitMaxFiles; + } + checkbox.checked = true; + checkbox.disabled = true; + checkbox.title = "Only admins can set this to unlimited"; + checkbox.value = "1"; + document.getElementById("mi_maxfiles").setAttribute("max", limitMaxFiles); + } else { + let checkbox = document.getElementById("mc_maxfiles"); + checkbox.disabled = false; + checkbox.title = ""; + document.getElementById("mi_maxfiles").setAttribute("max", ""); + } + + if (limitMaxSize != 0) { + let checkbox = document.getElementById("mc_maxsize"); + if (maxSize === null || maxSize == 0) { + maxSize = limitMaxSize; + } + checkbox.checked = true; + checkbox.disabled = true; + checkbox.title = "Only admins can set this to unlimited"; + checkbox.value = "1"; + document.getElementById("mi_maxsize").setAttribute("max", limitMaxSize); + } else { + let checkbox = document.getElementById("mc_maxsize"); + checkbox.disabled = false; + checkbox.title = ""; + document.getElementById("mi_maxsize").setAttribute("max", ""); + } + + if (maxFiles === null || maxFiles == 0) { + document.getElementById("mi_maxfiles").value = "1"; + document.getElementById("mi_maxfiles").disabled = true; + document.getElementById("mc_maxfiles").checked = false; + } else { + document.getElementById("mi_maxfiles").value = maxFiles; + document.getElementById("mi_maxfiles").disabled = false; + document.getElementById("mc_maxfiles").checked = true; + } + + + if (maxSize === null || maxSize == 0) { + document.getElementById("mi_maxsize").value = "10"; + document.getElementById("mi_maxsize").disabled = true; + document.getElementById("mc_maxsize").checked = false; + } else { + document.getElementById("mi_maxsize").value = maxSize; + document.getElementById("mi_maxsize").disabled = false; + document.getElementById("mc_maxsize").checked = true; + } + + if (expiry === null || expiry == 0) { + const defaultDate = Math.floor(new Date(Date.now() + (14 * 24 * 60 * 60 * 1000)).getTime() / 1000); + document.getElementById("mi_expiry").disabled = true; + document.getElementById("mc_expiry").checked = false; + document.getElementById("mi_expiry").value = defaultDate; + createCalendar("mi_expiry", defaultDate); + } else { + document.getElementById("mi_expiry").value = expiry; + document.getElementById("mi_expiry").disabled = false; + document.getElementById("mc_expiry").checked = true; + createCalendar("mi_expiry", expiry); + } + document.getElementById("mNotes").value = notes; +} + +function editFileRequest(id, name, maxFiles, maxSize, expiry, notes) { + setModalValues(id, name, maxFiles, maxSize, expiry, notes); + document.getElementById("m_urequestlabel").innerText = "Edit File Request"; + $('#addEditModal').modal('show'); + + document.getElementById("b_fr_save").onclick = function() { + saveFileRequest(); + $('#addEditModal').modal('hide'); + }; +} + + +function saveFileRequest() { + const buttonSave = document.getElementById("b_fr_save"); + const id = document.getElementById("freqId").value; + const name = document.getElementById("mFriendlyName").value; + const notes = document.getElementById("mNotes").value; + let maxFiles = 0; + let maxSize = 0; + let expiry = 0; + + if (document.getElementById("mc_maxfiles").checked) { + maxFiles = document.getElementById("mi_maxfiles").value; + } + if (document.getElementById("mc_maxsize").checked) { + maxSize = document.getElementById("mi_maxsize").value; + } + if (document.getElementById("mc_expiry").checked) { + expiry = document.getElementById("mi_expiry").value; + } + + buttonSave.disabled = true; + apiURequestSave(id, name, maxFiles, maxSize, expiry, notes) + .then(data => { + document.getElementById("b_fr_save").disabled = false; + insertOrReplaceFileRequest(data); + }) + .catch(error => { + alert("Unable to save file request: " + error); + console.error('Error:', error); + document.getElementById("b_fr_save").disabled = false; + }); +} + +function checkMaxNumber(element) { + if (element.value == "") { + element.value = "1"; + return; + } + let maxVal = element.getAttribute("max"); + if (maxVal == "") { + return; + } + if (element.value > maxVal) { + element.value = maxVal; + } +} + +function insertOrReplaceFileRequest(jsonResult) { + const tbody = document.getElementById("filerequesttable"); + let row = document.getElementById(`row-${jsonResult.id}`); + + if (row) { + const user = document.getElementById(`cell-username-${jsonResult.id}`).innerText; + row.replaceWith(createFileRequestRow(jsonResult, user)); + } else { + let tr = createFileRequestRow(jsonResult, userName); + tr.querySelectorAll('td').forEach((td) => { + td.classList.add("newFileRequest"); + setTimeout(() => { + td.classList.remove("newFileRequest"); + }, 700); + }); + tbody.prepend(tr); + } +} + + +function createFileRequestRow(jsonResult, user) { + + function tdText(text) { + const td = document.createElement("td"); + td.textContent = text; + return td; + } + + function tdLink(text, href) { + const td = document.createElement("td"); + const a = document.createElement("a"); + a.textContent = text; + a.href = href; + a.target = "_blank"; + td.appendChild(a); + return td; + } + + function icon(classes) { + const i = document.createElement("i"); + i.className = `bi ${classes}`; + return i; + } + + const publicUrl = `${baseUrl}publicUpload?id=${jsonResult.id}&key=${jsonResult.apikey}`; + + const tr = document.createElement("tr"); + tr.id = `row-${jsonResult.id}`; + tr.className = "filerequest-item"; + + // Name + tr.appendChild(tdLink(jsonResult.name, publicUrl)); + // Uploaded files / Max files + if (jsonResult.maxfiles == 0) { + tr.appendChild(tdText(jsonResult.uploadedfiles)); + } else { + tr.appendChild(tdText(`${jsonResult.uploadedfiles} / ${jsonResult.maxfiles}`)); + } + // Total size + tr.appendChild(tdText(getReadableSize(jsonResult.totalfilesize))); + // Last upload + tr.appendChild(tdText(formatTimestampWithNegative(jsonResult.lastupload, "None"))); + // Expiry + tr.appendChild(tdText(formatFileRequestExpiry(jsonResult.expiry))); + // Optional user column + if (canViewOtherRequests) { + let userTd = tdText(user); + userTd.id = `cell-username-${jsonResult.id}`; + tr.appendChild(userTd); + } + // Buttons + const td = document.createElement("td"); + + const group = document.createElement("div"); + group.className = "btn-group"; + group.role = "group"; + + // Download + const downloadBtn = document.createElement("button"); + downloadBtn.id = `download-${jsonResult.id}`; + downloadBtn.type = "button"; + downloadBtn.className = "btn btn-outline-light btn-sm"; + downloadBtn.title = "Download all"; + + if (jsonResult.uploadedfiles == 0) { + downloadBtn.classList.add("disabled"); + } + + downloadBtn.appendChild(icon("bi-download")); + + + // Copy + const copyBtn = document.createElement("button"); + copyBtn.id = `copy-${jsonResult.id}`; + copyBtn.type = "button"; + copyBtn.className = "copyurl btn btn-outline-light btn-sm"; + copyBtn.title = "Copy URL"; + copyBtn.setAttribute("data-clipboard-text", publicUrl); + copyBtn.onclick = () => + showToast(1000); + + copyBtn.appendChild(icon("bi-copy")); + + + // Edit + const editBtn = document.createElement("button"); + editBtn.id = `edit-${jsonResult.id}`; + editBtn.type = "button"; + editBtn.className = "btn btn-outline-light btn-sm"; + editBtn.title = "Edit request"; + editBtn.onclick = () => + editFileRequest(jsonResult.id, jsonResult.name, jsonResult.maxfiles, jsonResult.maxsize, jsonResult.expiry, jsonResult.notes); + + editBtn.appendChild(icon("bi-pencil")); + + // Delete + const deleteBtn = document.createElement("button"); + deleteBtn.id = `delete-${jsonResult.id}`; + deleteBtn.type = "button"; + deleteBtn.className = "btn btn-outline-danger btn-sm"; + deleteBtn.title = "Delete"; + deleteBtn.onclick = () => + deleteOrShowModal(jsonResult.id, jsonResult.name, jsonResult.uploadedfiles); + + deleteBtn.appendChild(icon("bi-trash3")); + + group.append(downloadBtn, copyBtn, editBtn, deleteBtn); + td.appendChild(group); + tr.appendChild(td); + return tr; +} diff --git a/internal/webserver/web/static/js/admin_ui_upload.js b/internal/webserver/web/static/js/admin_ui_upload.js index df8ba54..bf3ac8d 100644 --- a/internal/webserver/web/static/js/admin_ui_upload.js +++ b/internal/webserver/web/static/js/admin_ui_upload.js @@ -385,47 +385,6 @@ function editFile() { }); } -var calendarInstance = null; - -function createCalendar(timestamp) { - // Convert Unix timestamp to JavaScript Date object - const expiryDate = new Date(timestamp * 1000); - - calendarInstance = flatpickr('#mi_edit_expiry', { - enableTime: true, - dateFormat: 'U', // Unix timestamp - altInput: true, - altFormat: 'Y-m-d H:i', - allowInput: true, - time_24hr: true, - defaultDate: expiryDate, - minDate: 'today', - }); - -} - - - -function handleEditCheckboxChange(checkbox) { - var targetElement = document.getElementById(checkbox.getAttribute("data-toggle-target")); - var timestamp = checkbox.getAttribute("data-timestamp"); - - if (checkbox.checked) { - targetElement.classList.remove("disabled"); - targetElement.removeAttribute("disabled"); - if (timestamp != null) { - calendarInstance._input.disabled = false; - } - } else { - if (timestamp != null) { - calendarInstance._input.disabled = true; - } - targetElement.classList.add("disabled"); - targetElement.setAttribute("disabled", true); - } - -} - function showEditModal(filename, id, downloads, expiry, password, unlimitedown, unlimitedtime, isE2e, canReplace) { // Cloning removes any previous values or form validation let originalModal = $('#modaledit').clone(); @@ -438,7 +397,7 @@ function showEditModal(filename, id, downloads, expiry, password, unlimitedown, document.getElementById("m_filenamelabel").innerText = filename; document.getElementById("mc_expiry").setAttribute("data-timestamp", expiry); document.getElementById("mb_save").setAttribute('data-fileid', id); - createCalendar(expiry); + createCalendar("mi_edit_expiry", expiry); if (unlimitedown) { document.getElementById("mi_edit_down").value = "1"; @@ -843,10 +802,30 @@ function createButtonGroup(item) { dropdown2.appendChild(emailLi); group1.appendChild(dropdown2); - // Button group for Edit/Delete + // Button group for Download/Edit/Delete const group2 = document.createElement("div"); group2.className = "btn-group me-2"; group2.setAttribute("role", "group"); + + + // === Button: Download === + const btnDownload = document.createElement('button'); + btnDownload.type = 'button'; + btnDownload.className = 'btn btn-outline-light btn-sm'; + btnDownload.title = 'Download'; + if (item.RequiresClientSideDecryption) { + btnDownload.classList.add("disabled"); + } + + const downloadIcon = document.createElement('i'); + downloadIcon.className = 'bi bi-download'; + btnDownload.appendChild(downloadIcon); + + btnDownload.addEventListener('click', () => { + downloadFileWithPresign(item.Id); + }); + + group2.appendChild(btnDownload); // === Button: Edit === const btnEdit = document.createElement('button'); diff --git a/internal/webserver/web/static/js/admin_ui_users.js b/internal/webserver/web/static/js/admin_ui_users.js index 19e0d94..1ad1079 100644 --- a/internal/webserver/web/static/js/admin_ui_users.js +++ b/internal/webserver/web/static/js/admin_ui_users.js @@ -79,7 +79,7 @@ function changeRank(userId, newRank, buttonId) { -function showDeleteModal(userId, userEmail) { +function showDeleteUserModal(userId, userEmail) { let checkboxDelete = document.getElementById("checkboxDelete"); checkboxDelete.checked = false; document.getElementById("deleteModalBody").innerText = userEmail; @@ -173,7 +173,8 @@ function addNewUser() { apiUserCreate(editName.value.trim()) .then(data => { $('#newUserModal').modal('hide'); - addRowUser(data.id, data.name); + addRowUser(data.id, data.name, data.permissions); + console.log(data); }) .catch(error => { if (error.message == "duplicate") { @@ -190,8 +191,87 @@ function addNewUser() { +const PermissionDefinitions = [ + { + key: "UserPermGuestUploads", + bit: 1 << 8, + icon: "bi bi-box-arrow-in-down", + title: "Create file requests", + htmlId: userid => `perm_guest_upload_${userid}`, + apiName: "PERM_GUEST_UPLOAD" + }, + { + key: "UserPermReplaceUploads", + bit: 1 << 0, + icon: "bi bi-recycle", + title: "Replace own uploads", + htmlId: userid => `perm_replace_${userid}`, + apiName: "PERM_REPLACE" + }, + { + key: "UserPermListOtherUploads", + bit: 1 << 1, + icon: "bi bi-eye", + title: "List other uploads", + htmlId: userid => `perm_list_${userid}`, + apiName: "PERM_LIST" + }, + { + key: "UserPermEditOtherUploads", + bit: 1 << 2, + icon: "bi bi-pencil", + title: "Edit other uploads", + htmlId: userid => `perm_edit_${userid}`, + apiName: "PERM_EDIT" + }, + { + key: "UserPermDeleteOtherUploads", + bit: 1 << 4, + icon: "bi bi-trash3", + title: "Delete other uploads", + htmlId: userid => `perm_delete_${userid}`, + apiName: "PERM_DELETE" + }, + { + key: "UserPermReplaceOtherUploads", + bit: 1 << 3, + icon: "bi bi-arrow-left-right", + title: "Replace other uploads", + htmlId: userid => `perm_replace_other_${userid}`, + apiName: "PERM_REPLACE_OTHER" + }, + { + key: "UserPermManageLogs", + bit: 1 << 5, + icon: "bi bi-card-list", + title: "Manage system logs", + htmlId: userid => `perm_logs_${userid}`, + apiName: "PERM_LOGS" + }, + { + key: "UserPermManageUsers", + bit: 1 << 7, + icon: "bi bi-people", + title: "Manage users", + htmlId: userid => `perm_users_${userid}`, + apiName: "PERM_USERS" + }, + { + key: "UserPermManageApiKeys", + bit: 1 << 6, + icon: "bi bi-sliders2", + title: "Manage API keys", + htmlId: userid => `perm_api_${userid}`, + apiName: "PERM_API" + } +]; -function addRowUser(userid, name) { +function hasPermission(userPermissions, permissionBit) { + return (userPermissions & permissionBit) !== 0; +} + + +function addRowUser(userid, name, permissions) { userid = sanitizeUserId(userid); @@ -251,7 +331,7 @@ function addRowUser(userid, name) { btnDelete.type = "button"; btnDelete.className = "btn btn-outline-danger btn-sm"; btnDelete.title = "Delete"; - btnDelete.onclick = () => showDeleteModal(userid, name); + btnDelete.onclick = () => showDeleteUserModal(userid, name); btnDelete.innerHTML = ``; btnGroup.appendChild(btnDelete); @@ -260,23 +340,20 @@ function addRowUser(userid, name) { cellActions.appendChild(btnGroup); // Permissions - cellPermissions.innerHTML = ` - + cellPermissions.innerHTML = PermissionDefinitions.map(perm => { + const granted = hasPermission(permissions, perm.bit) + ? "perm-granted" + : "perm-notgranted"; - - - - - - - - - - - - -`; + const id = perm.htmlId(userid); + return ` + + `; + }).join(""); setTimeout(() => { diff --git a/internal/webserver/web/static/js/all_public.js b/internal/webserver/web/static/js/all_public.js new file mode 100644 index 0000000..2056d46 --- /dev/null +++ b/internal/webserver/web/static/js/all_public.js @@ -0,0 +1,116 @@ +function getUuid() { + // Native UUID, not available in insecure environment + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // CSPRNG-backed fallback + if (typeof crypto !== "undefined" && crypto.getRandomValues) { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + + // RFC 4122 compliance + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + + return [...bytes] + .map((b, i) => + (i === 4 || i === 6 || i === 8 || i === 10 ? "-" : "") + + b.toString(16).padStart(2, "0") + ) + .join(""); + } + + // If unavailable, Math.random (not cryptographically secure) + let uuid = "", + i; + for (i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) { + uuid += "-"; + } else if (i === 14) { + uuid += "4"; + } else { + const r = Math.random() * 16 | 0; + uuid += (i === 19 ? (r & 0x3) | 0x8 : r).toString(16); + } + } + return uuid; +} + + +function formatUnixTimestamp(unixTimestamp) { + const date = new Date(unixTimestamp * 1000); + const pad = (n) => String(n).padStart(2, '0'); + + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); // months are 0-based + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + + return `${year}-${month}-${day} ${hours}:${minutes}`; +} + +function formatTimestampWithNegative(unixTimestamp, negative) { + if (negative === undefined) { + negative = "Never"; + } + if (unixTimestamp == 0) { + return negative; + } + return formatUnixTimestamp(unixTimestamp); +} + +function insertFormattedDate(unixTimestamp, id) { + document.getElementById(id).innerText = formatUnixTimestamp(unixTimestamp); +} + +function insertDateWithNegative(unixTimestamp, id, negative) { + document.getElementById(id).innerText = formatTimestampWithNegative(unixTimestamp, negative); +} + +function insertLastOnlineDate(unixTimestamp, id) { + if ((Date.now() / 1000) - 120 < unixTimestamp) { + document.getElementById(id).innerText = "Online"; + return; + } + insertDateWithNegative(unixTimestamp, id); +} + +function formatFileRequestExpiry(unixTimestamp) { + if (unixTimestamp == 0) { + return "Never"; + } + if ((Date.now() / 1000) > unixTimestamp) { + return "Expired"; + } + return formatUnixTimestamp(unixTimestamp); +} + +function insertFileRequestExpiry(unixTimestamp, id) { + document.getElementById(id).innerText = formatFileRequestExpiry(unixTimestamp); + +} + +function getReadableSize(bytes) { + if (!bytes) return "0 B"; + const units = ["B", "kB", "MB", "GB", "TB"]; + let i = 0; + while (bytes >= 1024 && i < units.length - 1) { + bytes /= 1024; + i++; + } + return `${bytes.toFixed(1)} ${units[i]}`; +} + +function insertReadableSize(bytes, multiplier, id) { + document.getElementById(id).innerText = getReadableSize(bytes * multiplier); +} + +/** +* +* Base64 encode / decode +* http://www.webtoolkit.info/ +* +**/ +var Base64={_keyStr:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",encode:function(r){var t,e,o,a,h,n,c,d="",C=0;for(r=Base64._utf8_encode(r);C>2,h=(3&t)<<4|(e=r.charCodeAt(C++))>>4,n=(15&e)<<2|(o=r.charCodeAt(C++))>>6,c=63&o,isNaN(e)?n=c=64:isNaN(o)&&(c=64),d=d+this._keyStr.charAt(a)+this._keyStr.charAt(h)+this._keyStr.charAt(n)+this._keyStr.charAt(c);return d},decode:function(r){var t,e,o,a,h,n,c="",d=0;for(r=r.replace(/[^A-Za-z0-9\+\/\=]/g,"");d>4,e=(15&a)<<4|(h=this._keyStr.indexOf(r.charAt(d++)))>>2,o=(3&h)<<6|(n=this._keyStr.indexOf(r.charAt(d++))),c+=String.fromCharCode(t),64!=h&&(c+=String.fromCharCode(e)),64!=n&&(c+=String.fromCharCode(o));return c=Base64._utf8_decode(c)},_utf8_encode:function(r){r=r.replace(/\r\n/g,"\n");for(var t="",e=0;e127&&o<2048?(t+=String.fromCharCode(o>>6|192),t+=String.fromCharCode(63&o|128)):(t+=String.fromCharCode(o>>12|224),t+=String.fromCharCode(o>>6&63|128),t+=String.fromCharCode(63&o|128))}return t},_utf8_decode:function(r){for(var t="",e=0,o=c1=c2=0;e191&&o<224?(c2=r.charCodeAt(e+1),t+=String.fromCharCode((31&o)<<6|63&c2),e+=2):(c2=r.charCodeAt(e+1),c3=r.charCodeAt(e+2),t+=String.fromCharCode((15&o)<<12|(63&c2)<<6|63&c3),e+=3);return t}}; diff --git a/internal/webserver/web/static/js/dateformat.js b/internal/webserver/web/static/js/dateformat.js deleted file mode 100644 index 8cf5ba9..0000000 --- a/internal/webserver/web/static/js/dateformat.js +++ /dev/null @@ -1,36 +0,0 @@ -function formatUnixTimestamp(unixTimestamp) { - const date = new Date(unixTimestamp * 1000); - const pad = (n) => String(n).padStart(2, '0'); - - const year = date.getFullYear(); - const month = pad(date.getMonth() + 1); // months are 0-based - const day = pad(date.getDate()); - const hours = pad(date.getHours()); - const minutes = pad(date.getMinutes()); - - return `${year}-${month}-${day} ${hours}:${minutes}`; -} - -function insertFormattedDate(unixTimestamp, id) { - document.getElementById(id).innerText = formatUnixTimestamp(unixTimestamp); -} - -function insertLastOnlineDate(unixTimestamp, id) { - if (unixTimestamp == 0) { - document.getElementById(id).innerText = "Never"; - return; - } - if ((Date.now()/1000) - 120 < unixTimestamp) { - document.getElementById(id).innerText = "Online"; - return; - } - insertFormattedDate(unixTimestamp, id); -} - -function insertLastUsed(unixTimestamp, id) { - if (unixTimestamp == 0) { - document.getElementById(id).innerText = "Never"; - return; - } - insertFormattedDate(unixTimestamp, id); -} diff --git a/internal/webserver/web/static/js/min/admin.min.15.js b/internal/webserver/web/static/js/min/admin.min.15.js index 5252551..3732d9e 100644 --- a/internal/webserver/web/static/js/min/admin.min.15.js +++ b/internal/webserver/web/static/js/min/admin.min.15.js @@ -1,20 +1,10 @@ -const storedTokens=new Map;async function getToken(e,t){const n="./auth/token";if(!t){if(!storedTokens.has(e))return getToken(e,!0);let t=storedTokens.get(e);return t.expiry-Date.now()/1e3<60?getToken(e,!0):t.key}const s={method:"POST",headers:{"Content-Type":"application/json",permission:e}};try{const o=await fetch(n,s);if(!o.ok)throw new Error(`Request failed with status: ${o.status}`);const t=await o.json();if(!t.hasOwnProperty("key"))throw new Error(`Invalid response when trying to get token`);return storedTokens.set(e,{key:t.key,expiry:t.expiry}),t.key}catch(e){throw console.error("Error in getToken:",e),e}}async function apiAuthModify(e,t,n){const o="./api/auth/modify",i="PERM_API_MOD";let s;try{s=await getToken(i,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const a={method:"POST",headers:{"Content-Type":"application/json",apikey:s,targetKey:e,permission:t,permissionModifier:n}};try{const e=await fetch(o,a);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiAuthModify:",e),e}}async function apiAuthFriendlyName(e,t){const s="./api/auth/friendlyname",o="PERM_API_MOD";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"PUT",headers:{"Content-Type":"application/json",apikey:n,targetKey:e,friendlyName:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiAuthModify:",e),e}}async function apiAuthDelete(e){const n="./api/auth/delete",s="PERM_API_MOD";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t,targetKey:e}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiAuthDelete:",e),e}}async function apiAuthCreate(){const t="./api/auth/create",n="PERM_API_MOD";let e;try{e=await getToken(n,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const s={method:"POST",headers:{"Content-Type":"application/json",apikey:e,basicPermissions:"true"}};try{const e=await fetch(t,s);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const n=await e.json();return n}catch(e){throw console.error("Error in apiAuthCreate:",e),e}}async function apiChunkComplete(e,t,n,s,o,i,a,r,c,l){const u="./api/chunk/complete",h="PERM_UPLOAD";let d;try{d=await getToken(h,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const m={method:"POST",headers:{"Content-Type":"application/json",apikey:d,uuid:e,filename:"base64:"+Base64.encode(t),filesize:n,realsize:s,contenttype:o,allowedDownloads:i,expiryDays:a,password:r,isE2E:c,nonblocking:l}};try{const e=await fetch(u,m);if(!e.ok){let t;try{const n=await e.json();t=n.ErrorMessage||`Request failed with status: ${e.status}`}catch{const n=await e.text();t=n||`Request failed with status: ${e.status}`}throw new Error(t)}const t=await e.json();return t}catch(e){throw console.error("Error in apiChunkComplete:",e),e}}async function apiFilesReplace(e,t){const s="./api/files/replace",o="PERM_REPLACE";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"PUT",headers:{"Content-Type":"application/json",id:e,apikey:n,idNewContent:t,deleteNewFile:!1}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesReplace:",e),e}}async function apiFilesListById(e){const n="./api/files/list/"+e,s="PERM_VIEW";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"GET",headers:{"Content-Type":"application/json",apikey:t}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesListById:",e),e}}async function apiFilesModify(e,t,n,s,o){const a="./api/files/modify",r="PERM_EDIT";let i;try{i=await getToken(r,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const c={method:"PUT",headers:{"Content-Type":"application/json",id:e,apikey:i,allowedDownloads:t,expiryTimestamp:n,password:s,originalPassword:o}};try{const e=await fetch(a,c);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesModify:",e),e}}async function apiFilesDelete(e,t){const s="./api/files/delete",o="PERM_DELETE";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"POST",headers:{"Content-Type":"application/json",apikey:n,id:e,delay:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiFilesDelete:",e),e}}async function apiFilesRestore(e){const n="./api/files/restore",s="PERM_DELETE";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t,id:e}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesRestore:",e),e}}async function apiUserCreate(e){const n="./api/user/create",s="PERM_MANAGE_USERS";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t,username:e}};try{const e=await fetch(n,o);if(!e.ok)throw e.status==409?new Error("duplicate"):new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiUserModify:",e),e}}async function apiUserModify(e,t,n){const o="./api/user/modify",i="PERM_MANAGE_USERS";let s;try{s=await getToken(i,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const a={method:"POST",headers:{"Content-Type":"application/json",apikey:s,userid:e,userpermission:t,permissionModifier:n}};try{const e=await fetch(o,a);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiUserModify:",e),e}}async function apiUserChangeRank(e,t){const s="./api/user/changeRank",o="PERM_MANAGE_USERS";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"POST",headers:{"Content-Type":"application/json",apikey:n,userid:e,newRank:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiUserModify:",e),e}}async function apiUserDelete(e,t){const s="./api/user/delete",o="PERM_MANAGE_USERS";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"POST",headers:{"Content-Type":"application/json",apikey:n,userid:e,deleteFiles:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiUserDelete:",e),e}}async function apiUserResetPassword(e,t){const s="./api/user/resetPassword",o="PERM_MANAGE_USERS";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"POST",headers:{"Content-Type":"application/json",apikey:n,userid:e,generateNewPassword:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiUserResetPassword:",e),e}}async function apiLogsDelete(e){const n="./api/logs/delete",s="PERM_MANAGE_LOGS";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t,timestamp:e}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiLogsDelete:",e),e}}async function apiE2eGet(){const t="./api/e2e/get",n="PERM_UPLOAD";let e;try{e=await getToken(n,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const s={method:"POST",headers:{"Content-Type":"application/json",apikey:e}};try{const e=await fetch(t,s);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);return await e.text()}catch(e){throw console.error("Error in apiE2eGet:",e),e}}async function apiE2eStore(e){const n="./api/e2e/set",s="PERM_UPLOAD";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t},body:JSON.stringify({content:e})};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiE2eStore:",e),e}}try{var toastId,dropzoneObject,isE2EEnabled,isUploading,rowCount,calendarInstance,statusItemCount,clipboard=new ClipboardJS(".copyurl")}catch{}function showToast(e,t){let n=document.getElementById("toastnotification");typeof t!="undefined"?n.innerText=t:n.innerText=n.dataset.default,n.classList.add("show"),clearTimeout(toastId),toastId=setTimeout(()=>{hideToast()},e)}function hideToast(){document.getElementById("toastnotification").classList.remove("show")}function changeApiPermission(e,t,n){var o,i,s=document.getElementById(n);if(s.classList.contains("perm-processing")||s.classList.contains("perm-nochange"))return;o=s.classList.contains("perm-granted"),s.classList.add("perm-processing"),s.classList.remove("perm-granted"),s.classList.remove("perm-notgranted"),i="GRANT",o&&(i="REVOKE"),apiAuthModify(e,t,i).then(e=>{o?(s.classList.add("perm-notgranted"),s.classList.add("perm-nownotgranted")):(s.classList.add("perm-granted"),s.classList.add("perm-nowgranted")),s.classList.remove("perm-processing"),setTimeout(()=>{s.classList.remove("perm-nowgranted"),s.classList.remove("perm-nownotgranted")},1e3)}).catch(e=>{o?s.classList.add("perm-granted"):s.classList.add("perm-notgranted"),s.classList.remove("perm-processing"),alert("Unable to set permission: "+e),console.error("Error:",e)})}function deleteApiKey(e){document.getElementById("delete-"+e).disabled=!0,apiAuthDelete(e).then(t=>{document.getElementById("row-"+e).classList.add("rowDeleting"),setTimeout(()=>{document.getElementById("row-"+e).remove()},290)}).catch(e=>{alert("Unable to delete API key: "+e),console.error("Error:",e)})}function newApiKey(){document.getElementById("button-newapi").disabled=!0,apiAuthCreate().then(e=>{addRowApi(e.Id,e.PublicId),document.getElementById("button-newapi").disabled=!1}).catch(e=>{alert("Unable to create API key: "+e),console.error("Error:",e)})}function addFriendlyNameChange(e){let t=document.getElementById("friendlyname-"+e);if(t.classList.contains("isBeingEdited"))return;t.classList.add("isBeingEdited");let i=t.innerText,n=document.createElement("input");n.size=5,n.value=i;let s=!0,o=function(){if(!s)return;s=!1;let o=n.value;o==""&&(o="Unnamed key"),t.innerText=o,t.classList.remove("isBeingEdited"),apiAuthFriendlyName(e,o).catch(e=>{alert("Unable to save name: "+e),console.error("Error:",e)})};n.onblur=o,n.addEventListener("keyup",function(e){e.keyCode===13&&(e.preventDefault(),o())}),t.innerText="",t.appendChild(n),n.focus()}function addRowApi(e,t){let p=document.getElementById("apitable"),s=p.insertRow(0);s.id="row-"+t;let i=0,c=s.insertCell(i++),l=s.insertCell(i++),d=s.insertCell(i++),a=s.insertCell(i++),u;canViewOtherApiKeys&&(u=s.insertCell(i++));let h=s.insertCell(i++);canViewOtherApiKeys&&(u.classList.add("newApiKey"),u.innerText=userName),c.classList.add("newApiKey"),l.classList.add("newApiKey"),d.classList.add("newApiKey"),a.classList.add("newApiKey"),a.classList.add("prevent-select"),h.classList.add("newApiKey"),c.innerText="Unnamed key",c.id="friendlyname-"+t,c.onclick=function(){addFriendlyNameChange(t)},l.innerText=e,l.classList.add("font-monospace"),d.innerText="Never";const r=document.createElement("div");r.className="btn-group",r.setAttribute("role","group");const n=document.createElement("button");n.type="button",n.dataset.clipboardText=e,n.title="Copy API Key",n.className="copyurl btn btn-outline-light btn-sm",n.setAttribute("onclick","showToast(1000)");const m=document.createElement("i");m.className="bi bi-copy",n.appendChild(m);const o=document.createElement("button");o.type="button",o.id=`delete-${t}`,o.title="Delete",o.className="btn btn-outline-danger btn-sm",o.setAttribute("onclick",`deleteApiKey('${t}')`);const f=document.createElement("i");f.className="bi bi-trash3",o.appendChild(f),r.appendChild(n),r.appendChild(o),h.appendChild(r);const g=[{perm:"PERM_VIEW",icon:"bi-eye",granted:!0,title:"List Uploads"},{perm:"PERM_UPLOAD",icon:"bi-file-earmark-arrow-up",granted:!0,title:"Upload"},{perm:"PERM_EDIT",icon:"bi-pencil",granted:!0,title:"Edit Uploads"},{perm:"PERM_DELETE",icon:"bi-trash3",granted:!0,title:"Delete Uploads"},{perm:"PERM_REPLACE",icon:"bi-recycle",granted:!1,title:"Replace Uploads"},{perm:"PERM_MANAGE_USERS",icon:"bi-people",granted:!1,title:"Manage Users"},{perm:"PERM_MANAGE_LOGS",icon:"bi-card-list",granted:!1,title:"Manage System Logs"},{perm:"PERM_API_MOD",icon:"bi-sliders2",granted:!1,title:"Manage API Keys"}];if(g.forEach(({perm:e,icon:n,granted:s,title:o})=>{const i=document.createElement("i"),r=`${e.toLowerCase()}_${t}`;i.id=r,i.className=`bi ${n} ${s?"perm-granted":"perm-notgranted"}`,i.title=o,i.setAttribute("onclick",`changeApiPermission("${t}","${e}", "${r}");`),a.appendChild(i),a.appendChild(document.createTextNode(" "))}),!canReplaceFiles){let e=document.getElementById("perm_replace_"+t);e.classList.add("perm-unavailable"),e.classList.add("perm-nochange")}if(!canManageUsers){let e=document.getElementById("perm_manage_users_"+t);e.classList.add("perm-unavailable"),e.classList.add("perm-nochange")}if(!canViewSystemLog){let e=document.getElementById("perm_manage_logs_"+t);e.classList.add("perm-unavailable"),e.classList.add("perm-nochange")}setTimeout(()=>{c.classList.remove("newApiKey"),l.classList.remove("newApiKey"),d.classList.remove("newApiKey"),a.classList.remove("newApiKey"),h.classList.remove("newApiKey")},700)}function filterLogs(e){e=="all"?textarea.value=logContent:textarea.value=logContent.split(` +const storedTokens=new Map;async function getToken(e,t){const n="./auth/token";if(!t){if(!storedTokens.has(e))return getToken(e,!0);let t=storedTokens.get(e);return t.expiry-Date.now()/1e3<60?getToken(e,!0):t.key}const s={method:"POST",headers:{"Content-Type":"application/json",permission:e}};try{const o=await fetch(n,s);if(!o.ok)throw new Error(`Request failed with status: ${o.status}`);const t=await o.json();if(!t.hasOwnProperty("key"))throw new Error(`Invalid response when trying to get token`);return storedTokens.set(e,{key:t.key,expiry:t.expiry}),t.key}catch(e){throw console.error("Error in getToken:",e),e}}async function apiAuthModify(e,t,n){const o="./api/auth/modify",i="PERM_API_MOD";let s;try{s=await getToken(i,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const a={method:"POST",headers:{"Content-Type":"application/json",apikey:s,targetKey:e,permission:t,permissionModifier:n}};try{const e=await fetch(o,a);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiAuthModify:",e),e}}async function apiAuthFriendlyName(e,t){const s="./api/auth/friendlyname",o="PERM_API_MOD";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"PUT",headers:{"Content-Type":"application/json",apikey:n,targetKey:e,friendlyName:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiAuthModify:",e),e}}async function apiAuthDelete(e){const n="./api/auth/delete",s="PERM_API_MOD";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t,targetKey:e}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiAuthDelete:",e),e}}async function apiAuthCreate(){const t="./api/auth/create",n="PERM_API_MOD";let e;try{e=await getToken(n,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const s={method:"POST",headers:{"Content-Type":"application/json",apikey:e,basicPermissions:"true"}};try{const e=await fetch(t,s);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const n=await e.json();return n}catch(e){throw console.error("Error in apiAuthCreate:",e),e}}async function apiChunkComplete(e,t,n,s,o,i,a,r,c,l){const u="./api/chunk/complete",h="PERM_UPLOAD";let d;try{d=await getToken(h,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const m={method:"POST",headers:{"Content-Type":"application/json",apikey:d,uuid:e,filename:"base64:"+Base64.encode(t),filesize:n,realsize:s,contenttype:o,allowedDownloads:i,expiryDays:a,password:r,isE2E:c,nonblocking:l}};try{const e=await fetch(u,m);if(!e.ok){let t;try{const n=await e.json();t=n.ErrorMessage||`Request failed with status: ${e.status}`}catch{const n=await e.text();t=n||`Request failed with status: ${e.status}`}throw new Error(t)}const t=await e.json();return t}catch(e){throw console.error("Error in apiChunkComplete:",e),e}}async function apiFilesReplace(e,t){const s="./api/files/replace",o="PERM_REPLACE";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"PUT",headers:{"Content-Type":"application/json",id:e,apikey:n,idNewContent:t,deleteNewFile:!1}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesReplace:",e),e}}async function apiFilesListById(e){const n="./api/files/list/"+e,s="PERM_VIEW";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"GET",headers:{"Content-Type":"application/json",apikey:t}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesListById:",e),e}}async function apiFilesListDownloadSingle(e){const n="./api/files/download/"+e,s="PERM_DOWNLOAD";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"GET",headers:{"Content-Type":"application/json",apikey:t,presignUrl:!0}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesListDownloadSingle:",e),e}}async function apiFilesListDownloadZip(e,t){const s="./api/files/downloadzip",o="PERM_DOWNLOAD";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"GET",headers:{"Content-Type":"application/json",apikey:n,ids:e,filename:"base64:"+Base64.encode(t),presignUrl:!0}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesListDownloadZip:",e),e}}async function apiFilesModify(e,t,n,s,o){const a="./api/files/modify",r="PERM_EDIT";let i;try{i=await getToken(r,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const c={method:"PUT",headers:{"Content-Type":"application/json",id:e,apikey:i,allowedDownloads:t,expiryTimestamp:n,password:s,originalPassword:o}};try{const e=await fetch(a,c);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesModify:",e),e}}async function apiFilesDelete(e,t){const s="./api/files/delete",o="PERM_DELETE";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"POST",headers:{"Content-Type":"application/json",apikey:n,id:e,delay:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiFilesDelete:",e),e}}async function apiFilesRestore(e){const n="./api/files/restore",s="PERM_DELETE";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t,id:e}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiFilesRestore:",e),e}}async function apiUserCreate(e){const n="./api/user/create",s="PERM_MANAGE_USERS";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t,username:e}};try{const e=await fetch(n,o);if(!e.ok)throw e.status==409?new Error("duplicate"):new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiUserModify:",e),e}}async function apiUserModify(e,t,n){const o="./api/user/modify",i="PERM_MANAGE_USERS";let s;try{s=await getToken(i,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const a={method:"POST",headers:{"Content-Type":"application/json",apikey:s,userid:e,userpermission:t,permissionModifier:n}};try{const e=await fetch(o,a);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiUserModify:",e),e}}async function apiUserChangeRank(e,t){const s="./api/user/changeRank",o="PERM_MANAGE_USERS";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"POST",headers:{"Content-Type":"application/json",apikey:n,userid:e,newRank:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiUserModify:",e),e}}async function apiUserDelete(e,t){const s="./api/user/delete",o="PERM_MANAGE_USERS";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"POST",headers:{"Content-Type":"application/json",apikey:n,userid:e,deleteFiles:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiUserDelete:",e),e}}async function apiUserResetPassword(e,t){const s="./api/user/resetPassword",o="PERM_MANAGE_USERS";let n;try{n=await getToken(o,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const i={method:"POST",headers:{"Content-Type":"application/json",apikey:n,userid:e,generateNewPassword:t}};try{const e=await fetch(s,i);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiUserResetPassword:",e),e}}async function apiLogsDelete(e){const n="./api/logs/delete",s="PERM_MANAGE_LOGS";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t,timestamp:e}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiLogsDelete:",e),e}}async function apiE2eGet(){const t="./api/e2e/get",n="PERM_UPLOAD";let e;try{e=await getToken(n,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const s={method:"POST",headers:{"Content-Type":"application/json",apikey:e}};try{const e=await fetch(t,s);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);return await e.text()}catch(e){throw console.error("Error in apiE2eGet:",e),e}}async function apiE2eStore(e){const n="./api/e2e/set",s="PERM_UPLOAD";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"POST",headers:{"Content-Type":"application/json",apikey:t},body:JSON.stringify({content:e})};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiE2eStore:",e),e}}async function apiURequestDelete(e){const n="./api/uploadrequest/delete",s="PERM_MANAGE_FILE_REQUESTS";let t;try{t=await getToken(s,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const o={method:"DELETE",headers:{"Content-Type":"application/json",apikey:t,id:e}};try{const e=await fetch(n,o);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}catch(e){throw console.error("Error in apiURequestDelete:",e),e}}async function apiURequestSave(e,t,n,s,o,i){const r="./api/uploadrequest/save",c="PERM_MANAGE_FILE_REQUESTS";let a;try{a=await getToken(c,!1)}catch(e){throw console.error("Unable to gain permission token:",e),e}const l={method:"POST",headers:{"Content-Type":"application/json",apikey:a,id:e,name:"base64:"+Base64.encode(t),expiry:o,maxfiles:n,maxsize:s,notes:"base64:"+Base64.encode(i)}};try{const e=await fetch(r,l);if(!e.ok)throw new Error(`Request failed with status: ${e.status}`);const t=await e.json();return t}catch(e){throw console.error("Error in apiURequestDelete:",e),e}}try{var toastId,calendarInstance,dropzoneObject,isE2EEnabled,isUploading,rowCount,statusItemCount,clipboard=new ClipboardJS(".copyurl")}catch{}function showToast(e,t){let n=document.getElementById("toastnotification");typeof t!="undefined"?n.innerText=t:n.innerText=n.dataset.default,n.classList.add("show"),clearTimeout(toastId),toastId=setTimeout(()=>{hideToast()},e)}function hideToast(){document.getElementById("toastnotification").classList.remove("show")}calendarInstance=null;function createCalendar(e,t){const n=new Date(t*1e3);calendarInstance=flatpickr(document.getElementById(e),{enableTime:!0,dateFormat:"U",altInput:!0,altFormat:"Y-m-d H:i",allowInput:!0,time_24hr:!0,defaultDate:n,minDate:"today"})}function handleEditCheckboxChange(e){var t=document.getElementById(e.getAttribute("data-toggle-target")),n=e.getAttribute("data-timestamp");e.checked?(t.classList.remove("disabled"),t.removeAttribute("disabled"),n!=null&&(calendarInstance._input.disabled=!1)):(n!=null&&(calendarInstance._input.disabled=!0),t.classList.add("disabled"),t.setAttribute("disabled",!0))}function downloadFileWithPresign(e){apiFilesListDownloadSingle(e).then(e=>{if(!e.hasOwnProperty("downloadUrl"))throw new Error("Unable to get presigned key");const t=document.createElement("a");t.href=e.downloadUrl,t.style.display="none",document.body.appendChild(t),t.click(),t.remove()}).catch(e=>{alert("Unable to download: "+e),console.error("Error:",e)})}function downloadFilesZipWithPresign(e,t){apiFilesListDownloadZip(e,t).then(e=>{if(!e.hasOwnProperty("downloadUrl"))throw new Error("Unable to get presigned key");const t=document.createElement("a");t.href=e.downloadUrl,t.style.display="none",document.body.appendChild(t),t.click(),t.remove()}).catch(e=>{alert("Unable to download: "+e),console.error("Error:",e)})}function changeApiPermission(e,t,n){var o,i,s=document.getElementById(n);if(s.classList.contains("perm-processing")||s.classList.contains("perm-nochange"))return;o=s.classList.contains("perm-granted"),s.classList.add("perm-processing"),s.classList.remove("perm-granted"),s.classList.remove("perm-notgranted"),i="GRANT",o&&(i="REVOKE"),apiAuthModify(e,t,i).then(e=>{o?(s.classList.add("perm-notgranted"),s.classList.add("perm-nownotgranted")):(s.classList.add("perm-granted"),s.classList.add("perm-nowgranted")),s.classList.remove("perm-processing"),setTimeout(()=>{s.classList.remove("perm-nowgranted"),s.classList.remove("perm-nownotgranted")},1e3)}).catch(e=>{o?s.classList.add("perm-granted"):s.classList.add("perm-notgranted"),s.classList.remove("perm-processing"),alert("Unable to set permission: "+e),console.error("Error:",e)})}function deleteApiKey(e){document.getElementById("delete-"+e).disabled=!0,apiAuthDelete(e).then(t=>{document.getElementById("row-"+e).classList.add("rowDeleting"),setTimeout(()=>{document.getElementById("row-"+e).remove()},290)}).catch(e=>{alert("Unable to delete API key: "+e),console.error("Error:",e)})}function newApiKey(){document.getElementById("button-newapi").disabled=!0,apiAuthCreate().then(e=>{addRowApi(e.Id,e.PublicId),document.getElementById("button-newapi").disabled=!1}).catch(e=>{alert("Unable to create API key: "+e),console.error("Error:",e)})}function addFriendlyNameChange(e){let t=document.getElementById("friendlyname-"+e);if(t.classList.contains("isBeingEdited"))return;t.classList.add("isBeingEdited");let i=t.innerText,n=document.createElement("input");n.size=5,n.value=i;let s=!0,o=function(){if(!s)return;s=!1;let o=n.value;o==""&&(o="Unnamed key"),t.innerText=o,t.classList.remove("isBeingEdited"),apiAuthFriendlyName(e,o).catch(e=>{alert("Unable to save name: "+e),console.error("Error:",e)})};n.onblur=o,n.addEventListener("keyup",function(e){e.keyCode===13&&(e.preventDefault(),o())}),t.innerText="",t.appendChild(n),n.focus()}function addRowApi(e,t){let p=document.getElementById("apitable"),s=p.insertRow(0);s.id="row-"+t;let i=0,c=s.insertCell(i++),l=s.insertCell(i++),d=s.insertCell(i++),a=s.insertCell(i++),u;canViewOtherApiKeys&&(u=s.insertCell(i++));let h=s.insertCell(i++);canViewOtherApiKeys&&(u.classList.add("newApiKey"),u.innerText=userName),c.classList.add("newApiKey"),l.classList.add("newApiKey"),d.classList.add("newApiKey"),a.classList.add("newApiKey"),a.classList.add("prevent-select"),h.classList.add("newApiKey"),c.innerText="Unnamed key",c.id="friendlyname-"+t,c.onclick=function(){addFriendlyNameChange(t)},l.innerText=e,l.classList.add("font-monospace"),d.innerText="Never";const r=document.createElement("div");r.className="btn-group",r.setAttribute("role","group");const n=document.createElement("button");n.type="button",n.dataset.clipboardText=e,n.title="Copy API Key",n.className="copyurl btn btn-outline-light btn-sm",n.setAttribute("onclick","showToast(1000)");const m=document.createElement("i");m.className="bi bi-copy",n.appendChild(m);const o=document.createElement("button");o.type="button",o.id=`delete-${t}`,o.title="Delete",o.className="btn btn-outline-danger btn-sm",o.setAttribute("onclick",`deleteApiKey('${t}')`);const f=document.createElement("i");f.className="bi bi-trash3",o.appendChild(f),r.appendChild(n),r.appendChild(o),h.appendChild(r);const g=[{perm:"PERM_VIEW",icon:"bi-eye",granted:!0,title:"List Uploads"},{perm:"PERM_UPLOAD",icon:"bi-file-earmark-plus",granted:!0,title:"Upload"},{perm:"PERM_EDIT",icon:"bi-pencil",granted:!0,title:"Edit Uploads"},{perm:"PERM_DELETE",icon:"bi-trash3",granted:!0,title:"Delete Uploads"},{perm:"PERM_REPLACE",icon:"bi-recycle",granted:!1,title:"Replace Uploads"},{perm:"PERM_DOWNLOAD",icon:"bi-box-arrow-in-down",granted:!1,title:"Download Files"},{perm:"PERM_MANAGE_FILE_REQUESTS",icon:"bi-file-earmark-arrow-up",granted:!1,title:"Manage File Requests"},{perm:"PERM_MANAGE_USERS",icon:"bi-people",granted:!1,title:"Manage Users"},{perm:"PERM_MANAGE_LOGS",icon:"bi-card-list",granted:!1,title:"Manage System Logs"},{perm:"PERM_API_MOD",icon:"bi-sliders2",granted:!1,title:"Manage API Keys"}];if(g.forEach(({perm:e,icon:n,granted:s,title:o})=>{const i=document.createElement("i"),r=`${e.toLowerCase()}_${t}`;i.id=r,i.className=`bi ${n} ${s?"perm-granted":"perm-notgranted"}`,i.title=o,i.setAttribute("onclick",`changeApiPermission("${t}","${e}", "${r}");`),a.appendChild(i),a.appendChild(document.createTextNode(" "))}),!canReplaceFiles){let e=document.getElementById("perm_replace_"+t);e.classList.add("perm-unavailable"),e.classList.add("perm-nochange")}if(!canManageUsers){let e=document.getElementById("perm_manage_users_"+t);e.classList.add("perm-unavailable"),e.classList.add("perm-nochange")}if(!canViewSystemLog){let e=document.getElementById("perm_manage_logs_"+t);e.classList.add("perm-unavailable"),e.classList.add("perm-nochange")}if(!canCreateFileRequest){let e=document.getElementById("perm_manage_file_requests_"+t);e.classList.add("perm-unavailable"),e.classList.add("perm-nochange")}setTimeout(()=>{c.classList.remove("newApiKey"),l.classList.remove("newApiKey"),d.classList.remove("newApiKey"),a.classList.remove("newApiKey"),h.classList.remove("newApiKey")},700)}function deleteFileRequest(e){document.getElementById("delete-"+e).disabled=!0,apiURequestDelete(e).then(t=>{const s=document.getElementById("row-"+e),n=document.getElementById("filelist-"+e);s.classList.add("rowDeleting"),n!==null&&n.classList.add("rowDeleting"),setTimeout(()=>{s.remove(),n!==null&&n.remove()},290)}).catch(e=>{alert("Unable to delete file request: "+e),console.error("Error:",e)})}function deleteOrShowModal(e,t,n){n===0?deleteFileRequest(e):showDeleteFRequestModal(e,t,n)}function showDeleteFRequestModal(e,t,n){document.getElementById("deleteModalBodyName").innerText=t,document.getElementById("deleteModalBodyCount").innerText=n,$("#deleteModal").modal("show"),document.getElementById("buttonDelete").onclick=function(){$("#deleteModal").modal("hide"),deleteFileRequest(e)}}function newFileRequest(){loadFileRequestDefaults(),document.getElementById("m_urequestlabel").innerText="New File Request",$("#addEditModal").modal("show"),document.getElementById("b_fr_save").onclick=function(){saveFileRequestDefaults(),saveFileRequest(),$("#addEditModal").modal("hide")}}function saveFileRequestDefaults(){if(document.getElementById("mc_maxfiles").checked?localStorage.setItem("fr_maxfiles",document.getElementById("mi_maxfiles").value):localStorage.setItem("fr_maxfiles",0),document.getElementById("mc_maxsize").checked?localStorage.setItem("fr_maxsize",document.getElementById("mi_maxsize").value):localStorage.setItem("fr_maxsize",0),document.getElementById("mc_expiry").checked){let e=document.getElementById("mi_expiry").value-Math.round(Date.now()/1e3);localStorage.setItem("fr_expiry",e)}else localStorage.setItem("fr_expiry",0)}function loadFileRequestDefaults(){const t=localStorage.getItem("fr_maxfiles"),n=localStorage.getItem("fr_maxsize");let e=localStorage.getItem("fr_expiry");if(e!=="0"&&e!==null){let t=new Date(Date.now()+Number(e*1e3));t.setHours(12,0,0,0),e=Math.floor(t.getTime()/1e3)}setModalValues("","",t,n,e,"")}function setModalValues(e,t,n,s,o,i){if(document.getElementById("freqId").value=e,t===null?document.getElementById("mFriendlyName").value="":document.getElementById("mFriendlyName").value=t,limitMaxFiles!=0){let e=document.getElementById("mc_maxfiles");(n===null||n==0)&&(n=limitMaxFiles),e.checked=!0,e.disabled=!0,e.title="Only admins can set this to unlimited",e.value="1",document.getElementById("mi_maxfiles").setAttribute("max",limitMaxFiles)}else{let e=document.getElementById("mc_maxfiles");e.disabled=!1,e.title="",document.getElementById("mi_maxfiles").setAttribute("max","")}if(limitMaxSize!=0){let e=document.getElementById("mc_maxsize");(s===null||s==0)&&(s=limitMaxSize),e.checked=!0,e.disabled=!0,e.title="Only admins can set this to unlimited",e.value="1",document.getElementById("mi_maxsize").setAttribute("max",limitMaxSize)}else{let e=document.getElementById("mc_maxsize");e.disabled=!1,e.title="",document.getElementById("mi_maxsize").setAttribute("max","")}if(n===null||n==0?(document.getElementById("mi_maxfiles").value="1",document.getElementById("mi_maxfiles").disabled=!0,document.getElementById("mc_maxfiles").checked=!1):(document.getElementById("mi_maxfiles").value=n,document.getElementById("mi_maxfiles").disabled=!1,document.getElementById("mc_maxfiles").checked=!0),s===null||s==0?(document.getElementById("mi_maxsize").value="10",document.getElementById("mi_maxsize").disabled=!0,document.getElementById("mc_maxsize").checked=!1):(document.getElementById("mi_maxsize").value=s,document.getElementById("mi_maxsize").disabled=!1,document.getElementById("mc_maxsize").checked=!0),o===null||o==0){const e=Math.floor(new Date(Date.now()+14*24*60*60*1e3).getTime()/1e3);document.getElementById("mi_expiry").disabled=!0,document.getElementById("mc_expiry").checked=!1,document.getElementById("mi_expiry").value=e,createCalendar("mi_expiry",e)}else document.getElementById("mi_expiry").value=o,document.getElementById("mi_expiry").disabled=!1,document.getElementById("mc_expiry").checked=!0,createCalendar("mi_expiry",o);document.getElementById("mNotes").value=i}function editFileRequest(e,t,n,s,o,i){setModalValues(e,t,n,s,o,i),document.getElementById("m_urequestlabel").innerText="Edit File Request",$("#addEditModal").modal("show"),document.getElementById("b_fr_save").onclick=function(){saveFileRequest(),$("#addEditModal").modal("hide")}}function saveFileRequest(){const s=document.getElementById("b_fr_save"),o=document.getElementById("freqId").value,i=document.getElementById("mFriendlyName").value,a=document.getElementById("mNotes").value;let e=0,t=0,n=0;document.getElementById("mc_maxfiles").checked&&(e=document.getElementById("mi_maxfiles").value),document.getElementById("mc_maxsize").checked&&(t=document.getElementById("mi_maxsize").value),document.getElementById("mc_expiry").checked&&(n=document.getElementById("mi_expiry").value),s.disabled=!0,apiURequestSave(o,i,e,t,n,a).then(e=>{document.getElementById("b_fr_save").disabled=!1,insertOrReplaceFileRequest(e)}).catch(e=>{alert("Unable to save file request: "+e),console.error("Error:",e),document.getElementById("b_fr_save").disabled=!1})}function checkMaxNumber(e){if(e.value==""){e.value="1";return}let t=e.getAttribute("max");if(t=="")return;e.value>t&&(e.value=t)}function insertOrReplaceFileRequest(e){const n=document.getElementById("filerequesttable");let t=document.getElementById(`row-${e.id}`);if(t){const n=document.getElementById(`cell-username-${e.id}`).innerText;t.replaceWith(createFileRequestRow(e,n))}else{let t=createFileRequestRow(e,userName);t.querySelectorAll("td").forEach(e=>{e.classList.add("newFileRequest"),setTimeout(()=>{e.classList.remove("newFileRequest")},700)}),n.prepend(t)}}function createFileRequestRow(e,t){function r(e){const t=document.createElement("td");return t.textContent=e,t}function h(e,t){const s=document.createElement("td"),n=document.createElement("a");return n.textContent=e,n.href=t,n.target="_blank",s.appendChild(n),s}function c(e){const t=document.createElement("i");return t.className=`bi ${e}`,t}const d=`${baseUrl}publicUpload?id=${e.id}&key=${e.apikey}`,n=document.createElement("tr");if(n.id=`row-${e.id}`,n.className="filerequest-item",n.appendChild(h(e.name,d)),e.maxfiles==0?n.appendChild(r(e.uploadedfiles)):n.appendChild(r(`${e.uploadedfiles} / ${e.maxfiles}`)),n.appendChild(r(getReadableSize(e.totalfilesize))),n.appendChild(r(formatTimestampWithNegative(e.lastupload,"None"))),n.appendChild(r(formatFileRequestExpiry(e.expiry))),canViewOtherRequests){let s=r(t);s.id=`cell-username-${e.id}`,n.appendChild(s)}const u=document.createElement("td"),l=document.createElement("div");l.className="btn-group",l.role="group";const o=document.createElement("button");o.id=`download-${e.id}`,o.type="button",o.className="btn btn-outline-light btn-sm",o.title="Download all",e.uploadedfiles==0&&o.classList.add("disabled"),o.appendChild(c("bi-download"));const s=document.createElement("button");s.id=`copy-${e.id}`,s.type="button",s.className="copyurl btn btn-outline-light btn-sm",s.title="Copy URL",s.setAttribute("data-clipboard-text",d),s.onclick=()=>showToast(1e3),s.appendChild(c("bi-copy"));const i=document.createElement("button");i.id=`edit-${e.id}`,i.type="button",i.className="btn btn-outline-light btn-sm",i.title="Edit request",i.onclick=()=>editFileRequest(e.id,e.name,e.maxfiles,e.maxsize,e.expiry,e.notes),i.appendChild(c("bi-pencil"));const a=document.createElement("button");return a.id=`delete-${e.id}`,a.type="button",a.className="btn btn-outline-danger btn-sm",a.title="Delete",a.onclick=()=>deleteOrShowModal(e.id,e.name,e.uploadedfiles),a.appendChild(c("bi-trash3")),l.append(o,s,i,a),u.appendChild(l),n.appendChild(u),n}function filterLogs(e){e=="all"?textarea.value=logContent:textarea.value=logContent.split(` `).filter(t=>t.includes("["+e+"]")).join(` -`),textarea.scrollTop=textarea.scrollHeight}function deleteLogs(e){if(e=="none")return;if(!confirm("Do you want to delete the selected logs?")){document.getElementById("deleteLogs").selectedIndex=0;return}let t=Math.floor(Date.now()/1e3);switch(e){case"all":t=0;break;case"2":t=t-2*24*60*60;break;case"7":t=t-7*24*60*60;break;case"14":t=t-14*24*60*60;break;case"30":t=t-30*24*60*60;break}apiLogsDelete(t).then(e=>{location.reload()}).catch(e=>{alert("Unable to delete logs: "+e),console.error("Error:",e)})}isE2EEnabled=!1,isUploading=!1,rowCount=-1;function initDropzone(){Dropzone.options.uploaddropzone={paramName:"file",dictDefaultMessage:"Drop files, paste or click here to upload",createImageThumbnails:!1,chunksUploaded:function(e,t){sendChunkComplete(e,t)},init:function(){dropzoneObject=this,this.on("addedfile",e=>{e.upload.uuid=getUuid(),saveUploadDefaults(),addFileProgress(e)}),this.on("queuecomplete",function(){isUploading=!1}),this.on("sending",function(){isUploading=!0}),this.on("error",function(e,t,n){if(console.log(t),n){if(n.status===413){showError(e,"File too large to upload. If you are using a reverse proxy, make sure that the allowed body size is at least 70MB.");return}try{console.log(n),errInfo=JSON.parse(n.responseText),showError(e,"Error: "+errInfo.ErrorMessage)}catch{showError(e,"Error: "+n.responseText)}}else showError(e,"Error: "+t)}),this.on("uploadprogress",function(e,t,n){updateProgressbar(e,t,n)}),isE2EEnabled&&(dropzoneObject.disable(),dropzoneObject.options.dictDefaultMessage="Loading end-to-end encryption...",document.getElementsByClassName("dz-button")[0].innerText="Loading end-to-end encryption...",setE2eUpload())}},document.onpaste=function(e){if(dropzoneObject.disabled)return;const n=document.activeElement;if(n&&(n.hasAttribute("data-allow-regular-paste")||n.hasAttribute("placeholder")))return;var t,s=(e.clipboardData||e.originalEvent.clipboardData).items;for(let e in s)t=s[e],t.kind==="file"&&dropzoneObject.addFile(t.getAsFile()),t.kind==="string"&&t.getAsString(function(e){const t=//gi;if(t.test(e)===!1){let t=new Blob([e],{type:"text/plain"}),n=new File([t],"Pasted Text.txt",{type:"text/plain",lastModified:new Date(0)});dropzoneObject.addFile(n)}})},window.addEventListener("beforeunload",e=>{isUploading&&(e.returnValue="Upload is still in progress. Do you want to close this page?")})}function updateProgressbar(e,t,n){let o=e.upload.uuid,i=document.getElementById(`us-container-${o}`);if(i==null||i.getAttribute("data-complete")==="true")return;let s=Math.round(t);s<0&&(s=0),s>100&&(s=100);let r=Date.now()-i.getAttribute("data-starttime"),c=n/(r/1e3)/1024/1024;document.getElementById(`us-progressbar-${o}`).style.width=s+"%";let a=Math.round(c*10)/10;Number.isNaN(a)||(document.getElementById(`us-progress-info-${o}`).innerText=s+"% - "+a+"MB/s")}function addFileProgress(e){addFileStatus(e.upload.uuid,e.upload.filename)}function setUploadDefaults(){let s=getLocalStorageWithDefault("defaultDownloads",1),o=getLocalStorageWithDefault("defaultExpiry",14),e=getLocalStorageWithDefault("defaultPassword",""),t=getLocalStorageWithDefault("defaultUnlimitedDownloads",!1)==="true",n=getLocalStorageWithDefault("defaultUnlimitedTime",!1)==="true";document.getElementById("allowedDownloads").value=s,document.getElementById("expiryDays").value=o,document.getElementById("password").value=e,document.getElementById("enableDownloadLimit").checked=!t,document.getElementById("enableTimeLimit").checked=!n,e===""?(document.getElementById("enablePassword").checked=!1,document.getElementById("password").disabled=!0):(document.getElementById("enablePassword").checked=!0,document.getElementById("password").disabled=!1),t&&(document.getElementById("allowedDownloads").disabled=!0),n&&(document.getElementById("expiryDays").disabled=!0)}function saveUploadDefaults(){localStorage.setItem("defaultDownloads",document.getElementById("allowedDownloads").value),localStorage.setItem("defaultExpiry",document.getElementById("expiryDays").value),localStorage.setItem("defaultPassword",document.getElementById("password").value),localStorage.setItem("defaultUnlimitedDownloads",!document.getElementById("enableDownloadLimit").checked),localStorage.setItem("defaultUnlimitedTime",!document.getElementById("enableTimeLimit").checked)}function getLocalStorageWithDefault(e,t){var n=localStorage.getItem(e);return n===null?t:n}function urlencodeFormData(e){let t="";function s(e){return encodeURIComponent(e).replace(/%20/g,"+")}for(var n of e.entries())typeof n[1]=="string"&&(t+=(t?"&":"")+s(n[0])+"="+s(n[1]));return t}function sendChunkComplete(e,t){let c=e.upload.uuid,n=e.name,s=e.size,l=e.size,o=e.type,i=document.getElementById("allowedDownloads").value,a=document.getElementById("expiryDays").value,d=document.getElementById("password").value,r=e.isEndToEndEncrypted===!0,u=!0;document.getElementById("enableDownloadLimit").checked||(i=0),document.getElementById("enableTimeLimit").checked||(a=0),r&&(s=e.sizeEncrypted,n="Encrypted File",o=""),apiChunkComplete(c,n,s,l,o,i,a,d,r,u).then(n=>{t();let s=document.getElementById(`us-progress-info-${e.upload.uuid}`);s!=null&&(s.innerText="In Queue...")}).catch(t=>{console.error("Error:",t),dropzoneUploadError(e,t)})}function dropzoneUploadError(e,t){e.accepted=!1,dropzoneObject._errorProcessing([e],t),showError(e,t)}function dropzoneGetFile(e){for(let t=0;t{addRow(n);let s=dropzoneGetFile(t);if(s==null)return;if(s.isEndToEndEncrypted===!0){try{let o=GokapiE2EAddFile(t,e,s.name);if(o instanceof Error)throw o;let n=GokapiE2EInfoEncrypt();if(n instanceof Error)throw n;storeE2EInfo(n)}catch(e){s.accepted=!1,dropzoneObject._errorProcessing([s],e);return}GokapiE2EDecryptMenu()}removeFileStatus(t)}).catch(e=>{let n=dropzoneGetFile(t);n!=null&&dropzoneUploadError(n,e),console.error("Error:",e)})}function parseProgressStatus(e){let n=document.getElementById(`us-container-${e.chunk_id}`);if(n==null)return;n.setAttribute("data-complete","true");let t;switch(e.upload_status){case 0:t="Processing file...";break;case 1:t="Uploading file...";break;case 2:t="Finalising...",requestFileInfo(e.file_id,e.chunk_id);break;case 3:t="Error";let n=dropzoneGetFile(e.chunk_id);e.error_message==""&&(e.error_message="Server Error"),n!=null&&dropzoneUploadError(n,e.error_message);return;default:t="Unknown status";break}document.getElementById(`us-progress-info-${e.chunk_id}`).innerText=t}function showError(e,t){let n=e.upload.uuid;document.getElementById(`us-progressbar-${n}`).style.width="100%",document.getElementById(`us-progressbar-${n}`).style.backgroundColor="red",document.getElementById(`us-progress-info-${n}`).innerText=t,document.getElementById(`us-progress-info-${n}`).classList.add("uploaderror")}function editFile(){const e=document.getElementById("mb_save");e.disabled=!0;let s=e.getAttribute("data-fileid"),o=document.getElementById("mi_edit_down").value,i=document.getElementById("mi_edit_expiry").value,t=document.getElementById("mi_edit_pw").value,a=t==="(unchanged)";document.getElementById("mc_download").checked||(o=0),document.getElementById("mc_expiry").checked||(i=0),document.getElementById("mc_password").checked||(a=!1,t="");let r=!1,n="";document.getElementById("mc_replace").checked&&(n=document.getElementById("mi_edit_replace").value,r=n!=""),apiFilesModify(s,o,i,t,a).then(t=>{if(!r){location.reload();return}apiFilesReplace(s,n).then(e=>{location.reload()}).catch(t=>{alert("Unable to edit file: "+t),console.error("Error:",t),e.disabled=!1})}).catch(t=>{alert("Unable to edit file: "+t),console.error("Error:",t),e.disabled=!1})}calendarInstance=null;function createCalendar(e){const t=new Date(e*1e3);calendarInstance=flatpickr("#mi_edit_expiry",{enableTime:!0,dateFormat:"U",altInput:!0,altFormat:"Y-m-d H:i",allowInput:!0,time_24hr:!0,defaultDate:t,minDate:"today"})}function handleEditCheckboxChange(e){var t=document.getElementById(e.getAttribute("data-toggle-target")),n=e.getAttribute("data-timestamp");e.checked?(t.classList.remove("disabled"),t.removeAttribute("disabled"),n!=null&&(calendarInstance._input.disabled=!1)):(n!=null&&(calendarInstance._input.disabled=!0),t.classList.add("disabled"),t.setAttribute("disabled",!0))}function showEditModal(e,t,n,s,o,i,a,r,c){let d=$("#modaledit").clone();$("#modaledit").on("hide.bs.modal",function(){$("#modaledit").remove();let e=d.clone();$("body").append(e)}),document.getElementById("m_filenamelabel").innerText=e,document.getElementById("mc_expiry").setAttribute("data-timestamp",s),document.getElementById("mb_save").setAttribute("data-fileid",t),createCalendar(s),i?(document.getElementById("mi_edit_down").value="1",document.getElementById("mi_edit_down").disabled=!0,document.getElementById("mc_download").checked=!1):(document.getElementById("mi_edit_down").value=n,document.getElementById("mi_edit_down").disabled=!1,document.getElementById("mc_download").checked=!0),a?(document.getElementById("mi_edit_expiry").value=add14DaysIfBeforeCurrentTime(s),document.getElementById("mi_edit_expiry").disabled=!0,document.getElementById("mc_expiry").checked=!1,calendarInstance._input.disabled=!0):(document.getElementById("mi_edit_expiry").value=s,document.getElementById("mi_edit_expiry").disabled=!1,document.getElementById("mc_expiry").checked=!0,calendarInstance._input.disabled=!1),o?(document.getElementById("mi_edit_pw").value="(unchanged)",document.getElementById("mi_edit_pw").disabled=!1,document.getElementById("mc_password").checked=!0):(document.getElementById("mi_edit_pw").value="",document.getElementById("mi_edit_pw").disabled=!0,document.getElementById("mc_password").checked=!1);let l=document.getElementById("mi_edit_replace");if(c)if(document.getElementById("replaceGroup").style.display="flex",r)document.getElementById("mc_replace").disabled=!0,document.getElementById("mc_replace").title="Replacing content is not available for end-to-end encrypted files",l.add(new Option("Unavailable",0)),l.title="Replacing content is not available for end-to-end encrypted files",l.value="0";else{let e=getAllAvailableFiles();for(let n=0;n{changeRowCount(!1,document.getElementById("row-"+e)),showToastFileDeletion(e)}).catch(e=>{alert("Unable to delete file: "+e),console.error("Error:",e)})}function checkBoxChanged(e,t){let n=!e.checked;n?document.getElementById(t).setAttribute("disabled",""):document.getElementById(t).removeAttribute("disabled"),t==="password"&&n&&(document.getElementById("password").value="")}function parseSseData(e){let t;try{t=JSON.parse(e)}catch(e){console.error("Failed to parse event data:",e);return}switch(t.event){case"download":setNewDownloadCount(t.file_id,t.download_count,t.downloads_remaining);return;case"uploadStatus":parseProgressStatus(t);return;default:console.error("Unknown event",t)}}function setNewDownloadCount(e,t,n){let s=document.getElementById("cell-downloads-"+e);if(s!=null&&(s.innerText=t,s.classList.add("updatedDownloadCount"),setTimeout(()=>s.classList.remove("updatedDownloadCount"),500)),n!=-1){let t=document.getElementById("cell-downloadsRemaining-"+e);t!=null&&(t.innerText=n,t.classList.add("updatedDownloadCount"),setTimeout(()=>t.classList.remove("updatedDownloadCount"),500))}}function registerChangeHandler(){const e=new EventSource("./uploadStatus");e.onmessage=e=>{parseSseData(e.data)},e.onerror=t=>{t.target.readyState!==EventSource.CLOSED&&e.close(),console.log("Reconnecting to SSE..."),setTimeout(registerChangeHandler,5e3)}}statusItemCount=0;function addFileStatus(e,t){const n=document.createElement("div");n.setAttribute("id",`us-container-${e}`),n.classList.add("us-container");const a=document.createElement("div");a.classList.add("filename"),a.textContent=t,n.appendChild(a);const s=document.createElement("div");s.classList.add("upload-progress-container"),s.setAttribute("id",`us-progress-container-${e}`);const r=document.createElement("div");r.classList.add("upload-progress-bar");const o=document.createElement("div");o.setAttribute("id",`us-progressbar-${e}`),o.classList.add("upload-progress-bar-progress"),o.style.width="0%",r.appendChild(o);const i=document.createElement("div");i.setAttribute("id",`us-progress-info-${e}`),i.classList.add("upload-progress-info"),i.textContent="0%",s.appendChild(r),s.appendChild(i),n.appendChild(s),n.setAttribute("data-starttime",Date.now()),n.setAttribute("data-complete","false");const c=document.getElementById("uploadstatus");c.appendChild(n),c.style.visibility="visible",statusItemCount++}function removeFileStatus(e){const t=document.getElementById(`us-container-${e}`);if(t==null)return;t.remove(),statusItemCount--,statusItemCount<1&&(document.getElementById("uploadstatus").style.visibility="hidden")}function addRow(e){let d=document.getElementById("downloadtable"),t=d.insertRow(0);e.Id=sanitizeId(e.Id),t.id="row-"+e.Id;let i=t.insertCell(0),a=t.insertCell(1),s=t.insertCell(2),r=t.insertCell(3),c=t.insertCell(4),o=t.insertCell(5),l=t.insertCell(6);i.innerText=e.Name,i.id="cell-name-"+e.Id,c.id="cell-downloads-"+e.Id,a.innerText=e.Size,e.UnlimitedDownloads?s.innerText="Unlimited":(s.innerText=e.DownloadsRemaining,s.id="cell-downloadsRemaining-"+e.Id),e.UnlimitedTime?r.innerText="Unlimited":r.innerText=formatUnixTimestamp(e.ExpireAt),c.innerText=e.DownloadCount;const n=document.createElement("a");if(n.href=e.UrlDownload,n.target="_blank",n.style.color="inherit",n.id="url-href-"+e.Id,n.textContent=e.Id,o.appendChild(n),e.IsPasswordProtected===!0){const e=document.createElement("i");e.className="bi bi-key",e.title="Password protected",o.appendChild(document.createTextNode(" ")),o.appendChild(e)}return l.appendChild(createButtonGroup(e)),i.classList.add("newItem"),a.classList.add("newItem"),s.classList.add("newItem"),r.classList.add("newItem"),c.classList.add("newItem"),o.classList.add("newItem"),l.classList.add("newItem"),a.setAttribute("data-order",e.SizeBytes),changeRowCount(!0,t),e.Id}function createButtonGroup(e){const h=document.createElement("div");h.className="btn-toolbar",h.setAttribute("role","toolbar");const t=document.createElement("div");t.className="btn-group me-2",t.setAttribute("role","group");const n=document.createElement("button");n.type="button",n.className="copyurl btn btn-outline-light btn-sm",n.dataset.clipboardText=e.UrlDownload,n.id="url-button-"+e.Id,n.title="Copy URL";const j=document.createElement("i");j.className="bi bi-copy",n.appendChild(j),n.appendChild(document.createTextNode(" URL")),n.addEventListener("click",()=>{showToast(1e3)}),t.appendChild(n);const m=document.createElement("button");m.type="button",m.className="btn btn-outline-light btn-sm dropdown-toggle dropdown-toggle-split",m.setAttribute("data-bs-toggle","dropdown"),m.setAttribute("aria-expanded","false"),t.appendChild(m);const f=document.createElement("ul");f.className="dropdown-menu dropdown-menu-end",f.setAttribute("data-bs-theme","dark");const g=document.createElement("li"),s=document.createElement("a");e.UrlHotlink!==""?(s.className="dropdown-item copyurl",s.title="Copy hotlink",s.style.cursor="pointer",s.setAttribute("data-clipboard-text",e.UrlHotlink),s.onclick=()=>showToast(1e3),s.innerHTML=` Hotlink`):(s.className="dropdown-item",s.innerText="Hotlink not available"),g.appendChild(s),f.appendChild(g),t.appendChild(f);const r=document.createElement("button");r.type="button",r.className="btn btn-outline-light btn-sm",r.title="Share",r.onclick=()=>shareUrl(e.Id),r.innerHTML=` +`),textarea.scrollTop=textarea.scrollHeight}function deleteLogs(e){if(e=="none")return;if(!confirm("Do you want to delete the selected logs?")){document.getElementById("deleteLogs").selectedIndex=0;return}let t=Math.floor(Date.now()/1e3);switch(e){case"all":t=0;break;case"2":t=t-2*24*60*60;break;case"7":t=t-7*24*60*60;break;case"14":t=t-14*24*60*60;break;case"30":t=t-30*24*60*60;break}apiLogsDelete(t).then(e=>{location.reload()}).catch(e=>{alert("Unable to delete logs: "+e),console.error("Error:",e)})}isE2EEnabled=!1,isUploading=!1,rowCount=-1;function initDropzone(){Dropzone.options.uploaddropzone={paramName:"file",dictDefaultMessage:"Drop files, paste or click here to upload",createImageThumbnails:!1,chunksUploaded:function(e,t){sendChunkComplete(e,t)},init:function(){dropzoneObject=this,this.on("addedfile",e=>{e.upload.uuid=getUuid(),saveUploadDefaults(),addFileProgress(e)}),this.on("queuecomplete",function(){isUploading=!1}),this.on("sending",function(){isUploading=!0}),this.on("error",function(e,t,n){if(console.log(t),n){if(n.status===413){showError(e,"File too large to upload. If you are using a reverse proxy, make sure that the allowed body size is at least 70MB.");return}try{console.log(n),errInfo=JSON.parse(n.responseText),showError(e,"Error: "+errInfo.ErrorMessage)}catch{showError(e,"Error: "+n.responseText)}}else showError(e,"Error: "+t)}),this.on("uploadprogress",function(e,t,n){updateProgressbar(e,t,n)}),isE2EEnabled&&(dropzoneObject.disable(),dropzoneObject.options.dictDefaultMessage="Loading end-to-end encryption...",document.getElementsByClassName("dz-button")[0].innerText="Loading end-to-end encryption...",setE2eUpload())}},document.onpaste=function(e){if(dropzoneObject.disabled)return;const n=document.activeElement;if(n&&(n.hasAttribute("data-allow-regular-paste")||n.hasAttribute("placeholder")))return;var t,s=(e.clipboardData||e.originalEvent.clipboardData).items;for(let e in s)t=s[e],t.kind==="file"&&dropzoneObject.addFile(t.getAsFile()),t.kind==="string"&&t.getAsString(function(e){const t=//gi;if(t.test(e)===!1){let t=new Blob([e],{type:"text/plain"}),n=new File([t],"Pasted Text.txt",{type:"text/plain",lastModified:new Date(0)});dropzoneObject.addFile(n)}})},window.addEventListener("beforeunload",e=>{isUploading&&(e.returnValue="Upload is still in progress. Do you want to close this page?")})}function updateProgressbar(e,t,n){let o=e.upload.uuid,i=document.getElementById(`us-container-${o}`);if(i==null||i.getAttribute("data-complete")==="true")return;let s=Math.round(t);s<0&&(s=0),s>100&&(s=100);let r=Date.now()-i.getAttribute("data-starttime"),c=n/(r/1e3)/1024/1024;document.getElementById(`us-progressbar-${o}`).style.width=s+"%";let a=Math.round(c*10)/10;Number.isNaN(a)||(document.getElementById(`us-progress-info-${o}`).innerText=s+"% - "+a+"MB/s")}function addFileProgress(e){addFileStatus(e.upload.uuid,e.upload.filename)}function setUploadDefaults(){let s=getLocalStorageWithDefault("defaultDownloads",1),o=getLocalStorageWithDefault("defaultExpiry",14),e=getLocalStorageWithDefault("defaultPassword",""),t=getLocalStorageWithDefault("defaultUnlimitedDownloads",!1)==="true",n=getLocalStorageWithDefault("defaultUnlimitedTime",!1)==="true";document.getElementById("allowedDownloads").value=s,document.getElementById("expiryDays").value=o,document.getElementById("password").value=e,document.getElementById("enableDownloadLimit").checked=!t,document.getElementById("enableTimeLimit").checked=!n,e===""?(document.getElementById("enablePassword").checked=!1,document.getElementById("password").disabled=!0):(document.getElementById("enablePassword").checked=!0,document.getElementById("password").disabled=!1),t&&(document.getElementById("allowedDownloads").disabled=!0),n&&(document.getElementById("expiryDays").disabled=!0)}function saveUploadDefaults(){localStorage.setItem("defaultDownloads",document.getElementById("allowedDownloads").value),localStorage.setItem("defaultExpiry",document.getElementById("expiryDays").value),localStorage.setItem("defaultPassword",document.getElementById("password").value),localStorage.setItem("defaultUnlimitedDownloads",!document.getElementById("enableDownloadLimit").checked),localStorage.setItem("defaultUnlimitedTime",!document.getElementById("enableTimeLimit").checked)}function getLocalStorageWithDefault(e,t){var n=localStorage.getItem(e);return n===null?t:n}function urlencodeFormData(e){let t="";function s(e){return encodeURIComponent(e).replace(/%20/g,"+")}for(var n of e.entries())typeof n[1]=="string"&&(t+=(t?"&":"")+s(n[0])+"="+s(n[1]));return t}function sendChunkComplete(e,t){let c=e.upload.uuid,n=e.name,s=e.size,l=e.size,o=e.type,i=document.getElementById("allowedDownloads").value,a=document.getElementById("expiryDays").value,d=document.getElementById("password").value,r=e.isEndToEndEncrypted===!0,u=!0;document.getElementById("enableDownloadLimit").checked||(i=0),document.getElementById("enableTimeLimit").checked||(a=0),r&&(s=e.sizeEncrypted,n="Encrypted File",o=""),apiChunkComplete(c,n,s,l,o,i,a,d,r,u).then(n=>{t();let s=document.getElementById(`us-progress-info-${e.upload.uuid}`);s!=null&&(s.innerText="In Queue...")}).catch(t=>{console.error("Error:",t),dropzoneUploadError(e,t)})}function dropzoneUploadError(e,t){e.accepted=!1,dropzoneObject._errorProcessing([e],t),showError(e,t)}function dropzoneGetFile(e){for(let t=0;t{addRow(n);let s=dropzoneGetFile(t);if(s==null)return;if(s.isEndToEndEncrypted===!0){try{let o=GokapiE2EAddFile(t,e,s.name);if(o instanceof Error)throw o;let n=GokapiE2EInfoEncrypt();if(n instanceof Error)throw n;storeE2EInfo(n)}catch(e){s.accepted=!1,dropzoneObject._errorProcessing([s],e);return}GokapiE2EDecryptMenu()}removeFileStatus(t)}).catch(e=>{let n=dropzoneGetFile(t);n!=null&&dropzoneUploadError(n,e),console.error("Error:",e)})}function parseProgressStatus(e){let n=document.getElementById(`us-container-${e.chunk_id}`);if(n==null)return;n.setAttribute("data-complete","true");let t;switch(e.upload_status){case 0:t="Processing file...";break;case 1:t="Uploading file...";break;case 2:t="Finalising...",requestFileInfo(e.file_id,e.chunk_id);break;case 3:t="Error";let n=dropzoneGetFile(e.chunk_id);e.error_message==""&&(e.error_message="Server Error"),n!=null&&dropzoneUploadError(n,e.error_message);return;default:t="Unknown status";break}document.getElementById(`us-progress-info-${e.chunk_id}`).innerText=t}function showError(e,t){let n=e.upload.uuid;document.getElementById(`us-progressbar-${n}`).style.width="100%",document.getElementById(`us-progressbar-${n}`).style.backgroundColor="red",document.getElementById(`us-progress-info-${n}`).innerText=t,document.getElementById(`us-progress-info-${n}`).classList.add("uploaderror")}function editFile(){const e=document.getElementById("mb_save");e.disabled=!0;let s=e.getAttribute("data-fileid"),o=document.getElementById("mi_edit_down").value,i=document.getElementById("mi_edit_expiry").value,t=document.getElementById("mi_edit_pw").value,a=t==="(unchanged)";document.getElementById("mc_download").checked||(o=0),document.getElementById("mc_expiry").checked||(i=0),document.getElementById("mc_password").checked||(a=!1,t="");let r=!1,n="";document.getElementById("mc_replace").checked&&(n=document.getElementById("mi_edit_replace").value,r=n!=""),apiFilesModify(s,o,i,t,a).then(t=>{if(!r){location.reload();return}apiFilesReplace(s,n).then(e=>{location.reload()}).catch(t=>{alert("Unable to edit file: "+t),console.error("Error:",t),e.disabled=!1})}).catch(t=>{alert("Unable to edit file: "+t),console.error("Error:",t),e.disabled=!1})}function showEditModal(e,t,n,s,o,i,a,r,c){let d=$("#modaledit").clone();$("#modaledit").on("hide.bs.modal",function(){$("#modaledit").remove();let e=d.clone();$("body").append(e)}),document.getElementById("m_filenamelabel").innerText=e,document.getElementById("mc_expiry").setAttribute("data-timestamp",s),document.getElementById("mb_save").setAttribute("data-fileid",t),createCalendar("mi_edit_expiry",s),i?(document.getElementById("mi_edit_down").value="1",document.getElementById("mi_edit_down").disabled=!0,document.getElementById("mc_download").checked=!1):(document.getElementById("mi_edit_down").value=n,document.getElementById("mi_edit_down").disabled=!1,document.getElementById("mc_download").checked=!0),a?(document.getElementById("mi_edit_expiry").value=add14DaysIfBeforeCurrentTime(s),document.getElementById("mi_edit_expiry").disabled=!0,document.getElementById("mc_expiry").checked=!1,calendarInstance._input.disabled=!0):(document.getElementById("mi_edit_expiry").value=s,document.getElementById("mi_edit_expiry").disabled=!1,document.getElementById("mc_expiry").checked=!0,calendarInstance._input.disabled=!1),o?(document.getElementById("mi_edit_pw").value="(unchanged)",document.getElementById("mi_edit_pw").disabled=!1,document.getElementById("mc_password").checked=!0):(document.getElementById("mi_edit_pw").value="",document.getElementById("mi_edit_pw").disabled=!0,document.getElementById("mc_password").checked=!1);let l=document.getElementById("mi_edit_replace");if(c)if(document.getElementById("replaceGroup").style.display="flex",r)document.getElementById("mc_replace").disabled=!0,document.getElementById("mc_replace").title="Replacing content is not available for end-to-end encrypted files",l.add(new Option("Unavailable",0)),l.title="Replacing content is not available for end-to-end encrypted files",l.value="0";else{let e=getAllAvailableFiles();for(let n=0;n{changeRowCount(!1,document.getElementById("row-"+e)),showToastFileDeletion(e)}).catch(e=>{alert("Unable to delete file: "+e),console.error("Error:",e)})}function checkBoxChanged(e,t){let n=!e.checked;n?document.getElementById(t).setAttribute("disabled",""):document.getElementById(t).removeAttribute("disabled"),t==="password"&&n&&(document.getElementById("password").value="")}function parseSseData(e){let t;try{t=JSON.parse(e)}catch(e){console.error("Failed to parse event data:",e);return}switch(t.event){case"download":setNewDownloadCount(t.file_id,t.download_count,t.downloads_remaining);return;case"uploadStatus":parseProgressStatus(t);return;default:console.error("Unknown event",t)}}function setNewDownloadCount(e,t,n){let s=document.getElementById("cell-downloads-"+e);if(s!=null&&(s.innerText=t,s.classList.add("updatedDownloadCount"),setTimeout(()=>s.classList.remove("updatedDownloadCount"),500)),n!=-1){let t=document.getElementById("cell-downloadsRemaining-"+e);t!=null&&(t.innerText=n,t.classList.add("updatedDownloadCount"),setTimeout(()=>t.classList.remove("updatedDownloadCount"),500))}}function registerChangeHandler(){const e=new EventSource("./uploadStatus");e.onmessage=e=>{parseSseData(e.data)},e.onerror=t=>{t.target.readyState!==EventSource.CLOSED&&e.close(),console.log("Reconnecting to SSE..."),setTimeout(registerChangeHandler,5e3)}}statusItemCount=0;function addFileStatus(e,t){const n=document.createElement("div");n.setAttribute("id",`us-container-${e}`),n.classList.add("us-container");const a=document.createElement("div");a.classList.add("filename"),a.textContent=t,n.appendChild(a);const s=document.createElement("div");s.classList.add("upload-progress-container"),s.setAttribute("id",`us-progress-container-${e}`);const r=document.createElement("div");r.classList.add("upload-progress-bar");const o=document.createElement("div");o.setAttribute("id",`us-progressbar-${e}`),o.classList.add("upload-progress-bar-progress"),o.style.width="0%",r.appendChild(o);const i=document.createElement("div");i.setAttribute("id",`us-progress-info-${e}`),i.classList.add("upload-progress-info"),i.textContent="0%",s.appendChild(r),s.appendChild(i),n.appendChild(s),n.setAttribute("data-starttime",Date.now()),n.setAttribute("data-complete","false");const c=document.getElementById("uploadstatus");c.appendChild(n),c.style.visibility="visible",statusItemCount++}function removeFileStatus(e){const t=document.getElementById(`us-container-${e}`);if(t==null)return;t.remove(),statusItemCount--,statusItemCount<1&&(document.getElementById("uploadstatus").style.visibility="hidden")}function addRow(e){let d=document.getElementById("downloadtable"),t=d.insertRow(0);e.Id=sanitizeId(e.Id),t.id="row-"+e.Id;let i=t.insertCell(0),a=t.insertCell(1),s=t.insertCell(2),r=t.insertCell(3),c=t.insertCell(4),o=t.insertCell(5),l=t.insertCell(6);i.innerText=e.Name,i.id="cell-name-"+e.Id,c.id="cell-downloads-"+e.Id,a.innerText=e.Size,e.UnlimitedDownloads?s.innerText="Unlimited":(s.innerText=e.DownloadsRemaining,s.id="cell-downloadsRemaining-"+e.Id),e.UnlimitedTime?r.innerText="Unlimited":r.innerText=formatUnixTimestamp(e.ExpireAt),c.innerText=e.DownloadCount;const n=document.createElement("a");if(n.href=e.UrlDownload,n.target="_blank",n.style.color="inherit",n.id="url-href-"+e.Id,n.textContent=e.Id,o.appendChild(n),e.IsPasswordProtected===!0){const e=document.createElement("i");e.className="bi bi-key",e.title="Password protected",o.appendChild(document.createTextNode(" ")),o.appendChild(e)}return l.appendChild(createButtonGroup(e)),i.classList.add("newItem"),a.classList.add("newItem"),s.classList.add("newItem"),r.classList.add("newItem"),c.classList.add("newItem"),o.classList.add("newItem"),l.classList.add("newItem"),a.setAttribute("data-order",e.SizeBytes),changeRowCount(!0,t),e.Id}function createButtonGroup(e){const h=document.createElement("div");h.className="btn-toolbar",h.setAttribute("role","toolbar");const t=document.createElement("div");t.className="btn-group me-2",t.setAttribute("role","group");const n=document.createElement("button");n.type="button",n.className="copyurl btn btn-outline-light btn-sm",n.dataset.clipboardText=e.UrlDownload,n.id="url-button-"+e.Id,n.title="Copy URL";const _=document.createElement("i");_.className="bi bi-copy",n.appendChild(_),n.appendChild(document.createTextNode(" URL")),n.addEventListener("click",()=>{showToast(1e3)}),t.appendChild(n);const f=document.createElement("button");f.type="button",f.className="btn btn-outline-light btn-sm dropdown-toggle dropdown-toggle-split",f.setAttribute("data-bs-toggle","dropdown"),f.setAttribute("aria-expanded","false"),t.appendChild(f);const p=document.createElement("ul");p.className="dropdown-menu dropdown-menu-end",p.setAttribute("data-bs-theme","dark");const b=document.createElement("li"),s=document.createElement("a");e.UrlHotlink!==""?(s.className="dropdown-item copyurl",s.title="Copy hotlink",s.style.cursor="pointer",s.setAttribute("data-clipboard-text",e.UrlHotlink),s.onclick=()=>showToast(1e3),s.innerHTML=` Hotlink`):(s.className="dropdown-item",s.innerText="Hotlink not available"),b.appendChild(s),p.appendChild(b),t.appendChild(p);const d=document.createElement("button");d.type="button",d.className="btn btn-outline-light btn-sm",d.title="Share",d.onclick=()=>shareUrl(e.Id),d.innerHTML=` - `,t.appendChild(r);const d=document.createElement("button");d.type="button",d.className="btn btn-outline-light btn-sm dropdown-toggle dropdown-toggle-split",d.setAttribute("data-bs-toggle","dropdown"),d.setAttribute("aria-expanded","false"),t.appendChild(d);const u=document.createElement("ul");u.className="dropdown-menu dropdown-menu-end",u.setAttribute("data-bs-theme","dark");const p=document.createElement("li"),a=document.createElement("a");a.className="dropdown-item",a.id=`qrcode-${e.Id}`,a.style.cursor="pointer",a.title="Open QR Code",a.onclick=()=>showQrCode(e.UrlDownload),a.innerHTML=` QR Code`,p.appendChild(a),u.appendChild(p);const v=document.createElement("li"),i=document.createElement("a");i.className="dropdown-item",i.title="Share via email",i.id=`email-${e.Id}`,i.target="_blank",i.href=`mailto:?body=${encodeURIComponent(e.UrlDownload)}`,i.innerHTML=` Email`,v.appendChild(i),u.appendChild(v),t.appendChild(u);const l=document.createElement("div");l.className="btn-group me-2",l.setAttribute("role","group");const c=document.createElement("button");c.type="button",c.className="btn btn-outline-light btn-sm",c.title="Edit";const b=document.createElement("i");b.className="bi bi-pencil",c.appendChild(b),c.addEventListener("click",()=>{showEditModal(e.Name,e.Id,e.DownloadsRemaining,e.ExpireAt,e.IsPasswordProtected,e.UnlimitedDownloads,e.UnlimitedTime,e.IsEndToEndEncrypted,canReplaceOwnFiles)}),l.appendChild(c);const o=document.createElement("button");o.type="button",o.className="btn btn-outline-danger btn-sm",o.title="Delete",o.id="button-delete-"+e.Id;const y=document.createElement("i");return y.className="bi bi-trash3",o.appendChild(y),o.addEventListener("click",()=>{deleteFile(e.Id)}),l.appendChild(o),h.appendChild(t),h.appendChild(l),h}function sanitizeId(e){return e.replace(/[^a-zA-Z0-9]/g,"")}function changeRowCount(e,t){let n=$("#maintable").DataTable();rowCount==-1&&(rowCount=n.rows().count()),e?(rowCount=rowCount+1,n.row.add(t)):(rowCount=rowCount-1,t.classList.add("rowDeleting"),setTimeout(()=>{n.row(t).remove(),t.remove()},290));let s=document.getElementsByClassName("dataTables_empty")[0];typeof s!="undefined"?s.innerText="Files stored: "+rowCount:document.getElementsByClassName("dataTables_info")[0].innerText="Files stored: "+rowCount}function hideQrCode(){document.getElementById("qroverlay").style.display="none",document.getElementById("qrcode").innerHTML=""}function showQrCode(e){const t=document.getElementById("qroverlay");t.style.display="block",new QRCode(document.getElementById("qrcode"),{text:e,width:200,height:200,colorDark:"#000000",colorLight:"#ffffff",correctLevel:QRCode.CorrectLevel.H}),t.addEventListener("click",hideQrCode)}function showToastFileDeletion(e){let t=document.getElementById("toastnotificationUndo"),n=document.getElementById("cell-name-"+e).innerText,s=document.getElementById("toastFilename"),o=document.getElementById("toastUndoButton");s.innerText=n,o.dataset.fileid=e,hideToast(),t.classList.add("show"),clearTimeout(toastId),toastId=setTimeout(()=>{hideFileToast()},5e3)}function hideFileToast(){document.getElementById("toastnotificationUndo").classList.remove("show")}function handleUndo(e){hideFileToast(),apiFilesRestore(e.dataset.fileid).then(e=>{addRow(e.FileInfo),isE2EEnabled&&GokapiE2EDecryptMenu()}).catch(e=>{alert("Unable to restore file: "+e),console.error("Error:",e)})}function shareUrl(e){if(!navigator.share)return;let t=document.getElementById("cell-name-"+e).innerText,n=document.getElementById("url-href-"+e).getAttribute("href");navigator.share({title:t,url:n})}function showDeprecationNotice(){let e=document.getElementById("toastDeprecation");e.classList.add("show"),setTimeout(()=>{e.classList.remove("show")},5e3)}function changeUserPermission(e,t,n){let s=document.getElementById(n);if(s.classList.contains("perm-processing")||s.classList.contains("perm-nochange"))return;let o=s.classList.contains("perm-granted");s.classList.add("perm-processing"),s.classList.remove("perm-granted"),s.classList.remove("perm-notgranted");let i="GRANT";o&&(i="REVOKE"),t=="PERM_REPLACE_OTHER"&&!o&&(hasNotPermissionReplace=document.getElementById("perm_replace_"+e).classList.contains("perm-notgranted"),hasNotPermissionReplace&&(showToast(2e3,"Also granting permission to replace own files"),changeUserPermission(e,"PERM_REPLACE","perm_replace_"+e))),t=="PERM_REPLACE"&&o&&(hasPermissionReplaceOthers=document.getElementById("perm_replace_other_"+e).classList.contains("perm-granted"),hasPermissionReplaceOthers&&(showToast(2e3,"Also revoking permission to replace files of other users"),changeUserPermission(e,"PERM_REPLACE_OTHER","perm_replace_other_"+e))),apiUserModify(e,t,i).then(e=>{o?s.classList.add("perm-notgranted"):s.classList.add("perm-granted"),s.classList.remove("perm-processing")}).catch(e=>{o?s.classList.add("perm-granted"):s.classList.add("perm-notgranted"),s.classList.remove("perm-processing"),alert("Unable to set permission: "+e),console.error("Error:",e)})}function changeRank(e,t,n){let s=document.getElementById(n);if(s.disabled)return;s.disabled=!0,apiUserChangeRank(e,t).then(e=>{location.reload()}).catch(e=>{s.disabled=!1,alert("Unable to change rank: "+e),console.error("Error:",e)})}function showDeleteModal(e,t){let n=document.getElementById("checkboxDelete");n.checked=!1,document.getElementById("deleteModalBody").innerText=t,$("#deleteModal").modal("show"),document.getElementById("buttonDelete").onclick=function(){apiUserDelete(e,n.checked).then(t=>{$("#deleteModal").modal("hide"),document.getElementById("row-"+e).classList.add("rowDeleting"),setTimeout(()=>{document.getElementById("row-"+e).remove()},290)}).catch(e=>{alert("Unable to delete user: "+e),console.error("Error:",e)})}}function showAddUserModal(){let e=$("#newUserModal").clone();$("#newUserModal").on("hide.bs.modal",function(){$("#newUserModal").remove();let t=e.clone();$("body").append(t)}),$("#newUserModal").modal("show")}function showResetPwModal(e,t){let n=$("#resetPasswordModal").clone();$("#resetPasswordModal").on("hide.bs.modal",function(){$("#resetPasswordModal").remove();let e=n.clone();$("body").append(e)}),document.getElementById("l_userpwreset").innerText=t;let s=document.getElementById("resetPasswordButton");s.onclick=function(){resetPw(e,document.getElementById("generateRandomPassword").checked)},$("#resetPasswordModal").modal("show")}function resetPw(e,t){let n=document.getElementById("resetPasswordButton");document.getElementById("resetPasswordButton").disabled=!0,apiUserResetPassword(e,t).then(e=>{if(!t){$("#resetPasswordModal").modal("hide"),showToast(1e3,"Password change requirement set successfully");return}n.style.display="none",document.getElementById("cancelPasswordButton").style.display="none",document.getElementById("formentryReset").style.display="none",document.getElementById("randomPasswordContainer").style.display="block",document.getElementById("closeModalResetPw").style.display="block",document.getElementById("l_returnedPw").innerText=e.password,document.getElementById("copypwclip").onclick=function(){navigator.clipboard.writeText(e.password),showToast(1e3,"Password copied to clipboard")}}).catch(e=>{alert("Unable to reset user password: "+e),console.error("Error:",e),n.disabled=!1})}function addNewUser(){let e=document.getElementById("mb_addUser");e.disabled=!0;let t=document.getElementById("newUserForm");if(t.checkValidity()){let t=document.getElementById("e_userName");apiUserCreate(t.value.trim()).then(e=>{$("#newUserModal").modal("hide"),addRowUser(e.id,e.name)}).catch(t=>{t.message=="duplicate"?(alert("A user already exists with that name"),e.disabled=!1):(alert("Unable to create user: "+t),console.error("Error:",t),e.disabled=!1)})}else t.classList.add("was-validated"),e.disabled=!1}function addRowUser(e,t){e=sanitizeUserId(e);let h=document.getElementById("usertable"),n=h.insertRow(1);n.id="row-"+e;let r=n.insertCell(0),c=n.insertCell(1),l=n.insertCell(2),d=n.insertCell(3),u=n.insertCell(4),a=n.insertCell(5);r.classList.add("newUser"),c.classList.add("newUser"),l.classList.add("newUser"),d.classList.add("newUser"),u.classList.add("newUser"),a.classList.add("newUser"),r.innerText=t,c.innerText="User",l.innerText="Never",d.innerText="0";const i=document.createElement("div");if(i.className="btn-group",i.setAttribute("role","group"),isInternalAuth){const n=document.createElement("button");n.id=`pwchange-${e}`,n.type="button",n.className="btn btn-outline-light btn-sm",n.title="Reset Password",n.onclick=()=>showResetPwModal(e,t),n.innerHTML=``,i.appendChild(n)}const s=document.createElement("button");s.id=`changeRank_${e}`,s.type="button",s.className="btn btn-outline-light btn-sm",s.title="Promote User",s.onclick=()=>changeRank(e,"ADMIN",`changeRank_${e}`),s.innerHTML=``,i.appendChild(s);const o=document.createElement("button");o.id=`delete-${e}`,o.type="button",o.className="btn btn-outline-danger btn-sm",o.title="Delete",o.onclick=()=>showDeleteModal(e,t),o.innerHTML=``,i.appendChild(o),a.innerHTML="",a.appendChild(i),u.innerHTML=` - - - - - - - - - - - - - - -`,setTimeout(()=>{r.classList.remove("newUser"),c.classList.remove("newUser"),l.classList.remove("newUser"),d.classList.remove("newUser"),u.classList.remove("newUser"),a.classList.remove("newUser")},700)}function sanitizeUserId(e){const t=e.toString().trim();if(!/^\d+$/.test(t))throw new Error("Invalid ID: must contain only digits.");return t} \ No newline at end of file + `,t.appendChild(d);const m=document.createElement("button");m.type="button",m.className="btn btn-outline-light btn-sm dropdown-toggle dropdown-toggle-split",m.setAttribute("data-bs-toggle","dropdown"),m.setAttribute("aria-expanded","false"),t.appendChild(m);const u=document.createElement("ul");u.className="dropdown-menu dropdown-menu-end",u.setAttribute("data-bs-theme","dark");const g=document.createElement("li"),o=document.createElement("a");o.className="dropdown-item",o.id=`qrcode-${e.Id}`,o.style.cursor="pointer",o.title="Open QR Code",o.onclick=()=>showQrCode(e.UrlDownload),o.innerHTML=` QR Code`,g.appendChild(o),u.appendChild(g);const v=document.createElement("li"),r=document.createElement("a");r.className="dropdown-item",r.title="Share via email",r.id=`email-${e.Id}`,r.target="_blank",r.href=`mailto:?body=${encodeURIComponent(e.UrlDownload)}`,r.innerHTML=` Email`,v.appendChild(r),u.appendChild(v),t.appendChild(u);const l=document.createElement("div");l.className="btn-group me-2",l.setAttribute("role","group");const a=document.createElement("button");a.type="button",a.className="btn btn-outline-light btn-sm",a.title="Download",e.RequiresClientSideDecryption&&a.classList.add("disabled");const j=document.createElement("i");j.className="bi bi-download",a.appendChild(j),a.addEventListener("click",()=>{downloadFileWithPresign(e.Id)}),l.appendChild(a);const c=document.createElement("button");c.type="button",c.className="btn btn-outline-light btn-sm",c.title="Edit";const y=document.createElement("i");y.className="bi bi-pencil",c.appendChild(y),c.addEventListener("click",()=>{showEditModal(e.Name,e.Id,e.DownloadsRemaining,e.ExpireAt,e.IsPasswordProtected,e.UnlimitedDownloads,e.UnlimitedTime,e.IsEndToEndEncrypted,canReplaceOwnFiles)}),l.appendChild(c);const i=document.createElement("button");i.type="button",i.className="btn btn-outline-danger btn-sm",i.title="Delete",i.id="button-delete-"+e.Id;const w=document.createElement("i");return w.className="bi bi-trash3",i.appendChild(w),i.addEventListener("click",()=>{deleteFile(e.Id)}),l.appendChild(i),h.appendChild(t),h.appendChild(l),h}function sanitizeId(e){return e.replace(/[^a-zA-Z0-9]/g,"")}function changeRowCount(e,t){let n=$("#maintable").DataTable();rowCount==-1&&(rowCount=n.rows().count()),e?(++rowCount,n.row.add(t)):(--rowCount,t.classList.add("rowDeleting"),setTimeout(()=>{n.row(t).remove(),t.remove()},290));let s=document.getElementsByClassName("dataTables_empty")[0];typeof s!="undefined"?s.innerText="Files stored: "+rowCount:document.getElementsByClassName("dataTables_info")[0].innerText="Files stored: "+rowCount}function hideQrCode(){document.getElementById("qroverlay").style.display="none",document.getElementById("qrcode").innerHTML=""}function showQrCode(e){const t=document.getElementById("qroverlay");t.style.display="block",new QRCode(document.getElementById("qrcode"),{text:e,width:200,height:200,colorDark:"#000000",colorLight:"#ffffff",correctLevel:QRCode.CorrectLevel.H}),t.addEventListener("click",hideQrCode)}function showToastFileDeletion(e){let t=document.getElementById("toastnotificationUndo"),n=document.getElementById("cell-name-"+e).innerText,s=document.getElementById("toastFilename"),o=document.getElementById("toastUndoButton");s.innerText=n,o.dataset.fileid=e,hideToast(),t.classList.add("show"),clearTimeout(toastId),toastId=setTimeout(()=>{hideFileToast()},5e3)}function hideFileToast(){document.getElementById("toastnotificationUndo").classList.remove("show")}function handleUndo(e){hideFileToast(),apiFilesRestore(e.dataset.fileid).then(e=>{addRow(e.FileInfo),isE2EEnabled&&GokapiE2EDecryptMenu()}).catch(e=>{alert("Unable to restore file: "+e),console.error("Error:",e)})}function shareUrl(e){if(!navigator.share)return;let t=document.getElementById("cell-name-"+e).innerText,n=document.getElementById("url-href-"+e).getAttribute("href");navigator.share({title:t,url:n})}function showDeprecationNotice(){let e=document.getElementById("toastDeprecation");e.classList.add("show"),setTimeout(()=>{e.classList.remove("show")},5e3)}function changeUserPermission(e,t,n){let s=document.getElementById(n);if(s.classList.contains("perm-processing")||s.classList.contains("perm-nochange"))return;let o=s.classList.contains("perm-granted");s.classList.add("perm-processing"),s.classList.remove("perm-granted"),s.classList.remove("perm-notgranted");let i="GRANT";o&&(i="REVOKE"),t=="PERM_REPLACE_OTHER"&&!o&&(hasNotPermissionReplace=document.getElementById("perm_replace_"+e).classList.contains("perm-notgranted"),hasNotPermissionReplace&&(showToast(2e3,"Also granting permission to replace own files"),changeUserPermission(e,"PERM_REPLACE","perm_replace_"+e))),t=="PERM_REPLACE"&&o&&(hasPermissionReplaceOthers=document.getElementById("perm_replace_other_"+e).classList.contains("perm-granted"),hasPermissionReplaceOthers&&(showToast(2e3,"Also revoking permission to replace files of other users"),changeUserPermission(e,"PERM_REPLACE_OTHER","perm_replace_other_"+e))),apiUserModify(e,t,i).then(e=>{o?s.classList.add("perm-notgranted"):s.classList.add("perm-granted"),s.classList.remove("perm-processing")}).catch(e=>{o?s.classList.add("perm-granted"):s.classList.add("perm-notgranted"),s.classList.remove("perm-processing"),alert("Unable to set permission: "+e),console.error("Error:",e)})}function changeRank(e,t,n){let s=document.getElementById(n);if(s.disabled)return;s.disabled=!0,apiUserChangeRank(e,t).then(e=>{location.reload()}).catch(e=>{s.disabled=!1,alert("Unable to change rank: "+e),console.error("Error:",e)})}function showDeleteUserModal(e,t){let n=document.getElementById("checkboxDelete");n.checked=!1,document.getElementById("deleteModalBody").innerText=t,$("#deleteModal").modal("show"),document.getElementById("buttonDelete").onclick=function(){apiUserDelete(e,n.checked).then(t=>{$("#deleteModal").modal("hide"),document.getElementById("row-"+e).classList.add("rowDeleting"),setTimeout(()=>{document.getElementById("row-"+e).remove()},290)}).catch(e=>{alert("Unable to delete user: "+e),console.error("Error:",e)})}}function showAddUserModal(){let e=$("#newUserModal").clone();$("#newUserModal").on("hide.bs.modal",function(){$("#newUserModal").remove();let t=e.clone();$("body").append(t)}),$("#newUserModal").modal("show")}function showResetPwModal(e,t){let n=$("#resetPasswordModal").clone();$("#resetPasswordModal").on("hide.bs.modal",function(){$("#resetPasswordModal").remove();let e=n.clone();$("body").append(e)}),document.getElementById("l_userpwreset").innerText=t;let s=document.getElementById("resetPasswordButton");s.onclick=function(){resetPw(e,document.getElementById("generateRandomPassword").checked)},$("#resetPasswordModal").modal("show")}function resetPw(e,t){let n=document.getElementById("resetPasswordButton");document.getElementById("resetPasswordButton").disabled=!0,apiUserResetPassword(e,t).then(e=>{if(!t){$("#resetPasswordModal").modal("hide"),showToast(1e3,"Password change requirement set successfully");return}n.style.display="none",document.getElementById("cancelPasswordButton").style.display="none",document.getElementById("formentryReset").style.display="none",document.getElementById("randomPasswordContainer").style.display="block",document.getElementById("closeModalResetPw").style.display="block",document.getElementById("l_returnedPw").innerText=e.password,document.getElementById("copypwclip").onclick=function(){navigator.clipboard.writeText(e.password),showToast(1e3,"Password copied to clipboard")}}).catch(e=>{alert("Unable to reset user password: "+e),console.error("Error:",e),n.disabled=!1})}function addNewUser(){let e=document.getElementById("mb_addUser");e.disabled=!0;let t=document.getElementById("newUserForm");if(t.checkValidity()){let t=document.getElementById("e_userName");apiUserCreate(t.value.trim()).then(e=>{$("#newUserModal").modal("hide"),addRowUser(e.id,e.name,e.permissions),console.log(e)}).catch(t=>{t.message=="duplicate"?(alert("A user already exists with that name"),e.disabled=!1):(alert("Unable to create user: "+t),console.error("Error:",t),e.disabled=!1)})}else t.classList.add("was-validated"),e.disabled=!1}const PermissionDefinitions=[{key:"UserPermGuestUploads",bit:1<<8,icon:"bi bi-box-arrow-in-down",title:"Create file requests",htmlId:e=>`perm_guest_upload_${e}`,apiName:"PERM_GUEST_UPLOAD"},{key:"UserPermReplaceUploads",bit:1<<0,icon:"bi bi-recycle",title:"Replace own uploads",htmlId:e=>`perm_replace_${e}`,apiName:"PERM_REPLACE"},{key:"UserPermListOtherUploads",bit:1<<1,icon:"bi bi-eye",title:"List other uploads",htmlId:e=>`perm_list_${e}`,apiName:"PERM_LIST"},{key:"UserPermEditOtherUploads",bit:1<<2,icon:"bi bi-pencil",title:"Edit other uploads",htmlId:e=>`perm_edit_${e}`,apiName:"PERM_EDIT"},{key:"UserPermDeleteOtherUploads",bit:1<<4,icon:"bi bi-trash3",title:"Delete other uploads",htmlId:e=>`perm_delete_${e}`,apiName:"PERM_DELETE"},{key:"UserPermReplaceOtherUploads",bit:1<<3,icon:"bi bi-arrow-left-right",title:"Replace other uploads",htmlId:e=>`perm_replace_other_${e}`,apiName:"PERM_REPLACE_OTHER"},{key:"UserPermManageLogs",bit:1<<5,icon:"bi bi-card-list",title:"Manage system logs",htmlId:e=>`perm_logs_${e}`,apiName:"PERM_LOGS"},{key:"UserPermManageUsers",bit:1<<7,icon:"bi bi-people",title:"Manage users",htmlId:e=>`perm_users_${e}`,apiName:"PERM_USERS"},{key:"UserPermManageApiKeys",bit:1<<6,icon:"bi bi-sliders2",title:"Manage API keys",htmlId:e=>`perm_api_${e}`,apiName:"PERM_API"}];function hasPermission(e,t){return(e&t)!==0}function addRowUser(e,t,n){e=sanitizeUserId(e);let m=document.getElementById("usertable"),s=m.insertRow(1);s.id="row-"+e;let c=s.insertCell(0),l=s.insertCell(1),d=s.insertCell(2),u=s.insertCell(3),h=s.insertCell(4),r=s.insertCell(5);c.classList.add("newUser"),l.classList.add("newUser"),d.classList.add("newUser"),u.classList.add("newUser"),h.classList.add("newUser"),r.classList.add("newUser"),c.innerText=t,l.innerText="User",d.innerText="Never",u.innerText="0";const a=document.createElement("div");if(a.className="btn-group",a.setAttribute("role","group"),isInternalAuth){const n=document.createElement("button");n.id=`pwchange-${e}`,n.type="button",n.className="btn btn-outline-light btn-sm",n.title="Reset Password",n.onclick=()=>showResetPwModal(e,t),n.innerHTML=``,a.appendChild(n)}const o=document.createElement("button");o.id=`changeRank_${e}`,o.type="button",o.className="btn btn-outline-light btn-sm",o.title="Promote User",o.onclick=()=>changeRank(e,"ADMIN",`changeRank_${e}`),o.innerHTML=``,a.appendChild(o);const i=document.createElement("button");i.id=`delete-${e}`,i.type="button",i.className="btn btn-outline-danger btn-sm",i.title="Delete",i.onclick=()=>showDeleteUserModal(e,t),i.innerHTML=``,a.appendChild(i),r.innerHTML="",r.appendChild(a),h.innerHTML=PermissionDefinitions.map(t=>{const o=hasPermission(n,t.bit)?"perm-granted":"perm-notgranted",s=t.htmlId(e);return` + + `}).join(""),setTimeout(()=>{c.classList.remove("newUser"),l.classList.remove("newUser"),d.classList.remove("newUser"),u.classList.remove("newUser"),h.classList.remove("newUser"),r.classList.remove("newUser")},700)}function sanitizeUserId(e){const t=e.toString().trim();if(!/^\d+$/.test(t))throw new Error("Invalid ID: must contain only digits.");return t} \ No newline at end of file diff --git a/internal/webserver/web/static/js/min/all_public.min.js b/internal/webserver/web/static/js/min/all_public.min.js new file mode 100644 index 0000000..ec94130 --- /dev/null +++ b/internal/webserver/web/static/js/min/all_public.min.js @@ -0,0 +1,2 @@ +function getUuid(){if(typeof crypto!="undefined"&&crypto.randomUUID)return crypto.randomUUID();if(typeof crypto!="undefined"&&crypto.getRandomValues){const e=new Uint8Array(16);return crypto.getRandomValues(e),e[6]=e[6]&15|64,e[8]=e[8]&63|128,[...e].map((e,t)=>(t===4||t===6||t===8||t===10?"-":"")+e.toString(16).padStart(2,"0")).join("")}let t="",e;for(e=0;e<36;e++)if(e===8||e===13||e===18||e===23)t+="-";else if(e===14)t+="4";else{const n=Math.random()*16|0;t+=(e===19?n&3|8:n).toString(16)}return t}function formatUnixTimestamp(e){const t=new Date(e*1e3),n=e=>String(e).padStart(2,"0"),s=t.getFullYear(),o=n(t.getMonth()+1),i=n(t.getDate()),a=n(t.getHours()),r=n(t.getMinutes());return`${s}-${o}-${i} ${a}:${r}`}function formatTimestampWithNegative(e,t){return t===0[0]&&(t="Never"),e==0?t:formatUnixTimestamp(e)}function insertFormattedDate(e,t){document.getElementById(t).innerText=formatUnixTimestamp(e)}function insertDateWithNegative(e,t,n){document.getElementById(t).innerText=formatTimestampWithNegative(e,n)}function insertLastOnlineDate(e,t){if(Date.now()/1e3-120e?"Expired":formatUnixTimestamp(e)}function insertFileRequestExpiry(e,t){document.getElementById(t).innerText=formatFileRequestExpiry(e)}function getReadableSize(e){if(!e)return"0 B";const n=["B","kB","MB","GB","TB"];let t=0;for(;e>=1024&&t>2,l=(3&r)<<4|(s=e.charCodeAt(n++))>>4,i=(15&s)<<2|(o=e.charCodeAt(n++))>>6,t=63&o,isNaN(s)?i=t=64:isNaN(o)&&(t=64),a=a+this._keyStr.charAt(c)+this._keyStr.charAt(l)+this._keyStr.charAt(i)+this._keyStr.charAt(t);return a},decode:function(e){var s,o,i,a,r,c,t="",n=0;for(e=e.replace(/[^A-Za-z0-9+/=]/g,"");n>4,i=(15&r)<<4|(s=this._keyStr.indexOf(e.charAt(n++)))>>2,a=(3&s)<<6|(c=this._keyStr.indexOf(e.charAt(n++))),t+=String.fromCharCode(o),64!=s&&(t+=String.fromCharCode(i)),64!=c&&(t+=String.fromCharCode(a));return t=Base64._utf8_decode(t)},_utf8_encode:function(e){e=e.replace(/\r\n/g,` +`);for(var t,n="",s=0;s127&&t<2048?(n+=String.fromCharCode(t>>6|192),n+=String.fromCharCode(63&t|128)):(n+=String.fromCharCode(t>>12|224),n+=String.fromCharCode(t>>6&63|128),n+=String.fromCharCode(63&t|128));return n},_utf8_decode:function(e){for(var s="",t=0,n=c1=c2=0;t191&&n<224?(c2=e.charCodeAt(t+1),s+=String.fromCharCode((31&n)<<6|63&c2),t+=2):(c2=e.charCodeAt(t+1),c3=e.charCodeAt(t+2),s+=String.fromCharCode((15&n)<<12|(63&c2)<<6|63&c3),t+=3);return s}} \ No newline at end of file diff --git a/internal/webserver/web/static/js/min/dateformat.min.js b/internal/webserver/web/static/js/min/dateformat.min.js index 7492f8f..0c1a009 100644 --- a/internal/webserver/web/static/js/min/dateformat.min.js +++ b/internal/webserver/web/static/js/min/dateformat.min.js @@ -1 +1 @@ -function formatUnixTimestamp(e){const t=new Date(e*1e3),n=e=>String(e).padStart(2,"0"),s=t.getFullYear(),o=n(t.getMonth()+1),i=n(t.getDate()),a=n(t.getHours()),r=n(t.getMinutes());return`${s}-${o}-${i} ${a}:${r}`}function insertFormattedDate(e,t){document.getElementById(t).innerText=formatUnixTimestamp(e)}function insertLastOnlineDate(e,t){if(e==0){document.getElementById(t).innerText="Never";return}if(Date.now()/1e3-120String(e).padStart(2,"0"),s=t.getFullYear(),o=n(t.getMonth()+1),i=n(t.getDate()),a=n(t.getHours()),r=n(t.getMinutes());return`${s}-${o}-${i} ${a}:${r}`}function formatTimestampWithNegative(e,t){return t===0[0]&&(t="Never"),e==0?t:formatUnixTimestamp(e)}function insertFormattedDate(e,t){document.getElementById(t).innerText=formatUnixTimestamp(e)}function insertDateWithNegative(e,t,n){document.getElementById(t).innerText=formatTimestampWithNegative(e,n)}function insertLastOnlineDate(e,t){if(Date.now()/1e3-120e?"Expired":formatUnixTimestamp(e)}function insertFileRequestExpiry(e,t){document.getElementById(t).innerText=formatFileRequestExpiry(e)} \ No newline at end of file diff --git a/internal/webserver/web/static/js/min/public_upload.min.js b/internal/webserver/web/static/js/min/public_upload.min.js new file mode 100644 index 0000000..2f947bf --- /dev/null +++ b/internal/webserver/web/static/js/min/public_upload.min.js @@ -0,0 +1 @@ +function createUploadBox(){fileInput.addEventListener("change",()=>{Array.from(fileInput.files).forEach(e=>{if(e.size>MAX_FILE_SIZE){document.getElementById("span-modal-error").innerText=`The file "${e.name}" exceeds the maximum allowed size of ${formatSize(MAX_FILE_SIZE)}.`,errorModal.show();return}document.getElementById("uploadbutton").disabled=!1;const o=getUuid(),n=document.createElement("div");n.className="pu-file-item",n.dataset.uuid=o;const a=document.createElement("span");a.textContent=e.name,a.className="file-name";const i=document.createElement("span");i.className="upload-status",i.textContent="Ready";const s=document.createElement("progress");s.className="upload-progress",e.size==0?s.max=1:s.max=e.size,s.value=0;const r=document.createElement("span");r.className="file-size",r.textContent=formatSize(e.size);const t=document.createElement("button");t.type="button",t.title="Remove",t.className="btn btn-sm btn-link text-light p-0",t.innerHTML='',t.onclick=async()=>{const e=filesMap.get(o);e.controller&&e.controller.abort(),e.serverUuid&&await unreserve(e.serverUuid),e.removed=!0,n.remove(),updateUploadButtonState()},n.append(a,i,s,r,t),fileList.appendChild(n),filesMap.set(o,{uuid:o,file:e,removed:!1,controller:new AbortController,lastSpeed:"",elements:{progressBar:s,progressText:i,removeBtn:t,item:n}})}),fileInput.value=""}),["dragenter","dragover","dragleave","drop"].forEach(e=>{uploadBox.addEventListener(e,e=>{e.preventDefault(),e.stopPropagation()},!1)}),["dragenter","dragover"].forEach(e=>{uploadBox.addEventListener(e,()=>uploadBox.classList.add("highlight"),!1)}),["dragleave","drop"].forEach(e=>{uploadBox.addEventListener(e,()=>uploadBox.classList.remove("highlight"),!1)}),uploadBox.addEventListener("drop",e=>{const t=e.dataTransfer,n=t.files;handleFiles(n)}),window.addEventListener("paste",e=>{const t=e.clipboardData.items,n=[];for(let e=0;e{const t=new Blob([e],{type:"text/plain"}),n=new File([t],"pasted-text.txt",{type:"text/plain"});handleFiles([n])});n.length>0&&handleFiles(n)})}function setUnload(){window.addEventListener("beforeunload",e=>{const t=Array.from(filesMap.values()).some(e=>!e.removed);t&&(e.preventDefault(),e.returnValue="")}),window.addEventListener("unload",()=>{for(const e of filesMap.values())!e.removed&&e.serverUuid&&unreserve(e.serverUuid)})}function handleFiles(e){const t=new DataTransfer;Array.from(e).forEach(e=>t.items.add(e)),fileInput.files=t.files,fileInput.dispatchEvent(new Event("change"))}function updateUploadButtonState(){const e=document.getElementById("uploadbutton"),t=Array.from(filesMap.values()).filter(e=>!e.removed&&e.elements.progressText.textContent!=="Completed");e.disabled=t.length===0}function showModal(e){let t="";switch(e){case"alluploaded":new bootstrap.Modal(document.getElementById("allUploadedModal"),{keyboard:!1,backdrop:"static"}).show();return;case"maxfiles":maxFilesRemaining==1?t="Too many files are selected for upload. Please only select 1 file.":t="Too many files are selected for upload. Please only select "+maxFilesRemaining+" files or fewer.";break;case"maxfilesdynamic":t="Some files could not be uploaded because the server rejected the request. This likely occurred because another user was uploading files at the same time and the maximum file limit was reached.";break;case"expired":t="The upload request exceeded the permitted time limit, and uploading additional files is no longer possible.";break}document.getElementById("span-modal-error").innerText=t,errorModal.show()}function formatSize(e){const n=["B","KB","MB","GB"];let t=0;for(;e>=1024&&tsetTimeout(e,5e3));continue}}if(s&&asetTimeout(e,n));else break}}throw r}function getQueuedFileCount(){let e=0;for(const t of filesMap.values())t.removed||e++;return e}function initUpload(){const e=document.getElementById("uploadbutton");e.disabled=!0,startUpload().catch(console.error).finally(()=>{updateUploadButtonState()})}async function startUpload(){if(!IS_UNLIMITED_FILES&&getQueuedFileCount()>maxFilesRemaining){showModal("maxfiles");return}for(const t of filesMap.values()){if(t.removed)continue;const{file:n,uuid:o,elements:e}=t;e.progressBar.style.display="",e.progressText.style.color="";let s="";try{e.progressText.textContent="Reserving...";const a=await reserveChunk(e);t.serverUuid=a,e.removeBtn.innerHTML='',e.removeBtn.title="Cancel Upload";let i=0;do{if(t.controller.signal.aborted)return;const o=n.slice(i,i+CHUNK_SIZE);await withRetry(async()=>new Promise((r,c)=>{const d=new FormData;d.append("file",o),d.append("uuid",a),d.append("filesize",n.size),d.append("offset",i);const l=new XMLHttpRequest;t.xhr=l,l.open("POST",UPLOAD_URL),l.setRequestHeader("apikey",API_KEY),l.setRequestHeader("fileRequestId",FILE_REQUEST_ID);const h=Date.now(),u=()=>{l.abort(),c(new Error("Cancelled"))};t.controller.signal.addEventListener("abort",u),l.upload.onprogress=t=>{if(t.lengthComputable){const o=i+t.loaded,r=n.size===0?1:n.size,c=Math.floor(o/r*100),a=(Date.now()-h)/1e3;a>0&&(s=` (${formatSize(t.loaded/a)}/s)`),e.progressBar.value=o,e.progressText.textContent=c+"%"+s}},l.onload=async()=>{t.controller.signal.removeEventListener("abort",u),l.status>=200&&l.status<300?r():c(await parseXhrError(l))},l.onerror=()=>{const e=new Error(`Server Error`);e.status=l.status,c(e)},l.send(d)}),{signal:t.controller.signal,onWait:()=>{e.progressText.textContent="Waiting for upload slot..."},onRetry:(t,n)=>{e.progressText.textContent=`Retry ${t}/3: ${n.message}${s}`}}),i+=o.size}while(i{const e=await fetch(RESERVE_URL,{method:"POST",headers:{id:FILE_REQUEST_ID,apikey:API_KEY}});if(!e.ok)throw await parseErrorResponse(e);const t=await e.json();if(!t.Uuid)throw new Error("Invalid reserve response");return t.Uuid},{onRetry:(t,n)=>{e.progressText.textContent=`Retry ${t}/3: ${n.message}`}})}async function finaliseUpload(e,t,n){await withRetry(async()=>{const n=await fetch(COMPLETE_URL,{method:"POST",headers:{uuid:t,fileRequestId:FILE_REQUEST_ID,filename:encodeFilename(e.name),filesize:e.size,nonblocking:!0,contenttype:e.type||"application/octet-stream",apikey:API_KEY}});if(!n.ok)throw await parseErrorResponse(n)},{onRetry:(e,t)=>{n.progressText.textContent=`Retry ${e}/3: ${t.message}`}})}function encodeFilename(e){return"base64:"+Base64.encode(e)}async function unreserve(e){if(!e)return;try{await fetch(UNRESERVE_URL,{method:"POST",headers:{uuid:e,apikey:API_KEY,id:FILE_REQUEST_ID},keepalive:!0})}catch(e){console.error("Unreserve failed",e)}} \ No newline at end of file diff --git a/internal/webserver/web/static/js/public_upload.js b/internal/webserver/web/static/js/public_upload.js new file mode 100644 index 0000000..29d31af --- /dev/null +++ b/internal/webserver/web/static/js/public_upload.js @@ -0,0 +1,523 @@ +function createUploadBox() { + + fileInput.addEventListener('change', () => { + Array.from(fileInput.files).forEach(file => { + + if (file.size > MAX_FILE_SIZE) { + document.getElementById('span-modal-error').innerText = + `The file "${file.name}" exceeds the maximum allowed size of ${formatSize(MAX_FILE_SIZE)}.`; + errorModal.show(); + return; + } + document.getElementById('uploadbutton').disabled = false; + const uuid = getUuid(); + + const item = document.createElement('div'); + item.className = 'pu-file-item'; + item.dataset.uuid = uuid; + + const name = document.createElement('span'); + name.textContent = file.name; + name.className = 'file-name'; + + const progressText = document.createElement('span'); + progressText.className = 'upload-status'; + progressText.textContent = 'Ready'; + + const progressBar = document.createElement('progress'); + progressBar.className = 'upload-progress'; + + if (file.size == 0) { + progressBar.max = 1; + } else { + progressBar.max = file.size; + } + progressBar.value = 0; + + const size = document.createElement('span'); + size.className = 'file-size'; + size.textContent = formatSize(file.size); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.title = 'Remove'; + removeBtn.className = 'btn btn-sm btn-link text-light p-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = async () => { + const entry = filesMap.get(uuid); + + // 1. If currently uploading, abort it + if (entry.controller) { + entry.controller.abort(); + } + + // 2. If it has a server reservation, clean it up + if (entry.serverUuid) { + await unreserve(entry.serverUuid); + } + + entry.removed = true; + item.remove(); + updateUploadButtonState(); + }; + + item.append(name, progressText, progressBar, size, removeBtn); + fileList.appendChild(item); + + filesMap.set(uuid, { + uuid, + file, + removed: false, + controller: new AbortController(), + lastSpeed: "", + elements: { + progressBar, + progressText, + removeBtn, + item + } + }); + }); + // Allow re-selecting same files + fileInput.value = ''; + }); + + + + // --- Drag and Drop Functionality --- + + // Prevent default behaviors for drag events + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + uploadBox.addEventListener(eventName, (e) => { + e.preventDefault(); + e.stopPropagation(); + }, false); + }); + + // Highlight box when dragging over + ['dragenter', 'dragover'].forEach(eventName => { + uploadBox.addEventListener(eventName, () => uploadBox.classList.add('highlight'), false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + uploadBox.addEventListener(eventName, () => uploadBox.classList.remove('highlight'), false); + }); + + // Handle dropped files + uploadBox.addEventListener('drop', (e) => { + const dt = e.dataTransfer; + const files = dt.files; + handleFiles(files); + }); + + // --- Paste Functionality --- + + window.addEventListener('paste', (e) => { + const items = e.clipboardData.items; + const files = []; + + for (let i = 0; i < items.length; i++) { + // Handle Files (Images, etc) + if (items[i].kind === 'file') { + files.push(items[i].getAsFile()); + } + // Handle Text pastes (converts text to a .txt file) + else if (items[i].kind === 'string' && items[i].type === 'text/plain') { + items[i].getAsString((text) => { + const blob = new Blob([text], { + type: 'text/plain' + }); + const file = new File([blob], "pasted-text.txt", { + type: 'text/plain' + }); + handleFiles([file]); + }); + } + } + + if (files.length > 0) { + handleFiles(files); + } + }); + +} + + +function setUnload() { + + // Confirm before closing tab + window.addEventListener('beforeunload', (e) => { + const uploading = Array.from(filesMap.values()).some(f => !f.removed); + if (uploading) { + // Standard way to trigger a "Are you sure?" browser dialog + e.preventDefault(); + e.returnValue = ''; + } + }); + + // Attempt unreserve on actual exit + window.addEventListener('unload', () => { + for (const entry of filesMap.values()) { + if (!entry.removed && entry.serverUuid) { + unreserve(entry.serverUuid); + } + } + }); +} + +function handleFiles(files) { + const dataTransfer = new DataTransfer(); + Array.from(files).forEach(file => dataTransfer.items.add(file)); + fileInput.files = dataTransfer.files; + fileInput.dispatchEvent(new Event('change')); +} + +function updateUploadButtonState() { + const btn = document.getElementById("uploadbutton"); + const pendingFiles = Array.from(filesMap.values()).filter(entry => + !entry.removed && entry.elements.progressText.textContent !== "Completed" + ); + btn.disabled = pendingFiles.length === 0; +} + + +function showModal(modalCode) { + let message = ""; + switch (modalCode) { + + case "alluploaded": + new bootstrap.Modal(document.getElementById('allUploadedModal'), { + keyboard: false, + backdrop: "static" + }).show(); + return; + + case "maxfiles": + if (maxFilesRemaining == 1) { + message = "Too many files are selected for upload. Please only select 1 file."; + } else { + message = "Too many files are selected for upload. Please only select " + maxFilesRemaining + " files or fewer."; + } + break; + + case "maxfilesdynamic": + message = "Some files could not be uploaded because the server rejected the request. This likely occurred because another user was uploading files at the same time and the maximum file limit was reached."; + break; + + case "expired": + message = "The upload request exceeded the permitted time limit, and uploading additional files is no longer possible."; + break; + } + document.getElementById('span-modal-error').innerText = message; + errorModal.show(); +} + +function formatSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + while (bytes >= 1024 && i < units.length - 1) { + bytes /= 1024; + i++; + } + return bytes.toFixed(1) + ' ' + units[i]; +} + + +async function withRetry(fn, { + retries = 3, + retryDelay = 5000, + onRetry, + onWait, // New callback for 429s + signal +} = {}) { + let lastError; + let attempt = 1; + const startTime = Date.now(); + const MAX_WAIT_TIME = 60000; // 60 seconds + + while (attempt <= retries) { + if (signal && signal.aborted) throw new Error("Cancelled"); + + try { + return await fn(); + } catch (err) { + lastError = err; + + if (err.message === "Cancelled" || (signal && signal.aborted)) throw err; + + // Handle Rate Limiting (429) + if (err.status === 429) { + const elapsed = Date.now() - startTime; + if (elapsed < MAX_WAIT_TIME) { + if (onWait) onWait(); + await new Promise(r => setTimeout(r, 5000)); + continue; // "continue" doesn't increment 'attempt', so it retries indefinitely for 60s + } + } + + // Standard Retry Logic + if (onRetry && attempt < retries) { + onRetry(attempt, err); + } + + if (err.status === 400 || err.status === 401) throw err; + + if (attempt < retries) { + attempt++; + await new Promise(r => setTimeout(r, retryDelay)); + } else { + break; + } + } + } + throw lastError; +} + +function getQueuedFileCount() { + let count = 0; + for (const entry of filesMap.values()) { + if (!entry.removed) count++; + } + return count; +} + +function initUpload() { + const btn = document.getElementById("uploadbutton"); + btn.disabled = true; + startUpload().catch(console.error).finally(() => { + updateUploadButtonState(); + }); +} + +async function startUpload() { + if (!IS_UNLIMITED_FILES && getQueuedFileCount() > maxFilesRemaining) { + showModal("maxfiles"); + return; + } + + for (const entry of filesMap.values()) { + if (entry.removed) continue; + const { + file, + uuid, + elements + } = entry; + + // Reset UI state for (re)attempt + elements.progressBar.style.display = ""; + elements.progressText.style.color = ""; + let lastSpeedText = ""; + + try { + elements.progressText.textContent = "Reserving..."; + const serverUuid = await reserveChunk(elements); + entry.serverUuid = serverUuid; + + elements.removeBtn.innerHTML = ''; + elements.removeBtn.title = "Cancel Upload"; + + let offset = 0; + // do-while so that add chunk is run for 0byte files as well + do { + if (entry.controller.signal.aborted) return; + const chunk = file.slice(offset, offset + CHUNK_SIZE); + + await withRetry(async () => { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append("file", chunk); + formData.append("uuid", serverUuid); + formData.append("filesize", file.size); + formData.append("offset", offset); + + const xhr = new XMLHttpRequest(); + entry.xhr = xhr; + xhr.open("POST", UPLOAD_URL); + xhr.setRequestHeader("apikey", API_KEY); + xhr.setRequestHeader("fileRequestId", FILE_REQUEST_ID); + + const startTime = Date.now(); + + // Listen for the cancel signal + const abortHandler = () => { + xhr.abort(); + reject(new Error("Cancelled")); + }; + entry.controller.signal.addEventListener('abort', abortHandler); + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const chunkOffset = offset + event.loaded; + const totalSize = file.size === 0 ? 1 : file.size; + const percent = Math.floor((chunkOffset / totalSize) * 100); + + const duration = (Date.now() - startTime) / 1000; + if (duration > 0) { + // Update the persistent lastSpeedText + lastSpeedText = ` (${formatSize(event.loaded / duration)}/s)`; + } + + elements.progressBar.value = chunkOffset; + elements.progressText.textContent = percent + "%" + lastSpeedText; + } + }; + + xhr.onload = async () => { + entry.controller.signal.removeEventListener('abort', abortHandler); + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(await parseXhrError(xhr)); + }; + + xhr.onerror = () => { + const err = new Error(`Server Error`); + err.status = xhr.status; + reject(err); + }; + + xhr.send(formData); + }); + }, { + signal: entry.controller.signal, + onWait: () => { + elements.progressText.textContent = "Waiting for upload slot..."; + }, + onRetry: (a, e) => { + elements.progressText.textContent = `Retry ${a}/3: ${e.message}${lastSpeedText}`; + } + }); + + offset += chunk.size; + } while (offset < file.size); + + await finaliseUpload(file, serverUuid, elements); + + elements.progressText.textContent = "Completed"; + elements.item.style.opacity = "0.6"; + elements.removeBtn.remove(); // Remove button only on success + + filesMap.get(uuid).removed = true; + maxFilesRemaining--; + + if (maxFilesRemaining === 0) showModal("alluploaded"); + + } catch (err) { + if (err.message === "Cancelled" || entry.controller.signal.aborted) return; + + elements.progressText.textContent = err.message || "Upload failed"; + elements.progressText.style.color = "#ff6b6b"; + elements.progressBar.style.display = "none"; + + elements.removeBtn.innerHTML = ''; + elements.removeBtn.title = "Remove from list"; + } + } +} + +async function parseErrorResponse(response) { + const text = await response.text(); + let data = null; + try { + data = JSON.parse(text); + } catch { + /* not JSON */ + } + if (data && data.Result === "error") { + let message; + switch (data.ErrorCode) { + case 9: + message = "File size limit exceeded"; + break; + case 14: + message = "Upload request has expired"; + showModal("expired"); + break; + case 15: + message = "Maximum file count reached"; + showModal("maxfilesdynamic"); + break; + case 16: + message = "Too many requests, please try again later"; + break; + default: + message = data.ErrorMessage || "Unknown upload error"; + } + const err = new Error(message); + err.status = response.status; + err.code = data.ErrorCode; + err.raw = data; + return err; + } + // Fallback: plain text / non-JSON error + const err = new Error(text || `HTTP ${response.status}`); + err.status = response.status; + return err; +} + +async function reserveChunk(elements) { + return withRetry(async () => { + const response = await fetch(RESERVE_URL, { + method: "POST", + headers: { + id: FILE_REQUEST_ID, + apikey: API_KEY + } + }); + if (!response.ok) { + throw await parseErrorResponse(response); + } + const data = await response.json(); + if (!data.Uuid) throw new Error("Invalid reserve response"); + return data.Uuid; + }, { + onRetry: (a, e) => { + elements.progressText.textContent = `Retry ${a}/3: ${e.message}`; + } + }); +} + +async function finaliseUpload(file, uuid, elements) { + await withRetry(async () => { + const response = await fetch(COMPLETE_URL, { + method: "POST", + headers: { + uuid, + fileRequestId: FILE_REQUEST_ID, + filename: encodeFilename(file.name), + filesize: file.size, + nonblocking: true, + contenttype: file.type || "application/octet-stream", + apikey: API_KEY + } + }); + if (!response.ok) { + throw await parseErrorResponse(response); + } + }, { + onRetry: (a, e) => { + elements.progressText.textContent = `Retry ${a}/3: ${e.message}`; + } + }); +} + +function encodeFilename(name) { + return "base64:" + Base64.encode(name); +} + + + +async function unreserve(uuid) { + if (!uuid) return; + try { + await fetch(UNRESERVE_URL, { + method: "POST", + headers: { + uuid: uuid, + apikey: API_KEY, + id: FILE_REQUEST_ID + }, + keepalive: true // Crucial for calls during page unload + }); + } catch (e) { + console.error("Unreserve failed", e); + } +} diff --git a/internal/webserver/web/static/js/uuid.js b/internal/webserver/web/static/js/uuid.js deleted file mode 100644 index 60198e9..0000000 --- a/internal/webserver/web/static/js/uuid.js +++ /dev/null @@ -1,37 +0,0 @@ -function getUuid() { - // Native UUID, not available in insecure environment - if (typeof crypto !== "undefined" && crypto.randomUUID) { - return crypto.randomUUID(); - } - - // CSPRNG-backed fallback - if (typeof crypto !== "undefined" && crypto.getRandomValues) { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - - // RFC 4122 compliance - bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 - bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 - - return [...bytes] - .map((b, i) => - (i === 4 || i === 6 || i === 8 || i === 10 ? "-" : "") + - b.toString(16).padStart(2, "0") - ) - .join(""); - } - - // If unavailable, Math.random (not cryptographically secure) - let uuid = "", i; - for (i = 0; i < 36; i++) { - if (i === 8 || i === 13 || i === 18 || i === 23) { - uuid += "-"; - } else if (i === 14) { - uuid += "4"; - } else { - const r = Math.random() * 16 | 0; - uuid += (i === 19 ? (r & 0x3) | 0x8 : r).toString(16); - } - } - return uuid; -} diff --git a/internal/webserver/web/templates/html_admin.tmpl b/internal/webserver/web/templates/html_admin.tmpl index e8aa6f7..787e3b0 100644 --- a/internal/webserver/web/templates/html_admin.tmpl +++ b/internal/webserver/web/templates/html_admin.tmpl @@ -61,7 +61,7 @@ {{ range .Items }} - {{ if not .IsPendingDeletion }} + {{ if not (or .IsPendingDeletion .IsFileRequest) }} {{ if or (gt .ExpireAt $.TimeNow) (.UnlimitedTime) }} {{ if or (gt .DownloadsRemaining 0) (.UnlimitedDownloads) }} @@ -87,6 +87,7 @@ {{ template "admin_button_share" (newAdminButtonContext . $.ActiveUser)}}
+ {{ template "admin_button_download" (newAdminButtonContext . $.ActiveUser) }} {{ template "admin_button_edit" (newAdminButtonContext . $.ActiveUser) }} {{ template "admin_button_delete" (newAdminButtonContext . $.ActiveUser) }}
@@ -185,13 +186,26 @@ {{ end }} -{{ template "pagename" "UploadMenu"}} +{{ template "pagename" "FileRequest"}} {{ template "customjs" .}} {{ template "footer" true}} {{ end }} + + +{{ define "admin_button_download" }} + + +{{ end }} + {{ define "admin_button_edit" }} + + + + + + + + + + + + + + + + + + + +{{ template "pagename" "PublicUpload"}} +{{ template "customjs" .}} +{{ template "footer"}} +{{end}} diff --git a/internal/webserver/web/templates/html_uploadrequest.tmpl b/internal/webserver/web/templates/html_uploadrequest.tmpl new file mode 100644 index 0000000..698bbd3 --- /dev/null +++ b/internal/webserver/web/templates/html_uploadrequest.tmpl @@ -0,0 +1,243 @@ +{{ define "uploadreq" }}{{ template "header" . }} +
+
+
+
+
+
+
+
+
+

File Requests

+
+
+ +
+
+
+ +
+
+ + + + + + + + +{{ if .ActiveUser.HasPermissionListOtherUploads }} + +{{ end }} + + + + + +{{ range .FileRequests }} + + + {{ template "uRFileCell" . }} + + + + + + +{{ if $.ActiveUser.HasPermissionListOtherUploads }} + +{{ end }} + + + + + +{{ end }} + +
NameUploaded FilesTotal SizeLast UploadExpiryUserActions
{{ .Name }}{{ .GetReadableTotalSize }}{{(index $.UserMap .UserId).Name}} +
+ {{ template "uRDownloadbutton" . }} + + + + + + + + +
+
+
+
+ +
    + {{ range .Files }} +
  • + + + +
    + {{ .Size }} ยท +
    + + + + +
  • + {{ end }} +
+ +
+
+
+
+
+
Toast Text
+
+
+ + + + + +{{ template "urequest_modal_confirm" }} +{{ template "urequest_modal_addedit" }} + +{{ template "pagename" "UploadRequest"}} +{{ template "customjs" .}} + +{{ template "footer" true }} +{{ end }} + + + +{{ define "urequest_modal_confirm" }} + +{{ end }} + +{{ define "urequest_modal_addedit" }} + + + +{{ end }} + + +{{ define "uRDownloadbutton" }} + {{ if eq .UploadedFiles 0 }} + + {{ else }} + {{ if eq .UploadedFiles 1 }} + + {{ else }} + + {{ end }} + {{ end }} +{{ end }} + + +{{ define "uRFileCell" }} + + {{ .UploadedFiles }}{{ if ne .ReservedUploads 0 }}+{{.ReservedUploads}}{{end}}{{ if ne .MaxFiles 0 }} / {{ .MaxFiles }}{{end}} + {{ if gt .UploadedFiles 0 }} + + {{end}} + +{{ end }} diff --git a/internal/webserver/web/templates/html_users.tmpl b/internal/webserver/web/templates/html_users.tmpl index 2f406f1..b2cdb24 100644 --- a/internal/webserver/web/templates/html_users.tmpl +++ b/internal/webserver/web/templates/html_users.tmpl @@ -40,6 +40,9 @@ {{ .UploadCount }} + + + @@ -56,6 +59,7 @@ +
@@ -72,7 +76,7 @@ {{ end }} - +
{{ end }} diff --git a/internal/webserver/web/templates/string_constants.tmpl b/internal/webserver/web/templates/string_constants.tmpl index e41962a..d305094 100644 --- a/internal/webserver/web/templates/string_constants.tmpl +++ b/internal/webserver/web/templates/string_constants.tmpl @@ -1,5 +1,5 @@ // File contains auto-generated values. Do not change manually -{{define "version"}}2.1.0{{end}} +{{define "version"}}2.2.0-dev{{end}} // Specifies the version of JS files, so that the browser doesn't // use a cached version, if the file has been updated diff --git a/openapi.json b/openapi.json index e55613d..82c5477 100644 --- a/openapi.json +++ b/openapi.json @@ -31,6 +31,9 @@ { "name": "auth" }, + { + "name": "uploadrequest" + }, { "name": "user" }, @@ -105,6 +108,183 @@ } } }, + "/files/downloadzip": { + "get": { + "tags": [ + "files" + ], + "summary": "Downloads files as ZIP file with optionally increasing the download counter", + "description": "This API call downloads multiple file that are not expired and increasing their download counter is disabled by default. Can be set up to return a pre-signed URL instead of the zip file itself, which is valid for 30 seconds and can be accessed by any registered user. End-to-end encrypted files and encrypted files stored on cloud servers cannot be downloaded. Returns 404 if an invalid/expired ID was passed. Requires API permission DOWNLOAD. To download files that were not uploaded by the user, the user needs to have the user permission LIST", + "operationId": "downloadzip", + "parameters": [ + { + "name": "ids", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "IDs of files to be downloaded seperated by comma" + }, + { + "name": "filename", + "in": "header", + "required": false, + "schema": { + "type": "string" + }, + "description": "The filename for the new Zip file. If the filename includes non-ANSI characters, you can encode them with base64, by adding 'base64:' at the beginning, e.g. 'base64:ZmlsZW5hbWU='" + }, + { + "name": "increaseCounter", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Increase counter if set to true" + }, + { + "name": "presignUrl", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Return a pre-signed URL instead of the actual file. Valid for one download within 30 seconds and can only be used by logged in users. When this option is set, download counter cannot be increased." + } + ], + "security": [ + { + "apikey": [ + "DOWNLOAD" + ] + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/octet-stream": { + "schema": { + "type": "object", + "format": "binary" + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "Result": { + "type": "string", + "example": "OK" + }, + "downloadUrl": { + "type": "string", + "format": "uri", + "example": "http://gokapi.local:53842/downloadPresigned?key=xieph5ae1leph6Heel0Hoo9uth1eiY9xei8IiboPoothie0ahm6tutufoo2s" + } + } + } + } + } + }, + "400": { + "description": "Invalid input or trying to download an end-to-end encrypted file" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Invalid ID provided or file has expired" + } + } + } + }, + "/files/download/{id}": { + "get": { + "tags": [ + "files" + ], + "summary": "Downloads file with optionally increasing the download counter", + "description": "This API call downloads a file that is not expired and increasing its download counter is disabled by default. Can be set up to return a pre-signed URL instead of the file itself, which is valid for 30 seconds and can be accessed by any registered user. End-to-end encrypted files and encrypted files stored on cloud servers cannot be downloaded. Returns 404 if an invalid/expired ID was passed. Requires API permission DOWNLOAD. To download files that were not uploaded by the user, the user needs to have the user permission LIST", + "operationId": "downloadsingle", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of file to be downloaded" + }, + { + "name": "increaseCounter", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Increase counter if set to true" + }, + { + "name": "presignUrl", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Return a pre-signed URL instead of the actual file. Valid for one download within 30 seconds and can only be used by logged in users. When this option is set, download counter cannot be increased." + } + ], + "security": [ + { + "apikey": [ + "DOWNLOAD" + ] + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/octet-stream": { + "schema": { + "type": "object", + "format": "binary" + } + }, + "application/json": { + "schema": { + "type": "object", + "properties": { + "Result": { + "type": "string", + "example": "OK" + }, + "downloadUrl": { + "type": "string", + "format": "uri", + "example": "http://gokapi.local:53842/downloadPresigned?key=xieph5ae1leph6Heel0Hoo9uth1eiY9xei8IiboPoothie0ahm6tutufoo2s" + } + } + } + } + } + }, + "400": { + "description": "Invalid input or trying to download an end-to-end encrypted file" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Invalid ID provided or file has expired" + } + } + } + }, "/files/list": { "get": { "tags": [ @@ -120,6 +300,17 @@ ] } ], + "parameters": [ + { + "name": "showFileRequests", + "in": "header", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "Set to true, to include files uploaded through file requests" + } + ], "responses": { "200": { "description": "Operation successful", @@ -199,7 +390,7 @@ "chunk" ], "summary": "Uploads a new chunk", - "description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text! To upload an end-to-end encrypted file, use gokapi-cli. Requires API permission UPLOAD", + "description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text! To upload an end-to-end encrypted file, use gokapi-cli. Chunks must be at least 5MB in size, unless last chunk of file. Requires API permission UPLOAD", "operationId": "chunkadd", "security": [ { @@ -316,6 +507,264 @@ "schema": { "type": "string" } + }, + { + "name": "nonblocking", + "in": "header", + "description": "If set to true, the call returns without waiting for the file processing to finish.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadResult" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + } + } + } + }, + "/uploadrequest/chunk/reserve": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Requests a UUID for uploading a new file for a file request", + "description": "Requests an UUID that can be used for uplading a new file. The chunks for the new file have to use this UUID. The first chunk needs to be uploaded latest 4 minutes after requesting the UUID. Requires API key associated with the file request", + "operationId": "chunkreserve", + "security": [ + { + "apikey": [ + "FileRequest" + ] + } + ], + "parameters": [ + { + "name": "id", + "in": "header", + "description": "The file request ID", + "required": true, + "schema": { + "type": "string" + } + }], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/chunkReserveResult" + } + } + } + }, + "400": { + "description": "Invalid ID or the file request does not accept any more files" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "429": { + "description": "If too many chunks are currently requested, the caller has to wait a couple of seconds and try again. The rate limit is only for file requests that are not limited in file count" + } + } + } + }, + "/uploadrequest/chunk/unreserve": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Frees a reserved UUID if upload was cancelled", + "description": "This call frees a reserved UUID, so that it does not count towards the quota anymore. Used if an upload was cancelled or failed. Requires API key associated with the file request", + "operationId": "chunkunreserve", + "security": [ + { + "apikey": [ + "FileRequest" + ] + } + ], + "parameters": [ + { + "name": "id", + "in": "header", + "description": "The file request ID", + "required": true, + "schema": { + "type": "string" + } + },{ + "name": "uuid", + "in": "header", + "description": "The reserved UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + } + } + }, + "400": { + "description": "Invalid ID or the file request does not accept any more files" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "429": { + "description": "If too many chunks are currently requested, the caller has to wait a couple of seconds and try again. The rate limit is only for file requests that are not limited in file count" + } + } + } + }, + "/uploadrequest/chunk/add": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Uploads a new chunk for a file request", + "description": "Uploads a file in chunks. Parallel uploading is supported. Must call /uploadrequest/chunk/reserve to request an UUID first and must call /uploadrequest/chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text! Chunks must be at least 5MB in size, unless last chunk of file. Requires API key associated with the file request", + "operationId": "chunkaddur", + "security": [ + { + "apikey": [ + "FileRequest" + ] + } + ], + "parameters": [ + { + "name": "fileRequestId", + "in": "header", + "description": "The ID of the upload request", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "nonblocking", + "in": "header", + "description": "If set to true, the call returns without waiting for the file processing to finish.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/chunking" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/chunkUploadResult" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + } + } + } + }, + "/uploadrequest/chunk/complete": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Finalises uploaded chunks", + "description": "Needs to be called after all chunks have been uploaded. Adds the uploaded file to Gokapi. Requires API permission UPLOAD", + "operationId": "chunkurcomplete", + "security": [ + { + "apikey": [ + "FileRequest" + ] + } + ], + "parameters": [ + { + "name": "uuid", + "in": "header", + "description": "The unique ID that was used for the uploaded chunks", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "fileRequestId", + "in": "header", + "description": "The file request ID that was used for the uploaded chunks", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filename", + "in": "header", + "description": "The filename of the uploaded file. If the filename includes non-ANSI characters, you can encode them with base64, by adding 'base64:' at the beginning, e.g. 'base64:ZmlsZW5hbWU='", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filesize", + "in": "header", + "description": "The total filesize of the uploaded file in bytes", + "required": true, + "schema": { + "type": "integer" + } + }, + { + "name": "contenttype", + "in": "header", + "description": "The MIME content type. If empty, application/octet-stream will be used.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -343,7 +792,7 @@ "tags": [ "logs" ], - "summary": "Deletes entries from the logfilek", + "summary": "Deletes entries from the logfile", "description": "This API call deletes all lines before older than a cutoff date. Requires API permission MANAGE_LOGS and user needs to be admin or super-admin.", "operationId": "logsdelete", "security": [ @@ -971,6 +1420,7 @@ "PERM_EDIT", "PERM_DELETE", "PERM_REPLACE", + "PERM_MANAGE_FILE_REQUESTS", "PERM_MANAGE_LOGS", "PERM_MANAGE_USERS", "PERM_API_MOD" @@ -1050,6 +1500,244 @@ } } }, + "/uploadrequest/list": { + "get": { + "tags": [ + "uploadrequest" + ], + "summary": "Lists all file requests", + "description": "This API call lists all file requests. Requires API permission GUEST_UPLOAD. To view file requests created by a different user, the user needs to have the user permission LIST", + "operationId": "ulist", + "security": [ + { + "apikey": [ + "PERM_GUEST_UPLOAD" + ] + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "type": "array", + "nullable": false, + "items": { + "$ref": "#/components/schemas/FileRequest" + } + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + } + } + } + }, + "/uploadrequest/list/{id}": { + "get": { + "tags": [ + "uploadrequest" + ], + "summary": "Get file request by ID", + "description": "This API call lists a specific file request. Returns 404 if an invalid ID was passed. Requires API permission GUEST_UPLOAD. To view file requests from a different user, the user needs to have the user permission LIST", + "operationId": "ulistbyid", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of file request" + } + ], + "security": [ + { + "apikey": [ + "PERM_GUEST_UPLOAD" + ] + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileRequest" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Invalid ID provided" + } + } + } + }, + "/uploadrequest/save": { + "post": { + "tags": [ + "uploadrequest" + ], + "summary": "Creates a new or saves an existing upload request", + "description": "This API call creates a new upload request if the parameter ID is not submitted. If editing a request, only the submitted parameters will be changed. To save a request of a different user, the user requires the user permission EDIT to execute this call. Requires API permission GUEST_UPLOAD", + "operationId": "uploadrequestsave", + "security": [ + { + "apikey": [ + "PERM_GUEST_UPLOAD" + ] + } + ], + "parameters": [ + { + "name": "id", + "in": "header", + "description": "The request to be saved. If empty, a new request will be created", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "header", + "description": "The given name for the request. If the name includes non-ANSI characters, you can encode them with base64, by adding 'base64:' at the beginning, e.g. 'base64:ZmlsZW5hbWU='", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + }, + { + "name": "notes", + "in": "header", + "description": "The public notes for the request. If the notes includes non-ANSI characters, you can encode them with base64, by adding 'base64:' at the beginning, e.g. 'base64:ZmlsZW5hbWU='", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + }, + { + "name": "expiry", + "in": "header", + "description": "The expiry as a UTC unix timestamp. No expiry if 0", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "integer" + } + }, + { + "name": "maxfiles", + "in": "header", + "description": "The amount of files that can be uploaded. No limit if 0", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "integer" + } + }, + { + "name": "maxsize", + "in": "header", + "description": "The maximum size in Megabytes per file. No limit if 0", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Operation successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileRequest" + } + } + } + }, + "400": { + "description": "Invalid ID or parameters supplied" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Upload request not found" + } + } + } + }, + "/uploadrequest/delete": { + "delete": { + "tags": [ + "uploadrequest" + ], + "summary": "Deletes the upload request and all associated files", + "description": "This API call deletes the given file requests. If files are associated with the request, they will also be deleted. To delete a request of a different user, the user requires the user permission DELETE to execute this call. Requires API permission GUEST_UPLOAD", + "operationId": "uploadrequestdelete", + "security": [ + { + "apikey": [ + "PERM_GUEST_UPLOAD" + ] + } + ], + "parameters": [ + { + "name": "id", + "in": "header", + "description": "The request to be deleted", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Operation successful" + }, + "400": { + "description": "Invalid ID or parameters supplied" + }, + "401": { + "description": "Invalid API key provided for authentication or API key does not have the required permission" + }, + "404": { + "description": "Upload request not found" + } + } + } + }, "/user/create": { "post": { "tags": [ @@ -1156,7 +1844,8 @@ "PERM_DELETE", "PERM_LOGS", "PERM_API", - "PERM_USERS" + "PERM_USERS", + "PERM_GUEST_UPLOAD" ] } }, @@ -1257,7 +1946,7 @@ "user" ], "summary": "Deletes the selected user", - "description": "This API call changes deletes the given user. If files are associated with the user, they will be linked with the user that initiated the deletion. If deleteFiles is \"true\", the files will be deleted instead. Requires API permission MANAGE_USERS", + "description": "This API call deletes the given user. If files are associated with the user, they will be linked with the user that initiated the deletion. If deleteFiles is \"true\", the files will be deleted instead. Requires API permission MANAGE_USERS", "operationId": "userdelete", "security": [ { @@ -1397,6 +2086,11 @@ "description": "The public hotlink URL for the file", "example": "https://gokapi.server/h/tDMs0U8MvRFwK69PfjagI7F87C13UVeQuOGDvtCG.jpg" }, + "FileRequestId": { + "type": "string", + "description": "If the file belongs to an upload request, the ID is set in this field", + "example": "cnMEWsrMwSx1wyr" + }, "UploadDate": { "type": "integer", "description": "UTC timestamp of file upload", @@ -1467,6 +2161,11 @@ "type": "boolean", "example": "false" }, + "IsFileRequest": { + "description": "True if the file belongs to an upload request", + "type": "boolean", + "example": "true" + }, "UploaderId": { "description": "The user ID of the uploader", "type": "integer", @@ -1476,6 +2175,104 @@ "description": "File is a struct used for saving information about an uploaded file", "x-go-package": "Gokapi/internal/models" }, + "FileRequest": { + "type": "object", + "description": "Represents a file upload request and its associated metadata.", + "properties": { + "id": { + "type": "string", + "description": "The internal ID of the file request", + "example": "caep3Ooquu6phoo" + }, + "userid": { + "type": "integer", + "format": "int32", + "description": "The user ID of the owner", + "example": "2" + }, + "maxfiles": { + "type": "integer", + "format": "int32", + "description": "The maximum number of files allowed or 0 if unlimited", + "example": "20" + }, + "maxsize": { + "type": "integer", + "format": "int32", + "description": "The maximum file size allowed in MB or 0 if unlimited", + "example": "0" + }, + "CombinedMaxSize": { + "type": "integer", + "format": "int32", + "description": "The lesser of MaxSize and the server's max upload size.", + "example": "0" + }, + "expiry": { + "type": "integer", + "format": "int64", + "description": "The expiry time of the file request as a Unix timestamp or 0 if no expiry", + "example": "1767022842" + }, + "creationdate": { + "type": "integer", + "format": "int64", + "description": "The timestamp when the file request was created", + "example": "1767021842" + }, + "name": { + "type": "string", + "description": "The given name for the file request", + "example": "Book list entries" + }, + "notes": { + "type": "string", + "description": "The public notes for the file request", + "example": "Please make sure to upload revision 1 files" + }, + "apikey": { + "type": "string", + "description": "The API key that is used for uploading files for this request", + "example": "wrg5L7ldIUiXd27mIH1Fh0gGIyrekC" + }, + "uploadedfiles": { + "type": "integer", + "format": "int32", + "description": "The number of uploaded files for this request", + "example": "3" + }, + "reserveduploads": { + "type": "integer", + "format": "int32", + "description": "The number of current uploads, which have not been finalised yet", + "example": "1" + }, + "lastupload": { + "type": "integer", + "format": "int64", + "description": "The timestamp of the last upload", + "example": "1767022002" + }, + "totalfilesize": { + "type": "integer", + "format": "int64", + "description": "The total size of all uploaded files in bytes", + "example": "544332214" + }, + "fileidlist": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of the IDs of all uploaded files", + "example": [ + "cohng2weGh", + "see5Ohng9y", + "EoYiog4Che" + ] + } + } + }, "chunkUploadResult": { "type": "object", "properties": { @@ -1487,6 +2284,21 @@ "description": "Result after uploading a chunk", "x-go-package": "Gokapi/internal/models" }, + "chunkReserveResult": { + "type": "object", + "properties": { + "Result": { + "type": "string", + "example": "OK" + }, + "Uuid": { + "type": "string", + "example": "naPh9athuyeimie3uu8pingoyi2Sho" + } + }, + "description": "Result after uploading a chunk", + "x-go-package": "Gokapi/internal/models" + }, "UploadResult": { "type": "object", "properties": { @@ -1640,7 +2452,7 @@ "properties": { "file": { "type": "string", - "description": "The file to be uploaded", + "description": "The chunk to be uploaded", "format": "binary" }, "uuid": {