Skip to content

Commit

Permalink
Added streaming file uploads to reduce memory usage and provide progr…
Browse files Browse the repository at this point in the history
…ess feedback on the front end
  • Loading branch information
DeanWard committed Feb 12, 2025
1 parent fd6bc55 commit ea6d068
Show file tree
Hide file tree
Showing 8 changed files with 448 additions and 102 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
.DS_Store
storage/
erugo.db
*.db
frontend/node_modules
frontend/dist
build/*
private
*/*.db
9 changes: 5 additions & 4 deletions frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,6 @@ export const createUser = async user => {
return data.data
}



export const updateUser = async user => {
const response = await fetchWithAuth(`${apiUrl}/api/users/${user.id}`, {
method: 'PUT',
Expand Down Expand Up @@ -274,15 +272,17 @@ export const saveLogo = async logoFile => {
return data.data
}

export const createShare = async (files, name, description) => {
// Share Methods
export const createShare = async (files, name, description, uploadId) => {

const formData = new FormData()
files.forEach(file => {
formData.append('files', file)
})
formData.append('name', name)
formData.append('description', description)

const response = await fetchWithAuth(`${apiUrl}/api/shares`, {
const response = await fetchWithAuth(`${apiUrl}/api/shares?uploadId=${uploadId}`, {
method: 'POST',
body: formData
})
Expand All @@ -307,6 +307,7 @@ export const getShare = async id => {
return data.data.share
}

//misc methods
export const getHealth = async () => {
const response = await fetch(`${apiUrl}/api/health`)
const data = await response.json()
Expand Down
120 changes: 115 additions & 5 deletions frontend/src/components/uploader.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { CircleSlash2, FilePlus, FolderPlus, Upload, Trash, Copy, X, Loader, Check } from 'lucide-vue-next'
import { niceFileSize, niceFileType } from '../utils'
import { niceFileSize, niceFileType, simpleUUID, getApiUrl } from '../utils'
import { createShare, getHealth } from '../api'
const apiUrl = getApiUrl()
const fileInput = ref(null)
const sharePanelVisible = ref(false)
const shareUrl = ref('')
const currentlyUploading = ref(false)
const uploadBasket = ref([])
const maxShareSize = ref(0)
const uploadProgress = ref(0)
const uploadedBytes = ref(0)
const totalBytes = ref(0)
onMounted(async () => {
const health = await getHealth()
Expand Down Expand Up @@ -58,19 +61,34 @@
})
const uploadFiles = async () => {
//Simple UUID-like string to track upload progress via SSE
const uploadId = simpleUUID()
currentlyUploading.value = true
if (totalSize.value > maxShareSize.value) {
alert(`Total size of files is greater than the max share size of ${niceFileSize(maxShareSize.value)}`)
return
}
setTimeout(() => {
const eventSource = createEventSource(uploadId)
}, 1)
try {
const share = await createShare(uploadBasket.value, 'test', 'test')
const share = await createShare(uploadBasket.value, 'test', 'test', uploadId)
console.log(share)
showSharePanel(createShareURL(share.long_id))
uploadBasket.value = []
} catch (error) {
console.error(error)
} finally {
currentlyUploading.value = false
setTimeout(() => {
uploadProgress.value = 0
uploadedBytes.value = 0
totalBytes.value = 0
}, 1000)
}
currentlyUploading.value = false
}
const createShareURL = longId => {
Expand All @@ -91,7 +109,29 @@
showCopySuccess.value = true
setTimeout(() => {
showCopySuccess.value = false
}, 2000)
}, 10)
}
const createEventSource = uploadId => {
const eventSource = new EventSource(`${apiUrl}/api/shares/progress/${uploadId}`)
console.log(eventSource)
eventSource.onmessage = event => {
const progress = JSON.parse(event.data)
uploadProgress.value = progress.totalProgress
uploadedBytes.value = progress.totalBytesRead
totalBytes.value = progress.totalFileSize
}
//if we get an error 10 times, close the event source
let errorCount = 0
eventSource.onerror = () => {
errorCount++
if (errorCount >= 10) {
eventSource.close()
currentlyUploading.value = false
}
}
return eventSource
}
</script>

Expand All @@ -108,6 +148,17 @@
</button>
</div>
<div class="max-size-label">{{ niceFileSize(totalSize) }} / {{ niceFileSize(maxShareSize) }}</div>
<div>
<div class="progress-bar-container" :class="{ visible: currentlyUploading }">
<div class="progress-bar">
<div class="progress-bar-fill" :style="{ width: `${uploadProgress}%` }"></div>
</div>
<div class="progress-bar-text">
{{ Math.round(uploadProgress) }}%
<div class="progress-bar-text-sub">{{ niceFileSize(uploadedBytes) }} / {{ niceFileSize(totalBytes) }}</div>
</div>
</div>
</div>
</div>

<div class="upload-basket">
Expand Down Expand Up @@ -165,3 +216,62 @@
</div>
</div>
</template>

<style scoped lang="scss">
.progress-bar-container {
margin-top: -20px;
// width: 300px;
// height: 30px;
background-color: var(--accent-color-light);
border-radius: 5px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
position: absolute;
opacity: 0;
transition: all 0.3s ease-in-out;
left: 0;
right: 0;
top: 20px;
bottom: 0;
z-index: 1000;
pointer-events: none;
&.visible {
opacity: 1;
}
.progress-bar {
height: 100%;
width: 100%;
background: transparent;
.progress-bar-fill {
background-color: var(--primary-color);
border-radius: 5px;
transition: all 1s linear;
height: 100%;
}
}
.progress-bar-text {
font-size: 24px;
color: var(--secondary-color);
font-weight: 600;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
.progress-bar-text-sub {
font-size: 10px;
color: var(--secondary-color);
font-weight: 400;
}
}
}
</style>
12 changes: 10 additions & 2 deletions frontend/src/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@


const simpleUUID = () => {
//this isn't cryptographically secure, but it's good enough for our purposes
//our purposes being a simple unique string to track upload progress via SSE
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}

const niceFileSize = size => {
//return in most readable format
Expand Down Expand Up @@ -48,4 +56,4 @@ const getApiUrl = () => {
return url
}

export { niceFileSize, niceFileType, niceExpirationDate, timeUntilExpiration, getApiUrl }
export { niceFileSize, niceFileType, niceExpirationDate, timeUntilExpiration, getApiUrl, simpleUUID }
120 changes: 120 additions & 0 deletions handlers/progress.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package handlers

import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"github.com/DeanWard/erugo/progress"
"github.com/gorilla/mux"
)

func UploadProgressHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uploadID := mux.Vars(r)["uploadId"]
if uploadID == "" {
http.Error(w, "Upload ID required", http.StatusBadRequest)
return
}

// Set headers for SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")

// Get the progress channel for this upload
tracker := progress.GetTracker()
progressChan, exists := tracker.GetUploadChannel(uploadID)
if !exists {
http.Error(w, "Upload not found", http.StatusNotFound)
return
}

flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}

// Use the request's context to detect client disconnect
ctx := r.Context()

for {
select {
case <-ctx.Done():
// Client disconnected
tracker.DeleteUpload(uploadID)
return

case progress, ok := <-progressChan:
if !ok {
// Channel closed - upload complete or failed
return
}

// Send progress update
data, _ := json.Marshal(progress)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
})
}

type ProgressReader struct {
Reader io.Reader
Size int64 // Current file size
TotalFileSize int64 // Size of all files
bytesRead int64 // Current file bytes read
totalRead int64 // Total bytes read across all files
lastUpdate time.Time
uploadID string
tracker *progress.ProgressTracker
}

func NewProgressReader(reader io.Reader, size int64, totalSize int64, totalRead int64, uploadID string) *ProgressReader {
return &ProgressReader{
Reader: reader,
Size: size,
TotalFileSize: totalSize,
totalRead: totalRead,
uploadID: uploadID,
tracker: progress.GetTracker(),
lastUpdate: time.Now(),
}
}

func (pr *ProgressReader) Read(p []byte) (int, error) {
n, err := pr.Reader.Read(p)

pr.bytesRead += int64(n)
pr.totalRead += int64(n)

// Update progress every 500ms
if time.Since(pr.lastUpdate) > 500*time.Millisecond {
if progressChan, exists := pr.tracker.GetUploadChannel(pr.uploadID); exists {

totalProgress := float64(pr.totalRead) / float64(pr.TotalFileSize) * 100

select {
case progressChan <- progress.Progress{
BytesRead: pr.bytesRead,
TotalSize: pr.Size,
TotalBytesRead: pr.totalRead,
TotalFileSize: pr.TotalFileSize,
TotalProgress: totalProgress,
UploadID: pr.uploadID,
LastUpdate: time.Now(),
}:

default:
}
}
pr.lastUpdate = time.Now()
}

return n, err
}
Loading

0 comments on commit ea6d068

Please sign in to comment.