diff --git a/.gitignore b/.gitignore index a3bca706..3f7d3f99 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ AGENTS.md # Release artifacts dist/ +stage/ !internal/server/dist/ !internal/server/dist/.gitkeep web/tsconfig.tsbuildinfo diff --git a/Makefile b/Makefile index 273bb54c..f66b317f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ SHELL := /bin/bash +-include .env +export + # Resolve each tool from $PATH first, then fall back to its common install # location. Override any of them explicitly, e.g. `make GO=/opt/go/bin/go build`. # `tool ` = the binary on PATH if present, else the fallback. @@ -30,7 +33,7 @@ help: ## Show this help .PHONY: dev dev: ## Run backend (Air hot reload) and frontend (Vite HMR) together - @echo "Antares dev: API :8787 · Web :5173" + @echo "Antares dev: API :8790 · Web :5174" @trap 'kill 0' EXIT INT TERM; \ $(MAKE) dev-api & \ $(MAKE) dev-web & \ @@ -42,7 +45,7 @@ dev-api: ## Backend with hot reload .PHONY: dev-web dev-web: ## Frontend dev server with HMR - @cd web && $(BUN) x vite + @cd web && $(BUN) x vite --port 5174 .PHONY: tui tui: ## Hot-reload the TUI preview in the foreground (real terminal) diff --git a/cmd/antares/device_test.go b/cmd/antares/device_test.go index 0fe6f7dc..c944e5ca 100644 --- a/cmd/antares/device_test.go +++ b/cmd/antares/device_test.go @@ -16,6 +16,10 @@ func TestDevicePairCLI(t *testing.T) { t.Setenv("ANTARES_HOME", home) t.Setenv("ANTARES_CONFIG", "") t.Setenv("ANTARES_PORT", "18999") + if _, err := config.Reload(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = config.Reload() }) var out bytes.Buffer if err := cmdDevicePair(&out, []string{"--name", "Test Mac", "--platform=desktop-macos", "--json"}); err != nil { diff --git a/desktop/main.go b/desktop/main.go index 9116240e..be839b2a 100644 --- a/desktop/main.go +++ b/desktop/main.go @@ -23,7 +23,7 @@ import ( ) // Version is the desktop shell's version (set with -ldflags -X main.Version=…). -var Version = "0.1.0" +var Version = "0.11.0" //go:embed all:frontend var frontendFS embed.FS diff --git a/desktop/shell.go b/desktop/shell.go index 93bfef38..4425c7f3 100644 --- a/desktop/shell.go +++ b/desktop/shell.go @@ -3,6 +3,7 @@ package main import ( "context" "net/url" + "os/exec" "runtime" "strings" "sync" @@ -94,7 +95,7 @@ func (s *Shell) onPageMessage(msg string, origin *application.OriginInfo) { s.app.Logger.Debug("page message", "msg", msg, "origin", origin.Origin) from := originOf(origin.Origin) bundled := strings.HasPrefix(from, "wails://") - dashboard := from != "" && from == s.mgr.CurrentOrigin() + dashboard := from != "" && isSameDashboardOrigin(from, s.mgr.CurrentOrigin()) if !bundled && !dashboard { return } @@ -107,13 +108,36 @@ func (s *Shell) onPageMessage(msg string, origin *application.OriginInfo) { } switch u.Scheme { case "http", "https", "mailto": - _ = s.app.Browser.OpenURL(u.String()) + if err := s.app.Browser.OpenURL(u.String()); err != nil { + s.app.Logger.Warn("app.Browser.OpenURL failed, trying system open", "err", err) + openWithSystemDefault(u.String()) + } else { + // Fallback on Linux if WebKit/Wails browser fails silently + openWithSystemDefault(u.String()) + } } case strings.HasPrefix(msg, "antares:path:") && dashboard: s.mgr.PagePath(origin.Origin, strings.TrimPrefix(msg, "antares:path:")) } } +func isSameDashboardOrigin(a, b string) bool { + if a == b { + return true + } + ua, errA := url.Parse(a) + ub, errB := url.Parse(b) + if errA != nil || errB != nil { + return false + } + if ua.Port() != ub.Port() { + return false + } + isLoopbackA := ua.Hostname() == "127.0.0.1" || ua.Hostname() == "localhost" + isLoopbackB := ub.Hostname() == "127.0.0.1" || ub.Hostname() == "localhost" + return isLoopbackA && isLoopbackB +} + func originOf(raw string) string { u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { @@ -122,6 +146,21 @@ func originOf(raw string) string { return strings.ToLower(u.Scheme + "://" + u.Host) } +func openWithSystemDefault(targetURL string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "linux": + cmd = exec.Command("xdg-open", targetURL) + case "darwin": + cmd = exec.Command("open", targetURL) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", targetURL) + } + if cmd != nil { + _ = cmd.Start() + } +} + // ---- window, menu, tray ---- func (s *Shell) showWindow() { diff --git a/dev.sh b/dev.sh index cab34726..23be6d7a 100755 --- a/dev.sh +++ b/dev.sh @@ -4,6 +4,16 @@ set -e ROOT="$(cd "$(dirname "$0")" && pwd)" cd "$ROOT" +if [ -f "$ROOT/.env" ]; then + set -a + source "$ROOT/.env" + set +a +fi + +if [ -n "$ANTARES_HOME" ]; then + mkdir -p "$ANTARES_HOME" +fi + # Colors GREEN='\033[0;32m' YELLOW='\033[0;33m' @@ -33,7 +43,7 @@ if [ ! -x "$BUN" ]; then fi echo -e "${CYAN}Antares Dev${NC}" -echo -e " Backend: ${GREEN}http://localhost:8787${NC}" +echo -e " Backend: ${GREEN}http://localhost:${ANTARES_PORT:-8790}${NC}" echo -e " Frontend: ${GREEN}http://localhost:${VITE_PORT:-5174}${NC}" echo -e " ${YELLOW}Open http://localhost:${VITE_PORT:-5174} in your browser${NC}" echo "" diff --git a/docs/screenshots/01-academic-draft-preview.png b/docs/screenshots/01-academic-draft-preview.png new file mode 100644 index 00000000..94139601 Binary files /dev/null and b/docs/screenshots/01-academic-draft-preview.png differ diff --git a/docs/screenshots/02-gdocs-oauth-settings.png b/docs/screenshots/02-gdocs-oauth-settings.png new file mode 100644 index 00000000..ad01f275 Binary files /dev/null and b/docs/screenshots/02-gdocs-oauth-settings.png differ diff --git a/docs/screenshots/03-latex-editor.png b/docs/screenshots/03-latex-editor.png new file mode 100644 index 00000000..bf061258 Binary files /dev/null and b/docs/screenshots/03-latex-editor.png differ diff --git a/go.mod b/go.mod index 7f46968e..b66c0299 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ go 1.26.3 replace github.com/coder/hnsw => github.com/tradik/hnsw v0.6.2-0.20260824091751-ad04fee59f41 require ( + github.com/alecthomas/chroma/v2 v2.20.0 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 @@ -13,6 +14,8 @@ require ( github.com/charmbracelet/x/ansi v0.11.6 github.com/chromedp/chromedp v0.16.0 github.com/coder/hnsw v0.6.2-0.20260622133054-36cab6028fed + github.com/coder/websocket v1.8.15 + github.com/creack/pty v1.1.24 github.com/emersion/go-imap v1.2.1 github.com/enowdev/cloak-go v0.2.2-0.20260802105213-db0721c9e2e8 github.com/jackc/pgx/v5 v5.10.0 @@ -20,6 +23,8 @@ require ( github.com/muesli/termenv v0.16.0 github.com/pkg/sftp v1.13.9 github.com/sardanioss/httpcloak v1.6.8 + github.com/yuin/goldmark v1.7.13 + github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.54.0 golang.org/x/net v0.56.0 golang.org/x/term v0.45.0 @@ -28,7 +33,6 @@ require ( ) require ( - github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -84,9 +88,7 @@ require ( github.com/viterin/partial v1.1.0 // indirect github.com/viterin/vek v0.4.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - github.com/zalando/go-keyring v0.2.8 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/sync v0.22.0 // indirect diff --git a/go.sum b/go.sum index acfc2f9c..1a7e95ea 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,10 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -166,6 +170,8 @@ github.com/sardanioss/utls v1.10.3 h1:b1HU1+owWCUhfLUk0mk2mOgepL14rmthbA1PS8XGQX github.com/sardanioss/utls v1.10.3/go.mod h1:3sXK05Ir31HiMGINYV1uMDFRv/z9JL+QY9ITi/WEbV0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go index 3eb0ddd6..92738e69 100644 --- a/internal/agent/prompt.go +++ b/internal/agent/prompt.go @@ -122,9 +122,51 @@ You are running as a worker for another agent. Nobody is watching your stream. "Keep replies reasonably short for a chat; do not emit raw HTML.\n") } + if cfg.Research.Zotero.APIKey != "" || cfg.Research.GDocs.WebhookURL != "" || cfg.Research.GDocs.DocumentURL != "" { + b.WriteString("\n## Connected Academic & Document Integrations\n\n") + if cfg.Research.Zotero.APIKey != "" { + fmt.Fprintf(&b, "- Zotero Library: Connected (User ID: %s). You have access to the user's Zotero collections and can format citations using zotero_collections, zotero_search, and zotero_cite.\n", cfg.Research.Zotero.UserID) + } + if cfg.Research.GDocs.WebhookURL != "" || cfg.Research.GDocs.DocumentURL != "" { + targetDoc := cfg.Research.GDocs.DocumentURL + if targetDoc == "" { + targetDoc = cfg.Research.GDocs.WebhookURL + } + fmt.Fprintf(&b, "- Google Docs: Connected (Target document/bridge: %s).\n", targetDoc) + b.WriteString(" * CRITICAL: ALWAYS use `gdocs_read` to inspect document contents and `gdocs_update` to write text, headings, or citations.\n") + b.WriteString(" * CRITICAL TEMPLATE DISCIPLINE: DO NOT overwrite or wipe entire chapters. When a template already provides chapter/subchapter headings (e.g. 3.1, 3.2) or template placeholder guides, ALWAYS preserve the headings and structure! Read the template using `gdocs_read` (which displays both headings and template tables in Markdown format), then fill each section in-place using `find_text` with `position: 'after'` or targeted placeholder replacement.\n") + b.WriteString(" * TABLES: When the template includes a table (indicated by `` in `gdocs_read`), examine the required columns and format, and use `gdocs_update` with `table: {rows, columns, data}` to create or populate matching formal tables.\n") + b.WriteString(" * UNDO / ROLLBACK: If a mistake is made during editing, immediately use `gdocs_rollback` to restore the previous content from the automatic snapshot.\n") + b.WriteString(" * When inserting citations with `gdocs_update`, pass `zotero_items` (or `item_keys`) so Antares embeds official Zotero Named Ranges (compatible with the Zotero Google Docs extension and automated bibliography generation).\n") + b.WriteString(" * NEVER use the `browser` tool for Google Docs. Google Docs renders inside an inaccessible Canvas/WebGL surface where browser DOM manipulation and typing will fail.\n") + } + b.WriteString("\n## Academic Writing & Journal Standards (LaTeX & Google Docs)\n\n") + b.WriteString("- Document Drafting & Two-Way Sync (Draft-First Pattern):\n") + b.WriteString(" * Whenever asked to write, draft, or revise a chapter (e.g. Bab 3 Bilik), report, or academic essay, ALWAYS use the `draft_update` tool! This tool directly writes to the live 'Draf Naskah' tab in the user's Split View.\n") + b.WriteString(" * Format the draft in clean, structured Markdown with headings (`#`, `##`), academic tables (`| Col 1 | Col 2 |`), and inline SVG flowcharts/diagrams.\n") + b.WriteString(" * The user can inspect the live rendered draft in their Split View, make manual adjustments, and click 'Ekspor ke Google Docs' to generate a native Google Doc on Google Drive with one click!\n") + b.WriteString(" * Two-way sync: Users can Push updates from Draft to Google Docs or Pull latest edits made by team members directly from Google Docs into the local Draft.\n") + b.WriteString(" * Use `draft_read` to inspect the current draft content before making modular revisions.\n") + b.WriteString("- Standard Typography: Use formal academic style (Times New Roman, 12pt, Justified alignment).\n") + b.WriteString("- Figures & Flowcharts:\n") + b.WriteString(" * For SVG diagrams in drafts, use clean academic styling: transparent or clean white background (`background: transparent`), clean dark/slate lines, legible fonts, and NO dark sci-fi cards or heavy artificial bezels so the diagram blends seamlessly into white paper and Google Docs.\n") + b.WriteString(" * Always place an explicit figure placeholder indicating what illustration or diagram to insert and where.\n") + b.WriteString(" * Always place the caption/legend BELOW the figure (e.g. `Gambar 2.1: Flowchart Metodologi Penelitian` or `Figure 2.1: System Architecture`).\n") + b.WriteString(" * In LaTeX, use standard figure environments: `\\begin{figure}[htbp] \\centering ... \\caption{...} \\label{fig:...} \\end{figure}`.\n") + b.WriteString("- Tables: Always place the caption/title ABOVE the table (e.g. `Tabel 2.1: Hasil Pengujian Akurasi` or `Table 2.1: Accuracy Metrics`).\n") + b.WriteString("- Mathematical Formulas & Equations:\n") + b.WriteString(" * In LaTeX, use native numbered equations (`\\begin{equation} ... \\end{equation}`) or inline math (`$...$`).\n") + b.WriteString("- Citations & Bibliography:\n") + b.WriteString(" * For LaTeX documents: Use `\\cite{key}` for in-text citations and maintain `references.bib` using BibTeX format (fetch BibTeX directly from Zotero using `zotero_cite` with `format=\"bibtex\"`).\n") + b.WriteString(" * For Google Docs: Use `gdocs_update` with `zotero_items` to insert official Zotero Named Ranges and formatted bibliography entries with hanging indent.\n") + } + if len(active) > 0 { b.WriteString("\n## Tool notes\n\n") b.WriteString("- Paths given to file tools are relative to the workspace; you cannot read outside it.\n") + if hasTool(active, "gdocs_update") || hasTool(active, "gdocs_read") { + b.WriteString("- Google Docs: When asked to read, write, edit, or update Google Docs, you MUST use gdocs_read and gdocs_update. Do NOT attempt to navigate to Google Docs using the browser tool.\n") + } if hasTool(active, "read_file") || hasTool(active, "edit_file") { // Harness guidance for the read → edit loop. The two tools share // one contract — what a read returns is what an edit can find — so diff --git a/internal/config/config.go b/internal/config/config.go index a07779ad..a6559a50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,8 @@ type Config struct { Social Social `yaml:"social" json:"social"` + Research Research `yaml:"research" json:"research"` + // inline*FromEnv record that model.base_url / model.api_key were supplied by // ANTARES_BASE_URL / ANTARES_API_KEY rather than read from config.yaml. // ResolveProvider honours an env-supplied credential over a named provider's @@ -74,6 +76,28 @@ type Social struct { AutopilotEnabled bool `yaml:"autopilot_enabled" json:"autopilot_enabled"` } +// Research configures academic & research integrations: Zotero and Google Docs. +type Research struct { + Zotero ZoteroConfig `yaml:"zotero" json:"zotero"` + GDocs GDocsConfig `yaml:"gdocs" json:"gdocs"` +} + +type ZoteroConfig struct { + UserID string `yaml:"user_id" json:"user_id"` + APIKey string `yaml:"api_key" json:"api_key"` +} + +type GDocsConfig struct { + Method string `yaml:"method" json:"method"` // "webhook" or "oauth" + WebhookURL string `yaml:"webhook_url" json:"webhook_url"` + DocumentURL string `yaml:"document_url" json:"document_url"` + DocumentID string `yaml:"document_id" json:"document_id"` + OAuthClientID string `yaml:"oauth_client_id" json:"oauth_client_id"` + OAuthClientSecret string `yaml:"oauth_client_secret" json:"oauth_client_secret"` + OAuthAccessToken string `yaml:"-" json:"-"` + OAuthRefreshToken string `yaml:"-" json:"-"` +} + // Model selects which LLM answers by default. type Model struct { Default string `yaml:"default" json:"default"` diff --git a/internal/config/load.go b/internal/config/load.go index 2084c170..ea54689b 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -259,6 +259,13 @@ func applyEnv(c *Config) { str("TELEGRAM_BOT_TOKEN", &c.Gateway.Telegram.BotToken) str("DISCORD_BOT_TOKEN", &c.Gateway.Discord.BotToken) + str("ZOTERO_USER_ID", &c.Research.Zotero.UserID) + str("ZOTERO_API_KEY", &c.Research.Zotero.APIKey) + str("GDOCS_METHOD", &c.Research.GDocs.Method) + str("GDOCS_WEBHOOK_URL", &c.Research.GDocs.WebhookURL) + str("GDOCS_OAUTH_CLIENT_ID", &c.Research.GDocs.OAuthClientID) + str("GDOCS_OAUTH_CLIENT_SECRET", &c.Research.GDocs.OAuthClientSecret) + // Provider API keys: explicit ANTARES_PROVIDER__API_KEY wins, then the // provider's declared api_key_env, then conventional vendor variables. for name, p := range c.Providers { diff --git a/internal/config/schema.go b/internal/config/schema.go index 232799ef..6102f5ae 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -85,6 +85,14 @@ var common = map[string]bool{ "display.max_live_reasoning_chars": true, "logging.level": true, "server.host": true, + + "research.zotero.user_id": true, + "research.zotero.api_key": true, + "research.gdocs.method": true, + "research.gdocs.webhook_url": true, + "research.gdocs.document_url": true, + "research.gdocs.oauth_client_id": true, + "research.gdocs.oauth_client_secret": true, } func tierFor(path string) string { @@ -110,6 +118,10 @@ var hidden = map[string]bool{ // express "absent" versus "empty" for display.modules. "display.modules": true, "display.preset": true, + + "research.gdocs.document_id": true, + "research.gdocs.oauth_access_token": true, + "research.gdocs.oauth_refresh_token": true, } var enums = map[string][]string{ @@ -122,9 +134,17 @@ var enums = map[string][]string{ "logging.level": {"debug", "info", "warn", "error"}, "tools.web_search.provider": {"browser", "brave", "tavily", "searxng", "none"}, "image_gen.reference_mode": {"edits", "generations"}, + "research.gdocs.method": {"webhook", "oauth"}, } var help = map[string]string{ + "research.zotero.user_id": "Your personal Zotero User ID (from zotero.org/settings/keys).", + "research.zotero.api_key": "Your Zotero API Key with library and file permissions.", + "research.gdocs.method": "Google Docs connection method: webhook (Google Apps Script) or oauth (Google Cloud).", + "research.gdocs.webhook_url": "Apps Script Webhook URL for Google Docs updates.", + "research.gdocs.document_url": "Active Google Docs URL to read and edit.", + "research.gdocs.oauth_client_id": "Google Cloud OAuth Client ID for Google Docs API.", + "research.gdocs.oauth_client_secret": "Google Cloud OAuth Client Secret for Google Docs API.", "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.", "model.provider": "Which entry under providers to call.", "model.auxiliary": "Cheaper model used for summarising and other background work.", @@ -392,9 +412,14 @@ func redactMap(m map[string]any, prefix string) { } } -func maskSecret(s string) string { +// MaskSecret returns a masked representation of a secret string. +func MaskSecret(s string) string { if len(s) <= 8 { return "••••••••" } return s[:4] + strings.Repeat("•", 8) + s[len(s)-4:] } + +func maskSecret(s string) string { + return MaskSecret(s) +} diff --git a/internal/docx/docx.go b/internal/docx/docx.go new file mode 100644 index 00000000..e60f2c07 --- /dev/null +++ b/internal/docx/docx.go @@ -0,0 +1,640 @@ +package docx + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "regexp" + "strconv" + "strings" +) + +// Document represents an in-memory Microsoft Word (.docx) document. +type Document struct { + Title string + Content string // Markdown/HTML content +} + +// Generate creates a valid .docx ZIP archive as a byte buffer. +func Generate(title, markdownContent string) ([]byte, error) { + buf := new(bytes.Buffer) + zw := zip.NewWriter(buf) + + // Collect embedded base64/data-uri images from markdown + images, cleanedContent := extractEmbeddedImages(markdownContent) + + // 1. [Content_Types].xml + if err := writeZipFile(zw, "[Content_Types].xml", buildContentTypesXML(len(images))); err != nil { + return nil, err + } + + // 2. _rels/.rels + if err := writeZipFile(zw, "_rels/.rels", buildRootRelsXML()); err != nil { + return nil, err + } + + // 3. word/_rels/document.xml.rels + if err := writeZipFile(zw, "word/_rels/document.xml.rels", buildDocumentRelsXML(len(images))); err != nil { + return nil, err + } + + // 4. word/styles.xml + if err := writeZipFile(zw, "word/styles.xml", buildStylesXML()); err != nil { + return nil, err + } + + // 5. word/media/image{N}.png + for i, imgData := range images { + imgName := fmt.Sprintf("word/media/image%d.png", i+1) + if err := writeZipFile(zw, imgName, string(imgData.Bytes)); err != nil { + return nil, err + } + } + + // 6. word/document.xml + docXML := buildDocumentXML(title, cleanedContent, images) + if err := writeZipFile(zw, "word/document.xml", docXML); err != nil { + return nil, err + } + + if err := zw.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +type embeddedImage struct { + ID string + RelID string + Bytes []byte + Width int64 // EMU (1 pt = 12700 EMU, 1 px = 9525 EMU) + Height int64 +} + +var ( + dataURIPattern = regexp.MustCompile(`data:image\/(?:png|jpeg|jpg|gif|webp);base64,([A-Za-z0-9+/=]+)`) + imgTagPattern = regexp.MustCompile(`]*src=["'](data:image\/[^"']+)["'][^>]*>`) + mdImgPattern = regexp.MustCompile(`!\[([^\]]*)\]\((data:image\/[^\)]+)\)`) +) + +func extractEmbeddedImages(content string) ([]embeddedImage, string) { + var images []embeddedImage + + // Replace HTML with token __DOCX_IMG_{N}__ + replaced := imgTagPattern.ReplaceAllStringFunc(content, func(m string) string { + sub := dataURIPattern.FindStringSubmatch(m) + if len(sub) < 2 { + return "" + } + raw, err := base64.StdEncoding.DecodeString(sub[1]) + if err != nil { + return "" + } + idx := len(images) + 1 + cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)) + wEMU := int64(500 * 9525) + hEMU := int64(300 * 9525) + if err == nil && cfg.Width > 0 && cfg.Height > 0 { + // Cap width at 540 pt (approx 6.8 inches) + wPt := cfg.Width + hPt := cfg.Height + if wPt > 540 { + hPt = int(float64(hPt) * 540.0 / float64(wPt)) + wPt = 540 + } + wEMU = int64(wPt) * 9525 + hEMU = int64(hPt) * 9525 + } + images = append(images, embeddedImage{ + ID: fmt.Sprintf("img%d", idx), + RelID: fmt.Sprintf("rIdImg%d", idx), + Bytes: raw, + Width: wEMU, + Height: hEMU, + }) + return fmt.Sprintf("\n__DOCX_IMG_%d__\n", idx) + }) + + // Replace Markdown ![alt](data:image/...) with token __DOCX_IMG_{N}__ + replaced = mdImgPattern.ReplaceAllStringFunc(replaced, func(m string) string { + sub := dataURIPattern.FindStringSubmatch(m) + if len(sub) < 2 { + return "" + } + raw, err := base64.StdEncoding.DecodeString(sub[1]) + if err != nil { + return "" + } + idx := len(images) + 1 + cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)) + wEMU := int64(500 * 9525) + hEMU := int64(300 * 9525) + if err == nil && cfg.Width > 0 && cfg.Height > 0 { + wPt := cfg.Width + hPt := cfg.Height + if wPt > 540 { + hPt = int(float64(hPt) * 540.0 / float64(wPt)) + wPt = 540 + } + wEMU = int64(wPt) * 9525 + hEMU = int64(hPt) * 9525 + } + images = append(images, embeddedImage{ + ID: fmt.Sprintf("img%d", idx), + RelID: fmt.Sprintf("rIdImg%d", idx), + Bytes: raw, + Width: wEMU, + Height: hEMU, + }) + return fmt.Sprintf("\n__DOCX_IMG_%d__\n", idx) + }) + + return images, replaced +} + +func writeZipFile(zw *zip.Writer, name, content string) error { + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = io.WriteString(w, content) + return err +} + +func xmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "\"", """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +func buildContentTypesXML(numImages int) string { + var sb strings.Builder + sb.WriteString(` + + + + + + + + +`) + return sb.String() +} + +func buildRootRelsXML() string { + return ` + + +` +} + +func buildDocumentRelsXML(numImages int) string { + var sb strings.Builder + sb.WriteString(` + + +`) + for i := 1; i <= numImages; i++ { + sb.WriteString(fmt.Sprintf(` `+"\n", i, i)) + } + sb.WriteString(``) + return sb.String() +} + +func buildStylesXML() string { + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` +} + +func buildDocumentXML(title, markdown string, images []embeddedImage) string { + var body strings.Builder + + lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n") + + inTable := false + var tableRows [][]string + + flushTable := func() { + if !inTable || len(tableRows) == 0 { + inTable = false + tableRows = nil + return + } + body.WriteString(renderTableXML(tableRows)) + inTable = false + tableRows = nil + } + + for i := 0; i < len(lines); i++ { + raw := lines[i] + trimmed := strings.TrimSpace(raw) + + if trimmed == "" { + if inTable { + flushTable() + } + continue + } + + // Table row detection + if strings.Contains(raw, "|") && i+1 < len(lines) && isTableSep(lines[i+1]) { + if inTable { + flushTable() + } + inTable = true + tableRows = append(tableRows, parseTableRow(raw)) + i++ // skip separator line + for i+1 < len(lines) && strings.Contains(lines[i+1], "|") && strings.TrimSpace(lines[i+1]) != "" { + i++ + tableRows = append(tableRows, parseTableRow(lines[i])) + } + flushTable() + continue + } + + if inTable { + flushTable() + } + + // Embedded image token: __DOCX_IMG_{N}__ + if strings.HasPrefix(trimmed, "__DOCX_IMG_") && strings.HasSuffix(trimmed, "__") { + numStr := strings.TrimSuffix(strings.TrimPrefix(trimmed, "__DOCX_IMG_"), "__") + idx, err := strconv.Atoi(numStr) + if err == nil && idx > 0 && idx <= len(images) { + img := images[idx-1] + body.WriteString(renderImageXML(img)) + continue + } + } + + // Headings + if strings.HasPrefix(trimmed, "# ") { + text := strings.TrimSpace(strings.TrimPrefix(trimmed, "# ")) + body.WriteString(renderHeadingXML(text, "Heading1")) + continue + } + if strings.HasPrefix(trimmed, "## ") { + text := strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")) + body.WriteString(renderHeadingXML(text, "Heading2")) + continue + } + if strings.HasPrefix(trimmed, "### ") { + text := strings.TrimSpace(strings.TrimPrefix(trimmed, "### ")) + body.WriteString(renderHeadingXML(text, "Heading3")) + continue + } + + // List Item: bullet / ordered + listMatch := regexp.MustCompile(`^(\s*)([-*+]|\d+[.)])\s+(.*)$`).FindStringSubmatch(raw) + if len(listMatch) == 4 { + indent := len(strings.ReplaceAll(listMatch[1], "\t", " ")) + level := indent / 2 + isNum := regexp.MustCompile(`\d`).MatchString(listMatch[2]) + marker := listMatch[2] + if !isNum { + marker = "•" + } + body.WriteString(renderListItemXML(marker, listMatch[3], level)) + continue + } + + // Caption check: Gambar/Tabel + isCaption := regexp.MustCompile(`^\*?(Gambar|Figure|Tabel|Table)\s+\d+`).MatchString(trimmed) + if isCaption { + body.WriteString(renderCaptionXML(trimmed)) + continue + } + + // Standard Paragraph (Justified, Indented 8 spaces = ~720 dxa) + body.WriteString(renderParagraphXML(trimmed, true)) + } + + if inTable { + flushTable() + } + + return fmt.Sprintf(` + + + %s + + + + + + +`, body.String()) +} + +func isTableSep(s string) bool { + return regexp.MustCompile(`^\s*\|?[\s:|-]+\|[\s:|-]*$`).MatchString(s) +} + +func parseTableRow(s string) []string { + s = strings.TrimPrefix(strings.TrimSpace(s), "|") + s = strings.TrimSuffix(s, "|") + parts := strings.Split(s, "|") + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + return parts +} + +func renderHeadingXML(text, styleId string) string { + return fmt.Sprintf(` + + + + %s + `+"\n", styleId, formatRuns(text, true, false)) +} + +func renderParagraphXML(text string, indentFirstLine bool) string { + indentTag := "" + if indentFirstLine { + indentTag = `` + } + return fmt.Sprintf(` + + + + %s + + %s + `+"\n", indentTag, formatRuns(text, false, false)) +} + +func renderCaptionXML(text string) string { + cleaned := strings.Trim(text, "*_") + return fmt.Sprintf(` + + + + + + + + + + + + %s + + `+"\n", xmlEscape(cleaned)) +} + +func renderListItemXML(marker, text string, level int) string { + leftIndent := 720 + (level * 500) + hanging := 360 + return fmt.Sprintf(` + + + + + + + + + + %s + + %s + `+"\n", leftIndent, hanging, xmlEscape(marker), formatRuns(text, false, false)) +} + +func renderTableXML(rows [][]string) string { + if len(rows) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString(` + + + + + + + + + + + + + + + + + + +`) + + for rIdx, row := range rows { + isHeader := rIdx == 0 + sb.WriteString(" \n") + for _, cell := range row { + bgShade := "" + align := "left" + if isHeader { + bgShade = `` + align = "center" + } + sb.WriteString(fmt.Sprintf(` + + %s + + + + + + + + %s + + `+"\n", bgShade, align, formatRuns(cell, isHeader, false))) + } + sb.WriteString(" \n") + } + + sb.WriteString(" \n") + return sb.String() +} + +func renderImageXML(img embeddedImage) string { + return fmt.Sprintf(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `+"\n", img.Width, img.Height, img.RelID, img.Width, img.Height) +} + +// formatRuns parses basic inline bold/italic/code markdown and emits elements +func formatRuns(text string, forceBold, forceItalic bool) string { + var sb strings.Builder + + // Tokenize bold (**...**) and italic (*...*) + pattern := regexp.MustCompile(`(\*\*[^*]+\*\*|\*[^*]+\*|` + "`" + `[^` + "`" + `]+` + "`" + `)`) + parts := pattern.Split(text, -1) + matches := pattern.FindAllString(text, -1) + + for i := 0; i < len(parts); i++ { + if parts[i] != "" { + writeRun(&sb, parts[i], forceBold, forceItalic, false) + } + if i < len(matches) { + m := matches[i] + if strings.HasPrefix(m, "**") && strings.HasSuffix(m, "**") { + inner := strings.TrimPrefix(strings.TrimSuffix(m, "**"), "**") + writeRun(&sb, inner, true, forceItalic, false) + } else if strings.HasPrefix(m, "*") && strings.HasSuffix(m, "*") { + inner := strings.TrimPrefix(strings.TrimSuffix(m, "*"), "*") + writeRun(&sb, inner, forceBold, true, false) + } else if strings.HasPrefix(m, "`") && strings.HasSuffix(m, "`") { + inner := strings.TrimPrefix(strings.TrimSuffix(m, "`"), "`") + writeRun(&sb, inner, forceBold, forceItalic, true) + } + } + } + + return sb.String() +} + +func writeRun(sb *strings.Builder, text string, bold, italic, code bool) { + if text == "" { + return + } + font := "Times New Roman" + size := "24" + color := "000000" + if code { + font = "Courier New" + size = "20" + color = "334155" + } + + sb.WriteString(" \n \n") + sb.WriteString(fmt.Sprintf(` `+"\n", font, font)) + sb.WriteString(fmt.Sprintf(` `+"\n", size)) + sb.WriteString(fmt.Sprintf(` `+"\n", size)) + if color != "000000" { + sb.WriteString(fmt.Sprintf(` `+"\n", color)) + } + if bold { + sb.WriteString(" \n") + } + if italic { + sb.WriteString(" \n") + } + sb.WriteString(" \n") + sb.WriteString(fmt.Sprintf(` %s`+"\n", xmlEscape(text))) + sb.WriteString(" \n") +} diff --git a/internal/docx/docx_test.go b/internal/docx/docx_test.go new file mode 100644 index 00000000..bb7b6eed --- /dev/null +++ b/internal/docx/docx_test.go @@ -0,0 +1,86 @@ +package docx + +import ( + "archive/zip" + "bytes" + "io" + "strings" + "testing" +) + +func TestGenerateDOCX(t *testing.T) { + title := "Bab 3 - Metodologi Penelitian" + md := `# Bab 3 - Metodologi Penelitian + +## 3.1 Pendekatan Penelitian +Penelitian ini menggunakan pendekatan kuantitatif berbasis eksperimen. + +1. Pengumpulan Data Awal + - Wawancara mendalam + - Observasi lapangan +2. Implementasi Sistem + - Modul antarmuka + - Modul backend + +| Komponen | Spesifikasi | Keterangan | +| :--- | :---: | ---: | +| Server | 8 vCPU | Cloud instance | +| Database | PostgreSQL 16 | Distributed | + +*Tabel 1: Spesifikasi Lingkungan Pengujian* +` + + b, err := Generate(title, md) + if err != nil { + t.Fatalf("Generate failed: %v", err) + } + + if len(b) == 0 { + t.Fatal("generated docx is empty") + } + + // Verify it is a valid zip archive + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + t.Fatalf("failed to read generated docx as zip: %v", err) + } + + files := make(map[string]string) + for _, f := range zr.File { + rc, err := f.Open() + if err != nil { + t.Fatalf("failed to open zip entry %s: %v", f.Name, err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatalf("failed to read zip entry %s: %v", f.Name, err) + } + files[f.Name] = string(data) + } + + // Verify required OpenXML structure + required := []string{ + "[Content_Types].xml", + "_rels/.rels", + "word/_rels/document.xml.rels", + "word/styles.xml", + "word/document.xml", + } + for _, req := range required { + if _, ok := files[req]; !ok { + t.Fatalf("missing required docx part: %s", req) + } + } + + docXML := files["word/document.xml"] + if !strings.Contains(docXML, "Bab 3 - Metodologi Penelitian") { + t.Errorf("document.xml missing heading text") + } + if !strings.Contains(docXML, "") { + t.Errorf("document.xml missing table element") + } + if !strings.Contains(docXML, "Wawancara mendalam") { + t.Errorf("document.xml missing nested list text") + } +} diff --git a/internal/server/handlers_auth.go b/internal/server/handlers_auth.go index e8c8a8a1..8e5edd80 100644 --- a/internal/server/handlers_auth.go +++ b/internal/server/handlers_auth.go @@ -77,6 +77,9 @@ func (s *Server) requireDashboardPassword(w http.ResponseWriter, r *http.Request if s.bearerAuthorizedOrQuery(r) { return false } + if cfg.Server.AuthDisabled { + return false + } writeJSON(w, http.StatusPreconditionRequired, map[string]any{ "error": "dashboard_password_required", "message": "Set a dashboard password first — this stores or uses sensitive " + diff --git a/internal/server/handlers_editor_test.go b/internal/server/handlers_editor_test.go new file mode 100644 index 00000000..5696286b --- /dev/null +++ b/internal/server/handlers_editor_test.go @@ -0,0 +1,144 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/enowdev/antares/internal/config" +) + +func editorTestServer(t *testing.T) (*Server, string) { + t.Helper() + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + root := t.TempDir() + cfg.Agent.Workspace = root + cfg.Server.DashboardPasswordHash = "test-hash" + return &Server{cfg: cfg}, root +} + +func writeFileRequest(t *testing.T, s *Server, body map[string]any) *httptest.ResponseRecorder { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/files/write", bytes.NewReader(raw)) + rr := httptest.NewRecorder() + s.handleWriteFile(rr, req) + return rr +} + +func TestWriteFileSavesAndReturnsMtime(t *testing.T) { + s, root := editorTestServer(t) + path := filepath.Join(root, "main.go") + if err := os.WriteFile(path, []byte("package main\n"), 0o640); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + rr := writeFileRequest(t, s, map[string]any{ + "path": "main.go", "content": "package main\n\nfunc main() {}\n", + "expected_mtime": fi.ModTime().UnixMilli(), + }) + if rr.Code != http.StatusOK { + t.Fatalf("save status = %d, body = %s", rr.Code, rr.Body.String()) + } + if got, _ := os.ReadFile(path); string(got) != "package main\n\nfunc main() {}\n" { + t.Fatalf("file contents = %q", got) + } + if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 { + t.Fatalf("mode = %v, want 0640", info.Mode().Perm()) + } + var res map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil { + t.Fatal(err) + } + if _, ok := res["mtime"].(float64); !ok { + t.Fatalf("response has no numeric mtime: %v", res) + } +} + +func TestWriteFileRefusesStaleMtime(t *testing.T) { + s, root := editorTestServer(t) + path := filepath.Join(root, "stale.txt") + if err := os.WriteFile(path, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + rr := writeFileRequest(t, s, map[string]any{ + "path": "stale.txt", "content": "new", "expected_mtime": 1, + }) + if rr.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rr.Code) + } + if got, _ := os.ReadFile(path); string(got) != "old" { + t.Fatalf("stale save clobbered the file: %q", got) + } +} + +func TestWriteFileRejectsTraversalAndSymlinkEscape(t *testing.T) { + s, root := editorTestServer(t) + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(root, "link.txt")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + for _, path := range []string{"../secret.txt", filepath.Join(outside, "secret.txt"), "link.txt"} { + rr := writeFileRequest(t, s, map[string]any{"path": path, "content": "pwned"}) + if rr.Code != http.StatusBadRequest { + t.Fatalf("%s: status = %d, body = %s", path, rr.Code, rr.Body.String()) + } + } + if got, _ := os.ReadFile(filepath.Join(outside, "secret.txt")); string(got) != "secret" { + t.Fatalf("outside file was modified: %q", got) + } +} + +func TestWriteFileRejectsBinaryAndOversize(t *testing.T) { + s, root := editorTestServer(t) + path := filepath.Join(root, "a.txt") + if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + + if rr := writeFileRequest(t, s, map[string]any{"path": "a.txt", "content": "a\x00b"}); rr.Code != http.StatusBadRequest { + t.Fatalf("binary status = %d, want 400", rr.Code) + } + big := strings.Repeat("a", maxEditableFile+1) + if rr := writeFileRequest(t, s, map[string]any{"path": "a.txt", "content": big}); rr.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversize status = %d, want 413", rr.Code) + } +} + +func TestWriteFileRequiresPasswordAndExistingFile(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + cfg.Agent.Workspace = t.TempDir() + s := &Server{cfg: cfg} + + rr := writeFileRequest(t, s, map[string]any{"path": "x.txt", "content": "x"}) + if rr.Code != http.StatusPreconditionRequired { + t.Fatalf("unprotected status = %d, want 428", rr.Code) + } + + cfg.Server.DashboardPasswordHash = "test-hash" + rr = writeFileRequest(t, s, map[string]any{"path": "missing.txt", "content": "x"}) + if rr.Code != http.StatusNotFound { + t.Fatalf("missing file status = %d, want 404", rr.Code) + } + if _, err := os.Stat(filepath.Join(cfg.Agent.Workspace, "missing.txt")); !os.IsNotExist(err) { + t.Fatal("write created a new file") + } +} diff --git a/internal/server/handlers_files_test.go b/internal/server/handlers_files_test.go new file mode 100644 index 00000000..f1fab79c --- /dev/null +++ b/internal/server/handlers_files_test.go @@ -0,0 +1,175 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" +) + +func fileOpRequest(t *testing.T, s *Server, method, target string, body map[string]any) *httptest.ResponseRecorder { + t.Helper() + var reader *bytes.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + reader = bytes.NewReader(raw) + } else { + reader = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, target, reader) + rr := httptest.NewRecorder() + switch { + case target == "/api/files/create": + s.handleCreateFile(rr, req) + case target == "/api/files/rename": + s.handleRenameFile(rr, req) + case method == http.MethodDelete: + s.handleDeleteFile(rr, req) + default: + t.Fatalf("unhandled target %s", target) + } + return rr +} + +func TestCreateFileAndDirectory(t *testing.T) { + s, root := editorTestServer(t) + + rr := fileOpRequest(t, s, http.MethodPost, "/api/files/create", map[string]any{"path": "src", "type": "dir"}) + if rr.Code != http.StatusOK { + t.Fatalf("create dir status = %d, body = %s", rr.Code, rr.Body.String()) + } + if fi, err := os.Stat(filepath.Join(root, "src")); err != nil || !fi.IsDir() { + t.Fatalf("src was not created as a directory: %v", err) + } + + rr = fileOpRequest(t, s, http.MethodPost, "/api/files/create", map[string]any{"path": "src/main.go", "type": "file"}) + if rr.Code != http.StatusOK { + t.Fatalf("create file status = %d, body = %s", rr.Code, rr.Body.String()) + } + fi, err := os.Stat(filepath.Join(root, "src", "main.go")) + if err != nil { + t.Fatal(err) + } + if fi.IsDir() || fi.Mode().Perm() != 0o644 { + t.Fatalf("mode = %v, want regular 0644", fi.Mode()) + } + var res map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil { + t.Fatal(err) + } + if res["path"] != "src/main.go" { + t.Fatalf("path = %v, want src/main.go", res["path"]) + } + + if rr := fileOpRequest(t, s, http.MethodPost, "/api/files/create", map[string]any{"path": "src/main.go", "type": "file"}); rr.Code != http.StatusConflict { + t.Fatalf("duplicate status = %d, want 409", rr.Code) + } + if rr := fileOpRequest(t, s, http.MethodPost, "/api/files/create", map[string]any{"path": "missing/x.txt", "type": "file"}); rr.Code != http.StatusBadRequest { + t.Fatalf("missing parent status = %d, want 400", rr.Code) + } + if rr := fileOpRequest(t, s, http.MethodPost, "/api/files/create", map[string]any{"path": "x.txt", "type": "socket"}); rr.Code != http.StatusBadRequest { + t.Fatalf("bad type status = %d, want 400", rr.Code) + } + if rr := fileOpRequest(t, s, http.MethodPost, "/api/files/create", map[string]any{"path": "../escape.txt", "type": "file"}); rr.Code != http.StatusBadRequest { + t.Fatalf("traversal status = %d, want 400", rr.Code) + } +} + +func TestCreateRejectsSymlinkedParentEscape(t *testing.T) { + s, root := editorTestServer(t) + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(root, "out")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + rr := fileOpRequest(t, s, http.MethodPost, "/api/files/create", map[string]any{"path": "out/pwned.txt", "type": "file"}) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(filepath.Join(outside, "pwned.txt")); !os.IsNotExist(err) { + t.Fatalf("file escaped the workspace") + } +} + +func TestRenameFileAndDirectory(t *testing.T) { + s, root := editorTestServer(t) + if err := os.WriteFile(filepath.Join(root, "old.txt"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + rr := fileOpRequest(t, s, http.MethodPost, "/api/files/rename", map[string]any{"path": "old.txt", "name": "new.txt"}) + if rr.Code != http.StatusOK { + t.Fatalf("rename status = %d, body = %s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "new.txt")); err != nil { + t.Fatalf("new name missing: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) { + t.Fatalf("old name still exists") + } + + if err := os.Mkdir(filepath.Join(root, "dir"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "dir", "a.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if rr := fileOpRequest(t, s, http.MethodPost, "/api/files/rename", map[string]any{"path": "dir", "name": "renamed"}); rr.Code != http.StatusOK { + t.Fatalf("dir rename status = %d", rr.Code) + } + if _, err := os.Stat(filepath.Join(root, "renamed", "a.txt")); err != nil { + t.Fatalf("directory rename lost contents: %v", err) + } + + if rr := fileOpRequest(t, s, http.MethodPost, "/api/files/rename", map[string]any{"path": "new.txt", "name": "renamed"}); rr.Code != http.StatusConflict { + t.Fatalf("collision status = %d, want 409", rr.Code) + } + if rr := fileOpRequest(t, s, http.MethodPost, "/api/files/rename", map[string]any{"path": "new.txt", "name": "../escape.txt"}); rr.Code != http.StatusBadRequest { + t.Fatalf("separator name status = %d, want 400", rr.Code) + } + if rr := fileOpRequest(t, s, http.MethodPost, "/api/files/rename", map[string]any{"path": "missing.txt", "name": "x.txt"}); rr.Code != http.StatusNotFound { + t.Fatalf("missing source status = %d, want 404", rr.Code) + } +} + +func TestDeleteFileAndDirectory(t *testing.T) { + s, root := editorTestServer(t) + if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if rr := fileOpRequest(t, s, http.MethodDelete, "/api/files?path="+url.QueryEscape("a.txt"), nil); rr.Code != http.StatusOK { + t.Fatalf("delete file status = %d, body = %s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "a.txt")); !os.IsNotExist(err) { + t.Fatalf("file still exists") + } + + if err := os.MkdirAll(filepath.Join(root, "tree", "nested"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "tree", "nested", "x.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + target := "/api/files?path=" + url.QueryEscape("tree") + if rr := fileOpRequest(t, s, http.MethodDelete, target, nil); rr.Code != http.StatusConflict { + t.Fatalf("non-recursive delete status = %d, want 409", rr.Code) + } + if _, err := os.Stat(filepath.Join(root, "tree")); err != nil { + t.Fatalf("non-recursive delete removed the tree: %v", err) + } + if rr := fileOpRequest(t, s, http.MethodDelete, target+"&recursive=1", nil); rr.Code != http.StatusOK { + t.Fatalf("recursive delete status = %d, body = %s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "tree")); !os.IsNotExist(err) { + t.Fatalf("tree still exists") + } + + if rr := fileOpRequest(t, s, http.MethodDelete, "/api/files?path="+url.QueryEscape(".."), nil); rr.Code != http.StatusBadRequest { + t.Fatalf("root delete status = %d, want 400", rr.Code) + } +} diff --git a/internal/server/handlers_google_oauth.go b/internal/server/handlers_google_oauth.go new file mode 100644 index 00000000..96baddbc --- /dev/null +++ b/internal/server/handlers_google_oauth.go @@ -0,0 +1,302 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const googleOAuthTokenURL = "https://oauth2.googleapis.com/token" + +func getGoogleRedirectURI(r *http.Request) string { + // If custom query redirect_uri is passed, validate it points strictly to callback endpoint and legitimate host + if q := r.URL.Query().Get("redirect_uri"); q != "" { + if u, err := url.Parse(q); err == nil && (strings.HasSuffix(u.Path, "/api/auth/google/callback") || u.Path == "/api/auth/google/callback") { + host := u.Hostname() + reqHost := r.Host + if h, _, err := net.SplitHostPort(r.Host); err == nil { + reqHost = h + } + if host == "" || host == reqHost || host == "localhost" || host == "127.0.0.1" { + return q + } + } + } + // Prefer origin header from web frontend + if origin := r.Header.Get("Origin"); origin != "" { + if u, err := url.Parse(origin); err == nil && u.Host != "" { + proto := u.Scheme + if proto == "" { + proto = "http" + } + return fmt.Sprintf("%s://%s/api/auth/google/callback", proto, u.Host) + } + } + if referer := r.Header.Get("Referer"); referer != "" { + if u, err := url.Parse(referer); err == nil && u.Host != "" { + proto := u.Scheme + if proto == "" { + proto = "http" + } + return fmt.Sprintf("%s://%s/api/auth/google/callback", proto, u.Host) + } + } + // Prefer host from request header + host := r.Host + if host == "" { + host = "localhost:8787" + } + proto := "http" + if r.TLS != nil { + proto = "https" + } + return fmt.Sprintf("%s://%s/api/auth/google/callback", proto, host) +} + +func (s *Server) handleGoogleOAuthURL(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + cfg := s.config() + clientID := strings.TrimSpace(cfg.Research.GDocs.OAuthClientID) + if clientID == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("Google OAuth Client ID is not configured. Set it in Research Settings first.")) + return + } + + redirectURI := getGoogleRedirectURI(r) + scopes := []string{ + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.file", + } + + // Mint a cryptographically secure random state nonce to prevent OAuth CSRF/login manipulation + stateNonce := newID("gstate") + _ = s.db.SetKV(r.Context(), "research:oauth:state:"+stateNonce, redirectURI) + + // Encode both redirectURI and stateNonce securely in state parameter + stateVal := fmt.Sprintf("%s|%s", stateNonce, redirectURI) + + authURL := fmt.Sprintf( + "https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&access_type=offline&prompt=consent&state=%s", + url.QueryEscape(clientID), + url.QueryEscape(redirectURI), + url.QueryEscape(strings.Join(scopes, " ")), + url.QueryEscape(stateVal), + ) + + writeJSON(w, http.StatusOK, map[string]any{ + "url": authURL, + "redirect_uri": redirectURI, + }) +} + +func (s *Server) handleGoogleOAuthCallback(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + code := r.URL.Query().Get("code") + if code == "" { + errMsg := r.URL.Query().Get("error") + writeError(w, http.StatusBadRequest, fmt.Errorf("Google authorization denied or failed: %s", errMsg)) + return + } + + cfg := s.config() + clientID := strings.TrimSpace(cfg.Research.GDocs.OAuthClientID) + clientSecret := strings.TrimSpace(cfg.Research.GDocs.OAuthClientSecret) + if clientID == "" || clientSecret == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("Google OAuth Client ID or Secret is missing in config")) + return + } + + redirectURI := getGoogleRedirectURI(r) + stateParam := r.URL.Query().Get("state") + if stateParam == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("security violation: missing OAuth state parameter")) + return + } + parts := strings.SplitN(stateParam, "|", 2) + if len(parts) != 2 { + writeError(w, http.StatusBadRequest, fmt.Errorf("security violation: malformed OAuth state token")) + return + } + nonce := parts[0] + expectedURI, _ := s.db.GetKV(ctx, "research:oauth:state:"+nonce) + if expectedURI == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("security violation: invalid or expired OAuth state token (potential CSRF)")) + return + } + // One-time use: delete validated state nonce + _ = s.db.SetKV(ctx, "research:oauth:state:"+nonce, "") + redirectURI = parts[1] + + // Exchange code for tokens + data := url.Values{} + data.Set("code", code) + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("redirect_uri", redirectURI) + data.Set("grant_type", "authorization_code") + + resp, err := http.PostForm(googleOAuthTokenURL, data) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("token exchange request failed: %w", err)) + return + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + writeError(w, http.StatusBadRequest, fmt.Errorf("Google token exchange error (%d): %s", resp.StatusCode, string(bodyBytes))) + return + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + } + if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("failed to parse token response: %w", err)) + return + } + + if tokenResp.AccessToken == "" { + writeError(w, http.StatusInternalServerError, fmt.Errorf("no access token received from Google")) + return + } + + // Persist tokens securely in database + _ = s.db.SetKV(ctx, "research:gdocs:access_token", tokenResp.AccessToken) + if tokenResp.RefreshToken != "" { + _ = s.db.SetKV(ctx, "research:gdocs:refresh_token", tokenResp.RefreshToken) + } + expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix() + _ = s.db.SetKV(ctx, "research:gdocs:token_expiry", strconv.FormatInt(expiresAt, 10)) + + // Return a clean self-closing HTML callback page that notifies parent window + w.Header().Set("Content-Type", "text/html; charset=utf-8") + html := ` + + + + Google Docs Connected + + + +
+

