Add API call and GUI option to replace content of files, API returns 404 on invalid file IDs, better handling for E2E errors

This commit is contained in:
Marc Ole Bulling
2024-12-15 17:07:29 +01:00
committed by GitHub
parent 103fc49f8e
commit c167e752f3
17 changed files with 383 additions and 49 deletions

View File

@@ -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
@@ -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 {

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>

View File

@@ -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 {
@@ -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

View File

@@ -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
}
@@ -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)

View File

@@ -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) {

View File

@@ -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
@@ -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

View File

@@ -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":
@@ -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 != "" {
@@ -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":
@@ -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
}
@@ -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
}
@@ -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()
@@ -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)
@@ -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 {
@@ -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"),
@@ -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"),

View File

@@ -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)

View File

@@ -278,6 +278,9 @@
},
"401": {
"description": "Invalid API key provided"
},
"404": {
"description": "Invalid ID provided or file has expired"
}
}
}
@@ -292,7 +295,7 @@
"operationId": "modifyfile",
"security": [
{
"apikey": ["API_EDIT"]
"apikey": ["EDIT"]
},
],
"parameters": [
@@ -358,6 +361,73 @@
},
"401": {
"description": "Invalid API key provided"
},
"404": {
"description": "Invalid ID provided or file has expired"
}
}
}
},
"/files/replace": {
"put": {
"tags": [
"files"
],
"summary": "Replaces an uploaded file",
"description": "This API replaces the content of an uploaded file with the content of a different (already uplaoded) file. Note: Replacing end-to-end ecrypted files is NOT possible and will result in an error when downloading. Requires permission REPLACE",
"operationId": "replacefile",
"security": [
{
"apikey": ["REPLACE"]
},
],
"parameters": [
{
"name": "id",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "ID of file to be replaced"
},{
"name": "idNewContent",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "ID of the file with the new content"
},
{
"name": "deleteNewFile",
"in": "header",
"required": false,
"schema": {
"type": "boolean"
},
"description": "If true, the file with the ID passed in idNewContent will be deleted afterwards"
}
],
"responses": {
"200": {
"description": "Operation successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/File"
}
}
}
},
"400": {
"description": "Invalid ID supplied or incorrect data type sent"
},
"401": {
"description": "Invalid API key provided"
},
"404": {
"description": "Invalid ID provided or file has expired"
}
}
}
@@ -697,6 +767,11 @@
"type": "boolean",
"example": "false"
},
"IsEndToEndEncrypted": {
"description": "True if the file is end-to-end encrypted",
"type": "boolean",
"example": "false"
},
"IsPasswordProtected": {
"type": "boolean",
"description": "True if a password has to be entered before downloading the file",

View File

@@ -340,10 +340,62 @@ function showError(file, message) {
}
async function apiEdit(id, allowedDownloads, expiry, password, originalPw) {
let apiUrl = './api/files/modify';
const requestOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'id': id,
'apikey': systemKey,
'allowedDownloads': allowedDownloads,
'expiryTimestamp': expiry,
'password': password,
'originalPassword': originalPw
},
};
await fetch(apiUrl, requestOptions)
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
})
.catch(error => {
throw error
});
}
async function apiReplace(id, newId) {
let apiUrl = './api/files/replace';
const requestOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'id': id,
'apikey': systemKey,
'idNewContent': newId,
'deleteNewFile': false
},
};
await fetch(apiUrl, requestOptions)
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
})
.catch(error => {
throw error
});
}
function editFile() {
const button = document.getElementById('mb_save');
button.disabled = true;
let apiUrl = './api/files/modify';
let id = button.getAttribute('data-fileid');
let allowedDownloads = document.getElementById('mi_edit_down').value;
let expiryTimestamp = document.getElementById('mi_edit_expiry').value;
@@ -361,29 +413,28 @@ function editFile() {
password = "";
}
const requestOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'id': button.getAttribute('data-fileid'),
'apikey': systemKey,
'allowedDownloads': allowedDownloads,
'expiryTimestamp': expiryTimestamp,
'password': password,
'originalPassword': originalPassword
let replaceFile = false;
let replaceId = "";
if (document.getElementById('mc_replace').checked) {
replaceFile = true;
replaceId = document.getElementById('mi_edit_replace').value;
}
},
};
// Send the request
fetch(apiUrl, requestOptions)
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
})
apiEdit(id, allowedDownloads, expiryTimestamp, password, originalPassword)
.then(data => {
location.reload();
if (!replaceFile) {
location.reload();
return;
}
apiReplace(id, replaceId)
.then(data => {
location.reload();
})
.catch(error => {
alert("Unable to edit file: " + error);
console.error('Error:', error);
button.disabled = false;
});
})
.catch(error => {
alert("Unable to edit file: " + error);
@@ -433,7 +484,7 @@ function handleEditCheckboxChange(checkbox) {
}
function showEditModal(filename, id, downloads, expiry, password, unlimitedown, unlimitedtime) {
function showEditModal(filename, id, downloads, expiry, password, unlimitedown, unlimitedtime, isE2e) {
document.getElementById("m_filenamelabel").innerHTML = filename;
document.getElementById("mc_expiry").setAttribute("data-timestamp", expiry);
document.getElementById("mb_save").setAttribute('data-fileid', id);
@@ -470,6 +521,24 @@ function showEditModal(filename, id, downloads, expiry, password, unlimitedown,
document.getElementById("mi_edit_pw").disabled = true;
document.getElementById("mc_password").checked = false;
}
let selectReplace = document.getElementById("mi_edit_replace");
if (!isE2e) {
let files = getAllAvailableFiles();
for (let i = 0; i < files[0].length; i++) {
if (files[0][i] == id)
continue;
selectReplace.add(new Option(files[1][i] + " (" + files[0][i] + ")", files[0][i]));
}
} else {
document.getElementById("mc_replace").disabled = true;
document.getElementById("mc_replace").title = "Replacing content is not available for end-to-end encrypted files";
selectReplace.add(new Option("Unavailable", 0));
selectReplace.title = "Replacing content is not available for end-to-end encrypted files";
selectReplace.value = "0";
}
new bootstrap.Modal('#modaledit', {}).show();
}
@@ -490,6 +559,18 @@ function add14DaysIfBeforeCurrentTime(unixTimestamp) {
}
}
function getAllAvailableFiles() {
let ids = [];
let filenames = [];
let elements = document.querySelectorAll('[id^="cell-name-"]');
for (let element of elements) {
ids.push(element.id.replace("cell-name-", ""));
filenames.push(element.innerHTML);
}
return [ids, filenames];
}
function changeApiPermission(apiKey, permission, buttonId) {
var indicator = document.getElementById(buttonId);
@@ -807,7 +888,7 @@ function addRow(item) {
buttons = buttons + '<button type="button" onclick="showToast()" data-clipboard-text="' + item.UrlHotlink + '" class="copyurl btn btn-outline-light btn-sm"><i class="bi bi-copy"></i> Hotlink</button> ';
}
buttons = buttons + '<button type="button" id="qrcode-' + item.Id + '" title="QR Code" class="btn btn-outline-light btn-sm" onclick="showQrCode(\'' + item.UrlDownload + '\');"><i class="bi bi-qr-code"></i></button> ';
buttons = buttons + '<button type="button" title="Edit" class="btn btn-outline-light btn-sm" onclick="showEditModal(\'' + item.Name + '\',\'' + item.Id + '\', ' + item.DownloadsRemaining + ', ' + item.ExpireAt + ', ' + item.IsPasswordProtected + ', ' + item.UnlimitedDownloads + ', ' + item.UnlimitedTime + ');"><i class="bi bi-pencil"></i></button> ';
buttons = buttons + '<button type="button" title="Edit" class="btn btn-outline-light btn-sm" onclick="showEditModal(\'' + item.Name + '\',\'' + item.Id + '\', ' + item.DownloadsRemaining + ', ' + item.ExpireAt + ', ' + item.IsPasswordProtected + ', ' + item.UnlimitedDownloads + ', ' + item.UnlimitedTime + ', ' + item.IsEndToEndEncrypted + ');"><i class="bi bi-pencil"></i></button> ';
buttons = buttons + '<button type="button" id="button-delete-' + item.Id + '" title="Delete" class="btn btn-outline-danger btn-sm" onclick="deleteFile(\'' + item.Id + '\')"><i class="bi bi-trash3"></i></button>';
cellButtons.innerHTML = buttons;

File diff suppressed because one or more lines are too long

View File

@@ -86,7 +86,7 @@
<button type="button"class="copyurl btn btn-outline-light btn-sm disabled"><i class="bi bi-copy"></i> Hotlink</button>
{{ end }}
<button type="button" id="qrcode-{{ .Id }}" title="QR Code" class="btn btn-outline-light btn-sm" onclick="showQrCode('{{ .UrlDownload }}');"><i class="bi bi-qr-code"></i></button>
<button type="button" title="Edit" class="btn btn-outline-light btn-sm" onclick="showEditModal('{{.Name }}','{{.Id}}', {{.DownloadsRemaining }}, {{.ExpireAt }}, {{.IsPasswordProtected}}, {{.UnlimitedDownloads }}, {{.UnlimitedTime}});"><i class="bi bi-pencil"></i></button>
<button type="button" title="Edit" class="btn btn-outline-light btn-sm" onclick="showEditModal('{{.Name }}','{{.Id}}', {{.DownloadsRemaining }}, {{.ExpireAt }}, {{.IsPasswordProtected}}, {{.UnlimitedDownloads }}, {{.UnlimitedTime}}, {{.IsEndToEndEncrypted}});"><i class="bi bi-pencil"></i></button>
<button id="button-delete-{{ .Id }}" type="button" title="Delete" class="btn btn-outline-danger btn-sm" onclick="deleteFile('{{ .Id }}')"><i class="bi bi-trash3"></i></button></td>
</tr>
{{ end }}
@@ -102,7 +102,7 @@
<!-- Modal for editing-->
<div class="modal fade" id="modaledit" tabindex="-1" aria-labelledby="m_filenamelabel" aria-hidden="true">
<div class="modal-dialog gokapi-dialog">
<div class="modal-dialog modal-lg gokapi-dialog">
<div class="modal-content gokapi-dialog">
<div class="modal-header">
<h1 class="modal-title fs-5" id="m_filenamelabel">Filename</h1>
@@ -129,9 +129,18 @@
<span class="input-group-text" id="edit_pw">Password&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<input type="text" id="mi_edit_pw" class="form-control" aria-label="Password" disabled onclick="selectTextForPw(this)" aria-describedby="edit_pw">
</div>
<div class="input-group mb-3">
<div class="input-group-text">
<input type="checkbox" id="mc_replace" aria-label="Replace file content" title="Replace file content" data-toggle-target="mi_edit_replace" onchange="handleEditCheckboxChange(this)">
</div>
<span class="input-group-text" id="edit_replace">Replace Content&nbsp;&nbsp;</span>
<select id="mi_edit_replace" class="form-select" aria-label="Replace File Content" disabled>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-light" aria-label="Close" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" data-fileid="" id="mb_save" onclick="editFile();">Save changes</button>
</div>
</div>
@@ -178,6 +187,7 @@
registerChangeHandler();
var systemKey = "{{.SystemKey}}";
setUploadDefaults();
</script>
{{ if .EndToEndEncryption }}

View File

@@ -33,6 +33,7 @@
<i id="perm_upload_{{ .Id }}" class="bi bi-file-earmark-arrow-up {{if not .HasPermissionUpload}}apiperm-notgranted{{else}}apiperm-granted{{end}}" title="Upload" onclick='changeApiPermission("{{ .Id }}","PERM_UPLOAD", "perm_upload_{{ .Id }}");'></i>
<i id="perm_edit_{{ .Id }}" class="bi bi-pencil {{if not .HasPermissionEdit}}apiperm-notgranted{{else}}apiperm-granted{{end}}" title="Edit Uploads" onclick='changeApiPermission("{{ .Id }}","PERM_EDIT", "perm_edit_{{ .Id }}");'></i>
<i id="perm_delete_{{ .Id }}" class="bi bi-trash3 {{if not .HasPermissionDelete}}apiperm-notgranted{{else}}apiperm-granted{{end}}" title="Delete Uploads" onclick='changeApiPermission("{{ .Id }}","PERM_DELETE", "perm_delete_{{ .Id }}");'></i>
<i id="perm_replace_{{ .Id }}" class="bi bi-recycle {{if not .HasPermissionReplace}}apiperm-notgranted{{else}}apiperm-granted{{end}}" title="Replace Uploads" onclick='changeApiPermission("{{ .Id }}","PERM_REPLACE", "perm_replace_{{ .Id }}");'></i>
<i id="perm_api_{{ .Id }}" class="bi bi-sliders2 {{if not .HasPermissionApiMod}}apiperm-notgranted{{else}}apiperm-granted{{end}}" title="Manage API Keys" onclick='changeApiPermission("{{ .Id }}","PERM_API_MOD", "perm_api_{{ .Id }}");'></i>
</td>

View File

@@ -133,8 +133,12 @@
function Download(button) {
button.disabled = true;
DownloadEncrypted();
DownloadEncrypted().catch(err => {
console.error('Error during download:', err);
displayError(err);
});
}
</script>
{{ else }}

View File

@@ -278,6 +278,9 @@
},
"401": {
"description": "Invalid API key provided"
},
"404": {
"description": "Invalid ID provided or file has expired"
}
}
}
@@ -292,7 +295,7 @@
"operationId": "modifyfile",
"security": [
{
"apikey": ["API_EDIT"]
"apikey": ["EDIT"]
},
],
"parameters": [
@@ -358,6 +361,73 @@
},
"401": {
"description": "Invalid API key provided"
},
"404": {
"description": "Invalid ID provided or file has expired"
}
}
}
},
"/files/replace": {
"put": {
"tags": [
"files"
],
"summary": "Replaces an uploaded file",
"description": "This API replaces the content of an uploaded file with the content of a different (already uplaoded) file. Note: Replacing end-to-end ecrypted files is NOT possible and will result in an error when downloading. Requires permission REPLACE",
"operationId": "replacefile",
"security": [
{
"apikey": ["REPLACE"]
},
],
"parameters": [
{
"name": "id",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "ID of file to be replaced"
},{
"name": "idNewContent",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "ID of the file with the new content"
},
{
"name": "deleteNewFile",
"in": "header",
"required": false,
"schema": {
"type": "boolean"
},
"description": "If true, the file with the ID passed in idNewContent will be deleted afterwards"
}
],
"responses": {
"200": {
"description": "Operation successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/File"
}
}
}
},
"400": {
"description": "Invalid ID supplied or incorrect data type sent"
},
"401": {
"description": "Invalid API key provided"
},
"404": {
"description": "Invalid ID provided or file has expired"
}
}
}
@@ -697,6 +767,11 @@
"type": "boolean",
"example": "false"
},
"IsEndToEndEncrypted": {
"description": "True if the file is end-to-end encrypted",
"type": "boolean",
"example": "false"
},
"IsPasswordProtected": {
"type": "boolean",
"description": "True if a password has to be entered before downloading the file",