Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions arc-paste/Caddyfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
# To change the public hostname, edit the site address below. To change the
# ACME contact, edit `email` in the global block. Reload without downtime via:
# docker compose -f arc-paste/compose.yaml exec caddy caddy reload --config /etc/caddy/Caddyfile

{
# Let's Encrypt sends expiry + revocation notices here. Without it, you
# only learn about cert problems when the site goes down.
Expand All @@ -17,9 +16,33 @@ arcpaste.company.com {
# Negotiate compression with the client. zstd preferred, gzip fallback.
encode zstd gzip

# arc-paste listens on the internal Docker network — the service name
# resolves via Compose's embedded DNS.
reverse_proxy arc-paste:7433
# Default-deny edge: arc-paste only needs to expose four things to render
# a share — the share route itself, SvelteKit's hashed asset bundle, the
# paste API, and robots.txt. Anything else (the arc app shell at /, stray
# /api/v1/* probes from the SPA, /labels, /planner/*, etc.) is rejected
# before it reaches the upstream. arc-paste/main.go has matching in-binary
# guards so dev (no Caddy) behaves the same.
#
# Note: directives inside one named-matcher block are AND'd. To OR
# different matcher *kinds* (path-list vs. regex), define them separately
# and use multiple handle blocks pointing at the same upstream.
# /api/paste covers `arc share create` (POST to bare /api/paste);
# /api/paste/* covers GET/PUT/append against an existing share id.
@assets path /_app/* /api/paste /api/paste/* /robots.txt
@share path_regexp ^/share/[^/]+$

handle @assets {
# arc-paste listens on the internal Docker network — the service
# name resolves via Compose's embedded DNS.
reverse_proxy arc-paste:7433
}
handle @share {
reverse_proxy arc-paste:7433
}

handle {
respond "Not found" 404
}

# Structured access logs to stdout so `docker compose logs caddy`
# stays parseable by jq / log shippers.
Expand Down
29 changes: 22 additions & 7 deletions arc-paste/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"database/sql"
"fmt"
"log"
"net/http"
"os"
"path/filepath"

Expand Down Expand Up @@ -54,9 +55,13 @@ func run() error {
return fmt.Errorf("apply migrations: %w", err)
}

store := pastesqlite.New(db)
handlers := paste.NewHandlers(store)
handlers := paste.NewHandlers(pastesqlite.New(db))
return newRouter(handlers).Start(addr)
}

// newRouter wires the arc-paste HTTP surface. Extracted so tests can exercise
// route precedence (/, /api/v1/*, SPA fallback) without binding a real listener.
func newRouter(handlers *paste.Handlers) *echo.Echo {
e := echo.New()
e.HideBanner = true
e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
Expand All @@ -72,13 +77,23 @@ func run() error {
e.Use(middleware.Recover())
e.Use(middleware.CORS())

// Mount paste handlers at /api/paste
handlers.Register(e.Group("/api/paste"))
// arc-paste only owns /api/paste/*. The arc SPA shell probes /api/v1/* on
// boot; without this guard those probes fall through to the SPA fallback
// and return HTML, which the browser then fails to JSON.parse.
e.Any("/api/v1/*", func(c echo.Context) error {
return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
})

// Serve embedded SPA with fallback to index.html for routing
web.RegisterSPA(e)
// Paste deployments don't serve the arc app shell — the only useful
// destination is /share/<id>. Mirrors the Caddy edge config so dev and
// prod behave the same.
e.Any("/", func(c echo.Context) error {
return c.String(http.StatusNotFound, "Not found")
})

return e.Start(addr)
handlers.Register(e.Group("/api/paste"))
web.RegisterSPA(e)
return e
}

// envOr returns the value of env variable key, or defaultVal if not set.
Expand Down
57 changes: 50 additions & 7 deletions arc-paste/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,29 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/labstack/echo/v4"
"github.com/sentiolabs/arc/internal/paste"
pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite"
_ "modernc.org/sqlite"
)

func TestArcPasteCreate(t *testing.T) {
db, _ := sql.Open("sqlite", ":memory:")
defer db.Close()
_ = pastesqlite.Apply(context.Background(), db)
e := echo.New()
paste.NewHandlers(pastesqlite.New(db)).Register(e.Group("/api/paste"))
func newTestRouter(t *testing.T) http.Handler {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := pastesqlite.Apply(context.Background(), db); err != nil {
t.Fatalf("migrate: %v", err)
}
return newRouter(paste.NewHandlers(pastesqlite.New(db)))
}

func TestArcPasteCreate(t *testing.T) {
e := newTestRouter(t)
body, _ := json.Marshal(map[string]any{
"plan_blob": []byte{1, 2, 3},
"plan_iv": []byte{4, 5, 6},
Expand All @@ -35,3 +43,38 @@ func TestArcPasteCreate(t *testing.T) {
t.Fatalf("expected 201, got %d", rec.Code)
}
}

// arc-paste only owns /api/paste/*. The arc SPA shell calls /api/v1/projects
// on boot; without an explicit guard those requests fall through to the SPA
// fallback and return HTML, which the browser fails to JSON.parse. Return a
// clean JSON 404 so the SPA gets a parseable error instead of a crash.
func TestArcPasteRejectsArcAPIProbes(t *testing.T) {
e := newTestRouter(t)
for _, path := range []string{"/api/v1/projects", "/api/v1/workspaces", "/api/v1/anything/nested"} {
rec := httptest.NewRecorder()
e.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
if rec.Code != http.StatusNotFound {
t.Errorf("%s: expected 404, got %d", path, rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("%s: expected JSON content-type, got %q", path, ct)
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Errorf("%s: body is not JSON: %v (%q)", path, err, rec.Body.String())
}
}
}

// arc-paste deployments don't serve the arc app shell — the only useful
// destination is /share/<id>. Returning 404 at the root mirrors the Caddy
// edge configuration so dev (localhost) and prod (arcpaste.company.com)
// behave identically.
func TestArcPasteRootIs404(t *testing.T) {
e := newTestRouter(t)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 at /, got %d (body=%q)", rec.Code, rec.Body.String())
}
}
4 changes: 2 additions & 2 deletions cmd/arc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ type Config struct {
// 4. `git config user.name`
ShareAuthor string `json:"share_author,omitempty"`
// ShareServer is the default URL for the remote paste server used by
// `arc share create --share`. Lets users persistently target a private
// `arc share create --remote`. Lets users persistently target a private
// arc-paste deployment instead of the public default.
// Resolution precedence in `arc share create --share`:
// Resolution precedence in `arc share create --remote`:
// 1. --server flag (highest)
// 2. this config field
// 3. $ARC_SHARE_SERVER
Expand Down
31 changes: 20 additions & 11 deletions cmd/arc/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ var shareDeleteCmd = &cobra.Command{
}

var (
shareCreateLocal bool
shareCreateRemote bool
shareCreateServer string
shareCreateAuthor string
Expand All @@ -201,8 +200,10 @@ var (
)

func init() {
shareCreateCmd.Flags().BoolVar(&shareCreateLocal, "local", false, "Use the local arc-server")
shareCreateCmd.Flags().BoolVar(&shareCreateRemote, "share", false, "Use the configured remote share server")
shareCreateCmd.Flags().BoolVar(&shareCreateRemote, "remote", false,
"Use the configured remote share server (precedence: --server flag > share_server in "+
"cli-config.json > $ARC_SHARE_SERVER > built-in default). Without --remote, --server, "+
"or an explicit URL, the share is created on the local arc-server.")
shareCreateCmd.Flags().StringVar(&shareCreateServer, "server", "",
"Server URL override (precedence: flag > share_server in cli-config.json > "+
"$ARC_SHARE_SERVER > built-in default).")
Expand Down Expand Up @@ -234,7 +235,7 @@ func runShareCreate(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
server, kind := resolveServer(shareCreateLocal, shareCreateRemote, shareCreateServer)
server, kind := resolveServer(shareCreateRemote, shareCreateServer)
key, err := paste.GenerateKey()
if err != nil {
return err
Expand Down Expand Up @@ -279,10 +280,17 @@ func runShareCreate(cmd *cobra.Command, args []string) error {
return err
}
trimmedServer := strings.TrimRight(server, "/")
fmt.Printf("Share URL (send to reviewers):\n %s/share/%s#k=%s\n\n",
trimmedServer, resp.ID, keyB64)
fmt.Printf("Author URL (keep private — gives you Accept/Resolve):\n %s/share/%s#k=%s&t=%s\n\n",
trimmedServer, resp.ID, keyB64, resp.EditToken)
authorURL := fmt.Sprintf("%s/share/%s#k=%s&t=%s", trimmedServer, resp.ID, keyB64, resp.EditToken)
// Reviewer URL is intentionally NOT printed — copy-pasting the wrong line
// would hand a recipient author privileges (the &t=<editToken> grants
// Accept/Resolve/Reject). Authors get a reviewer URL by opening the link
// below and clicking the in-page "Share link" button, which strips &t=.
if kind == shareKindLocal {
fmt.Printf("Preview URL (local-only — not reachable by others):\n %s\n\n", authorURL)
} else {
fmt.Printf("Author URL (keep private — open it, then use the in-page "+
"Share link button to copy a reviewer URL):\n %s\n\n", authorURL)
}
fmt.Println("Edit token saved to the local arc keyring")
return nil
}
Expand Down Expand Up @@ -489,12 +497,13 @@ func resolveAuthor(flag string) string {
//
// For local mode, the server URL comes from `server_url` in the CLI config
// (defaulting to http://localhost:7432). The `--server` flag still wins over
// everything in either mode.
func resolveServer(_, share bool, override string) (server, kind string) {
// everything in either mode — passing a URL there forces shared mode regardless
// of `--remote`.
func resolveServer(remote bool, override string) (server, kind string) {
if s := strings.TrimSpace(override); s != "" {
return s, shareKindShared
}
if share {
if remote {
return resolveShareServer(), shareKindShared
}
return cliConfigServerURL(), shareKindLocal
Expand Down
Loading
Loading