mirror of
https://github.com/Forceu/Gokapi.git
synced 2025-12-29 21:09:34 -06:00
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:
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const versionJsAdmin = 8
|
||||
const versionJsAdmin = 9
|
||||
const versionJsDropzone = 5
|
||||
const versionJsE2EAdmin = 5
|
||||
const versionCssMain = 4
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -134,6 +134,12 @@ var routes = []apiRoute{
|
||||
execution: apiResetPassword,
|
||||
RequestParser: ¶mUserResetPw{},
|
||||
},
|
||||
{
|
||||
Url: "/logs/delete",
|
||||
ApiPerm: models.ApiPermManageLogs,
|
||||
execution: apiLogsDelete,
|
||||
RequestParser: ¶mLogsDelete{},
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -598,6 +598,34 @@ func (p *paramUserResetPw) New() requestParser {
|
||||
return ¶mUserResetPw{}
|
||||
}
|
||||
|
||||
// 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 ¶mLogsDelete{}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
48
internal/webserver/web/static/js/admin_ui_logs.js
Normal file
48
internal/webserver/web/static/js/admin_ui_logs.js
Normal 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
26
internal/webserver/web/static/js/min/admin.min.9.js
Normal file
26
internal/webserver/web/static/js/min/admin.min.9.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}}
|
||||
289
openapi.json
289
openapi.json
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user