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/cloudlena/s3manager/internal/app/s3manager"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"io" "io"
"net/url"
"sync" "sync"
"time"
) )
// Ensure, that S3Mock does implement s3manager.S3. // 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 { // MakeBucketFunc: func(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error {
// panic("mock out the MakeBucket method") // 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) { // 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") // panic("mock out the PutObject method")
// }, // },
@ -61,6 +66,9 @@ type S3Mock struct {
// MakeBucketFunc mocks the MakeBucket method. // MakeBucketFunc mocks the MakeBucket method.
MakeBucketFunc func(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error 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 mocks the PutObject method.
PutObjectFunc func(ctx context.Context, bucketName string, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) 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 is the opts argument value.
Opts minio.MakeBucketOptions 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 holds details about calls to the PutObject method.
PutObject []struct { PutObject []struct {
// Ctx is the ctx argument value. // Ctx is the ctx argument value.
@ -140,13 +161,14 @@ type S3Mock struct {
Opts minio.RemoveObjectOptions Opts minio.RemoveObjectOptions
} }
} }
lockGetObject sync.RWMutex lockGetObject sync.RWMutex
lockListBuckets sync.RWMutex lockListBuckets sync.RWMutex
lockListObjects sync.RWMutex lockListObjects sync.RWMutex
lockMakeBucket sync.RWMutex lockMakeBucket sync.RWMutex
lockPutObject sync.RWMutex lockPresignedGetObject sync.RWMutex
lockRemoveBucket sync.RWMutex lockPutObject sync.RWMutex
lockRemoveObject sync.RWMutex lockRemoveBucket sync.RWMutex
lockRemoveObject sync.RWMutex
} }
// GetObject calls GetObjectFunc. // GetObject calls GetObjectFunc.
@ -305,6 +327,54 @@ func (mock *S3Mock) MakeBucketCalls() []struct {
return calls 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. // 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) { 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 { if mock.PutObjectFunc == nil {

View file

@ -3,6 +3,8 @@ package s3manager
import ( import (
"context" "context"
"io" "io"
"net/url"
"time"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
) )
@ -15,6 +17,7 @@ type S3 interface {
ListBuckets(ctx context.Context) ([]minio.BucketInfo, error) ListBuckets(ctx context.Context) ([]minio.BucketInfo, error)
ListObjects(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo ListObjects(ctx context.Context, bucketName string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo
MakeBucket(ctx context.Context, bucketName string, opts minio.MakeBucketOptions) error 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) 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 RemoveBucket(ctx context.Context, bucketName string) error
RemoveObject(ctx context.Context, bucketName, objectName string, opts minio.RemoveObjectOptions) 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}", 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", 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) r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleGetObject(s3, configuration.ForceDownload)).Methods(http.MethodGet)
if configuration.AllowDelete { if configuration.AllowDelete {
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleDeleteObject(s3)).Methods(http.MethodDelete) r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleDeleteObject(s3)).Methods(http.MethodDelete)

View file

@ -72,6 +72,7 @@
<!-- Dropdown Structure --> <!-- Dropdown Structure -->
<ul id="actions-dropdown-{{ $index }}" class="dropdown-content"> <ul id="actions-dropdown-{{ $index }}" class="dropdown-content">
<li><a target="_blank" href="/api/buckets/{{ $.BucketName }}/objects/{{ $object.Key }}">Download</a></li> <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 }} {{- if $.AllowDelete }}
<li><a href="#" onclick="deleteObject({{ $.BucketName }}, {{ $object.Key }})">Delete</a></li> <li><a href="#" onclick="deleteObject({{ $.BucketName }}, {{ $object.Key }})">Delete</a></li>
{{- end }} {{- end }}
@ -110,7 +111,7 @@
<i class="large material-icons">create_new_folder</i> <i class="large material-icons">create_new_folder</i>
</button> </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> <i class="large material-icons">create</i>
</button> </button>
</div> </div>
@ -118,9 +119,8 @@
<input type="file" id="upload-folder-input" webkitdirectory multiple style="display: none;"> <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;"> <input type="file" id="upload-file-input" name="file" multiple style="display: none;">
<div id="modal-create-folder" class="modal"> <div id="modal-change-path" class="modal">
<form id="create-folder-form" enctype="multipart/form-data"> <form id="change-path-form" enctype="multipart/form-data">
<div class="modal-content"> <div class="modal-content">
<h4>Change directory path</h4> <h4>Change directory path</h4>
<br> <br>
@ -133,12 +133,58 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="modal-close waves-effect waves-green btn-flat">Cancel</button> <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> </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> </form>
</div> </div>
@ -150,6 +196,7 @@ function deleteObject(bucketName, objectName) {
success: function () { location.reload(); } success: function () { location.reload(); }
}) })
} }
function deleteBucket(bucketName) { function deleteBucket(bucketName) {
$.ajax({ $.ajax({
type: 'DELETE', type: 'DELETE',
@ -164,19 +211,24 @@ function handleUploadFiles(event) {
uploadFiles(files, url); uploadFiles(files, url);
} }
function handleCreateFolder(event) { function handleChangePath(event) {
event.preventDefault(); event.preventDefault();
const form = event.target; const form = event.target;
const formData = new FormData(form); const formData = new FormData(form);
const newPath = formData.get("new-path")
let newHref = window.location.href + newPath let appendPath = formData.get("new-path")
if(!newHref.endsWith("/")) { if(!appendPath.endsWith("/")) {
newHref = newHref + "/" 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) { function uploadFiles(files, url) {
@ -219,8 +271,60 @@ function createNotification(fileName) {
return notification; 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) => { window.onload = (event) => {
$('#create-folder-form').submit(handleCreateFolder) $('#change-path-form').submit(handleChangePath)
$('#download-link-form').submit(handleGenerateDownloadLink)
uploadFolderInput = $('#upload-folder-input'); uploadFolderInput = $('#upload-folder-input');
$('#upload-folder-btn').click(event => uploadFolderInput.click()); $('#upload-folder-btn').click(event => uploadFolderInput.click());