# yDropbox Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a self-hosted single-user file drop box (yDropbox) where web uploads go to `workspace/inbox/`, local programs write to `workspace/outbox/`, and users can download via browser or share links. **Architecture:** Single Go binary with embedded Alpine.js frontend, SQLite metadata (pure Go driver), HTTP API using standard library `net/http`. Background scanner reconciles filesystem directories with database every 10s. **Tech Stack:** Go 1.22+, SQLite via `modernc.org/sqlite` (no CGO), Alpine.js (vendor), standard library only for HTTP. **Spec:** `docs/superpowers/specs/2026-08-26-ydropbox-design.md` ## Global Constraints - Go 1.22+ with `net/http` ServeMux method+path patterns - SQLite via `modernc.org/sqlite` (pure Go, no CGO) - Single binary deployment: `CGO_ENABLED=0 go build -o yDropbox ./cmd/ydropbox` - Frontend: Alpine.js vendor file embedded via `go:embed`, no build chain, no CDN - Upload limit: 100MB via `http.MaxBytesReader` - Session: in-memory map, 7-day sliding expiry, HttpOnly + SameSite=Lax + Secure cookies - Share tokens: 24 bytes `crypto/rand` → base64url (32 chars) - Password hashing: bcrypt cost 10 - Brute-force protection: 5 failures → 60s lockout per IP/token - Listen default: `127.0.0.1:8999` (localhost only) --- ## File Structure ``` yoresee_dropbox/ ├── cmd/ydropbox/main.go # Entry: flag/env parsing → app.Run(cfg) ├── internal/ │ ├── app/app.go # Assembly: open DB, build routes, start scanner, listen │ ├── server/ │ │ ├── server.go # New(): route registration │ │ ├── auth.go # Login/session/lockout │ │ ├── files.go # Upload/list/download/delete │ │ ├── share.go # Share create/revoke/public access │ │ └── middleware.go # Auth middleware, JSON response helpers │ ├── store/ │ │ ├── store.go # SQLite open, migrations │ │ ├── files.go # File CRUD │ │ └── shares.go # Share CRUD │ └── scanner/scanner.go # Directory reconciliation ├── web/ │ ├── web.go # //go:embed exports FS │ ├── index.html # Main page (Alpine.js) │ ├── login.html # Login page │ ├── style.css │ ├── app.js │ └── alpine.min.js # Vendor (committed) └── workspace/ # Runtime: inbox/, outbox/, .ydropbox/db.sqlite ``` --- ### Task 1: Project Scaffolding **Files:** - Create: `go.mod` - Create: `cmd/ydropbox/main.go` - Create: `internal/app/app.go` **Interfaces:** - Produces: Compilable Go module with stub main and app packages - [ ] **Step 1: Initialize Go module** ```bash go mod init yoresee_dropbox ``` - [ ] **Step 2: Add dependencies** ```bash go get modernc.org/sqlite@latest go get golang.org/x/crypto/bcrypt ``` - [ ] **Step 3: Create stub main.go** ```go // cmd/ydropbox/main.go package main import "log" func main() { log.Println("yDropbox starting...") } ``` - [ ] **Step 4: Verify compilation** ```bash go build ./cmd/ydropbox ``` Expected: Binary compiles successfully - [ ] **Step 5: Commit** ```bash git add go.mod go.sum cmd/ydropbox/main.go git commit -m "chore: project scaffolding" ``` --- ### Task 2: Store Layer — Schema & File CRUD **Files:** - Create: `internal/store/store.go` - Create: `internal/store/files.go` - Create: `internal/store/store_test.go` - Create: `internal/store/files_test.go` **Interfaces:** - Consumes: SQLite database path - Produces: `store.Store` with `FileCreate`, `FileList`, `FileGet`, `FileDelete` methods - [ ] **Step 1: Write failing test for store.Open** ```go // internal/store/store_test.go package store import ( "os" "path/filepath" "testing" ) func TestOpen(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") s, err := Open(dbPath) if err != nil { t.Fatalf("Open failed: %v", err) } defer s.Close() if _, err := os.Stat(dbPath); os.IsNotExist(err) { t.Error("Database file not created") } } ``` - [ ] **Step 2: Run test to verify it fails** ```bash go test ./internal/store -run TestOpen -v ``` Expected: FAIL — `Open` not defined - [ ] **Step 3: Implement store.Open with migrations** ```go // internal/store/store.go package store import ( "database/sql" _ "modernc.org/sqlite" ) type Store struct { db *sql.DB } func Open(dbPath string) (*Store, error) { db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, err } if err := migrate(db); err != nil { db.Close() return nil, err } return &Store{db: db}, nil } func (s *Store) Close() error { return s.db.Close() } func migrate(db *sql.DB) error { schema := ` CREATE TABLE IF NOT EXISTS files ( id TEXT PRIMARY KEY, original_name TEXT NOT NULL, storage_name TEXT NOT NULL UNIQUE, dir TEXT NOT NULL CHECK (dir IN ('inbox','outbox')), size INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS shares ( id TEXT PRIMARY KEY, file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, password_hash TEXT, expires_at INTEGER, created_at INTEGER NOT NULL, last_accessed_at INTEGER ); ` _, err := db.Exec(schema) return err } ``` - [ ] **Step 4: Run test to verify it passes** ```bash go test ./internal/store -run TestOpen -v ``` Expected: PASS - [ ] **Step 5: Write failing test for FileCreate** ```go // internal/store/files_test.go package store import ( "path/filepath" "testing" ) func TestFileCreate(t *testing.T) { dir := t.TempDir() s, err := Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } defer s.Close() f := &File{ ID: "test-id", OriginalName: "test.pdf", StorageName: "storage-uuid", Dir: "inbox", Size: 1024, CreatedAt: 1750000000, } if err := s.FileCreate(f); err != nil { t.Fatalf("FileCreate failed: %v", err) } got, err := s.FileGet("test-id") if err != nil { t.Fatalf("FileGet failed: %v", err) } if got.OriginalName != "test.pdf" { t.Errorf("OriginalName = %q, want %q", got.OriginalName, "test.pdf") } } ``` - [ ] **Step 6: Run test to verify it fails** ```bash go test ./internal/store -run TestFileCreate -v ``` Expected: FAIL — `File`, `FileCreate`, `FileGet` not defined - [ ] **Step 7: Implement File type and CRUD** ```go // internal/store/files.go package store import "database/sql" type File struct { ID string OriginalName string StorageName string Dir string Size int64 CreatedAt int64 } func (s *Store) FileCreate(f *File) error { _, err := s.db.Exec( `INSERT INTO files (id, original_name, storage_name, dir, size, created_at) VALUES (?, ?, ?, ?, ?, ?)`, f.ID, f.OriginalName, f.StorageName, f.Dir, f.Size, f.CreatedAt, ) return err } func (s *Store) FileGet(id string) (*File, error) { row := s.db.QueryRow( `SELECT id, original_name, storage_name, dir, size, created_at FROM files WHERE id = ?`, id, ) return scanFile(row) } func (s *Store) FileList(dir string) ([]*File, error) { query := `SELECT id, original_name, storage_name, dir, size, created_at FROM files` var args []any if dir != "" { query += ` WHERE dir = ?` args = []any{dir} } query += ` ORDER BY created_at DESC` rows, err := s.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var files []*File for rows.Next() { f, err := scanFile(rows) if err != nil { return nil, err } files = append(files, f) } return files, rows.Err() } func (s *Store) FileDelete(id string) error { _, err := s.db.Exec(`DELETE FROM files WHERE id = ?`, id) return err } func scanFile(row interface { Scan(dest ...any) error }) (*File, error) { f := &File{} err := row.Scan(&f.ID, &f.OriginalName, &f.StorageName, &f.Dir, &f.Size, &f.CreatedAt) if err == sql.ErrNoRows { return nil, err } return f, err } ``` - [ ] **Step 8: Run test to verify it passes** ```bash go test ./internal/store -run TestFileCreate -v ``` Expected: PASS - [ ] **Step 9: Commit** ```bash git add internal/store/ git commit -m "feat: store layer with file CRUD" ``` --- ### Task 3: Store Layer — Shares **Files:** - Create: `internal/store/shares.go` - Create: `internal/store/shares_test.go` **Interfaces:** - Consumes: `store.Store` - Produces: `ShareCreate`, `ShareGetByToken`, `ShareList`, `ShareDelete` methods - [ ] **Step 1: Write failing test for ShareCreate** ```go // internal/store/shares_test.go package store import ( "path/filepath" "testing" ) func TestShareCreate(t *testing.T) { dir := t.TempDir() s, err := Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } defer s.Close() // Create a file first f := &File{ ID: "file-1", OriginalName: "doc.pdf", StorageName: "uuid-1", Dir: "inbox", Size: 2048, CreatedAt: 1750000000, } if err := s.FileCreate(f); err != nil { t.Fatal(err) } share := &Share{ ID: "share-1", FileID: "file-1", Token: "token-abc", CreatedAt: 1750000000, } if err := s.ShareCreate(share); err != nil { t.Fatalf("ShareCreate failed: %v", err) } got, err := s.ShareGetByToken("token-abc") if err != nil { t.Fatalf("ShareGetByToken failed: %v", err) } if got.FileID != "file-1" { t.Errorf("FileID = %q, want %q", got.FileID, "file-1") } } ``` - [ ] **Step 2: Run test to verify it fails** ```bash go test ./internal/store -run TestShareCreate -v ``` Expected: FAIL — `Share`, `ShareCreate`, `ShareGetByToken` not defined - [ ] **Step 3: Implement Share type and CRUD** ```go // internal/store/shares.go package store type Share struct { ID string FileID string Token string PasswordHash string ExpiresAt int64 CreatedAt int64 LastAccessedAt int64 } func (s *Store) ShareCreate(sh *Share) error { _, err := s.db.Exec( `INSERT INTO shares (id, file_id, token, password_hash, expires_at, created_at, last_accessed_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, sh.ID, sh.FileID, sh.Token, sh.PasswordHash, sh.ExpiresAt, sh.CreatedAt, sh.LastAccessedAt, ) return err } func (s *Store) ShareGetByToken(token string) (*Share, error) { row := s.db.QueryRow( `SELECT id, file_id, token, password_hash, expires_at, created_at, last_accessed_at FROM shares WHERE token = ?`, token, ) return scanShare(row) } func (s *Store) ShareList() ([]*Share, error) { rows, err := s.db.Query( `SELECT id, file_id, token, password_hash, expires_at, created_at, last_accessed_at FROM shares ORDER BY created_at DESC`, ) if err != nil { return nil, err } defer rows.Close() var shares []*Share for rows.Next() { sh, err := scanShare(rows) if err != nil { return nil, err } shares = append(shares, sh) } return shares, rows.Err() } func (s *Store) ShareDelete(id string) error { _, err := s.db.Exec(`DELETE FROM shares WHERE id = ?`, id) return err } func scanShare(row interface { Scan(dest ...any) error }) (*Share, error) { sh := &Share{} err := row.Scan(&sh.ID, &sh.FileID, &sh.Token, &sh.PasswordHash, &sh.ExpiresAt, &sh.CreatedAt, &sh.LastAccessedAt) if err == sql.ErrNoRows { return nil, err } return sh, err } ``` - [ ] **Step 4: Run test to verify it passes** ```bash go test ./internal/store -run TestShareCreate -v ``` Expected: PASS - [ ] **Step 5: Write test for cascade delete** ```go func TestFileDeleteCascadesShares(t *testing.T) { dir := t.TempDir() s, err := Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatal(err) } defer s.Close() f := &File{ID: "file-2", OriginalName: "x.pdf", StorageName: "uuid-2", Dir: "inbox", Size: 100, CreatedAt: 1750000000} s.FileCreate(f) sh := &Share{ID: "share-2", FileID: "file-2", Token: "tok-2", CreatedAt: 1750000000} s.ShareCreate(sh) if err := s.FileDelete("file-2"); err != nil { t.Fatal(err) } _, err = s.ShareGetByToken("tok-2") if err == nil { t.Error("Share should be deleted after file deletion") } } ``` - [ ] **Step 6: Run test to verify cascade works** ```bash go test ./internal/store -run TestFileDeleteCascadesShares -v ``` Expected: PASS (SQLite ON DELETE CASCADE handles this) - [ ] **Step 7: Commit** ```bash git add internal/store/shares.go internal/store/shares_test.go git commit -m "feat: share CRUD with cascade delete" ``` --- ### Task 4: Scanner — Directory Reconciliation **Files:** - Create: `internal/scanner/scanner.go` - Create: `internal/scanner/scanner_test.go` **Interfaces:** - Consumes: `store.Store`, workspace path - Produces: `Scanner` with `Start()` method running background goroutine - [ ] **Step 1: Write failing test for scanner reconciliation** ```go // internal/scanner/scanner_test.go package scanner import ( "os" "path/filepath" "testing" "time" "yoresee_dropbox/internal/store" ) func TestScannerReconciles(t *testing.T) { dir := t.TempDir() inbox := filepath.Join(dir, "inbox") outbox := filepath.Join(dir, "outbox") os.MkdirAll(inbox, 0755) os.MkdirAll(outbox, 0755) dbPath := filepath.Join(dir, "test.db") s, err := store.Open(dbPath) if err != nil { t.Fatal(err) } defer s.Close() // Create a file on disk testFile := filepath.Join(inbox, "test.txt") os.WriteFile(testFile, []byte("hello"), 0644) sc := New(s, dir, 100*time.Millisecond) sc.Start() time.Sleep(300 * time.Millisecond) sc.Stop() files, err := s.FileList("inbox") if err != nil { t.Fatal(err) } if len(files) != 1 { t.Errorf("Expected 1 file, got %d", len(files)) } if files[0].OriginalName != "test.txt" { t.Errorf("OriginalName = %q, want %q", files[0].OriginalName, "test.txt") } } ``` - [ ] **Step 2: Run test to verify it fails** ```bash go test ./internal/scanner -run TestScannerReconciles -v ``` Expected: FAIL — `New`, `Start`, `Stop` not defined - [ ] **Step 3: Implement scanner** ```go // internal/scanner/scanner.go package scanner import ( "crypto/rand" "encoding/hex" "os" "path/filepath" "time" "yoresee_dropbox/internal/store" ) type Scanner struct { store *store.Store workspace string interval time.Duration stopCh chan struct{} } func New(s *store.Store, workspace string, interval time.Duration) *Scanner { return &Scanner{ store: s, workspace: workspace, interval: interval, stopCh: make(chan struct{}), } } func (sc *Scanner) Start() { go sc.run() } func (sc *Scanner) Stop() { close(sc.stopCh) } func (sc *Scanner) run() { ticker := time.NewTicker(sc.interval) defer ticker.Stop() sc.scan() for { select { case <-sc.stopCh: return case <-ticker.C: sc.scan() } } } func (sc *Scanner) scan() { sc.scanDir("inbox") sc.scanDir("outbox") } func (sc *Scanner) scanDir(dir string) { dirPath := filepath.Join(sc.workspace, dir) entries, err := os.ReadDir(dirPath) if err != nil { return } dbFiles, err := sc.store.FileList(dir) if err != nil { return } dbMap := make(map[string]*store.File) for _, f := range dbFiles { dbMap[f.StorageName] = f } diskMap := make(map[string]bool) for _, entry := range entries { if entry.IsDir() { continue } info, err := entry.Info() if err != nil { continue } diskMap[entry.Name()] = true if _, exists := dbMap[entry.Name()]; !exists { id := generateUUID() f := &store.File{ ID: id, OriginalName: entry.Name(), StorageName: entry.Name(), Dir: dir, Size: info.Size(), CreatedAt: time.Now().Unix(), } sc.store.FileCreate(f) } } for name, f := range dbMap { if !diskMap[name] { sc.store.FileDelete(f.ID) } } } func generateUUID() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } ``` - [ ] **Step 4: Run test to verify it passes** ```bash go test ./internal/scanner -run TestScannerReconciles -v ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add internal/scanner/ git commit -m "feat: scanner for directory reconciliation" ``` --- ### Task 5: Server — Auth & Session **Files:** - Create: `internal/server/auth.go` - Create: `internal/server/middleware.go` - Create: `internal/server/auth_test.go` **Interfaces:** - Consumes: access token, session store - Produces: login handler, session middleware, brute-force lockout - [ ] **Step 1: Write failing test for login** ```go // internal/server/auth_test.go package server import ( "bytes" "net/http" "net/http/httptest" "testing" ) func TestLoginSuccess(t *testing.T) { s := &Server{accessToken: "secret123", sessions: make(map[string]int64)} req := httptest.NewRequest("POST", "/api/login", bytes.NewBufferString(`{"token":"secret123"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.handleLogin(w, req) if w.Code != http.StatusOK { t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) } if len(w.Header().Values("Set-Cookie")) == 0 { t.Error("Expected Set-Cookie header") } } func TestLoginFailure(t *testing.T) { s := &Server{accessToken: "secret123", sessions: make(map[string]int64)} req := httptest.NewRequest("POST", "/api/login", bytes.NewBufferString(`{"token":"wrong"}`)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() s.handleLogin(w, req) if w.Code != http.StatusUnauthorized { t.Errorf("Status = %d, want %d", w.Code, http.StatusUnauthorized) } } ``` - [ ] **Step 2: Run test to verify it fails** ```bash go test ./internal/server -run TestLogin -v ``` Expected: FAIL — `Server`, `handleLogin` not defined - [ ] **Step 3: Implement auth handlers and session management** ```go // internal/server/auth.go package server import ( "crypto/rand" "encoding/hex" "encoding/json" "net/http" "sync" "time" ) type loginRequest struct { Token string `json:"token"` } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { ip := r.RemoteAddr s.mu.Lock() if lockTime, locked := s.loginLockout[ip]; locked && time.Now().Before(lockTime) { s.mu.Unlock() http.Error(w, "Too many attempts, locked for 60s", http.StatusForbidden) return } s.mu.Unlock() var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if req.Token != s.accessToken { s.mu.Lock() s.loginAttempts[ip]++ if s.loginAttempts[ip] >= 5 { s.loginLockout[ip] = time.Now().Add(60 * time.Second) s.loginAttempts[ip] = 0 } s.mu.Unlock() http.Error(w, "Invalid token", http.StatusUnauthorized) return } s.mu.Lock() delete(s.loginAttempts, ip) delete(s.loginLockout, ip) s.mu.Unlock() sessionID := generateSessionID() s.mu.Lock() s.sessions[sessionID] = time.Now().Unix() s.mu.Unlock() http.SetCookie(w, &http.Cookie{ Name: "ydropbox_session", Value: sessionID, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: true, MaxAge: 7 * 24 * 60 * 60, }) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{}) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("ydropbox_session") if err != nil { http.Error(w, "Not logged in", http.StatusUnauthorized) return } s.mu.Lock() delete(s.sessions, cookie.Value) s.mu.Unlock() http.SetCookie(w, &http.Cookie{ Name: "ydropbox_session", Value: "", Path: "/", MaxAge: -1, }) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{}) } func generateSessionID() string { b := make([]byte, 32) rand.Read(b) return hex.EncodeToString(b) } ``` ```go // internal/server/middleware.go package server import ( "net/http" ) func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("ydropbox_session") if err != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } s.mu.Lock() _, exists := s.sessions[cookie.Value] s.mu.Unlock() if !exists { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } next(w, r) } } func (s *Server) requireAuthPage(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("ydropbox_session") if err != nil { http.Redirect(w, r, "/login", http.StatusFound) return } s.mu.Lock() _, exists := s.sessions[cookie.Value] s.mu.Unlock() if !exists { http.Redirect(w, r, "/login", http.StatusFound) return } next(w, r) } } ``` - [ ] **Step 4: Define Server struct** ```go // internal/server/server.go package server import ( "crypto/rand" "encoding/hex" "sync" "time" "yoresee_dropbox/internal/store" ) type Server struct { store *store.Store accessToken string sessions map[string]int64 shareSessions map[string]string loginAttempts map[string]int loginLockout map[string]time.Time mu sync.Mutex workspace string } func New(store *store.Store, accessToken string, workspace string) *Server { return &Server{ store: store, accessToken: accessToken, sessions: make(map[string]int64), shareSessions: make(map[string]string), loginAttempts: make(map[string]int), loginLockout: make(map[string]time.Time), workspace: workspace, } } func generateUUID() string { b := make([]byte, 16) rand.Read(b) return hex.EncodeToString(b) } ``` - [ ] **Step 5: Run test to verify it passes** ```bash go test ./internal/server -run TestLogin -v ``` Expected: PASS - [ ] **Step 6: Commit** ```bash git add internal/server/ git commit -m "feat: auth handlers and session management" ``` --- ### Task 6: Server — Files Handlers **Files:** - Modify: `internal/server/files.go` - Create: `internal/server/files_test.go` **Interfaces:** - Consumes: `store.Store`, workspace path - Produces: upload, list, download, delete handlers - [ ] **Step 1: Write failing test for upload** ```go // internal/server/files_test.go package server import ( "bytes" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "yoresee_dropbox/internal/store" ) func TestUpload(t *testing.T) { dir := t.TempDir() workspace := filepath.Join(dir, "workspace") os.MkdirAll(filepath.Join(workspace, "inbox"), 0755) s, _ := store.Open(filepath.Join(dir, "test.db")) defer s.Close() srv := &Server{store: s, accessToken: "token", sessions: make(map[string]int64), workspace: workspace} body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, _ := writer.CreateFormFile("file", "test.txt") part.Write([]byte("hello")) writer.Close() req := httptest.NewRequest("POST", "/api/upload", body) req.Header.Set("Content-Type", writer.FormDataContentType()) req.AddCookie(&http.Cookie{Name: "ydropbox_session", Value: "valid"}) srv.sessions["valid"] = 1 w := httptest.NewRecorder() srv.handleUpload(w, req) if w.Code != http.StatusCreated { t.Errorf("Status = %d, want %d", w.Code, http.StatusCreated) } } ``` - [ ] **Step 2: Run test to verify it fails** ```bash go test ./internal/server -run TestUpload -v ``` Expected: FAIL — `handleUpload` not defined, `workspace` field missing - [ ] **Step 3: Verify Server struct has workspace field** The `workspace` field was added to `Server` in Task 5. Verify `internal/server/server.go` has: ```go type Server struct { store *store.Store accessToken string sessions map[string]int64 loginAttempts map[string]int loginLockout map[string]time.Time mu sync.Mutex workspace string } ``` - [ ] **Step 4: Implement upload handler** ```go // internal/server/files.go package server import ( "encoding/json" "io" "net/http" "os" "path/filepath" "time" "yoresee_dropbox/internal/store" ) func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 100<<20) if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, "File too large", http.StatusRequestEntityTooLarge) return } file, header, err := r.FormFile("file") if err != nil { http.Error(w, "Missing file", http.StatusBadRequest) return } defer file.Close() storageName := generateUUID() destPath := filepath.Join(s.workspace, "inbox", storageName) dest, err := os.Create(destPath) if err != nil { http.Error(w, "Failed to save", http.StatusInternalServerError) return } defer dest.Close() if _, err := io.Copy(dest, file); err != nil { http.Error(w, "Failed to save", http.StatusInternalServerError) return } f := &store.File{ ID: generateUUID(), OriginalName: header.Filename, StorageName: storageName, Dir: "inbox", Size: header.Size, CreatedAt: time.Now().Unix(), } if err := s.store.FileCreate(f); err != nil { http.Error(w, "Failed to save metadata", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{"file": f}) } func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) { dir := r.URL.Query().Get("dir") files, err := s.store.FileList(dir) if err != nil { http.Error(w, "Failed to list", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]any{"files": files}) } func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") f, err := s.store.FileGet(id) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } path := filepath.Join(s.workspace, f.Dir, f.StorageName) w.Header().Set("Content-Disposition", "attachment; filename="+f.OriginalName) http.ServeFile(w, r, path) } func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.store.FileDelete(id); err != nil { http.Error(w, "Failed to delete", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } ``` - [ ] **Step 5: Run test to verify it passes** ```bash go test ./internal/server -run TestUpload -v ``` Expected: PASS - [ ] **Step 6: Commit** ```bash git add internal/server/files.go internal/server/files_test.go internal/server/server.go git commit -m "feat: file handlers (upload/list/download/delete)" ``` --- ### Task 7: Server — Share Handlers **Files:** - Create: `internal/server/share.go` - Create: `internal/server/share_test.go` **Interfaces:** - Consumes: `store.Store` - Produces: share create, list, delete, public access handlers - [ ] **Step 1: Write failing test for share creation** ```go // internal/server/share_test.go package server import ( "bytes" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "yoresee_dropbox/internal/store" ) func TestCreateShare(t *testing.T) { dir := t.TempDir() workspace := filepath.Join(dir, "workspace") os.MkdirAll(filepath.Join(workspace, "inbox"), 0755) s, _ := store.Open(filepath.Join(dir, "test.db")) defer s.Close() f := &store.File{ID: "file-1", OriginalName: "doc.pdf", StorageName: "uuid-1", Dir: "inbox", Size: 100, CreatedAt: 1750000000} s.FileCreate(f) srv := &Server{store: s, workspace: workspace, sessions: make(map[string]int64)} body := bytes.NewBufferString(`{"file_id":"file-1"}`) req := httptest.NewRequest("POST", "/api/files/file-1/share", body) req.Header.Set("Content-Type", "application/json") req.AddCookie(&http.Cookie{Name: "ydropbox_session", Value: "valid"}) srv.sessions["valid"] = 1 w := httptest.NewRecorder() srv.handleCreateShare(w, req) if w.Code != http.StatusCreated { t.Errorf("Status = %d, want %d", w.Code, http.StatusCreated) } } ``` - [ ] **Step 2: Run test to verify it fails** ```bash go test ./internal/server -run TestCreateShare -v ``` Expected: FAIL — `handleCreateShare` not defined - [ ] **Step 3: Implement share handlers** ```go // internal/server/share.go package server import ( "crypto/rand" "encoding/base64" "encoding/json" "net/http" "path/filepath" "time" "golang.org/x/crypto/bcrypt" "yoresee_dropbox/internal/store" ) type createShareRequest struct { Password string `json:"password"` ExpiresInHours int `json:"expires_in_hours"` } func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) { fileID := r.PathValue("id") var req createShareRequest json.NewDecoder(r.Body).Decode(&req) token := generateShareToken() share := &store.Share{ ID: generateUUID(), FileID: fileID, Token: token, CreatedAt: time.Now().Unix(), } if req.Password != "" { hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10) if err != nil { http.Error(w, "Failed to hash password", http.StatusInternalServerError) return } share.PasswordHash = string(hash) } if req.ExpiresInHours > 0 { share.ExpiresAt = time.Now().Add(time.Duration(req.ExpiresInHours) * time.Hour).Unix() } if err := s.store.ShareCreate(share); err != nil { http.Error(w, "Failed to create share", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{ "url": "/s/" + token, "token": token, }) } func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) { shares, err := s.store.ShareList() if err != nil { http.Error(w, "Failed to list", http.StatusInternalServerError) return } json.NewEncoder(w).Encode(map[string]any{"shares": shares}) } func (s *Server) handleDeleteShare(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if err := s.store.ShareDelete(id); err != nil { http.Error(w, "Failed to delete", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func generateShareToken() string { b := make([]byte, 24) rand.Read(b) return base64.URLEncoding.EncodeToString(b) } ``` - [ ] **Step 4: Run test to verify it passes** ```bash go test ./internal/server -run TestCreateShare -v ``` Expected: PASS - [ ] **Step 5: Write test for public share access** ```go func TestShareAccessNoPassword(t *testing.T) { dir := t.TempDir() workspace := filepath.Join(dir, "workspace") os.MkdirAll(filepath.Join(workspace, "inbox"), 0755) os.WriteFile(filepath.Join(workspace, "inbox", "uuid-1"), []byte("content"), 0644) s, _ := store.Open(filepath.Join(dir, "test.db")) defer s.Close() f := &store.File{ID: "file-1", OriginalName: "doc.pdf", StorageName: "uuid-1", Dir: "inbox", Size: 7, CreatedAt: 1750000000} s.FileCreate(f) sh := &store.Share{ID: "share-1", FileID: "file-1", Token: "tok-abc", CreatedAt: 1750000000} s.ShareCreate(sh) srv := &Server{store: s, workspace: workspace, sessions: make(map[string]int64)} req := httptest.NewRequest("GET", "/s/tok-abc", nil) w := httptest.NewRecorder() srv.handleShareAccess(w, req) if w.Code != http.StatusOK { t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) } } func TestShareAccessExpired(t *testing.T) { dir := t.TempDir() workspace := filepath.Join(dir, "workspace") os.MkdirAll(filepath.Join(workspace, "inbox"), 0755) s, _ := store.Open(filepath.Join(dir, "test.db")) defer s.Close() f := &store.File{ID: "file-1", OriginalName: "doc.pdf", StorageName: "uuid-1", Dir: "inbox", Size: 7, CreatedAt: 1750000000} s.FileCreate(f) sh := &store.Share{ID: "share-1", FileID: "file-1", Token: "tok-exp", ExpiresAt: 1, CreatedAt: 1750000000} s.ShareCreate(sh) srv := &Server{store: s, workspace: workspace, sessions: make(map[string]int64)} req := httptest.NewRequest("GET", "/s/tok-exp", nil) w := httptest.NewRecorder() srv.handleShareAccess(w, req) if w.Code != http.StatusGone { t.Errorf("Status = %d, want %d", w.Code, http.StatusGone) } } ``` - [ ] **Step 6: Run tests to verify they fail** ```bash go test ./internal/server -run TestShareAccess -v ``` Expected: FAIL — `handleShareAccess` not defined - [ ] **Step 7: Implement public share access handlers** Add these functions to `internal/server/share.go`: ```go func (s *Server) handleShareAccess(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") share, err := s.store.ShareGetByToken(token) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } if share.ExpiresAt > 0 && time.Now().Unix() > share.ExpiresAt { s.store.ShareDelete(share.ID) http.Error(w, "Share expired", http.StatusGone) return } if share.PasswordHash != "" { cookie, err := r.Cookie("ydropbox_share_" + share.ID) if err != nil || !s.verifyShareCookie(share.ID, cookie.Value) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(`
`)) return } } s.serveSharedFile(w, r, share) } func (s *Server) handleSharePassword(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") share, err := s.store.ShareGetByToken(token) if err != nil { http.Error(w, "Not found", http.StatusNotFound) return } password := r.FormValue("password") if err := bcrypt.CompareHashAndPassword([]byte(share.PasswordHash), []byte(password)); err != nil { http.Error(w, "Wrong password", http.StatusForbidden) return } cookieVal := generateSessionID() s.mu.Lock() if s.shareSessions == nil { s.shareSessions = make(map[string]string) } s.shareSessions[cookieVal] = share.ID s.mu.Unlock() http.SetCookie(w, &http.Cookie{ Name: "ydropbox_share_" + share.ID, Value: cookieVal, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 3600, }) http.Redirect(w, r, "/s/"+token, http.StatusFound) } func (s *Server) serveSharedFile(w http.ResponseWriter, r *http.Request, share *store.Share) { f, err := s.store.FileGet(share.FileID) if err != nil { http.Error(w, "File not found", http.StatusNotFound) return } path := filepath.Join(s.workspace, f.Dir, f.StorageName) w.Header().Set("Content-Disposition", "attachment; filename="+f.OriginalName) w.Header().Set("X-Content-Type-Options", "nosniff") http.ServeFile(w, r, path) } func (s *Server) verifyShareCookie(shareID, cookieVal string) bool { s.mu.Lock() defer s.mu.Unlock() return s.shareSessions[cookieVal] == shareID } ``` Add `shareSessions map[string]string` to Server struct and `golang.org/x/crypto/bcrypt` import. - [ ] **Step 8: Run tests to verify they pass** ```bash go test ./internal/server -run TestShareAccess -v ``` Expected: PASS - [ ] **Step 9: Commit** ```bash git add internal/server/share.go internal/server/share_test.go internal/server/server.go git commit -m "feat: public share access with password and expiry" ``` --- ### Task 8: Route Registration **Files:** - Modify: `internal/server/server.go` **Interfaces:** - Consumes: all handlers - Produces: `http.Handler` with all routes registered - [ ] **Step 1: Add route registration to server.go** ```go // internal/server/server.go import ( "net/http" "yoresee_dropbox/web" ) func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /api/login", s.handleLogin) mux.HandleFunc("POST /api/logout", s.requireAuth(s.handleLogout)) mux.HandleFunc("POST /api/upload", s.requireAuth(s.handleUpload)) mux.HandleFunc("GET /api/files", s.requireAuth(s.handleListFiles)) mux.HandleFunc("GET /api/files/{id}/download", s.requireAuth(s.handleDownload)) mux.HandleFunc("DELETE /api/files/{id}", s.requireAuth(s.handleDeleteFile)) mux.HandleFunc("POST /api/files/{id}/share", s.requireAuth(s.handleCreateShare)) mux.HandleFunc("GET /api/shares", s.requireAuth(s.handleListShares)) mux.HandleFunc("DELETE /api/shares/{id}", s.requireAuth(s.handleDeleteShare)) mux.HandleFunc("GET /s/{token}", s.handleShareAccess) mux.HandleFunc("POST /s/{token}", s.handleSharePassword) mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { data, _ := web.FS.ReadFile("login.html") w.Write(data) }) mux.HandleFunc("GET /{$}", s.requireAuthPage(func(w http.ResponseWriter, r *http.Request) { data, _ := web.FS.ReadFile("index.html") w.Write(data) })) mux.Handle("GET /style.css", http.FileServerFS(web.FS)) mux.Handle("GET /app.js", http.FileServerFS(web.FS)) mux.Handle("GET /alpine.min.js", http.FileServerFS(web.FS)) return mux } ``` - [ ] **Step 2: Verify compilation** ```bash go build ./... ``` Expected: Success - [ ] **Step 3: Commit** ```bash git add internal/server/server.go git commit -m "feat: route registration" ``` --- ### Task 9: Web Frontend **Files:** - Create: `web/web.go` - Create: `web/index.html` - Create: `web/login.html` - Create: `web/style.css` - Create: `web/app.js` - Create: `web/alpine.min.js` (download from CDN) **Interfaces:** - Consumes: Alpine.js - Produces: Embedded FS for serving - [ ] **Step 1: Download Alpine.js** ```bash curl -o web/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js ``` - [ ] **Step 2: Create web.go with embed** ```go // web/web.go package web import "embed" //go:embed * var FS embed.FS ``` - [ ] **Step 3: Create login.html** ```html