From ddc72b0a98be32099e61e1201c3cc0c5114ff734 Mon Sep 17 00:00:00 2001 From: Marc Ole Bulling Date: Thu, 5 Dec 2024 13:55:11 +0100 Subject: [PATCH] Use API for most of UI interactions with an internal API key (#211), breaking API changes * Changed color of API procressing status * Added internal system key, use system key for API page perm changes, BREAKING: removed session auth * API: Added /auth/delete, added option to include basic parameters in /auth/create, migrated API overview page completely to use API calls * Added toast in API menu for clipboard, removed reference to session authentication in documentation * Changed delete button in upload menu to API call, fixed modify API call in menu #210 * Set Api key in API menu for changing permissions, renamed title for Deleting Uploads permission, refactoring * Added tests, refactoring --- build/go-generate/minifyStaticContent.go | 2 +- build/go-generate/updateVersionNumbers.go | 2 +- docs/advanced.rst | 2 +- docs/setup.rst | 3 - internal/configuration/database/Database.go | 5 + .../database/dbabstraction/DbAbstraction.go | 2 + .../database/provider/redis/apikeys.go | 23 ++ .../database/provider/sqlite/Sqlite.go | 13 +- .../database/provider/sqlite/apikeys.go | 49 +++- internal/configuration/setup/ProtectedUrls.go | 2 +- internal/models/Api.go | 9 +- internal/models/FileList.go | 9 +- internal/webserver/Webserver.go | 31 +-- internal/webserver/Webserver_test.go | 123 +--------- internal/webserver/api/Api.go | 70 ++++-- internal/webserver/api/Api_test.go | 213 +++++++++++++++++- internal/webserver/fileupload/FileUpload.go | 4 +- .../webserver/fileupload/FileUpload_test.go | 4 +- .../web/static/apidocumentation/openapi.json | 90 ++++---- internal/webserver/web/static/css/cover.css | 2 +- .../web/static/css/min/gokapi.min.2.css | 2 +- internal/webserver/web/static/js/admin.js | 93 +++++++- .../web/static/js/min/admin.min.5.js | 1 - .../web/static/js/min/admin.min.6.js | 1 + .../webserver/web/templates/html_admin.tmpl | 5 +- .../webserver/web/templates/html_api.tmpl | 13 +- .../web/templates/string_constants.tmpl | 2 +- openapi.json | 90 ++++---- 28 files changed, 573 insertions(+), 292 deletions(-) delete mode 100644 internal/webserver/web/static/js/min/admin.min.5.js create mode 100644 internal/webserver/web/static/js/min/admin.min.6.js diff --git a/build/go-generate/minifyStaticContent.go b/build/go-generate/minifyStaticContent.go index 78d5111a..9344d111 100644 --- a/build/go-generate/minifyStaticContent.go +++ b/build/go-generate/minifyStaticContent.go @@ -137,6 +137,6 @@ func fileExists(filename string) bool { // Auto-generated content below, do not modify // Version codes can be changed in updateVersionNumbers.go -const jsAdminVersion = 5 +const jsAdminVersion = 6 const jsE2EVersion = 3 const cssMainVersion = 2 diff --git a/build/go-generate/updateVersionNumbers.go b/build/go-generate/updateVersionNumbers.go index d3222aea..a675188a 100644 --- a/build/go-generate/updateVersionNumbers.go +++ b/build/go-generate/updateVersionNumbers.go @@ -11,7 +11,7 @@ import ( "strings" ) -const versionJsAdmin = 5 +const versionJsAdmin = 6 const versionJsDropzone = 4 const versionJsE2EAdmin = 3 const versionCssMain = 2 diff --git a/docs/advanced.rst b/docs/advanced.rst index baeef903..32f08b57 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -220,7 +220,7 @@ Interacting with the API ============================ -All API calls will need an API key as authentication or a valid admin session cookie. An API key can be generated in the web UI in the menu "API". The API key needs to be passed as a header. +All API calls will need an API key as authentication. An API key can be generated in the web UI in the menu "API". The API key needs to be passed as a header. Example: Getting a list of all stored files with curl :: diff --git a/docs/setup.rst b/docs/setup.rst index 1f855d74..e0212ab6 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -226,10 +226,7 @@ Only use this if you are running Gokapi behind a reverse proxy that is capable o This option disables Gokapis internal authentication completely, except for API calls. The following URLs need to be restricted by the reverse proxy: - ``/admin`` -- ``/apiDelete`` - ``/apiKeys`` -- ``/apiNew`` -- ``/delete`` - ``/e2eInfo`` - ``/e2eSetup`` - ``/logs`` diff --git a/internal/configuration/database/Database.go b/internal/configuration/database/Database.go index 3f632990..8a3a897a 100644 --- a/internal/configuration/database/Database.go +++ b/internal/configuration/database/Database.go @@ -130,6 +130,11 @@ func DeleteApiKey(id string) { db.DeleteApiKey(id) } +// GetSystemKey returns the latest UI API key +func GetSystemKey() (models.ApiKey, bool) { + return db.GetSystemKey() +} + // E2E Section // SaveEnd2EndInfo stores the encrypted e2e info diff --git a/internal/configuration/database/dbabstraction/DbAbstraction.go b/internal/configuration/database/dbabstraction/DbAbstraction.go index 024628a5..a35a2943 100644 --- a/internal/configuration/database/dbabstraction/DbAbstraction.go +++ b/internal/configuration/database/dbabstraction/DbAbstraction.go @@ -43,6 +43,8 @@ type Database interface { UpdateTimeApiKey(apikey models.ApiKey) // DeleteApiKey deletes an API key with the given ID DeleteApiKey(id string) + // GetSystemKey returns the latest UI API key + GetSystemKey() (models.ApiKey, bool) // SaveEnd2EndInfo stores the encrypted e2e info SaveEnd2EndInfo(info models.E2EInfoEncrypted) diff --git a/internal/configuration/database/provider/redis/apikeys.go b/internal/configuration/database/provider/redis/apikeys.go index 01a4c4ac..76f3f4da 100644 --- a/internal/configuration/database/provider/redis/apikeys.go +++ b/internal/configuration/database/provider/redis/apikeys.go @@ -41,9 +41,32 @@ func (p DatabaseProvider) GetApiKey(id string) (models.ApiKey, bool) { return apikey, true } +// GetSystemKey returns the latest UI API key +func (p DatabaseProvider) GetSystemKey() (models.ApiKey, bool) { + keys := p.GetAllApiKeys() + foundKey := "" + var latestExpiry int64 + for _, key := range keys { + if !key.IsSystemKey { + continue + } + if key.Expiry > latestExpiry { + foundKey = key.Id + latestExpiry = key.Expiry + } + } + if foundKey == "" { + return models.ApiKey{}, false + } + return keys[foundKey], true +} + // SaveApiKey saves the API key to the database func (p DatabaseProvider) SaveApiKey(apikey models.ApiKey) { p.setHashMap(p.buildArgs(prefixApiKeys + apikey.Id).AddFlat(apikey)) + if apikey.Expiry != 0 { + p.setExpiryAt(prefixApiKeys+apikey.Id, apikey.Expiry) + } } // UpdateTimeApiKey writes the content of LastUsage to the database diff --git a/internal/configuration/database/provider/sqlite/Sqlite.go b/internal/configuration/database/provider/sqlite/Sqlite.go index 68952757..47d373a6 100644 --- a/internal/configuration/database/provider/sqlite/Sqlite.go +++ b/internal/configuration/database/provider/sqlite/Sqlite.go @@ -17,7 +17,7 @@ type DatabaseProvider struct { sqliteDb *sql.DB } -const DatabaseSchemeVersion = 3 +const DatabaseSchemeVersion = 4 // New returns an instance func New(dbConfig models.DbConnection) (DatabaseProvider, error) { @@ -57,6 +57,14 @@ func (p DatabaseProvider) Upgrade(currentDbVersion int) { ALTER TABLE "ApiKeys_New" RENAME TO "ApiKeys";`) helper.Check(err) } + // < v1.9.0 + if currentDbVersion < 4 { + // Add Column LastUsedString, keeping old data + err := p.rawSqlite(`ALTER TABLE "ApiKeys" ADD COLUMN "Expiry" INTEGER;`) + helper.Check(err) + err = p.rawSqlite(`ALTER TABLE "ApiKeys" ADD COLUMN "IsSystemKey" INTEGER;`) + helper.Check(err) + } } // GetDbVersion gets the version number of the database @@ -125,6 +133,7 @@ func (p DatabaseProvider) Close() { func (p DatabaseProvider) RunGarbageCollection() { p.cleanExpiredSessions() p.cleanUploadStatus() + p.cleanApiKeys() } func (p DatabaseProvider) createNewDatabase() error { @@ -133,6 +142,8 @@ func (p DatabaseProvider) createNewDatabase() error { "FriendlyName" TEXT NOT NULL, "LastUsed" INTEGER NOT NULL, "Permissions" INTEGER NOT NULL DEFAULT 0, + "Expiry" INTEGER, + "IsSystemKey" INTEGER, PRIMARY KEY("Id") ) WITHOUT ROWID; CREATE TABLE "E2EConfig" ( diff --git a/internal/configuration/database/provider/sqlite/apikeys.go b/internal/configuration/database/provider/sqlite/apikeys.go index 8390ba67..a00d0b9b 100644 --- a/internal/configuration/database/provider/sqlite/apikeys.go +++ b/internal/configuration/database/provider/sqlite/apikeys.go @@ -12,24 +12,28 @@ type schemaApiKeys struct { FriendlyName string LastUsed int64 Permissions int + Expiry int64 + IsSystemKey int } // GetAllApiKeys returns a map with all API keys func (p DatabaseProvider) GetAllApiKeys() map[string]models.ApiKey { result := make(map[string]models.ApiKey) - rows, err := p.sqliteDb.Query("SELECT * FROM ApiKeys") + rows, err := p.sqliteDb.Query("SELECT * FROM ApiKeys WHERE ApiKeys.Expiry == 0 OR ApiKeys.Expiry > ?", currentTime().Unix()) helper.Check(err) defer rows.Close() for rows.Next() { rowData := schemaApiKeys{} - err = rows.Scan(&rowData.Id, &rowData.FriendlyName, &rowData.LastUsed, &rowData.Permissions) + err = rows.Scan(&rowData.Id, &rowData.FriendlyName, &rowData.LastUsed, &rowData.Permissions, &rowData.Expiry, &rowData.IsSystemKey) helper.Check(err) result[rowData.Id] = models.ApiKey{ Id: rowData.Id, FriendlyName: rowData.FriendlyName, LastUsed: rowData.LastUsed, Permissions: uint8(rowData.Permissions), + Expiry: rowData.Expiry, + IsSystemKey: rowData.IsSystemKey == 1, } } return result @@ -39,7 +43,7 @@ func (p DatabaseProvider) GetAllApiKeys() map[string]models.ApiKey { func (p DatabaseProvider) GetApiKey(id string) (models.ApiKey, bool) { var rowResult schemaApiKeys row := p.sqliteDb.QueryRow("SELECT * FROM ApiKeys WHERE Id = ?", id) - err := row.Scan(&rowResult.Id, &rowResult.FriendlyName, &rowResult.LastUsed, &rowResult.Permissions) + err := row.Scan(&rowResult.Id, &rowResult.FriendlyName, &rowResult.LastUsed, &rowResult.Permissions, &rowResult.Expiry, &rowResult.IsSystemKey) if err != nil { if errors.Is(err, sql.ErrNoRows) { return models.ApiKey{}, false @@ -53,15 +57,45 @@ func (p DatabaseProvider) GetApiKey(id string) (models.ApiKey, bool) { FriendlyName: rowResult.FriendlyName, LastUsed: rowResult.LastUsed, Permissions: uint8(rowResult.Permissions), + Expiry: rowResult.Expiry, + IsSystemKey: rowResult.IsSystemKey == 1, } return result, true } +// GetSystemKey returns the latest UI API key +func (p DatabaseProvider) GetSystemKey() (models.ApiKey, bool) { + var rowResult schemaApiKeys + row := p.sqliteDb.QueryRow("SELECT * FROM ApiKeys WHERE IsSystemKey = 1 ORDER BY Expiry DESC LIMIT 1") + err := row.Scan(&rowResult.Id, &rowResult.FriendlyName, &rowResult.LastUsed, &rowResult.Permissions, &rowResult.Expiry, &rowResult.IsSystemKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return models.ApiKey{}, false + } + helper.Check(err) + return models.ApiKey{}, false + } + + result := models.ApiKey{ + Id: rowResult.Id, + FriendlyName: rowResult.FriendlyName, + LastUsed: rowResult.LastUsed, + Permissions: uint8(rowResult.Permissions), + Expiry: rowResult.Expiry, + IsSystemKey: rowResult.IsSystemKey == 1, + } + return result, true +} + // SaveApiKey saves the API key to the database func (p DatabaseProvider) SaveApiKey(apikey models.ApiKey) { - _, err := p.sqliteDb.Exec("INSERT OR REPLACE INTO ApiKeys (Id, FriendlyName, LastUsed, Permissions) VALUES (?, ?, ?, ?)", - apikey.Id, apikey.FriendlyName, apikey.LastUsed, apikey.Permissions) + isSystemKey := 0 + if apikey.IsSystemKey { + isSystemKey = 1 + } + _, err := p.sqliteDb.Exec("INSERT OR REPLACE INTO ApiKeys (Id, FriendlyName, LastUsed, Permissions, Expiry, IsSystemKey) VALUES (?, ?, ?, ?, ?, ?)", + apikey.Id, apikey.FriendlyName, apikey.LastUsed, apikey.Permissions, apikey.Expiry, isSystemKey) helper.Check(err) } @@ -77,3 +111,8 @@ func (p DatabaseProvider) DeleteApiKey(id string) { _, err := p.sqliteDb.Exec("DELETE FROM ApiKeys WHERE Id = ?", id) helper.Check(err) } + +func (p DatabaseProvider) cleanApiKeys() { + _, err := p.sqliteDb.Exec("DELETE FROM ApiKeys WHERE ApiKeys.Expiry > 0 AND ApiKeys.Expiry < ?", currentTime().Unix()) + helper.Check(err) +} diff --git a/internal/configuration/setup/ProtectedUrls.go b/internal/configuration/setup/ProtectedUrls.go index b1991292..cdd3866a 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", "/apiKeys", "/e2eInfo", "/e2eSetup", "/logs", "/uploadChunk", "/uploadComplete", "/uploadStatus"} diff --git a/internal/models/Api.go b/internal/models/Api.go index 9743a63d..d67c562f 100644 --- a/internal/models/Api.go +++ b/internal/models/Api.go @@ -18,18 +18,21 @@ const ( // ApiPermNone means no permission granted const ApiPermNone = 0 -// ApiPermAllNoApiMod means all permission granted, except ApiPermApiMod -const ApiPermAllNoApiMod = 23 - // ApiPermAll means all permission granted const ApiPermAll = 31 +// ApiPermAllNoApiMod means all permission granted, except ApiPermApiMod +// This is the default for new API keys that are created from the UI +const ApiPermAllNoApiMod = ApiPermAll - ApiPermApiMod + // ApiKey contains data of a single api key type ApiKey struct { Id string `json:"Id" redis:"Id"` FriendlyName string `json:"FriendlyName" redis:"FriendlyName"` LastUsed int64 `json:"LastUsed" redis:"LastUsed"` Permissions uint8 `json:"Permissions" redis:"Permissions"` + Expiry int64 `json:"Expiry" redis:"Expiry"` // Does not expire if 0 + IsSystemKey bool `json:"IsSystemKey" redis:"IsSystemKey"` } func (key *ApiKey) GetReadableDate() string { diff --git a/internal/models/FileList.go b/internal/models/FileList.go index 3b74d915..9026e10a 100644 --- a/internal/models/FileList.go +++ b/internal/models/FileList.go @@ -107,17 +107,16 @@ func (f *File) ToJsonResult(serverUrl string, includeFilename bool) string { if err != nil { return errorAsJson(err) } - result := Result{ + + byteOutput, err := json.Marshal(Result{ Result: "OK", IncludeFilename: includeFilename, FileInfo: info, - } - - bytes, err := json.Marshal(result) + }) if err != nil { return errorAsJson(err) } - return string(bytes) + return string(byteOutput) } // RequiresClientDecryption checks if the file needs to be decrypted by the client diff --git a/internal/webserver/Webserver.go b/internal/webserver/Webserver.go index b9303769..c516a666 100644 --- a/internal/webserver/Webserver.go +++ b/internal/webserver/Webserver.go @@ -92,11 +92,8 @@ func Start() { mux.HandleFunc("/admin", requireLogin(showAdminMenu, false)) mux.HandleFunc("/api/", processApi) - mux.HandleFunc("/apiDelete", requireLogin(deleteApiKey, false)) mux.HandleFunc("/apiKeys", requireLogin(showApiAdmin, false)) - mux.HandleFunc("/apiNew", requireLogin(newApiKey, false)) mux.HandleFunc("/d", showDownload) - mux.HandleFunc("/delete", requireLogin(deleteFile, false)) mux.HandleFunc("/downloadFile", downloadFile) mux.HandleFunc("/e2eInfo", requireLogin(e2eInfo, true)) mux.HandleFunc("/e2eSetup", requireLogin(showE2ESetup, false)) @@ -302,21 +299,6 @@ func showApiAdmin(w http.ResponseWriter, r *http.Request) { helper.CheckIgnoreTimeout(err) } -// Handling of /apiNew -func newApiKey(w http.ResponseWriter, r *http.Request) { - api.NewKey(true) - redirect(w, "apiKeys") -} - -// Handling of /apiDelete -func deleteApiKey(w http.ResponseWriter, r *http.Request) { - keys, ok := r.URL.Query()["id"] - if ok { - api.DeleteKey(keys[0]) - } - redirect(w, "apiKeys") -} - // Handling of /api/ func processApi(w http.ResponseWriter, r *http.Request) { api.Process(w, r, configuration.Get().MaxMemory) @@ -505,17 +487,6 @@ func getE2eInfo(w http.ResponseWriter) { _, _ = w.Write(bytesE2e) } -// Handling of /delete -// User needs to be admin. Deletes the requested file -func deleteFile(w http.ResponseWriter, r *http.Request) { - keyId := queryUrl(w, r, "admin") - if keyId == "" { - return - } - storage.DeleteFile(keyId, true) - redirect(w, "admin") -} - // Checks if a file is associated with the GET parameter from the current URL // Stops for 500ms to limit brute forcing if invalid key and redirects to redirectUrl func queryUrl(w http.ResponseWriter, r *http.Request, redirectUrl string) string { @@ -593,6 +564,7 @@ type UploadView struct { DefaultPassword string Logs string PublicName string + SystemKey string IsAdminView bool IsDownloadView bool IsApiView bool @@ -678,6 +650,7 @@ func (u *UploadView) convertGlobalConfig(view int) *UploadView { u.MaxParallelUploads = config.MaxParallelUploads u.ChunkSize = config.ChunkSize u.IncludeFilename = config.IncludeFilename + u.SystemKey = api.GetSystemKey() return u } diff --git a/internal/webserver/Webserver_test.go b/internal/webserver/Webserver_test.go index ce7f2b07..f6d186d8 100644 --- a/internal/webserver/Webserver_test.go +++ b/internal/webserver/Webserver_test.go @@ -5,7 +5,6 @@ package webserver import ( "errors" "github.com/forceu/gokapi/internal/configuration" - "github.com/forceu/gokapi/internal/configuration/database" "github.com/forceu/gokapi/internal/test" "github.com/forceu/gokapi/internal/test/testconfiguration" "github.com/forceu/gokapi/internal/webserver/authentication" @@ -428,37 +427,6 @@ func TestDownloadCorrectPassword(t *testing.T) { }) } -func TestDeleteFileNonAuth(t *testing.T) { - t.Parallel() - test.HttpPageResult(t, test.HttpTestConfig{ - Url: "http://127.0.0.1:53843/delete?id=e4TjE7CokWK0giiLNxDL", - IsHtml: true, - RequiredContent: []string{"URL=./login"}, - }) -} - -func TestDeleteFileInvalidKey(t *testing.T) { - t.Parallel() - test.HttpPageResult(t, test.HttpTestConfig{ - Url: "http://127.0.0.1:53843/delete", - IsHtml: true, - RequiredContent: []string{"URL=./admin"}, - Cookies: []test.Cookie{{ - Name: "session_token", - Value: "validsession", - }}, - }) - test.HttpPageResult(t, test.HttpTestConfig{ - Url: "http://127.0.0.1:53843/delete?id=", - IsHtml: true, - RequiredContent: []string{"URL=./admin"}, - Cookies: []test.Cookie{{ - Name: "session_token", - Value: "validsession", - }}, - }) -} - func TestPostUploadNoAuth(t *testing.T) { t.Parallel() test.HttpPostUploadRequest(t, test.HttpTestConfig{ @@ -522,18 +490,6 @@ func TestPostUpload(t *testing.T) { }) } -func TestDeleteFile(t *testing.T) { - test.HttpPageResult(t, test.HttpTestConfig{ - Url: "http://127.0.0.1:53843/delete?id=e4TjE7CokWK0giiLNxDL", - IsHtml: true, - RequiredContent: []string{"URL=./admin"}, - Cookies: []test.Cookie{{ - Name: "session_token", - Value: "validsession", - }}, - }) -} - func TestApiPageAuthorized(t *testing.T) { t.Parallel() test.HttpPageResult(t, test.HttpTestConfig{ @@ -560,78 +516,6 @@ func TestApiPageNotAuthorized(t *testing.T) { }) } -func TestNewApiKey(t *testing.T) { - // Authorised - amountKeys := len(database.GetAllApiKeys()) - test.HttpPageResult(t, test.HttpTestConfig{ - Url: "http://127.0.0.1:53843/apiNew", - IsHtml: true, - RequiredContent: []string{"URL=./apiKeys"}, - ExcludedContent: []string{"URL=./login"}, - Cookies: []test.Cookie{{ - Name: "session_token", - Value: "validsession", - }}, - }) - amountKeysAfter := len(database.GetAllApiKeys()) - test.IsEqualInt(t, amountKeysAfter, amountKeys+1) - test.IsEqualInt(t, amountKeysAfter, 5) - - // Not authorised - amountKeys++ - test.HttpPageResult(t, test.HttpTestConfig{ - Url: "http://127.0.0.1:53843/apiNew", - IsHtml: true, - RequiredContent: []string{"URL=./login"}, - ExcludedContent: []string{"URL=./apiKeys"}, - Cookies: []test.Cookie{{ - Name: "session_token", - Value: "invalid", - }}, - }) - amountKeysAfter = len(database.GetAllApiKeys()) - test.IsEqualInt(t, amountKeysAfter, amountKeys) - test.IsEqualInt(t, amountKeysAfter, 5) -} - -func TestDeleteApiKey(t *testing.T) { - // Not authorised - amountKeys := len(database.GetAllApiKeys()) - test.HttpPageResult(t, test.HttpTestConfig{ - Url: "http://127.0.0.1:53843/apiDelete?id=jiREglQJW0bOqJakfjdVfe8T1EM8n8", - IsHtml: true, - RequiredContent: []string{"URL=./login"}, - ExcludedContent: []string{"URL=./apiKeys"}, - Cookies: []test.Cookie{{ - Name: "session_token", - Value: "invalid", - }}, - }) - amountKeysAfter := len(database.GetAllApiKeys()) - key, ok := database.GetApiKey("jiREglQJW0bOqJakfjdVfe8T1EM8n8") - test.IsEqualBool(t, ok, true) - test.IsEqualString(t, key.Id, "jiREglQJW0bOqJakfjdVfe8T1EM8n8") - test.IsEqualInt(t, amountKeysAfter, amountKeys) - test.IsEqualInt(t, amountKeysAfter, 5) - - // Authorised - test.HttpPageResult(t, test.HttpTestConfig{ - Url: "http://127.0.0.1:53843/apiDelete?id=jiREglQJW0bOqJakfjdVfe8T1EM8n8", - IsHtml: true, - RequiredContent: []string{"URL=./apiKeys"}, - ExcludedContent: []string{"URL=./login"}, - Cookies: []test.Cookie{{ - Name: "session_token", - Value: "validsession", - }}, - }) - amountKeysAfter = len(database.GetAllApiKeys()) - _, ok = database.GetApiKey("jiREglQJW0bOqJakfjdVfe8T1EM8n8") - test.IsEqualBool(t, ok, false) - test.IsEqualInt(t, amountKeysAfter, amountKeys-1) - test.IsEqualInt(t, amountKeysAfter, 4) -} - func TestProcessApi(t *testing.T) { // Not authorised test.HttpPageResult(t, test.HttpTestConfig{ @@ -652,11 +536,12 @@ func TestProcessApi(t *testing.T) { Headers: []test.Header{{"apikey", "invalid"}}, }) - // Authorised + // Valid session does not grant API access test.HttpPageResult(t, test.HttpTestConfig{ Url: "http://127.0.0.1:53843/api/files/list", - RequiredContent: []string{"smallfile2"}, - ExcludedContent: []string{"Unauthorized"}, + RequiredContent: []string{"{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}"}, + ExcludedContent: []string{"smallfile2"}, + ResultCode: 401, Cookies: []test.Cookie{{ Name: "session_token", Value: "validsession", diff --git a/internal/webserver/api/Api.go b/internal/webserver/api/Api.go index d0c7df51..c3715e84 100644 --- a/internal/webserver/api/Api.go +++ b/internal/webserver/api/Api.go @@ -8,8 +8,6 @@ import ( "github.com/forceu/gokapi/internal/helper" "github.com/forceu/gokapi/internal/models" "github.com/forceu/gokapi/internal/storage" - "github.com/forceu/gokapi/internal/webserver/authentication" - "github.com/forceu/gokapi/internal/webserver/authentication/sessionmanager" "github.com/forceu/gokapi/internal/webserver/fileupload" "net/http" "strconv" @@ -46,6 +44,8 @@ func Process(w http.ResponseWriter, r *http.Request, maxMemory int) { changeFriendlyName(w, request) case "/auth/modify": modifyApiPermission(w, request) + case "/auth/delete": + deleteApiKey(w, request) default: sendError(w, http.StatusBadRequest, "Invalid request") } @@ -122,6 +122,8 @@ func getApiPermissionRequired(requestUrl string) (uint8, bool) { return models.ApiPermApiMod, true case "/auth/modify": return models.ApiPermApiMod, true + case "/auth/delete": + return models.ApiPermApiMod, true default: return models.ApiPermNone, false } @@ -143,6 +145,8 @@ func NewKey(defaultPermissions bool) string { FriendlyName: "Unnamed key", LastUsed: 0, Permissions: models.ApiPermAllNoApiMod, + Expiry: 0, + IsSystemKey: false, } if !defaultPermissions { newKey.Permissions = models.ApiPermNone @@ -151,6 +155,38 @@ func NewKey(defaultPermissions bool) string { return newKey.Id } +// newSystemKey generates a new API key that is only used internally for the GUI +// and will be valid for 48 hours +func newSystemKey() string { + newKey := models.ApiKey{ + Id: helper.GenerateRandomString(30), + FriendlyName: "Internal System Key", + LastUsed: 0, + Permissions: models.ApiPermAll, + Expiry: time.Now().Add(time.Hour * 48).Unix(), + IsSystemKey: true, + } + database.SaveApiKey(newKey) + return newKey.Id +} + +// GetSystemKey returns the latest System API key or generates a new one, if none exists or the current one expires +// within the next 24 hours +func GetSystemKey() string { + key, ok := database.GetSystemKey() + if !ok || key.Expiry < time.Now().Add(time.Hour*24).Unix() { + return newSystemKey() + } + return key.Id +} + +func deleteApiKey(w http.ResponseWriter, request apiRequest) { + if !isValidKeyForEditing(w, request) { + return + } + DeleteKey(request.apiInfo.apiKeyToModify) +} + func modifyApiPermission(w http.ResponseWriter, request apiRequest) { if !isValidKeyForEditing(w, request) { return @@ -184,7 +220,7 @@ func isValidKeyForEditing(w http.ResponseWriter, request apiRequest) bool { } func createApiKey(w http.ResponseWriter, request apiRequest) { - key := NewKey(false) + key := NewKey(request.apiInfo.basicPermissions) output := models.ApiKeyOutput{ Result: "OK", Id: key, @@ -283,7 +319,7 @@ func upload(w http.ResponseWriter, request apiRequest, maxMemory int) { } request.request.Body = http.MaxBytesReader(w, request.request.Body, maxUpload) - err := fileupload.Process(w, request.request, false, maxMemory) + err := fileupload.Process(w, request.request, maxMemory) if err != nil { sendError(w, http.StatusBadRequest, err.Error()) return @@ -329,10 +365,7 @@ func isAuthorisedForApi(w http.ResponseWriter, request apiRequest) bool { sendError(w, http.StatusBadRequest, "Invalid request") return false } - config := configuration.Get() - isOauth := config.Authentication.Method == authentication.OAuth2 - interval := config.Authentication.OAuthRecheckInterval - if IsValidApiKey(request.apiKey, true, perm) || sessionmanager.IsValidSession(w, request.request, isOauth, interval) { + if IsValidApiKey(request.apiKey, true, perm) { return true } sendError(w, http.StatusUnauthorized, "Unauthorized") @@ -384,10 +417,11 @@ type fileInfo struct { } type apiInfo struct { - friendlyName string - apiKeyToModify string - permission uint8 - grantPermission bool + friendlyName string + apiKeyToModify string + permission uint8 + grantPermission bool + basicPermissions bool } type filemodInfo struct { id string @@ -424,10 +458,12 @@ func parseRequest(r *http.Request) apiRequest { originalPassword: r.Header.Get("originalPassword") == "true", }, apiInfo: apiInfo{ - friendlyName: r.Header.Get("friendlyName"), - apiKeyToModify: r.Header.Get("apiKeyToModify"), - permission: uint8(permission), - grantPermission: r.Header.Get("permissionModifier") == "GRANT"}, + friendlyName: r.Header.Get("friendlyName"), + apiKeyToModify: r.Header.Get("apiKeyToModify"), + permission: uint8(permission), + grantPermission: r.Header.Get("permissionModifier") == "GRANT", + basicPermissions: r.Header.Get("basicPermissions") == "true", + }, } } @@ -494,7 +530,7 @@ func IsValidApiKey(key string, modifyTime bool, requiredPermission uint8) bool { return false } savedKey, ok := database.GetApiKey(key) - if ok && savedKey.Id != "" { + if ok && savedKey.Id != "" && (savedKey.Expiry == 0 || savedKey.Expiry > time.Now().Unix()) { if modifyTime { savedKey.LastUsed = time.Now().Unix() database.UpdateTimeApiKey(savedKey) diff --git a/internal/webserver/api/Api_test.go b/internal/webserver/api/Api_test.go index b5aaf480..cee1eba9 100644 --- a/internal/webserver/api/Api_test.go +++ b/internal/webserver/api/Api_test.go @@ -30,8 +30,6 @@ func TestMain(m *testing.M) { os.Exit(exitVal) } -// TODO test new permission system - const maxMemory = 20 var newKeyId string @@ -62,22 +60,212 @@ func TestDeleteKey(t *testing.T) { } func TestIsValidApiKey(t *testing.T) { - test.IsEqualBool(t, IsValidApiKey("", false, models.ApiPermNone), false) // TODO permission - test.IsEqualBool(t, IsValidApiKey("invalid", false, models.ApiPermNone), false) // TODO permission - test.IsEqualBool(t, IsValidApiKey("validkey", false, models.ApiPermNone), true) // TODO permission + test.IsEqualBool(t, IsValidApiKey("", false, models.ApiPermNone), false) + test.IsEqualBool(t, IsValidApiKey("invalid", false, models.ApiPermNone), false) + test.IsEqualBool(t, IsValidApiKey("validkey", false, models.ApiPermNone), true) key, ok := database.GetApiKey("validkey") test.IsEqualBool(t, ok, true) test.IsEqualBool(t, key.LastUsed == 0, true) - test.IsEqualBool(t, IsValidApiKey("validkey", true, models.ApiPermNone), true) // TODO permission + test.IsEqualBool(t, IsValidApiKey("validkey", true, models.ApiPermNone), true) key, ok = database.GetApiKey("validkey") test.IsEqualBool(t, ok, true) test.IsEqualBool(t, key.LastUsed == 0, false) + + newApiKey := NewKey(false) + test.IsEqualBool(t, IsValidApiKey(newApiKey, true, models.ApiPermNone), true) + for _, permission := range getAvailablePermissions(t) { + test.IsEqualBool(t, IsValidApiKey(newApiKey, true, permission), false) + } + for _, newPermission := range getAvailablePermissions(t) { + setPermissionApikey(newApiKey, newPermission, t) + for _, permission := range getAvailablePermissions(t) { + test.IsEqualBool(t, IsValidApiKey(newApiKey, true, permission), permission == newPermission) + } + } + setPermissionApikey(newApiKey, models.ApiPermEdit|models.ApiPermDelete, t) + test.IsEqualBool(t, IsValidApiKey(newApiKey, true, models.ApiPermEdit), true) + test.IsEqualBool(t, IsValidApiKey(newApiKey, true, models.ApiPermAll), false) + test.IsEqualBool(t, IsValidApiKey(newApiKey, true, models.ApiPermView), false) +} + +func setPermissionApikey(key string, newPermission uint8, t *testing.T) { + apiKey, ok := database.GetApiKey(key) + test.IsEqualBool(t, ok, true) + apiKey.Permissions = newPermission + database.SaveApiKey(apiKey) +} + +func getAvailablePermissions(t *testing.T) []uint8 { + result := []uint8{models.ApiPermView, models.ApiPermUpload, models.ApiPermDelete, models.ApiPermApiMod, models.ApiPermEdit} + sum := 0 + for _, perm := range result { + sum = sum + int(perm) + } + if sum != models.ApiPermAll { + t.Fatal("List of permissions are incorrect") + } + return result +} + +func TestGetSystemKey(t *testing.T) { + keys := database.GetAllApiKeys() + for _, key := range keys { + if key.IsSystemKey { + t.Error("No system key expected, but found") + } + } + systemKey := GetSystemKey() + retrievedSystemKey, ok := database.GetApiKey(systemKey) + test.IsEqualBool(t, ok, true) + test.IsEqualBool(t, retrievedSystemKey.IsSystemKey, true) + test.IsEqualBool(t, retrievedSystemKey.Permissions == models.ApiPermAll, true) + test.IsEqualBool(t, retrievedSystemKey.Expiry > time.Now().Add(time.Hour*47).Unix(), true) + newKey := GetSystemKey() + test.IsEqualBool(t, systemKey == newKey, true) + retrievedSystemKey.Expiry = time.Now().Add(time.Hour * 23).Unix() + database.SaveApiKey(retrievedSystemKey) + newKey = GetSystemKey() + test.IsEqualBool(t, systemKey != newKey, true) +} + +func TestDelete(t *testing.T) { + database.SaveApiKey(models.ApiKey{ + Id: "toDelete", + }) + _, ok := database.GetApiKey("toDelete") + test.IsEqualBool(t, ok, true) + + w, r := test.GetRecorder("GET", "/auth/delete", nil, []test.Header{{ + Name: "apikey", + Value: "invalid", + }}, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") + w, r = test.GetRecorder("GET", "/auth/delete", nil, []test.Header{{ + Name: "apiKeyToModify", + Value: "toDelete", + }}, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") + + w, r = test.GetRecorder("GET", "/auth/delete", nil, []test.Header{{ + Name: "apiKeyToModify", + Value: "toDelete", + }, { + Name: "apikey", + Value: getNewKeyWithPermissionMissing(t, models.ApiPermApiMod).Id, + }}, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") + + w, r = test.GetRecorder("GET", "/auth/delete", nil, []test.Header{{ + Name: "apiKeyToModify", + Value: "toDelete", + }, { + Name: "apikey", + Value: getNewKeyWithAllPermissions(t).Id, + }}, nil) + Process(w, r, maxMemory) + test.IsEqualInt(t, w.Code, 200) + _, ok = database.GetApiKey("toDelete") + test.IsEqualBool(t, ok, false) + + w, r = test.GetRecorder("GET", "/auth/delete", nil, []test.Header{{ + Name: "apiKeyToModify", + Value: "toDelete", + }, { + Name: "apikey", + Value: "validkey", + }}, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Invalid api key provided.\"}") +} + +func getNewKeyWithAllPermissions(t *testing.T) models.ApiKey { + validKey, ok := database.GetApiKey(NewKey(false)) + test.IsEqualBool(t, ok, true) + validKey.SetPermission(models.ApiPermAll) + database.SaveApiKey(validKey) + return validKey +} + +func getNewKeyWithPermissionMissing(t *testing.T, removePerm uint8) models.ApiKey { + validKey, ok := database.GetApiKey(NewKey(false)) + test.IsEqualBool(t, ok, true) + validKey.SetPermission(models.ApiPermAll) + validKey.RemovePermission(removePerm) + database.SaveApiKey(validKey) + return validKey +} + +func countApiKeys() int { + return len(database.GetAllApiKeys()) +} + +func TestNewApiKey(t *testing.T) { + keysBefore := countApiKeys() + w, r := test.GetRecorder("GET", "/auth/create", nil, []test.Header{{ + Name: "apikey", + Value: "invalid", + }}, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") + w, r = test.GetRecorder("GET", "/auth/create", nil, nil, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") + + w, r = test.GetRecorder("GET", "/auth/create", nil, []test.Header{{ + Name: "apikey", + Value: "validkey", + }, { + Name: "friendlyName", + Value: "New Key", + }}, nil) + Process(w, r, maxMemory) + test.IsEqualInt(t, w.Code, 200) + keysAfter := countApiKeys() + test.IsEqualInt(t, keysAfter, keysBefore+1) + var result models.ApiKeyOutput + err := json.Unmarshal(w.Body.Bytes(), &result) + test.IsNil(t, err) + + newKey, ok := database.GetApiKey(result.Id) + test.IsEqualBool(t, ok, true) + test.IsEqualString(t, newKey.FriendlyName, "New Key") + + w, r = test.GetRecorder("GET", "/auth/create", nil, []test.Header{{ + Name: "apikey", + Value: "validkey", + }}, nil) + Process(w, r, maxMemory) + test.IsEqualInt(t, w.Code, 200) + keysAfter = countApiKeys() + test.IsEqualInt(t, keysAfter, keysBefore+2) + err = json.Unmarshal(w.Body.Bytes(), &result) + test.IsNil(t, err) + + newKey, ok = database.GetApiKey(result.Id) + test.IsEqualBool(t, ok, true) + test.IsEqualString(t, newKey.FriendlyName, "Unnamed key") + + w, r = test.GetRecorder("GET", "/auth/create", nil, []test.Header{{ + Name: "apikey", + Value: getNewKeyWithPermissionMissing(t, models.ApiPermApiMod).Id, + }}, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") } func TestProcess(t *testing.T) { w, r := test.GetRecorder("GET", "/api/auth/friendlyname", nil, nil, nil) Process(w, r, maxMemory) test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") + w, r = test.GetRecorder("GET", "/api/auth/friendlyname", []test.Cookie{{ + Name: "session_token", + Value: "validsession", + }}, nil, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") w, r = test.GetRecorder("GET", "/api/invalid", nil, nil, nil) Process(w, r, maxMemory) test.ResponseBodyContains(t, w, "Invalid request") @@ -87,12 +275,6 @@ func TestProcess(t *testing.T) { }}, nil) Process(w, r, maxMemory) test.ResponseBodyContains(t, w, "Invalid request") - w, r = test.GetRecorder("GET", "/api/invalid", []test.Cookie{{ - Name: "session_token", - Value: "validsession", - }}, nil, nil) - Process(w, r, maxMemory) - test.ResponseBodyContains(t, w, "Invalid request") } func TestAuthDisabledLogin(t *testing.T) { @@ -134,6 +316,13 @@ func TestChangeFriendlyName(t *testing.T) { w = httptest.NewRecorder() Process(w, r, maxMemory) test.IsEqualInt(t, w.Code, 200) + + w, r = test.GetRecorder("GET", "/api/auth/friendlyname", nil, []test.Header{{ + Name: "apikey", Value: getNewKeyWithPermissionMissing(t, models.ApiPermApiMod).Id}, { + Name: "apiKeyToModify", Value: "validkey"}, { + Name: "friendlyName", Value: "NewName2"}}, nil) + Process(w, r, maxMemory) + test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}") } func TestDeleteFile(t *testing.T) { diff --git a/internal/webserver/fileupload/FileUpload.go b/internal/webserver/fileupload/FileUpload.go index 91cc4e9a..fedae673 100644 --- a/internal/webserver/fileupload/FileUpload.go +++ b/internal/webserver/fileupload/FileUpload.go @@ -13,13 +13,13 @@ import ( ) // Process processes a file upload request -func Process(w http.ResponseWriter, r *http.Request, isWeb bool, maxMemory int) error { +func Process(w http.ResponseWriter, r *http.Request, maxMemory int) error { err := r.ParseMultipartForm(int64(maxMemory) * 1024 * 1024) if err != nil { return err } defer r.MultipartForm.RemoveAll() - config, err := parseConfig(r.Form, isWeb) + config, err := parseConfig(r.Form, false) if err != nil { return err } diff --git a/internal/webserver/fileupload/FileUpload_test.go b/internal/webserver/fileupload/FileUpload_test.go index 221a3ace..b63ad697 100644 --- a/internal/webserver/fileupload/FileUpload_test.go +++ b/internal/webserver/fileupload/FileUpload_test.go @@ -80,12 +80,12 @@ func TestParseConfig(t *testing.T) { func TestProcess(t *testing.T) { w, r := test.GetRecorder("POST", "/upload", nil, nil, strings.NewReader("invalid§$%&%§")) - err := Process(w, r, false, 20) + err := Process(w, r, 20) test.IsNotNil(t, err) w = httptest.NewRecorder() r = getFileUploadRecorder(false) - err = Process(w, r, false, 20) + err = Process(w, r, 20) test.IsNil(t, err) resp := w.Result() body, _ := io.ReadAll(resp.Body) diff --git a/internal/webserver/web/static/apidocumentation/openapi.json b/internal/webserver/web/static/apidocumentation/openapi.json index 40a86a89..aa1fcd8e 100644 --- a/internal/webserver/web/static/apidocumentation/openapi.json +++ b/internal/webserver/web/static/apidocumentation/openapi.json @@ -15,9 +15,6 @@ { "apikey": ["VIEW","UPLOAD","DELETE", "API_MANAGE"] }, - { - "session": [] - } ], "tags": [ { @@ -40,9 +37,6 @@ { "apikey": ["VIEW"] }, - { - "session": [] - } ], "responses": { "200": { @@ -80,9 +74,6 @@ { "apikey": ["UPLOAD"] }, - { - "session": [] - } ], "requestBody": { "content": { @@ -126,9 +117,6 @@ { "apikey": ["UPLOAD"] }, - { - "session": [] - } ], "requestBody": { "content": { @@ -172,9 +160,6 @@ { "apikey": ["UPLOAD"] }, - { - "session": [] - } ], "requestBody": { "content": { @@ -218,9 +203,6 @@ { "apikey": ["VIEW","UPLOAD"] }, - { - "session": [] - } ], "requestBody": { "content": { @@ -264,9 +246,6 @@ { "apikey": ["API_EDIT"] }, - { - "session": [] - } ], "parameters": [ { @@ -346,9 +325,6 @@ "security": [ { "apikey": ["DELETE"] - }, - { - "session": [] } ], "parameters": [ @@ -383,14 +359,11 @@ "auth" ], "summary": "Creates a new API key", - "description": "This API call returns a new API key. The new key does not have any permissions. Requires permission API_MOD", + "description": "This API call returns a new API key. The new key does not have any permissions, unless specified. Requires permission API_MOD", "operationId": "create", "security": [ { "apikey": ["API_MANAGE"] - }, - { - "session": [] } ], "parameters": [ @@ -404,6 +377,17 @@ "schema": { "type": "string" } + }, + { + "name": "basicPermissions", + "in": "header", + "description": "If true, basic permissions are automatically granted", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -434,9 +418,6 @@ "security": [ { "apikey": ["API_MANAGE"] - }, - { - "session": [] } ], "parameters": [ @@ -487,9 +468,6 @@ "security": [ { "apikey": ["API_MANAGE"] - }, - { - "session": [] } ], "parameters": [ @@ -541,6 +519,45 @@ } } } + }, + "/auth/delete": { + "delete": { + "tags": [ + "auth" + ], + "summary": "Deletes an API key", + "description": "This API call deletes the given API key. Requires permission API_MOD", + "operationId": "apidelete", + "security": [ + { + "apikey": ["API_MANAGE"] + } + ], + "parameters": [ + { + "name": "apiKeyToModify", + "in": "header", + "description": "The API key to delete", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Operation successful" + }, + "400": { + "description": "Invalid ID supplied" + }, + "401": { + "description": "Invalid API key provided or not logged in as admin" + } + } + } } }, "components": { @@ -812,11 +829,6 @@ "type": "apiKey", "name": "apikey", "in": "header" - }, - "session": { - "type": "apiKey", - "name": "session_token", - "in": "cookie" } } } diff --git a/internal/webserver/web/static/css/cover.css b/internal/webserver/web/static/css/cover.css index a5aa218c..8b66f827 100644 --- a/internal/webserver/web/static/css/cover.css +++ b/internal/webserver/web/static/css/cover.css @@ -189,7 +189,7 @@ a:hover { color: #7e7e7e; } .apiperm-processing { - color: #929611; + color: #e5eb00; } .gokapi-dialog { diff --git a/internal/webserver/web/static/css/min/gokapi.min.2.css b/internal/webserver/web/static/css/min/gokapi.min.2.css index 91e263ed..b673b667 100644 --- a/internal/webserver/web/static/css/min/gokapi.min.2.css +++ b/internal/webserver/web/static/css/min/gokapi.min.2.css @@ -1 +1 @@ -.btn-secondary,.btn-secondary:hover,.btn-secondary:focus{color:#333;text-shadow:none}body{background:url(../../assets/background.jpg)no-repeat 50% fixed;-webkit-background-size:cover;-moz-background-size:cover;-o-background-size:cover;background-size:cover;display:-ms-flexbox;display:-webkit-box;display:flex;-ms-flex-pack:center;-webkit-box-pack:center;justify-content:center}td{vertical-align:middle;position:relative}a{color:inherit}a:hover{color:inherit;filter:brightness(80%)}.dropzone{background:#2f343a!important;color:#fff;border-radius:5px}.dropzone:hover{background:#33393f!important;color:#fff;border-radius:5px}.card{margin:0 auto;float:none;margin-bottom:10px;border:2px solid #33393f}.card-body{background-color:#212529;color:#ddd}.card-title{font-weight:900}.admin-input{text-align:center}.form-control:disabled{background:#bababa}.break{flex-basis:100%;height:0}.bd-placeholder-img{font-size:1.125rem;text-anchor:middle;-webkit-user-select:none;-moz-user-select:none;user-select:none}@media(min-width:768px){.bd-placeholder-img-lg{font-size:3.5rem}.break{flex-basis:0}}.masthead{margin-bottom:2rem}.masthead-brand{margin-bottom:0}.nav-masthead .nav-link{padding:.25rem 0;font-weight:700;color:rgba(255,255,255,.5);background-color:initial;border-bottom:.25rem solid transparent}.nav-masthead .nav-link:hover,.nav-masthead .nav-link:focus{border-bottom-color:rgba(255,255,255,.25)}.nav-masthead .nav-link+.nav-link{margin-left:1rem}.nav-masthead .active{color:#fff;border-bottom-color:#fff}#qroverlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,.3)}#qrcode{position:absolute;top:50%;left:50%;margin-top:-105px;margin-left:-105px;width:210px;height:210px;border:5px solid #fff}.toastnotification{pointer-events:none;position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background-color:#333;color:#fff;padding:15px;border-radius:5px;box-shadow:0 2px 5px rgba(0,0,0,.3);opacity:0;transition:opacity .3s ease-in-out;z-index:9999}.toastnotification.show{opacity:1;pointer-events:auto}.apiperm-granted{cursor:pointer;color:#19b90e}.apiperm-notgranted{cursor:pointer;color:#7e7e7e}.apiperm-processing{color:#929611}.gokapi-dialog{background-color:#212529;color:#ddd}.filename{font-weight:700;font-size:14px;margin-bottom:5px}.upload-progress-container{display:flex;align-items:center}.upload-progress-bar{position:relative;height:10px;background-color:#eee;flex:1;margin-right:10px;border-radius:4px}.upload-progress-bar-progress{position:absolute;top:0;left:0;height:100%;background-color:#0a0;border-radius:4px;transition:width .2s ease-in-out}.upload-progress-info{font-size:12px}.us-container{margin-top:10px;margin-bottom:20px}.uploaderror{font-weight:700;color:red;margin-bottom:5px}.uploads-container{background-color:#2f343a;border:2px solid rgba(0,0,0,.3);border-radius:5px;margin-left:0;margin-right:0;max-width:none;visibility:hidden} \ No newline at end of file +.btn-secondary,.btn-secondary:hover,.btn-secondary:focus{color:#333;text-shadow:none}body{background:url(../../assets/background.jpg)no-repeat 50% fixed;-webkit-background-size:cover;-moz-background-size:cover;-o-background-size:cover;background-size:cover;display:-ms-flexbox;display:-webkit-box;display:flex;-ms-flex-pack:center;-webkit-box-pack:center;justify-content:center}td{vertical-align:middle;position:relative}a{color:inherit}a:hover{color:inherit;filter:brightness(80%)}.dropzone{background:#2f343a!important;color:#fff;border-radius:5px}.dropzone:hover{background:#33393f!important;color:#fff;border-radius:5px}.card{margin:0 auto;float:none;margin-bottom:10px;border:2px solid #33393f}.card-body{background-color:#212529;color:#ddd}.card-title{font-weight:900}.admin-input{text-align:center}.form-control:disabled{background:#bababa}.break{flex-basis:100%;height:0}.bd-placeholder-img{font-size:1.125rem;text-anchor:middle;-webkit-user-select:none;-moz-user-select:none;user-select:none}@media(min-width:768px){.bd-placeholder-img-lg{font-size:3.5rem}.break{flex-basis:0}}.masthead{margin-bottom:2rem}.masthead-brand{margin-bottom:0}.nav-masthead .nav-link{padding:.25rem 0;font-weight:700;color:rgba(255,255,255,.5);background-color:initial;border-bottom:.25rem solid transparent}.nav-masthead .nav-link:hover,.nav-masthead .nav-link:focus{border-bottom-color:rgba(255,255,255,.25)}.nav-masthead .nav-link+.nav-link{margin-left:1rem}.nav-masthead .active{color:#fff;border-bottom-color:#fff}#qroverlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,.3)}#qrcode{position:absolute;top:50%;left:50%;margin-top:-105px;margin-left:-105px;width:210px;height:210px;border:5px solid #fff}.toastnotification{pointer-events:none;position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background-color:#333;color:#fff;padding:15px;border-radius:5px;box-shadow:0 2px 5px rgba(0,0,0,.3);opacity:0;transition:opacity .3s ease-in-out;z-index:9999}.toastnotification.show{opacity:1;pointer-events:auto}.apiperm-granted{cursor:pointer;color:#19b90e}.apiperm-notgranted{cursor:pointer;color:#7e7e7e}.apiperm-processing{color:#e5eb00}.gokapi-dialog{background-color:#212529;color:#ddd}.filename{font-weight:700;font-size:14px;margin-bottom:5px}.upload-progress-container{display:flex;align-items:center}.upload-progress-bar{position:relative;height:10px;background-color:#eee;flex:1;margin-right:10px;border-radius:4px}.upload-progress-bar-progress{position:absolute;top:0;left:0;height:100%;background-color:#0a0;border-radius:4px;transition:width .2s ease-in-out}.upload-progress-info{font-size:12px}.us-container{margin-top:10px;margin-bottom:20px}.uploaderror{font-weight:700;color:red;margin-bottom:5px}.uploads-container{background-color:#2f343a;border:2px solid rgba(0,0,0,.3);border-radius:5px;margin-left:0;margin-right:0;max-width:none;visibility:hidden} \ No newline at end of file diff --git a/internal/webserver/web/static/js/admin.js b/internal/webserver/web/static/js/admin.js index 37c1477f..f4c6e7b1 100644 --- a/internal/webserver/web/static/js/admin.js +++ b/internal/webserver/web/static/js/admin.js @@ -249,6 +249,7 @@ function editFile() { headers: { 'Content-Type': 'application/json', 'id': button.getAttribute('data-fileid'), + 'apikey': systemKey, 'allowedDownloads': allowedDownloads, 'expiryTimestamp': expiryTimestamp, 'password': password, @@ -393,6 +394,7 @@ function changeApiPermission(apiKey, permission, buttonId) { method: 'POST', headers: { 'Content-Type': 'application/json', + 'apikey': systemKey, 'apiKeyToModify': apiKey, 'permission': permission, 'permissionModifier': modifier @@ -400,7 +402,6 @@ function changeApiPermission(apiKey, permission, buttonId) { }, }; - // Send the request fetch(apiUrl, requestOptions) .then(response => { if (!response.ok) { @@ -427,6 +428,93 @@ function changeApiPermission(apiKey, permission, buttonId) { }); } +function deleteApiKey(apiKey) { + + document.getElementById("delete-"+apiKey).disabled = true; + var apiUrl = './api/auth/delete'; + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': systemKey, + 'apiKeyToModify': apiKey, + }, + }; + + fetch(apiUrl, requestOptions) + .then(response => { + if (!response.ok) { + throw new Error(`Request failed with status: ${response.status}`); + } + }) + .then(data => { + document.getElementById("row-"+apiKey).remove(); + }) + .catch(error => { + alert("Unable to delete API key: " + error); + console.error('Error:', error); + }); +} + + + +function newApiKey() { + + document.getElementById("button-newapi").disabled = true; + var apiUrl = './api/auth/create'; + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': systemKey, + 'basicPermissions': 'true' + }, + }; + + fetch(apiUrl, requestOptions) + .then(response => { + if (!response.ok) { + throw new Error(`Request failed with status: ${response.status}`); + } + }) + .then(data => { + location.reload(); + }) + .catch(error => { + alert("Unable to create API key: " + error); + console.error('Error:', error); + }); +} + + +function deleteFile(id) { + + document.getElementById("button-delete-"+id).disabled = true; + var apiUrl = './api/files/delete'; + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': systemKey, + 'id': id + }, + }; + + fetch(apiUrl, requestOptions) + .then(response => { + if (!response.ok) { + throw new Error(`Request failed with status: ${response.status}`); + } + }) + .then(data => { + location.reload(); + }) + .catch(error => { + alert("Unable to delete file: " + error); + console.error('Error:', error); + }); +} + function checkBoxChanged(checkBox, correspondingInput) { let disable = !checkBox.checked; @@ -550,6 +638,7 @@ function addRow(jsonText) { let item = jsonObject.FileInfo; let table = document.getElementById("downloadtable"); let row = table.insertRow(0); + row.id = "row-"+ item.Id; let cellFilename = row.insertCell(0); let cellFileSize = row.insertCell(1); let cellRemainingDownloads = row.insertCell(2); @@ -586,7 +675,7 @@ function addRow(jsonText) { } buttons = buttons + ' '; buttons = buttons + ' '; - buttons = buttons + ''; + buttons = buttons + ''; cellButtons.innerHTML = buttons; diff --git a/internal/webserver/web/static/js/min/admin.min.5.js b/internal/webserver/web/static/js/min/admin.min.5.js deleted file mode 100644 index 24e4e17f..00000000 --- a/internal/webserver/web/static/js/min/admin.min.5.js +++ /dev/null @@ -1 +0,0 @@ -var dropzoneObject,calendarInstance,statusItemCount,clipboard=new ClipboardJS(".btn"),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 70MB."):showError(e,"Server responded with code "+n.status)}),this.on("uploadprogress",function(e,t,n){updateProgressbar(e,t,n)}),isE2EEnabled&&(dropzoneObject.disable(),dropzoneObject.options.dictDefaultMessage="Loading end-to-end encryption...",document.getElementsByClassName("dz-button")[0].innerText="Loading end-to-end encryption...",setE2eUpload())}};function updateProgressbar(e,t,n){let o=e.upload.uuid,i=document.getElementById(`us-container-${o}`);if(i==null||i.getAttribute("data-complete")==="true")return;let s=Math.round(t);s<0&&(s=0),s>100&&(s=100);let r=Date.now()-i.getAttribute("data-starttime"),c=n/(r/1e3)/1024/1024;document.getElementById(`us-progressbar-${o}`).style.width=s+"%";let a=Math.round(c*10)/10;Number.isNaN(a)||(document.getElementById(`us-progress-info-${o}`).innerText=s+"% - "+a+"MB/s")}function 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){var s=new XMLHttpRequest;s.open("POST","./uploadComplete",!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){let n=addRow(s.response);if(e.isEndToEndEncrypted===!0){try{let s=GokapiE2EAddFile(e.upload.uuid,n,e.name);if(s instanceof Error)throw s;let t=GokapiE2EInfoEncrypt();if(t instanceof Error)throw t;storeE2EInfo(t)}catch(t){e.accepted=!1,dropzoneObject._errorProcessing([e],t);return}GokapiE2EDecryptMenu()}removeFileStatus(e.upload.uuid),t()}else{e.accepted=!1;let t=getErrorMessage(s.responseText);dropzoneObject._errorProcessing([e],t),showError(e,t)}},s.send(urlencodeFormData(n))}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 editFile(){const e=document.getElementById("mb_save");e.disabled=!0;let i="./api/files/modify",n=document.getElementById("mi_edit_down").value,s=document.getElementById("mi_edit_expiry").value,t=document.getElementById("mi_edit_pw").value,o=t==="(unchanged)";document.getElementById("mc_download").checked||(n=0),document.getElementById("mc_expiry").checked||(s=0),document.getElementById("mc_password").checked||(o=!1,t="");const a={method:"PUT",headers:{"Content-Type":"application/json",id:e.getAttribute("data-fileid"),allowedDownloads:n,expiryTimestamp:s,password:t,originalPassword:o}};fetch(i,a).then(e=>{if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}).then(e=>{location.reload()}).catch(t=>{alert("Unable to edit file: "+t),console.error("Error:",t),e.disabled=!1})}calendarInstance=null;function createCalendar(e){const t=new Date(e*1e3);calendarInstance=flatpickr("#mi_edit_expiry",{enableTime:!0,dateFormat:"U",altInput:!0,altFormat:"Y-m-d H:i",allowInput:!0,time_24hr:!0,defaultDate:t,minDate:"today"})}function handleEditCheckboxChange(e){var t=document.getElementById(e.getAttribute("data-toggle-target")),n=e.getAttribute("data-timestamp");e.checked?(t.classList.remove("disabled"),t.removeAttribute("disabled"),n!=null&&(calendarInstance._input.disabled=!1)):(n!=null&&(calendarInstance._input.disabled=!0),t.classList.add("disabled"),t.setAttribute("disabled",!0))}function showEditModal(e,t,n,s,o,i,a){document.getElementById("m_filenamelabel").innerHTML=e,document.getElementById("mc_expiry").setAttribute("data-timestamp",s),document.getElementById("mb_save").setAttribute("data-fileid",t),createCalendar(s),i?(document.getElementById("mi_edit_down").value="1",document.getElementById("mi_edit_down").disabled=!0,document.getElementById("mc_download").checked=!1):(document.getElementById("mi_edit_down").value=n,document.getElementById("mi_edit_down").disabled=!1,document.getElementById("mc_download").checked=!0),a?(document.getElementById("mi_edit_expiry").value=add14DaysIfBeforeCurrentTime(s),document.getElementById("mi_edit_expiry").disabled=!0,document.getElementById("mc_expiry").checked=!1,calendarInstance._input.disabled=!0):(document.getElementById("mi_edit_expiry").value=s,document.getElementById("mi_edit_expiry").disabled=!1,document.getElementById("mc_expiry").checked=!0,calendarInstance._input.disabled=!1),o?(document.getElementById("mi_edit_pw").value="(unchanged)",document.getElementById("mi_edit_pw").disabled=!1,document.getElementById("mc_password").checked=!0):(document.getElementById("mi_edit_pw").value="",document.getElementById("mi_edit_pw").disabled=!0,document.getElementById("mc_password").checked=!1),new bootstrap.Modal("#modaledit",{}).show()}function selectTextForPw(e){e.value==="(unchanged)"&&e.setSelectionRange(0,e.value.length)}function add14DaysIfBeforeCurrentTime(e){let t=Date.now(),n=e*1e3;if(n{if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}).then(e=>{o?s.classList.add("apiperm-notgranted"):s.classList.add("apiperm-granted"),s.classList.remove("apiperm-processing")}).catch(e=>{o?s.classList.add("apiperm-granted"):s.classList.add("apiperm-notgranted"),s.classList.remove("apiperm-processing"),alert("Unable to set permission: "+e),console.error("Error:",e)})}function checkBoxChanged(e,t){let n=!e.checked;n?document.getElementById(t).setAttribute("disabled",""):document.getElementById(t).removeAttribute("disabled"),t==="password"&&n&&(document.getElementById("password").value="")}function 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");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,5e3)}}statusItemCount=0;function addFileStatus(e,t){const n=document.createElement("div");n.setAttribute("id",`us-container-${e}`),n.classList.add("us-container");const a=document.createElement("div");a.classList.add("filename"),a.textContent=t,n.appendChild(a);const s=document.createElement("div");s.classList.add("upload-progress-container"),s.setAttribute("id",`us-progress-container-${e}`);const r=document.createElement("div");r.classList.add("upload-progress-bar");const o=document.createElement("div");o.setAttribute("id",`us-progressbar-${e}`),o.classList.add("upload-progress-bar-progress"),o.style.width="0%",r.appendChild(o);const i=document.createElement("div");i.setAttribute("id",`us-progress-info-${e}`),i.classList.add("upload-progress-info"),i.textContent="0%",s.appendChild(r),s.appendChild(i),n.appendChild(s),n.setAttribute("data-starttime",Date.now()),n.setAttribute("data-complete","false");const c=document.getElementById("uploadstatus");c.appendChild(n),c.style.visibility="visible",statusItemCount++}function removeFileStatus(e){const t=document.getElementById(`us-container-${e}`);if(t==null)return;t.remove(),statusItemCount--,statusItemCount<1&&(document.getElementById("uploadstatus").style.visibility="hidden")}function addRow(e){let r=parseData(e);if(r.Result!=="OK"){alert("Failed to upload file!"),location.reload();return}let t=r.FileInfo,p=document.getElementById("downloadtable"),s=p.insertRow(0),i=s.insertCell(0),a=s.insertCell(1),o=s.insertCell(2),c=s.insertCell(3),l=s.insertCell(4),d=s.insertCell(5),u=s.insertCell(6),h="";t.IsPasswordProtected===!0&&(h=' '),i.innerText=t.Name,i.id="cell-name-"+t.Id,a.innerText=t.Size,t.UnlimitedDownloads?o.innerText="Unlimited":o.innerText=t.DownloadsRemaining,t.UnlimitedTime?c.innerText="Unlimited":c.innerText=t.ExpireAtString,l.innerHTML="0",d.innerHTML=''+t.Id+""+h;let n=' ';t.UrlHotlink===""?n=n+' ':n=n+' ',n=n+' `,n=n+` ',n=n+``,u.innerHTML=n,i.style.backgroundColor="green",a.style.backgroundColor="green",a.setAttribute("data-order",r.FileInfo.SizeBytes),o.style.backgroundColor="green",c.style.backgroundColor="green",l.style.backgroundColor="green",d.style.backgroundColor="green",u.style.backgroundColor="green";let m=$("#maintable").DataTable();rowCount==-1&&(rowCount=m.rows().count()),rowCount=rowCount+1,m.row.add(s);let f=document.getElementsByClassName("dataTables_empty")[0];return typeof f!="undefined"?f.innerText="Files stored: "+rowCount:document.getElementsByClassName("dataTables_info")[0].innerText="Files stored: "+rowCount,t.Id}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/static/js/min/admin.min.6.js b/internal/webserver/web/static/js/min/admin.min.6.js new file mode 100644 index 00000000..a982b881 --- /dev/null +++ b/internal/webserver/web/static/js/min/admin.min.6.js @@ -0,0 +1 @@ +var dropzoneObject,calendarInstance,statusItemCount,clipboard=new ClipboardJS(".btn"),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 70MB."):showError(e,"Server responded with code "+n.status)}),this.on("uploadprogress",function(e,t,n){updateProgressbar(e,t,n)}),isE2EEnabled&&(dropzoneObject.disable(),dropzoneObject.options.dictDefaultMessage="Loading end-to-end encryption...",document.getElementsByClassName("dz-button")[0].innerText="Loading end-to-end encryption...",setE2eUpload())}};function updateProgressbar(e,t,n){let o=e.upload.uuid,i=document.getElementById(`us-container-${o}`);if(i==null||i.getAttribute("data-complete")==="true")return;let s=Math.round(t);s<0&&(s=0),s>100&&(s=100);let r=Date.now()-i.getAttribute("data-starttime"),c=n/(r/1e3)/1024/1024;document.getElementById(`us-progressbar-${o}`).style.width=s+"%";let a=Math.round(c*10)/10;Number.isNaN(a)||(document.getElementById(`us-progress-info-${o}`).innerText=s+"% - "+a+"MB/s")}function 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){var s=new XMLHttpRequest;s.open("POST","./uploadComplete",!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){let n=addRow(s.response);if(e.isEndToEndEncrypted===!0){try{let s=GokapiE2EAddFile(e.upload.uuid,n,e.name);if(s instanceof Error)throw s;let t=GokapiE2EInfoEncrypt();if(t instanceof Error)throw t;storeE2EInfo(t)}catch(t){e.accepted=!1,dropzoneObject._errorProcessing([e],t);return}GokapiE2EDecryptMenu()}removeFileStatus(e.upload.uuid),t()}else{e.accepted=!1;let t=getErrorMessage(s.responseText);dropzoneObject._errorProcessing([e],t),showError(e,t)}},s.send(urlencodeFormData(n))}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 editFile(){const e=document.getElementById("mb_save");e.disabled=!0;let i="./api/files/modify",n=document.getElementById("mi_edit_down").value,s=document.getElementById("mi_edit_expiry").value,t=document.getElementById("mi_edit_pw").value,o=t==="(unchanged)";document.getElementById("mc_download").checked||(n=0),document.getElementById("mc_expiry").checked||(s=0),document.getElementById("mc_password").checked||(o=!1,t="");const a={method:"PUT",headers:{"Content-Type":"application/json",id:e.getAttribute("data-fileid"),apikey:systemKey,allowedDownloads:n,expiryTimestamp:s,password:t,originalPassword:o}};fetch(i,a).then(e=>{if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}).then(e=>{location.reload()}).catch(t=>{alert("Unable to edit file: "+t),console.error("Error:",t),e.disabled=!1})}calendarInstance=null;function createCalendar(e){const t=new Date(e*1e3);calendarInstance=flatpickr("#mi_edit_expiry",{enableTime:!0,dateFormat:"U",altInput:!0,altFormat:"Y-m-d H:i",allowInput:!0,time_24hr:!0,defaultDate:t,minDate:"today"})}function handleEditCheckboxChange(e){var t=document.getElementById(e.getAttribute("data-toggle-target")),n=e.getAttribute("data-timestamp");e.checked?(t.classList.remove("disabled"),t.removeAttribute("disabled"),n!=null&&(calendarInstance._input.disabled=!1)):(n!=null&&(calendarInstance._input.disabled=!0),t.classList.add("disabled"),t.setAttribute("disabled",!0))}function showEditModal(e,t,n,s,o,i,a){document.getElementById("m_filenamelabel").innerHTML=e,document.getElementById("mc_expiry").setAttribute("data-timestamp",s),document.getElementById("mb_save").setAttribute("data-fileid",t),createCalendar(s),i?(document.getElementById("mi_edit_down").value="1",document.getElementById("mi_edit_down").disabled=!0,document.getElementById("mc_download").checked=!1):(document.getElementById("mi_edit_down").value=n,document.getElementById("mi_edit_down").disabled=!1,document.getElementById("mc_download").checked=!0),a?(document.getElementById("mi_edit_expiry").value=add14DaysIfBeforeCurrentTime(s),document.getElementById("mi_edit_expiry").disabled=!0,document.getElementById("mc_expiry").checked=!1,calendarInstance._input.disabled=!0):(document.getElementById("mi_edit_expiry").value=s,document.getElementById("mi_edit_expiry").disabled=!1,document.getElementById("mc_expiry").checked=!0,calendarInstance._input.disabled=!1),o?(document.getElementById("mi_edit_pw").value="(unchanged)",document.getElementById("mi_edit_pw").disabled=!1,document.getElementById("mc_password").checked=!0):(document.getElementById("mi_edit_pw").value="",document.getElementById("mi_edit_pw").disabled=!0,document.getElementById("mc_password").checked=!1),new bootstrap.Modal("#modaledit",{}).show()}function selectTextForPw(e){e.value==="(unchanged)"&&e.setSelectionRange(0,e.value.length)}function add14DaysIfBeforeCurrentTime(e){let t=Date.now(),n=e*1e3;if(n{if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}).then(e=>{o?s.classList.add("apiperm-notgranted"):s.classList.add("apiperm-granted"),s.classList.remove("apiperm-processing")}).catch(e=>{o?s.classList.add("apiperm-granted"):s.classList.add("apiperm-notgranted"),s.classList.remove("apiperm-processing"),alert("Unable to set permission: "+e),console.error("Error:",e)})}function deleteApiKey(e){document.getElementById("delete-"+e).disabled=!0;var t="./api/auth/delete";const n={method:"POST",headers:{"Content-Type":"application/json",apikey:systemKey,apiKeyToModify:e}};fetch(t,n).then(e=>{if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}).then(t=>{document.getElementById("row-"+e).remove()}).catch(e=>{alert("Unable to delete API key: "+e),console.error("Error:",e)})}function newApiKey(){document.getElementById("button-newapi").disabled=!0;var e="./api/auth/create";const t={method:"POST",headers:{"Content-Type":"application/json",apikey:systemKey,basicPermissions:"true"}};fetch(e,t).then(e=>{if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}).then(e=>{location.reload()}).catch(e=>{alert("Unable to create API key: "+e),console.error("Error:",e)})}function deleteFile(e){document.getElementById("button-delete-"+e).disabled=!0;var t="./api/files/delete";const n={method:"POST",headers:{"Content-Type":"application/json",apikey:systemKey,id:e}};fetch(t,n).then(e=>{if(!e.ok)throw new Error(`Request failed with status: ${e.status}`)}).then(e=>{location.reload()}).catch(e=>{alert("Unable to delete file: "+e),console.error("Error:",e)})}function checkBoxChanged(e,t){let n=!e.checked;n?document.getElementById(t).setAttribute("disabled",""):document.getElementById(t).removeAttribute("disabled"),t==="password"&&n&&(document.getElementById("password").value="")}function 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");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,5e3)}}statusItemCount=0;function addFileStatus(e,t){const n=document.createElement("div");n.setAttribute("id",`us-container-${e}`),n.classList.add("us-container");const a=document.createElement("div");a.classList.add("filename"),a.textContent=t,n.appendChild(a);const s=document.createElement("div");s.classList.add("upload-progress-container"),s.setAttribute("id",`us-progress-container-${e}`);const r=document.createElement("div");r.classList.add("upload-progress-bar");const o=document.createElement("div");o.setAttribute("id",`us-progressbar-${e}`),o.classList.add("upload-progress-bar-progress"),o.style.width="0%",r.appendChild(o);const i=document.createElement("div");i.setAttribute("id",`us-progress-info-${e}`),i.classList.add("upload-progress-info"),i.textContent="0%",s.appendChild(r),s.appendChild(i),n.appendChild(s),n.setAttribute("data-starttime",Date.now()),n.setAttribute("data-complete","false");const c=document.getElementById("uploadstatus");c.appendChild(n),c.style.visibility="visible",statusItemCount++}function removeFileStatus(e){const t=document.getElementById(`us-container-${e}`);if(t==null)return;t.remove(),statusItemCount--,statusItemCount<1&&(document.getElementById("uploadstatus").style.visibility="hidden")}function addRow(e){let r=parseData(e);if(r.Result!=="OK"){alert("Failed to upload file!"),location.reload();return}let t=r.FileInfo,p=document.getElementById("downloadtable"),s=p.insertRow(0);s.id="row-"+t.Id;let i=s.insertCell(0),a=s.insertCell(1),o=s.insertCell(2),c=s.insertCell(3),l=s.insertCell(4),d=s.insertCell(5),u=s.insertCell(6),h="";t.IsPasswordProtected===!0&&(h=' '),i.innerText=t.Name,i.id="cell-name-"+t.Id,a.innerText=t.Size,t.UnlimitedDownloads?o.innerText="Unlimited":o.innerText=t.DownloadsRemaining,t.UnlimitedTime?c.innerText="Unlimited":c.innerText=t.ExpireAtString,l.innerHTML="0",d.innerHTML=''+t.Id+""+h;let n=' ';t.UrlHotlink===""?n=n+' ':n=n+' ',n=n+' `,n=n+` ',n=n+'`,u.innerHTML=n,i.style.backgroundColor="green",a.style.backgroundColor="green",a.setAttribute("data-order",r.FileInfo.SizeBytes),o.style.backgroundColor="green",c.style.backgroundColor="green",l.style.backgroundColor="green",d.style.backgroundColor="green",u.style.backgroundColor="green";let m=$("#maintable").DataTable();rowCount==-1&&(rowCount=m.rows().count()),rowCount=rowCount+1,m.row.add(s);let f=document.getElementsByClassName("dataTables_empty")[0];return typeof f!="undefined"?f.innerText="Files stored: "+rowCount:document.getElementsByClassName("dataTables_info")[0].innerText="Files stored: "+rowCount,t.Id}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_admin.tmpl b/internal/webserver/web/templates/html_admin.tmpl index 68a6812e..08626c56 100644 --- a/internal/webserver/web/templates/html_admin.tmpl +++ b/internal/webserver/web/templates/html_admin.tmpl @@ -64,7 +64,7 @@ {{ range .Items }} {{ if or (gt .ExpireAt $.TimeNow) (.UnlimitedTime) }} {{ if or (gt .DownloadsRemaining 0) (.UnlimitedDownloads) }} - + {{ .Name }} {{ .Size }} {{ if .UnlimitedDownloads }} @@ -87,7 +87,7 @@ {{ end }} - + {{ end }} {{ end }} @@ -176,6 +176,7 @@ }); }); registerChangeHandler(); + var systemKey = "{{.SystemKey}}"; {{ if .EndToEndEncryption }} diff --git a/internal/webserver/web/templates/html_api.tmpl b/internal/webserver/web/templates/html_api.tmpl index 97ab429a..dd9835f1 100644 --- a/internal/webserver/web/templates/html_api.tmpl +++ b/internal/webserver/web/templates/html_api.tmpl @@ -18,12 +18,13 @@ Last Used Permissions Actions - + {{ range .ApiKeys }} - +{{ if not .IsSystemKey }} + {{ .FriendlyName }} {{ .Id }} {{ .GetReadableDate }} @@ -31,19 +32,21 @@ - + - + +{{ end }} {{ end }} +
API key copied to clipboard
@@ -69,6 +72,7 @@ xmlhttp.open("GET", "./api/auth/friendlyname"); xmlhttp.setRequestHeader("apiKeyToModify", row.id); xmlhttp.setRequestHeader("friendlyName", val); + xmlhttp.setRequestHeader("apikey", systemKey); xmlhttp.send(); row.classList.remove("isBeingEdited"); @@ -88,6 +92,7 @@ input.focus(); } }); + var systemKey = "{{.SystemKey}}"; {{ template "footer" true }} {{ end }} diff --git a/internal/webserver/web/templates/string_constants.tmpl b/internal/webserver/web/templates/string_constants.tmpl index bd4784f7..87d8f418 100644 --- a/internal/webserver/web/templates/string_constants.tmpl +++ b/internal/webserver/web/templates/string_constants.tmpl @@ -3,7 +3,7 @@ // Specifies the version of JS files, so that the browser doesn't // use a cached version, if the file has been updated -{{define "js_admin_version"}}5{{end}} +{{define "js_admin_version"}}6{{end}} {{define "js_dropzone_version"}}4{{end}} {{define "js_e2eversion"}}3{{end}} {{define "css_main"}}2{{end}} \ No newline at end of file diff --git a/openapi.json b/openapi.json index 40a86a89..aa1fcd8e 100644 --- a/openapi.json +++ b/openapi.json @@ -15,9 +15,6 @@ { "apikey": ["VIEW","UPLOAD","DELETE", "API_MANAGE"] }, - { - "session": [] - } ], "tags": [ { @@ -40,9 +37,6 @@ { "apikey": ["VIEW"] }, - { - "session": [] - } ], "responses": { "200": { @@ -80,9 +74,6 @@ { "apikey": ["UPLOAD"] }, - { - "session": [] - } ], "requestBody": { "content": { @@ -126,9 +117,6 @@ { "apikey": ["UPLOAD"] }, - { - "session": [] - } ], "requestBody": { "content": { @@ -172,9 +160,6 @@ { "apikey": ["UPLOAD"] }, - { - "session": [] - } ], "requestBody": { "content": { @@ -218,9 +203,6 @@ { "apikey": ["VIEW","UPLOAD"] }, - { - "session": [] - } ], "requestBody": { "content": { @@ -264,9 +246,6 @@ { "apikey": ["API_EDIT"] }, - { - "session": [] - } ], "parameters": [ { @@ -346,9 +325,6 @@ "security": [ { "apikey": ["DELETE"] - }, - { - "session": [] } ], "parameters": [ @@ -383,14 +359,11 @@ "auth" ], "summary": "Creates a new API key", - "description": "This API call returns a new API key. The new key does not have any permissions. Requires permission API_MOD", + "description": "This API call returns a new API key. The new key does not have any permissions, unless specified. Requires permission API_MOD", "operationId": "create", "security": [ { "apikey": ["API_MANAGE"] - }, - { - "session": [] } ], "parameters": [ @@ -404,6 +377,17 @@ "schema": { "type": "string" } + }, + { + "name": "basicPermissions", + "in": "header", + "description": "If true, basic permissions are automatically granted", + "required": false, + "style": "simple", + "explode": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -434,9 +418,6 @@ "security": [ { "apikey": ["API_MANAGE"] - }, - { - "session": [] } ], "parameters": [ @@ -487,9 +468,6 @@ "security": [ { "apikey": ["API_MANAGE"] - }, - { - "session": [] } ], "parameters": [ @@ -541,6 +519,45 @@ } } } + }, + "/auth/delete": { + "delete": { + "tags": [ + "auth" + ], + "summary": "Deletes an API key", + "description": "This API call deletes the given API key. Requires permission API_MOD", + "operationId": "apidelete", + "security": [ + { + "apikey": ["API_MANAGE"] + } + ], + "parameters": [ + { + "name": "apiKeyToModify", + "in": "header", + "description": "The API key to delete", + "required": true, + "style": "simple", + "explode": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Operation successful" + }, + "400": { + "description": "Invalid ID supplied" + }, + "401": { + "description": "Invalid API key provided or not logged in as admin" + } + } + } } }, "components": { @@ -812,11 +829,6 @@ "type": "apiKey", "name": "apikey", "in": "header" - }, - "session": { - "type": "apiKey", - "name": "session_token", - "in": "cookie" } } }