Files
yoresee_dropbox/docs/superpowers/plans/2026-08-26-ydropbox-implementation.md
2026-08-26 21:34:44 +08:00

47 KiB

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

go mod init yoresee_dropbox
  • Step 2: Add dependencies
go get modernc.org/sqlite@latest
go get golang.org/x/crypto/bcrypt
  • Step 3: Create stub main.go
// cmd/ydropbox/main.go
package main

import "log"

func main() {
	log.Println("yDropbox starting...")
}
  • Step 4: Verify compilation
go build ./cmd/ydropbox

Expected: Binary compiles successfully

  • Step 5: Commit
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

// 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
go test ./internal/store -run TestOpen -v

Expected: FAIL — Open not defined

  • Step 3: Implement store.Open with migrations
// 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
go test ./internal/store -run TestOpen -v

Expected: PASS

  • Step 5: Write failing test for FileCreate
// 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
go test ./internal/store -run TestFileCreate -v

Expected: FAIL — File, FileCreate, FileGet not defined

  • Step 7: Implement File type and CRUD
// 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
go test ./internal/store -run TestFileCreate -v

Expected: PASS

  • Step 9: Commit
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

// 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
go test ./internal/store -run TestShareCreate -v

Expected: FAIL — Share, ShareCreate, ShareGetByToken not defined

  • Step 3: Implement Share type and CRUD
// 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
go test ./internal/store -run TestShareCreate -v

Expected: PASS

  • Step 5: Write test for cascade delete
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
go test ./internal/store -run TestFileDeleteCascadesShares -v

Expected: PASS (SQLite ON DELETE CASCADE handles this)

  • Step 7: Commit
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

// 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
go test ./internal/scanner -run TestScannerReconciles -v

Expected: FAIL — New, Start, Stop not defined

  • Step 3: Implement scanner
// 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
go test ./internal/scanner -run TestScannerReconciles -v

Expected: PASS

  • Step 5: Commit
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

// 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
go test ./internal/server -run TestLogin -v

Expected: FAIL — Server, handleLogin not defined

  • Step 3: Implement auth handlers and session management
// 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)
}
// 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
// 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
go test ./internal/server -run TestLogin -v

Expected: PASS

  • Step 6: Commit
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

// 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
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:

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
// 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
go test ./internal/server -run TestUpload -v

Expected: PASS

  • Step 6: Commit
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

// 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
go test ./internal/server -run TestCreateShare -v

Expected: FAIL — handleCreateShare not defined

  • Step 3: Implement share handlers
// 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
go test ./internal/server -run TestCreateShare -v

Expected: PASS

  • Step 5: Write test for public share access
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
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:

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(`<!DOCTYPE html><html><body>
				<form method="POST"><input type="password" name="password"><button>Submit</button></form>
			</body></html>`))
			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
go test ./internal/server -run TestShareAccess -v

Expected: PASS

  • Step 9: Commit
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

// 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
go build ./...

Expected: Success

  • Step 3: Commit
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

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
// web/web.go
package web

import "embed"

//go:embed *
var FS embed.FS
  • Step 3: Create login.html
<!-- web/login.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>yDropbox Login</title>
    <link rel="stylesheet" href="/style.css">
</head>
<body>
    <div class="container">
        <h1>yDropbox</h1>
        <form id="loginForm">
            <input type="password" id="token" placeholder="Access Token" required>
            <button type="submit">Login</button>
        </form>
    </div>
    <script>
        document.getElementById('loginForm').addEventListener('submit', async (e) => {
            e.preventDefault();
            const token = document.getElementById('token').value;
            const res = await fetch('/api/login', {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({token})
            });
            if (res.ok) window.location.href = '/';
            else alert('Invalid token');
        });
    </script>
</body>
</html>
  • Step 4: Create index.html with Alpine.js
<!-- web/index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>yDropbox</title>
    <link rel="stylesheet" href="/style.css">
    <script defer src="/alpine.min.js"></script>
</head>
<body>
    <div x-data="app()" x-init="loadFiles()">
        <header>
            <h1>yDropbox</h1>
            <button @click="logout()">Logout</button>
        </header>

        <section>
            <h2>Inbox</h2>
            <input type="file" @change="upload($event, 'inbox')">
            <template x-for="file in inboxFiles" :key="file.id">
                <div>
                    <span x-text="file.original_name"></span>
                    <button @click="download(file.id)">Download</button>
                    <button @click="deleteFile(file.id)">Delete</button>
                    <button @click="share(file.id)">Share</button>
                </div>
            </template>
        </section>

        <section>
            <h2>Outbox</h2>
            <template x-for="file in outboxFiles" :key="file.id">
                <div>
                    <span x-text="file.original_name"></span>
                    <button @click="download(file.id)">Download</button>
                    <button @click="deleteFile(file.id)">Delete</button>
                </div>
            </template>
        </section>
    </div>
    <script src="/app.js"></script>
</body>
</html>
  • Step 5: Create app.js
// 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
/* 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
go build ./...

Expected: Success

  • Step 8: Commit
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

// 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
// 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
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
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

// 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
go test ./tests -v

Expected: PASS

  • Step 3: Commit
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.