diff --git a/internal/db/db.go b/internal/db/db.go index b8f2a48..0abeebd 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -32,5 +32,9 @@ func Open(dbPath string) (*sql.DB, error) { return nil, fmt.Errorf("init schema: %w", err) } + // Migrations for existing databases + db.Exec("ALTER TABLE files ADD COLUMN batch_id TEXT") + db.Exec("CREATE INDEX IF NOT EXISTS idx_files_batch_id ON files(batch_id) WHERE batch_id IS NOT NULL") + return db, nil } diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 534cec4..ebd1ce5 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -8,8 +8,10 @@ CREATE TABLE IF NOT EXISTS files ( expires_at DATETIME, password_hash TEXT, delete_token TEXT NOT NULL UNIQUE, - download_count INTEGER DEFAULT 0 + download_count INTEGER DEFAULT 0, + batch_id TEXT ); CREATE INDEX IF NOT EXISTS idx_files_expires_at ON files(expires_at) WHERE expires_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_files_delete_token ON files(delete_token); +CREATE INDEX IF NOT EXISTS idx_files_batch_id ON files(batch_id) WHERE batch_id IS NOT NULL; diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 9b30fea..800cd6e 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -15,10 +15,10 @@ import ( var passwordTmpl *template.Template func InitTemplates(webFS embed.FS) { + funcs := template.FuncMap{"formatSize": formatSizeHTML} passwordTmpl = template.Must(template.ParseFS(webFS, "web/password.html")) - downloadTmpl = template.Must(template.New("download.html").Funcs(template.FuncMap{ - "formatSize": formatSizeHTML, - }).ParseFS(webFS, "web/download.html")) + downloadTmpl = template.Must(template.New("download.html").Funcs(funcs).ParseFS(webFS, "web/download.html")) + batchTmpl = template.Must(template.New("batch.html").Funcs(funcs).ParseFS(webFS, "web/batch.html")) } func formatSizeHTML(bytes int64) string { diff --git a/internal/handler/batch.go b/internal/handler/batch.go new file mode 100644 index 0000000..8a17869 --- /dev/null +++ b/internal/handler/batch.go @@ -0,0 +1,97 @@ +package handler + +import ( + "html/template" + "log" + "net/http" + "strings" + "time" +) + +var batchTmpl *template.Template + +func (h *Handler) Batch(w http.ResponseWriter, r *http.Request) { + batchID := r.PathValue("id") + if batchID == "" { + http.NotFound(w, r) + return + } + + files, err := h.store.GetBatch(batchID) + if err != nil { + log.Printf("db get batch error: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if len(files) == 0 { + http.NotFound(w, r) + return + } + + type fileData struct { + ID string + Filename string + Size int64 + ContentType string + UploadedAt string + ThumbType string + PreviewURL string + ViewURL string + DownloadURL string + } + + var items []fileData + var totalSize int64 + for _, f := range files { + // Skip expired files + if f.ExpiresAt != nil && f.ExpiresAt.Before(time.Now().UTC()) { + continue + } + + thumbType := "" + previewURL := "" + if strings.HasPrefix(f.ContentType, "image/") { + thumbType = "image" + } else if strings.HasPrefix(f.ContentType, "video/") { + thumbType = "video" + } else if strings.HasPrefix(f.ContentType, "audio/") { + thumbType = "audio" + } else if f.ContentType == "application/pdf" { + thumbType = "pdf" + } + if thumbType != "" { + if url, err := h.r2.PresignGet(r.Context(), f.R2Key, f.Filename, true); err == nil { + previewURL = url + } + } + + totalSize += f.SizeBytes + items = append(items, fileData{ + ID: f.ID, + Filename: f.Filename, + Size: f.SizeBytes, + ContentType: f.ContentType, + UploadedAt: f.UploadedAt.Format("Jan 2, 2006 at 3:04 PM"), + ThumbType: thumbType, + PreviewURL: previewURL, + ViewURL: "/f/" + f.ID + "/view", + DownloadURL: "/f/" + f.ID + "/dl", + }) + } + + if len(items) == 0 { + http.NotFound(w, r) + return + } + + data := map[string]any{ + "BatchID": batchID, + "Files": items, + "Count": len(items), + "TotalSize": totalSize, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + batchTmpl.Execute(w, data) +} diff --git a/internal/handler/upload.go b/internal/handler/upload.go index a19bea0..e1735dd 100644 --- a/internal/handler/upload.go +++ b/internal/handler/upload.go @@ -47,6 +47,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { expiresIn string password string customSlug string + batchID string fileSize int64 fileUploaded bool fileID string @@ -141,6 +142,11 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { buf := make([]byte, 128) n, _ := part.Read(buf) customSlug = strings.TrimSpace(string(buf[:n])) + + case "batch_id": + buf := make([]byte, 64) + n, _ := part.Read(buf) + batchID = strings.TrimSpace(string(buf[:n])) } part.Close() } @@ -185,6 +191,9 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { ContentType: contentType, DeleteToken: deleteTokenHex, } + if batchID != "" { + rec.BatchID = &batchID + } // Handle expiry if expiresIn != "" { @@ -228,6 +237,10 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) { if rec.ExpiresAt != nil { resp["expires_at"] = rec.ExpiresAt.Format(time.RFC3339) } + if batchID != "" { + resp["batch_id"] = batchID + resp["batch_url"] = fmt.Sprintf("%s/b/%s", h.config.BaseURL, batchID) + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) diff --git a/internal/store/store.go b/internal/store/store.go index aeaf4b0..7580957 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -16,6 +16,7 @@ type FileRecord struct { PasswordHash *string DeleteToken string DownloadCount int64 + BatchID *string } type Store struct { @@ -28,9 +29,9 @@ func New(db *sql.DB) *Store { func (s *Store) Create(f *FileRecord) error { _, err := s.db.Exec( - `INSERT INTO files (id, filename, r2_key, size_bytes, content_type, expires_at, password_hash, delete_token) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - f.ID, f.Filename, f.R2Key, f.SizeBytes, f.ContentType, f.ExpiresAt, f.PasswordHash, f.DeleteToken, + `INSERT INTO files (id, filename, r2_key, size_bytes, content_type, expires_at, password_hash, delete_token, batch_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + f.ID, f.Filename, f.R2Key, f.SizeBytes, f.ContentType, f.ExpiresAt, f.PasswordHash, f.DeleteToken, f.BatchID, ) return err } @@ -38,9 +39,9 @@ func (s *Store) Create(f *FileRecord) error { func (s *Store) Get(id string) (*FileRecord, error) { f := &FileRecord{} err := s.db.QueryRow( - `SELECT id, filename, r2_key, size_bytes, content_type, uploaded_at, expires_at, password_hash, delete_token, download_count + `SELECT id, filename, r2_key, size_bytes, content_type, uploaded_at, expires_at, password_hash, delete_token, download_count, batch_id FROM files WHERE id = ?`, id, - ).Scan(&f.ID, &f.Filename, &f.R2Key, &f.SizeBytes, &f.ContentType, &f.UploadedAt, &f.ExpiresAt, &f.PasswordHash, &f.DeleteToken, &f.DownloadCount) + ).Scan(&f.ID, &f.Filename, &f.R2Key, &f.SizeBytes, &f.ContentType, &f.UploadedAt, &f.ExpiresAt, &f.PasswordHash, &f.DeleteToken, &f.DownloadCount, &f.BatchID) if err != nil { return nil, err } @@ -60,9 +61,9 @@ func (s *Store) Delete(id string) error { func (s *Store) DeleteByToken(token string) (*FileRecord, error) { f := &FileRecord{} err := s.db.QueryRow( - `SELECT id, filename, r2_key, size_bytes, content_type, uploaded_at, expires_at, password_hash, delete_token, download_count + `SELECT id, filename, r2_key, size_bytes, content_type, uploaded_at, expires_at, password_hash, delete_token, download_count, batch_id FROM files WHERE delete_token = ?`, token, - ).Scan(&f.ID, &f.Filename, &f.R2Key, &f.SizeBytes, &f.ContentType, &f.UploadedAt, &f.ExpiresAt, &f.PasswordHash, &f.DeleteToken, &f.DownloadCount) + ).Scan(&f.ID, &f.Filename, &f.R2Key, &f.SizeBytes, &f.ContentType, &f.UploadedAt, &f.ExpiresAt, &f.PasswordHash, &f.DeleteToken, &f.DownloadCount, &f.BatchID) if err != nil { return nil, err } @@ -75,7 +76,7 @@ func (s *Store) DeleteByToken(token string) (*FileRecord, error) { func (s *Store) ListExpired() ([]*FileRecord, error) { rows, err := s.db.Query( - `SELECT id, filename, r2_key, size_bytes, content_type, uploaded_at, expires_at, password_hash, delete_token, download_count + `SELECT id, filename, r2_key, size_bytes, content_type, uploaded_at, expires_at, password_hash, delete_token, download_count, batch_id FROM files WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')`, ) if err != nil { @@ -86,7 +87,28 @@ func (s *Store) ListExpired() ([]*FileRecord, error) { var files []*FileRecord for rows.Next() { f := &FileRecord{} - if err := rows.Scan(&f.ID, &f.Filename, &f.R2Key, &f.SizeBytes, &f.ContentType, &f.UploadedAt, &f.ExpiresAt, &f.PasswordHash, &f.DeleteToken, &f.DownloadCount); err != nil { + if err := rows.Scan(&f.ID, &f.Filename, &f.R2Key, &f.SizeBytes, &f.ContentType, &f.UploadedAt, &f.ExpiresAt, &f.PasswordHash, &f.DeleteToken, &f.DownloadCount, &f.BatchID); err != nil { + return nil, err + } + files = append(files, f) + } + return files, rows.Err() +} + +func (s *Store) GetBatch(batchID string) ([]*FileRecord, error) { + rows, err := s.db.Query( + `SELECT id, filename, r2_key, size_bytes, content_type, uploaded_at, expires_at, password_hash, delete_token, download_count, batch_id + FROM files WHERE batch_id = ? ORDER BY filename`, batchID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var files []*FileRecord + for rows.Next() { + f := &FileRecord{} + if err := rows.Scan(&f.ID, &f.Filename, &f.R2Key, &f.SizeBytes, &f.ContentType, &f.UploadedAt, &f.ExpiresAt, &f.PasswordHash, &f.DeleteToken, &f.DownloadCount, &f.BatchID); err != nil { return nil, err } files = append(files, f) diff --git a/main.go b/main.go index b97eeb1..92e696e 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,7 @@ func main() { mux.HandleFunc("GET /f/{id}/auth", h.AuthPage) mux.HandleFunc("POST /f/{id}/auth", h.AuthSubmit) mux.HandleFunc("DELETE /f/{id}", h.Delete) + mux.HandleFunc("GET /b/{id}", h.Batch) // Favicon (prevent 404) mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, _ *http.Request) { diff --git a/web/batch.html b/web/batch.html new file mode 100644 index 0000000..4595f7c --- /dev/null +++ b/web/batch.html @@ -0,0 +1,47 @@ + + + + + + {{.Count}} files — upload.jeffemmett.com + + + +
+

