1
0
Fork 0

Form handler, saver and style timprovements

upload-progress
Ambrose Chua 2021-05-23 23:32:44 +08:00
parent bce86f4e20
commit 7f274c0e10
16 changed files with 394 additions and 66 deletions

View File

@ -2,6 +2,7 @@ package main
import (
"fmt"
"net/url"
"strings"
)
@ -25,12 +26,45 @@ type credential struct {
Prefix string
}
func newCredential(endpoint, region, accessKey, secretKey, prefix, acl string) credential {
parsedEndpoint, _ := url.Parse(endpoint)
return credential{
Endpoint: parsedEndpoint.String(),
Region: region,
AccessKey: accessKey,
SecretKey: secretKey,
Prefix: prefix,
ACL: acl,
}
}
func (cred credential) validate() error {
parsedEndpoint, err := url.Parse(cred.Endpoint)
if err != nil {
return fmt.Errorf("%w: endpoint must be a URL and not empty", errBadRequest)
} else if parsedEndpoint.Host == "" {
return fmt.Errorf("%w: endpoint must have a valid host", errBadRequest)
} else if parsedEndpoint.User != nil {
return fmt.Errorf("%w: endpoint must not have user credentials", errBadRequest)
} else if parsedEndpoint.RawQuery != "" {
return fmt.Errorf("%w: endpoint must not have query parameters", errBadRequest)
} else if parsedEndpoint.RawFragment != "" {
return fmt.Errorf("%w: endpoint must not have fragment", errBadRequest)
} else if parsedEndpoint.Scheme != "http" && parsedEndpoint.Scheme != "https" {
return fmt.Errorf("%w: endpoint must be http(s)", errBadRequest)
}
if cred.Region == "" {
return fmt.Errorf("%w: region must not be empty", errBadRequest)
}
if strings.HasSuffix(cred.Endpoint, "/") {
return fmt.Errorf("%w: endpoint should not end with slash", errBadRequest)
}
if strings.HasPrefix(cred.Prefix, "/") {
return fmt.Errorf("%w: prefix should not start with slash", errBadRequest)
}
return nil
}

View File

