diff --git a/build/go-generate/minifyStaticContent.go b/build/go-generate/minifyStaticContent.go index ae9f53a7..4ef617ca 100644 --- a/build/go-generate/minifyStaticContent.go +++ b/build/go-generate/minifyStaticContent.go @@ -72,16 +72,10 @@ 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", - }) - result = append(result, converter{ - InputPath: pathPrefix + "js/uuid.js", - OutputPath: pathPrefix + "js/min/uuid.min.js", - Type: "text/javascript", - Name: "UUID JS", + Name: "Public functions JS", }) return result } diff --git a/build/go-generate/updateApiRouting.go b/build/go-generate/updateApiRouting.go index 8536d858..4e62753b 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.mod b/build/go.mod index 3643dff1..abce8ab2 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 5e76c9d1..79f8ec0f 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 2eb761b4..e882fc55 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 d782ae27..967d2463 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 68075376..a494c6b4 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 5f8738ee..ad039c32 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,30 @@ 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 != "" { return true } 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 +191,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 +272,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 +281,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 +289,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 +301,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 6244a3bb..1db647aa 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" @@ -65,7 +65,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() diff --git a/docs/setup.rst b/docs/setup.rst index 613c4d77..f6bba813 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/go.mod b/go.mod index 3643dff1..abce8ab2 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 c860eb47..e26d5b5b 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 a335c012..5feeff57 100644 --- a/internal/configuration/Configuration.go +++ b/internal/configuration/Configuration.go @@ -94,6 +94,7 @@ func Load() { serverSettings.MinLengthPassword = Environment.MinLengthPassword serverSettings.LengthId = Environment.LengthId serverSettings.LengthHotlinkId = Environment.LengthHotlinkId + serverSettings.AllowGuestUploadsByDefault = Environment.PermRequestGrantedByDefault helper.CreateDir(serverSettings.DataDir) filesystem.Init(serverSettings.DataDir) logging.Init(Environment.DataDir) diff --git a/internal/configuration/database/Database.go b/internal/configuration/database/Database.go index a9a1b7f4..c706f66f 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 @@ -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 981e3e3d..b3c40534 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 be5b5654..9acce1b8 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,24 @@ 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) +} + +// 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 - chunkreservation.GetCount(f.Id) + if result < 0 { + return 0 + } + return result +} diff --git a/internal/models/FileUpload.go b/internal/models/FileUpload.go index 7b58ee1f..63291e3e 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 00000000..dcde61be --- /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 526506ef..c51955ba 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 @@ -64,29 +64,31 @@ func (u *User) IsSameUser(userId int) bool { } 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 +147,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 280cf710..11d9067f 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 ace6e950..3269e8b5 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" @@ -46,10 +47,13 @@ var ErrorReplaceE2EFile = errors.New("end-to-end encrypted files cannot be repla // 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 +154,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 +291,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 +299,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 @@ -393,7 +398,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! @@ -600,12 +605,14 @@ 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 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 @@ -622,7 +629,7 @@ func ServeFile(file models.File, w http.ResponseWriter, r *http.Request, forceDo fileData, size := 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 } } @@ -631,7 +638,7 @@ func ServeFile(file models.File, w http.ResponseWriter, r *http.Request, forceDo 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 } @@ -642,6 +649,87 @@ func ServeFile(file models.File, w http.ResponseWriter, r *http.Request, forceDo 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() { + // TODO implement decrypting + 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 2223626c..069749aa 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) _, 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) 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) test.ResponseBodyContains(t, w, "Error decrypting file") } diff --git a/internal/storage/chunking/chunkreservation/ChunkReservation.go b/internal/storage/chunking/chunkreservation/ChunkReservation.go new file mode 100644 index 00000000..e4f3ab99 --- /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 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 SetComplete(id string, uuid string) { + reservationMutex.Lock() + delete(reservedChunks[id], uuid) + reservationMutex.Unlock() +} + +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 00000000..d10ecfae --- /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 00000000..99a44bb7 --- /dev/null +++ b/internal/storage/filerequest/ratelimiter/RateLimiter.go @@ -0,0 +1,61 @@ +package ratelimiter + +import ( + "sync" + "time" + + "golang.org/x/time/rate" +) + +var uuidLimiter = newLimiter() +var chunkLimiter = newLimiter() +var byteLimiter = newLimiter() + +type limiterEntry struct { + limiter *rate.Limiter + lastSeen time.Time +} + +type Store struct { + mu sync.Mutex + limiters map[string]*limiterEntry +} + +func newLimiter() *Store { + return &Store{ + limiters: make(map[string]*limiterEntry), + } +} + +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), + lastSeen: time.Now(), + } + s.limiters[key] = e + } + + e.lastSeen = time.Now() + return e.limiter +} + +func (s *Store) StartCleanup(maxIdle time.Duration) { + go func() { + ticker := time.NewTicker(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 9df85663..73692d56 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" @@ -15,11 +21,6 @@ import ( "github.com/aws/aws-sdk-go/service/s3/s3manager" "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 +107,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,6 +121,21 @@ func Download(writer io.WriterAt, file models.File) (int64, error) { return size, nil } +// Stream downloads a file from AWS sequentially, used for saving to a Zip file +func Stream(writer io.Writer, file models.File) (int64, 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 0, err + } + defer obj.Body.Close() + return io.Copy(writer, obj.Body) +} + // 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) { diff --git a/internal/storage/filesystem/s3filesystem/aws/Aws_mock.go b/internal/storage/filesystem/s3filesystem/aws/Aws_mock.go index cc225f37..ca08e764 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") } @@ -199,3 +200,20 @@ 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) (int64, error) { + if !isValidCredentials() { + return 0, errors.New("invalid credentials / invalid bucket / invalid region") + } + + if isUploaded(file) { + data, err := os.Open("data/" + file.SHA1) + if err != nil { + return 0, err + } + defer data.Close() + return io.Copy(writer, data) + } + return 0, 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 d97b0a7b..3d63cb3f 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) } @@ -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) (int64, error) { + return 0, errors.New(errorString) +} diff --git a/internal/storage/filesystem/s3filesystem/aws/Aws_test.go b/internal/storage/filesystem/s3filesystem/aws/Aws_test.go index 38ff457b..9286fdf5 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) + 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 08844271..ffe2d7e3 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/testconfiguration/TestConfiguration.go b/internal/test/testconfiguration/TestConfiguration.go index 9e36132d..2f3b1c4d 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 bcc9ea3b..d20874fc 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") @@ -354,21 +359,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 +424,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 +536,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,18 +600,18 @@ 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) } // 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"] +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.Get().LengthId { select { case <-time.After(500 * time.Millisecond): @@ -683,11 +710,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 @@ -711,7 +739,7 @@ type AdminView struct { 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 +759,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 +784,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 +817,21 @@ 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) + } } u.ServerUrl = config.ServerUrl @@ -807,9 +852,9 @@ func (u *AdminView) convertGlobalConfig(view int, user models.User) *AdminView { 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 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 +867,18 @@ func sortMetaData(input []models.FileApiOutput) []models.FileApiOutput { return input } +// sortMetaData arranges the provided array so that Fies are sorted by 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 +896,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 +942,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 +967,45 @@ 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) { + 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) + 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 +1023,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) } func requireLogin(next http.HandlerFunc, isUiCall, isPwChangeView bool) http.HandlerFunc { @@ -975,6 +1100,7 @@ type genericView struct { RedirectUrl string ErrorMessage string ErrorId int + ErrorCardWidth int MinPasswordLength int CustomContent customStatic } @@ -990,3 +1116,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 3b794f24..accf492c 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, }) } diff --git a/internal/webserver/api/Api.go b/internal/webserver/api/Api.go index 3f3d5373..3f158921 100644 --- a/internal/webserver/api/Api.go +++ b/internal/webserver/api/Api.go @@ -15,12 +15,15 @@ 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/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) { @@ -108,17 +111,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 @@ -175,6 +179,11 @@ func apiModifyApiKey(w http.ResponseWriter, r requestParser, user models.User) { sendError(w, http.StatusUnauthorized, "Insufficient user permission for owner to set this API permission") return } + case models.ApiPermManageFileRequests: + if !apiKeyOwner.HasPermissionCreateFileRequests() { + sendError(w, http.StatusUnauthorized, "Insufficient user permission for owner to set this API permission") + return + } default: // do nothing } @@ -214,7 +223,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,24 +239,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") - return + newUser, err := users.Create(request.Username) + if err != nil { + switch { + case errors.Is(err, users.ErrorNameToShort): + sendError(w, http.StatusBadRequest, "Invalid username provided.") + case errors.Is(err, users.ErrorUserExists): + sendError(w, http.StatusConflict, "User already exists.") + default: + sendError(w, http.StatusInternalServerError, err.Error()) + } } logging.LogUserCreation(newUser, user) _, _ = w.Write([]byte(newUser.ToJson())) @@ -339,50 +340,148 @@ 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, errString := processNewChunk(w, request, configuration.Get().MaxFileSizeMB, "") + if statusCode != http.StatusOK { + sendError(w, statusCode, 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, errorMsg := checkFileRequestAndApiKey(request.Id, request.ApiKey) + if !ok { + sendError(w, status, errorMsg) return } + if fileRequest.FilesRemaining() <= 0 && !fileRequest.IsUnlimitedFiles() { + sendError(w, http.StatusBadRequest, "No more files can be uploaded for this file request") + 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 apiChunkComplete(w http.ResponseWriter, r requestParser, user models.User) { - request, ok := r.(*paramChunkComplete) +func apiChunkUploadRequestAdd(w http.ResponseWriter, r requestParser, _ models.User) { + request, ok := r.(*paramChunkUploadRequestAdd) if !ok { panic("invalid parameter passed") } - if request.IsNonBlocking { - go doBlockingPartCompleteChunk(nil, request, user) - _, _ = io.WriteString(w, "{\"result\":\"OK\"}") + fileRequest, ok, status, errorMsg := checkFileRequestAndApiKey(request.FileRequestId, request.ApiKey) + if !ok { + sendError(w, status, errorMsg) return } - doBlockingPartCompleteChunk(w, request, user) + maxUpload := configuration.Get().MaxFileSizeMB + if !fileRequest.IsUnlimitedSize() { + if (fileRequest.MaxSize) < maxUpload { + maxUpload = fileRequest.MaxSize + } + } + statusCode, errString := processNewChunk(w, request, maxUpload, fileRequest.Id) + if statusCode != http.StatusOK { + sendError(w, statusCode, errString) + } +} + +func checkFileRequestAndApiKey(fileRequestId, apiKey string) (models.FileRequest, bool, int, string) { + fileRequest, ok := filerequest.Get(fileRequestId) + if !ok { + return models.FileRequest{}, false, http.StatusNotFound, "FileRequest does not exist with the given ID" + } + if fileRequest.ApiKey != apiKey { + return models.FileRequest{}, false, http.StatusUnauthorized, "Invalid API key" + } + if !fileRequest.IsUnlimitedTime() && fileRequest.Expiry < time.Now().Unix() { + return models.FileRequest{}, false, http.StatusUnauthorized, "Filerequest has expired" + } + if !fileRequest.IsUnlimitedFiles() && fileRequest.UploadedFiles >= fileRequest.MaxFiles { + return models.FileRequest{}, false, http.StatusUnauthorized, "Max file count has already been reached for this file request" + } + return fileRequest, true, 0, "" +} + +type chunkParams interface { + GetRequest() *http.Request +} + +func processNewChunk(w http.ResponseWriter, request chunkParams, maxFileSizeMb int, filerequestId string) (int, string) { + maxUpload := int64(maxFileSizeMb) * 1024 * 1024 + if request.GetRequest().ContentLength > maxUpload { + return http.StatusBadRequest, storage.ErrorFileTooLarge.Error() + } + + request.GetRequest().Body = http.MaxBytesReader(w, request.GetRequest().Body, maxUpload) + err := fileupload.ProcessNewChunk(w, request.GetRequest(), true, filerequestId) + if err != nil { + return http.StatusBadRequest, err.Error() + } + return http.StatusOK, "" } -func doBlockingPartCompleteChunk(w http.ResponseWriter, request *paramChunkComplete, user models.User) { - uploadRequest := fileupload.CreateUploadConfig(request.AllowedDownloads, +func apiChunkComplete(w http.ResponseWriter, r requestParser, user models.User) { + request, ok := r.(*paramChunkComplete) + if !ok { + panic("invalid parameter passed") + } + 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) + request.FileSize, + "") + 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 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, err.Error()) return } + if uploadParameters.FileRequestId != "" { + chunkreservation.SetComplete(uploadParameters.FileRequestId, uuid) + } logging.LogUpload(file, user) 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, errorMsg := checkFileRequestAndApiKey(request.FileRequestId, request.ApiKey) + if !ok { + sendError(w, status, 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 +507,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,8 +542,7 @@ 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") return @@ -454,6 +559,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, errCode, errMessage := checkDownloadAllowed(request.Id, user) + if errCode != 0 { + sendError(w, errCode, errMessage) + return + } + if !request.PresignUrl { + storage.ServeFile(file, w, request.WebRequest, true, request.IncreaseCounter) + 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, errCode, errMessage := checkDownloadAllowed(fileId, user) + if errCode != 0 { + sendError(w, 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, string) { + file, ok := storage.GetFile(fileId) + if !ok { + return models.File{}, http.StatusNotFound, "file not found" + } + if file.UserId != user.Id && !user.HasPermission(models.UserPermListOtherUploads) { + return models.File{}, http.StatusUnauthorized, "no permission to download file" + } + if file.RequiresClientDecryption() { + return models.File{}, http.StatusBadRequest, "End-to-end encrypted files and encrypted files stored on online storage cannot be downloaded" + } + return file, 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 { @@ -493,7 +669,8 @@ 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 { @@ -542,6 +719,10 @@ func apiReplaceFile(w http.ResponseWriter, r requestParser, user models.User) { return } + if fileOriginal.IsFileRequest() { + sendError(w, http.StatusBadRequest, "Cannot replace a file request upload") + return + } fileNewContent, ok := storage.GetFile(request.IdNewContent) if !ok { sendError(w, http.StatusNotFound, "Invalid id provided.") @@ -662,6 +843,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 } @@ -728,6 +911,18 @@ func apiDeleteUser(w http.ResponseWriter, r requestParser, user models.User) { } 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 +952,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,19 +970,129 @@ 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, "FileRequest does not exist with the given ID") + return + } + if uploadRequest.UserId != user.Id && !user.HasPermission(models.UserPermDeleteOtherUploads) { + sendError(w, http.StatusUnauthorized, "No permission to delete this upload request") + return + } + filerequest.Delete(uploadRequest) + logging.LogDeleteFileRequest(uploadRequest, user) + _, _ = w.Write([]byte("{\"result\":\"OK\"}")) +} + +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 !isNewRequest { + uploadRequest, ok = database.GetFileRequest(request.Id) + if !ok { + sendError(w, http.StatusNotFound, "FileRequest does not exist with the given ID") + return + } + if uploadRequest.UserId != user.Id && !user.HasPermission(models.UserPermEditOtherUploads) { + sendError(w, http.StatusUnauthorized, "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.MaxSize + } + 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, "FileRequest does not exist with the given ID") + return + } + if uploadRequest.UserId != user.Id && !user.HasPermission(models.UserPermDeleteOtherUploads) { + sendError(w, http.StatusUnauthorized, "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) { if w == nil { return diff --git a/internal/webserver/api/Api_test.go b/internal/webserver/api/Api_test.go index 74037a99..960f1155 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,7 +120,6 @@ 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) @@ -131,13 +130,13 @@ func testAuthorisation(t *testing.T, url string, requiredPermission models.ApiPe test.IsEqualBool(t, w.Code != 200, true) test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"Unauthorized"}`) - 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"}`) - for _, permission := range getAvailableApiPermissions(t) { + for _, permission := range getAvailableApiPermissions() { if permission == requiredPermission { continue } @@ -148,7 +147,7 @@ func testAuthorisation(t *testing.T, url string, requiredPermission models.ApiPe test.ResponseBodyContains(t, w, `{"Result":"error","ErrorMessage":"Unauthorized"}`) 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{{}}) @@ -432,7 +431,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{ @@ -699,16 +698,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 +716,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 +735,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 +744,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 +769,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") } @@ -949,12 +954,18 @@ func TestApikeyModify(t *testing.T) { ErrorMessage: `{"Result":"error","ErrorMessage":"Insufficient user permission for owner to set this API permission"}`, StatusCode: 401, }, + { + Value: "PERM_MANAGE_FILE_REQUESTS", + ErrorMessage: `{"Result":"error","ErrorMessage":"Insufficient user permission for owner to set this API permission"}`, + StatusCode: 401, + }, } testInvalidParameters(t, apiUrl, apiKey.Id, validHeaders, headerPermission, invalidParameter) 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 +981,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) { @@ -1254,6 +1266,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,7 +1278,7 @@ 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") @@ -1291,7 +1306,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, @@ -1407,6 +1422,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,7 +1443,7 @@ 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) @@ -1448,7 +1466,7 @@ 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) @@ -1460,8 +1478,12 @@ func TestChunkUpload(t *testing.T) { } 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,7 +1505,7 @@ 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) diff --git a/internal/webserver/api/VersionNumbers.go b/internal/webserver/api/VersionNumbers.go index 1ec7dc70..f8b45f6b 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/routing.go b/internal/webserver/api/routing.go index 2f769319..1d67de60 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,52 @@ 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: "/logs/delete", ApiPerm: models.ApiPermManageLogs, @@ -205,12 +264,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 +527,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 +593,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 +659,40 @@ 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" } + p.FileHeader = chunking.FileHeader{ + Filename: p.FileName, + ContentType: p.ContentType, + Size: p.FileSize, + } + 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 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" } @@ -557,6 +704,60 @@ func (p *paramChunkComplete) ProcessParameter(_ *http.Request) error { 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"` + MaxSize 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 f86b15ea..0e145e07 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,270 @@ 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 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.MaxSize, 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 00000000..3711e35b --- /dev/null +++ b/internal/webserver/authentication/users/Users.go @@ -0,0 +1,36 @@ +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.Get().AllowGuestUploadsByDefault { + 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 3ca0f4ee..ca3fcf0b 100644 --- a/internal/webserver/fileupload/FileUpload.go +++ b/internal/webserver/fileupload/FileUpload.go @@ -1,16 +1,19 @@ 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" ) // ProcessCompleteFile processes a file upload request @@ -43,7 +46,7 @@ func ProcessCompleteFile(w http.ResponseWriter, r *http.Request, userId, maxMemo } // 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 { err := r.ParseMultipartForm(int64(configuration.Get().MaxMemory) * 1024 * 1024) if err != nil { return err @@ -58,6 +61,12 @@ func ProcessNewChunk(w http.ResponseWriter, r *http.Request, isApiCall bool) err return err } + if filerequestId != "" { + if !chunkreservation.SetUploading(filerequestId, chunkInfo.UUID) { + return errors.New("chunk reservation has expired or was not requested") + } + } + err = chunking.NewChunk(file, header, chunkInfo) defer file.Close() if err != nil { @@ -70,33 +79,33 @@ func ProcessNewChunk(w http.ResponseWriter, r *http.Request, isApiCall bool) err // 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 +116,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 +155,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 3d58a98d..6d5c76b6 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/web/static/apidocumentation/openapi.json b/internal/webserver/web/static/apidocumentation/openapi.json index e55613d2..789ed8ac 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", @@ -309,9 +500,191 @@ } }, { - "name": "password", + "name": "password", + "in": "header", + "description": "Password for this file to be set. No password will be used if empty.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "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" + } + } + } + }, + "/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! 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" + } + }], + "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": "Password for this file to be set. No password will be used if empty.", + "description": "The MIME content type. If empty, application/octet-stream will be used.", "required": false, "schema": { "type": "string" @@ -971,6 +1344,7 @@ "PERM_EDIT", "PERM_DELETE", "PERM_REPLACE", + "PERM_MANAGE_FILE_REQUESTS", "PERM_MANAGE_LOGS", "PERM_MANAGE_USERS", "PERM_API_MOD" @@ -1050,6 +1424,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 +1768,8 @@ "PERM_DELETE", "PERM_LOGS", "PERM_API", - "PERM_USERS" + "PERM_USERS", + "PERM_GUEST_UPLOAD" ] } }, @@ -1257,7 +1870,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 +2010,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 +2085,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 +2099,92 @@ "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" + }, + "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" + }, + "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 +2196,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 +2364,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 744ecb0f..00000000 --- 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 bbfd3db7..2a24cff2 100644 --- a/internal/webserver/web/static/css/cover.css +++ b/internal/webserver/web/static/css/cover.css @@ -311,6 +311,10 @@ a:hover { } +.newFileRequest { + animation: subtleHighlightNewJson 0.7s ease-out; +} + .newApiKey { animation: subtleHighlightNewJson 0.7s ease-out; } @@ -349,3 +353,74 @@ a:hover { 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; + } 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 a1df7dd8..a90a7a86 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}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}.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 829a9d4c..b36dece1 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,86 @@ 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 + }, + }; + + 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 apiFilesListById:", error); + throw error; + } +} + + +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 }, }; @@ -322,7 +395,7 @@ async function apiFilesListById(fileId) { const data = await response.json(); return data; } catch (error) { - console.error("Error in apiFilesListById:", error); + console.error("Error in apiFilesListDownloadZip:", error); throw error; } } @@ -331,7 +404,7 @@ async function apiFilesListById(fileId) { 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 79a06e4c..a40f7052 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 f5a0d6e7..40a9fa19 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 00000000..058a9bfc --- /dev/null +++ b/internal/webserver/web/static/js/admin_ui_filerequest.js @@ -0,0 +1,313 @@ +// 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 (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 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 df8ba546..bf3ac8d4 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 19e0d949..1ad10797 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 hasPermission(userPermissions, permissionBit) { + return (userPermissions & permissionBit) !== 0; +} -function addRowUser(userid, name) { + +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 00000000..2056d465 --- /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 8cf5ba9a..00000000 --- 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 5252551c..6aa87fee 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,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 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 00000000..ec941308 --- /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 7492f8fe..0c1a009a 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/uuid.js b/internal/webserver/web/static/js/uuid.js deleted file mode 100644 index 60198e93..00000000 --- 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 e8aa6f76..787e3b04 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 00000000..6c2bc659 --- /dev/null +++ b/internal/webserver/web/templates/html_uploadrequest.tmpl @@ -0,0 +1,234 @@ +{{ 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 .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 2f406f13..b2cdb245 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 e41962a4..d3050943 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 e55613d2..789ed8ac 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", @@ -309,9 +500,191 @@ } }, { - "name": "password", + "name": "password", + "in": "header", + "description": "Password for this file to be set. No password will be used if empty.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "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" + } + } + } + }, + "/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! 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" + } + }], + "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": "Password for this file to be set. No password will be used if empty.", + "description": "The MIME content type. If empty, application/octet-stream will be used.", "required": false, "schema": { "type": "string" @@ -971,6 +1344,7 @@ "PERM_EDIT", "PERM_DELETE", "PERM_REPLACE", + "PERM_MANAGE_FILE_REQUESTS", "PERM_MANAGE_LOGS", "PERM_MANAGE_USERS", "PERM_API_MOD" @@ -1050,6 +1424,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 +1768,8 @@ "PERM_DELETE", "PERM_LOGS", "PERM_API", - "PERM_USERS" + "PERM_USERS", + "PERM_GUEST_UPLOAD" ] } }, @@ -1257,7 +1870,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 +2010,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 +2085,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 +2099,92 @@ "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" + }, + "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" + }, + "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 +2196,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 +2364,7 @@ "properties": { "file": { "type": "string", - "description": "The file to be uploaded", + "description": "The chunk to be uploaded", "format": "binary" }, "uuid": {