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:
parent
86e0df4250
commit
d8ff9154a1
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
1
main.go
1
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) {
|
||||
|
|
|
|||
|
|
@ -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">↓</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="/">upload.jeffemmett.com</a>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -83,6 +83,6 @@
|
|||
<a href="/cli">CLI tool</a>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/static/upload.js?v=2"></script>
|
||||
<script src="/static/upload.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 += '<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">' +
|
||||
'<a href="' + escapeAttr(data.url) + '" target="_blank" class="result-item-name">' + escapeHtml(data.filename) + '</a>' +
|
||||
'<span class="result-item-url">' + escapeHtml(data.url) + '</span>' +
|
||||
'</div>'
|
||||
).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, '<').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;
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue