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 @@ + + +
+ + +{{formatSize .TotalSize}} total
+ +