diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..da3108d5 --- /dev/null +++ b/TODO.md @@ -0,0 +1,18 @@ +# TODO + +## Feature: Guest Uploading +- [x] New admin page to manage guest uploading + - [x] View active tokens + - [x] Get links for guest uploads + - [x] Also allow deleting +- [x] New guest upload page /guestupload + - [x] Upload like admin + - [x] Only accept if file isn't too big + +## Remaining tasks for first release +- [x] Do a good check for security holes +- [x] Show the link to the uploaded file instead of immediately going to download page +- [ ] ~~Enable E2E encrypted uploading~~ + - [x] Remove E2E code from the guest upload page and JS + - [x] Display notices that guest tokens cannot use E2E encryption +- [ ] Actually delete the token after it's been used diff --git a/build/go-generate/minifyStaticContent.go b/build/go-generate/minifyStaticContent.go index d6b50217..4270118a 100644 --- a/build/go-generate/minifyStaticContent.go +++ b/build/go-generate/minifyStaticContent.go @@ -4,11 +4,12 @@ package main import ( "fmt" + "os" + "path/filepath" + "github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2/css" "github.com/tdewolff/minify/v2/js" - "os" - "path/filepath" ) const pathPrefix = "../../internal/webserver/web/static/" @@ -64,6 +65,12 @@ func getPaths() []converter { Type: "text/javascript", Name: "wasm_exec JS", }) + result = append(result, converter{ + InputPath: pathPrefix + "js/guestupload.js", + OutputPath: pathPrefix + "js/min/guestupload.min.js", + Type: "text/javascript", + Name: "GuestUpload JS", + }) return result } diff --git a/build/go-generate/updateVersionNumbers.go b/build/go-generate/updateVersionNumbers.go index bdec1347..18a9627f 100644 --- a/build/go-generate/updateVersionNumbers.go +++ b/build/go-generate/updateVersionNumbers.go @@ -13,6 +13,7 @@ import ( const versionJsAdmin = "4" const versionJsDropzone = "4" const versionJsE2EAdmin = "3" +const versionGuestUpload = "1" const versionCssMain = "2" const fileMain = "../../cmd/gokapi/Main.go" @@ -49,6 +50,7 @@ func getTemplate() string { result = strings.ReplaceAll(result, "%jsadmin%", versionJsAdmin) result = strings.ReplaceAll(result, "%jsdropzone%", versionJsDropzone) result = strings.ReplaceAll(result, "%jse2e%", versionJsE2EAdmin) + result = strings.ReplaceAll(result, "%jsguestupload%", versionGuestUpload) result = strings.ReplaceAll(result, "%css_main%", versionCssMain) return result } @@ -84,4 +86,5 @@ const templateVersions = `// Change these for rebranding {{define "js_admin_version"}}%jsadmin%{{end}} {{define "js_dropzone_version"}}%jsdropzone%{{end}} {{define "js_e2eversion"}}%jse2e%{{end}} +{{define "js_guestupload_version"}}%jsguestupload%{{end}} {{define "css_main"}}%css_main%{{end}}` diff --git a/docs/setup.rst b/docs/setup.rst index 661ba43d..fca9b381 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -210,9 +210,12 @@ This option disables Gokapis internal authentication completely, except for API - ``/apiKeys`` - ``/apiNew`` - ``/delete`` +- ``/deleteUploadToken`` - ``/e2eInfo`` - ``/e2eSetup`` +- ``/guestUploads`` - ``/logs`` +- ``/newUploadToken`` - ``/uploadChunk`` - ``/uploadComplete`` - ``/uploadStatus`` @@ -297,6 +300,9 @@ If you are concerned that the configuration file can be read, you can also choos .. note:: If you re-run the setup and enable encryption, unencrypted files will stay unencrypted. If you change any configuration related to encryption, all already encrypted files will be deleted. +.. note:: + Files uploaded using a Guest Upload link will never be End-to-End encrypted, even if Level 3 encryption is enabled for the server. In this case these files will be uploaded without any encryption. + ************************ Changing Configuration ************************ diff --git a/internal/configuration/configupgrade/Upgrade.go b/internal/configuration/configupgrade/Upgrade.go index aca6ddc2..0452c88e 100644 --- a/internal/configuration/configupgrade/Upgrade.go +++ b/internal/configuration/configupgrade/Upgrade.go @@ -10,7 +10,7 @@ import ( ) // CurrentConfigVersion is the version of the configuration structure. Used for upgrading -const CurrentConfigVersion = 20 +const CurrentConfigVersion = 21 // DoUpgrade checks if an old version is present and updates it to the current version if required func DoUpgrade(settings *models.Configuration, env *environment.Environment) bool { @@ -50,11 +50,21 @@ func updateConfig(settings *models.Configuration, env *environment.Environment) // < v1.8.5 if settings.ConfigVersion < 20 { err := database.RawSqlite(`DROP TABLE UploadStatus; CREATE TABLE "UploadStatus" ( - "ChunkId" TEXT NOT NULL UNIQUE, - "CurrentStatus" INTEGER NOT NULL, - "CreationDate" INTEGER NOT NULL, - PRIMARY KEY("ChunkId") -) WITHOUT ROWID;`) + "ChunkId" TEXT NOT NULL UNIQUE, + "CurrentStatus" INTEGER NOT NULL, + "CreationDate" INTEGER NOT NULL, + PRIMARY KEY("ChunkId") + ) WITHOUT ROWID;`) + helper.Check(err) + } + // < v1.8.6 + if settings.ConfigVersion < 21 { + err := database.RawSqlite(`CREATE TABLE IF NOT EXISTS "UploadTokens" ( + "Id" TEXT NOT NULL UNIQUE, + "LastUsed" INTEGER NOT NULL, + "LastUsedString" TEXT NOT NULL, + PRIMARY KEY("Id") + ) WITHOUT ROWID;`) helper.Check(err) } } diff --git a/internal/configuration/database/Database.go b/internal/configuration/database/Database.go index 6e35e1cf..ac07db5f 100644 --- a/internal/configuration/database/Database.go +++ b/internal/configuration/database/Database.go @@ -3,12 +3,15 @@ package database import ( "database/sql" "fmt" - "github.com/forceu/gokapi/internal/helper" "log" + + "github.com/forceu/gokapi/internal/helper" + // Required for sqlite driver - _ "modernc.org/sqlite" "os" "path/filepath" + + _ "modernc.org/sqlite" ) var sqliteDb *sql.DB @@ -99,6 +102,12 @@ func createNewDatabase() { "Permissions" INTEGER NOT NULL DEFAULT 0, PRIMARY KEY("Id") ) WITHOUT ROWID; + CREATE TABLE "UploadTokens" ( + "Id" TEXT NOT NULL UNIQUE, + "LastUsed" INTEGER NOT NULL, + "LastUsedString" TEXT NOT NULL, + PRIMARY KEY("Id") + ) WITHOUT ROWID; CREATE TABLE "E2EConfig" ( "id" INTEGER NOT NULL UNIQUE, "Config" BLOB NOT NULL, diff --git a/internal/configuration/database/Database_test.go b/internal/configuration/database/Database_test.go index 56f65d5e..48cd1a28 100644 --- a/internal/configuration/database/Database_test.go +++ b/internal/configuration/database/Database_test.go @@ -5,17 +5,19 @@ package database import ( "database/sql" "errors" - "github.com/DATA-DOG/go-sqlmock" - "github.com/forceu/gokapi/internal/helper" - "github.com/forceu/gokapi/internal/models" - "github.com/forceu/gokapi/internal/test" - "golang.org/x/exp/slices" "math" "os" "regexp" "sync" "testing" "time" + + "github.com/DATA-DOG/go-sqlmock" + "golang.org/x/exp/slices" + + "github.com/forceu/gokapi/internal/helper" + "github.com/forceu/gokapi/internal/models" + "github.com/forceu/gokapi/internal/test" ) func TestMain(m *testing.M) { @@ -152,6 +154,44 @@ func TestApiKey(t *testing.T) { test.IsEqualString(t, key.FriendlyName, "Old Key") } +func TestGuestUploadToken(t *testing.T) { + SaveUploadToken(models.UploadToken{ + Id: "newtoken", + LastUsedString: "LastUsed", + LastUsed: 100, + }) + SaveUploadToken(models.UploadToken{ + Id: "newtoken2", + LastUsedString: "LastUsed2", + LastUsed: 200, + }) + + tokens := GetAllUploadTokens() + test.IsEqualInt(t, len(tokens), 2) + test.IsEqualString(t, tokens["newtoken"].Id, "newtoken") + test.IsEqualString(t, tokens["newtoken"].LastUsedString, "LastUsed") + test.IsEqualInt64(t, tokens["newtoken"].LastUsed, 100) + + test.IsEqualInt(t, len(GetAllUploadTokens()), 2) + DeleteUploadToken("newtoken2") + test.IsEqualInt(t, len(GetAllUploadTokens()), 1) + + token, ok := GetUploadToken("newtoken") + test.IsEqualBool(t, ok, true) + test.IsEqualString(t, token.Id, "newtoken") + _, ok = GetUploadToken("newtoken2") + test.IsEqualBool(t, ok, false) + + SaveUploadToken(models.UploadToken{ + Id: "newtoken", + LastUsed: 100, + LastUsedString: "RecentlyUsed", + }) + token, ok = GetUploadToken("newtoken") + test.IsEqualBool(t, ok, true) + test.IsEqualString(t, token.LastUsedString, "RecentlyUsed") +} + func TestSession(t *testing.T) { renewAt := time.Now().Add(1 * time.Hour).Unix() SaveSession("newsession", models.Session{ diff --git a/internal/configuration/database/guestuploads.go b/internal/configuration/database/guestuploads.go new file mode 100644 index 00000000..2247f7ec --- /dev/null +++ b/internal/configuration/database/guestuploads.go @@ -0,0 +1,79 @@ +package database + +import ( + "database/sql" + "errors" + + "github.com/forceu/gokapi/internal/helper" + "github.com/forceu/gokapi/internal/models" +) + +type schemaUploadTokens struct { + Id string + FriendlyName string + LastUsed int64 + LastUsedString string + Permissions int +} + +// GetAllUploadTokens returns a map with all upload tokens +func GetAllUploadTokens() map[string]models.UploadToken { + result := make(map[string]models.UploadToken) + + rows, err := sqliteDb.Query("SELECT * FROM UploadTokens") + helper.Check(err) + defer rows.Close() + for rows.Next() { + rowData := schemaUploadTokens{} + err = rows.Scan(&rowData.Id, &rowData.LastUsed, &rowData.LastUsedString) + helper.Check(err) + result[rowData.Id] = models.UploadToken{ + Id: rowData.Id, + LastUsed: rowData.LastUsed, + LastUsedString: rowData.LastUsedString, + } + } + return result +} + +// GetUploadToken returns a models.UploadToken if valid or false if the ID is not valid +func GetUploadToken(id string) (models.UploadToken, bool) { + var rowResult schemaUploadTokens + row := sqliteDb.QueryRow("SELECT * FROM UploadTokens WHERE Id = ?", id) + err := row.Scan(&rowResult.Id, &rowResult.LastUsed, &rowResult.LastUsedString) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return models.UploadToken{}, false + } + helper.Check(err) + return models.UploadToken{}, false + } + + result := models.UploadToken{ + Id: rowResult.Id, + LastUsed: rowResult.LastUsed, + LastUsedString: rowResult.LastUsedString, + } + + return result, true +} + +// SaveUploadToken saves the upload token to the database +func SaveUploadToken(uploadToken models.UploadToken) { + _, err := sqliteDb.Exec("INSERT OR REPLACE INTO UploadTokens (Id, LastUsed, LastUsedString) VALUES (?, ?, ?)", + uploadToken.Id, uploadToken.LastUsed, uploadToken.LastUsedString) + helper.Check(err) +} + +// UpdateTimeUploadToken writes the content of LastUsage to the database +func UpdateTimeUploadToken(uploadToken models.UploadToken) { + _, err := sqliteDb.Exec("UPDATE UploadTokens SET LastUsed = ?, LastUsedString = ? WHERE Id = ?", + uploadToken.LastUsed, uploadToken.LastUsedString, uploadToken.Id) + helper.Check(err) +} + +// DeleteUploadToken deletes an upload token with the given ID +func DeleteUploadToken(id string) { + _, err := sqliteDb.Exec("DELETE FROM UploadTokens WHERE Id = ?", id) + helper.Check(err) +} diff --git a/internal/configuration/setup/ProtectedUrls.go b/internal/configuration/setup/ProtectedUrls.go index b1991292..9e80d4c1 100644 --- a/internal/configuration/setup/ProtectedUrls.go +++ b/internal/configuration/setup/ProtectedUrls.go @@ -5,4 +5,4 @@ package setup // protectedUrls contains a list of URLs that need to be protected if authentication is disabled. // This list will be displayed during the setup -var protectedUrls = []string{"/admin", "/apiDelete", "/apiKeys", "/apiNew", "/delete", "/e2eInfo", "/e2eSetup", "/logs", "/uploadChunk", "/uploadComplete", "/uploadStatus"} +var protectedUrls = []string{"/admin", "/apiDelete", "/apiKeys", "/apiNew", "/delete", "/deleteUploadToken", "/e2eInfo", "/e2eSetup", "/guestUploads", "/logs", "/newUploadToken", "/uploadChunk", "/uploadComplete", "/uploadStatus"} diff --git a/internal/configuration/setup/templates/setup.tmpl b/internal/configuration/setup/templates/setup.tmpl index 458267f5..034aeef6 100644 --- a/internal/configuration/setup/templates/setup.tmpl +++ b/internal/configuration/setup/templates/setup.tmpl @@ -585,6 +585,7 @@ function TestAWS(button, isManual) {
  • Does not support download progress bar
  • Gokapi starts without user input
  • Files uploaded through the API have to be unencrypted
  • +
  • Files uploaded through a Guest Upload link have to be unencrypted
  • Password cannot be read with access to Gokapi configuration
  • Warning: Download and upload might be significantly slower
  • Warning: Encryption keys are stored in plain-text on this machine
  • diff --git a/internal/models/GuestUpload.go b/internal/models/GuestUpload.go new file mode 100644 index 00000000..3a06d46b --- /dev/null +++ b/internal/models/GuestUpload.go @@ -0,0 +1,9 @@ +package models + +// UploadToken contains data of a single guest upload token. +// It is essntially a single-use API key for uploading one item. +type UploadToken struct { + Id string `json:"Id"` + LastUsedString string `json:"LastUsedString"` + LastUsed int64 `json:"LastUsed"` +} diff --git a/internal/test/testconfiguration/TestConfiguration.go b/internal/test/testconfiguration/TestConfiguration.go index 1fae8e1b..a7a5c45a 100644 --- a/internal/test/testconfiguration/TestConfiguration.go +++ b/internal/test/testconfiguration/TestConfiguration.go @@ -356,7 +356,7 @@ var configTestFile = []byte(`{ "ServerUrl": "http://127.0.0.1:53843/", "RedirectUrl": "https://test.com/", "PublicName": "Gokapi Test Version", - "ConfigVersion": 20, + "ConfigVersion": 21, "LengthId": 20, "DataDir": "test/data", "MaxFileSizeMB": 25, diff --git a/internal/webserver/Webserver.go b/internal/webserver/Webserver.go index 2c70e5b9..bc374b0f 100644 --- a/internal/webserver/Webserver.go +++ b/internal/webserver/Webserver.go @@ -12,6 +12,17 @@ import ( "encoding/json" "errors" "fmt" + "html/template" + "io" + "io/fs" + "log" + "net/http" + "os" + "sort" + "strings" + templatetext "text/template" + "time" + "github.com/NYTimes/gziphandler" "github.com/forceu/gokapi/internal/configuration" "github.com/forceu/gokapi/internal/configuration/database" @@ -25,18 +36,9 @@ import ( "github.com/forceu/gokapi/internal/webserver/authentication/oauth" "github.com/forceu/gokapi/internal/webserver/authentication/sessionmanager" "github.com/forceu/gokapi/internal/webserver/fileupload" + "github.com/forceu/gokapi/internal/webserver/guestupload" "github.com/forceu/gokapi/internal/webserver/sse" "github.com/forceu/gokapi/internal/webserver/ssl" - "html/template" - "io" - "io/fs" - "log" - "net/http" - "os" - "sort" - "strings" - templatetext "text/template" - "time" ) // TODO add 404 handler @@ -105,6 +107,10 @@ func Start() { mux.HandleFunc("/error-auth", showErrorAuth) mux.HandleFunc("/error-oauth", showErrorIntOAuth) mux.HandleFunc("/forgotpw", forgotPassword) + mux.HandleFunc("/guestUploads", requireLogin(showGuestUploadMenu, false)) + mux.HandleFunc("/newUploadToken", requireLogin(newUploadToken, false)) + mux.HandleFunc("/deleteUploadToken", requireLogin(deleteUploadToken, false)) + mux.HandleFunc("/guestUpload", showGuestUpload) mux.HandleFunc("/hotlink/", showHotlink) mux.HandleFunc("/index", showIndex) mux.HandleFunc("/login", showLogin) @@ -113,6 +119,9 @@ func Start() { mux.HandleFunc("/uploadChunk", requireLogin(uploadChunk, true)) mux.HandleFunc("/uploadComplete", requireLogin(uploadComplete, true)) mux.HandleFunc("/uploadStatus", requireLogin(sse.GetStatusSSE, false)) + mux.HandleFunc("/guestUploadChunk", requireUploadToken(uploadChunk, true)) + mux.HandleFunc("/guestUploadComplete", requireUploadToken(uploadComplete, true)) + mux.HandleFunc("/guestUploadStatus", requireUploadToken(sse.GetStatusSSE, false)) mux.Handle("/main.wasm", gziphandler.GzipHandler(http.HandlerFunc(serveDownloadWasm))) mux.Handle("/e2e.wasm", gziphandler.GzipHandler(http.HandlerFunc(serveE2EWasm))) @@ -242,6 +251,7 @@ func showError(w http.ResponseWriter, r *http.Request) { const invalidFile = 0 const noCipherSupplied = 1 const wrongCipher = 2 + const guestUploadError = 3 errorReason := invalidFile if r.URL.Query().Has("e2e") { @@ -250,6 +260,9 @@ func showError(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Has("key") { errorReason = wrongCipher } + if r.URL.Query().Has("guestupload") { + errorReason = guestUploadError + } err := templateFolder.ExecuteTemplate(w, "error", genericView{ErrorId: errorReason, PublicName: configuration.Get().PublicName}) helper.CheckIgnoreTimeout(err) } @@ -356,6 +369,7 @@ func showLogin(w http.ResponseWriter, r *http.Request) { type LoginView struct { IsFailedLogin bool IsAdminView bool + IsUploadView bool IsDownloadView bool User string PublicName string @@ -523,6 +537,66 @@ func showAdminMenu(w http.ResponseWriter, r *http.Request) { helper.CheckIgnoreTimeout(err) } +// Handling of /guestUploads +// If use is authenticated, this menu lists and allows creating guest upload tokens +func showGuestUploadMenu(w http.ResponseWriter, r *http.Request) { + err := templateFolder.ExecuteTemplate(w, "guestuploadmenu", (&UploadView{}).convertGlobalConfig(ViewGuestUploads)) + helper.CheckIgnoreTimeout(err) +} + +// Handling of /newUploadToken +// Creates a new upload token +func newUploadToken(w http.ResponseWriter, r *http.Request) { + guestupload.NewUploadToken() + redirect(w, "guestUploads") +} + +// Handling of /deleteUploadToken +// Checks if uploadToken exists and deletes it +func deleteUploadToken(w http.ResponseWriter, r *http.Request) { + tokens, ok := r.URL.Query()["token"] + if ok { + guestupload.DeleteUploadToken(tokens[0]) + } + redirect(w, "guestUploads") +} + +// Handling of /guestUpload +// Allows a guest to upload one file +func showGuestUpload(w http.ResponseWriter, r *http.Request) { + addNoCacheHeader(w) + tokens, ok := r.URL.Query()["token"] + + if !ok || !guestupload.IsValidUploadToken(tokens[0]) { + redirect(w, "error?guestupload") + return + } + + err := templateFolder.ExecuteTemplate(w, "guestupload", (&GuestUploadView{}).init(tokens[0])) + helper.CheckIgnoreTimeout(err) +} + +// // Handling of /guestUploadChunk +// // If the user is authenticated, this parses the uploaded chunk and stores it +// func guestUploadChunk(w http.ResponseWriter, r *http.Request) { +// maxUpload := int64(configuration.Get().MaxFileSizeMB) * 1024 * 1024 +// w.Header().Set("Content-Type", "application/json; charset=UTF-8") +// if r.ContentLength > maxUpload { +// responseError(w, storage.ErrorFileTooLarge) +// } +// r.Body = http.MaxBytesReader(w, r.Body, maxUpload) +// err := fileupload.ProcessNewChunk(w, r, false) +// responseError(w, err) +// } + +// // Handling of /guestUploadComplete +// // If the user is authenticated, this parses the uploaded chunk and stores it +// func guestUploadComplete(w http.ResponseWriter, r *http.Request) { +// w.Header().Set("Content-Type", "application/json; charset=UTF-8") +// err := fileupload.CompleteChunk(w, r, false) +// responseError(w, err) +// } + // Handling of /logs // If user is authenticated, this menu shows the stored logs func showLogs(w http.ResponseWriter, r *http.Request) { @@ -549,6 +623,7 @@ type DownloadView struct { PublicName string IsFailedLogin bool IsAdminView bool + IsUploadView bool IsDownloadView bool IsPasswordView bool ClientSideDecryption bool @@ -556,8 +631,18 @@ type DownloadView struct { UsesHttps bool } +type GuestUploadView struct { + IsAdminView bool + IsUploadView bool + IsDownloadView bool + Token string + PublicName string + MaxFileSize int +} + type e2ESetupView struct { IsAdminView bool + IsUploadView bool IsDownloadView bool HasBeenSetup bool PublicName string @@ -568,10 +653,16 @@ type UploadView struct { Items []models.FileApiOutput ApiKeys []models.ApiKey ServerUrl string + UploadTokens []models.UploadToken + DownloadUrl string + HotlinkUrl string + GenericHotlinkUrl string + GuestUploadUrl string DefaultPassword string Logs string PublicName string IsAdminView bool + IsUploadView bool IsDownloadView bool IsApiView bool IsLogoutAvailable bool @@ -597,11 +688,15 @@ const ViewLogs = 1 // ViewAPI is the identifier for the API menu const ViewAPI = 2 +// ViewGuestUploads is the identifier for the Guest Upload menu +const ViewGuestUploads = 3 + // Converts the globalConfig variable to an UploadView struct to pass the infos to // the admin template func (u *UploadView) convertGlobalConfig(view int) *UploadView { var result []models.FileApiOutput var resultApi []models.ApiKey + var resultUploadTokens []models.UploadToken config := configuration.Get() switch view { @@ -640,14 +735,35 @@ func (u *UploadView) convertGlobalConfig(view int) *UploadView { } else { u.Logs = "Warning: Log file not found!" } + case ViewGuestUploads: + for _, element := range database.GetAllUploadTokens() { + if element.LastUsed == 0 { + element.LastUsedString = "Never" + } else { + element.LastUsedString = time.Unix(element.LastUsed, 0).Format("2006-01-02 15:04:05") + } + resultUploadTokens = append(resultUploadTokens, element) + } + sort.Slice(resultUploadTokens[:], func(i, j int) bool { + if resultUploadTokens[i].LastUsed == resultUploadTokens[j].LastUsed { + return resultUploadTokens[i].Id < resultUploadTokens[j].Id + } + return resultUploadTokens[i].LastUsed > resultUploadTokens[j].LastUsed + }) } u.ServerUrl = config.ServerUrl + u.DownloadUrl = config.ServerUrl + "d?id=" + u.HotlinkUrl = config.ServerUrl + "hotlink/" + u.GenericHotlinkUrl = config.ServerUrl + "downloadFile?id=" + u.GuestUploadUrl = config.ServerUrl + "guestUpload?token=" u.Items = result u.PublicName = config.PublicName u.ApiKeys = resultApi + u.UploadTokens = resultUploadTokens u.TimeNow = time.Now().Unix() u.IsAdminView = true + u.IsUploadView = true u.ActiveView = view u.MaxFileSize = config.MaxFileSizeMB u.IsLogoutAvailable = authentication.IsLogoutAvailable() @@ -664,6 +780,18 @@ func (u *UploadView) convertGlobalConfig(view int) *UploadView { return u } +// Prepares the GuestUploadView +func (u *GuestUploadView) init(token string) *GuestUploadView { + u.Token = token + u.PublicName = configuration.Get().PublicName + u.IsAdminView = false + u.IsUploadView = true + u.IsDownloadView = false + u.MaxFileSize = configuration.Get().MaxFileSizeMB + + return u +} + // Handling of /uploadChunk // If the user is authenticated, this parses the uploaded chunk and stores it func uploadChunk(w http.ResponseWriter, r *http.Request) { @@ -749,6 +877,24 @@ func requireLogin(next http.HandlerFunc, isUpload bool) http.HandlerFunc { } } +func requireUploadToken(next http.HandlerFunc, isUpload bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + addNoCacheHeader(w) + + tokens, ok := r.URL.Query()["token"] + + if ok && guestupload.IsValidUploadToken(tokens[0]) { + next.ServeHTTP(w, r) + return + } + if isUpload { + _, _ = io.WriteString(w, "{\"Result\":\"error\",\"ErrorMessage\":\"Not authenticated\"}") + return + } + redirect(w, "error?guestupload") + } +} + // Write a cookie if the user has entered a correct password for a password-protected file func writeFilePwCookie(w http.ResponseWriter, file models.File) { http.SetCookie(w, &http.Cookie{ @@ -781,6 +927,7 @@ func addNoCacheHeader(w http.ResponseWriter) { // A view containing parameters for a generic template type genericView struct { IsAdminView bool + IsUploadView bool IsDownloadView bool PublicName string RedirectUrl string @@ -790,6 +937,7 @@ type genericView struct { // A view containing parameters for an oauth error type oauthErrorView struct { IsAdminView bool + IsUploadView bool IsDownloadView bool PublicName string IsAuthDenied bool diff --git a/internal/webserver/fileupload/FileUpload.go b/internal/webserver/fileupload/FileUpload.go index 91cc4e9a..58913cf8 100644 --- a/internal/webserver/fileupload/FileUpload.go +++ b/internal/webserver/fileupload/FileUpload.go @@ -1,15 +1,17 @@ package fileupload import ( + "io" + "net/http" + "strconv" + "time" + "github.com/forceu/gokapi/internal/configuration" "github.com/forceu/gokapi/internal/configuration/database" "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/webserver/guestupload" ) // Process processes a file upload request @@ -83,6 +85,13 @@ func CompleteChunk(w http.ResponseWriter, r *http.Request, isApiCall bool) error return err } _, _ = io.WriteString(w, result.ToJsonResult(config.ExternalUrl, configuration.Get().IncludeFilename)) + + // If an UploadToken was used, delete it + tokens, ok := r.URL.Query()["token"] + if ok && guestupload.IsValidUploadToken(tokens[0]) { + guestupload.DeleteUploadToken(tokens[0]) + } + return nil } diff --git a/internal/webserver/guestupload/Guest.go b/internal/webserver/guestupload/Guest.go new file mode 100644 index 00000000..da14e491 --- /dev/null +++ b/internal/webserver/guestupload/Guest.go @@ -0,0 +1,37 @@ +package guestupload + +import ( + "github.com/forceu/gokapi/internal/configuration/database" + "github.com/forceu/gokapi/internal/helper" + "github.com/forceu/gokapi/internal/models" +) + +func NewUploadToken() string { + newToken := models.UploadToken{ + Id: helper.GenerateRandomString(30), + LastUsed: 0, + } + database.SaveUploadToken(newToken) + return newToken.Id +} + +func DeleteUploadToken(id string) bool { + if !IsValidUploadToken(id) { + return false + } + database.DeleteUploadToken(id) + return true +} + +// IsValidUploadToken checks if the provides is valid. If modifyTime is true, it also automatically updates +// the lastUsed timestamp +func IsValidUploadToken(token string) bool { + if token == "" { + return false + } + savedToken, ok := database.GetUploadToken(token) + if ok && savedToken.Id != "" { + return true + } + return false +} diff --git a/internal/webserver/web/static/js/guestupload.js b/internal/webserver/web/static/js/guestupload.js new file mode 100644 index 00000000..4ece44ff --- /dev/null +++ b/internal/webserver/web/static/js/guestupload.js @@ -0,0 +1,356 @@ +var clipboard = new ClipboardJS('.btn'); + +var dropzoneObject; +var isE2EEnabled = false; + +var isUploading = false; + +var rowCount = -1; + +window.addEventListener('beforeunload', (event) => { + if (isUploading) { + event.returnValue = 'Upload is still in progress. Do you want to close this page?'; + } +}); + +Dropzone.options.uploaddropzone = { + paramName: "file", + dictDefaultMessage: "Drop files, paste or click here to upload", + createImageThumbnails: false, + chunksUploaded: function (file, done) { + sendChunkComplete(file, done); + }, + init: function () { + dropzoneObject = this; + this.on("addedfile", file => { + addFileProgress(file); + }); + this.on("queuecomplete", function () { + isUploading = false; + }); + this.on("sending", function (file, xhr, formData) { + isUploading = true; + }); + + // Error handling for chunk upload, especially returning 413 error code (invalid nginx configuration) + this.on("error", function (file, errorMessage, xhr) { + if (xhr && xhr.status === 413) { + showError(file, "File too large to upload. If you are using a reverse proxy, make sure that the allowed body size is at least 50MB."); + } else { + showError(file, "Server responded with code " + xhr.status); + } + }); + + this.on("uploadprogress", function (file, progress, bytesSent) { + updateProgressbar(file, progress, bytesSent); + }); + }, +}; + + + +function updateProgressbar(file, progress, bytesSent) { + let chunkId = file.upload.uuid; + let container = document.getElementById(`us-container-${chunkId}`); + if (container == null || container.getAttribute('data-complete') === "true") { + return; + } + let rounded = Math.round(progress); + if (rounded < 0) { + rounded = 0; + } + if (rounded > 100) { + rounded = 100; + } + let millisSinceUpload = Date.now() - container.getAttribute('data-starttime'); + let megabytePerSecond = bytesSent / (millisSinceUpload / 1000) / 1024 / 1024; + let uploadSpeed = Math.round(megabytePerSecond * 10) / 10; + document.getElementById(`us-progressbar-${chunkId}`).style.width = rounded + "%"; + document.getElementById(`us-progress-info-${chunkId}`).innerText = rounded + "% - " + uploadSpeed + "MB/s"; +} + +function setProgressStatus(chunkId, progressCode) { + let container = document.getElementById(`us-container-${chunkId}`); + if (container == null) { + return; + } + container.setAttribute('data-complete', 'true'); + let text; + switch (progressCode) { + case 0: + text = "Processing file..."; + break; + case 1: + text = "Uploading file..."; + break; + } + document.getElementById(`us-progress-info-${chunkId}`).innerText = text; +} + +function addFileProgress(file) { + addFileStatus(file.upload.uuid, file.upload.filename); +} + +document.onpaste = function (event) { + if (dropzoneObject.disabled) { + return; + } + var items = (event.clipboardData || event.originalEvent.clipboardData).items; + for (index in items) { + var item = items[index]; + if (item.kind === 'file') { + dropzoneObject.addFile(item.getAsFile()); + } + if (item.kind === 'string') { + item.getAsString(function (s) { + // If a picture was copied from a website, the origin information might be submitted, which is filtered with this regex out + const pattern = //gi; + if (pattern.test(s) === false) { + let blob = new Blob([s], { + type: 'text/plain' + }); + let file = new File([blob], "Pasted Text.txt", { + type: "text/plain", + lastModified: new Date(0) + }); + dropzoneObject.addFile(file); + } + }); + } + } +} + +function urlencodeFormData(fd) { + let s = ''; + + function encode(s) { + return encodeURIComponent(s).replace(/%20/g, '+'); + } + for (var pair of fd.entries()) { + if (typeof pair[1] == 'string') { + s += (s ? '&' : '') + encode(pair[0]) + '=' + encode(pair[1]); + } + } + return s; +} + +function sendChunkComplete(file, done) { + const token = document.querySelector("#uploaddropzone").attributes.getNamedItem("data-token").value; + var xhr = new XMLHttpRequest(); + xhr.open("POST", "./guestUploadComplete?token=" + token, true); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + let formData = new FormData(); + formData.append("allowedDownloads", document.getElementById("allowedDownloads").value); + formData.append("expiryDays", document.getElementById("expiryDays").value); + formData.append("password", document.getElementById("password").value); + formData.append("isUnlimitedDownload", !document.getElementById("enableDownloadLimit").checked); + formData.append("isUnlimitedTime", !document.getElementById("enableTimeLimit").checked); + formData.append("chunkid", file.upload.uuid); + + if (file.isEndToEndEncrypted === true) { + formData.append("filesize", file.sizeEncrypted); + formData.append("filename", "Encrypted File"); + formData.append("filecontenttype", ""); + formData.append("isE2E", "true"); + formData.append("realSize", file.size); + } else { + formData.append("filesize", file.size); + formData.append("filename", file.name); + formData.append("filecontenttype", file.type); + } + + xhr.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status == 200) { + removeFileStatus(file.upload.uuid); + showUploadResult(xhr.response) + done(); + } else { + file.accepted = false; + let errorMessage = getErrorMessage(xhr.responseText) + dropzoneObject._errorProcessing([file], errorMessage); + showError(file, errorMessage); + } + } + }; + xhr.send(urlencodeFormData(formData)); +} + +function showUploadResult(response) { + const result = JSON.parse(response) + + document.querySelector("#card-title").textContent = "Guest Upload Succeeded" + + document.querySelector("#upload-interface").style.display = "none"; + document.querySelector("#result-interface").style.display = "inline"; + + document.querySelector("#result-name").textContent = result.FileInfo.Name; + + const link = document.createElement("a"); + link.setAttribute("href", result.FileInfo.UrlDownload); + link.textContent = result.FileInfo.UrlDownload; + document.querySelector("#result-link").appendChild(link); + document.querySelector("#qr-button").addEventListener("click", () => showQrCode(result.FileInfo.UrlDownload)) + document.querySelector("#url-button").setAttribute("data-clipboard-text", result.FileInfo.UrlDownload) +} + +function getErrorMessage(response) { + let result; + try { + result = JSON.parse(response); + } catch (e) { + return "Unknown error: Server could not process file"; + } + return "Error: " + result.ErrorMessage; +} + +function showError(file, message) { + let chunkId = file.upload.uuid; + document.getElementById(`us-progressbar-${chunkId}`).style.width = "100%"; + document.getElementById(`us-progressbar-${chunkId}`).style.backgroundColor = "red"; + document.getElementById(`us-progress-info-${chunkId}`).innerText = message; + document.getElementById(`us-progress-info-${chunkId}`).classList.add('uploaderror'); +} + +function checkBoxChanged(checkBox, correspondingInput) { + let disable = !checkBox.checked; + + if (disable) { + document.getElementById(correspondingInput).setAttribute("disabled", ""); + } else { + document.getElementById(correspondingInput).removeAttribute("disabled"); + } + if (correspondingInput === "password" && disable) { + document.getElementById("password").value = ""; + } +} + +function parseData(data) { + if (!data) return { + "Result": "error" + }; + if (typeof data === 'object') return data; + if (typeof data === 'string') return JSON.parse(data); + + return { + "Result": "error" + }; +} + +function registerChangeHandler() { + const source = new EventSource("./uploadStatus?stream=changes") + source.onmessage = (event) => { + try { + let eventData = JSON.parse(event.data); + setProgressStatus(eventData.chunkid, eventData.currentstatus); + } catch (e) { + console.error("Failed to parse event data:", e); + } + } + source.onerror = (error) => { + + // Check for net::ERR_HTTP2_PROTOCOL_ERROR 200 (OK) and ignore it + if (error.target.readyState !== EventSource.CLOSED) { + source.close(); + } + + + console.log("Reconnecting to SSE..."); + // Attempt to reconnect after a delay + setTimeout(registerChangeHandler, 1000); + }; +} + +var statusItemCount = 0; + + +function addFileStatus(chunkId, filename) { + const container = document.createElement('div'); + container.setAttribute('id', `us-container-${chunkId}`); + container.classList.add('us-container'); + + // create filename div + const filenameDiv = document.createElement('div'); + filenameDiv.classList.add('filename'); + filenameDiv.textContent = filename; + container.appendChild(filenameDiv); + + // create progress bar container div + const progressContainerDiv = document.createElement('div'); + progressContainerDiv.classList.add('upload-progress-container'); + progressContainerDiv.setAttribute('id', `us-progress-container-${chunkId}`); + + // create progress bar div + const progressBarDiv = document.createElement('div'); + progressBarDiv.classList.add('upload-progress-bar'); + + // create progress bar progress div + const progressBarProgressDiv = document.createElement('div'); + progressBarProgressDiv.setAttribute('id', `us-progressbar-${chunkId}`); + progressBarProgressDiv.classList.add('upload-progress-bar-progress'); + progressBarProgressDiv.style.width = '0%'; + progressBarDiv.appendChild(progressBarProgressDiv); + + // create progress info div + const progressInfoDiv = document.createElement('div'); + progressInfoDiv.setAttribute('id', `us-progress-info-${chunkId}`); + progressInfoDiv.classList.add('upload-progress-info'); + progressInfoDiv.textContent = '0%'; + + // append progress bar and progress info to progress bar container + progressContainerDiv.appendChild(progressBarDiv); + progressContainerDiv.appendChild(progressInfoDiv); + + // append progress bar container to container + container.appendChild(progressContainerDiv); + + container.setAttribute('data-starttime', Date.now()); + container.setAttribute('data-complete', "false"); + + const uploadstatusContainer = document.getElementById("uploadstatus"); + uploadstatusContainer.appendChild(container); + uploadstatusContainer.style.visibility = "visible"; + statusItemCount++; +} + +function removeFileStatus(chunkId) { + const container = document.getElementById(`us-container-${chunkId}`); + if (container == null) { + return; + } + container.remove(); + statusItemCount--; + if (statusItemCount < 1) { + document.getElementById("uploadstatus").style.visibility = "hidden"; + } +} + + +function hideQrCode() { + document.getElementById("qroverlay").style.display = "none"; + document.getElementById("qrcode").innerHTML = ""; +} + + +function showQrCode(url) { + const overlay = document.getElementById("qroverlay"); + overlay.style.display = "block"; + new QRCode(document.getElementById("qrcode"), { + text: url, + width: 200, + height: 200, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: QRCode.CorrectLevel.H + }); + overlay.addEventListener("click", hideQrCode); +} + +function showToast() { + let notification = document.getElementById("toastnotification"); + notification.classList.add("show"); + setTimeout(() => { + notification.classList.remove("show"); + }, 1000); +} diff --git a/internal/webserver/web/static/js/min/guestupload.min.js b/internal/webserver/web/static/js/min/guestupload.min.js new file mode 100644 index 00000000..9f7cad10 --- /dev/null +++ b/internal/webserver/web/static/js/min/guestupload.min.js @@ -0,0 +1 @@ +var clipboard=new ClipboardJS(".btn"),dropzoneObject,statusItemCount,isE2EEnabled=!1,isUploading=!1,rowCount=-1;window.addEventListener("beforeunload",e=>{isUploading&&(e.returnValue="Upload is still in progress. Do you want to close this page?")}),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=>{addFileProgress(e)}),this.on("queuecomplete",function(){isUploading=!1}),this.on("sending",function(){isUploading=!0}),this.on("error",function(e,t,n){n&&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 50MB."):showError(e,"Server responded with code "+n.status)}),this.on("uploadprogress",function(e,t,n){updateProgressbar(e,t,n)})}};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 a=Date.now()-i.getAttribute("data-starttime"),r=n/(a/1e3)/1024/1024,c=Math.round(r*10)/10;document.getElementById(`us-progressbar-${o}`).style.width=s+"%",document.getElementById(`us-progress-info-${o}`).innerText=s+"% - "+c+"MB/s"}function setProgressStatus(e,t){let s=document.getElementById(`us-container-${e}`);if(s==null)return;s.setAttribute("data-complete","true");let n;switch(t){case 0:n="Processing file...";break;case 1:n="Uploading file...";break}document.getElementById(`us-progress-info-${e}`).innerText=n}function addFileProgress(e){addFileStatus(e.upload.uuid,e.upload.filename)}document.onpaste=function(e){if(dropzoneObject.disabled)return;var t,n=(e.clipboardData||e.originalEvent.clipboardData).items;for(index in n)t=n[index],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)}})};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){const o=document.querySelector("#uploaddropzone").attributes.getNamedItem("data-token").value;var s=new XMLHttpRequest;s.open("POST","./guestUploadComplete?token="+o,!0),s.setRequestHeader("Content-Type","application/x-www-form-urlencoded");let n=new FormData;n.append("allowedDownloads",document.getElementById("allowedDownloads").value),n.append("expiryDays",document.getElementById("expiryDays").value),n.append("password",document.getElementById("password").value),n.append("isUnlimitedDownload",!document.getElementById("enableDownloadLimit").checked),n.append("isUnlimitedTime",!document.getElementById("enableTimeLimit").checked),n.append("chunkid",e.upload.uuid),e.isEndToEndEncrypted===!0?(n.append("filesize",e.sizeEncrypted),n.append("filename","Encrypted File"),n.append("filecontenttype",""),n.append("isE2E","true"),n.append("realSize",e.size)):(n.append("filesize",e.size),n.append("filename",e.name),n.append("filecontenttype",e.type)),s.onreadystatechange=function(){if(this.readyState==4)if(this.status==200)removeFileStatus(e.upload.uuid),showUploadResult(s.response),t();else{e.accepted=!1;let t=getErrorMessage(s.responseText);dropzoneObject._errorProcessing([e],t),showError(e,t)}},s.send(urlencodeFormData(n))}function showUploadResult(e){const t=JSON.parse(e);document.querySelector("#card-title").textContent="Guest Upload Succeeded",document.querySelector("#upload-interface").style.display="none",document.querySelector("#result-interface").style.display="inline",document.querySelector("#result-name").textContent=t.FileInfo.Name;const n=document.createElement("a");n.setAttribute("href",t.FileInfo.UrlDownload),n.textContent=t.FileInfo.UrlDownload,document.querySelector("#result-link").appendChild(n),document.querySelector("#qr-button").addEventListener("click",()=>showQrCode(t.FileInfo.UrlDownload)),document.querySelector("#url-button").setAttribute("data-clipboard-text",t.FileInfo.UrlDownload)}function getErrorMessage(e){let t;try{t=JSON.parse(e)}catch{return"Unknown error: Server could not process file"}return"Error: "+t.ErrorMessage}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 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 parseData(e){return e?typeof e=="object"?e:typeof e=="string"?JSON.parse(e):{Result:"error"}:{Result:"error"}}function registerChangeHandler(){const e=new EventSource("./uploadStatus?stream=changes");e.onmessage=e=>{try{let t=JSON.parse(e.data);setProgressStatus(t.chunkid,t.currentstatus)}catch(e){console.error("Failed to parse event data:",e)}},e.onerror=t=>{t.target.readyState!==EventSource.CLOSED&&e.close(),console.log("Reconnecting to SSE..."),setTimeout(registerChangeHandler,1e3)}}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 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 showToast(){let e=document.getElementById("toastnotification");e.classList.add("show"),setTimeout(()=>{e.classList.remove("show")},1e3)} \ No newline at end of file diff --git a/internal/webserver/web/templates/html_error.tmpl b/internal/webserver/web/templates/html_error.tmpl index a701dfea..bb9ba545 100644 --- a/internal/webserver/web/templates/html_error.tmpl +++ b/internal/webserver/web/templates/html_error.tmpl @@ -17,6 +17,9 @@ {{ if eq .ErrorId 2 }} This file is encrypted and an incorrect key has been passed.

    If this file is end-to-end encrypted, please contact the uploader to give you the correct link, including the value after the hash. {{ end }} +{{ if eq .ErrorId 3 }} + This guest upload link is invalid, or has already been used. Please ask for a new link. +{{ end }}
     

    diff --git a/internal/webserver/web/templates/html_guestupload.tmpl b/internal/webserver/web/templates/html_guestupload.tmpl new file mode 100644 index 00000000..9fafcd96 --- /dev/null +++ b/internal/webserver/web/templates/html_guestupload.tmpl @@ -0,0 +1,97 @@ +{{ define "guestupload" }} +{{ template "header" . }} + +
    +
    +
    +
    +

    Guest Upload

    +
    + + +

    +

    + +
    +
    +

    + +
    +
    +
    +
    + + +
    + + Downloads +
    +
    +
    +
    + + +
    + + Days +
    +
    +
    +
    + + + +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    URL copied to clipboard
    +
    +
    +
    + + + + +{{ template "footer" }} +{{ end }} diff --git a/internal/webserver/web/templates/html_guestuploadmenu.tmpl b/internal/webserver/web/templates/html_guestuploadmenu.tmpl new file mode 100644 index 00000000..24de70b2 --- /dev/null +++ b/internal/webserver/web/templates/html_guestuploadmenu.tmpl @@ -0,0 +1,94 @@ +{{ define "guestuploadmenu" }} +{{ template "header" . }} + +
    +
    +
    +
    +

    Guest Upload Tokens

    +
    + New Upload Token + +

    +
    + + + + + + + + + + {{ range .UploadTokens }} + + + + + + + + {{ end }} + +
    TokenLast usedActions
    {{ .Id }}{{ .LastUsedString }} + + + +
    +
    +
    +
    +
    +
    + + +
    URL copied to clipboard
    +
    +
    +
    + + + + +{{ if .EndToEndEncryption }} + + + +{{ end }} + + + +{{ template "footer" }} +{{ end }} diff --git a/internal/webserver/web/templates/html_header.tmpl b/internal/webserver/web/templates/html_header.tmpl index 1a6b144b..28b4a395 100644 --- a/internal/webserver/web/templates/html_header.tmpl +++ b/internal/webserver/web/templates/html_header.tmpl @@ -15,14 +15,16 @@ -{{ if .IsAdminView }} - {{.PublicName}} Admin +{{ if .IsUploadView }} - + +{{ end }} +{{ if .IsAdminView }} + {{.PublicName}} Admin + - @@ -61,6 +63,7 @@

    {{.PublicName}}