Added feature for editing uploaded files #103, updated Bootstrap to 5.3

This commit is contained in:
Marc Ole Bulling
2023-11-29 20:45:27 +01:00
parent d24ea9735a
commit a130357018
22 changed files with 584 additions and 126 deletions

View File

@@ -5,11 +5,12 @@ const (
ApiPermUpload
ApiPermDelete
ApiPermApiMod
ApiPermEdit
)
const ApiPermNone = 0
const ApiPermAllNoApiMod = 7
const ApiPermAll = 15
const ApiPermAllNoApiMod = 23
const ApiPermAll = 31
// ApiKey contains data of a single api key
type ApiKey struct {
@@ -49,3 +50,7 @@ func (key *ApiKey) HasPermissionDelete() bool {
func (key *ApiKey) HasPermissionApiMod() bool {
return key.HasPermission(ApiPermApiMod)
}
func (key *ApiKey) HasPermissionEdit() bool {
return key.HasPermission(ApiPermEdit)
}

View File

@@ -264,6 +264,10 @@ func encryptChunkFile(file *os.File, metadata *models.File) (*os.File, error) {
return tempFileEnc, nil
}
func FormatTimestamp(timestamp int64) string {
return time.Unix(timestamp, 0).Format("2006-01-02 15:04")
}
func createNewMetaData(hash string, fileHeader chunking.FileHeader, uploadRequest models.UploadRequest) models.File {
file := models.File{
Id: createNewId(),
@@ -273,7 +277,7 @@ func createNewMetaData(hash string, fileHeader chunking.FileHeader, uploadReques
SizeBytes: fileHeader.Size,
ContentType: fileHeader.ContentType,
ExpireAt: uploadRequest.ExpiryTimestamp,
ExpireAtString: time.Unix(uploadRequest.ExpiryTimestamp, 0).Format("2006-01-02 15:04"),
ExpireAtString: FormatTimestamp(uploadRequest.ExpiryTimestamp),
DownloadsRemaining: uploadRequest.AllowedDownloads,
UnlimitedTime: uploadRequest.UnlimitedTime,
UnlimitedDownloads: uploadRequest.UnlimitedDownload,
@@ -348,7 +352,7 @@ func DuplicateFile(file models.File, parametersToChange int, newFileName string,
if changeExpiry {
newFile.ExpireAt = fileParameters.ExpiryTimestamp
newFile.ExpireAtString = time.Unix(fileParameters.ExpiryTimestamp, 0).Format("2006-01-02 15:04")
newFile.ExpireAtString = FormatTimestamp(fileParameters.ExpiryTimestamp)
newFile.UnlimitedTime = fileParameters.UnlimitedTime
}
if changeDownloads {

View File

@@ -36,15 +36,58 @@ func Process(w http.ResponseWriter, r *http.Request, maxMemory int) {
deleteFile(w, request)
case "/files/duplicate":
duplicateFile(w, request)
case "/files/modify":
editFile(w, request)
case "/auth/friendlyname":
changeFriendlyName(w, request)
case "/auth/modifypermission":
case "/auth/modify":
modifyApiPermission(w, request)
default:
sendError(w, http.StatusBadRequest, "Invalid request")
}
}
func editFile(w http.ResponseWriter, request apiRequest) {
file, ok := database.GetMetaDataById(request.filemodInfo.id)
if !ok {
sendError(w, http.StatusBadRequest, "Invalid file ID provided.")
return
}
if request.filemodInfo.downloads != "" {
dowloadsInt, err := strconv.Atoi(request.filemodInfo.downloads)
if err != nil {
sendError(w, http.StatusBadRequest, "Invalid download count provided.")
return
}
if dowloadsInt != 0 {
file.DownloadsRemaining = dowloadsInt
file.UnlimitedDownloads = false
} else {
file.UnlimitedDownloads = true
}
}
if request.filemodInfo.expiry != "" {
expiryInt, err := strconv.ParseInt(request.filemodInfo.expiry, 10, 64)
if err != nil {
sendError(w, http.StatusBadRequest, "Invalid expiry timestamp provided.")
return
}
if expiryInt != 0 {
file.ExpireAt = expiryInt
file.ExpireAtString = storage.FormatTimestamp(expiryInt)
file.UnlimitedTime = false
} else {
file.UnlimitedTime = true
}
}
if !request.filemodInfo.originalPassword {
file.PasswordHash = configuration.HashPassword(request.filemodInfo.password, true)
}
database.SaveMetaData(file)
outputFileInfo(w, file)
}
func getApiPermissionRequired(requestUrl string) (uint8, bool) {
switch requestUrl {
case "/chunk/add":
@@ -58,10 +101,12 @@ func getApiPermissionRequired(requestUrl string) (uint8, bool) {
case "/files/delete":
return models.ApiPermDelete, true
case "/files/duplicate":
return models.ApiPermUpload | models.ApiPermView, true
return models.ApiPermUpload, true
case "/files/modify":
return models.ApiPermEdit, true
case "/auth/friendlyname":
return models.ApiPermApiMod, true
case "/auth/modifypermission":
case "/auth/modify":
return models.ApiPermApiMod, true
default:
return models.ApiPermNone, false
@@ -93,7 +138,7 @@ func modifyApiPermission(w http.ResponseWriter, request apiRequest) {
if !isValidKeyForEditing(w, request) {
return
}
if request.apiInfo.permission < models.ApiPermView || request.apiInfo.permission > models.ApiPermApiMod {
if request.apiInfo.permission < models.ApiPermView || request.apiInfo.permission > models.ApiPermEdit {
sendError(w, http.StatusBadRequest, "Invalid permission sent")
return
}
@@ -142,7 +187,7 @@ func changeFriendlyName(w http.ResponseWriter, request apiRequest) {
func deleteFile(w http.ResponseWriter, request apiRequest) {
ok := storage.DeleteFile(request.fileInfo.id, true)
if !ok {
sendError(w, http.StatusBadRequest, "Invalid id provided.")
sendError(w, http.StatusBadRequest, "Invalid file ID provided.")
}
}
@@ -150,6 +195,7 @@ func chunkAdd(w http.ResponseWriter, request apiRequest) {
maxUpload := int64(configuration.Get().MaxFileSizeMB) * 1024 * 1024
if request.request.ContentLength > maxUpload {
sendError(w, http.StatusBadRequest, storage.ErrorFileTooLarge.Error())
return
}
request.request.Body = http.MaxBytesReader(w, request.request.Body, maxUpload)
@@ -162,12 +208,12 @@ func chunkComplete(w http.ResponseWriter, request apiRequest) {
err := request.request.ParseForm()
if err != nil {
sendError(w, http.StatusBadRequest, err.Error())
return
}
request.request.Form.Set("chunkid", request.request.Form.Get("uuid"))
err = fileupload.CompleteChunk(w, request.request, true)
if err != nil {
sendError(w, http.StatusBadRequest, err.Error())
return
}
}
@@ -190,6 +236,7 @@ func upload(w http.ResponseWriter, request apiRequest, maxMemory int) {
maxUpload := int64(configuration.Get().MaxFileSizeMB) * 1024 * 1024
if request.request.ContentLength > maxUpload {
sendError(w, http.StatusBadRequest, storage.ErrorFileTooLarge.Error())
return
}
request.request.Body = http.MaxBytesReader(w, request.request.Body, maxUpload)
@@ -221,7 +268,11 @@ func duplicateFile(w http.ResponseWriter, request apiRequest) {
sendError(w, http.StatusBadRequest, err.Error())
return
}
publicOutput, err := newFile.ToFileApiOutput()
outputFileInfo(w, newFile)
}
func outputFileInfo(w http.ResponseWriter, file models.File) {
publicOutput, err := file.ToFileApiOutput()
helper.Check(err)
result, err := json.Marshal(publicOutput)
helper.Check(err)
@@ -231,7 +282,8 @@ func duplicateFile(w http.ResponseWriter, request apiRequest) {
func isAuthorisedForApi(w http.ResponseWriter, request apiRequest) bool {
perm, ok := getApiPermissionRequired(request.requestUrl)
if !ok {
sendError(w, http.StatusUnauthorized, "Unauthorized")
sendError(w, http.StatusBadRequest, "Invalid request")
return false
}
if IsValidApiKey(request.apiKey, true, perm) || sessionmanager.IsValidSession(w, request.request) {
return true
@@ -240,7 +292,6 @@ func isAuthorisedForApi(w http.ResponseWriter, request apiRequest) bool {
return false
}
// TODO investigate superfluous response.WriteHeader call from github.com/forceu/gokapi/internal/webserver/api.sendError (Api.go:244)
// Probably from new API permission system
func sendError(w http.ResponseWriter, errorInt int, errorMessage string) {
w.WriteHeader(errorInt)
@@ -248,11 +299,12 @@ func sendError(w http.ResponseWriter, errorInt int, errorMessage string) {
}
type apiRequest struct {
apiKey string
requestUrl string
request *http.Request
fileInfo fileInfo
apiInfo apiInfo
apiKey string
requestUrl string
request *http.Request
fileInfo fileInfo
apiInfo apiInfo
filemodInfo filemodInfo
}
func (a *apiRequest) parseUploadRequest() error {
@@ -290,6 +342,13 @@ type apiInfo struct {
permission uint8
grantPermission bool
}
type filemodInfo struct {
id string
downloads string
expiry string
password string
originalPassword bool
}
func parseRequest(r *http.Request) apiRequest {
permission := models.ApiPermNone
@@ -302,12 +361,21 @@ func parseRequest(r *http.Request) apiRequest {
permission = models.ApiPermDelete
case "PERM_API_MOD":
permission = models.ApiPermApiMod
case "PERM_EDIT":
permission = models.ApiPermEdit
}
return apiRequest{
apiKey: r.Header.Get("apikey"),
requestUrl: strings.Replace(r.URL.String(), "/api", "", 1),
request: r,
fileInfo: fileInfo{id: r.Header.Get("id")},
filemodInfo: filemodInfo{
id: r.Header.Get("id"),
downloads: r.Header.Get("allowedDownloads"),
expiry: r.Header.Get("expiryTimestamp"),
password: r.Header.Get("password"),
originalPassword: r.Header.Get("originalPassword") == "true",
},
apiInfo: apiInfo{
friendlyName: r.Header.Get("friendlyName"),
apiKeyToModify: r.Header.Get("apiKeyToModify"),
@@ -384,8 +452,7 @@ func IsValidApiKey(key string, modifyTime bool, requiredPermission uint8) bool {
savedKey.LastUsed = time.Now().Unix()
database.UpdateTimeApiKey(savedKey)
}
result := savedKey.HasPermission(requiredPermission)
return result
return savedKey.HasPermission(requiredPermission)
}
return false
}

View File

@@ -76,7 +76,7 @@ func TestProcess(t *testing.T) {
test.ResponseBodyContains(t, w, "{\"Result\":\"error\",\"ErrorMessage\":\"Unauthorized\"}")
w, r = test.GetRecorder("GET", "/api/invalid", nil, nil, nil)
Process(w, r, maxMemory)
test.ResponseBodyContains(t, w, "Unauthorized")
test.ResponseBodyContains(t, w, "Invalid request")
w, r = test.GetRecorder("GET", "/api/invalid", nil, []test.Header{{
Name: "apikey",
Value: "validkey",
@@ -138,7 +138,7 @@ func TestDeleteFile(t *testing.T) {
Value: "validkey",
}}, nil)
Process(w, r, maxMemory)
test.ResponseBodyContains(t, w, "Invalid id provided.")
test.ResponseBodyContains(t, w, "Invalid file ID provided")
w, r = test.GetRecorder("GET", "/api/files/delete", nil, []test.Header{{
Name: "apikey",
Value: "validkey",
@@ -148,7 +148,7 @@ func TestDeleteFile(t *testing.T) {
},
}, nil)
Process(w, r, maxMemory)
test.ResponseBodyContains(t, w, "Invalid id provided.")
test.ResponseBodyContains(t, w, "Invalid file ID provided")
file, ok := database.GetMetaDataById("jpLXGJKigM4hjtA6T6sN2")
test.IsEqualBool(t, ok, true)
test.IsEqualString(t, file.Id, "jpLXGJKigM4hjtA6T6sN2")

View File

@@ -70,7 +70,7 @@
"/chunk/add": {
"post": {
"tags": [
"files"
"chunk"
],
"summary": "Uploads a new chunk",
"description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text!",
@@ -116,7 +116,7 @@
"/chunk/complete": {
"post": {
"tags": [
"files"
"chunk"
],
"summary": "Finalises uploaded chunks",
"description": "Needs to be called after all chunks have been uploaded. Adds the uploaded file to Gokapi.",
@@ -251,6 +251,89 @@
}
}
},
"/files/modify": {
"put": {
"tags": [
"files"
],
"summary": "Changes parameters of an uploaded file",
"description": "This API call changes parameters of an uploaded file",
"operationId": "modifyfile",
"security": [
{
"apikey": ["API_EDIT"]
},
{
"session": []
}
],
"parameters": [
{
"name": "id",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "ID of file to be edited"
},
{
"name": "allowedDownloads",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many remaining downloads are allowed. Unlimited if 0 is passed."
},
{
"name": "expiryTimestamp",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "Unix timestamp of the file expiration date. 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."
},
{
"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."
}
],
"responses": {
"200": {
"description": "Operation successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadResult"
}
}
}
},
"400": {
"description": "Invalid ID supplied or incorrect data type sent"
},
"401": {
"description": "Invalid API key provided or not logged in as admin"
}
}
}
},
"/files/delete": {
"delete": {
"tags": [
@@ -346,7 +429,7 @@
}
}
},
"/auth/modifypermission": {
"/auth/modify": {
"put": {
"tags": [
"auth"
@@ -383,7 +466,7 @@
"explode": false,
"schema": {
"type": "string",
"enum": ["PERM_VIEW", "PERM_UPLOAD", "PERM_DELETE", "PERM_API_MOD"]
"enum": ["PERM_VIEW", "PERM_UPLOAD", "PERM_EDIT", "PERM_DELETE", "PERM_API_MOD"]
}
},
{

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -181,12 +181,18 @@ a:hover {
.apiperm-granted {
cursor: pointer;
color: #378431;
color: #19b90e;
}
.apiperm-notgranted {
cursor: pointer;
color: #f85072;
color: #7e7e7e;
}
.apiperm-processing {
color: #aaaaaa;
color: #929611;
}
.gokapi-dialog {
background-color: #212529;
color: #ddd;
}

View File

@@ -1 +1 @@
.btn-secondary,.btn-secondary:hover,.btn-secondary:focus{color:#333;text-shadow:none}body{background:url(../../assets/background.jpg)no-repeat 50% fixed;-webkit-background-size:cover;-moz-background-size:cover;-o-background-size:cover;background-size:cover;display:-ms-flexbox;display:-webkit-box;display:flex;-ms-flex-pack:center;-webkit-box-pack:center;justify-content:center}td{vertical-align:middle;position:relative}a{color:inherit}a:hover{color:inherit;filter:brightness(80%)}.dropzone{background:#2f343a!important;color:#fff;border-radius:5px}.dropzone:hover{background:#33393f!important;color:#fff;border-radius:5px}.card{margin:0 auto;float:none;margin-bottom:10px;border:2px solid #33393f}.card-body{background-color:#212529;color:#ddd}.card-title{font-weight:900}.admin-input{text-align:center}.form-control:disabled{background:#bababa}.break{flex-basis:100%;height:0}.bd-placeholder-img{font-size:1.125rem;text-anchor:middle;-webkit-user-select:none;-moz-user-select:none;user-select:none}@media(min-width:768px){.bd-placeholder-img-lg{font-size:3.5rem}.break{flex-basis:0}}.masthead{margin-bottom:2rem}.masthead-brand{margin-bottom:0}.nav-masthead .nav-link{padding:.25rem 0;font-weight:700;color:rgba(255,255,255,.5);background-color:initial;border-bottom:.25rem solid transparent}.nav-masthead .nav-link:hover,.nav-masthead .nav-link:focus{border-bottom-color:rgba(255,255,255,.25)}.nav-masthead .nav-link+.nav-link{margin-left:1rem}.nav-masthead .active{color:#fff;border-bottom-color:#fff}#qroverlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,.3)}#qrcode{position:absolute;top:50%;left:50%;margin-top:-105px;margin-left:-105px;width:210px;height:210px;border:5px solid #fff}.toastnotification{pointer-events:none;position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background-color:#333;color:#fff;padding:15px;border-radius:5px;box-shadow:0 2px 5px rgba(0,0,0,.3);opacity:0;transition:opacity .3s ease-in-out;z-index:9999}.toastnotification.show{opacity:1;pointer-events:auto}.apiperm-granted{cursor:pointer;color:#378431}.apiperm-notgranted{cursor:pointer;color:#f85072}.apiperm-processing{color:#aaa}.filename{font-weight:700;font-size:14px;margin-bottom:5px}.upload-progress-container{display:flex;align-items:center}.upload-progress-bar{position:relative;height:10px;background-color:#eee;flex:1;margin-right:10px;border-radius:4px}.upload-progress-bar-progress{position:absolute;top:0;left:0;height:100%;background-color:#0a0;border-radius:4px;transition:width .2s ease-in-out}.upload-progress-info{font-size:12px}.us-container{margin-top:10px;margin-bottom:20px}.uploaderror{font-weight:700;color:red;margin-bottom:5px}.uploads-container{background-color:#2f343a;border:2px solid rgba(0,0,0,.3);border-radius:5px;margin-left:0;margin-right:0;max-width:none;visibility:hidden}
.btn-secondary,.btn-secondary:hover,.btn-secondary:focus{color:#333;text-shadow:none}body{background:url(../../assets/background.jpg)no-repeat 50% fixed;-webkit-background-size:cover;-moz-background-size:cover;-o-background-size:cover;background-size:cover;display:-ms-flexbox;display:-webkit-box;display:flex;-ms-flex-pack:center;-webkit-box-pack:center;justify-content:center}td{vertical-align:middle;position:relative}a{color:inherit}a:hover{color:inherit;filter:brightness(80%)}.dropzone{background:#2f343a!important;color:#fff;border-radius:5px}.dropzone:hover{background:#33393f!important;color:#fff;border-radius:5px}.card{margin:0 auto;float:none;margin-bottom:10px;border:2px solid #33393f}.card-body{background-color:#212529;color:#ddd}.card-title{font-weight:900}.admin-input{text-align:center}.form-control:disabled{background:#bababa}.break{flex-basis:100%;height:0}.bd-placeholder-img{font-size:1.125rem;text-anchor:middle;-webkit-user-select:none;-moz-user-select:none;user-select:none}@media(min-width:768px){.bd-placeholder-img-lg{font-size:3.5rem}.break{flex-basis:0}}.masthead{margin-bottom:2rem}.masthead-brand{margin-bottom:0}.nav-masthead .nav-link{padding:.25rem 0;font-weight:700;color:rgba(255,255,255,.5);background-color:initial;border-bottom:.25rem solid transparent}.nav-masthead .nav-link:hover,.nav-masthead .nav-link:focus{border-bottom-color:rgba(255,255,255,.25)}.nav-masthead .nav-link+.nav-link{margin-left:1rem}.nav-masthead .active{color:#fff;border-bottom-color:#fff}#qroverlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background-color:rgba(0,0,0,.3)}#qrcode{position:absolute;top:50%;left:50%;margin-top:-105px;margin-left:-105px;width:210px;height:210px;border:5px solid #fff}.toastnotification{pointer-events:none;position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background-color:#333;color:#fff;padding:15px;border-radius:5px;box-shadow:0 2px 5px rgba(0,0,0,.3);opacity:0;transition:opacity .3s ease-in-out;z-index:9999}.toastnotification.show{opacity:1;pointer-events:auto}.apiperm-granted{cursor:pointer;color:#19b90e}.apiperm-notgranted{cursor:pointer;color:#7e7e7e}.apiperm-processing{color:#929611}.gokapi-dialog{background-color:#212529;color:#ddd}.filename{font-weight:700;font-size:14px;margin-bottom:5px}.upload-progress-container{display:flex;align-items:center}.upload-progress-bar{position:relative;height:10px;background-color:#eee;flex:1;margin-right:10px;border-radius:4px}.upload-progress-bar-progress{position:absolute;top:0;left:0;height:100%;background-color:#0a0;border-radius:4px;transition:width .2s ease-in-out}.upload-progress-info{font-size:12px}.us-container{margin-top:10px;margin-bottom:20px}.uploaderror{font-weight:700;color:red;margin-bottom:5px}.uploads-container{background-color:#2f343a;border:2px solid rgba(0,0,0,.3);border-radius:5px;margin-left:0;margin-right:0;max-width:none;visibility:hidden}

View File

@@ -208,61 +208,209 @@ function showError(file, message) {
document.getElementById(`us-progress-info-${chunkId}`).classList.add('uploaderror');
}
function changeApiPermission(apiKey, permission, buttonId) {
var indicator = document.getElementById(buttonId);
if (indicator.classList.contains("apiperm-processing")) {
return;
}
var wasGranted = indicator.classList.contains("apiperm-granted");
indicator.classList.add("apiperm-processing");
indicator.classList.remove("apiperm-granted");
indicator.classList.remove("apiperm-notgranted");
var apiUrl = './api/auth/modifypermission';
var modifier = "GRANT";
if (wasGranted) {
modifier = "REVOKE";
}
// Fetch options with headers
const requestOptions = {
method: 'GET', // or 'POST' or any other HTTP method
headers: {
'Content-Type': 'application/json',
'apiKeyToModify': apiKey,
'permission': permission,
'permissionModifier': modifier
},
// You can add more options like body for POST requests
};
function editFile() {
const button = document.getElementById('mb_save');
button.disabled = true;
let apiUrl = './api/files/modify';
// Send the request
fetch(apiUrl, requestOptions)
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
})
.then(data => {
if (wasGranted) {
indicator.classList.add("apiperm-notgranted");
} else {
indicator.classList.add("apiperm-granted");
}
indicator.classList.remove("apiperm-processing");
})
.catch(error => {
if (wasGranted) {
indicator.classList.add("apiperm-granted");
} else {
indicator.classList.add("apiperm-notgranted");
}
indicator.classList.remove("apiperm-processing");
alert("Unable to set permission: "+error);
console.error('Error:', error);
let allowedDownloads = document.getElementById('mi_edit_down').value;
let expiryTimestamp = document.getElementById('mi_edit_expiry').value;
let password = document.getElementById('mi_edit_pw').value;
let originalPassword = (password === '(unchanged)');
if (!document.getElementById('mc_download').checked) {
allowedDownloads = 0;
}
if (!document.getElementById('mc_expiry').checked) {
expiryTimestamp = 0;
}
if (!document.getElementById('mc_password').checked) {
originalPassword = false;
password = "";
}
const requestOptions = {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'id': button.getAttribute('data-fileid'),
'allowedDownloads': allowedDownloads,
'expiryTimestamp': expiryTimestamp,
'password': password,
'originalPassword': originalPassword
},
};
// Send the request
fetch(apiUrl, requestOptions)
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
})
.then(data => {
location.reload();
})
.catch(error => {
alert("Unable to edit file: " + error);
console.error('Error:', error);
button.disabled = false;
});
}
var calendarInstance = null;
function createCalendar(timestamp) {
// Convert Unix timestamp to JavaScript Date object
const expiryDate = new Date(timestamp * 1000);
calendarInstance = flatpickr('#mi_edit_expiry', {
enableTime: true,
dateFormat: 'U', // Unix timestamp
altInput: true,
altFormat: 'Y-m-d H:i',
allowInput: true,
time_24hr: true,
defaultDate: expiryDate,
minDate: 'today',
});
}
function handleEditCheckboxChange(checkbox) {
var targetElement = document.getElementById(checkbox.getAttribute("data-toggle-target"));
var timestamp = checkbox.getAttribute("data-timestamp");
if (checkbox.checked) {
targetElement.classList.remove("disabled");
targetElement.removeAttribute("disabled");
if (timestamp != null) {
calendarInstance._input.disabled = false;
}
} else {
if (timestamp != null) {
calendarInstance._input.disabled = true;
}
targetElement.classList.add("disabled");
targetElement.setAttribute("disabled", true);
}
}
function showEditModal(filename, id, downloads, expiry, password, unlimitedown, unlimitedtime) {
document.getElementById("m_filenamelabel").innerHTML = filename;
document.getElementById("mc_expiry").setAttribute("data-timestamp", expiry);
document.getElementById("mb_save").setAttribute('data-fileid', id);
createCalendar(expiry);
if (unlimitedown) {
document.getElementById("mi_edit_down").value = "1";
document.getElementById("mi_edit_down").disabled = true;
document.getElementById("mc_download").checked = false;
} else {
document.getElementById("mi_edit_down").value = downloads;
document.getElementById("mi_edit_down").disabled = false;
document.getElementById("mc_download").checked = true;
}
if (unlimitedtime) {
document.getElementById("mi_edit_expiry").value = add14DaysIfBeforeCurrentTime(expiry);
document.getElementById("mi_edit_expiry").disabled = true;
document.getElementById("mc_expiry").checked = false;
calendarInstance._input.disabled = true;
} else {
document.getElementById("mi_edit_expiry").value = expiry;
document.getElementById("mi_edit_expiry").disabled = false;
document.getElementById("mc_expiry").checked = true;
calendarInstance._input.disabled = false;
}
if (password) {
document.getElementById("mi_edit_pw").value = "(unchanged)";
document.getElementById("mi_edit_pw").disabled = false;
document.getElementById("mc_password").checked = true;
} else {
document.getElementById("mi_edit_pw").value = "";
document.getElementById("mi_edit_pw").disabled = true;
document.getElementById("mc_password").checked = false;
}
new bootstrap.Modal('#modaledit', {}).show();
}
function selectTextForPw(input) {
if (input.value === "(unchanged)") {
input.setSelectionRange(0, input.value.length);
}
}
function add14DaysIfBeforeCurrentTime(unixTimestamp) {
let currentTime = Date.now();
let timestampInMilliseconds = unixTimestamp * 1000;
if (timestampInMilliseconds < currentTime) {
let newTimestamp = currentTime + (14 * 24 * 60 * 60 * 1000);
return Math.floor(newTimestamp / 1000);
} else {
return unixTimestamp;
}
}
function changeApiPermission(apiKey, permission, buttonId) {
var indicator = document.getElementById(buttonId);
if (indicator.classList.contains("apiperm-processing")) {
return;
}
var wasGranted = indicator.classList.contains("apiperm-granted");
indicator.classList.add("apiperm-processing");
indicator.classList.remove("apiperm-granted");
indicator.classList.remove("apiperm-notgranted");
var apiUrl = './api/auth/modify';
var modifier = "GRANT";
if (wasGranted) {
modifier = "REVOKE";
}
const requestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apiKeyToModify': apiKey,
'permission': permission,
'permissionModifier': modifier
},
};
// Send the request
fetch(apiUrl, requestOptions)
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status: ${response.status}`);
}
})
.then(data => {
if (wasGranted) {
indicator.classList.add("apiperm-notgranted");
} else {
indicator.classList.add("apiperm-granted");
}
indicator.classList.remove("apiperm-processing");
})
.catch(error => {
if (wasGranted) {
indicator.classList.add("apiperm-granted");
} else {
indicator.classList.add("apiperm-notgranted");
}
indicator.classList.remove("apiperm-processing");
alert("Unable to set permission: " + error);
console.error('Error:', error);
});
}
@@ -384,7 +532,7 @@ function addRow(jsonText) {
let lockIcon = "";
if (item.IsPasswordProtected === true) {
lockIcon = " &#128274;";
lockIcon = ' <i title="Password protected" class="bi bi-key"></i>';
}
cellFilename.innerText = item.Name;
cellFilename.id = "cell-name-" + item.Id;
@@ -413,6 +561,7 @@ function addRow(jsonText) {
}
}
buttons = buttons + '<button type="button" title="QR Code" class="btn btn-outline-light btn-sm" onclick="showQrCode(\'' + jsonObject.Url + item.Id + '\');"><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="Delete" class="btn btn-outline-danger btn-sm" onclick="window.location=\'./delete?id=' + item.Id + '\'"><i class="bi bi-trash3"></i></button>';
cellButtons.innerHTML = buttons;
@@ -447,6 +596,7 @@ function hideQrCode() {
document.getElementById("qrcode").innerHTML = "";
}
function showQrCode(url) {
const overlay = document.getElementById("qroverlay");
overlay.style.display = "block";

File diff suppressed because one or more lines are too long

View File

@@ -22,7 +22,7 @@
<input class="form-check-input" type="checkbox" name="enableDownloadLimit" id="enableDownloadLimit" onchange="checkBoxChanged(this, 'allowedDownloads')" value="" aria-label="Enable Download Limit" {{ if not .DefaultUnlimitedDownload }} checked {{ end }}>
<label class="control-label small" for="allowedDownloads">Limit Downloads</label>
<div class="input-group mb-3">
<input type="number" class="form-control admin-input" value="{{ .DefaultDownloads }}" name="allowedDownloads" id="allowedDownloads" min="1" {{ if .DefaultUnlimitedDownload }} disabled {{ end }} style="text-align: right;"/>
<input type="number" class="form-control admin-input" value="{{ .DefaultDownloads }}" name="allowedDownloads" id="allowedDownloads" min="1" {{ if .DefaultUnlimitedDownload }} disabled {{ end }} style="text-align: right;">
<span class="input-group-text">Downloads</span>
</div>
</div>
@@ -31,7 +31,7 @@
<input class="form-check-input" type="checkbox" name="enableTimeLimit" id="enableTimeLimit" onchange="checkBoxChanged(this, 'expiryDays')" value="" aria-label="Enable Time Limit" {{ if not .DefaultUnlimitedTime }} checked {{ end }}>
<label class="control-label small" for="expiryDays">Expiry</label>
<div class="input-group mb-3">
<input type="number" class="form-control admin-input" value="{{ .DefaultExpiry }}" name="expiryDays" id="expiryDays" min="1" {{ if .DefaultUnlimitedTime }} disabled {{ end }} style="text-align: right;"/>
<input type="number" class="form-control admin-input" value="{{ .DefaultExpiry }}" name="expiryDays" id="expiryDays" min="1" {{ if .DefaultUnlimitedTime }} disabled {{ end }} style="text-align: right;">
<span class="input-group-text">Days</span>
</div>
</div>
@@ -39,7 +39,7 @@
<div class="form-group col">
<input class="form-check-input" type="checkbox" name="enablePassword" id="enablePassword" onchange="checkBoxChanged(this, 'password')" value="" aria-label="Enable Password Protection" {{ if ne .DefaultPassword "" }} checked {{ end }}>
<label class="control-label small" for="password">Password</label>
<input class="form-control admin-input" value="{{ .DefaultPassword }}" name="password" id="password" placeholder="None" {{ if eq .DefaultPassword "" }} disabled {{ end }}/>
<input class="form-control admin-input" value="{{ .DefaultPassword }}" name="password" id="password" placeholder="None" {{ if eq .DefaultPassword "" }} disabled {{ end }}>
</div>
<div id="errordiv" class="alert alert-danger" style="display:none">
<span id="errormessage" ></span>
@@ -65,21 +65,21 @@
{{ if or (gt .ExpireAt $.TimeNow) (.UnlimitedTime) }}
{{ if or (gt .DownloadsRemaining 0) (.UnlimitedDownloads) }}
<tr>
<td scope="col" id="cell-name-{{ .Id }}">{{ .Name }}</td>
<td data-order="{{ .SizeBytes }}" scope="col">{{ .Size }}</td>
<td id="cell-name-{{ .Id }}">{{ .Name }}</td>
<td data-order="{{ .SizeBytes }}">{{ .Size }}</td>
{{ if .UnlimitedDownloads }}
<td scope="col">Unlimited</td>
<td>Unlimited</td>
{{ else }}
<td scope="col">{{ .DownloadsRemaining }}</td>
<td>{{ .DownloadsRemaining }}</td>
{{ end }}
{{ if .UnlimitedTime }}
<td scope="col">Unlimited</td>
<td>Unlimited</td>
{{ else }}
<td scope="col">{{ .ExpireAtString }}</td>
<td>{{ .ExpireAtString }}</td>
{{ end }}
<td scope="col">{{ .DownloadCount }}</td>
<td scope="col"><a id="url-href-{{ .Id }}" target="_blank" href="{{ $.Url }}{{ .Id }}">{{ .Id }}</a>{{ if .IsPasswordProtected }} &#128274;{{ end }}</td>
<td scope="col"><button id="url-button-{{ .Id }}" type="button" onclick="showToast()" data-clipboard-text="{{ $.Url }}{{ .Id }}" class="copyurl btn btn-outline-light btn-sm"><i class="bi bi-copy"></i> URL</button>
<td>{{ .DownloadCount }}</td>
<td><a id="url-href-{{ .Id }}" target="_blank" href="{{ $.Url }}{{ .Id }}">{{ .Id }}</a>{{ if .IsPasswordProtected }} <i title="Password protected" class="bi bi-key"></i>{{ end }}</td>
<td><button id="url-button-{{ .Id }}" type="button" onclick="showToast()" data-clipboard-text="{{ $.Url }}{{ .Id }}" class="copyurl btn btn-outline-light btn-sm"><i class="bi bi-copy"></i> URL</button>
{{ if ne .HotlinkId "" }}
<button type="button" onclick="showToast()" data-clipboard-text="{{ $.HotlinkUrl }}{{ .HotlinkId }}" class="copyurl btn btn-outline-light btn-sm"><i class="bi bi-copy"></i> Hotlink</button>
{{ else }}
@@ -89,8 +89,8 @@
<button type="button" onclick="showToast()" data-clipboard-text="{{ $.GenericHotlinkUrl }}{{ .Id }}" class="copyurl btn btn-outline-light btn-sm"><i class="bi bi-copy"></i> Hotlink</button>
{{ end }}
{{ end }}
<button type="button" title="QR Code" class="btn btn-outline-light btn-sm" onclick="showQrCode('{{ $.Url }}{{ .Id }}');"><i class="bi bi-qr-code"></i>
</button>
<button type="button" title="QR Code" class="btn btn-outline-light btn-sm" onclick="showQrCode('{{ $.Url }}{{ .Id }}');"><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="Delete" class="btn btn-outline-danger btn-sm" onclick="window.location='./delete?id={{ .Id }}'"><i class="bi bi-trash3"></i></button></td>
</tr>
{{ end }}
@@ -103,6 +103,46 @@
</div>
</div>
</div>
<!-- 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-content gokapi-dialog">
<div class="modal-header">
<h1 class="modal-title fs-5" id="m_filenamelabel">Filename</h1>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<div class="input-group-text">
<input type="checkbox" id="mc_download" checked aria-label="Limit downloads" title="Limit downloads" data-toggle-target="mi_edit_down" onchange="handleEditCheckboxChange(this)">
</div>
<span class="input-group-text" id="edit_down">Limit Downloads</span>
<input type="number" min="1" id="mi_edit_down" class="form-control" aria-label="Downloads Remaining" aria-describedby="edit_down">
</div>
<div class="input-group mb-3">
<div class="input-group-text">
<input id="mc_expiry" type="checkbox" checked aria-label="Expire files" title="Expire files" data-toggle-target="mi_edit_expiry" data-timestamp="" onchange="handleEditCheckboxChange(this)">
</div>
<span class="input-group-text" id="edit_expdate">Expiry&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<input type="text" id="mi_edit_expiry" class="form-control" aria-label="Expiry" aria-describedby="edit_expdate">
</div>
<div class="input-group mb-3">
<div class="input-group-text">
<input type="checkbox" id="mc_password" aria-label="Require password" title="Require password" data-toggle-target="mi_edit_pw" onchange="handleEditCheckboxChange(this)">
</div>
<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>
<div class="modal-footer">
<button type="button" class="btn btn-outline-light" 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>
</div>
</div>
<div id="toastnotification" class="toastnotification">URL copied to clipboard</div>
@@ -122,6 +162,7 @@
Dropzone.options.uploaddropzone["previewsContainer"] = false;
$(document).ready(function () {
$('#maintable').DataTable({
"responsive": true,
"columnDefs": [ {

View File

@@ -30,6 +30,7 @@
<td scope="col">
<i id="perm_view_{{ .Id }}" class="bi bi-eye {{if not .HasPermissionView}}apiperm-notgranted{{else}}apiperm-granted{{end}}" title="List Uploads" onclick='changeApiPermission("{{ .Id }}","PERM_VIEW", "perm_view_{{ .Id }}");'></i>
<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" onclick='changeApiPermission("{{ .Id }}","PERM_DELETE", "perm_delete_{{ .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>

View File

@@ -6,23 +6,27 @@
<meta name="description" content="">
<script src="./assets/dist/js/jquery.min.js"></script>
<script src="./assets/dist/js/bootstrap.bundle.min.js"></script>
<link href="./assets/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="./favicon-16x16.png">
<link rel="manifest" href="./site.webmanifest">
<link href="css/min/gokapi.min.css?v={{ template "css_main"}}" rel="stylesheet">
{{ if .IsAdminView }}
<title>{{.PublicName}} Admin</title>
<link href="./assets/dist/css/dropzone.min.css" rel="stylesheet">
<link href="./assets/dist/css/datatables.min.css" rel="stylesheet" />
<script src="./assets/dist/js/jquery.min.js"></script>
<script src="./assets/dist/js/bootstrap.bundle.min.js"></script>
<script src="./assets/dist/js/dropzone.min.js?v={{ template "js_dropzone_version"}}"></script>
<script src="./assets/dist/js/clipboard.min.js"></script>
<script src="./assets/dist/js/datatables.min.js"></script>
<script src="./assets/dist/js/qrcode.min.js"></script>
<link rel="stylesheet" href="./assets/dist/icons/bootstrap-icons.min.css">
<script src="./assets/dist/js/flatpickr.min.js"></script>
<link href="./assets/dist/css/dropzone.min.css" rel="stylesheet">
<link href="./assets/dist/css/datatables.min.css" rel="stylesheet">
<link href="./assets/dist/css/flatpickr.min.css" rel="stylesheet">
<link href="./assets/dist/css/flatpickr.dark.min.css" rel="stylesheet">
<link href="./assets/dist/icons/bootstrap-icons.min.css" rel="stylesheet">
<style>
.masthead-brand {
float: left;

View File

@@ -70,7 +70,7 @@
"/chunk/add": {
"post": {
"tags": [
"files"
"chunk"
],
"summary": "Uploads a new chunk",
"description": "Uploads a file in chunks, in case a reverse proxy does not support upload of larger files. Parallel uploading is supported. Must call /chunk/complete after all chunks have been uploaded. WARNING: Does not support end-to-end encryption! If server is setup to utilise end-to-end encryption, file will be stored in plain-text!",
@@ -116,7 +116,7 @@
"/chunk/complete": {
"post": {
"tags": [
"files"
"chunk"
],
"summary": "Finalises uploaded chunks",
"description": "Needs to be called after all chunks have been uploaded. Adds the uploaded file to Gokapi.",
@@ -251,6 +251,89 @@
}
}
},
"/files/modify": {
"put": {
"tags": [
"files"
],
"summary": "Changes parameters of an uploaded file",
"description": "This API call changes parameters of an uploaded file",
"operationId": "modifyfile",
"security": [
{
"apikey": ["API_EDIT"]
},
{
"session": []
}
],
"parameters": [
{
"name": "id",
"in": "header",
"required": true,
"schema": {
"type": "string"
},
"description": "ID of file to be edited"
},
{
"name": "allowedDownloads",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "How many remaining downloads are allowed. Unlimited if 0 is passed."
},
{
"name": "expiryTimestamp",
"in": "header",
"required": false,
"schema": {
"type": "integer"
},
"description": "Unix timestamp of the file expiration date. 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."
},
{
"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."
}
],
"responses": {
"200": {
"description": "Operation successful",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UploadResult"
}
}
}
},
"400": {
"description": "Invalid ID supplied or incorrect data type sent"
},
"401": {
"description": "Invalid API key provided or not logged in as admin"
}
}
}
},
"/files/delete": {
"delete": {
"tags": [
@@ -346,7 +429,7 @@
}
}
},
"/auth/modifypermission": {
"/auth/modify": {
"put": {
"tags": [
"auth"
@@ -383,7 +466,7 @@
"explode": false,
"schema": {
"type": "string",
"enum": ["PERM_VIEW", "PERM_UPLOAD", "PERM_DELETE", "PERM_API_MOD"]
"enum": ["PERM_VIEW", "PERM_UPLOAD", "PERM_EDIT", "PERM_DELETE", "PERM_API_MOD"]
}
},
{