From 6c85b724d0b75b7ee7babb5bb5a6b8502c67f9d6 Mon Sep 17 00:00:00 2001 From: Fendy Date: Wed, 26 Aug 2026 21:34:44 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20yDropbox=20=E5=AE=9E=E7=8E=B0=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-08-26-ydropbox-implementation.md | 2090 +++++++++++++++++ 1 file changed, 2090 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-ydropbox-implementation.md diff --git a/docs/superpowers/plans/2026-08-26-ydropbox-implementation.md b/docs/superpowers/plans/2026-08-26-ydropbox-implementation.md new file mode 100644 index 0000000..d9a74be --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-ydropbox-implementation.md @@ -0,0 +1,2090 @@ +# 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 + + + + + + yDropbox Login + + + +
+

yDropbox

+
+ + +
+
+ + + +``` + +- [ ] **Step 4: Create index.html with Alpine.js** + +```html + + + + + + yDropbox + + + + +
+
+

yDropbox

+ +
+ +
+

Inbox

+ + +
+ +
+

Outbox

+ +
+
+ + + +``` + +- [ ] **Step 5: Create app.js** + +```javascript +// web/app.js +function app() { + return { + inboxFiles: [], + outboxFiles: [], + async loadFiles() { + const [inbox, outbox] = await Promise.all([ + fetch('/api/files?dir=inbox').then(r => r.json()), + fetch('/api/files?dir=outbox').then(r => r.json()) + ]); + this.inboxFiles = inbox.files || []; + this.outboxFiles = outbox.files || []; + }, + async upload(event, dir) { + const file = event.target.files[0]; + const form = new FormData(); + form.append('file', file); + await fetch('/api/upload', {method: 'POST', body: form}); + this.loadFiles(); + }, + download(id) { + window.location.href = `/api/files/${id}/download`; + }, + async deleteFile(id) { + await fetch(`/api/files/${id}`, {method: 'DELETE'}); + this.loadFiles(); + }, + async share(id) { + const res = await fetch(`/api/files/${id}/share`, {method: 'POST'}); + const data = await res.json(); + alert('Share URL: ' + data.url); + }, + async logout() { + await fetch('/api/logout', {method: 'POST'}); + window.location.href = '/login'; + } + }; +} +``` + +- [ ] **Step 6: Create style.css** + +```css +/* web/style.css */ +body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; } +header { display: flex; justify-content: space-between; align-items: center; } +section { margin: 20px 0; } +div[style] { display: flex; gap: 10px; align-items: center; margin: 10px 0; } +button { cursor: pointer; } +``` + +- [ ] **Step 7: Verify compilation** + +```bash +go build ./... +``` + +Expected: Success + +- [ ] **Step 8: Commit** + +```bash +git add web/ +git commit -m "feat: web frontend with Alpine.js" +``` + +--- + +### Task 10: Assembly & Main + +**Files:** +- Create: `internal/app/app.go` +- Modify: `cmd/ydropbox/main.go` + +**Interfaces:** +- Consumes: all packages +- Produces: Running HTTP server + +- [ ] **Step 1: Implement app.Run and app.NewHandler** + +```go +// internal/app/app.go +package app + +import ( + "log" + "net/http" + "path/filepath" + "time" + "yoresee_dropbox/internal/scanner" + "yoresee_dropbox/internal/server" + "yoresee_dropbox/internal/store" +) + +type Config struct { + Addr string + Workspace string + Token string +} + +func NewHandler(cfg Config) (http.Handler, *store.Store, error) { + dbPath := filepath.Join(cfg.Workspace, ".ydropbox", "db.sqlite") + s, err := store.Open(dbPath) + if err != nil { + return nil, nil, err + } + + sc := scanner.New(s, cfg.Workspace, 10*time.Second) + sc.Start() + + srv := server.New(s, cfg.Token, cfg.Workspace) + return srv.Handler(), s, nil +} + +func Run(cfg Config) error { + handler, s, err := NewHandler(cfg) + if err != nil { + return err + } + defer s.Close() + + log.Printf("Listening on %s", cfg.Addr) + return http.ListenAndServe(cfg.Addr, handler) +} +``` + +- [ ] **Step 2: Implement main.go** + +```go +// cmd/ydropbox/main.go +package main + +import ( + "flag" + "log" + "os" + "path/filepath" + "yoresee_dropbox/internal/app" +) + +func main() { + addr := flag.String("addr", "127.0.0.1:8999", "Listen address") + workspace := flag.String("workspace", "./workspace", "Workspace directory") + token := flag.String("token", "", "Access token") + flag.Parse() + + if *token == "" { + *token = os.Getenv("YDROPBOX_TOKEN") + } + if *token == "" { + log.Fatal("Token required: --token or YDROPBOX_TOKEN env") + } + + absWorkspace, _ := filepath.Abs(*workspace) + os.MkdirAll(filepath.Join(absWorkspace, "inbox"), 0755) + os.MkdirAll(filepath.Join(absWorkspace, "outbox"), 0755) + os.MkdirAll(filepath.Join(absWorkspace, ".ydropbox"), 0755) + + cfg := app.Config{ + Addr: *addr, + Workspace: absWorkspace, + Token: *token, + } + + if err := app.Run(cfg); err != nil { + log.Fatal(err) + } +} +``` + +- [ ] **Step 3: Build and test manually** + +```bash +go build -o yDropbox ./cmd/ydropbox +YDROPBOX_TOKEN=test ./yDropbox --workspace=/tmp/ydropbox-test +``` + +Open browser to http://127.0.0.1:8999/login, login with token "test", upload a file, verify it appears in inbox. + +- [ ] **Step 4: Commit** + +```bash +git add internal/app/ cmd/ydropbox/main.go +git commit -m "feat: assembly and main entry point" +``` + +--- + +### Task 11: Integration Test + +**Files:** +- Create: `tests/integration_test.go` + +**Interfaces:** +- Consumes: full stack +- Produces: End-to-end test + +- [ ] **Step 1: Write integration test** + +```go +// tests/integration_test.go +package tests + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "yoresee_dropbox/internal/app" +) + +func TestIntegration(t *testing.T) { + dir := t.TempDir() + workspace := filepath.Join(dir, "workspace") + os.MkdirAll(filepath.Join(workspace, "inbox"), 0755) + os.MkdirAll(filepath.Join(workspace, "outbox"), 0755) + os.MkdirAll(filepath.Join(workspace, ".ydropbox"), 0755) + + cfg := app.Config{ + Workspace: workspace, + Token: "test-token", + } + + handler, store, err := app.NewHandler(cfg) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + ts := httptest.NewServer(handler) + defer ts.Close() + + client := ts.Client() + + // Login + loginBody := bytes.NewBufferString(`{"token":"test-token"}`) + loginRes, err := client.Post(ts.URL+"/api/login", "application/json", loginBody) + if err != nil || loginRes.StatusCode != 200 { + t.Fatalf("Login failed: %v, status: %d", err, loginRes.StatusCode) + } + + // Upload file + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, _ := writer.CreateFormFile("file", "test.txt") + part.Write([]byte("hello world")) + writer.Close() + + uploadReq, _ := http.NewRequest("POST", ts.URL+"/api/upload", body) + uploadReq.Header.Set("Content-Type", writer.FormDataContentType()) + for _, cookie := range loginRes.Cookies() { + uploadReq.AddCookie(cookie) + } + uploadRes, err := client.Do(uploadReq) + if err != nil || uploadRes.StatusCode != 201 { + t.Fatalf("Upload failed: %v, status: %d", err, uploadRes.StatusCode) + } + + var uploadResp map[string]any + json.NewDecoder(uploadRes.Body).Decode(&uploadResp) + file := uploadResp["file"].(map[string]any) + fileID := file["id"].(string) + + // List files + listReq, _ := http.NewRequest("GET", ts.URL+"/api/files?dir=inbox", nil) + for _, cookie := range loginRes.Cookies() { + listReq.AddCookie(cookie) + } + listRes, err := client.Do(listReq) + if err != nil || listRes.StatusCode != 200 { + t.Fatalf("List failed: %v, status: %d", err, listRes.StatusCode) + } + + var listResp map[string]any + json.NewDecoder(listRes.Body).Decode(&listResp) + files := listResp["files"].([]any) + if len(files) != 1 { + t.Errorf("Expected 1 file, got %d", len(files)) + } + + // Download file + dlReq, _ := http.NewRequest("GET", ts.URL+"/api/files/"+fileID+"/download", nil) + for _, cookie := range loginRes.Cookies() { + dlReq.AddCookie(cookie) + } + dlRes, err := client.Do(dlReq) + if err != nil || dlRes.StatusCode != 200 { + t.Fatalf("Download failed: %v, status: %d", err, dlRes.StatusCode) + } + + // Create share + shareReq, _ := http.NewRequest("POST", ts.URL+"/api/files/"+fileID+"/share", bytes.NewBufferString(`{}`)) + shareReq.Header.Set("Content-Type", "application/json") + for _, cookie := range loginRes.Cookies() { + shareReq.AddCookie(cookie) + } + shareRes, err := client.Do(shareReq) + if err != nil || shareRes.StatusCode != 201 { + t.Fatalf("Share create failed: %v, status: %d", err, shareRes.StatusCode) + } + + var shareResp map[string]any + json.NewDecoder(shareRes.Body).Decode(&shareResp) + shareURL := shareResp["url"].(string) + + // Public share access (no password) + pubRes, err := client.Get(ts.URL + shareURL) + if err != nil || pubRes.StatusCode != 200 { + t.Fatalf("Public share access failed: %v, status: %d", err, pubRes.StatusCode) + } +} +``` + +- [ ] **Step 2: Run integration test** + +```bash +go test ./tests -v +``` + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add tests/ +git commit -m "test: integration test" +``` + +--- + +## Summary + +This plan builds yDropbox incrementally with TDD at each layer: +1. **Tasks 1-3**: Store layer (schema, file CRUD, share CRUD) +2. **Task 4**: Scanner (directory reconciliation) +3. **Tasks 5-8**: Server (auth, files, shares, routing) +4. **Task 9**: Web frontend (Alpine.js) +5. **Task 10**: Assembly (app + main) +6. **Task 11**: Integration test + +Each task is self-contained with its own test cycle. The final binary is a single static Go executable with embedded frontend, ready for deployment.