feat: Batch uploads with shared /b/{id} link to view all files

Multi-file uploads now generate a batch ID, grouping files together.
A single batch URL (e.g. /b/Ab3xKz9q) shows all files with previews
and download links. Schema migrates automatically for existing DBs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-07 15:41:43 -04:00
parent 86e0df4250
commit d8ff9154a1
11 changed files with 302 additions and 16 deletions

View File

@ -32,5 +32,9 @@ func Open(dbPath string) (*sql.DB, error) {
return nil, fmt.Errorf("init schema: %w", err) 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 return db, nil
} }

View File

@ -8,8 +8,10 @@ CREATE TABLE IF NOT EXISTS files (
expires_at DATETIME, expires_at DATETIME,
password_hash TEXT, password_hash TEXT,
delete_token TEXT NOT NULL UNIQUE, 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_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_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;

View File

@ -15,10 +15,10 @@ import (
var passwordTmpl *template.Template var passwordTmpl *template.Template
func InitTemplates(webFS embed.FS) { func InitTemplates(webFS embed.FS) {
funcs := template.FuncMap{"formatSize": formatSizeHTML}
passwordTmpl = template.Must(template.ParseFS(webFS, "web/password.html")) passwordTmpl = template.Must(template.ParseFS(webFS, "web/password.html"))
downloadTmpl = template.Must(template.New("download.html").Funcs(template.FuncMap{ downloadTmpl = template.Must(template.New("download.html").Funcs(funcs).ParseFS(webFS, "web/download.html"))
"formatSize": formatSizeHTML, batchTmpl = template.Must(template.New("batch.html").Funcs(funcs).ParseFS(webFS, "web/batch.html"))
}).ParseFS(webFS, "web/download.html"))
} }
func formatSizeHTML(bytes int64) string { func formatSizeHTML(bytes int64) string {

97
internal/handler/batch.go Normal file
View File

@ -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)
}

View File

@ -47,6 +47,7 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
expiresIn string expiresIn string
password string password string
customSlug string customSlug string
batchID string
fileSize int64 fileSize int64
fileUploaded bool fileUploaded bool
fileID string fileID string
@ -141,6 +142,11 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 128) buf := make([]byte, 128)
n, _ := part.Read(buf) n, _ := part.Read(buf)
customSlug = strings.TrimSpace(string(buf[:n])) 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() part.Close()
} }
@ -185,6 +191,9 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
ContentType: contentType, ContentType: contentType,
DeleteToken: deleteTokenHex, DeleteToken: deleteTokenHex,
} }
if batchID != "" {
rec.BatchID = &batchID
}
// Handle expiry // Handle expiry
if expiresIn != "" { if expiresIn != "" {
@ -228,6 +237,10 @@ func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
if rec.ExpiresAt != nil { if rec.ExpiresAt != nil {
resp["expires_at"] = rec.ExpiresAt.Format(time.RFC3339) 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)

View File

@ -16,6 +16,7 @@ type FileRecord struct {
PasswordHash *string PasswordHash *string
DeleteToken string DeleteToken string
DownloadCount int64 DownloadCount int64
BatchID *string
} }
type Store struct { type Store struct {
@ -28,9 +29,9 @@ func New(db *sql.DB) *Store {
func (s *Store) Create(f *FileRecord) error { func (s *Store) Create(f *FileRecord) error {
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO files (id, filename, r2_key, size_bytes, content_type, expires_at, password_hash, delete_token) `INSERT INTO files (id, filename, r2_key, size_bytes, content_type, expires_at, password_hash, delete_token, batch_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.Filename, f.R2Key, f.SizeBytes, f.ContentType, f.ExpiresAt, f.PasswordHash, f.DeleteToken, f.ID, f.Filename, f.R2Key, f.SizeBytes, f.ContentType, f.ExpiresAt, f.PasswordHash, f.DeleteToken, f.BatchID,
) )
return err return err
} }
@ -38,9 +39,9 @@ func (s *Store) Create(f *FileRecord) error {
func (s *Store) Get(id string) (*FileRecord, error) { func (s *Store) Get(id string) (*FileRecord, error) {
f := &FileRecord{} f := &FileRecord{}
err := s.db.QueryRow( 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, 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 { if err != nil {
return nil, err return nil, err
} }
@ -60,9 +61,9 @@ func (s *Store) Delete(id string) error {
func (s *Store) DeleteByToken(token string) (*FileRecord, error) { func (s *Store) DeleteByToken(token string) (*FileRecord, error) {
f := &FileRecord{} f := &FileRecord{}
err := s.db.QueryRow( 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, 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 { if err != nil {
return nil, err return nil, err
} }
@ -75,7 +76,7 @@ func (s *Store) DeleteByToken(token string) (*FileRecord, error) {
func (s *Store) ListExpired() ([]*FileRecord, error) { func (s *Store) ListExpired() ([]*FileRecord, error) {
rows, err := s.db.Query( 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')`, FROM files WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')`,
) )
if err != nil { if err != nil {
@ -86,7 +87,28 @@ func (s *Store) ListExpired() ([]*FileRecord, error) {
var files []*FileRecord var files []*FileRecord
for rows.Next() { for rows.Next() {
f := &FileRecord{} 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 return nil, err
} }
files = append(files, f) files = append(files, f)

View File

@ -71,6 +71,7 @@ func main() {
mux.HandleFunc("GET /f/{id}/auth", h.AuthPage) mux.HandleFunc("GET /f/{id}/auth", h.AuthPage)
mux.HandleFunc("POST /f/{id}/auth", h.AuthSubmit) mux.HandleFunc("POST /f/{id}/auth", h.AuthSubmit)
mux.HandleFunc("DELETE /f/{id}", h.Delete) mux.HandleFunc("DELETE /f/{id}", h.Delete)
mux.HandleFunc("GET /b/{id}", h.Batch)
// Favicon (prevent 404) // Favicon (prevent 404)
mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, _ *http.Request) {

47
web/batch.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Count}} files — upload.jeffemmett.com</title>
<link rel="stylesheet" href="/static/style.css?v=3">
</head>
<body>
<div class="container">
<h1>{{.Count}} files</h1>
<p class="subtitle">{{formatSize .TotalSize}} total</p>
<div class="batch-list">
{{range .Files}}
<div class="batch-item">
<div class="batch-preview">
{{if eq .ThumbType "image"}}
<a href="{{.ViewURL}}"><img src="{{.PreviewURL}}" alt="{{.Filename}}" class="batch-thumb"></a>
{{else if eq .ThumbType "video"}}
<video src="{{.PreviewURL}}" class="batch-thumb" preload="metadata" muted playsinline></video>
{{else}}
<div class="batch-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/>
<polyline points="13 2 13 9 20 9"/>
</svg>
</div>
{{end}}
</div>
<div class="batch-info">
<a href="/f/{{.ID}}" class="batch-filename">{{.Filename}}</a>
<span class="batch-meta">{{formatSize .Size}}</span>
</div>
<div class="batch-actions">
<a href="{{.DownloadURL}}" class="btn btn-small btn-secondary" title="Download">&#8595;</a>
</div>
</div>
{{end}}
</div>
<footer>
<a href="/">upload.jeffemmett.com</a>
</footer>
</div>
</body>
</html>

View File

@ -83,6 +83,6 @@
<a href="/cli">CLI tool</a> <a href="/cli">CLI tool</a>
</footer> </footer>
</div> </div>
<script src="/static/upload.js?v=2"></script> <script src="/static/upload.js?v=3"></script>
</body> </body>
</html> </html>

View File

@ -365,6 +365,73 @@ footer a:hover { color: var(--accent); }
white-space: nowrap; 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 */ /* Download page */
.file-card { .file-card {
margin-top: 0; margin-top: 0;

View File

@ -136,6 +136,9 @@
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
const slug = document.getElementById('slug').value.trim(); 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++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
if (files.length > 1) { if (files.length > 1) {
@ -151,7 +154,8 @@
const data = await uploadOne(file, { const data = await uploadOne(file, {
expires, expires,
password, password,
slug: files.length === 1 ? slug : '' slug: files.length === 1 ? slug : '',
batchId
}); });
results.push(data); results.push(data);
} catch (err) { } catch (err) {
@ -179,6 +183,7 @@
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const formData = new FormData(); const formData = new FormData();
if (opts.slug) formData.append('slug', opts.slug); if (opts.slug) formData.append('slug', opts.slug);
if (opts.batchId) formData.append('batch_id', opts.batchId);
formData.append('file', file); formData.append('file', file);
if (opts.expires) formData.append('expires_in', opts.expires); if (opts.expires) formData.append('expires_in', opts.expires);
if (opts.password) formData.append('password', opts.password); if (opts.password) formData.append('password', opts.password);
@ -234,12 +239,32 @@
resultSingle.classList.add('hidden'); resultSingle.classList.add('hidden');
resultMulti.classList.remove('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 += '<div class="result-url" style="margin-bottom: 0.75rem;">' +
'<input type="text" id="batch-url" value="' + escapeAttr(batchUrl) + '" readonly>' +
'<button id="copy-batch-btn" class="btn btn-small">Copy</button>' +
'</div>';
}
html += results.map(data =>
'<div class="result-item">' + '<div class="result-item">' +
'<a href="' + escapeAttr(data.url) + '" target="_blank" class="result-item-name">' + escapeHtml(data.filename) + '</a>' + '<a href="' + escapeAttr(data.url) + '" target="_blank" class="result-item-name">' + escapeHtml(data.filename) + '</a>' +
'<span class="result-item-url">' + escapeHtml(data.url) + '</span>' + '<span class="result-item-url">' + escapeHtml(data.url) + '</span>' +
'</div>' '</div>'
).join(''); ).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 = () => { copyAllBtn.onclick = () => {
const urls = results.map(d => d.url).join('\n'); const urls = results.map(d => d.url).join('\n');
@ -280,4 +305,12 @@
function escapeAttr(s) { function escapeAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
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;
}
})(); })();