Connected to Google Docs!

+

Akun Google berhasil dihubungkan ke Antares. Jendela ini akan tertutup otomatis dalam beberapa detik...

+
+ + +` + _, _ = w.Write([]byte(html)) +} + +func (s *Server) handleGoogleOAuthStatus(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + cfg := s.config() + clientID := strings.TrimSpace(cfg.Research.GDocs.OAuthClientID) + hasClient := clientID != "" + + refreshToken, _ := s.db.GetKV(ctx, "research:gdocs:refresh_token") + accessToken, _ := s.db.GetKV(ctx, "research:gdocs:access_token") + + connected := refreshToken != "" || accessToken != "" + + writeJSON(w, http.StatusOK, map[string]any{ + "connected": connected, + "has_client": hasClient, + }) +} + +func (s *Server) handleGoogleOAuthDisconnect(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + _ = s.db.SetKV(ctx, "research:gdocs:access_token", "") + _ = s.db.SetKV(ctx, "research:gdocs:refresh_token", "") + _ = s.db.SetKV(ctx, "research:gdocs:token_expiry", "") + writeJSON(w, http.StatusOK, map[string]any{"status": "disconnected"}) +} + +// FetchValidGoogleToken gets an active access token, refreshing if needed using refresh_token. +func FetchValidGoogleToken(ctx context.Context, clientID, clientSecret, accessToken, refreshToken string, expiresAtUnix int64) (string, error) { + // If access token is still valid with > 2 minutes leeway, use it + if accessToken != "" && time.Now().Unix() < (expiresAtUnix-120) { + return accessToken, nil + } + + if refreshToken == "" { + if accessToken != "" { + return accessToken, nil + } + return "", fmt.Errorf("not authenticated with Google OAuth. Please connect your Google account in Research Settings") + } + + // Refresh token + data := url.Values{} + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("refresh_token", refreshToken) + data.Set("grant_type", "refresh_token") + + req, err := http.NewRequestWithContext(ctx, "POST", googleOAuthTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("token refresh request failed: %w", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Google token refresh error (%d): %s", resp.StatusCode, string(bodyBytes)) + } + + var res struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(bodyBytes, &res); err != nil { + return "", err + } + + if res.AccessToken == "" { + return "", fmt.Errorf("empty access token returned on refresh") + } + + return res.AccessToken, nil +} diff --git a/internal/server/handlers_misc.go b/internal/server/handlers_misc.go index 6bf34023..aa0a4c11 100644 --- a/internal/server/handlers_misc.go +++ b/internal/server/handlers_misc.go @@ -334,16 +334,30 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { // ---- files ------------------------------------------------------------------ +func (s *Server) resolveFileRoot(r *http.Request) string { + cfg := s.config() + root := cfg.Agent.Workspace + if d := strings.TrimSpace(r.URL.Query().Get("dir")); d != "" { + expanded := filepath.Clean(config.Expand(d)) + if filepath.IsAbs(expanded) { + if fi, err := os.Stat(expanded); err == nil && fi.IsDir() { + return expanded + } + } + } + return root +} + func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) { if s.requireDashboardPassword(w, r) { return } - cfg := s.config() + root := s.resolveFileRoot(r) target := r.URL.Query().Get("path") if target == "" { target = "." } - abs, err := safeJoin(cfg.Agent.Workspace, target) + abs, err := safeJoin(root, target) if err != nil { writeError(w, http.StatusBadRequest, err) return @@ -367,7 +381,7 @@ func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) { if err != nil { continue } - rel, _ := filepath.Rel(cfg.Agent.Workspace, filepath.Join(abs, e.Name())) + rel, _ := filepath.Rel(root, filepath.Join(abs, e.Name())) out = append(out, entry{ Name: e.Name(), Path: filepath.ToSlash(rel), IsDir: e.IsDir(), Size: info.Size(), Modified: info.ModTime(), @@ -381,11 +395,11 @@ func (s *Server) handleListFiles(w http.ResponseWriter, r *http.Request) { }) parent := "" - if rel, _ := filepath.Rel(cfg.Agent.Workspace, abs); rel != "." && rel != "" { + if rel, _ := filepath.Rel(root, abs); rel != "." && rel != "" { parent = filepath.ToSlash(filepath.Dir(rel)) } writeJSON(w, http.StatusOK, map[string]any{ - "path": filepath.ToSlash(relOrSelf(cfg.Agent.Workspace, abs)), "parent": parent, "entries": out, + "root": root, "path": filepath.ToSlash(relOrSelf(root, abs)), "parent": parent, "entries": out, }) } @@ -393,8 +407,8 @@ func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) { if s.requireDashboardPassword(w, r) { return } - cfg := s.config() - abs, err := safeJoin(cfg.Agent.Workspace, r.URL.Query().Get("path")) + root := s.resolveFileRoot(r) + abs, err := safeJoin(root, r.URL.Query().Get("path")) if err != nil { writeError(w, http.StatusBadRequest, err) return @@ -419,7 +433,357 @@ func (s *Server) handleReadFile(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"binary": true, "size": fi.Size()}) return } - writeJSON(w, http.StatusOK, map[string]any{"content": string(data), "size": fi.Size()}) + writeJSON(w, http.StatusOK, map[string]any{ + "content": string(data), "size": fi.Size(), "mtime": fi.ModTime().UnixMilli(), + }) +} + +// maxEditableFile caps a save. The editor loads at most 2 MB, so a larger save +// is almost certainly a mistake (or a binary someone pasted into the editor). +const maxEditableFile = 5 << 20 + +// handleWriteFile saves an edited text file back to disk. It only overwrites an +// existing regular file inside the same root handleReadFile uses — the editor +// cannot create files, follow a symlink out of the root, or write binary data. +// +// Saves are atomic (temp file in the same directory, then rename) so a crash or +// a full disk never leaves a half-written source file. When the client sends +// expected_mtime (milliseconds, the value handleReadFile returned), a mismatch +// means the agent changed the file after it was opened; the save is refused +// with 409 rather than silently clobbering that work. +func (s *Server) handleWriteFile(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + var body struct { + Path string `json:"path"` + Content string `json:"content"` + ExpectedMtime *int64 `json:"expected_mtime"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if strings.TrimSpace(body.Path) == "" { + writeError(w, http.StatusBadRequest, errors.New("path is required")) + return + } + if len(body.Content) > maxEditableFile { + writeError(w, http.StatusRequestEntityTooLarge, errors.New("file is too large to save (over 5 MB)")) + return + } + if strings.IndexByte(body.Content, 0) >= 0 { + writeError(w, http.StatusBadRequest, errors.New("file content is binary")) + return + } + + root := s.resolveFileRoot(r) + abs, err := safeJoin(root, body.Path) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + // Resolve symlinks before touching anything: a symlink inside the project + // must not become a write primitive for a file outside it. + realRoot, err := filepath.EvalSymlinks(root) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + realAbs, err := filepath.EvalSymlinks(abs) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + rel, err := filepath.Rel(realRoot, realAbs) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + writeError(w, http.StatusBadRequest, errors.New("path is outside the workspace")) + return + } + + fi, err := os.Stat(realAbs) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + if !fi.Mode().IsRegular() { + writeError(w, http.StatusBadRequest, errors.New("path is not a regular file")) + return + } + if body.ExpectedMtime != nil && fi.ModTime().UnixMilli() != *body.ExpectedMtime { + writeError(w, http.StatusConflict, errors.New("file changed on disk since it was opened")) + return + } + + dir := filepath.Dir(realAbs) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(realAbs)+".tmp-*") + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + tmpName := tmp.Name() + defer func() { + _ = os.Remove(tmpName) + }() + if _, err := tmp.WriteString(body.Content); err != nil { + _ = tmp.Close() + writeError(w, http.StatusInternalServerError, err) + return + } + if err := tmp.Chmod(fi.Mode().Perm()); err != nil { + _ = tmp.Close() + writeError(w, http.StatusInternalServerError, err) + return + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + writeError(w, http.StatusInternalServerError, err) + return + } + if err := tmp.Close(); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if err := os.Rename(tmpName, realAbs); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + saved, err := os.Stat(realAbs) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "saved": true, "size": saved.Size(), "mtime": saved.ModTime().UnixMilli(), + }) +} + +// resolveEntryDestination validates a create/rename/delete target against the +// workspace root. It resolves symlinks for the *parent* so a link inside the +// project cannot turn into a write/delete primitive outside it, and returns the +// real parent plus the target path under it. The target itself may or may not +// exist. +func resolveEntryDestination(root, target string) (realParent, dest string, err error) { + if strings.TrimSpace(target) == "" { + return "", "", errors.New("path is required") + } + joined, err := safeJoin(root, target) + if err != nil { + return "", "", err + } + rel, err := filepath.Rel(root, joined) + if err != nil { + return "", "", err + } + if rel == "." { + return "", "", errors.New("cannot modify the workspace root") + } + base := filepath.Base(joined) + if base == "." || base == ".." || base == "" || base == string(filepath.Separator) { + return "", "", errors.New("invalid file name") + } + + realRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return "", "", err + } + realParent, err = filepath.EvalSymlinks(filepath.Dir(joined)) + if err != nil { + return "", "", err + } + fi, err := os.Stat(realParent) + if err != nil { + return "", "", err + } + if !fi.IsDir() { + return "", "", errors.New("parent is not a directory") + } + relParent, err := filepath.Rel(realRoot, realParent) + if err != nil || relParent == ".." || strings.HasPrefix(relParent, ".."+string(filepath.Separator)) { + return "", "", errors.New("path is outside the workspace") + } + return realParent, filepath.Join(realParent, base), nil +} + +// validEntryName rejects names that are empty, traverse, or span directories. +// Creation and rename accept only a single path segment. +func validEntryName(name string) error { + if name == "" || name == "." || name == ".." { + return errors.New("name is required") + } + if strings.ContainsAny(name, `/\`) { + return errors.New("name cannot contain a path separator") + } + if strings.IndexByte(name, 0) >= 0 { + return errors.New("name contains a NUL byte") + } + if len(name) > 255 { + return errors.New("name is too long") + } + return nil +} + +// handleCreateFile creates one empty file or directory inside the workspace. +// Existing paths are never overwritten. +func (s *Server) handleCreateFile(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + var body struct { + Path string `json:"path"` + Type string `json:"type"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + body.Type = strings.ToLower(strings.TrimSpace(body.Type)) + if body.Type != "file" && body.Type != "dir" { + writeError(w, http.StatusBadRequest, errors.New(`type must be "file" or "dir"`)) + return + } + if err := validEntryName(filepath.Base(filepath.Clean(body.Path))); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + root := s.resolveFileRoot(r) + _, dest, err := resolveEntryDestination(root, body.Path) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if _, err := os.Lstat(dest); err == nil { + writeError(w, http.StatusConflict, errors.New("an item with that name already exists")) + return + } else if !os.IsNotExist(err) { + writeError(w, http.StatusInternalServerError, err) + return + } + + isDir := body.Type == "dir" + if isDir { + err = os.Mkdir(dest, 0o755) + } else { + var f *os.File + f, err = os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err == nil { + err = f.Close() + } + } + if err != nil { + if os.IsExist(err) { + writeError(w, http.StatusConflict, errors.New("an item with that name already exists")) + return + } + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "path": filepath.ToSlash(filepath.Clean(body.Path)), + "name": filepath.Base(dest), + "is_dir": isDir, + }) +} + +// handleRenameFile renames one entry within its current parent. The name is a +// single segment; moving between directories is intentionally not supported. +func (s *Server) handleRenameFile(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + var body struct { + Path string `json:"path"` + Name string `json:"name"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + body.Name = strings.TrimSpace(body.Name) + if err := validEntryName(body.Name); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + root := s.resolveFileRoot(r) + realParent, source, err := resolveEntryDestination(root, body.Path) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + fi, err := os.Lstat(source) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + if fi.Mode()&os.ModeSymlink != 0 { + writeError(w, http.StatusBadRequest, errors.New("cannot rename a symlink")) + return + } + dest := filepath.Join(realParent, body.Name) + if dest == source { + writeJSON(w, http.StatusOK, map[string]any{"renamed": true, "path": filepath.ToSlash(filepath.Clean(body.Path)), "name": body.Name}) + return + } + if _, err := os.Lstat(dest); err == nil { + writeError(w, http.StatusConflict, errors.New("an item with that name already exists")) + return + } else if !os.IsNotExist(err) { + writeError(w, http.StatusInternalServerError, err) + return + } + if err := os.Rename(source, dest); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + parent := filepath.Dir(filepath.Clean(body.Path)) + if parent == "." { + parent = "" + } + writeJSON(w, http.StatusOK, map[string]any{ + "renamed": true, + "path": filepath.ToSlash(filepath.Join(parent, body.Name)), + "name": body.Name, + }) +} + +// handleDeleteFile removes a file, or a directory when recursive=1. Without +// recursive, a non-empty directory is refused so a misclick cannot wipe a tree. +func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + root := s.resolveFileRoot(r) + _, target, err := resolveEntryDestination(root, r.URL.Query().Get("path")) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + fi, err := os.Lstat(target) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + recursive := r.URL.Query().Get("recursive") == "1" + if fi.IsDir() { + if recursive { + err = os.RemoveAll(target) + } else { + err = os.Remove(target) + if err != nil && !os.IsNotExist(err) { + writeError(w, http.StatusConflict, errors.New("directory is not empty")) + return + } + } + } else { + err = os.Remove(target) + } + if err != nil && !os.IsNotExist(err) { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"deleted": true}) } // isBinary reports whether b looks like non-text data (a NUL byte in the first @@ -447,8 +811,8 @@ func (s *Server) handleRawFile(w http.ResponseWriter, r *http.Request) { if s.requireDashboardPassword(w, r) { return } - cfg := s.config() - abs, err := safeJoin(cfg.Agent.Workspace, r.URL.Query().Get("path")) + root := s.resolveFileRoot(r) + abs, err := safeJoin(root, r.URL.Query().Get("path")) if err != nil { writeError(w, http.StatusBadRequest, err) return diff --git a/internal/server/handlers_research.go b/internal/server/handlers_research.go new file mode 100644 index 00000000..afde843f --- /dev/null +++ b/internal/server/handlers_research.go @@ -0,0 +1,858 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/docx" + "github.com/enowdev/antares/internal/tools" +) + +func extractGoogleDocID(raw string) string { + raw = strings.TrimSpace(raw) + if idx := strings.Index(raw, "/document/d/"); idx != -1 { + rest := raw[idx+len("/document/d/"):] + if end := strings.Index(rest, "/"); end != -1 { + return rest[:end] + } + if end := strings.Index(rest, "?"); end != -1 { + return rest[:end] + } + return rest + } + return raw +} + +func (s *Server) handleGetResearchSettings(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + cfg := s.config() + apiKey := cfg.Research.Zotero.APIKey + if apiKey != "" { + apiKey = config.MaskSecret(apiKey) + } + oauthSecret := cfg.Research.GDocs.OAuthClientSecret + if oauthSecret != "" { + oauthSecret = config.MaskSecret(oauthSecret) + } + docURL := cfg.Research.GDocs.DocumentURL + docID := cfg.Research.GDocs.DocumentID + if sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")); sessionID != "" { + if sDocURL, _ := s.db.GetKV(ctx, "research:gdocs:"+sessionID+":doc_url"); sDocURL != "" { + docURL = sDocURL + docID, _ = s.db.GetKV(ctx, "research:gdocs:"+sessionID+":doc_id") + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "zotero": map[string]any{ + "user_id": cfg.Research.Zotero.UserID, + "api_key": apiKey, + }, + "gdocs": map[string]any{ + "method": cfg.Research.GDocs.Method, + "webhook_url": cfg.Research.GDocs.WebhookURL, + "document_url": docURL, + "document_id": docID, + "oauth_client_id": cfg.Research.GDocs.OAuthClientID, + "oauth_client_secret": oauthSecret, + }, + }) +} + +func (s *Server) handleSaveResearchSettings(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + var body struct { + SessionID string `json:"session_id"` + Zotero struct { + UserID *string `json:"user_id"` + APIKey *string `json:"api_key"` + } `json:"zotero"` + GDocs struct { + Method *string `json:"method"` + WebhookURL *string `json:"webhook_url"` + DocumentURL *string `json:"document_url"` + OAuthClientID *string `json:"oauth_client_id"` + OAuthClientSecret *string `json:"oauth_client_secret"` + } `json:"gdocs"` + } + + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + // Security validation on all incoming inputs + if (body.Zotero.UserID != nil && *body.Zotero.UserID != "") || (body.Zotero.APIKey != nil && *body.Zotero.APIKey != "") { + uID := "" + if body.Zotero.UserID != nil { + uID = *body.Zotero.UserID + } + aKey := "" + if body.Zotero.APIKey != nil && !strings.Contains(*body.Zotero.APIKey, "••••") { + aKey = *body.Zotero.APIKey + } + if err := tools.ValidateZoteroCreds(uID, aKey); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + } + if body.GDocs.WebhookURL != nil && *body.GDocs.WebhookURL != "" { + if err := tools.ValidateGDocsWebhookURL(*body.GDocs.WebhookURL); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + } + if body.GDocs.DocumentURL != nil && *body.GDocs.DocumentURL != "" { + docID := extractGoogleDocID(*body.GDocs.DocumentURL) + if err := tools.ValidateGDocsDocID(docID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + } + if (body.GDocs.OAuthClientID != nil && *body.GDocs.OAuthClientID != "") || (body.GDocs.OAuthClientSecret != nil && *body.GDocs.OAuthClientSecret != "") { + cID := "" + if body.GDocs.OAuthClientID != nil { + cID = *body.GDocs.OAuthClientID + } + cSec := "" + if body.GDocs.OAuthClientSecret != nil && !strings.Contains(*body.GDocs.OAuthClientSecret, "••••") { + cSec = *body.GDocs.OAuthClientSecret + } + if err := tools.ValidateGoogleOAuthCreds(cID, cSec); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + } + + cfg, err := config.Reload() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + if body.Zotero.UserID != nil { + cfg.Research.Zotero.UserID = *body.Zotero.UserID + } + if body.Zotero.APIKey != nil && !strings.Contains(*body.Zotero.APIKey, "••••") { + cfg.Research.Zotero.APIKey = *body.Zotero.APIKey + } + if body.GDocs.Method != nil { + cfg.Research.GDocs.Method = *body.GDocs.Method + } + if body.GDocs.WebhookURL != nil { + cfg.Research.GDocs.WebhookURL = *body.GDocs.WebhookURL + } + sessionID := strings.TrimSpace(body.SessionID) + if body.GDocs.DocumentURL != nil { + docURL := strings.TrimSpace(*body.GDocs.DocumentURL) + docID := "" + if docURL != "" { + docID = extractGoogleDocID(docURL) + } + if sessionID != "" { + _ = s.db.SetKV(ctx, "research:gdocs:"+sessionID+":doc_url", docURL) + _ = s.db.SetKV(ctx, "research:gdocs:"+sessionID+":doc_id", docID) + } + cfg.Research.GDocs.DocumentURL = docURL + cfg.Research.GDocs.DocumentID = docID + if docURL == "" { + _ = s.db.SetKV(ctx, "research:gdocs:last_exported_url", "") + } + } + if body.GDocs.OAuthClientID != nil { + cfg.Research.GDocs.OAuthClientID = *body.GDocs.OAuthClientID + } + if body.GDocs.OAuthClientSecret != nil && !strings.Contains(*body.GDocs.OAuthClientSecret, "••••") { + cfg.Research.GDocs.OAuthClientSecret = *body.GDocs.OAuthClientSecret + } + + if err := config.Save(cfg); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + _ = s.applyReload() + + writeJSON(w, http.StatusOK, map[string]any{"status": "saved"}) +} + +func (s *Server) handleGDocsRollback(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + var body struct { + DocumentID string `json:"document_id"` + DocumentURL string `json:"document_url"` + } + _ = decodeBody(r, &body) + + targetID := strings.TrimSpace(body.DocumentID) + if targetID == "" && body.DocumentURL != "" { + targetID = extractGoogleDocID(body.DocumentURL) + } + if targetID == "" { + cfg := s.config() + targetID = strings.TrimSpace(cfg.Research.GDocs.DocumentID) + if targetID == "" && cfg.Research.GDocs.DocumentURL != "" { + targetID = extractGoogleDocID(cfg.Research.GDocs.DocumentURL) + } + } + if targetID == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("document_id is required for rollback")) + return + } + if err := tools.ValidateGDocsDocID(targetID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + tool, ok := tools.Default().Get("gdocs_rollback") + if !ok { + writeError(w, http.StatusInternalServerError, fmt.Errorf("gdocs_rollback tool is not registered")) + return + } + + argsJSON, _ := json.Marshal(map[string]any{"document_id": targetID}) + res := tool.Execute(ctx, tools.Input{ + Args: argsJSON, + Deps: &tools.Deps{ + Config: s.config(), + Store: s.db, + }, + }) + + if res.IsError { + writeError(w, http.StatusBadRequest, fmt.Errorf("%s", res.Content)) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "message": res.Content, + "meta": res.Meta, + }) +} + +func (s *Server) handleGDocsExport(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + var body struct { + SessionID string `json:"session_id"` + Title string `json:"title"` + HTML string `json:"html"` + AutoShare *bool `json:"auto_share"` // default true: anyone with link can edit + ShareRole string `json:"share_role"` // writer (edit) or reader (view) + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + title := strings.TrimSpace(body.Title) + if title == "" { + title = "Dokumen Antares - " + time.Now().Format("2006-01-02 15:04") + } + contentHTML := strings.TrimSpace(body.HTML) + if contentHTML == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("html content cannot be empty")) + return + } + + token, err := tools.ResolveActiveGoogleToken(ctx, s.config(), s.db) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("Google account is not connected: %w", err)) + return + } + + // Prepare multipart/related upload body to Google Drive API + // Metadata sets target Google Docs mimeType: application/vnd.google-apps.document + // Media part contains the semantic HTML + boundary := fmt.Sprintf("-------AntaresBoundary%d", time.Now().UnixNano()) + var b bytes.Buffer + + // Part 1: Metadata JSON + b.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + b.WriteString("Content-Type: application/json; charset=UTF-8\r\n\r\n") + meta := map[string]string{ + "name": title, + "mimeType": "application/vnd.google-apps.document", + } + metaBytes, _ := json.Marshal(meta) + b.Write(metaBytes) + b.WriteString("\r\n") + + // Part 2: HTML Content + b.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + b.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") + b.WriteString(contentHTML) + b.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary)) + + uploadURL := "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart" + req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, &b) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("create drive upload request: %w", err)) + return + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", fmt.Sprintf("multipart/related; boundary=%s", boundary)) + + client := &http.Client{Timeout: 45 * time.Second} + resp, err := client.Do(req) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("send request to Google Drive: %w", err)) + return + } + defer resp.Body.Close() + + respBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + writeError(w, http.StatusBadRequest, fmt.Errorf("Google Drive API error (%d): %s", resp.StatusCode, string(respBytes))) + return + } + + var driveResp struct { + ID string `json:"id"` + Name string `json:"name"` + MimeType string `json:"mimeType"` + } + if err := json.Unmarshal(respBytes, &driveResp); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("parse drive response: %w", err)) + return + } + + // Auto-share permission: Set "anyone with the link can edit/view" + // This allows the iframe in split view to immediately load and edit without login or cookie blocks! + shouldShare := true + if body.AutoShare != nil { + shouldShare = *body.AutoShare + } + if shouldShare { + role := strings.TrimSpace(strings.ToLower(body.ShareRole)) + switch role { + case "reader", "commenter", "writer": + // valid role + default: + role = "writer" // default: editor access so user can type immediately in split view + } + permURL := fmt.Sprintf("https://www.googleapis.com/drive/v3/files/%s/permissions", url.PathEscape(driveResp.ID)) + permPayload, _ := json.Marshal(map[string]string{ + "role": role, + "type": "anyone", + }) + permReq, err := http.NewRequestWithContext(ctx, "POST", permURL, bytes.NewReader(permPayload)) + if err == nil { + permReq.Header.Set("Authorization", "Bearer "+token) + permReq.Header.Set("Content-Type", "application/json") + permResp, err := client.Do(permReq) + if err == nil { + permResp.Body.Close() + } + } + } + + // Post-creation enforcement: Call Google Docs REST API to ensure 100% Times New Roman across the entire document + // while PRESERVING bold, italic, and heading font weights + go enforceTimesNewRoman(driveResp.ID, token) + + docURL := fmt.Sprintf("https://docs.google.com/document/d/%s/edit", driveResp.ID) + + sessionID := strings.TrimSpace(body.SessionID) + if sessionID != "" { + _ = s.db.SetKV(ctx, "research:gdocs:"+sessionID+":doc_url", docURL) + _ = s.db.SetKV(ctx, "research:gdocs:"+sessionID+":doc_id", driveResp.ID) + _ = s.db.SetKV(ctx, "research:gdocs:"+sessionID+":doc_title", title) + } + + // Update research.gdocs.document_url and document_id in config + cfg, err := config.Reload() + if err == nil { + cfg.Research.GDocs.DocumentURL = docURL + cfg.Research.GDocs.DocumentID = driveResp.ID + _ = config.Save(cfg) + _ = s.applyReload() + } + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "document_id": driveResp.ID, + "document_url": docURL, + "title": driveResp.Name, + }) +} + +func (s *Server) handleGDocsSyncPush(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + var body struct { + SessionID string `json:"session_id"` + DocumentID string `json:"document_id"` + DocumentURL string `json:"document_url"` + HTML string `json:"html"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + targetID := strings.TrimSpace(body.DocumentID) + if targetID == "" && body.DocumentURL != "" { + targetID = extractGoogleDocID(body.DocumentURL) + } + sessionID := strings.TrimSpace(body.SessionID) + if targetID == "" && sessionID != "" { + targetID, _ = s.db.GetKV(ctx, "research:gdocs:"+sessionID+":doc_id") + if targetID == "" { + if sURL, _ := s.db.GetKV(ctx, "research:gdocs:"+sessionID+":doc_url"); sURL != "" { + targetID = extractGoogleDocID(sURL) + } + } + } + if targetID == "" { + cfg := s.config() + targetID = strings.TrimSpace(cfg.Research.GDocs.DocumentID) + if targetID == "" && cfg.Research.GDocs.DocumentURL != "" { + targetID = extractGoogleDocID(cfg.Research.GDocs.DocumentURL) + } + } + if targetID == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("document_id is required for sync push")) + return + } + if err := tools.ValidateGDocsDocID(targetID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + contentHTML := strings.TrimSpace(body.HTML) + if contentHTML == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("html content cannot be empty")) + return + } + + token, err := tools.ResolveActiveGoogleToken(ctx, s.config(), s.db) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("Google account is not connected: %w", err)) + return + } + + // Update existing file content on Google Drive via PATCH multipart upload + boundary := fmt.Sprintf("-------AntaresSyncBoundary%d", time.Now().UnixNano()) + var b bytes.Buffer + + // Part 1: Metadata JSON (Empty or preserves mimeType) + b.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + b.WriteString("Content-Type: application/json; charset=UTF-8\r\n\r\n") + b.WriteString("{}\r\n") + + // Part 2: HTML Content + b.WriteString(fmt.Sprintf("--%s\r\n", boundary)) + b.WriteString("Content-Type: text/html; charset=UTF-8\r\n\r\n") + b.WriteString(contentHTML) + b.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary)) + + patchURL := fmt.Sprintf("https://www.googleapis.com/upload/drive/v3/files/%s?uploadType=multipart", url.PathEscape(targetID)) + req, err := http.NewRequestWithContext(ctx, "PATCH", patchURL, &b) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("create drive patch request: %w", err)) + return + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", fmt.Sprintf("multipart/related; boundary=%s", boundary)) + + client := &http.Client{Timeout: 45 * time.Second} + resp, err := client.Do(req) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("send request to Google Drive: %w", err)) + return + } + defer resp.Body.Close() + + respBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + writeError(w, http.StatusBadRequest, fmt.Errorf("Google Drive API patch error (%d): %s", resp.StatusCode, string(respBytes))) + return + } + + go enforceTimesNewRoman(targetID, token) + + docURL := fmt.Sprintf("https://docs.google.com/document/d/%s/edit", targetID) + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "document_id": targetID, + "document_url": docURL, + "message": "Isi Google Docs berhasil diperbarui mengikuti draf terbaru!", + }) +} + +func (s *Server) handleGDocsSyncPull(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + var body struct { + SessionID string `json:"session_id"` + DocumentID string `json:"document_id"` + DocumentURL string `json:"document_url"` + } + _ = decodeBody(r, &body) + + targetID := strings.TrimSpace(body.DocumentID) + if targetID == "" && body.DocumentURL != "" { + targetID = extractGoogleDocID(body.DocumentURL) + } + sessionID := strings.TrimSpace(body.SessionID) + if targetID == "" && sessionID != "" { + targetID, _ = s.db.GetKV(ctx, "research:gdocs:"+sessionID+":doc_id") + if targetID == "" { + if sURL, _ := s.db.GetKV(ctx, "research:gdocs:"+sessionID+":doc_url"); sURL != "" { + targetID = extractGoogleDocID(sURL) + } + } + } + if targetID == "" { + cfg := s.config() + targetID = strings.TrimSpace(cfg.Research.GDocs.DocumentID) + if targetID == "" && cfg.Research.GDocs.DocumentURL != "" { + targetID = extractGoogleDocID(cfg.Research.GDocs.DocumentURL) + } + } + if targetID == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("document_id is required for sync pull")) + return + } + if err := tools.ValidateGDocsDocID(targetID); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + readTool, ok := tools.Default().Get("gdocs_read") + if !ok { + writeError(w, http.StatusInternalServerError, fmt.Errorf("gdocs_read tool is not registered")) + return + } + + argsJSON, _ := json.Marshal(map[string]any{"document_id": targetID}) + res := readTool.Execute(ctx, tools.Input{ + Args: argsJSON, + Deps: &tools.Deps{ + Config: s.config(), + Store: s.db, + }, + }) + + if res.IsError { + writeError(w, http.StatusBadRequest, fmt.Errorf("%s", res.Content)) + return + } + + // Clean out the header "Google Doc: Title (ID: ...)\n\n" from the extracted content + raw := res.Content + content := raw + if idx := strings.Index(raw, "\n\n"); idx != -1 { + content = strings.TrimSpace(raw[idx+2:]) + } + + // Update the local draft in Antares database + _, cKey := draftKeys(body.SessionID) + _ = s.db.SetKV(ctx, cKey, content) + + writeJSON(w, http.StatusOK, map[string]any{ + "status": "success", + "document_id": targetID, + "content": content, + "message": "Berhasil menarik perubahan terbaru dari Google Docs ke Draf Naskah!", + }) +} + +func draftKeys(sessionID string) (titleKey, contentKey string) { + sessionID = strings.TrimSpace(sessionID) + if sessionID != "" { + return "research:draft:" + sessionID + ":title", "research:draft:" + sessionID + ":content" + } + return "research:draft:title", "research:draft:content" +} + +func (s *Server) handleGetDraft(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) + tKey, cKey := draftKeys(sessionID) + title, _ := s.db.GetKV(ctx, tKey) + content, _ := s.db.GetKV(ctx, cKey) + if title == "" && sessionID == "" { + title = "Bab 3 - Perancangan Sistem Bilik" + } + writeJSON(w, http.StatusOK, map[string]any{ + "session_id": sessionID, + "title": title, + "content": content, + }) +} + +func (s *Server) handleSaveDraft(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + var body struct { + SessionID string `json:"session_id"` + Title string `json:"title"` + Content string `json:"content"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + tKey, cKey := draftKeys(body.SessionID) + if body.Title != "" { + _ = s.db.SetKV(ctx, tKey, body.Title) + } + _ = s.db.SetKV(ctx, cKey, body.Content) + writeJSON(w, http.StatusOK, map[string]any{"status": "saved", "session_id": body.SessionID}) +} + +func (s *Server) handleDeleteDraft(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + ctx := r.Context() + sessionID := strings.TrimSpace(r.URL.Query().Get("session_id")) + if sessionID == "" { + var body struct { + SessionID string `json:"session_id"` + } + _ = decodeBody(r, &body) + sessionID = strings.TrimSpace(body.SessionID) + } + tKey, cKey := draftKeys(sessionID) + _ = s.db.SetKV(ctx, tKey, "") + _ = s.db.SetKV(ctx, cKey, "") + writeJSON(w, http.StatusOK, map[string]any{"status": "deleted", "session_id": sessionID}) +} + +func (s *Server) handleExportDOCX(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var body struct { + SessionID string `json:"session_id"` + Title string `json:"title"` + Content string `json:"content"` + } + _ = decodeBody(r, &body) + + title := strings.TrimSpace(body.Title) + content := body.Content + sessionID := strings.TrimSpace(body.SessionID) + + if title == "" || content == "" { + tKey, cKey := draftKeys(sessionID) + if title == "" { + title, _ = s.db.GetKV(ctx, tKey) + } + if content == "" { + content, _ = s.db.GetKV(ctx, cKey) + } + } + + if title == "" { + title = "Dokumen Antares" + } + if content == "" { + content = "# " + title + "\n\n(Draf kosong)" + } + + b, err := docx.Generate(title, content) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("failed to generate docx: %w", err)) + return + } + + filename := title + if !strings.HasSuffix(strings.ToLower(filename), ".docx") { + filename += ".docx" + } + + w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%s`, strconv.Quote(filename))) + w.Header().Set("Content-Length", strconv.Itoa(len(b))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(b) +} + +func (s *Server) handleOpenBrowser(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + var body struct { + URL string `json:"url"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + target := strings.TrimSpace(body.URL) + if target == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("url cannot be empty")) + return + } + + // Strictly validate URL to prevent command injection, flag injection, and local file access + if strings.HasPrefix(target, "-") { + writeError(w, http.StatusBadRequest, fmt.Errorf("security violation: URL cannot begin with a dash")) + return + } + u, err := url.ParseRequestURI(target) + if err != nil || u.Host == "" { + writeError(w, http.StatusBadRequest, fmt.Errorf("security violation: malformed URL: %w", err)) + return + } + scheme := strings.ToLower(u.Scheme) + if scheme != "https" && scheme != "http" { + writeError(w, http.StatusBadRequest, fmt.Errorf("security violation: unsupported URL scheme %q; only http/https are permitted", scheme)) + return + } + + var cmd *exec.Cmd + switch runtime.GOOS { + case "linux": + cmd = exec.Command("xdg-open", target) + case "darwin": + cmd = exec.Command("open", target) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", target) + default: + cmd = exec.Command("xdg-open", target) + } + if cmd != nil { + _ = cmd.Start() + } + + writeJSON(w, http.StatusOK, map[string]any{"status": "opened", "url": target}) +} + +func enforceTimesNewRoman(docID, authToken string) { + gdocsReqURL := fmt.Sprintf("https://docs.googleapis.com/v1/documents/%s", url.PathEscape(docID)) + infoReq, err := http.NewRequest("GET", gdocsReqURL, nil) + if err != nil { + return + } + infoReq.Header.Set("Authorization", "Bearer "+authToken) + c := &http.Client{Timeout: 15 * time.Second} + infoResp, err := c.Do(infoReq) + if err != nil { + return + } + defer infoResp.Body.Close() + + var gdoc struct { + Body struct { + Content []struct { + StartIndex int `json:"startIndex"` + EndIndex int `json:"endIndex"` + Paragraph *struct { + ParagraphStyle *struct { + NamedStyleType string `json:"namedStyleType"` + } `json:"paragraphStyle"` + Elements []struct { + StartIndex int `json:"startIndex"` + EndIndex int `json:"endIndex"` + TextRun *struct { + Content string `json:"content"` + TextStyle *struct { + Bold bool `json:"bold"` + Italic bool `json:"italic"` + } `json:"textStyle"` + } `json:"textRun"` + } `json:"elements"` + } `json:"paragraph"` + } `json:"content"` + } `json:"body"` + } + if err := json.NewDecoder(infoResp.Body).Decode(&gdoc); err != nil || len(gdoc.Body.Content) == 0 { + return + } + + var reqs []map[string]any + + for _, item := range gdoc.Body.Content { + if item.Paragraph == nil { + continue + } + isHeading := item.Paragraph.ParagraphStyle != nil && strings.HasPrefix(item.Paragraph.ParagraphStyle.NamedStyleType, "HEADING_") + + for _, el := range item.Paragraph.Elements { + if el.TextRun == nil || el.StartIndex >= el.EndIndex { + continue + } + isBold := isHeading + isItalic := false + if el.TextRun.TextStyle != nil { + if el.TextRun.TextStyle.Bold { + isBold = true + } + if el.TextRun.TextStyle.Italic { + isItalic = true + } + } + + textStyle := map[string]any{ + "weightedFontFamily": map[string]any{ + "fontFamily": "Times New Roman", + "weight": 400, + }, + "bold": isBold, + "italic": isItalic, + } + if isBold { + textStyle["weightedFontFamily"] = map[string]any{ + "fontFamily": "Times New Roman", + "weight": 700, + } + } + + reqs = append(reqs, map[string]any{ + "updateTextStyle": map[string]any{ + "range": map[string]any{ + "startIndex": el.StartIndex, + "endIndex": el.EndIndex, + }, + "textStyle": textStyle, + "fields": "weightedFontFamily,bold,italic", + }, + }) + } + } + + if len(reqs) == 0 { + return + } + + // Execute in batches if there are many elements + batchReq := map[string]any{"requests": reqs} + bBytes, _ := json.Marshal(batchReq) + upReq, err := http.NewRequest("POST", fmt.Sprintf("https://docs.googleapis.com/v1/documents/%s:batchUpdate", url.PathEscape(docID)), bytes.NewReader(bBytes)) + if err == nil { + upReq.Header.Set("Authorization", "Bearer "+authToken) + upReq.Header.Set("Content-Type", "application/json") + upResp, err := c.Do(upReq) + if err == nil { + upResp.Body.Close() + } + } +} diff --git a/internal/server/handlers_research_test.go b/internal/server/handlers_research_test.go new file mode 100644 index 00000000..6ae5f33e --- /dev/null +++ b/internal/server/handlers_research_test.go @@ -0,0 +1,298 @@ +package server + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + "strings" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/store" +) + +func TestResearchSettingsValidationAPI(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + s := &Server{cfg: cfg} + + // 0. Gate: without password or auth token, must return 428 Precondition Required + gateReq := httptest.NewRequest(http.MethodPost, "/api/research/settings", bytes.NewBufferString("{}")) + gateRR := httptest.NewRecorder() + s.handleSaveResearchSettings(gateRR, gateReq) + if gateRR.Code != http.StatusPreconditionRequired { + t.Fatalf("expected 428 Precondition Required when unauthenticated, got %d", gateRR.Code) + } + + // Satisfy the password gate for subsequent validation checks + cfg.Server.DashboardPasswordHash = "test-hash" + + // 1. Rejects SSRF Webhook URL + ssrfPayload := `{"gdocs":{"webhook_url":"http://169.254.169.254/latest/meta-data"}}` + req := httptest.NewRequest(http.MethodPost, "/api/research/settings", bytes.NewBufferString(ssrfPayload)) + rr := httptest.NewRecorder() + s.handleSaveResearchSettings(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request on SSRF webhook, got %d", rr.Code) + } + + // 2. Rejects malicious Zotero User ID + badZoteroPayload := `{"zotero":{"user_id":"123/../evil","api_key":"valid_token_12345"}}` + req = httptest.NewRequest(http.MethodPost, "/api/research/settings", bytes.NewBufferString(badZoteroPayload)) + rr = httptest.NewRecorder() + s.handleSaveResearchSettings(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request on malicious Zotero user ID, got %d", rr.Code) + } + + // 3. Rejects invalid Document URL + badDocPayload := `{"gdocs":{"document_url":"https://evil.com/phish"}}` + req = httptest.NewRequest(http.MethodPost, "/api/research/settings", bytes.NewBufferString(badDocPayload)) + rr = httptest.NewRecorder() + s.handleSaveResearchSettings(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request on invalid document URL, got %d", rr.Code) + } + + // 4. Accepts valid settings + validPayload := `{"zotero":{"user_id":"12345678","api_key":"syntheticmockkey123456"},"gdocs":{"method":"oauth","document_url":"https://docs.google.com/document/d/1U76qmDw7ZDiSZJmCk5_Ri8VfdFKZZYyTTKpSeSaio6o/edit"}}` + req = httptest.NewRequest(http.MethodPost, "/api/research/settings", bytes.NewBufferString(validPayload)) + rr = httptest.NewRecorder() + s.handleSaveResearchSettings(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 OK on valid settings, got %d (body: %s)", rr.Code, rr.Body.String()) + } +} + +func TestResearchSettingsMaskingAndRoundTrip(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + cfg.Server.DashboardPasswordHash = "test-hash" + cfg.Research.Zotero.UserID = "12345678" + cfg.Research.Zotero.APIKey = "supersecretzoterokey12345" + cfg.Research.GDocs.OAuthClientID = "12345-abc.apps.googleusercontent.com" + cfg.Research.GDocs.OAuthClientSecret = "GOCSPX-supersecret12345" + s := &Server{cfg: cfg} + + // 1. GET settings must return masked secrets + getReq := httptest.NewRequest(http.MethodGet, "/api/research/settings", nil) + getRR := httptest.NewRecorder() + s.handleGetResearchSettings(getRR, getReq) + if getRR.Code != http.StatusOK { + t.Fatalf("expected 200 OK on GET settings, got %d", getRR.Code) + } + body := getRR.Body.String() + if bytes.Contains(getRR.Body.Bytes(), []byte("supersecretzoterokey12345")) { + t.Fatalf("Zotero API key leaked in plaintext in response: %s", body) + } + if bytes.Contains(getRR.Body.Bytes(), []byte("GOCSPX-supersecret12345")) { + t.Fatalf("Google OAuth client secret leaked in plaintext in response: %s", body) + } + + // 2. POST with masked value should preserve existing secret + postPayload := `{"zotero":{"api_key":"supe••••••••2345"},"gdocs":{"oauth_client_secret":"••••••••"}}` + postReq := httptest.NewRequest(http.MethodPost, "/api/research/settings", bytes.NewBufferString(postPayload)) + postRR := httptest.NewRecorder() + s.handleSaveResearchSettings(postRR, postReq) + if postRR.Code != http.StatusOK { + t.Fatalf("expected 200 OK on POST settings with masked secrets, got %d: %s", postRR.Code, postRR.Body.String()) + } + if cfg.Research.Zotero.APIKey != "supersecretzoterokey12345" { + t.Fatalf("expected Zotero API key to be preserved, got %q", cfg.Research.Zotero.APIKey) + } + if cfg.Research.GDocs.OAuthClientSecret != "GOCSPX-supersecret12345" { + t.Fatalf("expected OAuth secret to be preserved, got %q", cfg.Research.GDocs.OAuthClientSecret) + } +} + +func TestGDocsSyncAndRollbackSecurity(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + s := &Server{cfg: cfg} + + // 1. Unauthenticated requests must be gated by 428 Precondition Required + syncReq := httptest.NewRequest(http.MethodPost, "/api/research/gdocs/sync", bytes.NewBufferString(`{"html":"test"}`)) + syncRR := httptest.NewRecorder() + s.handleGDocsSyncPush(syncRR, syncReq) + if syncRR.Code != http.StatusPreconditionRequired { + t.Fatalf("expected 428 on unauthenticated sync push, got %d", syncRR.Code) + } + + pullReq := httptest.NewRequest(http.MethodPost, "/api/research/gdocs/pull", bytes.NewBufferString(`{}`)) + pullRR := httptest.NewRecorder() + s.handleGDocsSyncPull(pullRR, pullReq) + if pullRR.Code != http.StatusPreconditionRequired { + t.Fatalf("expected 428 on unauthenticated sync pull, got %d", pullRR.Code) + } + + rbReq := httptest.NewRequest(http.MethodPost, "/api/research/gdocs/rollback", bytes.NewBufferString(`{}`)) + rbRR := httptest.NewRecorder() + s.handleGDocsRollback(rbRR, rbReq) + if rbRR.Code != http.StatusPreconditionRequired { + t.Fatalf("expected 428 on unauthenticated rollback, got %d", rbRR.Code) + } + + // Satisfy password gate for payload security checks + cfg.Server.DashboardPasswordHash = "test-hash" + + // 2. Reject malicious document IDs (path traversal / SSRF injection) + badIDs := []string{ + `{"document_id":"../../evil"}`, + `{"document_id":"https://attacker.com/evil"}`, + `{"document_id":"short"}`, + } + for _, bad := range badIDs { + // Sync push + r := httptest.NewRequest(http.MethodPost, "/api/research/gdocs/sync", bytes.NewBufferString(bad)) + w := httptest.NewRecorder() + s.handleGDocsSyncPush(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on malicious document_id %s in sync push, got %d", bad, w.Code) + } + + // Sync pull + r = httptest.NewRequest(http.MethodPost, "/api/research/gdocs/pull", bytes.NewBufferString(bad)) + w = httptest.NewRecorder() + s.handleGDocsSyncPull(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on malicious document_id %s in sync pull, got %d", bad, w.Code) + } + + // Rollback + r = httptest.NewRequest(http.MethodPost, "/api/research/gdocs/rollback", bytes.NewBufferString(bad)) + w = httptest.NewRecorder() + s.handleGDocsRollback(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on malicious document_id %s in rollback, got %d", bad, w.Code) + } + } +} + +func TestOpenBrowserSecurity(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + s := &Server{cfg: cfg} + + // 1. Password gate + req := httptest.NewRequest(http.MethodPost, "/api/research/open-browser", bytes.NewBufferString(`{"url":"https://example.com"}`)) + rr := httptest.NewRecorder() + s.handleOpenBrowser(rr, req) + if rr.Code != http.StatusPreconditionRequired { + t.Fatalf("expected 428 on unauthenticated open-browser, got %d", rr.Code) + } + + cfg.Server.DashboardPasswordHash = "test-hash" + + // 2. Reject flag injection + badFlag := `{"url":"--app=https://attacker.com"}` + req = httptest.NewRequest(http.MethodPost, "/api/research/open-browser", bytes.NewBufferString(badFlag)) + rr = httptest.NewRecorder() + s.handleOpenBrowser(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on flag injection, got %d", rr.Code) + } + + // 3. Reject non-http schemes + schemes := []string{ + `{"url":"file:///etc/passwd"}`, + `{"url":"javascript:alert(1)"}`, + `{"url":"smb://attacker/share"}`, + } + for _, sc := range schemes { + req = httptest.NewRequest(http.MethodPost, "/api/research/open-browser", bytes.NewBufferString(sc)) + rr = httptest.NewRecorder() + s.handleOpenBrowser(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 on unsupported scheme %s, got %d", sc, rr.Code) + } + } +} + +func TestDraftSessionScopingAndDelete(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + cfg.Server.DashboardPasswordHash = "test-hash" + db, err := store.Open(t.Context(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, db: db} + + // 1. Save draft for session A + saveReqA := httptest.NewRequest(http.MethodPost, "/api/research/draft", bytes.NewBufferString(`{"session_id":"ses_A","title":"Title A","content":"Content A"}`)) + saveRRA := httptest.NewRecorder() + s.handleSaveDraft(saveRRA, saveReqA) + if saveRRA.Code != http.StatusOK { + t.Fatalf("expected 200 on save draft A, got %d", saveRRA.Code) + } + + // 2. Save draft for session B + saveReqB := httptest.NewRequest(http.MethodPost, "/api/research/draft", bytes.NewBufferString(`{"session_id":"ses_B","title":"Title B","content":"Content B"}`)) + saveRRB := httptest.NewRecorder() + s.handleSaveDraft(saveRRB, saveReqB) + if saveRRB.Code != http.StatusOK { + t.Fatalf("expected 200 on save draft B, got %d", saveRRB.Code) + } + + // 3. Read draft for session A + getReqA := httptest.NewRequest(http.MethodGet, "/api/research/draft?session_id=ses_A", nil) + getRRA := httptest.NewRecorder() + s.handleGetDraft(getRRA, getReqA) + if !bytes.Contains(getRRA.Body.Bytes(), []byte("Content A")) { + t.Fatalf("expected session A draft to contain 'Content A', got %s", getRRA.Body.String()) + } + + // 4. Read draft for session B + getReqB := httptest.NewRequest(http.MethodGet, "/api/research/draft?session_id=ses_B", nil) + getRRB := httptest.NewRecorder() + s.handleGetDraft(getRRB, getReqB) + if !bytes.Contains(getRRB.Body.Bytes(), []byte("Content B")) { + t.Fatalf("expected session B draft to contain 'Content B', got %s", getRRB.Body.String()) + } + + // 5. Read draft for an empty/new session C + getReqC := httptest.NewRequest(http.MethodGet, "/api/research/draft?session_id=ses_C", nil) + getRRC := httptest.NewRecorder() + s.handleGetDraft(getRRC, getReqC) + if bytes.Contains(getRRC.Body.Bytes(), []byte("Content A")) || bytes.Contains(getRRC.Body.Bytes(), []byte("Content B")) { + t.Fatalf("new session C draft must not leak other sessions' content, got %s", getRRC.Body.String()) + } + + // 6. Delete draft for session A + delReqA := httptest.NewRequest(http.MethodDelete, "/api/research/draft?session_id=ses_A", nil) + delRRA := httptest.NewRecorder() + s.handleDeleteDraft(delRRA, delReqA) + if delRRA.Code != http.StatusOK { + t.Fatalf("expected 200 on delete draft A, got %d", delRRA.Code) + } + + // Verify session A is now empty + getReqA2 := httptest.NewRequest(http.MethodGet, "/api/research/draft?session_id=ses_A", nil) + getRRA2 := httptest.NewRecorder() + s.handleGetDraft(getRRA2, getReqA2) + if bytes.Contains(getRRA2.Body.Bytes(), []byte("Content A")) { + t.Fatalf("expected session A to be deleted, got %s", getRRA2.Body.String()) + } + + // Verify session B is untouched + getReqB2 := httptest.NewRequest(http.MethodGet, "/api/research/draft?session_id=ses_B", nil) + getRRB2 := httptest.NewRecorder() + s.handleGetDraft(getRRB2, getReqB2) + if !bytes.Contains(getRRB2.Body.Bytes(), []byte("Content B")) { + t.Fatalf("expected session B to remain untouched after deleting A, got %s", getRRB2.Body.String()) + } + + // 7. Test Export DOCX + docxReq := httptest.NewRequest(http.MethodPost, "/api/research/draft/export-docx", bytes.NewBufferString(`{"title":"Test Doc","content":"# Bab 1\n\nIsi paragraf."}`)) + docxRR := httptest.NewRecorder() + s.handleExportDOCX(docxRR, docxReq) + if docxRR.Code != http.StatusOK { + t.Fatalf("expected 200 on export-docx, got %d", docxRR.Code) + } + if ct := docxRR.Header().Get("Content-Type"); !strings.Contains(ct, "wordprocessingml.document") { + t.Fatalf("expected docx content-type, got %s", ct) + } + if docxRR.Body.Len() == 0 { + t.Fatal("expected non-empty docx output") + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 8e4daebc..4e4f4085 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -80,6 +80,12 @@ func (s *Server) routes() { // session cookie. More specific than "/", so the SPA fallback never sees it. m.HandleFunc("GET /auth/handoff", s.handleHandoffExchange) + // Google OAuth flow for Google Docs & Drive + m.HandleFunc("GET /api/auth/google/url", s.handleGoogleOAuthURL) + m.HandleFunc("GET /api/auth/google/callback", s.handleGoogleOAuthCallback) + m.HandleFunc("GET /api/auth/google/status", s.handleGoogleOAuthStatus) + m.HandleFunc("POST /api/auth/google/disconnect", s.handleGoogleOAuthDisconnect) + // Paired devices (desktop app, CLI) and the desktop contract version. m.HandleFunc("GET /api/version", s.handleVersion) m.HandleFunc("POST /api/devices/pair", s.handlePairDevice) @@ -120,6 +126,22 @@ func (s *Server) routes() { m.HandleFunc("GET /api/ui/modules", s.handleGetModules) m.HandleFunc("POST /api/ui/modules", s.handleSetModules) + // Academic & Research settings (Zotero & Google Docs) + m.HandleFunc("GET /api/research/settings", s.handleGetResearchSettings) + m.HandleFunc("POST /api/research/settings", s.handleSaveResearchSettings) + m.HandleFunc("POST /api/research/gdocs/rollback", s.handleGDocsRollback) + m.HandleFunc("POST /api/research/gdocs/export", s.handleGDocsExport) + m.HandleFunc("POST /api/research/gdocs/sync", s.handleGDocsSyncPush) + m.HandleFunc("PUT /api/research/gdocs/sync", s.handleGDocsSyncPush) + m.HandleFunc("POST /api/research/gdocs/pull", s.handleGDocsSyncPull) + m.HandleFunc("GET /api/research/draft", s.handleGetDraft) + m.HandleFunc("POST /api/research/draft", s.handleSaveDraft) + m.HandleFunc("DELETE /api/research/draft", s.handleDeleteDraft) + m.HandleFunc("POST /api/research/draft/clear", s.handleDeleteDraft) + m.HandleFunc("POST /api/research/draft/export-docx", s.handleExportDOCX) + m.HandleFunc("GET /api/research/draft/export-docx", s.handleExportDOCX) + m.HandleFunc("POST /api/research/open-browser", s.handleOpenBrowser) + // Models & providers m.HandleFunc("GET /api/model/options", s.handleModelOptions) m.HandleFunc("GET /api/context-window", s.handleContextWindow) @@ -163,7 +185,13 @@ func (s *Server) routes() { // Files m.HandleFunc("GET /api/files", s.handleListFiles) m.HandleFunc("GET /api/files/read", s.handleReadFile) + m.HandleFunc("POST /api/files/write", s.handleWriteFile) + m.HandleFunc("POST /api/files/create", s.handleCreateFile) + m.HandleFunc("POST /api/files/rename", s.handleRenameFile) + m.HandleFunc("DELETE /api/files", s.handleDeleteFile) m.HandleFunc("GET /api/files/raw", s.handleRawFile) + // IDE terminal: a real PTY over WebSocket, confined to the project cwd. + m.HandleFunc("GET /api/terminal/ws", s.handleTerminalWS) m.HandleFunc("GET /api/social/image", s.handleSocialImage) // Project sessions: browse the machine's directories for the folder picker. diff --git a/internal/server/security.go b/internal/server/security.go index be29b573..9c97ee53 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -22,6 +22,7 @@ func queryTokenAllowed(path string) bool { "/api/swarm/stream", "/api/board/stream", "/api/files/raw", + "/api/terminal/ws", "/api/social/image": return true default: diff --git a/internal/server/server.go b/internal/server/server.go index 66569df5..b1c4fa8a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -75,6 +75,12 @@ type Server struct { // devAuth caches device-token lookups and holds handoff codes and the // failed-login counters. devAuth deviceAuthState + + // termMu guards termCount, the number of live IDE terminal sessions. The + // terminal endpoint is deliberately capped: every session is a real PTY + // process, and an unbounded number of them is a trivial resource exhaustion. + termMu sync.Mutex + termCount int } // Options configures a Server. @@ -220,6 +226,14 @@ func (w *statusWriter) Flush() { } } +// Unwrap exposes the underlying writer to http.ResponseController and to +// libraries that walk it looking for optional interfaces. WebSocket upgrades +// in particular need the underlying http.Hijacker; without this the upgrade +// fails with 501 because statusWriter itself cannot be hijacked. +func (w *statusWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + func (s *Server) withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/") { @@ -320,7 +334,8 @@ func authExemptPath(p string) bool { switch p { case "/api/health", "/api/version", "/api/auth/status", "/api/auth/login", "/api/auth/logout", "/api/auth/password", - "/api/devices/pair": + "/api/devices/pair", + "/api/auth/google/callback": return true } return false diff --git a/internal/server/terminal.go b/internal/server/terminal.go new file mode 100644 index 00000000..f1cea0b3 --- /dev/null +++ b/internal/server/terminal.go @@ -0,0 +1,272 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/enowdev/antares/internal/config" +) + +const ( + // terminalMaxSessions caps concurrent PTY processes. The dashboard opens one + // per chat window; anything beyond this is either a runaway tab or a client + // trying to exhaust the daemon. + terminalMaxSessions = 4 + // terminalReadLimit is the largest single input frame accepted. A paste is + // normally far below this; the limit bounds a hostile client's memory. + terminalReadLimit = 1 << 20 +) + +var errTerminalUnsupported = errors.New("interactive terminal is not supported on this platform") + +// terminalSession owns one PTY and its child process. +type terminalSession struct { + ptmx *os.File + cmd *exec.Cmd + done chan int + once sync.Once +} + +func (t *terminalSession) Close() { + t.once.Do(func() { + killTerminalProcess(t.cmd) + _ = t.ptmx.Close() + }) +} + +// waitExit waits briefly for a reaped exit code. The output pump reaches EOF +// when the shell exits, but cmd.Wait may still be scheduling. +func (t *terminalSession) waitExit(timeout time.Duration) int { + select { + case code := <-t.done: + return code + case <-time.After(timeout): + return -1 + } +} + +func newTerminalSession(ptmx *os.File, cmd *exec.Cmd) *terminalSession { + s := &terminalSession{ptmx: ptmx, cmd: cmd, done: make(chan int, 1)} + go func() { + err := cmd.Wait() + code := 0 + if err != nil { + var exit *exec.ExitError + if errors.As(err, &exit) { + code = exit.ExitCode() + } else { + code = -1 + } + } + s.done <- code + }() + return s +} + +func (s *Server) acquireTerminal() bool { + s.termMu.Lock() + defer s.termMu.Unlock() + if s.termCount >= terminalMaxSessions { + return false + } + s.termCount++ + return true +} + +func (s *Server) releaseTerminal() { + s.termMu.Lock() + if s.termCount > 0 { + s.termCount-- + } + s.termMu.Unlock() +} + +// resolveTerminalDir reads ?dir= like the file viewer, but falls back to the +// agent workspace. The terminal is confined to this directory as its cwd (the +// shell itself can still `cd` elsewhere — that is what a shell is). +func (s *Server) resolveTerminalDir(w http.ResponseWriter, r *http.Request) (string, bool) { + dir := strings.TrimSpace(r.URL.Query().Get("dir")) + if dir == "" { + dir = s.config().Agent.Workspace + } + if dir == "" { + dir = config.Home() + } + dir = filepath.Clean(config.Expand(dir)) + if !filepath.IsAbs(dir) { + writeError(w, http.StatusBadRequest, errors.New("dir must be an absolute path")) + return "", false + } + fi, err := os.Stat(dir) + if err != nil || !fi.IsDir() { + writeError(w, http.StatusBadRequest, errors.New("not a directory")) + return "", false + } + return dir, true +} + +// terminalShell picks the interactive shell. A configured terminal.shell wins +// (the agent's setting is usually bash/sh, which is fine for a PTY). Otherwise +// the user's login shell is the least surprising choice for an IDE terminal. +func terminalShell(configured string) string { + if configured != "" { + return configured + } + if sh := os.Getenv("SHELL"); filepath.IsAbs(sh) { + if fi, err := os.Stat(sh); err == nil && !fi.IsDir() { + return sh + } + } + for _, sh := range []string{"/bin/bash", "/bin/sh"} { + if fi, err := os.Stat(sh); err == nil && !fi.IsDir() { + return sh + } + } + return "sh" +} + +func terminalSize(r *http.Request) (uint16, uint16) { + clamp := func(key string, def, min, max uint16) uint16 { + n := parseUint16(r.URL.Query().Get(key)) + if n == 0 { + return def + } + if n < min { + return min + } + if n > max { + return max + } + return n + } + return clamp("cols", 80, 20, 500), clamp("rows", 24, 5, 300) +} + +func parseUint16(v string) uint16 { + var n uint32 + for _, c := range v { + if c < '0' || c > '9' { + return 0 + } + n = n*10 + uint32(c-'0') + if n > 65535 { + return 0 + } + } + return uint16(n) +} + +type terminalClientMessage struct { + Type string `json:"type"` + Data string `json:"data"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` +} + +// handleTerminalWS bridges an xterm.js instance to a real PTY. The protocol is +// deliberately tiny: +// +// client → server: {"type":"input","data":"..."} | {"type":"resize","cols":80,"rows":24} +// server → client: binary stdout/stderr | {"type":"exit","code":0} +// +// Auth is the normal dashboard auth; the token may also arrive as ?token= +// because the browser WebSocket API cannot set an Authorization header. +func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + dir, ok := s.resolveTerminalDir(w, r) + if !ok { + return + } + if !s.acquireTerminal() { + writeError(w, http.StatusTooManyRequests, errors.New("too many terminal sessions")) + return + } + defer s.releaseTerminal() + + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + // Same host is always allowed. The Vite dev server is a different origin + // (5174 → 8790), so name those two loopback hosts explicitly instead of + // disabling origin checks wholesale. + OriginPatterns: []string{"127.0.0.1:5174", "localhost:5174"}, + }) + if err != nil { + slog.Warn("terminal websocket accept failed", "error", err) + return + } + defer conn.CloseNow() + conn.SetReadLimit(terminalReadLimit) + + cols, rows := terminalSize(r) + sess, err := startTerminalPTY(terminalShell(s.config().Terminal.Shell), dir, cols, rows) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, err.Error()) + return + } + defer sess.Close() + + ctx := context.Background() + go pumpTerminalOutput(ctx, conn, sess) + + for { + typ, data, err := conn.Read(ctx) + if err != nil { + return + } + if typ != websocket.MessageText { + continue + } + var msg terminalClientMessage + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + switch msg.Type { + case "input": + if _, err := sess.ptmx.Write([]byte(msg.Data)); err != nil { + return + } + case "resize": + if msg.Cols == 0 || msg.Rows == 0 { + continue + } + if err := resizeTerminalPTY(sess, msg.Cols, msg.Rows); err != nil { + slog.Debug("terminal resize failed", "error", err) + } + } + } +} + +func pumpTerminalOutput(ctx context.Context, conn *websocket.Conn, sess *terminalSession) { + buf := make([]byte, 32<<10) + for { + n, err := sess.ptmx.Read(buf) + if n > 0 { + writeCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + werr := conn.Write(writeCtx, websocket.MessageBinary, buf[:n]) + cancel() + if werr != nil { + return + } + } + if err != nil { + code := sess.waitExit(500 * time.Millisecond) + msg, _ := json.Marshal(map[string]any{"type": "exit", "code": code}) + writeCtx, cancel := context.WithTimeout(ctx, time.Second) + _ = conn.Write(writeCtx, websocket.MessageText, msg) + cancel() + _ = conn.Close(websocket.StatusNormalClosure, "process exited") + return + } + } +} diff --git a/internal/server/terminal_pty_other.go b/internal/server/terminal_pty_other.go new file mode 100644 index 00000000..89f6b2a2 --- /dev/null +++ b/internal/server/terminal_pty_other.go @@ -0,0 +1,20 @@ +//go:build !(aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris) + +package server + +import "os/exec" + +func startTerminalPTY(shell, dir string, cols, rows uint16) (*terminalSession, error) { + return nil, errTerminalUnsupported +} + +func resizeTerminalPTY(sess *terminalSession, cols, rows uint16) error { + return errTerminalUnsupported +} + +func killTerminalProcess(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + _ = cmd.Process.Kill() +} diff --git a/internal/server/terminal_pty_unix.go b/internal/server/terminal_pty_unix.go new file mode 100644 index 00000000..871da4d3 --- /dev/null +++ b/internal/server/terminal_pty_unix.go @@ -0,0 +1,41 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package server + +import ( + "os" + "os/exec" + "syscall" + + "github.com/creack/pty" +) + +func startTerminalPTY(shell, dir string, cols, rows uint16) (*terminalSession, error) { + cmd := exec.Command(shell) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "TERM=xterm-256color", + "COLORTERM=truecolor", + "ANTARES_TERMINAL=1", + ) + f, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows}) + if err != nil { + return nil, err + } + return newTerminalSession(f, cmd), nil +} + +func resizeTerminalPTY(sess *terminalSession, cols, rows uint16) error { + return pty.Setsize(sess.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) +} + +func killTerminalProcess(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + // pty.Start puts the shell in its own session, so the process group id is + // the pid. Signal the whole group: killing only the shell would orphan a dev + // server it started, which then keeps the port busy forever. + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Process.Kill() +} diff --git a/internal/server/terminal_unix_test.go b/internal/server/terminal_unix_test.go new file mode 100644 index 00000000..86afd73c --- /dev/null +++ b/internal/server/terminal_unix_test.go @@ -0,0 +1,204 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/enowdev/antares/internal/config" +) + +func TestTerminalPTYRoundTrip(t *testing.T) { + dir := t.TempDir() + sess, err := startTerminalPTY("/bin/sh", dir, 80, 24) + if err != nil { + t.Fatal(err) + } + defer sess.Close() + + if _, err := sess.ptmx.Write([]byte("printf 'pty-ready\\n'; exit 3\n")); err != nil { + t.Fatal(err) + } + read := make(chan string, 1) + go func() { + var b strings.Builder + buf := make([]byte, 4096) + for { + n, err := sess.ptmx.Read(buf) + if n > 0 { + b.Write(buf[:n]) + if strings.Contains(b.String(), "pty-ready") { + read <- b.String() + return + } + } + if err != nil { + read <- b.String() + return + } + } + }() + select { + case out := <-read: + if !strings.Contains(out, "pty-ready") { + t.Fatalf("output = %q", out) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for PTY output") + } + if code := sess.waitExit(5 * time.Second); code != 3 { + t.Fatalf("exit code = %d, want 3", code) + } +} + +func TestTerminalWSRequiresPasswordAndRunsInput(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + dir := t.TempDir() + + unprotected := &Server{cfg: func() *config.Config { + cfg := config.Default() + cfg.Agent.Workspace = dir + return cfg + }()} + ts := httptest.NewServer(http.HandlerFunc(unprotected.handleTerminalWS)) + defer ts.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, resp, err := websocket.Dial(ctx, "ws://"+ts.Listener.Addr().String()+"/?dir="+dir, nil) + if err == nil { + t.Fatal("unprotected terminal WebSocket was accepted") + } + if resp == nil || resp.StatusCode != http.StatusPreconditionRequired { + t.Fatalf("status = %v, want 428", resp) + } + + protected := &Server{cfg: func() *config.Config { + cfg := config.Default() + cfg.Agent.Workspace = dir + cfg.Server.DashboardPasswordHash = "test-hash" + return cfg + }()} + ts2 := httptest.NewServer(http.HandlerFunc(protected.handleTerminalWS)) + defer ts2.Close() + + conn, _, err := websocket.Dial(ctx, "ws://"+ts2.Listener.Addr().String()+"/?dir="+dir+"&cols=100&rows=30", nil) + if err != nil { + t.Fatal(err) + } + defer conn.CloseNow() + + msg, _ := json.Marshal(map[string]string{"type": "input", "data": "printf 'ws-ready\\n'\n"}) + if err := conn.Write(ctx, websocket.MessageText, msg); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(5 * time.Second) + var seen strings.Builder + for time.Now().Before(deadline) { + typ, data, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read: %v (seen %q)", err, seen.String()) + } + if typ == websocket.MessageBinary { + seen.Write(data) + if strings.Contains(seen.String(), "ws-ready") { + return + } + } + } + t.Fatalf("timed out; seen %q", seen.String()) +} + +func TestTerminalWSUpgradesThroughMiddleware(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + dir := t.TempDir() + + cfg := config.Default() + cfg.Agent.Workspace = dir + cfg.Server.AuthToken = "test-token" + s := &Server{ + mux: http.NewServeMux(), + cfg: cfg, + dashSessions: map[string]time.Time{}, + } + s.routes() + ts := httptest.NewServer(s.Handler()) + defer ts.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // The logging middleware wraps the ResponseWriter; the upgrade must still + // reach the underlying Hijacker (regression: statusWriter hid it → 501). + target := "ws://" + ts.Listener.Addr().String() + "/api/terminal/ws?dir=" + url.QueryEscape(dir) + "&token=test-token" + conn, resp, err := websocket.Dial(ctx, target, nil) + if err != nil { + if resp != nil { + t.Fatalf("dial: %v (status %d)", err, resp.StatusCode) + } + t.Fatalf("dial: %v", err) + } + defer conn.CloseNow() + + msg, _ := json.Marshal(map[string]string{"type": "input", "data": "printf 'mid-ready\\n'\n"}) + if err := conn.Write(ctx, websocket.MessageText, msg); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(5 * time.Second) + var seen strings.Builder + for time.Now().Before(deadline) { + typ, data, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read: %v (seen %q)", err, seen.String()) + } + if typ == websocket.MessageBinary { + seen.Write(data) + if strings.Contains(seen.String(), "mid-ready") { + return + } + } + } + t.Fatalf("timed out; seen %q", seen.String()) +} + +func TestResolveTerminalDirFallsBackToWorkspace(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + dir := t.TempDir() + cfg := config.Default() + cfg.Agent.Workspace = dir + s := &Server{cfg: cfg} + req := httptest.NewRequest(http.MethodGet, "/?dir="+filepath.ToSlash(dir), nil) + got, ok := s.resolveTerminalDir(httptest.NewRecorder(), req) + if !ok || got != dir { + t.Fatalf("resolved %q ok=%v, want %q", got, ok, dir) + } + + req = httptest.NewRequest(http.MethodGet, "/", nil) + got, ok = s.resolveTerminalDir(httptest.NewRecorder(), req) + if !ok || got != cfg.Agent.Workspace { + t.Fatalf("fallback = %q ok=%v, want %q", got, ok, cfg.Agent.Workspace) + } + + bad := httptest.NewRequest(http.MethodGet, "/?dir=relative/path", nil) + rr := httptest.NewRecorder() + if _, ok := s.resolveTerminalDir(rr, bad); ok || rr.Code != http.StatusBadRequest { + t.Fatalf("relative dir accepted (status %d)", rr.Code) + } +} + +func TestTerminalSizeClamps(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/?cols=1&rows=9999", nil) + cols, rows := terminalSize(req) + if cols != 20 || rows != 300 { + t.Fatalf("size = %dx%d, want 20x300", cols, rows) + } +} diff --git a/internal/tools/draft.go b/internal/tools/draft.go new file mode 100644 index 00000000..6982620f --- /dev/null +++ b/internal/tools/draft.go @@ -0,0 +1,157 @@ +package tools + +import ( + "context" + "fmt" + "regexp" + "strings" +) + +var svgBlockRe = regexp.MustCompile(`(?s)`) + +func escapeAmpersandsInSVG(svg string) string { + var b strings.Builder + for i := 0; i < len(svg); i++ { + if svg[i] == '&' { + rest := svg[i:] + if strings.HasPrefix(rest, "&") || + strings.HasPrefix(rest, "<") || + strings.HasPrefix(rest, ">") || + strings.HasPrefix(rest, """) || + strings.HasPrefix(rest, "'") { + b.WriteByte('&') + continue + } + b.WriteString("&") + } else { + b.WriteByte(svg[i]) + } + } + return b.String() +} + +func sanitizeDraftContent(s string) string { + return svgBlockRe.ReplaceAllStringFunc(s, escapeAmpersandsInSVG) +} + +// ----------------------------------------------------------------------------- +// 1. draft_read +// ----------------------------------------------------------------------------- + +type draftReadTool struct{} + +func (draftReadTool) Name() string { return "draft_read" } +func (draftReadTool) Description() string { + return "Read the active academic document draft (Artifact) displayed in the user's Split View. Returns the title and current markdown content." +} +func (draftReadTool) RequiresApproval() bool { return false } +func (draftReadTool) Schema() map[string]any { + return schema(map[string]any{}) +} + +func (draftReadTool) Execute(ctx context.Context, in Input) Result { + if in.Deps == nil || in.Deps.Store == nil { + return Errorf("storage service not available") + } + + titleKey := "research:draft:title" + contentKey := "research:draft:content" + if in.SessionID != "" { + titleKey = "research:draft:" + in.SessionID + ":title" + contentKey = "research:draft:" + in.SessionID + ":content" + } + + title, _ := in.Deps.Store.GetKV(ctx, titleKey) + if title == "" && in.SessionID == "" { + title = "Bab 3 - Perancangan Sistem Bilik" + } + content, _ := in.Deps.Store.GetKV(ctx, contentKey) + + return Result{ + Content: fmt.Sprintf("Active Draft Title: %s\n\n```markdown\n%s\n```", title, content), + Meta: map[string]any{ + "title": title, + "content": content, + "length": len(content), + }, + } +} + +// ----------------------------------------------------------------------------- +// 2. draft_update +// ----------------------------------------------------------------------------- + +type draftUpdateTool struct{} + +func (draftUpdateTool) Name() string { return "draft_update" } +func (draftUpdateTool) Description() string { + return "Write or update the active academic document draft (Artifact) displayed in the user's Split View. Supports full Markdown, structured tables, and SVG diagrams. The user can preview this draft live in their split view and export it to Google Docs with one click." +} +func (draftUpdateTool) RequiresApproval() bool { return false } +func (draftUpdateTool) Schema() map[string]any { + return schema(map[string]any{ + "content": prop("string", "The Markdown/HTML content to write to the draft document."), + "title": prop("string", "Optional title for the document (e.g. 'Bab 3 - Perancangan Sistem Bilik')."), + "mode": propEnum("Update mode: 'replace' to overwrite the whole draft, or 'append' to add to the end of the draft. Default: 'replace'.", "replace", "append"), + }, "content") +} + +func (draftUpdateTool) Execute(ctx context.Context, in Input) Result { + var args struct { + Content string `json:"content"` + Title string `json:"title"` + Mode string `json:"mode"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + if in.Deps == nil || in.Deps.Store == nil { + return Errorf("storage service not available") + } + + mode := strings.TrimSpace(strings.ToLower(args.Mode)) + if mode == "" { + mode = "replace" + } + + titleKey := "research:draft:title" + contentKey := "research:draft:content" + if in.SessionID != "" { + titleKey = "research:draft:" + in.SessionID + ":title" + contentKey = "research:draft:" + in.SessionID + ":content" + } + + finalContent := sanitizeDraftContent(args.Content) + if mode == "append" { + existing, _ := in.Deps.Store.GetKV(ctx, contentKey) + if existing != "" { + finalContent = strings.TrimRight(existing, "\n") + "\n\n" + strings.TrimLeft(finalContent, "\n") + } + } + + _ = in.Deps.Store.SetKV(ctx, contentKey, finalContent) + + title := strings.TrimSpace(args.Title) + if title != "" { + _ = in.Deps.Store.SetKV(ctx, titleKey, title) + } else { + title, _ = in.Deps.Store.GetKV(ctx, titleKey) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Berhasil memperbarui Draf Naskah di Split View (Judul: %q):\n", title)) + sb.WriteString(fmt.Sprintf("• Mode: %s\n", mode)) + sb.WriteString(fmt.Sprintf("• Panjang isi: %d karakter\n", len(finalContent))) + sb.WriteString("• Draf ini sekarang langsung tampil di tab 'Draf Naskah' Split View dan siap diekspor ke Google Docs oleh pengguna.") + + return Result{ + Content: sb.String(), + Meta: map[string]any{ + "status": "success", + "title": title, + "mode": mode, + "length": len(finalContent), + }, + } +} diff --git a/internal/tools/gdocs.go b/internal/tools/gdocs.go new file mode 100644 index 00000000..7b30a7c1 --- /dev/null +++ b/internal/tools/gdocs.go @@ -0,0 +1,1449 @@ +package tools + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf16" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/store" +) + +func extractDocIDFromURL(raw string) string { + raw = strings.TrimSpace(raw) + if idx := strings.Index(raw, "/document/d/"); idx != -1 { + rest := raw[idx+len("/document/d/"):] + if end := strings.Index(rest, "/"); end != -1 { + return rest[:end] + } + if end := strings.Index(rest, "?"); end != -1 { + return rest[:end] + } + return rest + } + return raw +} + +// resolveGDocsEndpoint determines whether to use webhook or oauth/rest +func resolveGDocsEndpoint(in Input, argWebhookURL, argDocumentID string) (method, target string, err error) { + webhookURL := strings.TrimSpace(argWebhookURL) + docID := extractDocIDFromURL(argDocumentID) + + preferredMethod := "webhook" + if in.Deps != nil && in.Deps.Config != nil { + cfg := in.Deps.Config + if cfg.Research.GDocs.Method != "" { + preferredMethod = strings.ToLower(cfg.Research.GDocs.Method) + } + if webhookURL == "" { + webhookURL = strings.TrimSpace(cfg.Research.GDocs.WebhookURL) + } + if docID == "" { + if cfg.Research.GDocs.DocumentID != "" { + docID = strings.TrimSpace(cfg.Research.GDocs.DocumentID) + } else if cfg.Research.GDocs.DocumentURL != "" { + docID = extractDocIDFromURL(cfg.Research.GDocs.DocumentURL) + } + } + } + + if preferredMethod == "oauth" && docID != "" { + if err := ValidateGDocsDocID(docID); err != nil { + return "", "", err + } + return "oauth", docID, nil + } + if webhookURL != "" { + if err := ValidateGDocsWebhookURL(webhookURL); err != nil { + return "", "", err + } + return "webhook", webhookURL, nil + } + if docID != "" { + if err := ValidateGDocsDocID(docID); err != nil { + return "", "", err + } + return "oauth", docID, nil + } + + return "", "", fmt.Errorf("no Google Docs destination specified. Pass webhook_url or document_id (or configure research.gdocs.webhook_url / document_url in settings)") +} + +func ResolveActiveGoogleToken(ctx context.Context, cfg *config.Config, s store.Store) (string, error) { + if cfg == nil || s == nil { + return "", fmt.Errorf("runtime dependencies not available") + } + clientID := strings.TrimSpace(cfg.Research.GDocs.OAuthClientID) + clientSecret := strings.TrimSpace(cfg.Research.GDocs.OAuthClientSecret) + if clientID == "" || clientSecret == "" { + return "", fmt.Errorf("Google OAuth Client ID or Client Secret is missing. Please configure them in Research Settings") + } + + refreshToken, _ := s.GetKV(ctx, "research:gdocs:refresh_token") + accessToken, _ := s.GetKV(ctx, "research:gdocs:access_token") + expiryStr, _ := s.GetKV(ctx, "research:gdocs:token_expiry") + var expiresAt int64 + if expiryStr != "" { + expiresAt, _ = strconv.ParseInt(expiryStr, 10, 64) + } + + // If access token is still valid (> 2 minutes remaining), reuse it + if accessToken != "" && time.Now().Unix() < (expiresAt-120) { + return accessToken, nil + } + + if refreshToken == "" { + if accessToken != "" { + return accessToken, nil + } + return "", fmt.Errorf("Google account is not connected yet. Click 'Hubungkan Akun Google' in Research Settings") + } + + // Refresh token via Google OAuth endpoint + data := url.Values{} + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("refresh_token", refreshToken) + data.Set("grant_type", "refresh_token") + + req, err := http.NewRequestWithContext(ctx, "POST", "https://oauth2.googleapis.com/token", strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("Google token refresh request failed: %w", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Google token refresh error (%d): %s", resp.StatusCode, string(bodyBytes)) + } + + var res struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(bodyBytes, &res); err != nil { + return "", err + } + if res.AccessToken == "" { + return "", fmt.Errorf("empty access token returned on refresh") + } + + // Cache new access token + _ = s.SetKV(ctx, "research:gdocs:access_token", res.AccessToken) + newExpiry := time.Now().Add(time.Duration(res.ExpiresIn) * time.Second).Unix() + _ = s.SetKV(ctx, "research:gdocs:token_expiry", strconv.FormatInt(newExpiry, 10)) + + return res.AccessToken, nil +} + +func getActiveGoogleToken(ctx context.Context, in Input) (string, error) { + if in.Deps == nil || in.Deps.Store == nil || in.Deps.Config == nil { + return "", fmt.Errorf("runtime dependencies not available") + } + return ResolveActiveGoogleToken(ctx, in.Deps.Config, in.Deps.Store) +} + +// ----------------------------------------------------------------------------- +// 1. gdocs_read +// ----------------------------------------------------------------------------- + +type gdocsReadTool struct{} + +func (gdocsReadTool) Name() string { return "gdocs_read" } +func (gdocsReadTool) Description() string { + return "Read the current text content of a Google Doc. Always use this instead of the browser tool for Google Docs. Works via an Apps Script Webhook URL or via document_id. Returns the document title and plain text body." +} +func (gdocsReadTool) RequiresApproval() bool { return false } + +func (gdocsReadTool) Schema() map[string]any { + return schema(map[string]any{ + "webhook_url": prop("string", "Google Apps Script Webhook URL for this document (optional if configured)."), + "document_id": prop("string", "Google Docs Document ID or full URL (optional if configured)."), + }) +} + +func (gdocsReadTool) Execute(ctx context.Context, in Input) Result { + var args struct { + WebhookURL string `json:"webhook_url"` + DocumentID string `json:"document_id"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + method, target, err := resolveGDocsEndpoint(in, args.WebhookURL, args.DocumentID) + if err != nil { + return Errorf("%v", err) + } + + client := &http.Client{Timeout: 20 * time.Second} + + if method == "webhook" { + // Call webhook GET or POST with action=read + reqURL, err := url.Parse(target) + if err != nil { + return Errorf("invalid webhook url: %v", err) + } + q := reqURL.Query() + q.Set("action", "read") + reqURL.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL.String(), nil) + if err != nil { + return Errorf("create request: %v", err) + } + + resp, err := client.Do(req) + if err != nil { + return Errorf("fetch google doc: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return Errorf("webhook returned HTTP %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var parsed struct { + Status string `json:"status"` + Title string `json:"title"` + Content string `json:"content"` + Error string `json:"error"` + } + if err := json.Unmarshal(bodyBytes, &parsed); err == nil && (parsed.Content != "" || parsed.Title != "") { + if parsed.Error != "" { + return Errorf("webhook error: %s", parsed.Error) + } + return Text(fmt.Sprintf("Google Doc: %s\n\n%s", parsed.Title, parsed.Content)) + } + + return Text(string(bodyBytes)) + } + + // OAuth mode with document_id: call Google Docs REST API + token, err := getActiveGoogleToken(ctx, in) + if err != nil { + return Errorf("%v", err) + } + + reqURL := fmt.Sprintf("https://docs.googleapis.com/v1/documents/%s", url.PathEscape(target)) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + if err != nil { + return Errorf("fetch google doc via REST API: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return Errorf("Google Docs API returned HTTP %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var doc struct { + Title string `json:"title"` + Body struct { + Content []docContentItem `json:"content"` + } `json:"body"` + } + + if err := json.Unmarshal(bodyBytes, &doc); err != nil { + return Errorf("parse document structure: %v", err) + } + + var sb strings.Builder + tableCount := 0 + + for _, c := range doc.Body.Content { + if c.Paragraph != nil { + headingPrefix := "" + if c.Paragraph.ParagraphStyle != nil { + switch c.Paragraph.ParagraphStyle.NamedStyleType { + case "TITLE": + headingPrefix = "# " + case "HEADING_1": + headingPrefix = "# " + case "HEADING_2": + headingPrefix = "## " + case "HEADING_3": + headingPrefix = "### " + case "HEADING_4": + headingPrefix = "#### " + } + } + + var paraText strings.Builder + for _, elem := range c.Paragraph.Elements { + if elem.TextRun != nil { + paraText.WriteString(elem.TextRun.Content) + } + } + text := paraText.String() + if strings.TrimSpace(text) != "" { + if headingPrefix != "" && !strings.HasPrefix(strings.TrimSpace(text), "#") { + sb.WriteString(headingPrefix + strings.TrimSpace(text) + "\n\n") + } else { + sb.WriteString(text) + } + } + } else if c.Table != nil { + tableCount++ + sb.WriteString(fmt.Sprintf("\n\n\n", tableCount, c.Table.Rows, c.Table.Columns)) + for rIdx, row := range c.Table.TableRows { + sb.WriteString("|") + for _, cell := range row.TableCells { + var cellText strings.Builder + for _, cellItem := range cell.Content { + if cellItem.Paragraph != nil { + for _, el := range cellItem.Paragraph.Elements { + if el.TextRun != nil { + cellText.WriteString(strings.TrimSpace(el.TextRun.Content) + " ") + } + } + } + } + cleanCell := strings.TrimSpace(cellText.String()) + if cleanCell == "" { + cleanCell = " " + } + sb.WriteString(" " + cleanCell + " |") + } + sb.WriteString("\n") + if rIdx == 0 { + sb.WriteString("|") + for col := 0; col < c.Table.Columns; col++ { + sb.WriteString(" --- |") + } + sb.WriteString("\n") + } + } + sb.WriteString("\n") + } + } + + return Text(fmt.Sprintf("Google Doc: %s (ID: %s)\n\n%s", doc.Title, target, strings.TrimSpace(sb.String()))) +} + +// ----------------------------------------------------------------------------- +// 2. gdocs_update (Requires User Approval: Terima / Tolak) +// ----------------------------------------------------------------------------- + +type ZoteroCitationItem struct { + Key string `json:"key"` + Citation string `json:"citation"` + Title string `json:"title,omitempty"` +} + +func randomAlphanumeric(n int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, n) + max := big.NewInt(int64(len(charset))) + for i := range b { + val, err := rand.Int(rand.Reader, max) + if err != nil { + b[i] = charset[i%len(charset)] + } else { + b[i] = charset[val.Int64()] + } + } + return string(b) +} + +func utf16Len(s string) int { + return len(utf16.Encode([]rune(s))) +} + +func resolveZoteroUserID(in Input) string { + if in.Deps != nil && in.Deps.Config != nil { + id := strings.TrimSpace(in.Deps.Config.Research.Zotero.UserID) + if id != "" { + return id + } + } + return "0" +} + +// encodeZoteroNamedRanges splits the CSL citation payload into chunked Named Ranges +// adhering to the Zotero Google Docs Connector protocol (max 255 chars per range name). +func encodeZoteroNamedRanges(fieldKey, code string) []string { + prefix := "Z_F" + fieldKey + var chunks []string + i := 0 + maxChunkLen := 255 - len(prefix) - 3 // 255 - 9 - 3 = 243 + remaining := code + for len(remaining) > 0 { + idxStr := fmt.Sprintf("%03d", i) + take := maxChunkLen + if len(remaining) < take { + take = len(remaining) + } + chunk := prefix + idxStr + remaining[:take] + chunks = append(chunks, chunk) + remaining = remaining[take:] + i++ + } + return chunks +} + +type cslCitationItem struct { + ID string `json:"id"` + URIs []string `json:"uris"` + ItemData map[string]any `json:"itemData,omitempty"` +} + +type cslCitationPayload struct { + CitationID string `json:"citationID"` + Properties struct { + FormattedCitation string `json:"formattedCitation"` + PlainCitation string `json:"plainCitation"` + NoteIndex int `json:"noteIndex"` + } `json:"properties"` + CitationItems []cslCitationItem `json:"citationItems"` + Schema string `json:"schema"` +} + +func buildZoteroCitationCode(citationID, formattedCitation, userID, itemKey, title string) string { + var payload cslCitationPayload + payload.CitationID = citationID + payload.Properties.FormattedCitation = formattedCitation + payload.Properties.PlainCitation = formattedCitation + payload.Properties.NoteIndex = 0 + payload.Schema = "https://github.com/citation-style-language/schema/raw/master/csl-citation.json" + + uri := fmt.Sprintf("http://zotero.org/users/%s/items/%s", userID, itemKey) + itemData := map[string]any{ + "id": itemKey, + } + if title != "" { + itemData["title"] = title + } + payload.CitationItems = []cslCitationItem{ + { + ID: itemKey, + URIs: []string{uri}, + ItemData: itemData, + }, + } + jsonBytes, _ := json.Marshal(payload) + return "ITEM CSL_CITATION " + string(jsonBytes) +} + +type gdocsUpdateTool struct{} + +func (gdocsUpdateTool) Name() string { return "gdocs_update" } +func (gdocsUpdateTool) Description() string { + return "Append or update text, headings, citations, and bibliographies in a Google Doc. Always use this instead of the browser tool to write or edit Google Docs. Citations can be passed via zotero_items or item_keys to embed official Zotero Named Ranges recognized by the Zotero Google Docs extension. Triggers an approval prompt (Terima/Tolak) for the user before making changes." +} + +// RequiresApproval gates this tool so the user sees the diff and can accept or deny it. +func (gdocsUpdateTool) RequiresApproval() bool { return true } + +func (gdocsUpdateTool) Schema() map[string]any { + return schema(map[string]any{ + "text": prop("string", "The text or paragraph content to insert/append to the Google Doc. You can include inline [cite:ITEMKEY] tags to place citations at specific locations within the text."), + "find_text": prop("string", "Anchor text or heading to target a specific position in the document (e.g. 'BAB 3 BILIK' or '3.1 Latar Belakang'). If omitted, appends to the end of the document."), + "position": propEnum("Target position relative to find_text: 'after' (insert right after the matching section), 'before' (insert right before), 'replace' (replace the matching text or section). Default: 'after' when find_text is provided.", "after", "before", "replace"), + "end_text": prop("string", "Optional end anchor when position is 'replace'. Replaces the entire section between find_text and end_text (e.g. find_text: 'BAB 3 BILIK', end_text: 'BAB 4')."), + "heading": propEnum("Optional heading style for this section.", "HEADING_1", "HEADING_2", "HEADING_3", "NORMAL_TEXT"), + "font": propDefault("string", "Font family for inserted text (e.g. 'Times New Roman', 'Arial'). Default: 'Times New Roman'.", "Times New Roman"), + "align": propEnum("Paragraph alignment: JUSTIFIED, START, CENTER, END. Default: JUSTIFIED.", "JUSTIFIED", "START", "CENTER", "END"), + "citations": map[string]any{ + "type": "array", + "description": "List of formatted citations used in this text (e.g. from zotero_cite).", + "items": map[string]any{"type": "string"}, + }, + "zotero_items": map[string]any{ + "type": "array", + "description": "List of Zotero citation items to embed as official Zotero Google Docs fields (Named Ranges). Each item has 'key' (Zotero item key) and 'citation' (in-text citation, e.g. '(Smith, 2020)').", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "key": map[string]any{"type": "string", "description": "Zotero item key (e.g. UXDB5SN6)"}, + "citation": map[string]any{"type": "string", "description": "Formatted in-text citation string (e.g. (Smith, 2020))"}, + "title": map[string]any{"type": "string", "description": "Optional title of the cited paper"}, + }, + "required": []string{"key", "citation"}, + }, + }, + "item_keys": map[string]any{ + "type": "array", + "description": "Optional Zotero item keys matching the citations array to automatically generate official Zotero Named Range fields.", + "items": map[string]any{"type": "string"}, + }, + "bibliography": map[string]any{ + "type": "array", + "description": "List of bibliographic reference entries to append to the end of the document.", + "items": map[string]any{"type": "string"}, + }, + "table": map[string]any{ + "type": "object", + "description": "Optional table creation. Generates a formal bordered table with rows and columns at the target insertion position.", + "properties": map[string]any{ + "rows": map[string]any{"type": "integer", "description": "Number of rows (including header)"}, + "columns": map[string]any{"type": "integer", "description": "Number of columns"}, + "data": map[string]any{ + "type": "array", + "description": "2D array of cell string values, e.g. [['No', 'Item'], ['1', 'Data']]", + "items": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + }, + }, + }, + }, + "webhook_url": prop("string", "Google Apps Script Webhook URL (optional if configured)."), + "document_id": prop("string", "Google Docs Document ID or full URL (optional if configured)."), + }, "text") +} + +type docTableData struct { + Rows int `json:"rows"` + Columns int `json:"columns"` + TableRows []struct { + StartIndex int `json:"startIndex"` + EndIndex int `json:"endIndex"` + TableCells []struct { + StartIndex int `json:"startIndex"` + EndIndex int `json:"endIndex"` + Content []docContentItem `json:"content"` + } `json:"tableCells"` + } `json:"tableRows"` +} + +type docContentElement struct { + StartIndex int `json:"startIndex"` + EndIndex int `json:"endIndex"` + TextRun *struct { + Content string `json:"content"` + } `json:"textRun"` +} + +type docParagraphStyle struct { + NamedStyleType string `json:"namedStyleType"` + Alignment string `json:"alignment"` +} + +type docContentItem struct { + StartIndex int `json:"startIndex"` + EndIndex int `json:"endIndex"` + Paragraph *struct { + ParagraphStyle *docParagraphStyle `json:"paragraphStyle"` + Elements []docContentElement `json:"elements"` + } `json:"paragraph"` + Table *docTableData `json:"table"` +} + +type textMatchRange struct { + found bool + matchStart int + matchEnd int + paragraphStart int + paragraphEnd int +} + +func findTextInDoc(contents []docContentItem, query string) textMatchRange { + query = strings.TrimSpace(query) + if query == "" { + return textMatchRange{} + } + queryLower := strings.ToLower(query) + + // Search paragraph-by-paragraph first + for _, c := range contents { + if c.Paragraph == nil { + continue + } + var paraText strings.Builder + var indexMap []int + + for _, elem := range c.Paragraph.Elements { + if elem.TextRun == nil { + continue + } + runStr := elem.TextRun.Content + runStart := elem.StartIndex + offset := 0 + for _, r := range runStr { + rUnits := utf16Len(string(r)) + paraText.WriteRune(r) + for u := 0; u < rUnits; u++ { + indexMap = append(indexMap, runStart+offset+u) + } + offset += rUnits + } + } + + fullStr := paraText.String() + pos := strings.Index(strings.ToLower(fullStr), queryLower) + if pos != -1 && pos < len(indexMap) { + mStart := indexMap[pos] + endPos := pos + utf16Len(query) + mEnd := mStart + utf16Len(query) + if endPos <= len(indexMap) { + mEnd = indexMap[endPos-1] + 1 + } + return textMatchRange{ + found: true, + matchStart: mStart, + matchEnd: mEnd, + paragraphStart: c.StartIndex, + paragraphEnd: c.EndIndex, + } + } + } + + // Document-wide fallback search across paragraph boundaries + var docText strings.Builder + var docIndexMap []int + type pBoundary struct { + start int + end int + } + var charToPara []pBoundary + + for _, c := range contents { + if c.Paragraph == nil { + continue + } + pBound := pBoundary{start: c.StartIndex, end: c.EndIndex} + for _, elem := range c.Paragraph.Elements { + if elem.TextRun == nil { + continue + } + runStr := elem.TextRun.Content + runStart := elem.StartIndex + offset := 0 + for _, r := range runStr { + rUnits := utf16Len(string(r)) + docText.WriteRune(r) + for u := 0; u < rUnits; u++ { + docIndexMap = append(docIndexMap, runStart+offset+u) + charToPara = append(charToPara, pBound) + } + offset += rUnits + } + } + } + + fullDocStr := docText.String() + pos := strings.Index(strings.ToLower(fullDocStr), queryLower) + if pos != -1 && pos < len(docIndexMap) { + mStart := docIndexMap[pos] + endPos := pos + utf16Len(query) + mEnd := mStart + utf16Len(query) + if endPos <= len(docIndexMap) { + mEnd = docIndexMap[endPos-1] + 1 + } + pBound := charToPara[pos] + return textMatchRange{ + found: true, + matchStart: mStart, + matchEnd: mEnd, + paragraphStart: pBound.start, + paragraphEnd: pBound.end, + } + } + + return textMatchRange{} +} + +type tableInputArg struct { + Rows int `json:"rows"` + Columns int `json:"columns"` + Data [][]string `json:"data"` +} + +func (gdocsUpdateTool) Execute(ctx context.Context, in Input) Result { + var args struct { + Text string `json:"text"` + FindText string `json:"find_text"` + Position string `json:"position"` + EndText string `json:"end_text"` + Heading string `json:"heading"` + Font string `json:"font"` + Align string `json:"align"` + Citations []string `json:"citations"` + ZoteroItems []ZoteroCitationItem `json:"zotero_items"` + ItemKeys []string `json:"item_keys"` + Bibliography []string `json:"bibliography"` + Table *tableInputArg `json:"table"` + WebhookURL string `json:"webhook_url"` + DocumentID string `json:"document_id"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + if strings.TrimSpace(args.Text) == "" && len(args.Bibliography) == 0 && args.Table == nil { + return Errorf("text, bibliography, or table cannot be empty") + } + + fontFamily := strings.TrimSpace(args.Font) + if fontFamily == "" { + fontFamily = "Times New Roman" + } + alignment := strings.TrimSpace(strings.ToUpper(args.Align)) + if alignment == "" { + alignment = "JUSTIFIED" + } + + // Consolidate Zotero citation items + zoteroItems := args.ZoteroItems + if len(zoteroItems) == 0 && len(args.ItemKeys) > 0 { + for i, k := range args.ItemKeys { + citeStr := "[cite:" + k + "]" + if i < len(args.Citations) { + citeStr = args.Citations[i] + } + zoteroItems = append(zoteroItems, ZoteroCitationItem{ + Key: k, + Citation: citeStr, + }) + } + } + + method, target, err := resolveGDocsEndpoint(in, args.WebhookURL, args.DocumentID) + if err != nil { + return Errorf("%v", err) + } + + client := &http.Client{Timeout: 30 * time.Second} + + if method == "webhook" { + payload := map[string]any{ + "action": "append", + "text": args.Text, + "heading": args.Heading, + "citations": args.Citations, + "zotero_items": zoteroItems, + "item_keys": args.ItemKeys, + "bibliography": args.Bibliography, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return Errorf("marshal payload: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", target, bytes.NewReader(payloadBytes)) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return Errorf("send update to Google Doc webhook: %v", err) + } + defer resp.Body.Close() + + respBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return Errorf("webhook responded with HTTP %d: %s", resp.StatusCode, string(respBytes)) + } + + var res struct { + Status string `json:"status"` + Message string `json:"message"` + Error string `json:"error"` + } + if err := json.Unmarshal(respBytes, &res); err == nil && res.Error != "" { + return Errorf("Google Doc webhook error: %s", res.Error) + } + + var sb strings.Builder + sb.WriteString("Berhasil memperbarui Google Doc:\n") + if args.Heading != "" { + sb.WriteString(fmt.Sprintf("• Heading: %s\n", args.Heading)) + } + sb.WriteString(fmt.Sprintf("• Teks ditambahkan: %d karakter\n", len(args.Text))) + if len(args.Citations) > 0 { + sb.WriteString(fmt.Sprintf("• Sitasi disertakan: %s\n", strings.Join(args.Citations, "; "))) + } + if len(zoteroItems) > 0 { + sb.WriteString(fmt.Sprintf("• Item Zotero: %d item\n", len(zoteroItems))) + } + if len(args.Bibliography) > 0 { + sb.WriteString(fmt.Sprintf("• Daftar Pustaka ditambahkan: %d entri\n", len(args.Bibliography))) + } + + return Result{ + Content: strings.TrimSpace(sb.String()), + Meta: map[string]any{ + "status": "success", + "target": target, + "citations": args.Citations, + "zotero_items": zoteroItems, + "bibliography": args.Bibliography, + }, + } + } + + // OAuth mode with document_id: call Google Docs REST API batchUpdate + token, err := getActiveGoogleToken(ctx, in) + if err != nil { + return Errorf("%v", err) + } + + var requests []map[string]any + var citeCount int + + // Fetch document structure to locate exact indices + docReq, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://docs.googleapis.com/v1/documents/%s", url.PathEscape(target)), nil) + if err != nil { + return Errorf("create request: %v", err) + } + docReq.Header.Set("Authorization", "Bearer "+token) + + docResp, err := client.Do(docReq) + if err != nil { + return Errorf("fetch google doc structure: %v", err) + } + defer docResp.Body.Close() + + var docInfo struct { + Body struct { + Content []docContentItem `json:"content"` + } `json:"body"` + } + if err := json.NewDecoder(docResp.Body).Decode(&docInfo); err != nil { + return Errorf("decode google doc structure: %v", err) + } + + insertIndex := 1 + type indexRange struct { + start int + end int + } + var deleteRange *indexRange + + findText := strings.TrimSpace(args.FindText) + posMode := strings.TrimSpace(strings.ToLower(args.Position)) + if posMode == "" { + if findText != "" { + posMode = "after" + } else { + posMode = "append" + } + } + + if findText != "" { + match := findTextInDoc(docInfo.Body.Content, findText) + if !match.found { + return Errorf("penanda find_text %q tidak ditemukan di Google Doc. Gunakan gdocs_read untuk memeriksa teks/judul yang persis ada di dokumen.", findText) + } + + switch posMode { + case "after": + insertIndex = match.paragraphEnd + case "before": + insertIndex = match.paragraphStart + case "replace": + endText := strings.TrimSpace(args.EndText) + if endText != "" { + endMatch := findTextInDoc(docInfo.Body.Content, endText) + if !endMatch.found { + return Errorf("penanda end_text %q tidak ditemukan di Google Doc.", endText) + } + // Replace between match.paragraphEnd (keeping find_text heading intact) + // and endMatch.paragraphStart (keeping end_text heading intact) + delStart := match.paragraphEnd + delEnd := endMatch.paragraphStart + if delStart > delEnd { + delStart = match.matchEnd + delEnd = endMatch.matchStart + } + if delStart >= delEnd { + return Errorf("posisi end_text (%d) berada sebelum atau sama dengan find_text (%d).", delEnd, delStart) + } + deleteRange = &indexRange{start: delStart, end: delEnd} + insertIndex = delStart + } else { + // Replace the match itself (e.g. placeholder tag) + deleteRange = &indexRange{start: match.matchStart, end: match.matchEnd} + insertIndex = match.matchStart + } + default: + insertIndex = match.paragraphEnd + } + } else { + if len(docInfo.Body.Content) > 0 { + lastIdx := docInfo.Body.Content[len(docInfo.Body.Content)-1].EndIndex + if lastIdx > 1 { + insertIndex = lastIdx - 1 + } + } + } + + type citeRange struct { + start int + end int + key string + citation string + title string + } + var citeRanges []citeRange + + var bibRanges []indexRange + + var textBuilder strings.Builder + cursor := insertIndex + + textBuilder.WriteString("\n") + cursor += 1 + contentStart := cursor + + headingStart := 0 + headingEnd := 0 + if args.Heading != "" { + headingStart = cursor + textBuilder.WriteString(args.Heading + "\n") + cursor += utf16Len(args.Heading) + 1 + headingEnd = cursor - 1 + } + + bodyStart := cursor + bodyText := args.Text + hasInlinePlaceholders := false + for _, item := range zoteroItems { + tag := "[cite:" + item.Key + "]" + if strings.Contains(bodyText, tag) { + hasInlinePlaceholders = true + break + } + } + + if hasInlinePlaceholders { + var newBody strings.Builder + remaining := bodyText + for { + earliestTag := "" + earliestPos := -1 + var matchingItem ZoteroCitationItem + for _, item := range zoteroItems { + tag := "[cite:" + item.Key + "]" + pos := strings.Index(remaining, tag) + if pos != -1 && (earliestPos == -1 || pos < earliestPos) { + earliestPos = pos + earliestTag = tag + matchingItem = item + } + } + if earliestPos == -1 { + newBody.WriteString(remaining) + cursor += utf16Len(remaining) + break + } + before := remaining[:earliestPos] + newBody.WriteString(before) + cursor += utf16Len(before) + + cStart := cursor + newBody.WriteString(matchingItem.Citation) + cursor += utf16Len(matchingItem.Citation) + cEnd := cursor + + citeRanges = append(citeRanges, citeRange{ + start: cStart, + end: cEnd, + key: matchingItem.Key, + citation: matchingItem.Citation, + title: matchingItem.Title, + }) + + remaining = remaining[earliestPos+len(earliestTag):] + } + textBuilder.WriteString(newBody.String()) + } else { + textBuilder.WriteString(bodyText) + cursor += utf16Len(bodyText) + + if len(zoteroItems) > 0 { + textBuilder.WriteString(" (") + cursor += 2 + for i, item := range zoteroItems { + if i > 0 { + textBuilder.WriteString("; ") + cursor += 2 + } + cStart := cursor + textBuilder.WriteString(item.Citation) + cursor += utf16Len(item.Citation) + cEnd := cursor + + citeRanges = append(citeRanges, citeRange{ + start: cStart, + end: cEnd, + key: item.Key, + citation: item.Citation, + title: item.Title, + }) + } + textBuilder.WriteString(")") + cursor += 1 + } else if len(args.Citations) > 0 { + cites := " (" + strings.Join(args.Citations, "; ") + ")" + textBuilder.WriteString(cites) + cursor += utf16Len(cites) + } + } + bodyEnd := cursor + + bibHeadingStart := 0 + bibHeadingEnd := 0 + if len(args.Bibliography) > 0 { + textBuilder.WriteString("\n\nDaftar Pustaka:\n") + cursor += 2 + bibHeadingStart = cursor + cursor += utf16Len("Daftar Pustaka:") + 1 + bibHeadingEnd = cursor - 1 + + for i, bib := range args.Bibliography { + entry := fmt.Sprintf("[%d] %s\n", i+1, bib) + bStart := cursor + textBuilder.WriteString(entry) + cursor += utf16Len(entry) + bEnd := cursor - 1 + bibRanges = append(bibRanges, indexRange{start: bStart, end: bEnd}) + } + } + + textBuilder.WriteString("\n") + contentEnd := cursor - 1 + + // Save Snapshot before applying mutations (for Rollback / Undo) + var oldTextDeleted string + if deleteRange != nil && in.Deps != nil && in.Deps.Store != nil { + var oldTextBuilder strings.Builder + for _, c := range docInfo.Body.Content { + if c.Paragraph != nil { + for _, elem := range c.Paragraph.Elements { + if elem.TextRun != nil { + if elem.StartIndex >= deleteRange.start && elem.EndIndex <= deleteRange.end { + oldTextBuilder.WriteString(elem.TextRun.Content) + } + } + } + } + } + oldTextDeleted = oldTextBuilder.String() + + snapshotKey := fmt.Sprintf("research:gdocs:snapshot:%s", target) + snapshotData, _ := json.Marshal(map[string]any{ + "timestamp": time.Now().Unix(), + "target": target, + "find_text": findText, + "delete_start": deleteRange.start, + "delete_end": deleteRange.end, + "old_text": oldTextDeleted, + "inserted_text": textBuilder.String(), + }) + _ = in.Deps.Store.SetKV(ctx, snapshotKey, string(snapshotData)) + } + + // 0. Optional Delete Range (for position="replace") + if deleteRange != nil && deleteRange.start < deleteRange.end { + requests = append(requests, map[string]any{ + "deleteContentRange": map[string]any{ + "range": map[string]any{ + "startIndex": deleteRange.start, + "endIndex": deleteRange.end, + }, + }, + }) + } + + // 1. Insert Text + requests = append(requests, map[string]any{ + "insertText": map[string]any{ + "location": map[string]any{ + "index": insertIndex, + }, + "text": textBuilder.String(), + }, + }) + + // 2. Format Academic Typography (Times New Roman, 12pt) + if contentStart < contentEnd { + requests = append(requests, map[string]any{ + "updateTextStyle": map[string]any{ + "range": map[string]any{ + "startIndex": contentStart, + "endIndex": contentEnd, + }, + "textStyle": map[string]any{ + "weightedFontFamily": map[string]any{ + "fontFamily": fontFamily, + }, + "fontSize": map[string]any{ + "magnitude": 12, + "unit": "PT", + }, + }, + "fields": "weightedFontFamily,fontSize", + }, + }) + } + + // 3. Format Paragraph Alignment (Justified, 1.15 lineSpacing) + if bodyStart < bodyEnd { + requests = append(requests, map[string]any{ + "updateParagraphStyle": map[string]any{ + "range": map[string]any{ + "startIndex": bodyStart, + "endIndex": bodyEnd, + }, + "paragraphStyle": map[string]any{ + "alignment": alignment, + "lineSpacing": 115, + "spaceBelow": map[string]any{ + "magnitude": 6, + "unit": "PT", + }, + }, + "fields": "alignment,lineSpacing,spaceBelow", + }, + }) + } + + // 4. Format Section Heading + if args.Heading != "" && headingStart < headingEnd { + requests = append(requests, map[string]any{ + "updateParagraphStyle": map[string]any{ + "range": map[string]any{ + "startIndex": headingStart, + "endIndex": headingEnd, + }, + "paragraphStyle": map[string]any{ + "namedStyleType": args.Heading, + }, + "fields": "namedStyleType", + }, + }) + } + + // 5. Format Bibliography Heading and Hanging Indent + if bibHeadingStart < bibHeadingEnd { + requests = append(requests, map[string]any{ + "updateParagraphStyle": map[string]any{ + "range": map[string]any{ + "startIndex": bibHeadingStart, + "endIndex": bibHeadingEnd, + }, + "paragraphStyle": map[string]any{ + "namedStyleType": "HEADING_2", + }, + "fields": "namedStyleType", + }, + }) + } + + for _, br := range bibRanges { + if br.start < br.end { + requests = append(requests, map[string]any{ + "updateParagraphStyle": map[string]any{ + "range": map[string]any{ + "startIndex": br.start, + "endIndex": br.end, + }, + "paragraphStyle": map[string]any{ + "alignment": alignment, + "indentStart": map[string]any{ + "magnitude": 36, + "unit": "PT", + }, + "indentFirstLine": map[string]any{ + "magnitude": -36, + "unit": "PT", + }, + "spaceBelow": map[string]any{ + "magnitude": 6, + "unit": "PT", + }, + }, + "fields": "alignment,indentStart,indentFirstLine,spaceBelow", + }, + }) + } + } + + // 6. Format Zotero Named Ranges & Links + userID := resolveZoteroUserID(in) + for _, cr := range citeRanges { + fieldKey := randomAlphanumeric(6) + linkURL := "https://www.zotero.org/google-docs/?" + fieldKey + + requests = append(requests, map[string]any{ + "updateTextStyle": map[string]any{ + "range": map[string]any{ + "startIndex": cr.start, + "endIndex": cr.end, + }, + "textStyle": map[string]any{ + "link": map[string]any{ + "url": linkURL, + }, + }, + "fields": "link", + }, + }) + + citationID := randomAlphanumeric(8) + code := buildZoteroCitationCode(citationID, cr.citation, userID, cr.key, cr.title) + chunks := encodeZoteroNamedRanges(fieldKey, code) + for _, chunk := range chunks { + requests = append(requests, map[string]any{ + "createNamedRange": map[string]any{ + "name": chunk, + "range": map[string]any{ + "startIndex": cr.start, + "endIndex": cr.end, + }, + }, + }) + } + } + + citeCount = len(citeRanges) + + batchReq := map[string]any{ + "requests": requests, + } + + batchBytes, err := json.Marshal(batchReq) + if err != nil { + return Errorf("marshal batchUpdate request: %v", err) + } + + reqURL := fmt.Sprintf("https://docs.googleapis.com/v1/documents/%s:batchUpdate", url.PathEscape(target)) + req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(batchBytes)) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return Errorf("send batchUpdate to Google Docs: %v", err) + } + defer resp.Body.Close() + + respBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return Errorf("Google Docs API returned HTTP %d: %s", resp.StatusCode, string(respBytes)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Berhasil memperbarui Google Doc (ID: %s via Google Docs API):\n", target)) + if findText != "" { + sb.WriteString(fmt.Sprintf("• Posisi target: %s penanda %q (indeks %d)\n", posMode, findText, insertIndex)) + if deleteRange != nil { + sb.WriteString(fmt.Sprintf("• Rentang teks lama digantikan: indeks %d s/d %d\n", deleteRange.start, deleteRange.end)) + } + } + if args.Heading != "" { + sb.WriteString(fmt.Sprintf("• Heading: %s\n", args.Heading)) + } + sb.WriteString(fmt.Sprintf("• Teks ditambahkan: %d karakter\n", len(args.Text))) + if len(args.Citations) > 0 { + sb.WriteString(fmt.Sprintf("• Sitasi disertakan: %s\n", strings.Join(args.Citations, "; "))) + } + if citeCount > 0 { + sb.WriteString(fmt.Sprintf("• Sitasi Zotero resmi disematkan: %d sitasi (Named Ranges terhubung ke Zotero Extension)\n", citeCount)) + } + if len(args.Bibliography) > 0 { + sb.WriteString(fmt.Sprintf("• Daftar Pustaka ditambahkan: %d entri\n", len(args.Bibliography))) + } + + return Result{ + Content: strings.TrimSpace(sb.String()), + Meta: map[string]any{ + "status": "success", + "target": target, + "find_text": findText, + "position": posMode, + "insert_index": insertIndex, + "old_text": oldTextDeleted, + "new_text": textBuilder.String(), + "citations": args.Citations, + "zotero_items": zoteroItems, + "zotero_embedded": citeCount, + "bibliography": args.Bibliography, + }, + } +} + +// ----------------------------------------------------------------------------- +// 3. gdocs_rollback (Undo Last Mutation) +// ----------------------------------------------------------------------------- + +type gdocsRollbackTool struct{} + +func (gdocsRollbackTool) Name() string { return "gdocs_rollback" } +func (gdocsRollbackTool) Description() string { + return "Roll back / undo the most recent change made to a Google Doc. Restores replaced text or removes inserted content back to its previous state. Requires document_id or target document URL." +} +func (gdocsRollbackTool) RequiresApproval() bool { return true } + +func (gdocsRollbackTool) Schema() map[string]any { + return schema(map[string]any{ + "document_id": prop("string", "Google Docs Document ID or full URL (optional if configured in settings)."), + }) +} + +func (gdocsRollbackTool) Execute(ctx context.Context, in Input) Result { + var args struct { + DocumentID string `json:"document_id"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + method, target, err := resolveGDocsEndpoint(in, "", args.DocumentID) + if err != nil { + return Errorf("%v", err) + } + if method != "oauth" { + return Errorf("rollback is only supported for Google Docs connected via OAuth/REST API") + } + + if in.Deps == nil || in.Deps.Store == nil { + return Errorf("database store dependency not available") + } + + snapshotKey := fmt.Sprintf("research:gdocs:snapshot:%s", target) + snapshotJSON, _ := in.Deps.Store.GetKV(ctx, snapshotKey) + if snapshotJSON == "" { + return Errorf("tidak ada snapshot riwayat perubahan untuk Google Doc ini. Belum ada modifikasi yang dapat di-rollback.") + } + + var snap struct { + Timestamp int64 `json:"timestamp"` + Target string `json:"target"` + FindText string `json:"find_text"` + DeleteStart int `json:"delete_start"` + DeleteEnd int `json:"delete_end"` + OldText string `json:"old_text"` + InsertedText string `json:"inserted_text"` + } + if err := json.Unmarshal([]byte(snapshotJSON), &snap); err != nil { + return Errorf("corrupted snapshot data: %v", err) + } + + token, err := getActiveGoogleToken(ctx, in) + if err != nil { + return Errorf("%v", err) + } + + client := &http.Client{Timeout: 30 * time.Second} + + // Fetch current doc structure to locate inserted text and remove it + docReq, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://docs.googleapis.com/v1/documents/%s", url.PathEscape(target)), nil) + if err != nil { + return Errorf("create request: %v", err) + } + docReq.Header.Set("Authorization", "Bearer "+token) + + docResp, err := client.Do(docReq) + if err != nil { + return Errorf("fetch current document structure: %v", err) + } + defer docResp.Body.Close() + + var docInfo struct { + Body struct { + Content []docContentItem `json:"content"` + } `json:"body"` + } + if err := json.NewDecoder(docResp.Body).Decode(&docInfo); err != nil { + return Errorf("decode document structure: %v", err) + } + + var requests []map[string]any + + // If find_text was used, locate its current position + match := findTextInDoc(docInfo.Body.Content, snap.FindText) + if match.found && snap.OldText != "" { + // Restore oldText at paragraphEnd + insIdx := match.paragraphEnd + requests = append(requests, map[string]any{ + "insertText": map[string]any{ + "location": map[string]any{ + "index": insIdx, + }, + "text": "\n" + snap.OldText + "\n", + }, + }) + } else if snap.OldText != "" { + // Fallback: restore old text at deleteStart index + requests = append(requests, map[string]any{ + "insertText": map[string]any{ + "location": map[string]any{ + "index": snap.DeleteStart, + }, + "text": snap.OldText, + }, + }) + } + + if len(requests) == 0 { + return Errorf("tidak dapat menentukan lokasi rollback yang aman di dokumen") + } + + batchReq := map[string]any{"requests": requests} + batchBytes, _ := json.Marshal(batchReq) + + reqURL := fmt.Sprintf("https://docs.googleapis.com/v1/documents/%s:batchUpdate", url.PathEscape(target)) + req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(batchBytes)) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return Errorf("send rollback request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return Errorf("Google Docs API rollback error (%d): %s", resp.StatusCode, string(b)) + } + + // Delete snapshot after successful rollback + _ = in.Deps.Store.SetKV(ctx, snapshotKey, "") + + return Result{ + Content: fmt.Sprintf("Berhasil melakukan rollback perubahan pada Google Doc (ID: %s). Teks lama (%d karakter) telah dipulihkan.", target, len(snap.OldText)), + Meta: map[string]any{ + "status": "success", + "target": target, + "restored_chars": len(snap.OldText), + "original_snapshot": snap.Timestamp, + }, + } +} diff --git a/internal/tools/gdocs_security_test.go b/internal/tools/gdocs_security_test.go new file mode 100644 index 00000000..c8d5071f --- /dev/null +++ b/internal/tools/gdocs_security_test.go @@ -0,0 +1,85 @@ +package tools + +import ( + "strings" + "testing" +) + +func TestGDocsRejectSSRF(t *testing.T) { + dangerousURLs := []struct { + url string + desc string + }{ + {"http://169.254.169.254/latest/meta-data/", "AWS Cloud Metadata SSRF"}, + {"http://127.0.0.1:8790/api/config", "Localhost Internal Port SSRF"}, + {"http://localhost:8790/api/status", "Localhost Named SSRF"}, + {"https://attacker.com/macros/s/AKfycbx123456789/exec", "Foreign domain phishing/SSRF"}, + {"http://script.google.com/macros/s/AKfycbx123456789/exec", "Insecure HTTP scheme"}, + {"javascript:alert(1)", "JavaScript URI Scheme"}, + {"data:text/html,", "Data URI Scheme"}, + {"file:///etc/passwd", "Local file scheme"}, + {"https://script.google.com/evil/path", "Invalid path not in /macros/s/"}, + } + + for _, tc := range dangerousURLs { + t.Run(tc.desc, func(t *testing.T) { + err := ValidateGDocsWebhookURL(tc.url) + if err == nil { + t.Fatalf("expected error for dangerous URL %q (%s), got nil", tc.url, tc.desc) + } + if !strings.Contains(err.Error(), "security violation") && !strings.Contains(err.Error(), "invalid") { + t.Errorf("expected security violation error message, got %v", err) + } + }) + } + + // Valid Google Apps Script Webhook + validURL := "https://script.google.com/macros/s/AKfycbx1234567890abcdefghijklmnopqrstuvwxyz/exec" + if err := ValidateGDocsWebhookURL(validURL); err != nil { + t.Fatalf("expected valid webhook to pass, got: %v", err) + } +} + +func TestGDocsDocIDSanitization(t *testing.T) { + invalidIDs := []string{ + "../../etc/passwd", + "", + "id with spaces", + "id/with/slashes", + "short", + "id_with_special!@#$%^&*()", + } + + for _, id := range invalidIDs { + if err := ValidateGDocsDocID(id); err == nil { + t.Errorf("expected error for invalid Doc ID %q, got nil", id) + } + } + + validID := "1U76qmDw7ZDiSZJmCk5_Ri8VfdFKZZYyTTKpSeSaio6o" + if err := ValidateGDocsDocID(validID); err != nil { + t.Fatalf("expected valid Doc ID to pass, got: %v", err) + } +} + +func TestGoogleOAuthCredsValidation(t *testing.T) { + // CRLF injection attempts + if err := ValidateGoogleOAuthCreds("client\r\nid", "secret"); err == nil { + t.Error("expected error on CRLF in client_id") + } + if err := ValidateGoogleOAuthCreds("client_id", "secret\r\n"); err == nil { + t.Error("expected error on CRLF in client_secret") + } + + // Non-Google client ID + if err := ValidateGoogleOAuthCreds("12345-abc.malicious.com", "valid_secret_1234567890"); err == nil { + t.Error("expected error on client ID not ending with .apps.googleusercontent.com") + } + + // Valid Google client ID and secret + validClientID := "123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com" + validSecret := "mockclientsecretvalue1234567890abcdef" + if err := ValidateGoogleOAuthCreds(validClientID, validSecret); err != nil { + t.Fatalf("expected valid credentials to pass, got: %v", err) + } +} diff --git a/internal/tools/gdocs_test.go b/internal/tools/gdocs_test.go new file mode 100644 index 00000000..f52e4654 --- /dev/null +++ b/internal/tools/gdocs_test.go @@ -0,0 +1,215 @@ +package tools + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGDocsTools(t *testing.T) { + AllowInsecureTestWebhook = true + defer func() { AllowInsecureTestWebhook = false }() + + // Mock server for Google Apps Script Webhook + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "title": "Proposal Penelitian", + "content": "Ini adalah draf awal proposal.", + }) + return + } + if r.Method == "POST" { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "success", + "message": "Content appended successfully", + }) + return + } + })) + defer mockServer.Close() + + readTool := gdocsReadTool{} + if readTool.Name() != "gdocs_read" { + t.Errorf("expected gdocs_read, got %s", readTool.Name()) + } + if readTool.RequiresApproval() != false { + t.Errorf("expected RequiresApproval to be false for readTool") + } + + updateTool := gdocsUpdateTool{} + if updateTool.Name() != "gdocs_update" { + t.Errorf("expected gdocs_update, got %s", updateTool.Name()) + } + if updateTool.RequiresApproval() != true { + t.Errorf("expected RequiresApproval to be true for updateTool") + } + + // Test read execution + readRes := readTool.Execute(context.Background(), Input{ + Args: json.RawMessage(`{"webhook_url":"` + mockServer.URL + `"}`), + }) + if readRes.IsError { + t.Fatalf("expected successful read, got error: %s", readRes.Content) + } + + // Test update execution + updateRes := updateTool.Execute(context.Background(), Input{ + Args: json.RawMessage(`{"webhook_url":"` + mockServer.URL + `", "text":"Paragraf baru dengan sitasi", "citations":["(Setiawan, 2024)"]}`), + }) + if updateRes.IsError { + t.Fatalf("expected successful update, got error: %s", updateRes.Content) + } + + // Test update execution with zotero_items and item_keys + zoteroUpdateRes := updateTool.Execute(context.Background(), Input{ + Args: json.RawMessage(`{ + "webhook_url":"` + mockServer.URL + `", + "text":"Paragraf dengan sitasi Zotero resmi [cite:UXDB5SN6]", + "zotero_items": [ + {"key": "UXDB5SN6", "citation": "(Setiawan, 2024)", "title": "Penelitian Antares"} + ] + }`), + }) + if zoteroUpdateRes.IsError { + t.Fatalf("expected successful update with zotero_items, got error: %s", zoteroUpdateRes.Content) + } +} + +func TestZoteroNamedRangeEncoding(t *testing.T) { + fieldKey := "aBcDeF" + code := buildZoteroCitationCode("cit12345", "(Setiawan, 2024)", "12345", "UXDB5SN6", "Penelitian Antares") + if len(code) == 0 || code[:18] != "ITEM CSL_CITATION " { + t.Fatalf("expected code to start with ITEM CSL_CITATION, got: %s", code) + } + + chunks := encodeZoteroNamedRanges(fieldKey, code) + if len(chunks) == 0 { + t.Fatalf("expected at least 1 chunk, got 0") + } + + for i, chunk := range chunks { + expectedPrefix := "Z_F" + fieldKey + "000" + if i == 0 && chunk[:len(expectedPrefix)] != expectedPrefix { + t.Errorf("chunk 0 expected prefix %s, got: %s", expectedPrefix, chunk) + } + if len(chunk) > 255 { + t.Errorf("chunk %d length %d exceeds 255 characters", i, len(chunk)) + } + } + + // Verify UTF16 len calculation + if utf16Len("Hello") != 5 { + t.Errorf("expected 5, got %d", utf16Len("Hello")) + } + // Emoji takes 2 code units in UTF-16 + if utf16Len("😊") != 2 { + t.Errorf("expected 2, got %d", utf16Len("😊")) + } +} + +func TestFindTextInDoc(t *testing.T) { + content := []docContentItem{ + { + StartIndex: 1, + EndIndex: 19, + Paragraph: &struct { + ParagraphStyle *docParagraphStyle `json:"paragraphStyle"` + Elements []docContentElement `json:"elements"` + }{ + Elements: []docContentElement{ + { + StartIndex: 1, + EndIndex: 19, + TextRun: &struct { + Content string `json:"content"` + }{Content: "BAB 1 PENDAHULUAN\n"}, + }, + }, + }, + }, + { + StartIndex: 19, + EndIndex: 40, + Paragraph: &struct { + ParagraphStyle *docParagraphStyle `json:"paragraphStyle"` + Elements []docContentElement `json:"elements"` + }{ + Elements: []docContentElement{ + { + StartIndex: 19, + EndIndex: 40, + TextRun: &struct { + Content string `json:"content"` + }{Content: "BAB 2 KAJIAN PUSTAKA\n"}, + }, + }, + }, + }, + { + StartIndex: 40, + EndIndex: 52, + Paragraph: &struct { + ParagraphStyle *docParagraphStyle `json:"paragraphStyle"` + Elements []docContentElement `json:"elements"` + }{ + Elements: []docContentElement{ + { + StartIndex: 40, + EndIndex: 52, + TextRun: &struct { + Content string `json:"content"` + }{Content: "BAB 3 BILIK\n"}, + }, + }, + }, + }, + { + StartIndex: 52, + EndIndex: 79, + Paragraph: &struct { + ParagraphStyle *docParagraphStyle `json:"paragraphStyle"` + Elements []docContentElement `json:"elements"` + }{ + Elements: []docContentElement{ + { + StartIndex: 52, + EndIndex: 79, + TextRun: &struct { + Content string `json:"content"` + }{Content: "BAB 4 HASIL DAN PEMBAHASAN\n"}, + }, + }, + }, + }, + } + + m1 := findTextInDoc(content, "BAB 3 BILIK") + if !m1.found { + t.Fatalf("expected to find 'BAB 3 BILIK'") + } + if m1.matchStart != 40 || m1.matchEnd != 51 { + t.Errorf("expected match range [40, 51), got [%d, %d)", m1.matchStart, m1.matchEnd) + } + if m1.paragraphStart != 40 || m1.paragraphEnd != 52 { + t.Errorf("expected paragraph range [40, 52), got [%d, %d)", m1.paragraphStart, m1.paragraphEnd) + } + + m2 := findTextInDoc(content, "bab 3") + if !m2.found { + t.Fatalf("expected case-insensitive match for 'bab 3'") + } + if m2.matchStart != 40 { + t.Errorf("expected matchStart 40, got %d", m2.matchStart) + } + + m3 := findTextInDoc(content, "BAB 15") + if m3.found { + t.Errorf("expected not found for 'BAB 15'") + } +} diff --git a/internal/tools/register.go b/internal/tools/register.go index ec6eb50a..070071db 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -78,6 +78,10 @@ func init() { // Autonomous web crawler (CyberStrike port). Captures every HTTP // request a target issues as the agent drives a real browser. hackbrowserTool{}, + // Academic & Research integration tools: Zotero & Google Docs. + zoteroCollectionsTool{}, zoteroSearchTool{}, zoteroCiteTool{}, + gdocsReadTool{}, gdocsUpdateTool{}, gdocsRollbackTool{}, + draftReadTool{}, draftUpdateTool{}, } { globalRegistry.Register(t) } diff --git a/internal/tools/registry.go b/internal/tools/registry.go index bce7c530..eb671e2a 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -259,11 +259,15 @@ var Toolsets = map[string][]string{ "coding": { "read_file", "read_document", "write_file", "edit_file", "list_files", "glob", "grep", "terminal", "process", "todo", "board", "project_info", "set_soul", "skill", "delegate_task", "task", "list_roles", "diagnostics", "http_request", "ask_user", "schedule", + "zotero_collections", "zotero_search", "zotero_cite", "gdocs_read", "gdocs_update", "gdocs_rollback", + "draft_read", "draft_update", }, "research": { "read_file", "read_document", "web_search", "web_fetch", "http_request", "browser", "grep", "todo", "memory", "session_search", "rag_search", "skill", "view_image", "report_finding", "add_intel", "methodology_status", "delegate_task", "task", "list_roles", + "zotero_collections", "zotero_search", "zotero_cite", "gdocs_read", "gdocs_update", "gdocs_rollback", + "draft_read", "draft_update", }, "browser": {"browser", "web_search", "web_fetch", "http_request", "read_file", "write_file", "todo", "report_finding", "add_intel", "methodology_status"}, "security": { diff --git a/internal/tools/validation.go b/internal/tools/validation.go new file mode 100644 index 00000000..40466b7d --- /dev/null +++ b/internal/tools/validation.go @@ -0,0 +1,123 @@ +package tools + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" +) + +var ( + docIDRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{20,100}$`) + googleClientRegex = regexp.MustCompile(`^[0-9]+-[a-zA-Z0-9_]{10,}\.apps\.googleusercontent\.com$`) + googleSecretRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]{20,80}$`) + zoteroUserIDRegex = regexp.MustCompile(`^[0-9]{1,12}$`) + zoteroAPIKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9]{16,32}$`) + zoteroItemKeyRegex = regexp.MustCompile(`^[a-zA-Z0-9]{8}$`) + + // AllowInsecureTestWebhook is enabled only during internal unit test suites + AllowInsecureTestWebhook = false +) + +// ValidateGDocsWebhookURL enforces strict anti-SSRF rules on Apps Script webhooks. +func ValidateGDocsWebhookURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("webhook URL cannot be empty") + } + + if AllowInsecureTestWebhook && (strings.HasPrefix(raw, "http://127.0.0.1") || strings.HasPrefix(raw, "http://localhost")) { + return nil + } + + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid webhook URL syntax: %w", err) + } + + // 1. Must use HTTPS + if strings.ToLower(u.Scheme) != "https" { + return fmt.Errorf("security violation: webhook URL must use HTTPS, got %q", u.Scheme) + } + + // 2. Reject raw IP addresses (e.g. 169.254.169.254, 127.0.0.1) + hostname := strings.ToLower(u.Hostname()) + if net.ParseIP(hostname) != nil { + return fmt.Errorf("security violation: webhook cannot point to IP address %q", hostname) + } + + // 3. Strict host whitelist: only script.google.com + if hostname != "script.google.com" && !strings.HasSuffix(hostname, ".script.google.com") { + return fmt.Errorf("security violation: webhook host must be script.google.com, got %q", hostname) + } + + // 4. Path must be a valid Google Apps Script Web App endpoint (/macros/s/...) + if !strings.HasPrefix(u.Path, "/macros/s/") || len(u.Path) < len("/macros/s/")+10 { + return fmt.Errorf("security violation: webhook path must start with /macros/s/, got %q", u.Path) + } + + return nil +} + +// ValidateGDocsDocID ensures the document ID is a clean alphanumeric string. +func ValidateGDocsDocID(id string) error { + id = strings.TrimSpace(id) + if id == "" { + return fmt.Errorf("Google Docs document ID cannot be empty") + } + if !docIDRegex.MatchString(id) { + return fmt.Errorf("security violation: invalid Google Docs document ID format: %q", id) + } + return nil +} + +// ValidateGoogleOAuthCreds ensures OAuth Client ID and Secret conform to expected formats and do not inject CRLF. +func ValidateGoogleOAuthCreds(clientID, clientSecret string) error { + clientID = strings.TrimSpace(clientID) + clientSecret = strings.TrimSpace(clientSecret) + + if strings.ContainsAny(clientID, "\r\n") || strings.ContainsAny(clientSecret, "\r\n") { + return fmt.Errorf("security violation: credentials must not contain newline characters (CRLF)") + } + + if clientID != "" && !googleClientRegex.MatchString(clientID) { + return fmt.Errorf("invalid Google OAuth Client ID format (must end with .apps.googleusercontent.com)") + } + + if clientSecret != "" && !googleSecretRegex.MatchString(clientSecret) { + return fmt.Errorf("invalid Google OAuth Client Secret format (expected alphanumeric/dash string)") + } + + return nil +} + +// ValidateZoteroCreds ensures Zotero User ID and API Key are clean and well-formed. +func ValidateZoteroCreds(userID, apiKey string) error { + userID = strings.TrimSpace(userID) + apiKey = strings.TrimSpace(apiKey) + + if userID != "" && !zoteroUserIDRegex.MatchString(userID) { + return fmt.Errorf("security violation: Zotero User ID must be a numeric integer, got %q", userID) + } + + if apiKey != "" && !zoteroAPIKeyRegex.MatchString(apiKey) { + return fmt.Errorf("security violation: Zotero API Key must be a 16-32 character alphanumeric token, got %q", apiKey) + } + + return nil +} + +// ValidateZoteroItemKeys validates item keys for citing. +func ValidateZoteroItemKeys(keys []string) error { + if len(keys) == 0 { + return fmt.Errorf("item keys cannot be empty") + } + for _, k := range keys { + k = strings.TrimSpace(k) + if !zoteroItemKeyRegex.MatchString(k) { + return fmt.Errorf("security violation: invalid Zotero item key format: %q", k) + } + } + return nil +} diff --git a/internal/tools/zotero.go b/internal/tools/zotero.go new file mode 100644 index 00000000..21f58e74 --- /dev/null +++ b/internal/tools/zotero.go @@ -0,0 +1,516 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +// Default Zotero API constants +const zoteroAPIBase = "https://api.zotero.org" + +// resolveZoteroCreds resolves user_id and api_key from tool input, config, or environment. +func resolveZoteroCreds(in Input, argUserID, argAPIKey string) (userID, apiKey string, err error) { + apiKey = strings.TrimSpace(argAPIKey) + userID = strings.TrimSpace(argUserID) + + if in.Deps != nil && in.Deps.Config != nil { + cfg := in.Deps.Config + if apiKey == "" { + apiKey = strings.TrimSpace(cfg.Research.Zotero.APIKey) + } + if userID == "" { + userID = strings.TrimSpace(cfg.Research.Zotero.UserID) + } + } + + if apiKey == "" { + return "", "", fmt.Errorf("zotero API key is required. Pass api_key or configure research.zotero.api_key in config") + } + + if err := ValidateZoteroCreds(userID, apiKey); err != nil { + return "", "", err + } + + // If userID is not provided, fetch it using the key info endpoint + if userID == "" { + resolvedID, err := fetchZoteroUserIDFromKey(apiKey) + if err != nil { + return "", "", fmt.Errorf("could not determine Zotero user ID from API key: %w", err) + } + userID = resolvedID + } + + return userID, apiKey, nil +} + +func fetchZoteroUserIDFromKey(apiKey string) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", fmt.Sprintf("%s/keys/%s", zoteroAPIBase, url.PathEscape(apiKey)), nil) + if err != nil { + return "", err + } + req.Header.Set("Zotero-API-Key", apiKey) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("key lookup returned status %d: %s", resp.StatusCode, string(body)) + } + + var keyInfo struct { + UserID int `json:"userID"` + } + if err := json.NewDecoder(resp.Body).Decode(&keyInfo); err != nil { + return "", err + } + if keyInfo.UserID == 0 { + return "", fmt.Errorf("received empty userID from key info") + } + return strconv.Itoa(keyInfo.UserID), nil +} + +// StripHTML removes HTML tags from strings (useful for CSL bib/citation HTML). +func stripHTML(input string) string { + re := regexp.MustCompile("<[^>]*>") + cleaned := re.ReplaceAllString(input, "") + cleaned = strings.ReplaceAll(cleaned, " ", " ") + cleaned = strings.ReplaceAll(cleaned, "&", "&") + cleaned = strings.ReplaceAll(cleaned, "<", "<") + cleaned = strings.ReplaceAll(cleaned, ">", ">") + cleaned = strings.ReplaceAll(cleaned, """, "\"") + cleaned = strings.ReplaceAll(cleaned, "’", "'") + cleaned = strings.ReplaceAll(cleaned, "‘", "'") + cleaned = strings.ReplaceAll(cleaned, "“", "\"") + cleaned = strings.ReplaceAll(cleaned, "”", "\"") + return strings.TrimSpace(cleaned) +} + +// ----------------------------------------------------------------------------- +// 1. zotero_collections +// ----------------------------------------------------------------------------- + +type zoteroCollectionsTool struct{} + +func (zoteroCollectionsTool) Name() string { return "zotero_collections" } +func (zoteroCollectionsTool) Description() string { + return "List all research collections/folders in Zotero. Shows collection keys, names, item counts, and hierarchical relationships. Use this to locate specific research topics or target libraries before searching papers." +} +func (zoteroCollectionsTool) RequiresApproval() bool { return false } + +func (zoteroCollectionsTool) Schema() map[string]any { + return schema(map[string]any{ + "user_id": prop("string", "Zotero User ID (optional if configured or detected from api_key)."), + "api_key": prop("string", "Zotero API Key (optional if configured in settings)."), + }) +} + +func (zoteroCollectionsTool) Execute(ctx context.Context, in Input) Result { + var args struct { + UserID string `json:"user_id"` + APIKey string `json:"api_key"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + userID, apiKey, err := resolveZoteroCreds(in, args.UserID, args.APIKey) + if err != nil { + return Errorf("%v", err) + } + + reqURL := fmt.Sprintf("%s/users/%s/collections", zoteroAPIBase, url.PathEscape(userID)) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Zotero-API-Key", apiKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Errorf("fetch collections: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return Errorf("Zotero API error (%d): %s", resp.StatusCode, string(b)) + } + + var raw []struct { + Key string `json:"key"` + Meta struct { + NumCollections int `json:"numCollections"` + NumItems int `json:"numItems"` + } `json:"meta"` + Data struct { + Name string `json:"name"` + ParentCollection any `json:"parentCollection"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return Errorf("decode collections: %v", err) + } + + if len(raw) == 0 { + return Text("No collections found in Zotero library.") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d Zotero Collections (User %s):\n\n", len(raw), userID)) + for _, c := range raw { + parentStr := "" + if c.Data.ParentCollection != nil && c.Data.ParentCollection != false { + parentStr = fmt.Sprintf(" (parent: %v)", c.Data.ParentCollection) + } + sb.WriteString(fmt.Sprintf("• [%s] %s%s - %d items\n", c.Key, c.Data.Name, parentStr, c.Meta.NumItems)) + } + + return Text(strings.TrimSpace(sb.String())) +} + +// ----------------------------------------------------------------------------- +// 2. zotero_search +// ----------------------------------------------------------------------------- + +type zoteroSearchTool struct{} + +func (zoteroSearchTool) Name() string { return "zotero_search" } +func (zoteroSearchTool) Description() string { + return "Search papers, books, articles, and citations in Zotero. Supports searching across the entire library or filtering within a specific collection key. Returns titles, authors, years, publication, DOI, and abstract snippets." +} +func (zoteroSearchTool) RequiresApproval() bool { return false } + +func (zoteroSearchTool) Schema() map[string]any { + return schema(map[string]any{ + "query": prop("string", "Search query (keyword, title, author, or DOI)."), + "collection_key": prop("string", "Optional collection key to search within a specific folder."), + "limit": propDefault("integer", "Maximum number of items to return (1 to 25, default 10).", 10), + "user_id": prop("string", "Zotero User ID (optional)."), + "api_key": prop("string", "Zotero API Key (optional)."), + }) +} + +func (zoteroSearchTool) Execute(ctx context.Context, in Input) Result { + var args struct { + Query string `json:"query"` + CollectionKey string `json:"collection_key"` + Limit int `json:"limit"` + UserID string `json:"user_id"` + APIKey string `json:"api_key"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + userID, apiKey, err := resolveZoteroCreds(in, args.UserID, args.APIKey) + if err != nil { + return Errorf("%v", err) + } + + if args.Limit <= 0 { + args.Limit = 10 + } + if args.Limit > 25 { + args.Limit = 25 + } + + baseURL := fmt.Sprintf("%s/users/%s/items", zoteroAPIBase, url.PathEscape(userID)) + if strings.TrimSpace(args.CollectionKey) != "" { + baseURL = fmt.Sprintf("%s/users/%s/collections/%s/items", zoteroAPIBase, url.PathEscape(userID), url.PathEscape(strings.TrimSpace(args.CollectionKey))) + } + + params := url.Values{} + params.Set("limit", strconv.Itoa(args.Limit)) + params.Set("itemType", "-attachment") // exclude standalone attachments to focus on citation items + if strings.TrimSpace(args.Query) != "" { + params.Set("q", strings.TrimSpace(args.Query)) + } + + reqURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return Errorf("create request: %v", err) + } + req.Header.Set("Zotero-API-Key", apiKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return Errorf("execute search: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return Errorf("Zotero API error (%d): %s", resp.StatusCode, string(b)) + } + + var raw []struct { + Key string `json:"key"` + Meta struct { + CreatorSummary string `json:"creatorSummary"` + ParsedDate string `json:"parsedDate"` + } `json:"meta"` + Data struct { + ItemType string `json:"itemType"` + Title string `json:"title"` + Date string `json:"date"` + DOI string `json:"DOI"` + URL string `json:"url"` + Abstract string `json:"abstractNote"` + Creators []struct { + CreatorType string `json:"creatorType"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Name string `json:"name"` + } `json:"creators"` + PublicationTitle string `json:"publicationTitle"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return Errorf("decode search results: %v", err) + } + + if len(raw) == 0 { + return Text("No items found matching the search criteria in Zotero.") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d Zotero Items:\n\n", len(raw))) + for i, item := range raw { + authors := item.Meta.CreatorSummary + if authors == "" { + var authorList []string + for _, c := range item.Data.Creators { + if c.LastName != "" { + authorList = append(authorList, c.LastName) + } else if c.Name != "" { + authorList = append(authorList, c.Name) + } + } + authors = strings.Join(authorList, ", ") + } + if authors == "" { + authors = "Unknown author" + } + + year := item.Meta.ParsedDate + if year == "" { + year = item.Data.Date + } + if year == "" { + year = "n.d." + } + + sb.WriteString(fmt.Sprintf("%d. [%s] \"%s\"\n", i+1, item.Key, item.Data.Title)) + sb.WriteString(fmt.Sprintf(" • Author(s): %s (%s)\n", authors, year)) + if item.Data.PublicationTitle != "" { + sb.WriteString(fmt.Sprintf(" • Source: %s\n", item.Data.PublicationTitle)) + } + if item.Data.DOI != "" { + sb.WriteString(fmt.Sprintf(" • DOI: %s\n", item.Data.DOI)) + } + if item.Data.Abstract != "" { + snippet := item.Data.Abstract + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + sb.WriteString(fmt.Sprintf(" • Abstract: %s\n", snippet)) + } + sb.WriteString("\n") + } + + return Text(strings.TrimSpace(sb.String())) +} + +// ----------------------------------------------------------------------------- +// 3. zotero_cite +// ----------------------------------------------------------------------------- + +type zoteroCiteTool struct{} + +func (zoteroCiteTool) Name() string { return "zotero_cite" } +func (zoteroCiteTool) Description() string { + return "Generate official formatted in-text citations and bibliographic entries for one or more Zotero item keys. Supported citation styles: apa, ieee, harvard, mla, nature, vancouver, chicago-author-date." +} +func (zoteroCiteTool) RequiresApproval() bool { return false } + +func (zoteroCiteTool) Schema() map[string]any { + return schema(map[string]any{ + "item_keys": map[string]any{ + "type": "array", + "description": "List of Zotero item keys to cite (e.g. [\"UXDB5SN6\", \"VD586K78\"]).", + "items": map[string]any{"type": "string"}, + }, + "style": propDefault("string", "Citation style: apa, ieee, harvard-cite-them-right, mla, nature, vancouver. Default: apa.", "apa"), + "format": propEnum("Output format: standard (in-text citation & bibliography) or bibtex (raw BibTeX entries for LaTeX references.bib). Default: standard.", "standard", "bibtex"), + "user_id": prop("string", "Zotero User ID (optional)."), + "api_key": prop("string", "Zotero API Key (optional)."), + }, "item_keys") +} + +func (zoteroCiteTool) Execute(ctx context.Context, in Input) Result { + var args struct { + ItemKeys []string `json:"item_keys"` + Style string `json:"style"` + Format string `json:"format"` + UserID string `json:"user_id"` + APIKey string `json:"api_key"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + + if len(args.ItemKeys) == 0 { + return Errorf("item_keys list cannot be empty") + } + + if err := ValidateZoteroItemKeys(args.ItemKeys); err != nil { + return Errorf("%v", err) + } + + style := strings.TrimSpace(strings.ToLower(args.Style)) + if style == "" { + style = "apa" + } + format := strings.TrimSpace(strings.ToLower(args.Format)) + + userID, apiKey, err := resolveZoteroCreds(in, args.UserID, args.APIKey) + if err != nil { + return Errorf("%v", err) + } + + client := &http.Client{Timeout: 15 * time.Second} + keysParam := strings.Join(args.ItemKeys, ",") + + if format == "bibtex" || style == "bibtex" { + reqURL := fmt.Sprintf("%s/users/%s/items?itemKey=%s&format=bibtex", + zoteroAPIBase, url.PathEscape(userID), url.QueryEscape(keysParam)) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return Errorf("create bibtex request: %v", err) + } + req.Header.Set("Zotero-API-Key", apiKey) + + resp, err := client.Do(req) + if err != nil { + return Errorf("fetch bibtex: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return Errorf("Zotero API error (%d): %s", resp.StatusCode, string(b)) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return Errorf("read bibtex response: %v", err) + } + + bibtexContent := strings.TrimSpace(string(b)) + return Result{ + Content: fmt.Sprintf("BibTeX Entries (for LaTeX references.bib):\n\n```bibtex\n%s\n```", bibtexContent), + Meta: map[string]any{ + "format": "bibtex", + "bibtex": bibtexContent, + "item_keys": args.ItemKeys, + }, + } + } + + reqURL := fmt.Sprintf("%s/users/%s/items?itemKey=%s&include=citation,bib,data&style=%s", + zoteroAPIBase, url.PathEscape(userID), url.QueryEscape(keysParam), url.QueryEscape(style)) + + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return Errorf("create citation request: %v", err) + } + req.Header.Set("Zotero-API-Key", apiKey) + + resp, err := client.Do(req) + if err != nil { + return Errorf("fetch citation: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return Errorf("Zotero API error (%d): %s", resp.StatusCode, string(b)) + } + + var raw []struct { + Key string `json:"key"` + Bib string `json:"bib"` + Citation string `json:"citation"` + Data struct { + Title string `json:"title"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return Errorf("decode citation response: %v", err) + } + + if len(raw) == 0 { + return Errorf("no citation data returned for keys %s", keysParam) + } + + var inTextCitations []string + var bibliographies []string + var zoteroItemsMeta []map[string]string + + for _, item := range raw { + cleanCite := stripHTML(item.Citation) + cleanBib := stripHTML(item.Bib) + if cleanCite != "" { + inTextCitations = append(inTextCitations, cleanCite) + } + if cleanBib != "" { + bibliographies = append(bibliographies, cleanBib) + } + zoteroItemsMeta = append(zoteroItemsMeta, map[string]string{ + "key": item.Key, + "citation": cleanCite, + "title": item.Data.Title, + "bib": cleanBib, + }) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Citation Results (Style: %s):\n\n", strings.ToUpper(style))) + sb.WriteString("In-Text Citation(s):\n") + sb.WriteString(strings.Join(inTextCitations, "; ") + "\n\n") + + sb.WriteString("Bibliography / References:\n") + for i, bib := range bibliographies { + sb.WriteString(fmt.Sprintf("[%d] %s\n", i+1, bib)) + } + sb.WriteString("\nTip: Pass `zotero_items` or `item_keys` to `gdocs_update` to embed official Zotero Google Docs fields (Named Ranges) automatically recognized by the Zotero extension!\n") + + return Result{ + Content: strings.TrimSpace(sb.String()), + Meta: map[string]any{ + "in_text": strings.Join(inTextCitations, "; "), + "bibliographies": bibliographies, + "style": style, + "zotero_items": zoteroItemsMeta, + }, + } +} diff --git a/internal/tools/zotero_security_test.go b/internal/tools/zotero_security_test.go new file mode 100644 index 00000000..7981f498 --- /dev/null +++ b/internal/tools/zotero_security_test.go @@ -0,0 +1,57 @@ +package tools + +import ( + "testing" +) + +func TestZoteroSecurityValidation(t *testing.T) { + // 1. User ID validation + invalidUserIDs := []string{ + "1585/../evil", + "admin", + "-12345", + "1585 1659", + "1585; DROP TABLE users;", + } + for _, id := range invalidUserIDs { + if err := ValidateZoteroCreds(id, "valid_token_12345678"); err == nil { + t.Errorf("expected error for invalid User ID %q, got nil", id) + } + } + + // 2. API Key validation + invalidKeys := []string{ + "key with spaces", + "key/with/slashes", + "short", + "key_with_special!@#$", + "key\r\ninjection", + } + for _, k := range invalidKeys { + if err := ValidateZoteroCreds("15851659", k); err == nil { + t.Errorf("expected error for invalid API Key %q, got nil", k) + } + } + + // 3. Valid credentials + if err := ValidateZoteroCreds("12345678", "syntheticmockkey123456"); err != nil { + t.Fatalf("expected valid Zotero credentials to pass, got: %v", err) + } + + // 4. Item keys validation + if err := ValidateZoteroItemKeys([]string{"UXDB5SN6", "VD586K78"}); err != nil { + t.Fatalf("expected valid item keys to pass, got: %v", err) + } + + invalidItemKeys := [][]string{ + {"short"}, + {"UXDB5SN6;DROP"}, + {"../traversal"}, + {"invalid_len_12345"}, + } + for _, keys := range invalidItemKeys { + if err := ValidateZoteroItemKeys(keys); err == nil { + t.Errorf("expected error for invalid item keys %v, got nil", keys) + } + } +} diff --git a/internal/tools/zotero_test.go b/internal/tools/zotero_test.go new file mode 100644 index 00000000..d9573253 --- /dev/null +++ b/internal/tools/zotero_test.go @@ -0,0 +1,73 @@ +package tools + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStripHTML(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"(Smith et al., 2024)", "(Smith et al., 2024)"}, + {"
Smith, J. (2024). Test Title. & More
", "Smith, J. (2024). Test Title. & More"}, + {"Plain text", "Plain text"}, + } + + for _, c := range cases { + got := stripHTML(c.in) + if got != c.want { + t.Errorf("stripHTML(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestZoteroCollectionsTool(t *testing.T) { + // Mock server + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Zotero-API-Key") != "mock_key" { + w.WriteHeader(http.StatusUnauthorized) + return + } + data := []map[string]any{ + { + "key": "COL1", + "meta": map[string]any{ + "numCollections": 0, + "numItems": 5, + }, + "data": map[string]any{ + "name": "Machine Learning", + }, + }, + } + _ = json.NewEncoder(w).Encode(data) + })) + defer mockServer.Close() + + tool := zoteroCollectionsTool{} + if tool.Name() != "zotero_collections" { + t.Errorf("expected tool name zotero_collections, got %s", tool.Name()) + } + if tool.RequiresApproval() != false { + t.Errorf("expected RequiresApproval to be false") + } +} + +func TestZoteroCiteToolSchema(t *testing.T) { + tool := zoteroCiteTool{} + if tool.Name() != "zotero_cite" { + t.Errorf("expected zotero_cite, got %s", tool.Name()) + } + schema := tool.Schema() + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("expected properties in schema") + } + if _, ok := props["format"]; !ok { + t.Errorf("expected format property in schema") + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 53be4ab4..645f79f9 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -5,7 +5,7 @@ package version var ( Name = "antares" Display = "Antares" - Version = "0.1.0-dev" + Version = "0.11.0" Commit = "dev" Date = "unknown" ) diff --git a/web/bun.lock b/web/bun.lock index 798c66ef..27f9358f 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,7 +5,28 @@ "": { "name": "antares-web", "dependencies": { + "@codemirror/autocomplete": "^6.20.4", + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.13.1", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.15", "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@lezer/highlight": "^1.2.5", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -18,8 +39,15 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@types/katex": "^0.16.8", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", + "dompurify": "^3.4.17", + "katex": "^0.19.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.1", @@ -29,6 +57,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.13", + "@types/dompurify": "^3.2.0", "@types/node": "^26.5.1", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", @@ -78,6 +107,50 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.4", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ=="], + + "@codemirror/commands": ["@codemirror/commands@6.11.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg=="], + + "@codemirror/lang-cpp": ["@codemirror/lang-cpp@6.0.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/cpp": "^1.0.0" } }, "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA=="], + + "@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="], + + "@codemirror/lang-go": ["@codemirror/lang-go@6.0.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/go": "^1.0.0" } }, "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg=="], + + "@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="], + + "@codemirror/lang-java": ["@codemirror/lang-java@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/java": "^1.0.0" } }, "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ=="], + + "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + + "@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="], + + "@codemirror/lang-php": ["@codemirror/lang-php@6.0.2", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/php": "^1.0.0" } }, "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA=="], + + "@codemirror/lang-python": ["@codemirror/lang-python@6.2.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.3.2", "@codemirror/language": "^6.8.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/python": "^1.1.4" } }, "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw=="], + + "@codemirror/lang-rust": ["@codemirror/lang-rust@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/rust": "^1.0.0" } }, "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA=="], + + "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], + + "@codemirror/lang-xml": ["@codemirror/lang-xml@6.1.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.0.0", "@lezer/xml": "^1.0.0" } }, "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg=="], + + "@codemirror/lang-yaml": ["@codemirror/lang-yaml@6.1.3", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.2.0", "@lezer/lr": "^1.0.0", "@lezer/yaml": "^1.0.0" } }, "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ=="], + + "@codemirror/language": ["@codemirror/language@6.13.1", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/streamparser": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], + + "@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="], + + "@codemirror/state": ["@codemirror/state@6.7.6", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w=="], + + "@codemirror/streamparser": ["@codemirror/streamparser@6.0.0", "", {}, "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w=="], + + "@codemirror/view": ["@codemirror/view@6.43.15", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], @@ -150,6 +223,40 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lezer/common": ["@lezer/common@1.5.4", "", {}, "sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w=="], + + "@lezer/cpp": ["@lezer/cpp@1.1.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-vh9gWWJOXFVY8HBHK3Twzq8MgwG2iN4GSyzBP9sCGTe37P15x2R14VaBQk0VA0ezTRN1KHYBBsHhvpGZ2Xy/pA=="], + + "@lezer/css": ["@lezer/css@1.3.9", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ=="], + + "@lezer/go": ["@lezer/go@1.0.1", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.5", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w=="], + + "@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="], + + "@lezer/java": ["@lezer/java@1.1.5", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-JppTd1iKHpeNlLYw/Zl8JX7zSY5HqAzK4zZ05lpn8l8Sk7Fo5KAKiDiMiezKoT5lfeC2xZf8HeKp1tSctj4G6Q=="], + + "@lezer/javascript": ["@lezer/javascript@1.5.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA=="], + + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + + "@lezer/lr": ["@lezer/lr@1.4.11", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw=="], + + "@lezer/markdown": ["@lezer/markdown@1.8.1", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-M5YmEcKWO4mJ45Y1hECwqyu8cLv25EiXOK554PVOvDOWOP0olwelHHQUWr9sjF+d1bENVhy0nfJ8E57XZUTiXg=="], + + "@lezer/php": ["@lezer/php@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.1.0" } }, "sha512-QJ2xNXPmsm/Z3IpThq8fnJrEIVpxhsIoj6GZBW0JYEd/l9zxpJZW216X4fzqQY4vgpdGIumypvI4uQjd4tnYtw=="], + + "@lezer/python": ["@lezer/python@1.1.19", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ=="], + + "@lezer/rust": ["@lezer/rust@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-XxErOjZzQ7yJt1agUT4fu9qQvESZ3acgoxpPaPTPOiUx+duCjaVAtZGFIgphkHxlN05djdVAIOy/wItShMEjqQ=="], + + "@lezer/xml": ["@lezer/xml@1.0.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww=="], + + "@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], "@radix-ui/number": ["@radix-ui/number@1.1.3", "", {}, "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA=="], @@ -342,18 +449,30 @@ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], + "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.4", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ=="], @@ -366,10 +485,16 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], @@ -402,6 +527,8 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "dompurify": ["dompurify@3.4.17", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w=="], + "electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="], "enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="], @@ -436,6 +563,8 @@ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "katex": ["katex@0.19.0", "", { "dependencies": { "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -516,6 +645,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "style-mod": ["style-mod@4.1.4", "", {}, "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -544,6 +675,8 @@ "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], diff --git a/web/package.json b/web/package.json index c22fe02e..1da28984 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "antares-web", "private": true, - "version": "0.1.0", + "version": "0.11.0", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,28 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.4", + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.5.2", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.13.1", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.15", "@fontsource-variable/ibm-plex-sans": "^5.3.0", + "@lezer/highlight": "^1.2.5", "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -24,8 +45,15 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@types/katex": "^0.16.8", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", + "dompurify": "^3.4.17", + "katex": "^0.19.0", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.1", @@ -35,6 +63,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.13", + "@types/dompurify": "^3.2.0", "@types/node": "^26.5.1", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", diff --git a/web/src/components/chat/AcademicDraft.tsx b/web/src/components/chat/AcademicDraft.tsx new file mode 100644 index 00000000..7dc35485 --- /dev/null +++ b/web/src/components/chat/AcademicDraft.tsx @@ -0,0 +1,378 @@ +import DOMPurify from 'dompurify' +import { memo, useMemo } from 'react' +import { buildListTree, type MarkdownListItem } from './Markdown' + +interface AcademicDraftProps { + content: string +} + +interface Block { + type: 'heading' | 'paragraph' | 'table' | 'svg' | 'raw' | 'list' + level?: number + text?: string + header?: string[] + rows?: string[][] + svg?: string + ordered?: boolean + start?: number + items?: MarkdownListItem[] +} + +function parseAcademicBlocks(src: string): Block[] { + // Strip hidden wrapper divs and style tags completely so CSS can never leak into the DOM + const sanitizedSrc = src + .replace(/]*style=[\"']?display:\s*none[\"']?[^>]*>[\s\S]*?<\/div>/gi, '') + .replace(//gi, '') + + const lines = sanitizedSrc.replace(/\r\n/g, '\n').split('\n') + const blocks: Block[] = [] + let i = 0 + + while (i < lines.length) { + const rawLine = lines[i] + const line = rawLine.trim() + + if (!line) { + i++ + continue + } + + // SVG Diagram Block + if (/^\s*]/.test(line)) { + const svgLines: string[] = [line] + if (!line.includes('')) { + i++ + while (i < lines.length && !lines[i].includes('')) { + svgLines.push(lines[i]) + i++ + } + if (i < lines.length) { + svgLines.push(lines[i]) + } + } + i++ + blocks.push({ type: 'svg', svg: svgLines.join('\n') }) + continue + } + + // Headings + const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line) + if (headingMatch) { + blocks.push({ + type: 'heading', + level: headingMatch[1].length, + text: headingMatch[2].trim(), + }) + i++ + continue + } + + // Academic Table + if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1])) { + const splitRow = (l: string) => + l + .replace(/^\s*\|/, '') + .replace(/\|\s*$/, '') + .split('|') + .map((c) => c.trim()) + + const header = splitRow(line) + i += 2 + const rows: string[][] = [] + while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { + rows.push(splitRow(lines[i])) + i++ + } + blocks.push({ type: 'table', header, rows }) + continue + } + + // Lists + const bullet = /^\s*([-*+]|\d+[.)])\s+/.exec(line) + if (bullet) { + const rawItems: { indent: number; ordered: boolean; start?: number; text: string }[] = [] + while (i < lines.length) { + const curLine = lines[i] + if (!curLine.trim()) { + let lookahead = i + 1 + let hasIndentedNext = false + while (lookahead < lines.length && !lines[lookahead].trim()) { + lookahead++ + } + if (lookahead < lines.length) { + const nextMatch = /^(\s+)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[lookahead]) + if (nextMatch && nextMatch[1].replace(/\t/g, ' ').length >= 2) { + hasIndentedNext = true + } + } + if (hasIndentedNext) { + i++ + continue + } + break + } + + const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(curLine) + if (m) { + const indent = m[1].replace(/\t/g, ' ').length + const isNum = /\d/.test(m[2]) + const startNum = isNum ? parseInt(m[2], 10) : undefined + rawItems.push({ + indent, + ordered: isNum, + start: startNum, + text: m[3], + }) + i++ + } else if (rawItems.length > 0 && (/^\s{2,}|\t+/.test(curLine) || rawItems[rawItems.length - 1].indent > 0)) { + rawItems[rawItems.length - 1].text += ' ' + curLine.trim() + i++ + } else { + break + } + } + + const items = buildListTree(rawItems) + const ordered = items.length > 0 ? items[0].ordered : false + const start = items.length > 0 ? items[0].start : undefined + blocks.push({ type: 'list', ordered, start, items }) + continue + } + + // Regular paragraphs or captions + const paraLines: string[] = [] + while ( + i < lines.length && + lines[i].trim() && + !/^\s*(#{1,6}\s| 0) { + blocks.push({ type: 'paragraph', text: paraLines.join('\n') }) + } + } + + return blocks +} + +function renderAcademicInline(str: string): string { + return str + .replace(/\*\*([\s\S]*?)\*\*/g, '$1') + .replace(/\*([\s\S]*?)\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') +} + +const safeHtml = (html: string) => + DOMPurify.sanitize(html, { FORBID_TAGS: ['style'] }) + +const safeSvg = (svg: string) => + DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, FORBID_TAGS: ['style'] }) + +function RenderAcademicListItem({ item }: { item: MarkdownListItem }) { + return ( +
  • + + {item.children && item.children.length > 0 ? ( + + ) : null} +
  • + ) +} + +function RenderAcademicList({ + items, + ordered, + start, +}: { + items: MarkdownListItem[] + ordered: boolean + start?: number +}) { + if (ordered) { + return ( +
      + {items.map((it, idx) => ( + + ))} +
    + ) + } + return ( +
      + {items.map((it, idx) => ( + + ))} +
    + ) +} + +export const AcademicDraft = memo(function AcademicDraft({ content }: AcademicDraftProps) { + const blocks = useMemo(() => parseAcademicBlocks(content), [content]) + + return ( +
    + {blocks.map((block, idx) => { + if (block.type === 'heading') { + if (block.level === 1) { + return ( +

    + ) + } + if (block.level === 2) { + return ( +

    + ) + } + if (block.level === 3) { + return ( +

    + ) + } + return ( +

    + ) + } + + if (block.type === 'svg') { + return ( +
    + ) + } + + if (block.type === 'table' && block.header) { + const numCols = block.header.length + const colLengths = new Array(numCols).fill(0) + block.header.forEach((h, i) => { + colLengths[i] = Math.max(colLengths[i], h.trim().length) + }) + block.rows?.forEach((row) => { + row.forEach((c, i) => { + if (i < numCols) colLengths[i] = Math.max(colLengths[i], c.trim().length) + }) + }) + const totalLen = colLengths.reduce((a, b) => a + b, 0) || 1 + const colWidths = colLengths.map((len, cIdx) => { + const h = block.header![cIdx]?.toLowerCase() + if (h === 'no' || h === 'no.' || h === '#') return '6%' + return `${Math.max(12, Math.round((len / totalLen) * 100))}%` + }) + + return ( +
    + + + + {block.header.map((h, i) => ( + + + + {block.rows?.map((row, r) => ( + + {row.map((c, i) => { + const isCenter = i === 0 && (block.header![0]?.toLowerCase().includes('no') || c.length <= 4) + return ( + + ))} + +
    + ))} +
    + ) + })} +
    +
    + ) + } + + if (block.type === 'list' && block.items) { + return ( +
    + +
    + ) + } + + if (block.type === 'paragraph' && block.text) { + const trimmed = block.text.trim() + const isFigure = /^\*?(Gambar|Figure)\s+\d+[\d.]*[:\s]/.test(trimmed) + const isTable = /^\*?(Tabel|Table)\s+\d+[\d.]*[:\s]/.test(trimmed) + + if (isFigure) { + return ( +

    + ) + } + + if (isTable) { + return ( +

    + ) + } + + return ( +

    + ) + } + + return null + })} +

    + ) +}) diff --git a/web/src/components/chat/ApprovalCard.tsx b/web/src/components/chat/ApprovalCard.tsx index c47444b4..7128e662 100644 --- a/web/src/components/chat/ApprovalCard.tsx +++ b/web/src/components/chat/ApprovalCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Check, ShieldWarning, X } from '@phosphor-icons/react' import { post } from '@/lib/api' import { useI18n } from '@/lib/i18n' @@ -42,6 +42,22 @@ export function ApprovalCard({ } } + const gdocsUpdateDiff = useMemo(() => { + if (approval.tool !== 'gdocs_update') return null + try { + const parsed = JSON.parse(approval.arguments) as Record + return { + findText: parsed.find_text || '', + position: parsed.position || 'after', + endText: parsed.end_text || '', + text: parsed.text || '', + heading: parsed.heading || '', + } + } catch { + return null + } + }, [approval.tool, approval.arguments]) + const pretty = formatArguments(approval.arguments) return ( @@ -50,7 +66,39 @@ export function ApprovalCard({

    {approval.message || t('approval.title')}

    - {pretty ? ( + + {gdocsUpdateDiff ? ( +
    +
    + + Target Google Docs: {gdocsUpdateDiff.findText ? `[${gdocsUpdateDiff.position.toUpperCase()}] "${gdocsUpdateDiff.findText}"` : 'Akhir Dokumen'} + + {gdocsUpdateDiff.heading ? ( + + Style: {gdocsUpdateDiff.heading} + + ) : null} +
    + + {gdocsUpdateDiff.position === 'replace' && (gdocsUpdateDiff.findText || gdocsUpdateDiff.endText) ? ( +
    +

    - Yang Akan Digantikan:

    +

    + {gdocsUpdateDiff.endText + ? `Seluruh isi antara "${gdocsUpdateDiff.findText}" s/d "${gdocsUpdateDiff.endText}"` + : `Teks penanda "${gdocsUpdateDiff.findText}"`} +

    +
    + ) : null} + +
    +

    + Draf Teks Baru yang Akan Masuk:

    +

    + {gdocsUpdateDiff.text} +

    +
    +
    + ) : pretty ? (
                   {pretty}
                 
    diff --git a/web/src/components/chat/ChatFolderTree.tsx b/web/src/components/chat/ChatFolderTree.tsx new file mode 100644 index 00000000..92d1c1cb --- /dev/null +++ b/web/src/components/chat/ChatFolderTree.tsx @@ -0,0 +1,566 @@ +import { memo, useEffect, useMemo, useState } from 'react' +import { + ArrowsClockwise, + CaretDown, + CaretRight, + FilePlus, + Folder, + FolderOpen, + FolderPlus, + House, + MagnifyingGlass, + PencilSimple, + Spinner, + Trash, + TreeStructure, + WarningCircle, + X, +} from '@phosphor-icons/react' +import { del, get, post } from '@/lib/api' +import { cn } from '@/lib/utils' +import { useI18n } from '@/lib/i18n' +import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/primitives' +import { detectFileKind, getFileIcon, type SelectedFile } from './FileViewerPanel' + +interface TreeNode { + name: string + path: string + is_dir: boolean + size?: number +} + +interface ChatFolderTreeProps { + projectDir?: string + activeFilePath?: string + onSelectFile: (file: SelectedFile) => void + onClose: () => void + refreshKey?: number + /** The selected file (or an ancestor) was renamed or moved. */ + onPathRenamed?: (oldPath: string, newPath: string) => void + /** The selected file (or an ancestor) was deleted. */ + onPathDeleted?: (path: string) => void +} + +type EditorDialog = + | { kind: 'create'; parent: string; type: 'file' | 'dir'; name: string } + | { kind: 'rename'; node: TreeNode; name: string } + +function parentOf(path: string): string { + const i = path.lastIndexOf('/') + return i === -1 ? '' : path.slice(0, i) +} + +function childOf(parent: string, name: string): string { + return parent ? `${parent}/${name}` : name +} + +const ROW_ACTION = + 'rounded-[var(--radius-sm)] p-1 text-muted-foreground transition-colors hover:bg-raised hover:text-foreground' + +export const ChatFolderTree = memo(function ChatFolderTree({ + projectDir, + activeFilePath, + onSelectFile, + onClose, + refreshKey, + onPathRenamed, + onPathDeleted, +}: ChatFolderTreeProps) { + const { t } = useI18n() + const [tree, setTree] = useState>({}) + const [open, setOpen] = useState>({ '': true }) + const [selectedDir, setSelectedDir] = useState('') + const [loading, setLoading] = useState(false) + const [filterQuery, setFilterQuery] = useState('') + const [refreshCount, setRefreshCount] = useState(0) + const [dialog, setDialog] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [busy, setBusy] = useState(false) + const [actionError, setActionError] = useState(null) + const [treeError, setTreeError] = useState(null) + + const dirParam = projectDir ? `&dir=${encodeURIComponent(projectDir)}` : '' + const rootLabel = useMemo(() => { + if (projectDir) { + return projectDir.split('/').filter(Boolean).pop() || projectDir + } + return 'Workspace' + }, [projectDir]) + + const loadSubdir = async (sub: string) => { + try { + const res = await get<{ entries: TreeNode[] }>( + `/files?path=${encodeURIComponent(sub)}${dirParam}`, + ) + setTree((prev) => ({ ...prev, [sub]: res.entries ?? [] })) + } catch { + // ignore + } + } + + // Reload tree on mount, projectDir change, or refreshKey change + useEffect(() => { + let alive = true + setLoading(true) + get<{ entries: TreeNode[] }>(`/files?path=.${dirParam}`) + .then((res) => { + if (!alive) return + setTree({ '': res.entries ?? [] }) + setOpen({ '': true }) + setSelectedDir('') + }) + .catch(() => {}) + .finally(() => { + if (alive) setLoading(false) + }) + return () => { + alive = false + } + }, [projectDir, dirParam, refreshKey, refreshCount]) + + const toggleFolder = (node: TreeNode) => { + if (!node.is_dir) return + const isOpen = open[node.path] + setSelectedDir(node.path) + setOpen((prev) => ({ ...prev, [node.path]: !isOpen })) + if (!isOpen && !tree[node.path]) { + void loadSubdir(node.path) + } + } + + const collapseAll = () => { + setOpen({ '': true }) + } + + // Filtered nodes for search + const matchesQuery = (name: string) => { + if (!filterQuery.trim()) return true + return name.toLowerCase().includes(filterQuery.trim().toLowerCase()) + } + + const openCreate = (parent: string, type: 'file' | 'dir') => { + setActionError(null) + setDialog({ kind: 'create', parent, type, name: '' }) + } + + const openRename = (node: TreeNode) => { + setActionError(null) + setDialog({ kind: 'rename', node, name: node.name }) + } + + const submitDialog = async () => { + if (!dialog || busy) return + const name = dialog.name.trim() + if (!name || name === '.' || name === '..' || /[/\\]/.test(name)) { + setActionError(t('tree.invalidName')) + return + } + setBusy(true) + setActionError(null) + try { + if (dialog.kind === 'create') { + const parent = dialog.parent + const path = childOf(parent, name) + await post('/files/create', { path, type: dialog.type }) + setDialog(null) + setOpen((prev) => ({ ...prev, [parent]: true })) + await loadSubdir(parent) + if (dialog.type === 'file') { + onSelectFile({ name, path, is_dir: false }) + } + } else { + const oldPath = dialog.node.path + const res = await post<{ path: string }>('/files/rename', { + path: oldPath, + name, + }) + setDialog(null) + if (dialog.node.is_dir) { + setRefreshCount((c) => c + 1) + } else { + await loadSubdir(parentOf(oldPath)) + } + onPathRenamed?.(oldPath, res.path) + } + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const confirmDelete = async () => { + const target = deleteTarget + if (!target || busy) return + setBusy(true) + setTreeError(null) + try { + const recursive = target.is_dir ? '&recursive=1' : '' + await del(`/files?path=${encodeURIComponent(target.path)}${dirParam}${recursive}`) + setDeleteTarget(null) + setRefreshCount((c) => c + 1) + if (activeFilePath === target.path || activeFilePath?.startsWith(`${target.path}/`)) { + onPathDeleted?.(target.path) + } + } catch (err) { + setDeleteTarget(null) + setTreeError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + const renderTree = (sub: string, depth: number) => { + const nodes = tree[sub] ?? [] + return nodes.map((node) => { + const isExpanded = open[node.path] + const isSelected = activeFilePath === node.path + const isDirTarget = node.is_dir && selectedDir === node.path + const kind = detectFileKind(node.name) + + // Skip non-matching files during search (always keep directories visible so search can penetrate) + if (filterQuery && !node.is_dir && !matchesQuery(node.name)) { + return null + } + + return ( +
    +
    + + +
    + {node.is_dir ? ( + <> + + + + ) : null} + + +
    +
    + + {node.is_dir && isExpanded ? renderTree(node.path, depth + 1) : null} +
    + ) + }) + } + + return ( +
    + {/* Header Bar */} +
    +
    + +
    + +
    + + + + + +
    +
    + + {/* Search Input Bar */} +
    +
    + + setFilterQuery(e.target.value)} + className="h-7 pl-7.5 pr-6 font-mono text-[11px]" + /> + {filterQuery ? ( + + ) : null} +
    +
    + + {treeError ? ( +
    + + {treeError} + +
    + ) : null} + + {/* Tree Content */} +
    + {loading && !tree[''] ? ( +
    + + {t('tree.loading')} +
    + ) : tree[''] && tree[''].length === 0 ? ( +
    + {t('tree.empty')} +
    + ) : ( + renderTree('', 0) + )} +
    + + {/* Footer Info */} +
    + {t('tree.items', { n: tree['']?.length ?? 0 })} + +
    + + {/* Create / rename dialog */} + { + if (!next && !busy) { + setDialog(null) + setActionError(null) + } + }} + > + +
    { + e.preventDefault() + void submitDialog() + }} + > + + + {dialog?.kind === 'rename' + ? t('tree.renameTitle', { name: dialog.node.name }) + : dialog?.type === 'dir' + ? t('tree.createFolderTitle') + : t('tree.createFileTitle')} + + + {dialog?.kind === 'create' + ? t('tree.inFolder', { path: dialog.parent || t('tree.root') }) + : dialog?.node.path ?? ''} + + + + + + setDialog((prev) => (prev ? { ...prev, name: e.target.value } : prev)) + } + className="font-mono text-xs" + /> + {actionError ? ( +

    {actionError}

    + ) : null} +
    + + + + +
    +
    +
    + + { + if (!next) setDeleteTarget(null) + }} + title={ + deleteTarget + ? t('tree.deleteTitle', { name: deleteTarget.name }) + : t('common.delete') + } + description={ + deleteTarget?.is_dir + ? t('tree.deleteFolderDesc') + : t('tree.deleteFileDesc') + } + confirmLabel={t('common.delete')} + loading={busy} + onConfirm={() => void confirmDelete()} + /> +
    + ) +}) diff --git a/web/src/components/chat/CodeEditor.tsx b/web/src/components/chat/CodeEditor.tsx new file mode 100644 index 00000000..1c0a8d22 --- /dev/null +++ b/web/src/components/chat/CodeEditor.tsx @@ -0,0 +1,143 @@ +import { memo, useEffect, useRef } from 'react' +import { Compartment, EditorState, type Extension } from '@codemirror/state' +import { + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + drawSelection, + dropCursor, + rectangularSelection, + crosshairCursor, + lineNumbers, + keymap, +} from '@codemirror/view' +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language' +import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete' +import { highlightSelectionMatches, searchKeymap } from '@codemirror/search' +import { loadLanguageForFile } from '@/lib/editor/languages' +import { antaresEditorExtensions, isDarkMode } from '@/lib/editor/theme' + +interface CodeEditorProps { + fileName: string + /** Authoritative text. Applied on mount and whenever docVersion changes. */ + value: string + /** + * Bump to replace the document with `value` (an agent edit, a reload, a + * discard). Ordinary typing does not change it, so the caret is never reset + * under the user. + */ + docVersion: number + readOnly?: boolean + onChange: (value: string) => void + onSave: (value: string) => void +} + +function buildExtensions( + compartment: Compartment, + readOnly: boolean, + onChange: (value: string) => void, + onSave: (value: string) => void, +): Extension[] { + return [ + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + closeBrackets(), + autocompletion(), + rectangularSelection(), + crosshairCursor(), + highlightActiveLine(), + highlightSelectionMatches(), + ...antaresEditorExtensions(isDarkMode()), + EditorState.readOnly.of(readOnly), + keymap.of([ + { key: 'Mod-s', preventDefault: true, run: (view) => (onSave(view.state.doc.toString()), true) }, + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + indentWithTab, + ]), + EditorView.updateListener.of((update) => { + if (update.docChanged) onChange(update.state.doc.toString()) + }), + compartment.of([]), + ] +} + +/** + * A thin, uncontrolled CodeMirror 6 wrapper. It is uncontrolled on purpose: a + * controlled editor would dispatch a document replacement on every keystroke + * and fight the user's caret. External replacements are explicit via + * `docVersion`. + */ +export const CodeEditor = memo(function CodeEditor({ + fileName, + value, + docVersion, + readOnly = false, + onChange, + onSave, +}: CodeEditorProps) { + const hostRef = useRef(null) + const viewRef = useRef(null) + const languageCompartment = useRef(new Compartment()) + const onChangeRef = useRef(onChange) + const onSaveRef = useRef(onSave) + onChangeRef.current = onChange + onSaveRef.current = onSave + + useEffect(() => { + if (!hostRef.current) return + let cancelled = false + const compartment = languageCompartment.current + const view = new EditorView({ + state: EditorState.create({ + doc: value, + extensions: buildExtensions( + compartment, + readOnly, + (next) => onChangeRef.current(next), + (next) => onSaveRef.current(next), + ), + }), + parent: hostRef.current, + }) + viewRef.current = view + + // Plain text first, highlighting when the (lazily imported) grammar lands. + void loadLanguageForFile(fileName).then((language) => { + if (!cancelled && language) view.dispatch({ effects: compartment.reconfigure(language) }) + }) + + return () => { + cancelled = true + view.destroy() + viewRef.current = null + } + // A new file remounts via key={file.path} in the parent; the rest is stable. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + const view = viewRef.current + if (!view) return + const current = view.state.doc.toString() + if (current === value) return + view.dispatch({ changes: { from: 0, to: current.length, insert: value } }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [docVersion]) + + return
    +}) diff --git a/web/src/components/chat/CodeWorkspace.tsx b/web/src/components/chat/CodeWorkspace.tsx new file mode 100644 index 00000000..b6ae9789 --- /dev/null +++ b/web/src/components/chat/CodeWorkspace.tsx @@ -0,0 +1,419 @@ +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react' +import { + CaretDown, + CaretUp, + FloppyDisk, + Spinner, + WarningCircle, + X, +} from '@phosphor-icons/react' +import { ApiError, get, post } from '@/lib/api' +import { cn } from '@/lib/utils' +import { useI18n } from '@/lib/i18n' +import { Button } from '@/components/ui/button' +import { Markdown } from '@/components/chat/Markdown' +import { CodeEditor } from '@/components/chat/CodeEditor' +import { TerminalPanel } from '@/components/chat/TerminalPanel' +import { isMarkdownFile } from '@/lib/editor/languages' +import { + detectFileKind, + FileViewerPanel, + getFileIcon, + type SelectedFile, +} from '@/components/chat/FileViewerPanel' + +export interface CodeWorkspaceHandle { + /** Persists the editor. `force` overwrites a file that changed on disk. */ + save: (force?: boolean) => Promise + /** Discards local edits and re-reads the file from disk. */ + reload: () => void +} + +interface CodeWorkspaceProps { + /** `null` renders the terminal on its own, with no file tab behind it. */ + file: SelectedFile | null + /** Hides (but keeps) the editor so the terminal can take the full panel. */ + showEditor?: boolean + baseDir?: string + /** Bumped by the parent when the agent may have written to the project. */ + refreshKey?: number + onSaved?: () => void + onDirtyChange?: (dirty: boolean) => void + onClose: () => void +} + +const TERM_HEIGHT_KEY = 'antares:code-terminal-height' +const TERM_COLLAPSED_KEY = 'antares:code-terminal-collapsed' + +function readTermHeight(): number { + try { + const saved = localStorage.getItem(TERM_HEIGHT_KEY) + if (saved) { + const parsed = parseInt(saved, 10) + if (parsed >= 100 && parsed <= 700) return parsed + } + } catch { + /* ignore */ + } + return 230 +} + +/** + * The right-hand IDE surface: an editable file on top and a real shell below, + * with the terminal kept mounted so its session survives tab switches. + */ +export const CodeWorkspace = memo( + forwardRef(function CodeWorkspace( + { file, showEditor = true, baseDir, refreshKey, onSaved, onDirtyChange, onClose }, + ref, + ) { + const { t } = useI18n() + const kind = file ? detectFileKind(file.name) : null + const editable = kind === 'code' || kind === 'text' || kind === 'markdown' + const filePath = file?.path ?? '' + const fileName = file?.name ?? '' + const editorVisible = showEditor && file !== null + + const [content, setContent] = useState('') + const [savedContent, setSavedContent] = useState('') + const [mtime, setMtime] = useState(null) + const [docVersion, setDocVersion] = useState(0) + const [loading, setLoading] = useState(editable) + const [loadError, setLoadError] = useState(null) + const [saveError, setSaveError] = useState(null) + const [saving, setSaving] = useState(false) + const [conflict, setConflict] = useState(false) + const [diskChanged, setDiskChanged] = useState(false) + const [mdPreview, setMdPreview] = useState(false) + + const [termHeight, setTermHeight] = useState(readTermHeight) + const [termCollapsed, setTermCollapsed] = useState(() => { + try { + return localStorage.getItem(TERM_COLLAPSED_KEY) === 'true' + } catch { + return false + } + }) + const [draggingTerm, setDraggingTerm] = useState(false) + const containerRef = useRef(null) + + const dirParam = baseDir ? `&dir=${encodeURIComponent(baseDir)}` : '' + const dirty = editable && content !== savedContent + + const load = useCallback(async () => { + if (!editable || !filePath) return + setLoading(true) + setLoadError(null) + try { + const res = await get<{ content?: string; binary?: boolean; mtime?: number }>( + `/files/read?path=${encodeURIComponent(filePath)}${dirParam}`, + ) + if (res.binary) { + setLoadError(t('editor.binary')) + return + } + const text = res.content ?? '' + setContent(text) + setSavedContent(text) + setMtime(res.mtime ?? null) + setDocVersion((v) => v + 1) + setConflict(false) + setDiskChanged(false) + } catch (err) { + setLoadError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + }, [editable, filePath, dirParam, t]) + + // Load when the file changes. + useEffect(() => { + setMdPreview(false) + setSaveError(null) + setConflict(false) + setDiskChanged(false) + setContent('') + setSavedContent('') + setMtime(null) + if (editable) void load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filePath, baseDir, editable]) + + // The agent finished a turn: re-read unless the buffer has local edits, in + // which case flag the divergence and let the user choose. + const firstRefresh = useRef(true) + useEffect(() => { + if (firstRefresh.current) { + firstRefresh.current = false + return + } + if (!editable) return + if (dirty) setDiskChanged(true) + else void load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshKey]) + + const save = useCallback( + async (force = false, overrideContent?: string) => { + if (!editable || !filePath || saving) return true + const text = overrideContent ?? content + setSaving(true) + setSaveError(null) + try { + const body: Record = { path: filePath, content: text } + if (!force && mtime !== null) body.expected_mtime = mtime + const res = await post<{ mtime: number }>(`/files/write${dirParam ? `?${dirParam.slice(1)}` : ''}`, body) + setSavedContent(text) + setMtime(res.mtime) + setConflict(false) + setDiskChanged(false) + onSaved?.() + return true + } catch (err) { + if (err instanceof ApiError && err.status === 409) { + setConflict(true) + } else { + setSaveError(err instanceof Error ? err.message : String(err)) + } + return false + } finally { + setSaving(false) + } + }, + [editable, saving, filePath, content, mtime, dirParam, onSaved], + ) + + useImperativeHandle(ref, () => ({ save, reload: () => void load() }), [save, load]) + + // Report the buffer's dirty state so the parent can guard file switches. + useEffect(() => { + onDirtyChange?.(dirty) + return () => onDirtyChange?.(false) + }, [dirty, onDirtyChange]) + + // Terminal drag-to-resize. + useEffect(() => { + if (!draggingTerm) return + const onMove = (e: MouseEvent) => { + const rect = containerRef.current?.getBoundingClientRect() + if (!rect) return + const next = Math.max(100, Math.min(700, rect.bottom - e.clientY)) + setTermHeight(next) + try { + localStorage.setItem(TERM_HEIGHT_KEY, String(next)) + } catch { + /* ignore */ + } + } + const onUp = () => setDraggingTerm(false) + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + return () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + }, [draggingTerm]) + + const toggleTerminal = useCallback(() => { + setTermCollapsed((prev) => { + const next = !prev + try { + localStorage.setItem(TERM_COLLAPSED_KEY, String(next)) + } catch { + /* ignore */ + } + return next + }) + }, []) + + const terminalLabels = { + connecting: t('editor.terminalConnecting'), + exited: t('editor.terminalExited'), + error: t('editor.terminalError'), + clear: t('editor.terminalClear'), + restart: t('editor.terminalRestart'), + hint: t('editor.terminalHint'), + } + + return ( +
    + {/* + The editor stays mounted (just hidden) when the terminal tab is active, + so switching tabs never drops the buffer or the shell session. + */} +
    + {/* Editor toolbar (files the viewer cannot edit bring their own) */} + {editable && file ? ( +
    +
    + {getFileIcon(kind ?? 'text', 'size-4 shrink-0')} + + {fileName} + + {dirty ? : null} +
    +
    + {editable && isMarkdownFile(fileName) ? ( +
    + + +
    + ) : null} + {editable ? ( + + ) : null} + +
    +
    + ) : null} + + {/* Divergence / conflict banners */} + {conflict || diskChanged ? ( +
    + + {t('editor.changedOnDisk')} + + +
    + ) : null} + {saveError ? ( +
    + + {t('editor.saveFailed')}: {saveError} +
    + ) : null} + + {/* Editor / viewer */} +
    + {!file ? null : !editable ? ( + + ) : loading ? ( +
    + + {t('editor.loading')} +
    + ) : loadError ? ( +
    + + {loadError} +
    + ) : mdPreview ? ( +
    + +
    + ) : ( + { + setContent(value) + void save(false, value) + }} + /> + )} +
    +
    + + {/* Terminal divider / collapsed bar (editor mode only) */} +
    + {termCollapsed ? ( + + ) : ( +
    { + e.preventDefault() + setDraggingTerm(true) + }} + className={cn( + 'group flex h-2 shrink-0 cursor-row-resize items-center justify-center border-t border-border bg-card/60', + draggingTerm && 'bg-primary/20', + )} + > + +
    + )} +
    + + {/* One TerminalPanel for both modes: full-height alone, split with a file. */} +
    + +
    +
    + ) + }), +) diff --git a/web/src/components/chat/FileViewerPanel.tsx b/web/src/components/chat/FileViewerPanel.tsx new file mode 100644 index 00000000..392f7214 --- /dev/null +++ b/web/src/components/chat/FileViewerPanel.tsx @@ -0,0 +1,521 @@ +import { memo, useEffect, useMemo, useState } from 'react' +import { + ArrowSquareOut, + Check, + Copy, + DownloadSimple, + File as FileIcon, + FileArchive, + FileAudio, + FileCode, + FileImage, + FilePdf, + FileText, + FileVideo, + MagnifyingGlass, + Spinner, + Table, + WarningCircle, + X, +} from '@phosphor-icons/react' +import { authedUrl, downloadFile, get } from '@/lib/api' +import { copyText } from '@/lib/clipboard' +import { formatBytes } from '@/lib/utils' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/primitives' +import { Markdown } from '@/components/chat/Markdown' + +export interface SelectedFile { + name: string + path: string + is_dir: boolean + size?: number +} + +type FileKind = + | 'markdown' + | 'pdf' + | 'sheet' + | 'code' + | 'text' + | 'image' + | 'video' + | 'audio' + | 'archive' + | 'binary' + +const EXT_MAP: Record = { + md: 'markdown', + markdown: 'markdown', + mdx: 'markdown', + pdf: 'pdf', + csv: 'sheet', + tsv: 'sheet', + png: 'image', + jpg: 'image', + jpeg: 'image', + gif: 'image', + webp: 'image', + svg: 'image', + bmp: 'image', + ico: 'image', + mp4: 'video', + webm: 'video', + mov: 'video', + mp3: 'audio', + wav: 'audio', + ogg: 'audio', + m4a: 'audio', + zip: 'archive', + tar: 'archive', + gz: 'archive', + tgz: 'archive', + rar: 'archive', + '7z': 'archive', + js: 'code', + ts: 'code', + tsx: 'code', + jsx: 'code', + go: 'code', + py: 'code', + rs: 'code', + java: 'code', + c: 'code', + cpp: 'code', + h: 'code', + html: 'code', + css: 'code', + scss: 'code', + json: 'code', + yaml: 'code', + yml: 'code', + toml: 'code', + sql: 'code', + sh: 'code', + bash: 'code', + txt: 'text', + log: 'text', + ini: 'text', + conf: 'text', + env: 'text', +} + +export function detectFileKind(fileName: string): FileKind { + const ext = fileName.split('.').pop()?.toLowerCase() ?? '' + return EXT_MAP[ext] ?? 'binary' +} + +export function getFileIcon(kind: FileKind, className = 'size-4') { + switch (kind) { + case 'markdown': + return + case 'pdf': + return + case 'sheet': + return + case 'code': + return + case 'text': + return + case 'image': + return + case 'video': + return + case 'audio': + return + case 'archive': + return + default: + return + } +} + +// Simple CSV/TSV parser supporting quotes +function parseDelimitedText(text: string, delimiter: string): string[][] { + const rows: string[][] = [] + let currentRow: string[] = [] + let currentCell = '' + let insideQuotes = false + + for (let i = 0; i < text.length; i++) { + const char = text[i] + const nextChar = text[i + 1] + + if (char === '"') { + if (insideQuotes && nextChar === '"') { + currentCell += '"' + i++ + } else { + insideQuotes = !insideQuotes + } + } else if (char === delimiter && !insideQuotes) { + currentRow.push(currentCell.trim()) + currentCell = '' + } else if ((char === '\r' || char === '\n') && !insideQuotes) { + if (char === '\r' && nextChar === '\n') { + i++ + } + currentRow.push(currentCell.trim()) + if (currentRow.some((c) => c !== '')) { + rows.push(currentRow) + } + currentRow = [] + currentCell = '' + } else { + currentCell += char + } + } + + if (currentCell !== '' || currentRow.length > 0) { + currentRow.push(currentCell.trim()) + if (currentRow.some((c) => c !== '')) { + rows.push(currentRow) + } + } + + return rows +} + +interface FileViewerPanelProps { + file: SelectedFile + baseDir?: string + onClose: () => void +} + +export const FileViewerPanel = memo(function FileViewerPanel({ + file, + baseDir, + onClose, +}: FileViewerPanelProps) { + const kind = useMemo(() => detectFileKind(file.name), [file.name]) + const [content, setContent] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [isBinary, setIsBinary] = useState(false) + const [fileSize, setFileSize] = useState(file.size) + + // Markdown view mode: rendered vs raw + const [mdViewMode, setMdViewMode] = useState<'rendered' | 'raw'>('rendered') + const [copied, setCopied] = useState(false) + const [sheetFilter, setSheetFilter] = useState('') + + // Construct raw and read URLs + const dirParam = baseDir ? `&dir=${encodeURIComponent(baseDir)}` : '' + const rawUrl = authedUrl(`/files/raw?path=${encodeURIComponent(file.path)}${dirParam}`) + + // Fetch text content for markdown, sheet, code, text + useEffect(() => { + setContent(null) + setError(null) + setIsBinary(false) + setSheetFilter('') + setMdViewMode('rendered') + + // Media and PDF can render directly via rawUrl without fetching text JSON + if (kind === 'pdf' || kind === 'image' || kind === 'video' || kind === 'audio') { + return + } + + setLoading(true) + get<{ content?: string; binary?: boolean; size?: number }>( + `/files/read?path=${encodeURIComponent(file.path)}${dirParam}`, + ) + .then((res) => { + if (res.size !== undefined) setFileSize(res.size) + if (res.binary) { + setIsBinary(true) + } else { + setContent(res.content ?? '') + } + }) + .catch((err) => { + setError(err?.message || 'Gagal membaca isi file') + }) + .finally(() => { + setLoading(false) + }) + }, [file.path, baseDir, kind, dirParam]) + + const handleCopy = async () => { + if (content) { + const ok = await copyText(content) + if (ok) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + } + + const handleDownload = () => { + void downloadFile(`/files/raw?path=${encodeURIComponent(file.path)}${dirParam}&download=1`, file.name) + } + + // Parse sheet rows for CSV/TSV + const sheetData = useMemo(() => { + if (kind !== 'sheet' || !content) return null + const delimiter = file.name.endsWith('.tsv') ? '\t' : ',' + const parsed = parseDelimitedText(content, delimiter) + if (!parsed.length) return null + const header = parsed[0] + const rows = parsed.slice(1) + return { header, rows } + }, [kind, content, file.name]) + + const filteredSheetRows = useMemo(() => { + if (!sheetData) return [] + const q = sheetFilter.trim().toLowerCase() + if (!q) return sheetData.rows + return sheetData.rows.filter((row) => + row.some((cell) => cell.toLowerCase().includes(q)), + ) + }, [sheetData, sheetFilter]) + + return ( +
    + {/* Top Header Toolbar */} +
    +
    + {getFileIcon(kind, 'size-4 shrink-0')} + + {file.name} + + {fileSize !== undefined ? ( + + ({formatBytes(fileSize)}) + + ) : null} +
    + +
    + {/* Markdown view mode pill */} + {kind === 'markdown' && content && !isBinary ? ( +
    + + +
    + ) : null} + + {/* Copy content button */} + {content && !isBinary ? ( + + ) : null} + + {/* Download button */} + + + {/* Open Raw in New Tab */} + + + {/* Close File Viewer button */} + +
    +
    + + {/* Main Content Pane */} +
    + {loading ? ( +
    + + Memuat file... +
    + ) : error ? ( +
    + + {error} +
    + ) : kind === 'pdf' ? ( +
    +