2091 lines
47 KiB
Markdown
2091 lines
47 KiB
Markdown
# 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(`<!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**
|
|
|
|
```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
|
|
<!-- 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**
|
|
|
|
```html
|
|
<!-- 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**
|
|
|
|
```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.
|