Generate link feature

This commit is contained in:
Tri Vo 2023-08-20 19:03:02 +07:00 committed by Lena Fuhrimann
parent d6a2b056c2
commit 4858c7493b
5 changed files with 241 additions and 20 deletions

View file

@ -0,0 +1,43 @@
package s3manager
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/gorilla/mux"
)
func HandleGenerateUrl(s3 S3) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
bucketName := mux.Vars(r)["bucketName"]
objectName := mux.Vars(r)["objectName"]
expiry := r.URL.Query().Get("expiry")
parsedExpiry, err := strconv.ParseInt(expiry, 10, 0)
if err != nil {
handleHTTPError(w, fmt.Errorf("error when converting expiry: %w", err))
return
}
if parsedExpiry > 7*24*60*60 || parsedExpiry < 1 {
handleHTTPError(w, fmt.Errorf("invalid expiry value: %v", parsedExpiry))
return
}
expirySecond := time.Duration(parsedExpiry * 1e9)
reqParams := make(url.Values)
url, err := s3.PresignedGetObject(r.Context(), bucketName, objectName, expirySecond, reqParams)
if err != nil {
handleHTTPError(w, fmt.Errorf("error when generate url: %w", err))
return
}
encoder := json.NewEncoder(w)
encoder.SetEscapeHTML(false)
encoder.Encode(map[string]string{"url": url.String()})
}
}

View file

