mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-13 11:30:58 +02:00
* feat(wecom): read the photos, files and videos people send the bot dispatchFrame routed only msgtype == "text". Everything else — a screenshot, a PDF, a video, a 图文混排 with a sentence and two pictures — was answered with "抱歉,我目前只能处理文字消息。" and dropped at the socket. No chat_message, no session, no agent run, nothing in the web UI either. On WeCom, pasting a screenshot under a question is how people ask half their questions, so the bot looked broken for a large share of what it was sent. Inbound media now routes. A callback's image/file/video body carries a pre-signed COS url and a per-url AES key, so this needs no WeCom credential: fetch, decrypt (AES-256-CBC, IV from the front of the key, PKCS#7 padded to 32 not 16), and copy the bytes into Multica storage behind an intent-ledger row written before the PUT — the same order and the same no-inline-delete rule lark/media_ingest.go established. The stored body carries [Image] / [File] / [Video], byte-identical to what lark/content_flatten.go and dingtalk/inbound.go emit, so the agent reads one spelling across every channel. The fetch is guarded. Its destination is a string that arrived over the socket, and the process fetching it sits inside the deployment's network, so the client resolves the host itself and refuses any non-public address, on every hop of every redirect. Without it this machine really does reach 100.100.100.100. The shape is @leroy-chen's from #6524; the reserved-range list and an injectable resolver are added on top. Transport errors are also stripped of their *url.Error wrapper before they reach a log, because that wrapper prints the URL and the URL is a five-minute bearer credential for somebody's private attachment. channelMsgType now maps "mixed" to MsgTypeText, replacing a comment that argued for Unknown. That comment was right about the code it described: without routing, calling mixed Text claimed a path that did not exist. This change is what makes the claim true, which is why the mapping and the routing are in one commit — Lark treats its own `post` exactly this way (feishu_channel.go:167 → Text, media_ingest.go:272 pulls the spans). The receipt for a kind that still cannot be read no longer says "text only", which would now be a lie told to someone who just watched the bot answer a screenshot. Not here: standalone voice notes (#6599 owns that path; only a voice run inside a 图文混排 is read here, because dropping it would lose a spoken sentence from the middle of a message whose other runs are read), sending media back out (that needs aibot_upload_media_*), and quoted-message rendering (it changes where slash commands are parsed from, which belongs with the command work). * fix(wecom): bind the media this PR downloads, and hold the run until it lands Caught testing this branch against a live tenant. The resolver worked — the image downloaded, decrypted and stored, 509KB of JPEG on disk with correct metadata — and then sessionBinder.BindMedia threw the refs away and returned nil, which the Router reads as "bound fine". No attachment row, so the agent saw only the [Image] placeholder and found nothing in its workdir. Nothing was logged, because from the engine's side nothing failed. The stub was correct before this PR: WeCom registered no MediaResolver, so there was genuinely nothing to bind, and the comment above it said so. Adding the resolver made that comment false and left the code under it untouched. engineSessionBinder did not even declare BindMediaRefs, so the binder was structurally unable to bind anything. Same cause one function up: AppendMessage never forwarded MediaPendingSeconds, so channel_media_pending_until stayed NULL and the chat task fired immediately — handing the agent the placeholder while the download was still running. Fixing only the binder leaves that as a race that still shows [Image] whenever the run wins. lark/feishu_resolvers.go:223 passes all of it, including IssueID, which is what makes an /issue turn's attachment belong to the issue rather than to a chat message nobody opens again. dingtalk and slack do the same. Three tests. The end-to-end one drives engine.Router.Handle over the real resolver set, the real chat session, real Postgres and a real download of genuinely AES-encrypted bytes, then asks the database whether an attachment exists. Reverting the binder: attachment rows on the chat message = 0, want 1 — the image was stored but never bound, so the agent only ever sees "[Image]" channel_media_pending_until is NULL on a message carrying an image — the chat task will fire at once and hand the agent the placeholder while the download is still running Also replaces TestSessionBinder_BindMediaIsNoop, which asserted the no-op as intended behaviour ("want nil (wecom resolves no media)") and would have kept passing forever. * fix(wecom): give the media guard's allow-list an actual switch SetMediaAllowedPrefixes shipped with no production caller. Its own doc comment said "called at boot from MULTICA_WECOM_MEDIA_ALLOW_CIDRS" and nothing read that variable — the only callers were its own tests. An operator behind a fake-IP proxy, where every hostname resolves into 198.18.0.0/15 and the SSRF guard therefore refuses every download, had the exemption and no way to turn it on. Wires it: router.go reads the variable at boot, reports each malformed entry and warns which ranges were opened; .env.example documents it and docker-compose.selfhost.yml passes it through. The security property is unchanged and now tested through the dialer rather than the policy function alone: the allow-list is consulted only inside the reservedMediaPrefixes loop, so loopback, private and link-local are refused before it is reached. MULTICA_WECOM_MEDIA_ALLOW_CIDRS=0.0.0.0/0 does not open 127.0.0.1, and the new test asserts no socket is opened to a real listening server on it. A guard test in cmd/server parses router.go and fails if the wiring goes missing again, or if the parse errors stop being reported. A value-level assertion cannot observe a call that was never made, which is the whole regression here. Also in this change: - Remove the stale BindMedia paragraph in wecom_resolvers.go. It said wecom registers no MediaResolver, the Router never resolves media, and the method is never called — every clause false as of this branch, sitting directly above the working implementation. - Fix the data race behind the red CI run. bindTestTasks.promoted is written from the Router's detached media goroutine and was read unguarded from the test goroutine; the DB poll the test waits on is not a synchronisation edge, because the bind transaction commits one call earlier than the promotion. Guarded with a mutex and an accessor, matching the engine package's own doubles, and the poll now waits for the promotion instead of assuming it already happened. The race was in the test rig only: service.TaskService.PromoteChannelChatTasksIfMediaReady keeps no in-process mutable state. - Correct two comments this branch's own diff falsified. media_stream.go described two temp files with ciphertext landing in the first; there is one, and the ciphertext streams from the socket without touching disk. It also narrated a prior buffered-only state that never existed — both paths arrived in the same commit. maxMediaBytes called itself the per-download heap bound, which holds only on the fallback path; both shipped storage backends implement UploadStream and take the streaming one, where the ceiling bounds disk. - Move publicAddrOnly's doc comment back onto publicAddrOnly. The allow-list was inserted between the two, leaving godoc attributing it to mediaAllowedPrefixes. * fix(wecom): tell an operator when the media guard is the one refusing Three residuals from the media-ingest review. ErrMediaAddrBlocked was exported with no production reader. Its doc said "the caller logs the two differently" and the caller did not: every download failure funnelled into one "wecom media ingest failed" line, and classifyMediaFailure branched only on errMediaTooLarge. An operator behind a fake-IP proxy — the case MULTICA_WECOM_MEDIA_ALLOW_CIDRS exists for — saw a generic failure and no reason to look at their own configuration. classifyMediaFailure now branches on it, and the blocked case gets a log line that names the guard as the refuser and points at the env var. The sender still gets the plain did-not-arrive wording: a refused address is not something they can act on, and neither the resolved address nor the signed url belongs in a chat message. Two kinds now render to one sentence, so the notice dedupe moved from appendFailure to the line build — one blocked plus one unreadable attachment is still one piece of news, not the adapter repeating itself. Tests drive real errors rather than hand-built ones. The refusal travels from the dialer through http.Client's *url.Error and stripURL before anything classifies it, so constructing ErrMediaAddrBlocked directly would pass even if that chain stopped preserving errors.Is. Also: - Delete mediaGuard.dialer. No writer anywhere, production or test: every mediaGuard literal in the tree left it nil and dial fell through to the default. It read as an injectable connect timeout nothing could inject. The default moves out of the loop, which also stops allocating a Dialer per resolved address. - Fix the DingTalk parenthetical in wecom_resolvers.go. Its media parameter is an engine.MediaResolver, so the if guarding that assignment is redundant for the same reason wecom assigns straight through; the typed-nil hazard is on the if above it, where ack is a concrete *ackNotifier. * fix(wecom): the media guard did not know the local-use NAT64 prefix reservedMediaPrefixes carried 64:ff9b::/96, the well-known NAT64 prefix, and not 64:ff9b:1::/48, the local-use one from RFC 8215 — the prefix an operator picks when the well-known one is already taken. An address in it fell through publicAddrOnly to true, so on a deployment running that prefix an attacker-supplied media URL reached IPv4 private and link-local targets through the translator. 64:ff9b:1::a9fe:a9fe is 169.254.169.254 on the other side. That is the whole guarantee this file exists to make. The regression test goes through the real dialer, not through publicAddrOnly: a policy that answers "no" while the transport connects anyway is a guard that does not guard, and the two are only told apart by which error comes back. ErrMediaAddrBlocked means the address never left the policy; a connect timeout means a socket was opened. With the prefix removed the test reports "dial tcp [64:ff9b:1::a9fe:a9fe]:80: i/o timeout" — the socket, not the refusal. A redirect case covers the same prefix on the second hop against a real server. Re-checked the whole list against the IANA IPv4 and IPv6 Special-Purpose Address Registries. Four more entries were the same defect wearing a different prefix — an IPv6 address that is really an IPv4 destination — and the rest were registry rows nothing covered: - 2002::/16 (6to4) and its IPv4 relay anycast 192.88.99.0/24. A 6to4 address embeds the IPv4 in the second and third groups, so 2002:a9fe:a9fe:: is the metadata endpoint through a relay. Identical hole to NAT64, and it was open. - 2001::/23, the IETF protocol-assignments block, taken whole rather than as its dozen sub-entries. It holds Teredo (2001::/32, which also carries an IPv4 endpoint), IPv6 benchmarking (2001:2::/48 — the twin of 198.18.0.0/15, which the list already had), AMT, AS112-v6, ORCHID/ORCHIDv2, DET, and the PCP / TURN / DNS-SD anycast addresses. Same call the v4 side already makes with 192.0.0.0/24, and one prefix is easier to keep true than eight. - 3fff::/20 (documentation, RFC 9637 — the newer companion to 2001:db8::/32), 5f00::/16 (SRv6 SIDs, RFC 9602), 100:0:0:1::/64 (dummy prefix, discard-only alongside the 100::/64 already listed). - fec0::/10. Deprecated by RFC 3879 and delisted, so no registry row and no netip predicate covers it, but networks numbered out of it before 2004 still route it internally. Deliberately NOT added, so the next reader does not re-derive it: the AS112 delegations (192.31.196.0/24, 192.175.48.0/24, 2620:4f:8000::/48) and AMT's 192.52.193.0/24 are globally routed unicast. They are special in who operates them, not in where they point. fc00::/7 needed nothing: Go's netip.Addr.IsPrivate matches the full /7 (ip.v6(0)&0xfe == 0xfc), not just fd00::/8. The allow-list table gained the boundaries as well as the hits — 2001:200::, 64:ff9c::, 2402:4e00:: — because a prefix that swallows one group too many refuses real attachments, which fails just as loudly. * test(wecom): an /issue after an image is still an /issue The command source is built from the sender's own runs (ownCommandSource, in the commit that introduces the 图文混排 rendering) and this is what holds it there. ownText renders a 图文混排 by joining its runs, each attachment as "[Image]" / "[File]" / "[Video]". engine.ParseIssueCommand reads the first non-empty line and only the first. So a person who attaches the screenshot and then types "/issue 登录坏了" — that order, because you pick the picture while you are still deciding what to say — produced a body opening with "[Image]", the parser declined, no issue was filed, and nothing in the chat or the log said why. The same two things in the other order worked, which is the part that makes it impossible to report. Routing (inbound_media_test.go): both orderings, an image between two typed runs, and a spoken command, all through dispatchFrame. The body keeps its placeholders — the agent has to be able to see that a picture was attached and where — and CommandText does not. A standalone photo carries no command at all rather than the string "[Image]". Binding (inbound_media_bind_db_test.go): the same message through engine.Router, the real ChatSession and a real Postgres, asking the question the sender would ask — was an issue created, and under what title. Deriving the command from the resolved body instead reports "issues created = 0, want 1". TestGroupMentionedIssueAfterAnImageIsStillFiled is the one that keeps the rebase honest. In a group the @-mention is how the bot is reached, so it arrives glued to the front of the text run: "@Multica Bot /issue 登录坏了". The command source needs main's stripLeadingMentions AND the placeholder removal to see "/issue" on the line the parser reads. Lose the strip and the title becomes the bot's own name; lose the placeholder removal and the parser sees "[Image]". Either way nothing is filed, and the test says so. * fix(wecom): the allow-list must not be able to open translation space publicAddrOnly applied mediaAllowedPrefixes to every entry in reservedMediaPrefixes. For the "not the public internet" group that is what the escape hatch is for — a fake-IP proxy's pool is a real deployment shape and an operator who knows theirs can declare it. For the translation group it was a hole. 64:ff9b::/96, 64:ff9b:1::/48 and 2002::/16 do not hold destinations. They hold IPv4 destinations in an IPv6 spelling, and the deny list was the only thing in the way: IsLoopback / IsPrivate / IsLinkLocalUnicast run first and never fire on them, because at that point they are IPv6 addresses. So with MULTICA_WECOM_MEDIA_ALLOW_CIDRS=::/0 — or =2002::/16, the narrow version of the same mistake, someone widening the range until images loaded — 64:ff9b:1::a9fe:a9fe (169.254.169.254) and 2002:7f00:1::1 (127.0.0.1) both came back allowed, from a URL WeCom hands us. .env.example promised the opposite: "Loopback and the private ranges are refused regardless and cannot be opened this way." True as written, and false the moment the same address is written in translation form. So the two groups the comment already described are now two lists, and only the first is overridable. Translation space is a hard block no configuration opens, which costs nothing — no COS object, no proxy pool and no deployment lives there, so there is no shape to accommodate the way there is for a fake-IP proxy. The escape hatch itself is unchanged, and a test says so. The regression cases run through the real dialer, not publicAddrOnly alone, because which error comes back IS the assertion: ErrMediaAddrBlocked means the address never got past policy, and anything else means a socket was opened. That distinction has already mattered here — folding the prefixes back together and rerunning, the 6to4 case does not time out, it returns EOF. The connection was established. * test(wecom): the seam where a spoken command, a mixed message and a group mention meet Voice notes landed on main while this branch was out. Both changes rewrote the same decision in channelMessageFromCallback from opposite ends: main sourced the command from the resolved body, because a spoken "/issue" carries its words in voice.content and reading the typed field leaves it empty; this branch sourced it from the sender's own runs, because a 图文混排 body opens with "[Image]" whenever the screenshot came first and the parser reads the first non-empty line and only that one. Neither side is right on its own. The reconciliation keeps one body resolver (ownText, now answering for voice as well) and one command source (ownCommandSource, now answering for voice as well), and the placeholders stay in the body and out of the command. TestDispatchFrame_GroupMentionedSpokenIssueWithAnImage is the case that holds it: someone in a group photographs the broken screen and says "@Multica Bot /issue 登录坏了". The command has to survive three strips that come from three different changes — the placeholder, the transcript, the addressing — and nothing covered their intersection. Take main's decision wholesale and the parser is handed "[Image]\n@Multica Bot /issue 登录坏了"; take this branch's and it is handed "". Both report a declined parse, which is what the sender would see: no issue, no answer, and no way to report it except the message that does not work. TestVoiceStaysOnTheReceiptPath asserted that a standalone voice note is NOT read, which was true when it was written and stopped being true when the voice work merged. It is now TestAVoiceNoteIsReadWhereverItArrives and says the merged thing: a transcript is read on its own or as one run of a 图文混排, and only an empty one — background noise, a half-second press — takes the receipt path. * test(wecom): the same seam through the router, the session and Postgres TestDispatchFrame_GroupMentionedSpokenIssueWithAnImage asks what the adapter hands the engine. This asks the question the sender would: is there an issue, and what is it called. It runs the message through engine.Router, the real ChatSession and a real database, with a real encrypted download behind the image run. Same reverse-verification, both directions, both reporting "issues created = 0, want 1": source the command from the resolved body and the parser gets "[Image]"; leave the voice run out of the sender's own words and it gets "". wecomMixedCallback now takes which run carries the words. Both spellings are the sender's own words, and the point of the test is that the command source does not care which one they arrived in. * fix(wecom): the attachment name arrives form-encoded, and nothing undid it A live tenant sent "PC D&T Strategy 2026.docx" and the object landed as "PC+D%26T+Strategy+2026.docx". The callback frame carries only {url, aeskey} — ws_frame.go's mediaBody has no name field — so Content-Disposition is the only place the original name exists, and the value arrived form-urlencoded: space as '+', '&' as %26, which is exactly url.QueryEscape of what the user typed. mime.ParseMediaType percent-decodes RFC 5987's filename* and hands the plain filename= back untouched, so the encoding survived into storage and into the name shown with the attachment. url.QueryUnescape on its own would fix that name and break the next one: "C++ notes.docx" becomes "C notes.docx", and no header field says which reading of a bare '+' was meant. Self-consistency does. A value that really is form-encoded survives a round trip through the encoder, and a value that merely looks like one usually does not — "C++ notes.docx" re-encodes to "C+++notes.docx", because the literal space would have been a '+' too. So the decode is conditional: undo it only when re-encoding the result reproduces the header, comparing percent-escapes case-insensitively so a server writing lower-case hex is not silently skipped. That is not a hypothetical case here. A non-ASCII filename is nothing but escapes, and it is the name most worth getting right. Two things the rule gives up, both written into the tests as expected values rather than left for a reader to find. A name with a '+' and nothing else an encoder would touch, "C++.docx", is byte-identical to the encoding of "C .docx"; that ambiguity cannot be settled from the header, and this decodes it. A name in the RFC 3986 spelling, %20 rather than '+', fails the round trip and keeps its escapes. A name carried in filename* is not touched at all — the server declared its encoding there and ParseMediaType has already applied it, so decoding again would be a guess on top of a declaration. The decode runs before the base-name reduction, and that order is load-bearing: "..%2F..%2Fetc%2Fpasswd" is a path only once it is decoded, so a cleaner running first would have handed the traversal straight through. All of the above is inference from one stored filename, because the URL that produced it expired five minutes after it arrived and cannot be fetched again. media_download.go logged nothing at all, so under MULTICA_WECOM_TRACE both fetch paths now record the Content-Disposition as it arrived beside the name parsed out of it. It is the one traced string deliberately not passed through the bearer-token redactor: the point of the line is the exact bytes, and a file may legitimately be called "token=...". The signed url stays out of it, as it stays out of every error this file returns. * fix(wecom): the media trace cut the names it was added to record Four things the filename commit got wrong about its own work. The trace truncated the exact case it exists for. traceMediaHeaders shared tracePreviewRunes with the message preview, and 120 runes is not a Content-Disposition. `attachment; filename=""` is 23 runes before the name starts, a percent-escaped CJK character is 9, so a name of eleven Chinese characters is already over — and a non-ASCII name, which cannot legally sit in the plain filename= parameter and therefore arrives as nothing but escapes, is the whole reason the line was added. Worse than a lost tail: the cut landed mid-escape ("%E7%89%8…"), and half an escape cannot be decoded back into the character it stood for, so an operator who turned MULTICA_WECOM_TRACE on to learn how the CDN encodes got a line that could not answer the question. The header now has its own cap, sized against the largest thing it can legitimately be — both parameter forms of a 255-byte name, fully escaped — so it bounds a runaway remote string and nothing else. Two comments contradicted the diff they shipped in. traceOutResult said "everything else this file writes is bounded and redacted" and the package docblock said "bearer tokens are redacted out of it", both written before traceMediaHeaders arrived as the one string deliberately bounded but not redacted. Both now say so and point at the function that argues it. Decoding widened the byte domain of the filename past what cleanMediaFilename was written for. Measured against net/http, a raw TAB in a header value is passed through and every other control byte — NUL, CR, LF, ESC, DEL — makes the transport reject the response, and ParseMediaType never percent-decodes the plain form, so `filename="a%00b.docx"` used to stay the printable string it looks like. Undoing the escapes turns %00 into a real NUL, which the attachments row cannot hold: filename is Postgres TEXT and TEXT cannot store a NUL, so a file that downloaded and decrypted perfectly is lost on the insert. Control characters are stripped after the decode. Nothing was injectable — internal/storage's ContentDisposition already replaces CR and LF when it builds the download header — but the row is written before that, and the raw tab was reaching the name all along. The known-gaps list understated itself twice. The '+' sacrifice is not one odd name: every name whose only encoder-touched character is a '+' loses it, so React+Redux.md, Q1+Q2.xlsx, C++11.pdf and 10+1.pdf go the same way as C++.docx. And the round trip assumes the server's unreserved set is url.QueryEscape's — Java's URLEncoder also passes '*', PHP's urlencode writes '~' as %7E — so "backup~.docx" from either fails the comparison and keeps its escapes. That failure is all-or-nothing: one character outside Go's set and the whole name stays escaped, which is how "Q1 report~.docx" reaches the user as "Q1+report%7E.docx", the unreadable state the decode exists to remove, plus sign still in it. Both are now written into the tests as expected values alongside the gaps already there. One correction to this branch's earlier backout report, which counted only the subtests it expected: removing the decode fails 15 tests on the previous head, not 7, because the four TestTheTraceRecordsTheDispositionThatArrived subtests assert the decoded name too. With the rows added here it is 24. Backing out the new trace cap fails 1; backing out the control-character strip fails 8.
179 lines
8.9 KiB
YAML
179 lines
8.9 KiB
YAML
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
|
|
#
|
|
# Services bind to 127.0.0.1 only. For cross-machine or public access, front
|
|
# them with a reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates
|
|
# TLS and forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend).
|
|
# Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls
|
|
# (UFW/iptables) by default, so the raw ports would be exposed to the internet
|
|
# with the default JWT_SECRET and Postgres credentials. See:
|
|
# apps/docs/content/docs/self-host-quickstart.mdx
|
|
#
|
|
# Usage:
|
|
# cp .env.example .env
|
|
# # Edit .env — change JWT_SECRET at minimum
|
|
# docker compose -f docker-compose.selfhost.yml up -d
|
|
#
|
|
# Frontend: http://localhost:${FRONTEND_PORT:-3000}
|
|
# Backend: http://localhost:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}
|
|
#
|
|
# The published values above are HOST ports; the containers always listen on
|
|
# 8080 / 3000 internally, so changing them never needs a rebuild. PORT is the
|
|
# variable to edit; BACKEND_PORT, API_PORT and SERVER_PORT are optional aliases
|
|
# that override it in that order. Keep this alias order identical to Makefile
|
|
# and scripts/local-env.sh. The web dev fallback intentionally omits PORT
|
|
# because Next uses that variable for its own frontend listener.
|
|
#
|
|
# Note that *which source* wins differs per entry point — Compose lets the
|
|
# calling environment outrank this file, while make lets the included env file
|
|
# outrank the environment. Nothing re-derives the published port from the inputs
|
|
# any more: `make selfhost` (via scripts/selfhost-wait.sh) and both installers
|
|
# read it back with `docker compose port`, so the health check and the printed
|
|
# URL always match what was actually published.
|
|
|
|
name: multica
|
|
|
|
services:
|
|
postgres:
|
|
image: pgvector/pgvector:pg17
|
|
environment:
|
|
POSTGRES_DB: ${POSTGRES_DB:-multica}
|
|
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql/data
|
|
restart: unless-stopped
|
|
healthcheck:
|
|
test:
|
|
[
|
|
"CMD-SHELL",
|
|
"pg_isready -U ${POSTGRES_USER:-multica} -d ${POSTGRES_DB:-multica}",
|
|
]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
backend:
|
|
image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
ports:
|
|
- "127.0.0.1:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}:8080"
|
|
volumes:
|
|
- backend_uploads:/app/data/uploads
|
|
environment:
|
|
DATABASE_URL: postgres://${POSTGRES_USER:-multica}:${POSTGRES_PASSWORD:-multica}@postgres:5432/${POSTGRES_DB:-multica}?sslmode=disable
|
|
PORT: "8080"
|
|
METRICS_ADDR: ${METRICS_ADDR:-}
|
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
|
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
|
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
|
SMTP_HOST: ${SMTP_HOST:-}
|
|
SMTP_PORT: ${SMTP_PORT:-25}
|
|
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
|
SMTP_TLS: ${SMTP_TLS:-}
|
|
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
|
|
SMTP_EHLO_NAME: ${SMTP_EHLO_NAME:-}
|
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
|
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
|
|
S3_BUCKET: ${S3_BUCKET:-}
|
|
S3_REGION: ${S3_REGION:-us-west-2}
|
|
AWS_ENDPOINT_URL: ${AWS_ENDPOINT_URL:-}
|
|
S3_USE_PATH_STYLE: ${S3_USE_PATH_STYLE:-}
|
|
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
|
|
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
|
|
ATTACHMENT_DOWNLOAD_MODE: ${ATTACHMENT_DOWNLOAD_MODE:-auto}
|
|
ATTACHMENT_DOWNLOAD_URL_TTL: ${ATTACHMENT_DOWNLOAD_URL_TTL:-30m}
|
|
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
|
|
CLOUDFRONT_KEY_PAIR_ID: ${CLOUDFRONT_KEY_PAIR_ID:-}
|
|
CLOUDFRONT_PRIVATE_KEY: ${CLOUDFRONT_PRIVATE_KEY:-}
|
|
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
|
APP_ENV: ${APP_ENV:-production}
|
|
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
|
|
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
|
MULTICA_SHUTDOWN_HOLD_DURATION: ${MULTICA_SHUTDOWN_HOLD_DURATION:-}
|
|
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
|
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
|
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
|
DISABLE_WORKSPACE_CREATION: ${DISABLE_WORKSPACE_CREATION:-}
|
|
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
|
|
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
|
|
# Public URL the API is reachable at from the open internet, no
|
|
# trailing slash. Used to mint absolute webhook URLs for autopilot
|
|
# webhook triggers. Leave unset behind a same-origin reverse proxy
|
|
# (e.g. plain localhost dev); the frontend will compose the URL
|
|
# from window.origin + webhook_path in that case. Headers are
|
|
# intentionally NOT used to derive this value, to avoid Host /
|
|
# X-Forwarded-Host spoofing on misconfigured proxies.
|
|
MULTICA_PUBLIC_URL: ${MULTICA_PUBLIC_URL:-}
|
|
# Comma-separated CIDRs whose source IP is allowed to set
|
|
# X-Forwarded-For / X-Real-IP for the webhook per-IP rate limiter.
|
|
# Empty default = headers ignored, RemoteAddr used. Set e.g.
|
|
# "127.0.0.1/32" when running behind a same-host reverse proxy.
|
|
MULTICA_TRUSTED_PROXIES: ${MULTICA_TRUSTED_PROXIES:-}
|
|
# Lark / Feishu bot integration. MULTICA_LARK_SECRET_KEY is the
|
|
# opt-in: unset = integration disabled. Mainland 飞书 and international
|
|
# Lark are auto-detected per installation and served side by side, so
|
|
# the two base-URL knobs should normally stay EMPTY. They are optional
|
|
# deployment-wide overrides that force every installation onto one host
|
|
# (proxy / mock / single-cloud staging). Upgrading from a setup that
|
|
# used https://open.larksuite.com here? The server relabels existing
|
|
# installs to region=lark on first boot, then you can clear them.
|
|
# See docs/lark-bot-integration.
|
|
MULTICA_LARK_SECRET_KEY: ${MULTICA_LARK_SECRET_KEY:-}
|
|
MULTICA_LARK_HTTP_BASE_URL: ${MULTICA_LARK_HTTP_BASE_URL:-}
|
|
MULTICA_LARK_CALLBACK_BASE_URL: ${MULTICA_LARK_CALLBACK_BASE_URL:-}
|
|
# Slack bot integration. MULTICA_SLACK_SECRET_KEY is the opt-in: unset =
|
|
# integration disabled. It decrypts the per-installation bot/app tokens,
|
|
# which are brought by each workspace via OAuth/BYO and stored encrypted
|
|
# in the database, so this single deployment-wide key is all the operator
|
|
# needs to set here.
|
|
MULTICA_SLACK_SECRET_KEY: ${MULTICA_SLACK_SECRET_KEY:-}
|
|
# Self-hosted Git provider integration (Forgejo / Gitea / GitLab). This
|
|
# is a self-host-only feature, so the compose file turns it on by default;
|
|
# the managed cloud leaves it unset (off). It still needs a valid
|
|
# MULTICA_VCS_SECRET_KEY below to actually work.
|
|
MULTICA_VCS_INTEGRATION_ENABLED: ${MULTICA_VCS_INTEGRATION_ENABLED:-true}
|
|
# VCS integration at-rest encryption key for token-based providers
|
|
MULTICA_VCS_SECRET_KEY: ${MULTICA_VCS_SECRET_KEY:-}
|
|
# WeCom smart-bot integration. MULTICA_WECOM_SECRET_KEY is the
|
|
# opt-in: unset = integration disabled. It encrypts each installation's
|
|
# smart-bot secret at rest. The server needs it to unseal a bot's
|
|
# subscribe credentials at connection time, and to seal the secret when
|
|
# an installation is created from the WeCom settings tab.
|
|
MULTICA_WECOM_SECRET_KEY: ${MULTICA_WECOM_SECRET_KEY:-}
|
|
# Comma-separated ranges the inbound media fetcher may dial even though
|
|
# they look reserved. Empty (the default) keeps the SSRF guard as strict
|
|
# as it ships; set it only behind a fake-IP proxy whose pool would
|
|
# otherwise get every attachment refused. See .env.example.
|
|
MULTICA_WECOM_MEDIA_ALLOW_CIDRS: ${MULTICA_WECOM_MEDIA_ALLOW_CIDRS:-}
|
|
# 1 = log every inbound and outbound WeCom frame, including the first
|
|
# 120 runes of each message body, so a real-device session can be
|
|
# checked against the server afterwards. Off unless set; turn it on only
|
|
# for a debugging session and unset it when that session ends, because
|
|
# what it records is user message content.
|
|
MULTICA_WECOM_TRACE: ${MULTICA_WECOM_TRACE:-}
|
|
restart: unless-stopped
|
|
|
|
frontend:
|
|
image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}
|
|
depends_on:
|
|
- backend
|
|
ports:
|
|
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
|
|
environment:
|
|
HOSTNAME: "0.0.0.0"
|
|
REMOTE_API_URL: ${REMOTE_API_URL:-http://backend:8080}
|
|
DOCS_URL: ${DOCS_URL:-}
|
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-}
|
|
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
pgdata:
|
|
backend_uploads:
|