{{.Count}} files

+

{{formatSize .TotalSize}} total

+ +
+ {{range .Files}} +
+
+ {{if eq .ThumbType "image"}} + {{.Filename}} + {{else if eq .ThumbType "video"}} + + {{else}} +
+ + + + +
+ {{end}} +
+
+ {{.Filename}} + {{formatSize .Size}} +
+
+ +
+
+ {{end}} +
+ + +
+ + diff --git a/web/index.html b/web/index.html index b477d70..aa9c7aa 100644 --- a/web/index.html +++ b/web/index.html @@ -83,6 +83,6 @@ CLI tool - + diff --git a/web/static/style.css b/web/static/style.css index 7c398cc..6109b22 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -365,6 +365,73 @@ footer a:hover { color: var(--accent); } white-space: nowrap; } +/* Batch page */ +.batch-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.batch-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} + +.batch-preview { + flex-shrink: 0; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; +} + +.batch-thumb { + max-width: 48px; + max-height: 48px; + border-radius: 4px; + object-fit: cover; +} + +.batch-icon { + color: var(--text-dim); +} + +.batch-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.batch-filename { + font-size: 0.875rem; + color: var(--accent); + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.batch-filename:hover { + text-decoration: underline; +} + +.batch-meta { + font-size: 0.75rem; + color: var(--text-dim); +} + +.batch-actions { + flex-shrink: 0; +} + /* Download page */ .file-card { margin-top: 0; diff --git a/web/static/upload.js b/web/static/upload.js index beae07f..36031b3 100644 --- a/web/static/upload.js +++ b/web/static/upload.js @@ -136,6 +136,9 @@ const password = document.getElementById('password').value; const slug = document.getElementById('slug').value.trim(); + // Generate batch ID for multi-file uploads + const batchId = files.length > 1 ? nanoid(8) : ''; + for (let i = 0; i < files.length; i++) { const file = files[i]; if (files.length > 1) { @@ -151,7 +154,8 @@ const data = await uploadOne(file, { expires, password, - slug: files.length === 1 ? slug : '' + slug: files.length === 1 ? slug : '', + batchId }); results.push(data); } catch (err) { @@ -179,6 +183,7 @@ return new Promise((resolve, reject) => { const formData = new FormData(); if (opts.slug) formData.append('slug', opts.slug); + if (opts.batchId) formData.append('batch_id', opts.batchId); formData.append('file', file); if (opts.expires) formData.append('expires_in', opts.expires); if (opts.password) formData.append('password', opts.password); @@ -234,12 +239,32 @@ resultSingle.classList.add('hidden'); resultMulti.classList.remove('hidden'); - resultList.innerHTML = results.map(data => + // Show batch URL if available + const batchUrl = results[0] && results[0].batch_url; + let html = ''; + if (batchUrl) { + html += '
' + + '' + + '' + + '
'; + } + html += results.map(data => '
' + '' + escapeHtml(data.filename) + '' + '' + escapeHtml(data.url) + '' + '
' ).join(''); + resultList.innerHTML = html; + + if (batchUrl) { + document.getElementById('copy-batch-btn').onclick = () => { + document.getElementById('batch-url').select(); + navigator.clipboard.writeText(batchUrl).then(() => { + document.getElementById('copy-batch-btn').textContent = 'Copied!'; + setTimeout(() => { document.getElementById('copy-batch-btn').textContent = 'Copy'; }, 2000); + }); + }; + } copyAllBtn.onclick = () => { const urls = results.map(d => d.url).join('\n'); @@ -280,4 +305,12 @@ function escapeAttr(s) { return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } + + function nanoid(size) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-'; + const bytes = crypto.getRandomValues(new Uint8Array(size)); + let id = ''; + for (let i = 0; i < size; i++) id += alphabet[bytes[i] & 63]; + return id; + } })();