Added extended logging (#240), fixed bug that prevented setting Manage_Users API permission on new API key, added Manage_Logs API permission

* Added Manage_Logs API permission, added API endpoint to delete logs, added more logging, added filtering and deletion of logs in UI, fixed bug that prevented setting Manage_Users API permission on new API key
This commit is contained in:
Marc Bulling
2025-03-21 15:06:17 +01:00
committed by GitHub
parent af5f46b2c6
commit f36d39e728
26 changed files with 735 additions and 382 deletions

View File

@@ -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 = 8
const jsAdminVersion = 9
const jsE2EVersion = 5
const cssMainVersion = 4

View File

@@ -11,7 +11,7 @@ import (
"strings"
)
const versionJsAdmin = 8
const versionJsAdmin = 9
const versionJsDropzone = 5
const versionJsE2EAdmin = 5
const versionCssMain = 4

View File

@@ -72,7 +72,7 @@ func main() {
createSsl(passedFlags)
initCloudConfig(passedFlags)
go storage.CleanUp(true)
logging.AddString("Gokapi started")
logging.LogStartup()
go webserver.Start()
c := make(chan os.Signal)
@@ -85,6 +85,7 @@ func main() {
func shutdown() {
fmt.Println("Shutting down...")
webserver.Shutdown()
logging.LogShutdown()
database.Close()
}
@@ -164,6 +165,7 @@ func initCloudConfig(passedFlags flagparser.MainFlags) {
// Checks for command line arguments that have to be parsed after loading the configuration
func reconfigureServer(passedFlags flagparser.MainFlags) bool {
if passedFlags.Reconfigure {
logging.LogSetup()
setup.RunConfigModification()
return true
}
@@ -210,7 +212,7 @@ func setDeploymentPassword(passedFlags flagparser.MainFlags) {
if passedFlags.DeploymentPassword == "" {
return
}
logging.AddString("Password has been changed for deployment")
logging.LogDeploymentPassword()
configuration.SetDeploymentPassword(passedFlags.DeploymentPassword)
}

View File

@@ -16,7 +16,7 @@ import (
"github.com/forceu/gokapi/internal/configuration/database"
"github.com/forceu/gokapi/internal/environment"
"github.com/forceu/gokapi/internal/helper"
log "github.com/forceu/gokapi/internal/logging"
"github.com/forceu/gokapi/internal/logging"
"github.com/forceu/gokapi/internal/models"
"github.com/forceu/gokapi/internal/storage/filesystem"
"io"
@@ -93,7 +93,7 @@ func Load() {
}
helper.CreateDir(serverSettings.DataDir)
filesystem.Init(serverSettings.DataDir)
log.Init(Environment.DataDir)
logging.Init(Environment.DataDir)
}
// ConnectDatabase loads the database that is defined in the configuration
@@ -152,6 +152,7 @@ func MigrateToV2(authPassword string, allowedUsers []string) {
database.SaveMetaData(file)
}
database.DeleteAllSessions()
logging.UpgradeToV2()
fmt.Println("Migration complete")
}

View File

@@ -18,7 +18,7 @@ type DatabaseProvider struct {
}
// DatabaseSchemeVersion contains the version number to be expected from the current database. If lower, an upgrade will be performed
const DatabaseSchemeVersion = 4
const DatabaseSchemeVersion = 5
// New returns an instance
func New(dbConfig models.DbConnection) (DatabaseProvider, error) {
@@ -96,7 +96,7 @@ func (p DatabaseProvider) Upgrade(currentDbVersion int) {
if currentDbVersion < 3 {
fmt.Println("Please update to v1.9.6 before upgrading to 2.0.0")
}
// < v2.0.0-beta
// < v2.0.0-beta1
if currentDbVersion < 4 {
p.DeleteAllSessions()
apiKeys := p.GetAllApiKeys()
@@ -109,6 +109,15 @@ func (p DatabaseProvider) Upgrade(currentDbVersion int) {
p.SaveEnd2EndInfo(legacyE2e, 0)
p.deleteKey("e2einfo")
}
// < v2.0.0-beta2
if currentDbVersion < 5 {
keys := p.GetAllApiKeys()
for _, key := range keys {
if key.IsSystemKey {
p.DeleteApiKey(key.Id)
}
}
}
}
const keyDbVersion = "dbversion"

View File

@@ -20,7 +20,7 @@ type DatabaseProvider struct {
}
// DatabaseSchemeVersion contains the version number to be expected from the current database. If lower, an upgrade will be performed
const DatabaseSchemeVersion = 7
const DatabaseSchemeVersion = 8
// New returns an instance
func New(dbConfig models.DbConnection) (DatabaseProvider, error) {
@@ -81,6 +81,15 @@ func (p DatabaseProvider) Upgrade(currentDbVersion int) {
p.SaveEnd2EndInfo(legacyE2E, 0)
}
}
// < v2.0.0-beta2
if currentDbVersion < 8 {
keys := p.GetAllApiKeys()
for _, key := range keys {
if key.IsSystemKey {
p.DeleteApiKey(key.Id)
}
}
}
}
func getLegacyE2EConfig(p DatabaseProvider) models.E2EInfoEncrypted {

View File

@@ -1,6 +1,7 @@
package logging
import (
"bufio"
"fmt"
"github.com/forceu/gokapi/internal/environment"
"github.com/forceu/gokapi/internal/helper"
@@ -16,6 +17,13 @@ import (
var logPath = "config/log.txt"
var mutex sync.Mutex
const categoryInfo = "info"
const categoryDownload = "download"
const categoryUpload = "upload"
const categoryEdit = "edit"
const categoryAuth = "auth"
const categoryWarning = "warning"
var outputToStdout = false
// Init sets the path where to write the log file to
@@ -25,29 +33,178 @@ func Init(filePath string) {
outputToStdout = env.LogToStdout
}
// AddString adds a line to the logfile including the current date. Non-Blocking
func AddString(text string) {
output := formatDate(text)
// GetAll returns all log entries as a single string and if the log file exists
func GetAll() (string, bool) {
if helper.FileExists(logPath) {
content, err := os.ReadFile(logPath)
helper.Check(err)
return string(content), true
} else {
return fmt.Sprintf("[%s] No log file found!", categoryWarning), false
}
}
// createLogEntry adds a line to the logfile including the current date. Also outputs to Stdout if set.
func createLogEntry(category, text string, blocking bool) {
output := createLogFormat(category, text)
if outputToStdout {
fmt.Println(output)
}
go writeToFile(output)
}
// GetLogPath returns the relative path to the log file
func GetLogPath() string {
return logPath
}
// AddDownload adds a line to the logfile when a download was requested. Non-Blocking
func AddDownload(file *models.File, r *http.Request, saveIp bool) {
if saveIp {
AddString(fmt.Sprintf("Download: Filename %s, IP %s, ID %s, Useragent %s", file.Name, getIpAddress(r), file.Id, r.UserAgent()))
if blocking {
writeToFile(output)
} else {
AddString(fmt.Sprintf("Download: Filename %s, ID %s, Useragent %s", file.Name, file.Id, r.UserAgent()))
go writeToFile(output)
}
}
func createLogFormat(category, text string) string {
return createLogFormatCustomTimestamp(category, text, time.Now())
}
func createLogFormatCustomTimestamp(category, text string, timestamp time.Time) string {
return fmt.Sprintf("%s [%s] %s", getDate(timestamp), category, text)
}
// LogStartup adds a log entry to indicate that Gokapi has started. Non-blocking
func LogStartup() {
createLogEntry(categoryInfo, "Gokapi started", false)
}
// LogShutdown adds a log entry to indicate that Gokapi is shutting down. Blocking call
func LogShutdown() {
createLogEntry(categoryInfo, "Gokapi shutting down", true)
}
// LogSetup adds a log entry to indicate that the setup was run. Non-blocking
func LogSetup() {
createLogEntry(categoryAuth, "Re-running Gokapi setup", false)
}
// LogDeploymentPassword adds a log entry to indicate that a deployment password was set. Non-blocking
func LogDeploymentPassword() {
createLogEntry(categoryAuth, "Setting new admin password", false)
}
// LogUserDeletion adds a log entry to indicate that a user was deleted. Non-blocking
func LogUserDeletion(modifiedUser, userEditor models.User) {
createLogEntry(categoryAuth, fmt.Sprintf("%s (#%d) was deleted by %s (user #%d)",
modifiedUser.Name, modifiedUser.Id, userEditor.Name, userEditor.Id), false)
}
// LogUserEdit adds a log entry to indicate that a user was modified. Non-blocking
func LogUserEdit(modifiedUser, userEditor models.User) {
createLogEntry(categoryAuth, fmt.Sprintf("%s (#%d) was modified by %s (user #%d)",
modifiedUser.Name, modifiedUser.Id, userEditor.Name, userEditor.Id), false)
}
// LogUserCreation adds a log entry to indicate that a user was created. Non-blocking
func LogUserCreation(modifiedUser, userEditor models.User) {
createLogEntry(categoryAuth, fmt.Sprintf("%s (#%d) was created by %s (user #%d)",
modifiedUser.Name, modifiedUser.Id, userEditor.Name, userEditor.Id), false)
}
// LogDownload adds a log entry when a download was requested. Non-Blocking
func LogDownload(file models.File, r *http.Request, saveIp bool) {
if saveIp {
createLogEntry(categoryDownload, fmt.Sprintf("%s, IP %s, ID %s, Useragent %s", file.Name, getIpAddress(r), file.Id, r.UserAgent()), false)
} else {
createLogEntry(categoryDownload, fmt.Sprintf("%s, ID %s, Useragent %s", file.Name, file.Id, r.UserAgent()), false)
}
}
// LogUpload adds a log entry when an upload was created. Non-Blocking
func LogUpload(file models.File, user models.User) {
createLogEntry(categoryUpload, fmt.Sprintf("%s, ID %s, uploaded by %s (user #%d)", file.Name, file.Id, user.Name, user.Id), false)
}
// LogEdit adds a log entry when an upload was edited. Non-Blocking
func LogEdit(file models.File, user models.User) {
createLogEntry(categoryEdit, fmt.Sprintf("%s, ID %s, edited by %s (user #%d)", file.Name, file.Id, user.Name, user.Id), false)
}
// LogReplace adds a log entry when an upload was replaced. Non-Blocking
func LogReplace(originalFile, newContent models.File, user models.User) {
createLogEntry(categoryEdit, fmt.Sprintf("%s, ID %s had content replaced with %s (ID %s) by %s (user #%d)",
originalFile.Name, originalFile.Id, newContent.Name, newContent.Id, user.Name, user.Id), false)
}
// LogDelete adds a log entry when an upload was deleted. Non-Blocking
func LogDelete(file models.File, user models.User) {
createLogEntry(categoryEdit, fmt.Sprintf("%s, ID %s, deleted by %s (user #%d)", file.Name, file.Id, user.Name, user.Id), false)
}
// UpgradeToV2 adds tags to existing logs
// deprecated
func UpgradeToV2() {
content, exists := GetAll()
mutex.Lock()
if !exists {
return
}
var newLogs strings.Builder
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "Gokapi started") {
line = strings.Replace(line, "Gokapi started", "["+categoryInfo+"] Gokapi started", 1)
}
if strings.Contains(line, "Download: Filename") {
line = strings.Replace(line, "Download: Filename", "["+categoryDownload+"] Filename", 1)
}
newLogs.WriteString(line)
newLogs.WriteString("\n")
}
helper.Check(scanner.Err())
err := os.WriteFile(logPath, []byte(newLogs.String()), 0600)
helper.Check(err)
defer mutex.Unlock()
}
func DeleteLogs(userName string, userId int, cutoff int64, r *http.Request) {
if cutoff == 0 {
deleteAllLogs(userName, userId, r)
return
}
mutex.Lock()
logFile, err := os.ReadFile(logPath)
helper.Check(err)
var newFile strings.Builder
scanner := bufio.NewScanner(strings.NewReader(string(logFile)))
newFile.WriteString(getLogDeletionMessage(userName, userId, r, time.Unix(cutoff, 0)))
for scanner.Scan() {
line := scanner.Text()
timeEntry, err := parseTimeLogEntry(line)
if err != nil {
fmt.Println(err)
continue
}
if timeEntry.Unix() > cutoff {
newFile.WriteString(line + "\n")
}
}
err = os.WriteFile(logPath, []byte(newFile.String()), 0600)
helper.Check(err)
defer mutex.Unlock()
}
func parseTimeLogEntry(input string) (time.Time, error) {
const layout = "Mon, 02 Jan 2006 15:04:05 MST"
lineContent := strings.Split(input, " [")
return time.Parse(layout, lineContent[0])
}
func getLogDeletionMessage(userName string, userId int, r *http.Request, timestamp time.Time) string {
return createLogFormatCustomTimestamp(categoryWarning, fmt.Sprintf("Previous logs deleted by %s (user #%d) on %s. IP: %s\n",
userName, userId, getDate(time.Now()), getIpAddress(r)), timestamp)
}
func deleteAllLogs(userName string, userId int, r *http.Request) {
mutex.Lock()
defer mutex.Unlock()
message := getLogDeletionMessage(userName, userId, r, time.Now())
err := os.WriteFile(logPath, []byte(message), 0600)
helper.Check(err)
}
func writeToFile(text string) {
mutex.Lock()
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
@@ -58,8 +215,8 @@ func writeToFile(text string) {
helper.Check(err)
}
func formatDate(input string) string {
return time.Now().UTC().Format(time.RFC1123) + " " + input
func getDate(timestamp time.Time) string {
return timestamp.UTC().Format(time.RFC1123)
}
func getIpAddress(r *http.Request) string {

View File

@@ -39,12 +39,10 @@ func TestInit(t *testing.T) {
func TestAddString(t *testing.T) {
test.FileDoesNotExist(t, "test/log.txt")
AddString("Hello")
// Need sleep, as AddString() is non-blocking
time.Sleep(500 * time.Millisecond)
createLogEntry(categoryInfo, "Hello", true)
test.FileExists(t, "test/log.txt")
content, _ := os.ReadFile("test/log.txt")
test.IsEqualBool(t, strings.Contains(string(content), "UTC Hello"), true)
test.IsEqualBool(t, strings.Contains(string(content), "UTC [info] Hello"), true)
}
func TestAddDownload(t *testing.T) {
@@ -55,19 +53,15 @@ func TestAddDownload(t *testing.T) {
r := httptest.NewRequest("GET", "/test", nil)
r.Header.Set("User-Agent", "testAgent")
r.Header.Add("X-REAL-IP", "1.1.1.1")
AddDownload(&file, r, true)
// Need sleep, as AddDownload() is non-blocking
LogDownload(file, r, true)
// Need sleep, as LogDownload() is non-blocking
time.Sleep(500 * time.Millisecond)
content, _ := os.ReadFile("test/log.txt")
test.IsEqualBool(t, strings.Contains(string(content), "UTC Download: Filename testName, IP 1.1.1.1, ID testId, Useragent testAgent"), true)
test.IsEqualBool(t, strings.Contains(string(content), "UTC [download] testName, IP 1.1.1.1, ID testId, Useragent testAgent"), true)
r.Header.Add("X-REAL-IP", "2.2.2.2")
AddDownload(&file, r, false)
// Need sleep, as AddDownload() is non-blocking
LogDownload(file, r, false)
// Need sleep, as LogDownload() is non-blocking
time.Sleep(500 * time.Millisecond)
content, _ = os.ReadFile("test/log.txt")
test.IsEqualBool(t, strings.Contains(string(content), "2.2.2.2"), false)
}
func TestGetLogPath(t *testing.T) {
test.IsEqualString(t, GetLogPath(), "test/log.txt")
}

View File

@@ -19,17 +19,19 @@ const (
ApiPermReplace
// ApiPermManageUsers is the permission for managing users
ApiPermManageUsers
// ApiPermManageLogs is the permission required for managing the log file
ApiPermManageLogs
)
// ApiPermNone means no permission granted
const ApiPermNone ApiPermission = 0
// ApiPermAll means all permission granted
const ApiPermAll ApiPermission = 127
const ApiPermAll ApiPermission = 255
// ApiPermDefault means all permission granted, except ApiPermApiMod, ApiPermManageUsers and ApiPermReplace
// ApiPermDefault means all permission granted, except ApiPermApiMod, ApiPermManageUsers, ApiPermManageLogs and ApiPermReplace
// This is the default for new API keys that are created from the UI
const ApiPermDefault = ApiPermAll - ApiPermApiMod - ApiPermManageUsers - ApiPermReplace
const ApiPermDefault = ApiPermAll - ApiPermApiMod - ApiPermManageUsers - ApiPermReplace - ApiPermManageLogs
// ApiKey contains data of a single api key
type ApiKey struct {
@@ -111,6 +113,11 @@ func (key *ApiKey) HasPermissionManageUsers() bool {
return key.HasPermission(ApiPermManageUsers)
}
// HasPermissionManageLogs returns true if ApiPermManageLogs is granted
func (key *ApiKey) HasPermissionManageLogs() bool {
return key.HasPermission(ApiPermManageLogs)
}
// ApiKeyOutput is the output that is used after a new key is created
type ApiKeyOutput struct {
Result string

View File

@@ -577,7 +577,7 @@ func ServeFile(file models.File, w http.ResponseWriter, r *http.Request, forceDo
file.DownloadsRemaining = file.DownloadsRemaining - 1
file.DownloadCount = file.DownloadCount + 1
database.IncreaseDownloadCount(file.Id, !file.UnlimitedDownloads)
logging.AddDownload(&file, r, configuration.Get().SaveIp)
logging.LogDownload(file, r, configuration.Get().SaveIp)
go sse.PublishDownloadCount(file)
if !file.IsLocalStorage() {

View File

@@ -32,7 +32,6 @@ import (
"io/fs"
"log"
"net/http"
"os"
"sort"
"strings"
templatetext "text/template"
@@ -619,11 +618,11 @@ func showAdminMenu(w http.ResponseWriter, r *http.Request) {
// Handling of /logs
// If user is authenticated, this menu shows the stored logs
func showLogs(w http.ResponseWriter, r *http.Request) {
userId, err := authentication.GetUserFromRequest(r)
user, err := authentication.GetUserFromRequest(r)
if err != nil {
panic(err)
}
view := (&UploadView{}).convertGlobalConfig(ViewLogs, userId)
view := (&UploadView{}).convertGlobalConfig(ViewLogs, user)
if !view.ActiveUser.HasPermissionManageLogs() {
redirect(w, "admin")
return
@@ -767,13 +766,7 @@ func (u *UploadView) convertGlobalConfig(view int, user models.User) *UploadView
return resultApi[i].LastUsed > resultApi[j].LastUsed
})
case ViewLogs:
if helper.FileExists(logging.GetLogPath()) {
content, err := os.ReadFile(logging.GetLogPath())
helper.Check(err)
u.Logs = string(content)
} else {
u.Logs = "Warning: Log file not found!"
}
u.Logs, _ = logging.GetAll()
case ViewUsers:
uploadCounts := storage.GetUploadCounts()
u.Users = make([]userInfo, 0)

View File

@@ -6,6 +6,7 @@ import (
"github.com/forceu/gokapi/internal/configuration"
"github.com/forceu/gokapi/internal/configuration/database"
"github.com/forceu/gokapi/internal/helper"
"github.com/forceu/gokapi/internal/logging"
"github.com/forceu/gokapi/internal/models"
"github.com/forceu/gokapi/internal/storage"
"github.com/forceu/gokapi/internal/webserver/fileupload"
@@ -97,6 +98,7 @@ func apiEditFile(w http.ResponseWriter, r requestParser, user models.User) {
}
database.SaveMetaData(file)
logging.LogEdit(file, user)
outputFileInfo(w, file)
}
@@ -136,6 +138,9 @@ func newSystemKey(userId int) string {
if !user.HasPermissionManageUsers() {
tempKey.RemovePermission(models.ApiPermManageUsers)
}
if !user.HasPermissionManageLogs() {
tempKey.RemovePermission(models.ApiPermManageLogs)
}
newKey := models.ApiKey{
Id: helper.GenerateRandomString(lengthApiKey),
@@ -203,6 +208,11 @@ func apiModifyApiKey(w http.ResponseWriter, r requestParser, user models.User) {
sendError(w, http.StatusUnauthorized, "Insufficient user permission for owner to set this API permission")
return
}
case models.ApiPermManageLogs:
if !apiKeyOwner.HasPermissionManageLogs() {
sendError(w, http.StatusUnauthorized, "Insufficient user permission for owner to set this API permission")
return
}
default:
// do nothing
}
@@ -253,7 +263,7 @@ func apiCreateApiKey(w http.ResponseWriter, r requestParser, user models.User) {
_, _ = w.Write(result)
}
func apiCreateUser(w http.ResponseWriter, r requestParser, _ models.User) {
func apiCreateUser(w http.ResponseWriter, r requestParser, user models.User) {
request, ok := r.(*paramUserCreate)
if !ok {
panic("invalid parameter passed")
@@ -277,6 +287,7 @@ func apiCreateUser(w http.ResponseWriter, r requestParser, _ models.User) {
sendError(w, http.StatusInternalServerError, "Could not save user")
return
}
logging.LogUserCreation(newUser, user)
_, _ = w.Write([]byte(newUser.ToJson()))
}
@@ -326,12 +337,12 @@ func apiDeleteFile(w http.ResponseWriter, r requestParser, user models.User) {
sendError(w, http.StatusNotFound, "Invalid file ID provided.")
return
}
if file.UserId == user.Id || user.HasPermission(models.UserPermDeleteOtherUploads) {
_ = storage.DeleteFile(request.Id, true)
} else {
if file.UserId != user.Id && !user.HasPermission(models.UserPermDeleteOtherUploads) {
sendError(w, http.StatusUnauthorized, "No permission to delete this file")
return
}
logging.LogDelete(file, user)
_ = storage.DeleteFile(request.Id, true)
}
func apiChunkAdd(w http.ResponseWriter, r requestParser, _ models.User) {
@@ -381,6 +392,7 @@ func doBlockingPartCompleteChunk(w http.ResponseWriter, request *paramChunkCompl
sendError(w, http.StatusBadRequest, err.Error())
return
}
logging.LogUpload(file, user)
if w != nil {
config := configuration.Get()
_, _ = io.WriteString(w, file.ToJsonResult(config.ServerUrl, config.IncludeFilename))
@@ -467,7 +479,7 @@ func apiDuplicateFile(w http.ResponseWriter, r requestParser, user models.User)
request.UnlimitedTime,
request.UnlimitedDownloads,
false, // is not being used by storage.DuplicateFile
0) // is not being used by storage.DuplicateFile
0) // is not being used by storage.DuplicateFile
newFile, err := storage.DuplicateFile(file, request.RequestedChanges, request.FileName, uploadRequest)
if err != nil {
sendError(w, http.StatusInternalServerError, err.Error())
@@ -513,6 +525,7 @@ func apiReplaceFile(w http.ResponseWriter, r requestParser, user models.User) {
}
return
}
logging.LogReplace(fileOriginal, modifiedFile, user)
outputFileInfo(w, modifiedFile)
}
@@ -542,6 +555,7 @@ func apiModifyUser(w http.ResponseWriter, r requestParser, user models.User) {
sendError(w, http.StatusBadRequest, "Cannot modify yourself")
return
}
logging.LogUserEdit(userEdit, user)
if request.GrantPermission {
if !userEdit.HasPermission(request.Permission) {
userEdit.GrantPermission(request.Permission)
@@ -588,6 +602,7 @@ func apiChangeUserRank(w http.ResponseWriter, r requestParser, user models.User)
sendError(w, http.StatusBadRequest, "invalid rank sent")
return
}
logging.LogUserEdit(userEdit, user)
database.SaveUser(userEdit, false)
}
@@ -598,6 +613,8 @@ func updateApiKeyPermsOnUserPermChange(userId int, userPerm models.UserPermissio
affectedPermission = models.ApiPermManageUsers
case models.UserPermReplaceUploads:
affectedPermission = models.ApiPermReplace
case models.UserPermManageLogs:
affectedPermission = models.ApiPermManageLogs
default:
return
}
@@ -662,6 +679,7 @@ func apiDeleteUser(w http.ResponseWriter, r requestParser, user models.User) {
sendError(w, http.StatusBadRequest, "Cannot delete yourself")
return
}
logging.LogUserDeletion(userToDelete, user)
database.DeleteUser(userToDelete.Id)
for _, file := range database.GetAllMetadata() {
if file.UserId == userToDelete.Id {
@@ -682,6 +700,14 @@ func apiDeleteUser(w http.ResponseWriter, r requestParser, user models.User) {
database.DeleteEnd2EndInfo(userToDelete.Id)
}
func apiLogsDelete(_ http.ResponseWriter, r requestParser, user models.User) {
request, ok := r.(*paramLogsDelete)
if !ok {
panic("invalid parameter passed")
}
logging.DeleteLogs(user.Name, user.Id, request.Timestamp, request.Request)
}
func isAuthorisedForApi(r *http.Request, routing apiRoute) (models.User, bool) {
apiKey := r.Header.Get("apikey")
user, _, ok := isValidApiKey(apiKey, true, routing.ApiPerm)

View File

@@ -800,7 +800,8 @@ func getAvailableApiPermissions(t *testing.T) []models.ApiPermission {
models.ApiPermApiMod,
models.ApiPermEdit,
models.ApiPermReplace,
models.ApiPermManageUsers}
models.ApiPermManageUsers,
models.ApiPermManageLogs}
sum := 0
for _, perm := range result {
sum = sum + int(perm)
@@ -820,6 +821,7 @@ func getApiPermMap(t *testing.T) map[models.ApiPermission]string {
result[models.ApiPermEdit] = "PERM_EDIT"
result[models.ApiPermReplace] = "PERM_REPLACE"
result[models.ApiPermManageUsers] = "PERM_MANAGE_USERS"
result[models.ApiPermManageLogs] = "PERM_MANAGE_LOGS"
sum := 0
for perm, _ := range result {
@@ -1064,6 +1066,7 @@ func TestApikeyModify(t *testing.T) {
grantUserPermission(t, idUser, models.UserPermReplaceUploads)
grantUserPermission(t, idUser, models.UserPermManageUsers)
grantUserPermission(t, idUser, models.UserPermManageLogs)
for permissionUint, permissionString := range getApiPermMap(t) {
test.IsEqualBool(t, retrievedApiKey.HasPermission(permissionUint), false)
@@ -1078,6 +1081,7 @@ func TestApikeyModify(t *testing.T) {
}
removeUserPermission(t, idUser, models.UserPermReplaceUploads)
removeUserPermission(t, idUser, models.UserPermManageUsers)
removeUserPermission(t, idUser, models.UserPermManageLogs)
}
func testApiModifyCall(t *testing.T, apiKey, targetKey string, permission string, grant bool) {

View File

@@ -134,6 +134,12 @@ var routes = []apiRoute{
execution: apiResetPassword,
RequestParser: &paramUserResetPw{},
},
{
Url: "/logs/delete",
ApiPerm: models.ApiPermManageLogs,
execution: apiLogsDelete,
RequestParser: &paramLogsDelete{},
},
}
func getRouting(requestUrl string) (apiRoute, bool) {
@@ -290,6 +296,8 @@ func (p *paramAuthModify) ProcessParameter(_ *http.Request) error {
p.Permission = models.ApiPermReplace
case "PERM_MANAGE_USERS":
p.Permission = models.ApiPermManageUsers
case "PERM_MANAGE_LOGS":
p.Permission = models.ApiPermManageLogs
default:
return errors.New("invalid permission")
}
@@ -394,6 +402,17 @@ type paramUserResetPw struct {
func (p *paramUserResetPw) ProcessParameter(_ *http.Request) error { return nil }
type paramLogsDelete struct {
Timestamp int64 `header:"timestamp"`
Request *http.Request
foundHeaders map[string]bool
}
func (p *paramLogsDelete) ProcessParameter(r *http.Request) error {
p.Request = r
return nil
}
type paramChunkAdd struct {
Request *http.Request
}

View File

@@ -598,6 +598,34 @@ func (p *paramUserResetPw) New() requestParser {
return &paramUserResetPw{}
}
// ParseRequest reads r and saves the passed header values in the paramLogsDelete struct
// In the end, ProcessParameter() is called
func (p *paramLogsDelete) ParseRequest(r *http.Request) error {
var err error
var exists bool
p.foundHeaders = make(map[string]bool)
// RequestParser header value "timestamp", required: false
exists, err = checkHeaderExists(r, "timestamp", false, false)
if err != nil {
return err
}
p.foundHeaders["timestamp"] = exists
if exists {
p.Timestamp, err = parseHeaderInt64(r, "timestamp")
if err != nil {
return fmt.Errorf("invalid value in header timestamp supplied")
}
}
return p.ProcessParameter(r)
}
// New returns a new instance of paramLogsDelete struct
func (p *paramLogsDelete) New() requestParser {
return &paramLogsDelete{}
}
// ParseRequest parses the header file. As paramChunkAdd has no fields with the
// tag header, this method does nothing, except calling ProcessParameter()
func (p *paramChunkAdd) ParseRequest(r *http.Request) error {

View File

@@ -2,6 +2,8 @@ package fileupload
import (
"github.com/forceu/gokapi/internal/configuration"
"github.com/forceu/gokapi/internal/configuration/database"
"github.com/forceu/gokapi/internal/logging"
"github.com/forceu/gokapi/internal/models"
"github.com/forceu/gokapi/internal/storage"
"github.com/forceu/gokapi/internal/storage/chunking"
@@ -12,6 +14,8 @@ import (
)
// ProcessCompleteFile processes a file upload request
// This is only used when a complete file is uploaded through the API with /files/add
// Normally a file is created from a chunk
func ProcessCompleteFile(w http.ResponseWriter, r *http.Request, userId, maxMemory int) error {
err := r.ParseMultipartForm(int64(maxMemory) * 1024 * 1024)
if err != nil {
@@ -32,6 +36,8 @@ func ProcessCompleteFile(w http.ResponseWriter, r *http.Request, userId, maxMemo
if err != nil {
return err
}
user, _ := database.GetUser(userId)
logging.LogUpload(result, user)
_, _ = io.WriteString(w, result.ToJsonResult(config.ExternalUrl, configuration.Get().IncludeFilename))
return nil
}

View File

@@ -3,7 +3,7 @@
"info": {
"title": "Gokapi",
"description": "[https://github.com/Forceu/Gokapi](https://github.com/Forceu/Gokapi)\n",
"version": "2.0"
"version": "1.0"
},
"servers": [
{
@@ -28,6 +28,9 @@
},
{
"name": "chunk"
},
{
"name": "logs"
}
],
"paths": {
@@ -172,79 +175,16 @@
"apikey": ["UPLOAD"]
},
],
"parameters": [
{
"name": "uuid",
"in": "header",
"required": true,
"schema": {
"type": "string"
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/chunkingcomplete"
}
}
},
"required": true
},
"description": "The unique ID that was used for the uploaded chunks"
},{
"name": "filename",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "The filename of the uploaded file"
},
{
"name": "filesize",
"in": "header",
"required": true,
"schema": {
"type": "integer"
},
"description": "The total filesize of the uploaded file in bytes"
},
{
"name": "contenttype",
"in": "header",
"required": false,
"schema": {
"type": "string"
},
"description": "The MIME content type. If empty, application/octet-stream will be used."
},
{
"name": "allowedDownloads",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many downloads are allowed. Default of 1 will be used if empty. Unlimited if 0 is passed."
},
{
"name": "expiryDays",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many days the file will be stored. Original value will be used if empty. Unlimited if 0 is passed."
},
{
"name": "password",
"in": "header",
"required": false,
"schema": {
"type": "string"
},
"description": "Password for this file to be set. No password will be used if empty value is passed."
},
{
"name": "nonblocking",
"in": "header",
"required": false,
"schema": {
"type": "boolean"
},
"description": "If true, the call is non blocking and does not wait until the upload is fully processed. No info regarding the file or any errors during the processing will be included in the output."
}
],
"responses": {
"200": {
"description": "Operation successful",
@@ -265,6 +205,43 @@
}
}
},
"/logs/delete": {
"post": {
"tags": [
"logs"
],
"summary": "Deletes entries from the logfilek",
"description": "This API call deletes all lines before older than a cutoff date. Requires API permission MANAGE_LOGS",
"operationId": "logsdelete",
"security": [
{
"apikey": ["MANAGE_LOGS"]
},
],
"parameters": [
{
"name": "timestamp",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "Unix timestamp of cutoff-date. All entries older than this timestamp will be deleted. To delete all entries, pass 0 or do not pass this parameter at all."
}
],
"responses": {
"200": {
"description": "Operation successful"
},
"400": {
"description": "Invalid input"
},
"401": {
"description": "Invalid API key provided for authentication or API key does not have the required permission"
}
}
}
},
"/files/add": {
"post": {
"tags": [
@@ -321,62 +298,16 @@
"apikey": ["VIEW","UPLOAD"]
},
],
"parameters": [
{
"name": "id",
"in": "header",
"required": true,
"schema": {
"type": "string"
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/duplicate"
}
}
},
"required": true
},
"description": "ID of file to be duplicated"
},
{
"name": "allowedDownloads",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many remaining downloads are allowed. Original value will be used if empty. Unlimited if 0 is passed."
},
{
"name": "expiryDays",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many days the file will be stored. Original value will be used if empty. Unlimited if 0 is passed."
},
{
"name": "password",
"in": "header",
"required": false,
"schema": {
"type": "string"
},
"description": "Password for this file to be set. No password will be used if empty value is passed."
},
{
"name": "originalPassword",
"in": "header",
"required": false,
"schema": {
"type": "boolean"
},
"description": "Set to true to use the original password. Field \"password\" will be ignored if set."
},
{
"name": "filename",
"in": "header",
"required": false,
"schema": {
"type": "string"
},
"description": "Sets a new filename. Filename will be unchanged if empty."
}
],
"responses": {
"200": {
"description": "Operation successful",
@@ -655,7 +586,7 @@
],
"parameters": [
{
"name": "targetKey",
"name": "apiKeyToModify",
"in": "header",
"description": "The API key to change the name of. Can be either the public ID or the actual API key",
"required": true,
@@ -705,7 +636,7 @@
],
"parameters": [
{
"name": "targetKey",
"name": "apiKeyToModify",
"in": "header",
"description": "The API key to change the permission of. Can be either the public ID or the actual API key",
"required": true,
@@ -771,7 +702,7 @@
],
"parameters": [
{
"name": "targetKey",
"name": "apiKeyToModify",
"in": "header",
"description": "The API key to delete. Can be either the public ID or the actual API key",
"required": true,
@@ -812,7 +743,17 @@
{
"name": "username",
"in": "header",
"description": "Name of new user, must be at least 4 characters",
"description": "Full name of new user, must be at least 2 characters",
"required": true,
"style": "simple",
"explode": false,
"schema": {
"type": "string"
}
},{
"name": "email",
"in": "header",
"description": "Email address of new user. Must be at least 4 characters and include an @ sign",
"required": true,
"style": "simple",
"explode": false,
@@ -1029,25 +970,15 @@
],
"parameters": [
{
"name": "userid",
"name": "generateNewPassword",
"in": "header",
"description": "The user to reset",
"description": "Generate a new password",
"required": true,
"style": "simple",
"explode": false,
"schema": {
"type": "string"
}
},{
"name": "generateNewPassword",
"in": "header",
"description": "Generate a new password",
"required": false,
"style": "simple",
"explode": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -1062,7 +993,7 @@
}
},
"400": {
"description": "Invalid ID or parameters supplied, user is super admin or user is equal to owner of API key"
"description": "Invalid ID or parameters supplied"
},
"401": {
"description": "Invalid API key provided for authentication or API key does not have the required permission"
@@ -1299,7 +1230,38 @@
"description": "Password for this file to be set. No password will be used if empty"
}
}
},"chunking": {
},"duplicate": {
"required": [
"id"
],
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "ID of file to be duplicated"
},
"allowedDownloads": {
"type": "integer",
"description": "How many downloads are allowed. Original value from web interface will be used if empty. Unlimited if 0 is passed."
},
"expiryDays": {
"type": "integer",
"description": "How many days the file will be stored. Original value from web interface will be used if empty. Unlimited if 0 is passed."
},
"password": {
"type": "string",
"description": "Password for this file to be set. No password will be used if empty."
},
"originalPassword": {
"type": "boolean",
"description": "Set to true to use original password. Field \"password\" will be ignored if set."
},
"filename": {
"type": "string",
"description": "Sets a new filename. Filename will be unchanged if empty."
}
}
},"chunking": {
"required": [
"file","uuid","filesize","offset"
],
@@ -1323,6 +1285,41 @@
"description": "The chunk's offset starting at the beginning of the file"
}
}
},"chunkingcomplete": {
"required": [
"uuid","filename","filesize"
],
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "The unique ID that was used for the uploaded chunks"
},
"filename": {
"type": "string",
"description": "The filename of the uploaded file"
},
"filesize": {
"type": "integer",
"description": "The total filesize of the uploaded file in bytes"
},
"contenttype": {
"type": "string",
"description": "The MIME content type. If empty, application/octet-stream will be used."
},
"allowedDownloads": {
"type": "integer",
"description": "How many downloads are allowed. Default of 1 will be used if empty. Unlimited if 0 is passed."
},
"expiryDays": {
"type": "integer",
"description": "How many days the file will be stored. Default of 14 will be used if empty. Unlimited if 0 is passed."
},
"password": {
"type": "string",
"description": "Password for this file to be set. No password will be used if empty"
}
}
}
},
"securitySchemes": {

View File

@@ -404,3 +404,29 @@ async function apiUserResetPassword(id, generatePw) {
}
}
async function apiLogsDelete(timestamp) {
const apiUrl = './api/logs/delete';
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': systemKey,
'timestamp': timestamp
},
};
try {
const response = await fetch(apiUrl, requestOptions);
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
} catch (error) {
console.error("Error in apiLogsDelete:", error);
throw error;
}
}

View File

@@ -166,7 +166,8 @@ function addRowApi(apiKey, publicId) {
<i id="perm_edit_` + publicId + `" class="bi bi-pencil perm-granted" title="Edit Uploads" onclick='changeApiPermission("` + publicId + `","PERM_EDIT", "perm_edit_` + publicId + `");'></i>
<i id="perm_delete_` + publicId + `" class="bi bi-trash3 perm-granted" title="Delete Uploads" onclick='changeApiPermission("` + publicId + `","PERM_DELETE", "perm_delete_` + publicId + `");'></i>
<i id="perm_replace_` + publicId + `" class="bi bi-recycle perm-notgranted" title="Replace Uploads" onclick='changeApiPermission("` + publicId + `","PERM_REPLACE", "perm_replace_` + publicId + `");'></i>
<i id="perm_users_` + publicId + `" class="bi bi-people perm-notgranted" title="Manage Users" onclick='changeApiPermission("` + publicId + `", "PERM_MANAGE_USERS", "` + publicId + `");'></i>
<i id="perm_users_` + publicId + `" class="bi bi-people perm-notgranted" title="Manage Users" onclick='changeApiPermission("` + publicId + `", "PERM_MANAGE_USERS", "perm_users_` + publicId + `");'></i>
<i id="perm_logs_` + publicId + `" class="bi bi-card-list perm-notgranted" title="Manage System Logs" onclick='changeApiPermission("` + publicId + `", "PERM_MANAGE_LOGS", "perm_logs_` + publicId + `");'></i>
<i id="perm_api_` + publicId + `" class="bi bi-sliders2 perm-notgranted" title="Manage API Keys" onclick='changeApiPermission("` + publicId + `","PERM_API_MOD", "perm_api_` + publicId + `");'></i>`;
if (!canReplaceFiles) {

View File

@@ -0,0 +1,48 @@
// This file contains JS code for the Logs view
// All files named admin_*.js will be merged together and minimised by calling
// go generate ./...
function filterLogs(tag) {
if (tag == "all") {
textarea.value = logContent;
} else {
textarea.value = logContent.split("\n").filter(line => line.includes("[" + tag + "]")).join("\n");
}
textarea.scrollTop = textarea.scrollHeight;
}
function deleteLogs(cutoff) {
if (cutoff == "none") {
return;
}
if (!confirm("Do you want to delete the selected logs?")) {
document.getElementById('deleteLogs').selectedIndex = 0;
return;
}
let timestamp = Math.floor(Date.now() / 1000)
switch (cutoff) {
case "all":
timestamp = 0;
break;
case "2":
timestamp = timestamp - 2 * 24 * 60 * 60;
break;
case "7":
timestamp = timestamp - 7 * 24 * 60 * 60;
break;
case "14":
timestamp = timestamp - 14 * 24 * 60 * 60;
break;
case "30":
timestamp = timestamp - 30 * 24 * 60 * 60;
break;
}
apiLogsDelete(timestamp)
.then(data => {
location.reload();
})
.catch(error => {
alert("Unable to delete logs: " + error);
console.error('Error:', error);
});
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -52,6 +52,8 @@
<i id="perm_users_{{ .PublicId }}" class="bi bi-people {{if not (index $.UserMap .UserId).HasPermissionManageUsers}}perm-unavailable perm-nochange{{ else }}{{if not .HasPermissionManageUsers}}perm-notgranted{{else}}perm-granted{{end}}{{end}}" title="Manage Users" onclick='changeApiPermission("{{ .PublicId }}","PERM_MANAGE_USERS", "perm_users_{{ .PublicId }}");'></i>
<i id="perm_logs_{{ .PublicId }}" class="bi bi-card-list {{if not (index $.UserMap .UserId).HasPermissionManageLogs}}perm-unavailable perm-nochange{{ else }}{{if not .HasPermissionManageLogs}}perm-notgranted{{else}}perm-granted{{end}}{{end}}" title="Manage System Logs" onclick='changeApiPermission("{{ .PublicId }}","PERM_MANAGE_LOGS", "perm_logs_{{ .PublicId }}");'></i>
<i id="perm_api_{{ .PublicId }}" class="bi bi-sliders2 {{if not .HasPermissionApiMod}}perm-notgranted{{else}}perm-granted{{end}}" title="Manage API Keys" onclick='changeApiPermission("{{ .PublicId }}","PERM_API_MOD", "perm_api_{{ .PublicId }}");'></i>
</td>
{{ if $.ActiveUser.HasPermissionManageApi }}

View File

@@ -9,17 +9,41 @@
<textarea class="form-control" id="logviewer" rows="20" readonly>
{{ .Logs }}</textarea>
<br>
<div class="d-flex gap-3">
<select id="logFilter" class="form-select" onchange="filterLogs(this.value)">
<option value="all">Show all logs</option>
<option value="warning">Show [warning]</option>
<option value="auth">Show [auth]</option>
<option value="download">Show [download]</option>
<option value="upload">Show [upload]</option>
<option value="edit">Show [edit]</option>
<option value="info">Show [info]</option>
</select>
<select id="deleteLogs" class="form-select" onchange="deleteLogs(this.value)">
<option value="none">Delete Logs...</option>
<option value="2">Older than 2 days</option>
<option value="7">Older than 7 days</option>
<option value="14">Older than 14 days</option>
<option value="30">Older than 30 days</option>
<option value="all">Delete all logs</option>
</select>
</div>
<br>
</div>
</div>
</div>
</div>
<script src="./js/min/admin.min.{{ template "js_admin_version"}}.js"></script>
<script>
var systemKey = "{{.SystemKey}}";
let textarea = document.getElementById('logviewer');
textarea.scrollTop = textarea.scrollHeight;
window.setTimeout( function() {
window.location.reload();
}, 30000);
textarea.scrollTop = textarea.scrollHeight;
var logContent = textarea.value;
</script>
{{ template "footer" true}}
{{ end }}

View File

@@ -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"}}8{{end}}
{{define "js_admin_version"}}9{{end}}
{{define "js_dropzone_version"}}5{{end}}
{{define "js_e2eversion"}}5{{end}}
{{define "css_main"}}4{{end}}

View File

@@ -3,7 +3,7 @@
"info": {
"title": "Gokapi",
"description": "[https://github.com/Forceu/Gokapi](https://github.com/Forceu/Gokapi)\n",
"version": "2.0"
"version": "1.0"
},
"servers": [
{
@@ -28,6 +28,9 @@
},
{
"name": "chunk"
},
{
"name": "logs"
}
],
"paths": {
@@ -172,79 +175,16 @@
"apikey": ["UPLOAD"]
},
],
"parameters": [
{
"name": "uuid",
"in": "header",
"required": true,
"schema": {
"type": "string"
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/chunkingcomplete"
}
}
},
"required": true
},
"description": "The unique ID that was used for the uploaded chunks"
},{
"name": "filename",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "The filename of the uploaded file"
},
{
"name": "filesize",
"in": "header",
"required": true,
"schema": {
"type": "integer"
},
"description": "The total filesize of the uploaded file in bytes"
},
{
"name": "contenttype",
"in": "header",
"required": false,
"schema": {
"type": "string"
},
"description": "The MIME content type. If empty, application/octet-stream will be used."
},
{
"name": "allowedDownloads",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many downloads are allowed. Default of 1 will be used if empty. Unlimited if 0 is passed."
},
{
"name": "expiryDays",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many days the file will be stored. Original value will be used if empty. Unlimited if 0 is passed."
},
{
"name": "password",
"in": "header",
"required": false,
"schema": {
"type": "string"
},
"description": "Password for this file to be set. No password will be used if empty value is passed."
},
{
"name": "nonblocking",
"in": "header",
"required": false,
"schema": {
"type": "boolean"
},
"description": "If true, the call is non blocking and does not wait until the upload is fully processed. No info regarding the file or any errors during the processing will be included in the output."
}
],
"responses": {
"200": {
"description": "Operation successful",
@@ -265,6 +205,43 @@
}
}
},
"/logs/delete": {
"post": {
"tags": [
"logs"
],
"summary": "Deletes entries from the logfilek",
"description": "This API call deletes all lines before older than a cutoff date. Requires API permission MANAGE_LOGS",
"operationId": "logsdelete",
"security": [
{
"apikey": ["MANAGE_LOGS"]
},
],
"parameters": [
{
"name": "timestamp",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "Unix timestamp of cutoff-date. All entries older than this timestamp will be deleted. To delete all entries, pass 0 or do not pass this parameter at all."
}
],
"responses": {
"200": {
"description": "Operation successful"
},
"400": {
"description": "Invalid input"
},
"401": {
"description": "Invalid API key provided for authentication or API key does not have the required permission"
}
}
}
},
"/files/add": {
"post": {
"tags": [
@@ -321,62 +298,16 @@
"apikey": ["VIEW","UPLOAD"]
},
],
"parameters": [
{
"name": "id",
"in": "header",
"required": true,
"schema": {
"type": "string"
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
"schema": {
"$ref": "#/components/schemas/duplicate"
}
}
},
"required": true
},
"description": "ID of file to be duplicated"
},
{
"name": "allowedDownloads",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many remaining downloads are allowed. Original value will be used if empty. Unlimited if 0 is passed."
},
{
"name": "expiryDays",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many days the file will be stored. Original value will be used if empty. Unlimited if 0 is passed."
},
{
"name": "password",
"in": "header",
"required": false,
"schema": {
"type": "string"
},
"description": "Password for this file to be set. No password will be used if empty value is passed."
},
{
"name": "originalPassword",
"in": "header",
"required": false,
"schema": {
"type": "boolean"
},
"description": "Set to true to use the original password. Field \"password\" will be ignored if set."
},
{
"name": "filename",
"in": "header",
"required": false,
"schema": {
"type": "string"
},
"description": "Sets a new filename. Filename will be unchanged if empty."
}
],
"responses": {
"200": {
"description": "Operation successful",
@@ -655,7 +586,7 @@
],
"parameters": [
{
"name": "targetKey",
"name": "apiKeyToModify",
"in": "header",
"description": "The API key to change the name of. Can be either the public ID or the actual API key",
"required": true,
@@ -705,7 +636,7 @@
],
"parameters": [
{
"name": "targetKey",
"name": "apiKeyToModify",
"in": "header",
"description": "The API key to change the permission of. Can be either the public ID or the actual API key",
"required": true,
@@ -771,7 +702,7 @@
],
"parameters": [
{
"name": "targetKey",
"name": "apiKeyToModify",
"in": "header",
"description": "The API key to delete. Can be either the public ID or the actual API key",
"required": true,
@@ -812,7 +743,17 @@
{
"name": "username",
"in": "header",
"description": "Name of new user, must be at least 4 characters",
"description": "Full name of new user, must be at least 2 characters",
"required": true,
"style": "simple",
"explode": false,
"schema": {
"type": "string"
}
},{
"name": "email",
"in": "header",
"description": "Email address of new user. Must be at least 4 characters and include an @ sign",
"required": true,
"style": "simple",
"explode": false,
@@ -1029,25 +970,15 @@
],
"parameters": [
{
"name": "userid",
"name": "generateNewPassword",
"in": "header",
"description": "The user to reset",
"description": "Generate a new password",
"required": true,
"style": "simple",
"explode": false,
"schema": {
"type": "string"
}
},{
"name": "generateNewPassword",
"in": "header",
"description": "Generate a new password",
"required": false,
"style": "simple",
"explode": false,
"schema": {
"type": "boolean"
}
}
],
"responses": {
@@ -1062,7 +993,7 @@
}
},
"400": {
"description": "Invalid ID or parameters supplied, user is super admin or user is equal to owner of API key"
"description": "Invalid ID or parameters supplied"
},
"401": {
"description": "Invalid API key provided for authentication or API key does not have the required permission"
@@ -1299,7 +1230,38 @@
"description": "Password for this file to be set. No password will be used if empty"
}
}
},"chunking": {
},"duplicate": {
"required": [
"id"
],
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "ID of file to be duplicated"
},
"allowedDownloads": {
"type": "integer",
"description": "How many downloads are allowed. Original value from web interface will be used if empty. Unlimited if 0 is passed."
},
"expiryDays": {
"type": "integer",
"description": "How many days the file will be stored. Original value from web interface will be used if empty. Unlimited if 0 is passed."
},
"password": {
"type": "string",
"description": "Password for this file to be set. No password will be used if empty."
},
"originalPassword": {
"type": "boolean",
"description": "Set to true to use original password. Field \"password\" will be ignored if set."
},
"filename": {
"type": "string",
"description": "Sets a new filename. Filename will be unchanged if empty."
}
}
},"chunking": {
"required": [
"file","uuid","filesize","offset"
],
@@ -1323,6 +1285,41 @@
"description": "The chunk's offset starting at the beginning of the file"
}
}
},"chunkingcomplete": {
"required": [
"uuid","filename","filesize"
],
"type": "object",
"properties": {
"uuid": {
"type": "string",
"description": "The unique ID that was used for the uploaded chunks"
},
"filename": {
"type": "string",
"description": "The filename of the uploaded file"
},
"filesize": {
"type": "integer",
"description": "The total filesize of the uploaded file in bytes"
},
"contenttype": {
"type": "string",
"description": "The MIME content type. If empty, application/octet-stream will be used."
},
"allowedDownloads": {
"type": "integer",
"description": "How many downloads are allowed. Default of 1 will be used if empty. Unlimited if 0 is passed."
},
"expiryDays": {
"type": "integer",
"description": "How many days the file will be stored. Default of 14 will be used if empty. Unlimited if 0 is passed."
},
"password": {
"type": "string",
"description": "Password for this file to be set. No password will be used if empty"
}
}
}
},
"securitySchemes": {