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.
+
+
Latest release v1.0.0
+
+
+
+
+
+
+
+
+ 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
+
copy
+
+
+
DOIT_ADDR=0.0.0.0:8080 doit
+
copy
+
+
+
+
+
+
+
+
+
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;
+ }
+}