Skip to content

Commit

Permalink
Add API call and GUI option to replace content of files, API returns …
Browse files Browse the repository at this point in the history
…404 on invalid file IDs, better handling for E2E errors
  • Loading branch information
Forceu authored Dec 15, 2024
1 parent 103fc49 commit c167e75
Show file tree
Hide file tree
Showing 17 changed files with 383 additions and 49 deletions.
4 changes: 2 additions & 2 deletions cmd/wasmdownloader/Main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func encryptDecrypt(key []byte, url string, doEncrypt bool) interface{} {
// Tell the controller we have an error
errorConstructor := js.Global().Get("Error")
errorObject := errorConstructor.New(err.Error())
controller.Call("error", errorObject)
js.Global().Call("displayError", errorObject)
return
}
// Read the entire stream and pass it to JavaScript
Expand All @@ -97,7 +97,7 @@ func encryptDecrypt(key []byte, url string, doEncrypt bool) interface{} {
// We're ignoring "EOF" however, which means the stream was done
errorConstructor := js.Global().Get("Error")
errorObject := errorConstructor.New(err.Error())
controller.Call("error", errorObject)
js.Global().Call("displayError", errorObject)
return
}
if n > 0 {
Expand Down
6 changes: 6 additions & 0 deletions internal/configuration/database/provider/redis/Redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ func (p DatabaseProvider) Upgrade(currentDbVersion int) {
for _, file := range allFiles {
p.SaveMetaData(file)
}
for _, apiKey := range p.GetAllApiKeys() {
if apiKey.HasPermissionEdit() {
apiKey.SetPermission(models.ApiPermReplace)
p.SaveApiKey(apiKey)
}
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions internal/configuration/database/provider/sqlite/Sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ func (p DatabaseProvider) Upgrade(currentDbVersion int) {
// Add Column LastUsedString, keeping old data
err := p.rawSqlite(`DROP TABLE IF EXISTS "UploadStatus";`)
helper.Check(err)

for _, apiKey := range p.GetAllApiKeys() {
if apiKey.HasPermissionEdit() {
apiKey.SetPermission(models.ApiPermReplace)
p.SaveApiKey(apiKey)
}
}
}
}

Expand Down
9 changes: 5 additions & 4 deletions internal/configuration/setup/templates/setup.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,7 @@ function TestAWS(button, isManual) {
<li>Does not support download progress bar</li>
<li>Gokapi starts without user input</li>
<li><b>Warning:</b> Password can be read with access to Gokapi configuration</li>
<li><b>Warning:</b> During upload temporary files containing the plaintext content may be created.</li>
<li><b>Warning:</b> During upload, temporary files containing the plaintext content may be created.</li>
<li><b>Warning:</b> Encryption has not been audited.</li>
</ul></p>
</div>
Expand All @@ -581,7 +581,7 @@ function TestAWS(button, isManual) {
<li>Does not support download progress bar</li>
<li>Password cannot be read with access to Gokapi configuration</li>
<li><span style="color:red"><b>Warning:</b></span> Gokapi requires user input to start</li>
<li><b>Warning:</b> During upload temporary files containing the plaintext content may be created.</li>
<li><b>Warning:</b> During upload, temporary files containing the plaintext content may be created.</li>
<li><b>Warning:</b> Encryption has not been audited.</li>
</ul></p>
</div>
Expand All @@ -598,7 +598,7 @@ function TestAWS(button, isManual) {
<li><b>Important:</b> For remote storage, CORS settings for the bucket need to allow access from the Gokapi URL</li>
<li><b>Warning:</b> Download might be significantly slower</li>
<li><b>Warning:</b> Password can be read with access to Gokapi configuration</li>
<li><b>Warning:</b> During upload temporary files containing the plaintext content may be created.</li>
<li><b>Warning:</b> During upload, temporary files containing the plaintext content may be created.</li>
<li><b>Warning:</b> Encryption has not been audited.</li>
</ul></p>
</div>
Expand All @@ -615,7 +615,7 @@ function TestAWS(button, isManual) {
<li><b>Important:</b> For remote storage, CORS settings for the bucket need to allow access from the Gokapi URL</li>
<li><span style="color:red"><b>Warning:</b></span> Gokapi requires user input to start</li>
<li><b>Warning:</b> Download might be significantly slower</li>
<li><b>Warning:</b> During upload temporary files containing the plaintext content may be created.</li>
<li><b>Warning:</b> During upload, temporary files containing the plaintext content may be created.</li>
<li><b>Warning:</b> Encryption has not been audited.</li>
</ul></p>
</div>
Expand All @@ -629,6 +629,7 @@ function TestAWS(button, isManual) {
<li>Does not support deduplication</li>
<li>Does not support hotlinks to files</li>
<li>Does not support download progress bar</li>
<li>Does not support replacing existing files with different content</li>
<li>Gokapi starts without user input</li>
<li>Files uploaded through the API have to be unencrypted</li>
<li>Password cannot be read with access to Gokapi configuration</li>
Expand Down
13 changes: 10 additions & 3 deletions internal/models/Api.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ const (
ApiPermApiMod
// ApiPermEdit is the permission for editing parameters of uploaded files
ApiPermEdit
// ApiPermReplace is the permission for replacing the content of uploaded files
ApiPermReplace
)

// ApiPermNone means no permission granted
const ApiPermNone = 0

// ApiPermAll means all permission granted
const ApiPermAll = 31
const ApiPermAll = 63

// ApiPermAllNoApiMod means all permission granted, except ApiPermApiMod
// ApiPermAllNoApiMod means all permission granted, except ApiPermApiMod and ApiPermReplace
// This is the default for new API keys that are created from the UI
const ApiPermAllNoApiMod = ApiPermAll - ApiPermApiMod
const ApiPermAllNoApiMod = ApiPermAll - ApiPermApiMod - ApiPermReplace

// ApiKey contains data of a single api key
type ApiKey struct {
Expand Down Expand Up @@ -86,6 +88,11 @@ func (key *ApiKey) HasPermissionEdit() bool {
return key.HasPermission(ApiPermEdit)
}

// HasPermissionReplace returns true if ApiPermReplace is granted
func (key *ApiKey) HasPermissionReplace() bool {
return key.HasPermission(ApiPermReplace)
}

// ApiKeyOutput is the output that is used after a new key is created
type ApiKeyOutput struct {
Result string
Expand Down
2 changes: 2 additions & 0 deletions internal/models/FileList.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type FileApiOutput struct {
UnlimitedTime bool `json:"UnlimitedTime"` // True if the uploader did not limit the time
RequiresClientSideDecryption bool `json:"RequiresClientSideDecryption"` // True if the file has to be decrypted client-side
IsEncrypted bool `json:"IsEncrypted"` // True if the file is encrypted
IsEndToEndEncrypted bool `json:"IsEndToEndEncrypted"` // True if the file is end-to-end encrypted
IsPasswordProtected bool `json:"IsPasswordProtected"` // True if a password has to be entered before downloading the file
IsSavedOnLocalStorage bool `json:"IsSavedOnLocalStorage"` // True if the file does not use cloud storage
}
Expand Down Expand Up @@ -76,6 +77,7 @@ func (f *File) ToFileApiOutput(serverUrl string, useFilenameInUrl bool) (FileApi
if f.Encryption.IsEndToEndEncrypted || f.RequiresClientDecryption() {
result.RequiresClientSideDecryption = true
}
result.IsEndToEndEncrypted = f.Encryption.IsEndToEndEncrypted
result.UrlHotlink = getHotlinkUrl(result, serverUrl, useFilenameInUrl)
result.UrlDownload = getDownloadUrl(result, serverUrl, useFilenameInUrl)

Expand Down
4 changes: 2 additions & 2 deletions internal/models/FileList_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ func TestToJsonResult(t *testing.T) {
UnlimitedDownloads: true,
UnlimitedTime: true,
}
test.IsEqualString(t, file.ToJsonResult("serverurl/", false), `{"Result":"OK","FileInfo":{"Id":"testId","Name":"testName","Size":"10 B","HotlinkId":"hotlinkid","ContentType":"text/html","ExpireAtString":"future","UrlDownload":"serverurl/d?id=testId","UrlHotlink":"","ExpireAt":50,"SizeBytes":10,"DownloadsRemaining":1,"DownloadCount":3,"UnlimitedDownloads":true,"UnlimitedTime":true,"RequiresClientSideDecryption":true,"IsEncrypted":true,"IsPasswordProtected":true,"IsSavedOnLocalStorage":false},"IncludeFilename":false}`)
test.IsEqualString(t, file.ToJsonResult("serverurl/", true), `{"Result":"OK","FileInfo":{"Id":"testId","Name":"testName","Size":"10 B","HotlinkId":"hotlinkid","ContentType":"text/html","ExpireAtString":"future","UrlDownload":"serverurl/d/testId/testName","UrlHotlink":"","ExpireAt":50,"SizeBytes":10,"DownloadsRemaining":1,"DownloadCount":3,"UnlimitedDownloads":true,"UnlimitedTime":true,"RequiresClientSideDecryption":true,"IsEncrypted":true,"IsPasswordProtected":true,"IsSavedOnLocalStorage":false},"IncludeFilename":true}`)
test.IsEqualString(t, file.ToJsonResult("serverurl/", false), `{"Result":"OK","FileInfo":{"Id":"testId","Name":"testName","Size":"10 B","HotlinkId":"hotlinkid","ContentType":"text/html","ExpireAtString":"future","UrlDownload":"serverurl/d?id=testId","UrlHotlink":"","ExpireAt":50,"SizeBytes":10,"DownloadsRemaining":1,"DownloadCount":3,"UnlimitedDownloads":true,"UnlimitedTime":true,"RequiresClientSideDecryption":true,"IsEncrypted":true,"IsEndToEndEncrypted":false,"IsPasswordProtected":true,"IsSavedOnLocalStorage":false},"IncludeFilename":false}`)
test.IsEqualString(t, file.ToJsonResult("serverurl/", true), `{"Result":"OK","FileInfo":{"Id":"testId","Name":"testName","Size":"10 B","HotlinkId":"hotlinkid","ContentType":"text/html","ExpireAtString":"future","UrlDownload":"serverurl/d/testId/testName","UrlHotlink":"","ExpireAt":50,"SizeBytes":10,"DownloadsRemaining":1,"DownloadCount":3,"UnlimitedDownloads":true,"UnlimitedTime":true,"RequiresClientSideDecryption":true,"IsEncrypted":true,"IsEndToEndEncrypted":false,"IsPasswordProtected":true,"IsSavedOnLocalStorage":false},"IncludeFilename":true}`)
}

func TestIsLocalStorage(t *testing.T) {
Expand Down
35 changes: 35 additions & 0 deletions internal/storage/FileServing.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ import (
// ErrorFileTooLarge is an error that is called when a file larger than the set maximum is uploaded
var ErrorFileTooLarge = errors.New("upload limit exceeded")

// ErrorReplaceE2EFile is caused when an end-to-end encrypted file is replaced
var ErrorReplaceE2EFile = errors.New("end-to-end encrypted files cannot be replaced")

// ErrorFileNotFound is raised when an invalid ID is passed or the file has expired
var ErrorFileNotFound = errors.New("file not found")

// NewFile creates a new file in the system. Called after an upload from the API has been completed. If a file with the same sha1 hash
// already exists, it is deduplicated. This function gathers information about the file, creates an ID and saves
// it into the global configuration. It is now only used by the API, the web UI uses NewFileFromChunk
Expand Down Expand Up @@ -337,6 +343,35 @@ const (
ParamName
)

// ReplaceFile replaces the file content of fileId with the content of newFileContentId
// Replacing e2e encrypted files is NOT possible
func ReplaceFile(fileId, newFileContentId string, delete bool) (models.File, error) {
file, ok := GetFile(fileId)
if !ok {
return models.File{}, ErrorFileNotFound
}
newFileContent, ok := GetFile(newFileContentId)
if !ok {
return models.File{}, ErrorFileNotFound
}
if file.Encryption.IsEndToEndEncrypted || newFileContent.Encryption.IsEndToEndEncrypted {
return models.File{}, ErrorReplaceE2EFile
}

file.Name = newFileContent.Name
file.Size = newFileContent.Size
file.SHA1 = newFileContent.SHA1
file.ContentType = newFileContent.ContentType
file.AwsBucket = newFileContent.AwsBucket
file.SizeBytes = newFileContent.SizeBytes
file.Encryption = newFileContent.Encryption
database.SaveMetaData(file)
if delete {
DeleteFile(newFileContent.Id, false)
}
return file, nil
}

// DuplicateFile creates a copy of an existing file with new parameters
func DuplicateFile(file models.File, parametersToChange int, newFileName string, fileParameters models.UploadRequest) (models.File, error) {
var newFile models.File
Expand Down
42 changes: 36 additions & 6 deletions internal/webserver/api/Api.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ func Process(w http.ResponseWriter, r *http.Request, maxMemory int) {
duplicateFile(w, request)
case "/files/modify":
editFile(w, request)
case "/files/replace":
replaceFile(w, request)
case "/auth/create":
createApiKey(w, request)
case "/auth/friendlyname":
Expand All @@ -60,7 +62,7 @@ func Process(w http.ResponseWriter, r *http.Request, maxMemory int) {
func editFile(w http.ResponseWriter, request apiRequest) {
file, ok := database.GetMetaDataById(request.filemodInfo.id)
if !ok {
sendError(w, http.StatusBadRequest, "Invalid file ID provided.")
sendError(w, http.StatusNotFound, "Invalid file ID provided.")
return
}
if request.filemodInfo.downloads != "" {
Expand Down Expand Up @@ -125,6 +127,8 @@ func getApiPermissionRequired(requestUrl string) (uint8, bool) {
return models.ApiPermUpload, true
case "/files/modify":
return models.ApiPermEdit, true
case "/files/replace":
return models.ApiPermReplace, true
case "/auth/create":
return models.ApiPermApiMod, true
case "/auth/friendlyname":
Expand Down Expand Up @@ -200,7 +204,7 @@ func modifyApiPermission(w http.ResponseWriter, request apiRequest) {
if !isValidKeyForEditing(w, request) {
return
}
if request.apiInfo.permission < models.ApiPermView || request.apiInfo.permission > models.ApiPermEdit {
if request.apiInfo.permission < models.ApiPermView || request.apiInfo.permission > models.ApiPermReplace {
sendError(w, http.StatusBadRequest, "Invalid permission sent")
return
}
Expand Down Expand Up @@ -328,10 +332,9 @@ func list(w http.ResponseWriter) {
}

func listSingle(w http.ResponseWriter, id string) {
timeNow := time.Now().Unix()
config := configuration.Get()
file, ok := database.GetMetaDataById(id)
if !ok || storage.IsExpiredFile(file, timeNow) {
file, ok := storage.GetFile(id)
if !ok {
sendError(w, http.StatusNotFound, "Could not find file with id "+id)
return
}
Expand Down Expand Up @@ -365,7 +368,7 @@ func duplicateFile(w http.ResponseWriter, request apiRequest) {
}
file, ok := storage.GetFile(request.fileInfo.id)
if !ok {
sendError(w, http.StatusBadRequest, "Invalid id provided.")
sendError(w, http.StatusNotFound, "Invalid id provided.")
return
}
err = request.parseUploadRequest()
Expand All @@ -381,6 +384,27 @@ func duplicateFile(w http.ResponseWriter, request apiRequest) {
outputFileInfo(w, newFile)
}

func replaceFile(w http.ResponseWriter, request apiRequest) {
err := request.parseForm()
if err != nil {
sendError(w, http.StatusBadRequest, err.Error())
return
}
modifiedFile, err := storage.ReplaceFile(request.fileInfo.id, request.filemodInfo.idNewContent, request.filemodInfo.deleteNewFile)
if err != nil {
switch {
case errors.Is(err, storage.ErrorReplaceE2EFile):
sendError(w, http.StatusBadRequest, "End-to-End encrypted files cannot be replaced")
case errors.Is(err, storage.ErrorFileNotFound):
sendError(w, http.StatusNotFound, "A file with such an ID could not be found")
default:
sendError(w, http.StatusBadRequest, err.Error())
}
return
}
outputFileInfo(w, modifiedFile)
}

func outputFileInfo(w http.ResponseWriter, file models.File) {
config := configuration.Get()
publicOutput, err := file.ToFileApiOutput(config.ServerUrl, config.IncludeFilename)
Expand Down Expand Up @@ -456,10 +480,12 @@ type apiInfo struct {
}
type filemodInfo struct {
id string
idNewContent string
downloads string
expiry string
password string
originalPassword bool
deleteNewFile bool
}

func parseRequest(r *http.Request) apiRequest {
Expand All @@ -475,6 +501,8 @@ func parseRequest(r *http.Request) apiRequest {
permission = models.ApiPermApiMod
case "PERM_EDIT":
permission = models.ApiPermEdit
case "PERM_REPLACE":
permission = models.ApiPermReplace
}
return apiRequest{
apiKey: r.Header.Get("apikey"),
Expand All @@ -483,10 +511,12 @@ func parseRequest(r *http.Request) apiRequest {
fileInfo: fileInfo{id: r.Header.Get("id")},
filemodInfo: filemodInfo{
id: r.Header.Get("id"),
idNewContent: r.Header.Get("idNewContent"),
downloads: r.Header.Get("allowedDownloads"),
expiry: r.Header.Get("expiryTimestamp"),
password: r.Header.Get("password"),
originalPassword: r.Header.Get("originalPassword") == "true",
deleteNewFile: r.Header.Get("deleteOriginal") == "true",
},
apiInfo: apiInfo{
friendlyName: r.Header.Get("friendlyName"),
Expand Down
2 changes: 1 addition & 1 deletion internal/webserver/api/Api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func setPermissionApikey(key string, newPermission uint8, t *testing.T) {
}

func getAvailablePermissions(t *testing.T) []uint8 {
result := []uint8{models.ApiPermView, models.ApiPermUpload, models.ApiPermDelete, models.ApiPermApiMod, models.ApiPermEdit}
result := []uint8{models.ApiPermView, models.ApiPermUpload, models.ApiPermDelete, models.ApiPermApiMod, models.ApiPermEdit, models.ApiPermReplace}
sum := 0
for _, perm := range result {
sum = sum + int(perm)
Expand Down
Loading

0 comments on commit c167e75

Please sign in to comment.