Andrey Antukh 225eb98de2 🐛 Refuse dead teams in resolve-team-id (PR #12189 review)
Second review round on PR #12189 (pabloalba).

The file re-check added in 87ca7aca53 stops at the file row, but the
organization-owner fallback grants read-only access to non-member org
owners via Nitrate over HTTP, which knows nothing about Penpot
soft-deletion. resolve-team-id resolved the team from unfiltered SQL,
so an expelled org owner kept the file subscription open for an
indefinitely soft-deleted file, project or team. Now both SQL lookups
filter deleted_at, and resolve-team-id checks the team row itself
(db/get*, which filters soft-deleted rows) for every caller — teams,
projects and files alike.

Also from the same review:

- file-readable? now uses the same sql:get-minimal-file as the
  subscribe gate (non-throwing via throw-if-not-exists false), and
  team-readable? projects id and deleted-at, so db/get* can filter.
- The test suite extracts the thrice-duplicated raw delete into
  delete-team-membership!; a sibling of the fail-open pin now redefs
  the existence probe to throw; memory and docstring aligned with the
  code.
- docs/technical-guide/developer/architecture/backend.md attributed
  the websocket to notifications.clj; it now names app.http.websocket
  as the owner and notifications.clj as the revocation publisher.
- mem:backend/auth-permissions-product-domains documents the fallback
  mechanism and the resolve-team-id contract.

Closes #12155

AI-assisted-by: inkling-2-small-free
2026-11-10 12:38:45 +00:00

140 lines
6.6 KiB
Markdown

---
title: Backend app
desc: Dive into self-hosting, configuration, developer insights (architecture, data model), integration, and troubleshooting. See Penpot's Technical Guide.
---
# Backend app
This app is in charge of CRUD of data, integrity validation and persistence
into a database and also into a file system for media attachments.
To handle deletions it uses a garbage collector mechanism: no object in the
database is deleted instantly. Instead, a field <code class="language-bash">deleted_at</code> is set with the
date and time of the deletion, and every query ignores db rows that have this
field set. Then, an async task that runs periodically, locates rows whose
deletion date is older than a given threshold and permanently deletes them.
For this, and other possibly slow tasks, there is an internal async tasks
worker, that may be used to queue tasks to be scheduled and executed when the
backend is idle. Other tasks are email sending, collecting data for telemetry
and detecting unused media attachment, for removing them from the file storage.
## Backend structure
Penpot backend app code resides under <code class="language-text">backend/src/app</code> path in the main repository.
@startuml BackendGeneral
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
!define DEVICONS https://raw.githubusercontent.com/tupadr3/plantuml-icon-font-sprites/master/devicons
!include DEVICONS/react.puml
!include DEVICONS/java.puml
!include DEVICONS/clojure.puml
!include DEVICONS/postgresql.puml
!include DEVICONS/redis.puml
!include DEVICONS/chrome.puml
HIDE_STEREOTYPE()
Container(frontend_app, "Frontend app", "React / ClojureScript", "", "react")
System_Boundary(backend, "Backend") {
Container(backend_app, "Backend app", "Clojure / JVM", "", "clojure")
ContainerDb(db, "Database", "PostgreSQL", "", "postgresql")
ContainerDb(redis, "Broker", "Redis", "", "redis")
}
BiRel(frontend_app, backend_app, "Open", "websocket")
Rel(frontend_app, backend_app, "Uses", "RPC API")
Rel(backend_app, db, "Uses", "SQL")
Rel(redis, backend_app, "Subscribes", "pub/sub")
Rel(backend_app, redis, "Notifies", "pub/sub")
@enduml
```
▾ backend/src/app/
▸ cli/
▸ http/
▸ migrations/
▸ rpc/
▸ setup/
▸ srepl/
▸ util/
▸ tasks/
main.clj
config.clj
http.clj
metrics.clj
migrations.clj
notifications.clj
rpc.clj
setup.clj
srepl.clj
worker.clj
...
```
* <code class="language-text">main.clj</code> defines the app global settings and the main entry point of the
application, served by a JVM.
* <code class="language-text">config.clj</code> defines of the configuration options read from linux
environment.
* <code class="language-text">http</code> contains the HTTP server and the backend routes list.
* <code class="language-text">migrations</code> contains the SQL scripts that define the database schema, in
the form of a sequence of migrations.
* <code class="language-text">rpc</code> is the main module to handle the RPC API calls.
* <code class="language-text">http/websocket.clj</code> manages the websocket endpoint: clients subscribe
to open files or teams and the server keeps the subscriptions; revoked access
is re-checked and dropped. <code class="language-text">notifications.clj</code> publishes
access-change events on the bus so the websocket re-checks the subscriptions
it owns.
* <code class="language-text">setup</code> initializes the environment (loads config variables, sets up the
database, executes migrations, loads initial data, etc).
* <code class="language-text">srepl</code> sets up an interactive REPL shell, with some useful commands to be
used to debug a running instance.
* <code class="language-text">cli</code> sets a command-line interface, with some more maintenance commands.
* <code class="language-text">metrics.clj</code> has some interceptors that watches RPC calls, calculate
statistics and other metrics, and send them to external systems to store and
analyze.
* <code class="language-text">worker.clj</code> and <code class="language-text">tasks</code> define some async tasks that are executed in
parallel to the main http server (using java threads), and scheduled in a
cron-like table. They are useful to do some garbage collection, data packing
and similar periodic maintenance tasks.
* <code class="language-text">db.clj</code>, <code class="language-text">emails.clj</code>, <code class="language-text">media.clj</code>, <code class="language-text">msgbus.clj</code>, <code class="language-text">storage.clj</code>,
<code class="language-text">rlimits.clj</code> are general libraries to use I/O resources (SQL database,
send emails, handle multimedia objects, use REDIS messages, external file
storage and semaphores).
* <code class="language-text">util/</code> has a collection of generic utility functions.
### RPC calls
The RPC (Remote Procedure Call) subsystem consists of a mechanism that allows
to expose clojure functions as an HTTP endpoint. We take advantage of being
using Clojure at both front and back ends, to avoid needing complex data
conversions.
1. Frontend initiates a "query" or "mutation" call to <code class="language-text">:xxx</code> method, and
passes a Clojure object as params.
2. Params are string-encoded using
[transit](https://github.com/cognitect/transit-clj), a format similar to
JSON but more powerful.
3. The call is mapped to <code class="language-text"><backend-host>/api/rpc/query/xxx</code> or
<code class="language-text"><backend-host>/api/rpc/mutation/xxx</code>.
4. The <code class="language-text">rpc</code> module receives the call, decode the parameters and executes the
corresponding method inside <code class="language-text">src/app/rpc/queries/</code> or <code class="language-text">src/app/rpc/mutations/</code>.
We have created a <code class="language-text">defmethod</code> macro to declare an RPC method and its
parameter specs.
5. The result value is also transit-encoded and returned to the frontend.
This way, frontend can execute backend calls like it was calling an async function,
with all the power of Clojure data structures.
### PubSub
To manage subscriptions to a file, to be notified of changes, we use a redis
server as a pub/sub broker. Whenever a user visits a file and opens a
websocket, the backend creates a subscription in redis, with a topic that has
the id of the file. If the user sends any change to the file, backend sends a
notification to this topic, that is received by all subscribers. Then the
notification is retrieved and sent to the user via the websocket.