diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab0306..35d1a92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ on: push: branches: [ "main", "dev" ] pull_request: - branches: [ "main" ] + branches: [ "main", "dev" ] jobs: build-and-test: @@ -21,4 +21,4 @@ jobs: run: go build -v ./... - name: Test - run: go test -v ./... \ No newline at end of file + run: go test -race -v ./... diff --git a/.gitignore b/.gitignore index 1b63969..ddb7d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ data/ awesomeProject +.gocache +.vercel diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a305cf6..aeced49 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -20,9 +20,12 @@ archives: format: zip name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" +checksum: + name_template: "checksums.txt" + changelog: sort: asc filters: exclude: - "^docs:" - - "^test:" \ No newline at end of file + - "^test:" diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..5dcc2d5 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,5 @@ +.git +.gocache +.idea +data +awesomeProject diff --git a/README.md b/README.md index 3ae0cfb..f95046c 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,33 @@ http://192.168.1.22:8080 Open that address from another device connected to the same Wi-Fi. +## Terminal Download + +Install the latest release with either `curl` or `wget`: + +```bash +curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh +``` + +```bash +wget -qO- https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh +``` + +The installer downloads the matching GitHub release archive for Linux or macOS on `amd64`/`arm64`, then installs `doit` into `~/.local/bin`. If you cloned the repository or downloaded `scripts/install.sh` locally, override the destination or version when needed: + +```bash +DOIT_INSTALL_DIR=/usr/local/bin DOIT_VERSION=v1.0.0 sh scripts/install.sh +``` + +Manual download format: + +```text +https://github.com/blackdragoon26/Do-It/releases/latest/download/Do-It_linux_amd64.tar.gz +https://github.com/blackdragoon26/Do-It/releases/latest/download/Do-It_linux_arm64.tar.gz +https://github.com/blackdragoon26/Do-It/releases/latest/download/Do-It_darwin_amd64.tar.gz +https://github.com/blackdragoon26/Do-It/releases/latest/download/Do-It_darwin_arm64.tar.gz +``` + ## Data Storage By default, Do-It writes local app data under: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 930382a..1494c60 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -243,6 +243,7 @@ For the current product, HTTP plus SSE is simpler and easier to debug while lear - Uploads are local to one server device. - Device presence is approximate because it uses IP plus user agent. - No offline edit queue yet. +- Each mutation currently broadcasts a full task snapshot to every connected browser. That is simple and reliable for a LAN-scale app, but large datasets should move toward diff-based updates or paged sync. ## Next Architecture Upgrades @@ -252,5 +253,6 @@ Best order: 2. Pairing code or local auth. 3. Better backup/export. 4. Drag-to-reparent graph nodes. -5. Optional gRPC/Connect-Go API. -6. mDNS discovery, so devices can open `doit.local`. +5. Diff-based live sync for large task graphs. +6. Optional gRPC/Connect-Go API. +7. mDNS discovery, so devices can open `doit.local`. diff --git a/go.mod b/go.mod index c7d2827..76bdadd 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module awesomeProject +module github.com/blackdragoon26/Do-It go 1.26 diff --git a/main.go b/main.go index 2dac000..76fa98f 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,16 @@ package main import ( + "context" "embed" "io/fs" "log" "net/http" "os" + "os/signal" "path/filepath" "strings" + "syscall" "time" ) @@ -42,8 +45,30 @@ func main() { log.Printf("LAN device URL: %s", url) } - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("serve: %v", err) + serverErr := make(chan error, 1) + go func() { + serverErr <- server.ListenAndServe() + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(stop) + + select { + case err := <-serverErr: + if err != nil && err != http.ErrServerClosed { + log.Fatalf("serve: %v", err) + } + case sig := <-stop: + log.Printf("received %s, shutting down", sig) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("shutdown: %v", err) + } + if err := <-serverErr; err != nil && err != http.ErrServerClosed { + log.Fatalf("serve: %v", err) + } } } diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..0da8382 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env sh +set -eu + +repo="blackdragoon26/Do-It" +install_dir="${DOIT_INSTALL_DIR:-$HOME/.local/bin}" +version="${DOIT_VERSION:-latest}" + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" + +case "$arch" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) echo "unsupported architecture: $arch" >&2; exit 1 ;; +esac + +case "$os" in + linux|darwin) archive_ext="tar.gz" ;; + *) echo "unsupported OS: $os" >&2; exit 1 ;; +esac + +if [ "$version" = "latest" ]; then + base_url="https://github.com/$repo/releases/latest/download" +else + base_url="https://github.com/$repo/releases/download/$version" +fi + +archive="Do-It_${os}_${arch}.${archive_ext}" +tmp_dir="$(mktemp -d 2>/dev/null || mktemp -d "${TMPDIR:-/tmp}/doit.XXXXXX")" +trap 'rm -rf "$tmp_dir"' EXIT INT TERM + +download() { + url="$1" + output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" + return + fi + if command -v wget >/dev/null 2>&1; then + wget -qO "$output" "$url" + return + fi + echo "curl or wget is required" >&2 + exit 1 +} + +download_optional() { + url="$1" + output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" >/dev/null 2>&1 && return 0 + return 1 + fi + if command -v wget >/dev/null 2>&1; then + wget -qO "$output" "$url" >/dev/null 2>&1 && return 0 + return 1 + fi + echo "curl or wget is required" >&2 + exit 1 +} + +verify_checksum() { + archive_path="$1" + checksums_path="$2" + archive_name="$(basename "$archive_path")" + expected="$(awk -v name="$archive_name" '$2 == name { print $1; found = 1 } END { if (!found) exit 1 }' "$checksums_path")" || { + echo "checksum for $archive_name not found" >&2 + exit 1 + } + + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$expected" "$archive_path" | sha256sum -c - + return + fi + if command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$archive_path" | awk '{ print $1 }')" + if [ "$actual" = "$expected" ]; then + return + fi + echo "$archive_name checksum mismatch" >&2 + exit 1 + fi + echo "sha256sum or shasum is required to verify downloads" >&2 + exit 1 +} + +download "$base_url/$archive" "$tmp_dir/$archive" +if download_optional "$base_url/checksums.txt" "$tmp_dir/checksums.txt"; then + verify_checksum "$tmp_dir/$archive" "$tmp_dir/checksums.txt" +else + echo "checksums.txt not found for this release; installing without checksum verification" >&2 +fi +tar -xzf "$tmp_dir/$archive" -C "$tmp_dir" + +mkdir -p "$install_dir" +install "$tmp_dir/doit" "$install_dir/doit" + +echo "Installed doit to $install_dir/doit" +echo "Run: $install_dir/doit" diff --git a/server.go b/server.go index 0b6afaa..fb526db 100644 --- a/server.go +++ b/server.go @@ -1,13 +1,15 @@ package main import ( + "crypto/rand" "crypto/sha256" + "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "io" - "mime" + "log" "net" "net/http" "os" @@ -20,9 +22,14 @@ import ( ) const ( - maxRequestBytes = 64 << 20 - maxUploadBytes = 32 << 20 - maxFilesPerTask = 8 + maxRequestBytes = 64 << 20 + maxUploadBytes = 32 << 20 + maxFilesPerTask = 8 + maxMutationsPerMinute = 60 + maxRateLimitClients = 4096 + rateLimitPruneEvery = 15 * time.Second + csrfCookieName = "doit_csrf" + csrfHeaderName = "X-CSRF-Token" ) type app struct { @@ -30,6 +37,7 @@ type app struct { hub *eventHub uploadDir string static http.Handler + limiter *rateLimiter } type eventHub struct { @@ -55,6 +63,19 @@ type serverEvent struct { Data []byte } +type rateLimiter struct { + mu sync.Mutex + window time.Duration + limit int + lastPrune time.Time + clients map[string]rateWindow +} + +type rateWindow struct { + start time.Time + count int +} + type ConnectedDevice struct { ID string `json:"id"` Name string `json:"name"` @@ -87,6 +108,7 @@ func newApp(store *Store, uploadDir string, static http.Handler) *app { hub: newEventHub(), uploadDir: uploadDir, static: static, + limiter: newRateLimiter(time.Minute, maxMutationsPerMinute), } } @@ -94,6 +116,14 @@ func newEventHub() *eventHub { return &eventHub{clients: make(map[string]*clientSession)} } +func newRateLimiter(window time.Duration, limit int) *rateLimiter { + return &rateLimiter{ + window: window, + limit: limit, + clients: make(map[string]rateWindow), + } +} + func (a *app) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/tasks", a.handleTasks) @@ -104,7 +134,7 @@ func (a *app) routes() http.Handler { mux.HandleFunc("/api/network", a.handleNetwork) mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(a.uploadDir)))) mux.Handle("/", a.static) - return withSecurityHeaders(mux) + return withSecurityHeaders(withCSRFCookie(withCSRFProtection(a.withMutationRateLimit(mux)))) } func (a *app) handleTasks(w http.ResponseWriter, r *http.Request) { @@ -188,11 +218,17 @@ func (a *app) handleTaskByID(w http.ResponseWriter, r *http.Request) { a.hub.broadcast(snapshot) writeJSON(w, http.StatusOK, task) case http.MethodDelete: + task, err := a.store.Task(id) + if err != nil { + writeStoreError(w, err) + return + } snapshot, err := a.store.DeleteTask(id) if err != nil { writeStoreError(w, err) return } + a.removeUploadedAttachments(task.Attachments) a.hub.broadcast(snapshot) w.WriteHeader(http.StatusNoContent) default: @@ -341,50 +377,56 @@ func (a *app) saveUploadedFiles(r *http.Request) ([]Attachment, error) { } attachments := make([]Attachment, 0, len(files)) + createdPaths := make([]string, 0, len(files)) + fail := func(err error) ([]Attachment, error) { + removeUploadedPaths(createdPaths) + return nil, err + } for _, header := range files { if header.Size > maxUploadBytes { - return nil, fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes)) + return fail(fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes))) } source, err := header.Open() if err != nil { - return nil, err + return fail(err) } fileID, err := newID("file") if err != nil { _ = source.Close() - return nil, err + return fail(err) } name := sanitizeFilename(header.Filename) if name == "" { name = fileID } + contentType, err := allowedUploadType(name) + if err != nil { + _ = source.Close() + return fail(err) + } storedName := fileID + "_" + name targetPath := filepath.Join(a.uploadDir, storedName) target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { _ = source.Close() - return nil, err + return fail(err) } + createdPaths = append(createdPaths, targetPath) written, copyErr := io.Copy(target, io.LimitReader(source, maxUploadBytes+1)) closeErr := errors.Join(source.Close(), target.Close()) if copyErr != nil { - return nil, copyErr + return fail(copyErr) } if closeErr != nil { - return nil, closeErr + return fail(closeErr) } if written > maxUploadBytes { - _ = os.Remove(targetPath) - return nil, fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes)) + return fail(fmt.Errorf("%s is larger than %s", header.Filename, humanBytes(maxUploadBytes))) } - contentType := header.Header.Get("Content-Type") - if contentType == "" { - contentType = mime.TypeByExtension(filepath.Ext(name)) - } attachments = append(attachments, Attachment{ ID: fileID, Name: name, @@ -397,6 +439,24 @@ func (a *app) saveUploadedFiles(r *http.Request) ([]Attachment, error) { return attachments, nil } +func (a *app) removeUploadedAttachments(attachments []Attachment) { + for _, attachment := range attachments { + storedName := filepath.Base(strings.TrimPrefix(attachment.URL, "/uploads/")) + if storedName == "." || storedName == string(filepath.Separator) || storedName == "" { + continue + } + removeUploadedPaths([]string{filepath.Join(a.uploadDir, storedName)}) + } +} + +func removeUploadedPaths(paths []string) { + for _, path := range paths { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Printf("remove upload %s: %v", path, err) + } + } +} + func (h *eventHub) subscribe(r *http.Request) (*clientSession, error) { id, err := newID("client") if err != nil { @@ -567,6 +627,116 @@ func methodNotAllowed(w http.ResponseWriter, methods ...string) { httpError(w, http.StatusMethodNotAllowed, "method not allowed") } +func (a *app) withMutationRateLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isMutation(r) && !a.limiter.Allow(remoteAddress(r)) { + httpError(w, http.StatusTooManyRequests, "too many requests") + return + } + next.ServeHTTP(w, r) + }) +} + +func withCSRFProtection(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requiresCSRF(r) && !validCSRFToken(r) { + httpError(w, http.StatusForbidden, "invalid CSRF token") + return + } + next.ServeHTTP(w, r) + }) +} + +func isMutation(r *http.Request) bool { + switch r.Method { + case http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodPut: + return strings.HasPrefix(r.URL.Path, "/api/") + default: + return false + } +} + +func withCSRFCookie(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isSafeMethod(r.Method) { + ensureCSRFCookie(w, r) + } + next.ServeHTTP(w, r) + }) +} + +func requiresCSRF(r *http.Request) bool { + switch r.Method { + case http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodPut: + return strings.HasPrefix(r.URL.Path, "/api/") + default: + return false + } +} + +func isSafeMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + default: + return false + } +} + +func ensureCSRFCookie(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(csrfCookieName); err == nil && strings.TrimSpace(cookie.Value) != "" { + return + } + token, err := newCSRFToken() + if err != nil { + return + } + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/", + SameSite: http.SameSiteStrictMode, + Secure: requestIsHTTPS(r), + }) +} + +func requestIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + return true + } + for _, part := range strings.Split(r.Header.Get("Forwarded"), ";") { + part = strings.TrimSpace(part) + if strings.EqualFold(part, "proto=https") { + return true + } + } + return false +} + +func validCSRFToken(r *http.Request) bool { + cookie, err := r.Cookie(csrfCookieName) + if err != nil { + return false + } + cookieToken := strings.TrimSpace(cookie.Value) + headerToken := strings.TrimSpace(r.Header.Get(csrfHeaderName)) + if cookieToken == "" || headerToken == "" || len(cookieToken) != len(headerToken) { + return false + } + return subtle.ConstantTimeCompare([]byte(cookieToken), []byte(headerToken)) == 1 +} + +func newCSRFToken() (string, error) { + var bytes [32]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", err + } + return hex.EncodeToString(bytes[:]), nil +} + func withSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") @@ -575,6 +745,45 @@ func withSecurityHeaders(next http.Handler) http.Handler { }) } +func (l *rateLimiter) Allow(key string) bool { + if key == "" { + key = "unknown" + } + now := time.Now().UTC() + + l.mu.Lock() + defer l.mu.Unlock() + + l.pruneExpiredLocked(now) + + window := l.clients[key] + if window.start.IsZero() || now.Sub(window.start) >= l.window { + if len(l.clients) >= maxRateLimitClients { + return false + } + l.clients[key] = rateWindow{start: now, count: 1} + return true + } + if window.count >= l.limit { + return false + } + window.count++ + l.clients[key] = window + return true +} + +func (l *rateLimiter) pruneExpiredLocked(now time.Time) { + if !l.lastPrune.IsZero() && now.Sub(l.lastPrune) < rateLimitPruneEvery { + return + } + l.lastPrune = now + for client, window := range l.clients { + if now.Sub(window.start) >= l.window { + delete(l.clients, client) + } + } +} + func sanitizeFilename(name string) string { name = filepath.Base(name) name = strings.TrimSpace(name) @@ -596,6 +805,29 @@ func sanitizeFilename(name string) string { return strings.Trim(builder.String(), ".-_") } +func allowedUploadType(name string) (string, error) { + ext := strings.ToLower(filepath.Ext(name)) + allowed := map[string]string{ + ".csv": "text/csv; charset=utf-8", + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".json": "application/json", + ".md": "text/markdown; charset=utf-8", + ".pdf": "application/pdf", + ".png": "image/png", + ".txt": "text/plain; charset=utf-8", + ".webp": "image/webp", + } + if contentType, ok := allowed[ext]; ok { + return contentType, nil + } + if ext == "" { + return "", fmt.Errorf("file extension is required") + } + return "", fmt.Errorf("%s uploads are not allowed", ext) +} + func humanBytes(n int64) string { const unit = 1024 if n < unit { @@ -634,6 +866,9 @@ func localNetworkURLs(port string) []string { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { continue } + if isLikelyVirtualInterface(iface.Name) { + continue + } addrs, err := iface.Addrs() if err != nil { continue @@ -650,6 +885,17 @@ func localNetworkURLs(port string) []string { return urls } +func isLikelyVirtualInterface(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + prefixes := []string{"br-", "docker", "veth", "virbr"} + for _, prefix := range prefixes { + if strings.HasPrefix(name, prefix) { + return true + } + } + return false +} + func remoteAddress(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { diff --git a/server_test.go b/server_test.go index a720cd0..787f40e 100644 --- a/server_test.go +++ b/server_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "fmt" "mime/multipart" "net/http" "net/http/httptest" @@ -10,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestCreateTaskWithUpload(t *testing.T) { @@ -38,6 +40,7 @@ func TestCreateTaskWithUpload(t *testing.T) { } request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) request.Header.Set("Content-Type", writer.FormDataContentType()) response := httptest.NewRecorder() app.routes().ServeHTTP(response, request) @@ -71,6 +74,289 @@ func TestCreateTaskWithUpload(t *testing.T) { } } +func TestCreateTaskRejectsExecutableUploadExtension(t *testing.T) { + dataDir := t.TempDir() + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, filepath.Join(dataDir, "uploads"), http.NotFoundHandler()) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Upload a script"); err != nil { + t.Fatalf("write title: %v", err) + } + file, err := writer.CreateFormFile("files", "payload.html") + if err != nil { + t.Fatalf("create file field: %v", err) + } + if _, err := file.Write([]byte("")); err != nil { + t.Fatalf("write file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", response.Code, response.Body.String()) + } + entries, err := os.ReadDir(filepath.Join(dataDir, "uploads")) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("read upload dir: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected rejected upload to avoid stored files, got %d", len(entries)) + } +} + +func TestCreateTaskRejectsMissingCSRFToken(t *testing.T) { + dataDir := t.TempDir() + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, filepath.Join(dataDir, "uploads"), http.NotFoundHandler()) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Missing token"); err != nil { + t.Fatalf("write title: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", response.Code, response.Body.String()) + } +} + +func TestUnsupportedAPIMethodRequiresCSRFBeforeMethodCheck(t *testing.T) { + dataDir := t.TempDir() + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, filepath.Join(dataDir, "uploads"), http.NotFoundHandler()) + + request := httptest.NewRequest(http.MethodPut, "/api/tasks", nil) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d: %s", response.Code, response.Body.String()) + } + + request = httptest.NewRequest(http.MethodPut, "/api/tasks", nil) + addCSRF(request) + response = httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d: %s", response.Code, response.Body.String()) + } +} + +func TestCSRFCookieIsOnlyIssuedForSafeMethods(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, t.TempDir(), http.NotFoundHandler()) + + postRequest := httptest.NewRequest(http.MethodPost, "/", nil) + postResponse := httptest.NewRecorder() + app.routes().ServeHTTP(postResponse, postRequest) + if cookieByName(postResponse.Result().Cookies(), csrfCookieName) != nil { + t.Fatal("expected unsafe non-API request not to receive a CSRF cookie") + } + + getRequest := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + getResponse := httptest.NewRecorder() + app.routes().ServeHTTP(getResponse, getRequest) + if cookieByName(getResponse.Result().Cookies(), csrfCookieName) == nil { + t.Fatal("expected safe request to receive a CSRF cookie") + } +} + +func TestCSRFCookieUsesForwardedHTTPSForSecureAttribute(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, t.TempDir(), http.NotFoundHandler()) + + request := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + request.Header.Set("X-Forwarded-Proto", "https") + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + cookie := cookieByName(response.Result().Cookies(), csrfCookieName) + if cookie == nil { + t.Fatal("expected CSRF cookie") + } + if !cookie.Secure { + t.Fatal("expected forwarded HTTPS request to set Secure cookie") + } +} + +func TestCreateTaskCleansEarlierUploadsWhenLaterFileFails(t *testing.T) { + dataDir := t.TempDir() + uploadDir := filepath.Join(dataDir, "uploads") + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, uploadDir, http.NotFoundHandler()) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Mixed upload"); err != nil { + t.Fatalf("write title: %v", err) + } + first, err := writer.CreateFormFile("files", "safe.txt") + if err != nil { + t.Fatalf("create first file: %v", err) + } + if _, err := first.Write([]byte("safe")); err != nil { + t.Fatalf("write first file: %v", err) + } + second, err := writer.CreateFormFile("files", "unsafe.sh") + if err != nil { + t.Fatalf("create second file: %v", err) + } + if _, err := second.Write([]byte("echo unsafe")); err != nil { + t.Fatalf("write second file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d: %s", response.Code, response.Body.String()) + } + entries, err := os.ReadDir(uploadDir) + if err != nil && !os.IsNotExist(err) { + t.Fatalf("read upload dir: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected failed multi-file upload to remove earlier files, got %d", len(entries)) + } +} + +func TestAllowedUploadTypeReportsUnknownExtension(t *testing.T) { + _, err := allowedUploadType("script.sh") + if err == nil { + t.Fatal("expected .sh to be rejected") + } + if !strings.Contains(err.Error(), ".sh uploads are not allowed") { + t.Fatalf("expected unknown extension message, got %q", err.Error()) + } +} + +func TestDeleteTaskRemovesUploadedFiles(t *testing.T) { + dataDir := t.TempDir() + uploadDir := filepath.Join(dataDir, "uploads") + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, uploadDir, http.NotFoundHandler()) + task := createUploadedTask(t, app, "notes.txt", "temporary notes") + + uploadedPath := filepath.Join(uploadDir, strings.TrimPrefix(task.Attachments[0].URL, "/uploads/")) + if _, err := os.Stat(uploadedPath); err != nil { + t.Fatalf("expected uploaded file before delete: %v", err) + } + + request := httptest.NewRequest(http.MethodDelete, "/api/tasks/"+task.ID, nil) + addCSRF(request) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d: %s", response.Code, response.Body.String()) + } + if _, err := os.Stat(uploadedPath); !os.IsNotExist(err) { + t.Fatalf("expected uploaded file to be removed, got %v", err) + } +} + +func TestMutationRateLimit(t *testing.T) { + dataDir := t.TempDir() + store, err := NewStore(filepath.Join(dataDir, "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + app := newApp(store, filepath.Join(dataDir, "uploads"), http.NotFoundHandler()) + app.limiter = newRateLimiter(time.Minute, 1) + + for i, want := range []int{http.StatusCreated, http.StatusTooManyRequests} { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Limited task"); err != nil { + t.Fatalf("write title: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) + request.RemoteAddr = "203.0.113.10:4000" + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + if response.Code != want { + t.Fatalf("request %d: expected status %d, got %d: %s", i+1, want, response.Code, response.Body.String()) + } + } +} + +func TestMutationRateLimitPrunesExpiredClients(t *testing.T) { + limiter := newRateLimiter(time.Minute, 1) + limiter.clients["expired"] = rateWindow{ + start: time.Now().Add(-2 * time.Minute), + count: 1, + } + + if !limiter.Allow("fresh") { + t.Fatal("expected fresh client to be allowed") + } + if _, ok := limiter.clients["expired"]; ok { + t.Fatal("expected expired client to be pruned") + } +} + +func TestMutationRateLimitCapsClientMap(t *testing.T) { + limiter := newRateLimiter(time.Minute, 1) + now := time.Now() + for i := 0; i < maxRateLimitClients; i++ { + limiter.clients[fmt.Sprintf("client-%d", i)] = rateWindow{start: now, count: 1} + } + + if limiter.Allow("overflow") { + t.Fatal("expected new client to be rejected when limiter map is full") + } +} + func TestEventHubTracksConnectedDevices(t *testing.T) { hub := newEventHub() request := httptest.NewRequest(http.MethodGet, "/api/events", nil) @@ -168,3 +454,69 @@ func TestEventHubTracksClientHealth(t *testing.T) { t.Fatalf("expected rtt, got %+v", health) } } + +func TestVirtualInterfaceNamesAreSkippedForLANURLs(t *testing.T) { + for _, name := range []string{"docker0", "br-1a2b3c", "veth123", "virbr0"} { + if !isLikelyVirtualInterface(name) { + t.Fatalf("expected %q to be treated as virtual", name) + } + } + for _, name := range []string{"en0", "wlan0", "eth0"} { + if isLikelyVirtualInterface(name) { + t.Fatalf("expected %q to be treated as a physical candidate", name) + } + } +} + +func createUploadedTask(t *testing.T, app *app, name, contents string) Task { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("title", "Upload a file"); err != nil { + t.Fatalf("write title: %v", err) + } + file, err := writer.CreateFormFile("files", name) + if err != nil { + t.Fatalf("create file field: %v", err) + } + if _, err := file.Write([]byte(contents)); err != nil { + t.Fatalf("write file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + request := httptest.NewRequest(http.MethodPost, "/api/tasks", &body) + addCSRF(request) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + app.routes().ServeHTTP(response, request) + + if response.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d: %s", response.Code, response.Body.String()) + } + var task Task + if err := json.Unmarshal(response.Body.Bytes(), &task); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(task.Attachments) != 1 { + t.Fatalf("expected one attachment, got %d", len(task.Attachments)) + } + return task +} + +func addCSRF(request *http.Request) { + const token = "test-csrf-token" + request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: token}) + request.Header.Set(csrfHeaderName, token) +} + +func cookieByName(cookies []*http.Cookie, name string) *http.Cookie { + for _, cookie := range cookies { + if cookie.Name == name { + return cookie + } + } + return nil +} diff --git a/static/app.css b/static/app.css index e3c131c..b8914d3 100644 --- a/static/app.css +++ b/static/app.css @@ -162,6 +162,27 @@ label span, margin-top: 26px; } +.credit { + margin-top: 28px; + padding-top: 14px; + border-top: 1px solid var(--faint); + display: flex; + flex-wrap: wrap; + gap: 5px; + color: var(--muted); + font-size: 12px; +} + +.credit a { + color: var(--ink); + text-decoration: none; +} + +.credit a:hover, +.credit a:focus-visible { + text-decoration: underline; +} + .section-heading { display: flex; align-items: center; diff --git a/static/app.js b/static/app.js index 37e5f52..556fe9f 100644 --- a/static/app.js +++ b/static/app.js @@ -27,6 +27,7 @@ form.addEventListener("submit", async (event) => { const response = await fetch("/api/tasks", { method: "POST", + headers: csrfHeaders(), body: data, }); if (!response.ok) { @@ -490,7 +491,7 @@ function compareTasks(a, b) { async function patchTask(id, payload) { const response = await fetch(`/api/tasks/${id}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...csrfHeaders() }, body: JSON.stringify(payload), }); if (!response.ok) { @@ -501,6 +502,7 @@ async function patchTask(id, payload) { async function deleteTask(id) { const response = await fetch(`/api/tasks/${id}`, { method: "DELETE", + headers: csrfHeaders(), }); if (!response.ok) { await showRequestError(response); @@ -520,6 +522,20 @@ function getClientId() { return id; } +function csrfHeaders() { + const token = readCookie("doit_csrf"); + return token ? { "X-CSRF-Token": token } : {}; +} + +function readCookie(name) { + const prefix = `${name}=`; + return document.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)) + ?.slice(prefix.length) || ""; +} + async function startClientStatusReporting() { await attachBatteryListeners(); await reportClientStatus(); @@ -547,7 +563,7 @@ async function reportClientStatus() { try { await fetch("/api/client-status", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...csrfHeaders() }, body: JSON.stringify(status), keepalive: true, }); diff --git a/static/index.html b/static/index.html index 16a98ed..49c7c2b 100644 --- a/static/index.html +++ b/static/index.html @@ -55,6 +55,11 @@

Do-It

+ +
diff --git a/store.go b/store.go index 5e5f9e0..1ab0bd0 100644 --- a/store.go +++ b/store.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "time" + "unicode/utf16" ) var ( @@ -19,6 +20,11 @@ var ( errNotFound = errors.New("not found") ) +const ( + maxTitleLength = 120 + maxNotesLength = 2000 +) + type Attachment struct { ID string `json:"id"` Name string `json:"name"` @@ -91,6 +97,25 @@ func (s *Store) Snapshot() Snapshot { return s.snapshotLocked() } +func (s *Store) Task(id string) (Task, error) { + id = strings.TrimSpace(id) + if id == "" { + return Task{}, fmt.Errorf("%w: id is required", errBadInput) + } + + s.mu.RLock() + defer s.mu.RUnlock() + + task, ok := s.tasks[id] + if !ok { + return Task{}, errNotFound + } + if len(task.Attachments) > 0 { + task.Attachments = append([]Attachment(nil), task.Attachments...) + } + return task, nil +} + func (s *Store) AddTask(title, notes, parentID string, attachments []Attachment) (Snapshot, Task, error) { title = strings.TrimSpace(title) notes = strings.TrimSpace(notes) @@ -98,6 +123,9 @@ func (s *Store) AddTask(title, notes, parentID string, attachments []Attachment) if title == "" { return Snapshot{}, Task{}, fmt.Errorf("%w: title is required", errBadInput) } + if err := validateTaskText(title, notes); err != nil { + return Snapshot{}, Task{}, err + } s.mu.Lock() defer s.mu.Unlock() @@ -151,10 +179,17 @@ func (s *Store) PatchTask(id string, patch TaskPatch) (Snapshot, Task, error) { if title == "" { return Snapshot{}, Task{}, fmt.Errorf("%w: title is required", errBadInput) } + if err := validateTaskText(title, task.Notes); err != nil { + return Snapshot{}, Task{}, err + } task.Title = title } if patch.Notes != nil { - task.Notes = strings.TrimSpace(*patch.Notes) + notes := strings.TrimSpace(*patch.Notes) + if err := validateTaskText(task.Title, notes); err != nil { + return Snapshot{}, Task{}, err + } + task.Notes = notes } if patch.Done != nil { task.Done = *patch.Done @@ -311,10 +346,13 @@ func (s *Store) sortedTasksLocked() []Task { tasks = append(tasks, task) } sort.Slice(tasks, func(i, j int) bool { - if tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) { + if !tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) { + return tasks[i].CreatedAt.Before(tasks[j].CreatedAt) + } + if tasks[i].Title != tasks[j].Title { return tasks[i].Title < tasks[j].Title } - return tasks[i].CreatedAt.Before(tasks[j].CreatedAt) + return tasks[i].ID < tasks[j].ID }) return tasks } @@ -333,6 +371,27 @@ func (s *Store) wouldCycleLocked(id, parentID string) bool { return false } +func validateTaskText(title, notes string) error { + if tooLong(title, maxTitleLength) { + return fmt.Errorf("%w: title must be at most %d characters", errBadInput, maxTitleLength) + } + if tooLong(notes, maxNotesLength) { + return fmt.Errorf("%w: notes must be at most %d characters", errBadInput, maxNotesLength) + } + return nil +} + +func tooLong(value string, max int) bool { + units := 0 + for _, r := range value { + units += utf16.RuneLen(r) + if units > max { + return true + } + } + return false +} + func newID(prefix string) (string, error) { var bytes [8]byte if _, err := rand.Read(bytes[:]); err != nil { diff --git a/store_test.go b/store_test.go index 5bf3900..9faf2f6 100644 --- a/store_test.go +++ b/store_test.go @@ -2,7 +2,9 @@ package main import ( "path/filepath" + "strings" "testing" + "time" ) func TestStoreTaskLifecyclePersists(t *testing.T) { @@ -75,3 +77,87 @@ func TestStoreRejectsParentCycles(t *testing.T) { t.Fatal("expected cycle to be rejected") } } + +func TestStoreTaskReturnsAttachmentCopy(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + _, task, err := store.AddTask("With attachment", "", "", []Attachment{{ + ID: "file_1", + Name: "notes.txt", + URL: "/uploads/file_1_notes.txt", + }}) + if err != nil { + t.Fatalf("add task: %v", err) + } + + got, err := store.Task(task.ID) + if err != nil { + t.Fatalf("get task: %v", err) + } + got.Attachments[0].Name = "mutated.txt" + + again, err := store.Task(task.ID) + if err != nil { + t.Fatalf("get task again: %v", err) + } + if again.Attachments[0].Name != "notes.txt" { + t.Fatalf("expected stored attachment to be unchanged, got %q", again.Attachments[0].Name) + } +} + +func TestStoreSortsTasksWithIDTieBreaker(t *testing.T) { + store := &Store{ + tasks: make(map[string]Task), + } + createdAt := time.Date(2026, 6, 22, 13, 8, 34, 0, time.UTC) + store.tasks["task_b"] = Task{ID: "task_b", Title: "Same", CreatedAt: createdAt} + store.tasks["task_a"] = Task{ID: "task_a", Title: "Same", CreatedAt: createdAt} + + tasks := store.sortedTasksLocked() + if len(tasks) != 2 { + t.Fatalf("expected two tasks, got %d", len(tasks)) + } + if tasks[0].ID != "task_a" || tasks[1].ID != "task_b" { + t.Fatalf("expected ID tie-breaker order, got %q then %q", tasks[0].ID, tasks[1].ID) + } +} + +func TestStoreEnforcesTaskTextLimits(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + + longTitle := strings.Repeat("t", maxTitleLength+1) + if _, _, err := store.AddTask(longTitle, "", "", nil); err == nil { + t.Fatal("expected long title to be rejected") + } + + _, task, err := store.AddTask("Within limit", "", "", nil) + if err != nil { + t.Fatalf("add task: %v", err) + } + longNotes := strings.Repeat("n", maxNotesLength+1) + if _, _, err := store.PatchTask(task.ID, TaskPatch{Notes: &longNotes}); err == nil { + t.Fatal("expected long notes to be rejected") + } +} + +func TestStoreTextLimitsMatchBrowserUTF16Counting(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatalf("new store: %v", err) + } + + withinLimit := strings.Repeat("a", maxTitleLength-2) + "😀" + if _, _, err := store.AddTask(withinLimit, "", "", nil); err != nil { + t.Fatalf("expected UTF-16 limit boundary to be accepted: %v", err) + } + + overLimit := strings.Repeat("a", maxTitleLength-1) + "😀" + if _, _, err := store.AddTask(overLimit, "", "", nil); err == nil { + t.Fatal("expected title beyond UTF-16 limit to be rejected") + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..e57de4d --- /dev/null +++ b/vercel.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": null, + "buildCommand": null, + "outputDirectory": "website", + "git": { + "deploymentEnabled": { + "dev": false + } + } +} diff --git a/website/assets/product-preview.svg b/website/assets/product-preview.svg new file mode 100644 index 0000000..ff0c1cf --- /dev/null +++ b/website/assets/product-preview.svg @@ -0,0 +1,33 @@ + + Do-It dark interface preview + A minimal dark task graph interface with a rail, connected nodes, and details panel. + + + + + Do-It + TASK + + LAN + + 192.168.1.22 + DETAILS + Build site + + + + + + + + + + Plan + + Build + + Notes + + Ship + + diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..faa3742 --- /dev/null +++ b/website/index.html @@ -0,0 +1,99 @@ + + + + + + + Do-It | Local-first task graph + + + + + +
+
+
+

Local-first task graph

+

Do-It

+

Tasks, notes, files, and nearby devices. Served from your own machine, synced across your own network.

+
+ Install + Source +
+

Latest release v1.0.0

+
+ +
+ Minimal Do-It task graph interface preview +
+
+ +
+

Runtime Single Go binary

+

Sync Live browser updates

+

Storage Local JSON and uploads

+
+ +
+
+

Install

+

Run it anywhere you keep online.

+

The installer auto-detects Linux/macOS and amd64/arm64 for the device running it. For a different target, use the manual release assets documented in the GitHub repo.

+
+
+
+
curl -fsSL https://raw.githubusercontent.com/blackdragoon26/Do-It/main/scripts/install.sh | sh
+ +
+
+
DOIT_ADDR=0.0.0.0:8080 doit
+ +
+
+
+
+ + + + + diff --git a/website/styles.css b/website/styles.css new file mode 100644 index 0000000..dbfee94 --- /dev/null +++ b/website/styles.css @@ -0,0 +1,407 @@ +:root { + color-scheme: dark; + --bg: #030303; + --ink: #f5f5f5; + --muted: rgb(245 245 245 / 58%); + --faint: rgb(245 245 245 / 10%); + --line: rgb(245 245 245 / 18%); + --line-strong: rgb(245 245 245 / 36%); + --panel: rgb(255 255 255 / 5%); + --panel-strong: rgb(255 255 255 / 8%); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 50% 0, rgb(255 255 255 / 8%), transparent 34rem), + var(--bg); + color: var(--ink); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(var(--faint) 1px, transparent 1px), + linear-gradient(90deg, var(--faint) 1px, transparent 1px); + background-size: 72px 72px; + mask-image: linear-gradient(to bottom, black, transparent 78%); +} + +a { + color: inherit; +} + +.site-header { + position: fixed; + z-index: 10; + top: 0; + left: 0; + right: 0; + height: 62px; + padding: 0 clamp(18px, 5vw, 72px); + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--line); + background: rgb(3 3 3 / 82%); + backdrop-filter: blur(18px); +} + +.brand, +nav a, +.site-footer a { + text-decoration: none; +} + +.brand { + font-size: 16px; + font-weight: 680; +} + +nav { + display: flex; + gap: 24px; + color: var(--muted); + font-size: 14px; +} + +nav a, +.site-footer a, +.button, +.command button { + transition: color 160ms ease, border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +nav a:hover, +.site-footer a:hover, +.release-note a:hover { + color: var(--ink); +} + +main { + position: relative; + padding-top: 62px; +} + +.hero { + min-height: calc(100vh - 62px); + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(320px, 0.75fr); + gap: clamp(34px, 7vw, 96px); + align-items: center; + padding: clamp(54px, 8vw, 96px) clamp(18px, 5vw, 72px); +} + +.hero-copy { + max-width: 760px; +} + +.eyebrow { + margin: 0 0 18px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +h1, +h2, +p { + overflow-wrap: anywhere; +} + +h1 { + margin: 0; + font-size: clamp(78px, 17vw, 220px); + line-height: 0.84; + font-weight: 680; +} + +.lede { + max-width: 650px; + margin: 30px 0 0; + color: rgb(245 245 245 / 76%); + font-size: clamp(22px, 3vw, 38px); + line-height: 1.16; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 34px; +} + +.release-note { + margin: 16px 0 0; + color: var(--muted); + font-size: 14px; +} + +.release-note a { + color: var(--ink); + text-decoration: none; + transition: color 160ms ease; +} + +.button { + min-height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 15px; + border: 1px solid var(--line); + border-radius: 6px; + background: transparent; + color: var(--ink); + font-weight: 620; + text-decoration: none; +} + +.button:hover { + border-color: var(--line-strong); + transform: translateY(-1px); +} + +.button.primary { + background: var(--ink); + color: var(--bg); +} + +.product-frame { + margin: 0; + opacity: 0.9; + transform: translateY(0); + animation: settle 700ms ease both; +} + +.product-frame img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 40px 120px rgb(0 0 0 / 52%); +} + +.facts, +.install-section { + scroll-margin-top: 84px; + border-top: 1px solid var(--line); + padding: clamp(34px, 5vw, 62px) clamp(18px, 5vw, 72px); +} + +.facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + background: var(--line); + padding: 1px; + margin: 0 clamp(18px, 5vw, 72px) clamp(34px, 5vw, 62px); + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; +} + +.facts p { + margin: 0; + padding: 20px; + background: rgb(3 3 3 / 86%); + color: var(--ink); +} + +.facts span { + display: block; + margin-bottom: 7px; + color: var(--muted); + font-size: 12px; + text-transform: uppercase; +} + +.install-section { + display: grid; + grid-template-columns: minmax(0, 0.7fr) minmax(0, 1fr); + gap: clamp(24px, 6vw, 72px); + align-items: start; +} + +.install-note { + max-width: 520px; + margin: 20px 0 0; + color: var(--muted); + font-size: 15px; + line-height: 1.55; +} + +h2 { + max-width: 520px; + margin: 0; + font-size: clamp(34px, 6vw, 82px); + line-height: 0.96; + font-weight: 620; +} + +.commands { + display: grid; + gap: 10px; +} + +.command { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--panel-strong); + overflow: hidden; +} + +pre { + margin: 0; + padding: 16px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + background: transparent; +} + +code { + color: rgb(245 245 245 / 84%); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.command button { + min-width: 68px; + border: 0; + border-left: 1px solid var(--line); + background: transparent; + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: 13px; +} + +.command button:hover, +.command button:focus-visible { + color: var(--ink); + outline: none; + background: var(--panel); +} + +.site-footer { + min-height: 74px; + padding: 0 clamp(18px, 5vw, 72px); + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 14px; +} + +@keyframes settle { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 0.9; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +@media (max-width: 840px) { + .site-header { + position: sticky; + } + + main { + padding-top: 0; + } + + .hero, + .install-section { + grid-template-columns: 1fr; + } + + .hero { + min-height: auto; + } + + .product-frame { + max-width: 560px; + } + + .facts { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .site-header { + height: 58px; + } + + nav { + gap: 16px; + } + + h1 { + font-size: 76px; + } + + .lede { + font-size: 23px; + } + + .button { + width: 100%; + } + + .command { + grid-template-columns: 1fr; + } + + .command button { + min-height: 38px; + border-left: 0; + border-top: 1px solid var(--line); + } + + .site-footer { + min-height: 92px; + flex-direction: column; + align-items: flex-start; + justify-content: center; + } +}