@ -64,7 +64,7 @@ func handleCreateMultipartUpload(w http.ResponseWriter, req *http.Request) {
}
if err := r.validate(); err != nil {
errorResponse(w, req, fmt.Errorf("%w: %s", errBadRequest, err))
errorResponse(w, req, err)
return
}
@ -73,7 +73,7 @@ func handleCreateMultipartUpload(w http.ResponseWriter, req *http.Request) {
result, err := initiateMultipartUpload(key, cred)
if err != nil {
errorResponse(w, req, fmt.Errorf("%w: %s", errInternalServerError, err))
errorResponse(w, req, err)
return
}
@ -109,7 +109,7 @@ func handleGetUploadedParts(w http.ResponseWriter, req *http.Request) {
for {
page, err := listParts(key, uploadID, cred, nextPartNumberMarker)
if err != nil {
errorResponse(w, req, fmt.Errorf("%w: %s", errInternalServerError, err))
errorResponse(w, req, err)
return
}
@ -208,13 +208,13 @@ func handleCompleteMultipartUpload(w http.ResponseWriter, req *http.Request) {
}
if err := r.validate(); err != nil {
errorResponse(w, req, fmt.Errorf("%w: %s", errBadRequest, err))
errorResponse(w, req, err)
return
}
result, err := completeMultipartUpload(key, uploadID, r.Parts, cred)
if err != nil {
errorResponse(w, req, fmt.Errorf("%w: %s", errInternalServerError, err))
errorResponse(w, req, err)
return
}
@ -242,7 +242,7 @@ func handleAbortMultipartUpload(w http.ResponseWriter, req *http.Request) {
err = abortMultipartUpload(key, uploadID, cred)
if err != nil {
errorResponse(w, req, fmt.Errorf("%w: %s", errInternalServerError, err))
errorResponse(w, req, err)
return
}
}

View File

@ -114,15 +114,16 @@ type createReq struct {
}
func handleCreateForm(w http.ResponseWriter, req *http.Request) {
cred := credential{
Endpoint: req.PostFormValue("Endpoint"),
Region: req.PostFormValue("Region"),
AccessKey: req.PostFormValue("AccessKey"),
SecretKey: req.PostFormValue("SecretKey"),
Prefix: req.PostFormValue("Prefix"),
}
cred := newCredential(
req.PostFormValue("Endpoint"),
req.PostFormValue("Region"),
req.PostFormValue("AccessKey"),
req.PostFormValue("SecretKey"),
req.PostFormValue("Prefix"),
req.PostFormValue("ACL"),
)
if err := cred.validate(); err != nil {
errorResponse(w, req, fmt.Errorf("%w: %s", errBadRequest, err))
errorResponse(w, req, err)
return
}

View File

@ -8,6 +8,8 @@ import (
var errNotFound = errors.New("not found")
var errBadRequest = errors.New("bad request")
var errInternalServerError = errors.New("internal server error")
var errUnauthorized = errors.New("unauthorized")
var errForbidden = errors.New("forbidden")
func errorResponseStatus(w http.ResponseWriter, req *http.Request, err error) {
errorStatus := http.StatusInternalServerError
@ -18,6 +20,10 @@ func errorResponseStatus(w http.ResponseWriter, req *http.Request, err error) {
errorStatus = http.StatusBadRequest
} else if errors.Is(err, errInternalServerError) {
errorStatus = http.StatusInternalServerError
} else if errors.Is(err, errUnauthorized) {
errorStatus = http.StatusUnauthorized
} else if errors.Is(err, errForbidden) {
errorStatus = http.StatusForbidden
}
w.WriteHeader(errorStatus)
@ -31,3 +37,19 @@ func errorResponse(w http.ResponseWriter, req *http.Request, err error) {
errorResponseStatus(w, req, err)
w.Write([]byte(err.Error()))
}
// responseToError converts a HTTP status code to an error
func responseToError(resp *http.Response) error {
if resp.StatusCode == http.StatusNotFound {
return errNotFound
} else if resp.StatusCode == http.StatusBadRequest {
return errBadRequest
} else if resp.StatusCode == http.StatusInternalServerError {
return errInternalServerError
} else if resp.StatusCode == http.StatusUnauthorized {
return errUnauthorized
} else if resp.StatusCode == http.StatusForbidden {
return errForbidden
}
return nil
}

View File

@ -26,8 +26,8 @@ func main() {
router.Methods(http.MethodGet).Path("/readyz").HandlerFunc(readyz)
router.Methods(http.MethodGet).PathPrefix("/assets").HandlerFunc(handleAssets)
router.Methods(http.MethodGet).Path("/create").HandlerFunc(handleCreate)
router.Methods(http.MethodPost).Path("/create").HandlerFunc(handleCreateForm)
router.Methods(http.MethodGet).Path("/").HandlerFunc(handleCreate)
router.Methods(http.MethodPost).Path("/").HandlerFunc(handleCreateForm)
uploadRouter := router.PathPrefix("/{id}").Subrouter()
uploadTemplateRouter := uploadRouter.Path("").Subrouter()

63
s3.go
View File

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/url"
"strconv"
@ -52,6 +53,32 @@ func stripETag(t string) string {
return strings.TrimSuffix(strings.TrimPrefix(t, "\""), "\"")
}
type errEndpoint struct {
err error
status string
body []byte
}
func (e errEndpoint) Unwrap() error {
return e.err
}
func (e errEndpoint) Error() string {
body := bytes.ReplaceAll(e.body, []byte("\n"), []byte(""))
if e.err != nil {
return fmt.Sprintf("endpoint responded with %v: %s", e.err, body)
}
return fmt.Sprintf("endpoint responded with %s: %s", e.status, body)
}
func endpointReturnedError(resp *http.Response) error {
if resp.StatusCode < 200 || resp.StatusCode > 299 {
body, _ := ioutil.ReadAll(resp.Body)
return errEndpoint{responseToError(resp), resp.Status, body}
}
return nil
}
/* initiateMultipartUpload */
type initiateMultipartUploadResult struct {
@ -71,6 +98,7 @@ func initiateMultipartUpload(
params.Set("uploads", "")
unsignedReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cred.Endpoint+"/"+key+"?"+params.Encode(), nil)
if err != nil {
log.Printf("failure creating request: %v", err)
return initiateMultipartUploadResult{}, err
}
if cred.ACL != "" {
@ -80,12 +108,14 @@ func initiateMultipartUpload(
signedReq := sign(unsignedReq, cred)
resp, err := httpClientS3.Do(signedReq)
if err != nil {
log.Printf("failure connecting to endpoint: %v", err)
return initiateMultipartUploadResult{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := ioutil.ReadAll(resp.Body)
return initiateMultipartUploadResult{}, fmt.Errorf("endpoint request failed: %d: %s", resp.StatusCode, body)
err = endpointReturnedError(resp)
if err != nil {
log.Printf("endpoint responded negatively: %v", err)
return initiateMultipartUploadResult{}, err
}
result := initiateMultipartUploadResult{}
@ -136,18 +166,21 @@ func listParts(
params.Set("uploadId", uploadID)
unsignedReq, err := http.NewRequestWithContext(ctx, http.MethodGet, cred.Endpoint+"/"+key+"?"+params.Encode(), nil)
if err != nil {
log.Printf("failure creating request: %v", err)
return listPartsResult{}, err
}
signedReq := sign(unsignedReq, cred)
resp, err := httpClientS3.Do(signedReq)
if err != nil {
log.Printf("failure connecting to endpoint: %v", err)
return listPartsResult{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := ioutil.ReadAll(resp.Body)
return listPartsResult{}, fmt.Errorf("endpoint request failed: %d: %s", resp.StatusCode, body)
err = endpointReturnedError(resp)
if err != nil {
log.Printf("endpoint responded negatively: %v", err)
return listPartsResult{}, err
}
result := listPartsResult{}
@ -213,18 +246,21 @@ func completeMultipartUpload(
params.Set("uploadId", uploadID)
unsignedReq, err := http.NewRequestWithContext(ctx, http.MethodPost, cred.Endpoint+"/"+key+"?"+params.Encode(), &body)
if err != nil {
log.Printf("failure creating request: %v", err)
return completeMultipartUploadResult{}, err
}
signedReq := sign(unsignedReq, cred)
resp, err := httpClientS3.Do(signedReq)
if err != nil {
log.Printf("failure connecting to endpoint: %v", err)
return completeMultipartUploadResult{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := ioutil.ReadAll(resp.Body)
return completeMultipartUploadResult{}, fmt.Errorf("endpoint request failed: %d: %s", resp.StatusCode, body)
err = endpointReturnedError(resp)
if err != nil {
log.Printf("endpoint responded negatively: %v", err)
return completeMultipartUploadResult{}, err
}
result := completeMultipartUploadResult{}
@ -252,18 +288,21 @@ func abortMultipartUpload(
params.Set("uploadId", uploadID)
unsignedReq, err := http.NewRequestWithContext(ctx, http.MethodDelete, cred.Endpoint+"/"+key+"?"+params.Encode(), nil)
if err != nil {
log.Printf("failure creating request: %v", err)
return err
}
signedReq := sign(unsignedReq, cred)
resp, err := httpClientS3.Do(signedReq)
if err != nil {
log.Printf("failure connecting to endpoint: %v", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
body, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf("endpoint request failed: %d: %s", resp.StatusCode, body)
err = endpointReturnedError(resp)
if err != nil {
log.Printf("endpoint responded negatively: %v", err)
return err
}
return nil

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
@ -62,7 +63,7 @@ func (s *redisStore) ping() error {
return err
}
if pong != "PONG" {
return errInternalServerError
return fmt.Errorf("%w: pong request failed", errInternalServerError)
}
return nil
}
@ -74,6 +75,7 @@ func (s *redisStore) put(key string, data []byte, expire time.Duration) error {
exists := 0
err := s.client.Do(ctx, radix.Cmd(&exists, "EXISTS", "upl:"+key))
if err != nil {
log.Printf("put failed on existence check: %v", err)
return err
}
@ -84,6 +86,7 @@ func (s *redisStore) put(key string, data []byte, expire time.Duration) error {
expireS := int64(expire / time.Second)
err = s.client.Do(ctx, radix.FlatCmd(nil, "SETEX", "upl:"+key, expireS, data))
if err != nil {
log.Printf("put failed: %v", err)
return err
}
return nil
@ -96,6 +99,7 @@ func (s *redisStore) get(key string) ([]byte, error) {
var data []byte
err := s.client.Do(ctx, radix.Cmd(&data, "GET", "upl:"+key))
if err != nil {
log.Printf("get failed: %v", err)
return nil, err
}

View File

@ -1,17 +1,35 @@
{{template "head.tmpl" "Create dropbox"}}
<form class="space-y-8 px-6 my-8" method="POST">
<h1 class="my-4 text-4xl font-bold text-center">Create a dropbox</h1>
{{template "head.tmpl" "Your dropboxes"}}
<form class="create space-y-12 px-6 my-12" method="POST">
<section class="space-y-4">
<h1 class="text-4xl font-bold text-center">Your dropboxes</h1>
<div class="flex items-center">
<h4 class="my-2 flex-1 text-lg font-bold">Bucket options</h4>
<h4 class="my-2 flex-1 text-xl font-bold">Previously created</h4>
<span>
<button
type="button"
class="log-clear px-6 py-2 rounded-md bg-yellow-600 text-white hover:bg-yellow-700 focus:ring-2 focus:ring-offset-2 focus:ring-yellow-600">
Clear
</button>
</span>
</div>
<div class="log-area"></div>
</section>
<section class="space-y-4">
<h1 class="text-4xl font-bold text-center">Create a dropbox</h1>
<div class="notice-area hidden p-4 bg-red-100 text-red-700"></div>
<div class="flex items-center">
<h4 class="my-2 flex-1 text-xl font-bold">Bucket options</h4>
<span>
<input
class="rounded"
type="checkbox"
id="options-save-bucket"
data-save="Endpoint,Region,AccessKey,SecretKey,Prefix,ACL">
data-save="Endpoint,Region,AccessKey,SecretKey">
<label for="options-save-bucket">Remember</label>
</span>
</div>
@ -23,6 +41,7 @@
type="url"
id="options-endpoint"
name="Endpoint"
required
placeholder="https://bucketname.s3.us-west-2.amazonaws.com">
</div>
</div>
@ -34,6 +53,7 @@
type="text"
id="options-region"
name="Region"
required
placeholder="us-east-1">
</div>
</div>
@ -45,6 +65,7 @@
type="text"
id="options-accesskey"
name="AccessKey"
autocomplete="off"
placeholder="">
</div>
</div>
@ -56,23 +77,37 @@
type="text"
id="options-secretkey"
name="SecretKey"
autocomplete="off"
placeholder="">
</div>
</div>
</section>
<section class="space-y-4">
<div class="flex items-center">
<h4 class="my-2 flex-1 text-lg font-bold">Upload options</h4>
<h4 class="my-2 flex-1 text-xl font-bold">Upload options</h4>
<span>
<input
class="rounded"
type="checkbox"
id="options-save-upload"
data-save="Prefix,ExpiresNumber,ExpiresUnits">
data-save="ACL,Prefix,ExpiresNumber,ExpiresUnits">
<label for="options-save-upload">Remember</label>
</span>
</div>
<div>
<label for="options-acl">Canned ACL <span class="text-sm text-gray-500">Optional</span></label>
<div class="mt-1">
<input
class="w-full rounded-md border-gray-400"
type="text"
id="options-acl"
name="ACL"
placeholder=""
value="">
</div>
<p class="mt-1 text-sm text-gray-500">
Specify an optional <a class="text-blue-700" target="_blank" href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl">canned ACL</a>. Otherwise, object access permissions will follow bucket defaults.
</p>
</div>
<div>
<label for="options-prefix">Prefix</label>
<div class="mt-1">
@ -85,7 +120,7 @@
value="{random}/">
</div>
<p class="mt-1 text-sm text-gray-500">
Files will be uploaded with this prefix. For a random <code>[a-z0-9]{16}</code> prefix , use <code>{random}</code>.
Files will be uploaded with this prefix. For a random <code>[a-z0-9]{16}</code> prefix , use <code>{random}</code>.
</p>
</div>
<div>
@ -118,9 +153,6 @@
data-derive="duration,ExpiresNumber,ExpiresUnits"
data-derive-notice="#expiry-notice">
</div>
</section>
<section class="space-y-4">
<div class="flex justify-end">
<input
class="px-6 py-2 rounded-md bg-green-600 text-white hover:bg-green-700 focus:ring-2 focus:ring-offset-2 focus:ring-green-600"

70
web/src/create.js Normal file
View File

@ -0,0 +1,70 @@
import Log from './log';
async function throwForStatus(res) {
if (!res.ok) {
const text = await res.text();
throw new Error(text);
}
return res;
}
const createAreas = document.querySelectorAll('.create');
createAreas.forEach(createArea => {
/* Elements */
const logArea = createArea.querySelector('.log-area');
const logClearBtn = createArea.querySelector('.log-clear');
const noticeArea = createArea.querySelector('.notice-area');
/* Components */
const log = new Log(logArea, window.location.pathname, {
empty: 'Your locally-stored dropbox creation history is empty',
});
logClearBtn.addEventListener('click', () => {
log.clear();
});
/* Form */
function showError(error='') {
let message = error.message || error.toString();
if (message !== '') {
noticeArea.classList.remove('hidden');
noticeArea.innerText = message;
} else {
noticeArea.classList.add('hidden');
noticeArea.innerText = message;
}
}
createArea.addEventListener('submit', e => {
e.preventDefault();
// Clear previous errors
showError();
const data = new FormData(e.target);
fetch(e.target.action, {
method: e.target.method,
body: data,
})
.then(throwForStatus)
.then(res => res.text())
.then(id => {
log.add({
location: window.location.origin + '/' + id,
title: `
Endpoint: ${data.get('Endpoint')}
Region: ${data.get('Region')}
Prefix: ${data.get('Prefix')}
Expires: ${data.get('ExpiresNumber')}${data.get('ExpiresUnits')}
`.trim(),
});
})
.catch(showError);
});
});

View File

@ -41,7 +41,7 @@ class Deriver {
if (!this.notice) {
return;
}
let message = error.toString();
let message = error.message || error.toString();
if (Array.isArray(error.problems)) {
message = 'Invalid input: ' + error.problems.join(', ');
}

40
web/src/limiter.js Normal file
View File

@ -0,0 +1,40 @@
class Limiter {
constructor(delay=500) {
this.delay = delay;
this.interval = null;
this.next = null;
}
call(fn) {
if (this.interval == null) {
fn();
this.enableClock();
} else {
this.next = fn;
}
}
checkCall() {
if (this.next == null) {
return this.disableClock();
}
// Function wanted to be called
this.next();
this.next = null;
}
enableClock() {
this.interval = setInterval(() => {
this.checkCall();
}, this.delay);
}
disableClock() {
clearInterval(this.interval);
this.interval = null;
}
}
export default Limiter;

View File

@ -1,14 +1,14 @@
import filesize from 'filesize';
class Log {
constructor(target, key) {
constructor(target, key, messages) {
this.key = key;
this.target = target;
this.items = [];
const empty = document.createElement('div');
empty.innerText = 'Your locally-stored file upload history is empty';
empty.classList.add('mt-1', 'p-2', 'bg-gray-50', 'text-gray-500', 'rounded-md', 'border', 'border-gray-400', 'flex', 'justify-center');
empty.innerText = messages.empty || 'Empty';
empty.classList.add('mt-2', 'p-2', 'bg-gray-50', 'text-gray-500', 'rounded-md', 'border', 'border-gray-400', 'flex', 'justify-center');
this.empty = empty;
this.localStorageLoad();
@ -26,24 +26,36 @@ class Log {
static renderItem(item) {
const base = document.createElement('div');
base.classList.add('mt-1');
base.classList.add('mt-2');
base.classList.add('flex');
base.title = item.title;
const url = document.createElement('input');
url.type = 'url';
url.value = item.location;
url.setAttribute('readonly', '');
url.classList.add('w-full', 'rounded-l-md', 'border-gray-400');
url.classList.add('w-full', 'rounded-l-md', 'border-r-0', 'border-gray-400');
url.addEventListener('click', (e) => {
//e.target.setSelectionRange(0, e.target.value.length);
e.target.select();
});
base.appendChild(url);
const size = document.createElement('span');
size.innerText = filesize(item.size);
size.classList.add('text-sm', 'whitespace-nowrap', 'px-2', 'bg-gray-50', 'text-gray-500', 'rounded-r-md', 'border', 'border-gray-400', 'border-l-0', 'inline-flex', 'items-center', 'justify-center', 'w-24');
base.appendChild(size);
const urlOpen = document.createElement('a');
urlOpen.target = '_blank';
urlOpen.href = item.location;
urlOpen.classList.add('rounded-r-md', 'px-2', 'text-blue-700', 'border', 'border-l-0', 'border-gray-400', 'inline-flex', 'items-center');
urlOpen.innerText = 'Open';
base.appendChild(urlOpen);
if (item.size) {
urlOpen.classList.remove('rounded-r-md');
const size = document.createElement('span');
size.innerText = filesize(item.size);
size.classList.add('text-sm', 'whitespace-nowrap', 'px-2', 'bg-gray-50', 'text-gray-500', 'rounded-r-md', 'border', 'border-gray-400', 'border-l-0', 'inline-flex', 'items-center', 'justify-center', 'w-24');
base.appendChild(size);
}
return base;
}

View File

@ -11,3 +11,4 @@ import '@uppy/status-bar/dist/style.css';
import './save';
import './derive';
import './upload';
import './create';

View File

@ -0,0 +1,64 @@
import Limiter from './limiter';
class Saver {
constructor(target, inputs, key='') {
this.target = target;
this.inputs = inputs;
this.key = '';
this.limiter = new Limiter();
this.updateState = this.updateState.bind(this);
this.input = this.input.bind(this);
this.save = this.save.bind(this);
this.target.addEventListener('input', this.updateState);
this.updateState();
}
updateState() {
if (this.target.checked) {
this.inputs.forEach(input => input.addEventListener('input', this.input));
this.save();
} else {
this.inputs.forEach(input => input.removeEventListener('input', this.input));
this.clear();
}
}
input() {
this.limiter.call(this.save);
}
load() {
const values = JSON.parse(window.localStorage.getItem('save' + this.key) || '{}');
for (const input of this.inputs) {
if (input.name in values) {
input.value = values[input.name];
}
}
}
save() {
const values = {};
for (const input of this.inputs) {
values[input.name] = input.value;
}
window.localStorage.setItem('save' + this.key, JSON.stringify(values));
}
clear() {
window.localStorage.removeItem('save' + this.key);
}
}
const saveInputs = document.querySelectorAll('[data-save]');
saveInputs.forEach(saveInput => {
const inputNames = saveInput.dataset.save.split(',');
const inputs = inputNames.map(inputName => document.querySelector(`[name="${inputName}"]`));
const saver = new Saver(saveInput, inputs);
saver.load();
});

View File

@ -17,7 +17,9 @@ uploadAreas.forEach(uploadArea => {
/* Components */
const log = new Log(logArea, window.location.pathname);
const log = new Log(logArea, window.location.pathname, {
empty: 'Your locally-stored file upload history is empty',
});
logClearBtn.addEventListener('click', () => {
log.clear();

View File

@ -1,18 +1,25 @@
{{template "head.tmpl" "Dropbox"}}
<div class="upload space-y-8 px-6 my-8">
<div>
<h1 class="my-4 text-4xl font-bold text-center">Drop a file</h1>
<section>
<div class="drop-area"></div>
<div class="status-area"></div>
</div>
<div class="flex items-center">
<h4 class="my-2 flex-1 text-lg font-bold">File upload history</h4>
<span>
<button
class="log-clear px-6 py-2 rounded-md bg-yellow-600 text-white hover:bg-yellow-700 focus:ring-2 focus:ring-offset-2 focus:ring-yellow-600">
Clear
</button>
</span>
</div>
<div class="log-area"></div>
</section>
<section class="space-y-4">
<div class="flex items-center">
<h4 class="my-2 flex-1 text-xl font-bold">File upload history</h4>
<span>
<button
class="log-clear px-6 py-2 rounded-md bg-yellow-600 text-white hover:bg-yellow-700 focus:ring-2 focus:ring-offset-2 focus:ring-yellow-600">
Clear
</button>
</span>
</div>
<div class="log-area"></div>
</section>
</div>
{{template "foot.tmpl"}}