@ -8,7 +8,9 @@ import (
"github.com/cloudlena/s3manager/internal/app/s3manager"
"github.com/minio/minio-go/v7"
"io"
"net/url"
"sync"
"time"
)
// Ensure, that S3Mock does implement s3manager.S3.
@ -33,6 +35,9 @@ var _ s3manager.S3 = &S3Mock{}
// MakeBucketFunc: func(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error {
// panic("mock out the MakeBucket method")
// },
// PresignedGetObjectFunc: func(ctx context.Context, bucketName string, objectName string, expiry time.Duration, reqParams url.Values) (*url.URL, error) {
// panic("mock out the PresignedGetObject method")
// },
// PutObjectFunc: func(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) {
// panic("mock out the PutObject method")
// },
@ -61,6 +66,9 @@ type S3Mock struct {
// MakeBucketFunc mocks the MakeBucket method.
MakeBucketFunc func(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error
// PresignedGetObjectFunc mocks the PresignedGetObject method.
PresignedGetObjectFunc func(ctx context.Context, bucketName string, objectName string, expiry time.Duration, reqParams url.Values) (*url.URL, error)
// PutObjectFunc mocks the PutObject method.
PutObjectFunc func(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
@ -106,6 +114,19 @@ type S3Mock struct {
// Opts is the opts argument value.
Opts minio.MakeBucketOptions
}
// PresignedGetObject holds details about calls to the PresignedGetObject method.
PresignedGetObject []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// BucketName is the bucketName argument value.
BucketName string
// ObjectName is the objectName argument value.
ObjectName string
// Expiry is the expiry argument value.
Expiry time.Duration
// ReqParams is the reqParams argument value.
ReqParams url.Values
}
// PutObject holds details about calls to the PutObject method.
PutObject []struct {
// Ctx is the ctx argument value.
@ -140,13 +161,14 @@ type S3Mock struct {
Opts minio.RemoveObjectOptions
}
}
lockGetObject sync.RWMutex
lockListBuckets sync.RWMutex
lockListObjects sync.RWMutex
lockMakeBucket sync.RWMutex
lockPutObject sync.RWMutex
lockRemoveBucket sync.RWMutex
lockRemoveObject sync.RWMutex
lockGetObject sync.RWMutex
lockListBuckets sync.RWMutex
lockListObjects sync.RWMutex
lockMakeBucket sync.RWMutex
lockPresignedGetObject sync.RWMutex
lockPutObject sync.RWMutex
lockRemoveBucket sync.RWMutex
lockRemoveObject sync.RWMutex
}
// GetObject calls GetObjectFunc.
@ -305,6 +327,54 @@ func (mock *S3Mock) MakeBucketCalls() []struct {
return calls
}
// PresignedGetObject calls PresignedGetObjectFunc.
func (mock *S3Mock) PresignedGetObject(ctx context.Context, bucketName string, objectName string, expiry time.Duration, reqParams url.Values) (*url.URL, error) {
if mock.PresignedGetObjectFunc == nil {
panic("S3Mock.PresignedGetObjectFunc: method is nil but S3.PresignedGetObject was just called")
}
callInfo := struct {
Ctx context.Context
BucketName string
ObjectName string
Expiry time.Duration
ReqParams url.Values
}{
Ctx: ctx,
BucketName: bucketName,
ObjectName: objectName,
Expiry: expiry,
ReqParams: reqParams,
}
mock.lockPresignedGetObject.Lock()
mock.calls.PresignedGetObject = append(mock.calls.PresignedGetObject, callInfo)
mock.lockPresignedGetObject.Unlock()
return mock.PresignedGetObjectFunc(ctx, bucketName, objectName, expiry, reqParams)
}
// PresignedGetObjectCalls gets all the calls that were made to PresignedGetObject.
// Check the length with:
//
// len(mockedS3.PresignedGetObjectCalls())
func (mock *S3Mock) PresignedGetObjectCalls() []struct {
Ctx context.Context
BucketName string
ObjectName string
Expiry time.Duration
ReqParams url.Values
} {
var calls []struct {
Ctx context.Context
BucketName string
ObjectName string
Expiry time.Duration
ReqParams url.Values
}
mock.lockPresignedGetObject.RLock()
calls = mock.calls.PresignedGetObject
mock.lockPresignedGetObject.RUnlock()
return calls
}
// PutObject calls PutObjectFunc.
func (mock *S3Mock) PutObject(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) {
if mock.PutObjectFunc == nil {

View file

@ -3,6 +3,8 @@ package s3manager
import (
"context"
"io"
"net/url"
"time"
"github.com/minio/minio-go/v7"
)
@ -15,6 +17,7 @@ type S3 interface {
ListBuckets(ctx context.Context) ([]minio.BucketInfo, error)
ListObjects(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo
MakeBucket(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error
PresignedGetObject(ctx context.Context, bucketName, objectName string, expiry time.Duration, reqParams url.Values) (*url.URL, error)
PutObject(ctx context.Context, bucketName, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
RemoveBucket(ctx context.Context, bucketName string) error
RemoveObject(ctx context.Context, bucketName, objectName string, opts minio.RemoveObjectOptions) error

View file

@ -161,6 +161,7 @@ func main() {
r.Handle("/api/buckets/{bucketName}", s3manager.HandleDeleteBucket(s3)).Methods(http.MethodDelete)
}
r.Handle("/api/buckets/{bucketName}/objects", s3manager.HandleCreateObject(s3, sseType)).Methods(http.MethodPost)
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}/url", s3manager.HandleGenerateUrl(s3)).Methods(http.MethodGet)
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleGetObject(s3, configuration.ForceDownload)).Methods(http.MethodGet)
if configuration.AllowDelete {
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleDeleteObject(s3)).Methods(http.MethodDelete)

View file

@ -72,6 +72,7 @@
<!-- Dropdown Structure -->
<ul id="actions-dropdown-{{ $index }}" class="dropdown-content">
<li><a target="_blank" href="/api/buckets/{{ $.BucketName }}/objects/{{ $object.Key }}">Download</a></li>
<li><a onclick="handleOpenDownloadLinkModal({{ $object.Key }})">Download link</a></li>
{{- if $.AllowDelete }}
<li><a href="#" onclick="deleteObject({{ $.BucketName }}, {{ $object.Key }})">Delete</a></li>
{{- end }}
@ -110,7 +111,7 @@
<i class="large material-icons">create_new_folder</i>
</button>
<button type="button" class="btn-floating btn-large red modal-trigger tooltipped" data-target="modal-create-folder" data-position="top" data-tooltip="Change path">
<button type="button" class="btn-floating btn-large red modal-trigger tooltipped" data-target="modal-change-path" data-position="top" data-tooltip="Change path">
<i class="large material-icons">create</i>
</button>
</div>
@ -118,9 +119,8 @@
<input type="file" id="upload-folder-input" webkitdirectory multiple style="display: none;">
<input type="file" id="upload-file-input" name="file" multiple style="display: none;">
<div id="modal-create-folder" class="modal">
<form id="create-folder-form" enctype="multipart/form-data">
<div id="modal-change-path" class="modal">
<form id="change-path-form" enctype="multipart/form-data">
<div class="modal-content">
<h4>Change directory path</h4>
<br>
@ -133,12 +133,58 @@
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="modal-close waves-effect waves-green btn-flat">Cancel</button>
<button type="submit" class="modal-close waves-effect waves-green btn">Create</button>
<button type="submit" class="modal-close waves-effect waves-green btn">Change path</button>
</div>
</form>
</div>
<div id="modal-create-download-link" class="modal">
<form id="download-link-form">
<div class="modal-content">
<div class="row">
<h4>Create download link for </h4>
<input name="objectName" id="objectName" type="text" readonly>
</div>
<div class="row">
<div class="col s4">
<div class="input-field">
<input name="day" id="gen-link-day" value="0" type="text" pattern="^[\d]+$" class="validate">
<label for="gen-link-day">Day</label>
<span class="helper-text" data-error="Invalid day format"></span>
</div>
</div>
<div class="col s4">
<div class="input-field">
<input name="hour" id="gen-link-hour" value="1" type="text" pattern="^([0-9]|1\d|2[0-3])$" class="validate">
<label for="gen-link-hour">Hour</label>
<span class="helper-text" data-error="Invalid hour format"></span>
</div>
</div>
<div class="col s4">
<div class="input-field">
<input name="minute" id="gen-link-minute" value="0" type="text" pattern="^([0-9]|[1-5]\d)$" class="validate">
<label for="gen-link-minute">Minute</label>
<span class="helper-text" data-error="Invalid minute format"></span>
</div>
</div>
</div>
<div class="row">
<div class="col s3">
<button id="create-link-btn" class="waves-effect waves-green btn">Create link</button>
</div>
<div class="col s9 red-text text-darken-2" id="gen-url-error"></div>
</div>
<div class="row">
<div class="col s11">
<div class="input-field">
<i class="material-icons prefix" onclick="handleCopyLink()" style="cursor:pointer;">content_copy</i>
<input name="generated-link" id="generated-link" type="text" readonly>
</div>
</div>
</div>
</div>
</form>
</div>
@ -150,6 +196,7 @@ function deleteObject(bucketName, objectName) {
success: function () { location.reload(); }
})
}
function deleteBucket(bucketName) {
$.ajax({
type: 'DELETE',
@ -164,19 +211,24 @@ function handleUploadFiles(event) {
uploadFiles(files, url);
}
function handleCreateFolder(event) {
function handleChangePath(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const newPath = formData.get("new-path")
let newHref = window.location.href + newPath
if(!newHref.endsWith("/")) {
newHref = newHref + "/"
let appendPath = formData.get("new-path")
if(!appendPath.endsWith("/")) {
appendPath = appendPath + "/";
}
window.location.href = newHref
let currentPath = window.location.href
if(!currentPath.endsWith("/")) {
currentPath = currentPath + "/";
}
form.reset();
window.location.href = currentPath + appendPath;
}
function uploadFiles(files, url) {
@ -219,8 +271,60 @@ function createNotification(fileName) {
return notification;
}
function handleOpenDownloadLinkModal(objectName) {
const downloadLinkForm = document.forms['download-link-form']
downloadLinkForm.elements['objectName'].value = objectName;
const createLinkModalElement = document.getElementById('modal-create-download-link')
document.getElementById('generated-link').setAttribute('value', "");
document.getElementById('gen-url-error').setHTML("");
const modalInstance = M.Modal.init(createLinkModalElement);
modalInstance.open()
}
function handleGenerateDownloadLink(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const objectName = formData.get('objectName');
const genUrlMessage = document.getElementById('gen-url-error');
const expiryTime = formData.get('day') * 24 * 60 * 60 + formData.get('hour') * 60 * 60 + formData.get('minute') * 60;
if(expiryTime > 7 * 24 * 60 * 60) {
genUrlMessage.setHTML("Expiry time must be less than 7 days");
return;
}
$.ajax({
type: 'GET',
url: '/api/buckets/' + {{ $.BucketName }}+ "/objects/" + objectName + "/url?expiry=" + expiryTime,
success: function (result) {
genUrlMessage.setHTML("")
document.getElementById("generated-link").setAttribute('value', JSON.parse(result).url);
},
error: function(request, status, error) {
genUrlMessage.setHTML("Error when generating url")
}
});
}
function handleCopyLink() {
const url = document.getElementById("generated-link").value;
if(!!url) {
navigator.clipboard.writeText(url).then(function() {
M.toast({html: 'Copied to clipboard!'});
}, function(err) {
console.error('Could not copy:', err);
});
}
}
window.onload = (event) => {
$('#create-folder-form').submit(handleCreateFolder)
$('#change-path-form').submit(handleChangePath)
$('#download-link-form').submit(handleGenerateDownloadLink)
uploadFolderInput = $('#upload-folder-input');
$('#upload-folder-btn').click(event => uploadFolderInput.click());