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" . }}
+
+