CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# dsm-mini — working rules for this project
A Telegram bot with a Mini App for managing a Synology NAS: Download Station
tasks and File Station files. Open source, MIT, Go + React.
## The main rule: never guess about the Synology API
The Synology Web API behaves differently from its documentation in places, and
`SYNO.DownloadStation2.*` is not documented at all. The order of work:
1. First **download the official PDF** — links are in [docs/synology-api.md](docs/synology-api.md).
2. Then **check the call against a live NAS** with an integration test.
3. Any discrepancy found goes **straight into `docs/synology-api.md`**, with a sample response.
Explaining API behaviour with a guess is not allowed: this project has already
seen a wrong cause given for a failure that way (see "limit = -1" in the same file).
**When DSM refuses something, read its own logs on the NAS before theorising.**
Over SSH: `/var/log/synopkgmgr.log` carries the verdict of every package check
with a code and an English description, `/var/log/synopkg.log` the install
step by step. Both are `system:log`, mode 660 — root only, and the Web API
does not expose them: `SYNO.Core.SyslogClient.Log` shows "package installed"
and nothing about a refusal. The message in Package Center is written for
somebody else's problem and names neither the stage nor the reason. A month
went into guessing why an upgrade failed while one line of that log named the
cause from the first attempt (see "preupgrade / postupgrade" in
`docs/synology-api.md`).
Known traps that specific lines of code exist for:
- Batch actions answer in **three different shapes**, and in two of them a
refusal arrives under `success: true`. Failing to parse the array means
showing the user success where the NAS did nothing.
- `limit = -1` works normally in Download Station and **takes File Station down**
with HTTP 502 on every API version.
- Parameter encoding differs in three places: ordinary calls use JSON,
`FileStation.Upload` takes no quotes, `DownloadStation2` create with a file is
JSON again. With multipart, `_sid` goes **in the query string**.
- Paths: File Station wants a leading slash, Download Station does not.
- A task's files, trackers and peers (`Task.BT.*`) are available **only while the
task is active**; a finished one answers with code 1913, and that is not a failure.
- **Task** priority applies to eMule, not to torrents: for BT the call is
accepted but does nothing and cannot be read back. Priority of **files** inside
a torrent is a working feature.
- When the API says nothing about what it can do, the answer is in the web UI
code on the NAS: `/var/packages/<Package>/target/ui/*.js` shows which call DSM
itself makes and with which parameters. Over HTTP the same files are at
`/webman/3rdparty/<Package>/`, and the package's `config` there names them —
that is how the whole task-status table was read out of Download Station
instead of being guessed one failing task at a time.
- **Task statuses come as numbers** in DownloadStation2, and the table is in
`docs/synology-api.md`. Ours was shifted by one from code 7 on, so a task
being unpacked (10) was shown as failed. Anything outside the table is an
error — that is what Download Station's own `getStatusString` does with its
default branch, and why a failure arrives as 101 with no string of its own.
- In `Task.edit` the parameter is called `id`, in `Task.BT.File` it is `task_id`.
- A trailing slash in the `CopyMove` destination path gives error 418.
- `SYNO.Core.System` does not return the host name — `FileStation.Info` has it.
- The level filter in the system log does not work: we filter on our side.
- `Thumb` and `Download` return bytes, and report an error by switching the
Content-Type to JSON.
- **The session name at sign-in is not a free label.** Under a name of our
own (`DsmMiniCheck`) a tester's ordinary account was refused with 402, while
as `DownloadStation` it signed in; an administrator gets in under either.
Every client signs in under `DownloadStation`. Why DSM refused is not yet
checked — see `docs/synology-api.md`.
This file is kept up to date continuously: a new API finding, the cause of a
non-obvious failure or an architecture decision is written here in the same
session, together with the code.
## Tests
```bash
go test ./... # no NAS needed, must always pass
DSM_URL=… DSM_USER=… DSM_PASSWORD=… \
DSM_INSECURE_TLS=true go test -tags=integration ./...
```
Integration tests run against a real NAS and are skipped by default. The ones
that change state need separate permission:
- `DSM_TEST_MUTATIONS=1` — creating and deleting tasks;
- `DSM_TEST_FOLDER=/Download` — the folder for file operations;
- `DSM_TEST_MAGNET`, `DSM_TEST_TORRENT` — your own sources instead of the built-in ones.
A test **must clean up** everything it created through `t.Cleanup`, even when it
fails. If the cleanup did not work — say so in the failure message and name what
has to be deleted by hand.
State-changing actions on the NAS are agreed with the user before they run —
test tasks and files included.
## Where things live
```
cmd/dsm-mini/ entry point
internal/dsm/ DSM Web API client: sessions, versions, multipart
downloadstation/ tasks: DownloadStation2 and legacy behind one interface
filestation/ files: browsing, upload, rename, delete
internal/httpapi/ REST for the Mini App, Telegram signature check
internal/bot/ the bot: links and .torrent files from chat, bot appearance from code
internal/watcher/ notifications about finished tasks
internal/dsm/system/ load, device details, packages, log
internal/dsm/storage/ disks, pools and volumes
internal/dsm/vmm/ virtual machines
internal/dsm/containers/ Container Manager containers
internal/store/ user settings on top of SQLite
internal/db/ schema and migrations
internal/config/ config.env: reading, defaults, what is still missing
internal/dsmui/ the settings window's side: status, settings, address
internal/dsmnotify/ DSM's own notifications through a webhook
web/ the React Mini App, built into internal/web/dist
spk/ the DSM package: scripts, privilege, the window (spk/ui)
contrib/spksrc/ the SynoCommunity recipe, with its copy of the window
docs/synology-api.md what has been learned about the API in practice
```
## Security
The service is exposed to the internet and can delete files on the NAS.
- **Every request to `/api/`** is checked twice: the `initData` signature with a
key derived from the bot token, and the user id against the allow list. The
signature only proves that a person opened the bot — and anyone can open it.
- **An empty `ALLOWED_USER_IDS` closes access to everyone**, it does not open it.
- Secrets live only in `.env` (which is in `.gitignore`), mode `0600`. Neither
the token nor the DSM password goes into the log — on a refusal the reason is
logged, never the value.
- The outside gets a generic phrase, the details go to the log: a message saying
exactly what did not match in the signature is a hint on how to forge it.
- For the NAS a **separate user** is created, with access only to the packages
needed and without two-factor, not an administrator. The price is known and
shown rather than hidden: DSM refuses an ordinary account the NAS details
(`SYNO.Core.System` `info`, code 1006), the load and the storage state (105)
— from a SynoCommunity tester's log. The API answers such a part with the
section's message plus "the DSM account the service uses has no access to
this", and `/api/system` answers part by part: failing it whole showed "no
connection" on a working service, zeros for a refused load would lie, and an
unread package list marked Download Station "not installed". Only DSM's
refusals are split up that way: a DSM that does not answer at all is still a
502, or the home screen would say "online" next to nothing.
- **The project never holds anyone's bot token**, and that rules out one idea
that keeps looking attractive: a "helper bot" that creates the user's bot for
them. Telegram does support it — managed bots, `bots.createBot` over MTProto
— but the manager bot can call `bots.exportBotToken` at any time afterwards,
with a `revoke` flag that reissues the token and takes the bot away from its
owner, and the documented methods offer **no way to detach a manager** once
it is set. So the price of saving one trip to @BotFather is a service that
permanently holds the keys to every user's bot. Considered and declined in
September 2026; the settings window explains the BotFather step instead.
- **The service binds loopback only** (`127.0.0.1:58080`), because the DSM
reverse proxy reaches it through localhost. `:58080` would also publish it to
the whole local network — which is what the package actually did until
September 2026, while the README claimed the opposite. A security claim in
the documentation is worth checking against a live port, not against the
intent of the person who wrote it.
- **Examples in documentation are invented, never copied from a live `.env`.**
A real bot token once went into the README as the example token, spread into
ten translations, nine wizard files and the compose block, and stayed there
until the bot started getting spam. The only token allowed in the repository
is `1234567890:AAExampleTokenReplaceThisWithYours0`; CI fails on anything
else shaped like a token, in the "No live secrets" job. The same goes for
real Telegram ids — `tools/shoot.js` had one.
Changes in this area are covered by tests in `internal/httpapi/auth_test.go`.
## Frontend
- State of screens that lead out into the NAS overview is **kept in `App`**:
navigating unmounts the screen, and unsaved edits are lost. This has already
broken the folder settings and the add screen twice. For the same reason the
open folder of the Files tab, the selected downloads tab, the path typed by
hand in the folder settings and the screen a person was interrupted on live
there too: switching tabs would otherwise reset all of it.
- Adding a download, the folder settings and the task screen are **parts of the
downloads section**, not separate places: the tab bar is not hidden on them,
and coming back to the tab lands where the person left off. Tapping the active
tab again goes back to the start of the section.
- Initial values that arrive as a prop (`initialPath`) are taken **once** through
`useRef`: sending them back up from a handler makes the screen reload itself.
- A handler that writes and then navigates **waits for the write first**. The
screens read their data when they mount, so switching first and saving after
lands on a screen that fetched a moment too early: pinning a folder from the
NAS browser showed "nothing pinned" until the screen was opened again, while
the server already had it. Order: write, refresh, then `setScreen`.
- Settings are saved **immediately**, with no "Save" button: the interface
updates before the server answers and rolls back on an error.
- Padding inside cards comes from the card's own `padding`, not from `margin` on
its children: such a rule breaks at the very first wrapper.
- Class names are checked against the ones already taken: a card with the class
`app` inherited the root container's styles and stretched across the screen,
and an empty disk bay marked `empty` picked up the 44px padding of the big
"nothing here" block and grew to twice the height of its neighbours. A
modifier that belongs to one component is named after it — `bay free`, not
`free`.
- Drive capacity is formatted with `diskSize` (decimal), everything else with
`size` (binary): disks are sold in decimal, so a 1 TB drive has to read as
"1 TB" and not as the 0.9 the binary arithmetic gives.
- The theme comes from Telegram's `themeParams`; confirmations and feedback are
native (`showConfirm`, `HapticFeedback`), not the browser's. The theme and the
height are re-read on the `themeChanged` and `viewportChanged` events: reading
them once at startup is not enough.
- The app height is Telegram's `viewportStableHeight`, not `100dvh`: inside the
client `dvh` equals the whole screen height, and in a collapsed app the tab bar
slides below the bottom edge. `viewportHeight` will not do for this — it jumps
around during gestures.
- Vertical swipes are disabled (`disableVerticalSwipes`, Bot API 7.7): otherwise
a scroll gesture over a list collapses the app.
- New Telegram features are called only under `isVersionAtLeast`: the
documentation does not promise that a call is safe on an old client. Outside
Telegram the script stub reports version 6.0 — the checks cover that case too.
- About the safe area (Bot API 8.0) the documentation **says nothing** about
whether `safeAreaInset` and `contentSafeAreaInset` add up or are nested. As
long as full-screen mode is not used, it is not used either, and the insets
come from `env(safe-area-inset-*)`. If it is ever wanted — check it on a live
client first, rather than picking blindly.
- Until the data arrives the screen shows **placeholders**
(`components/Skeleton`), not an empty list and not zeros in counters: "No
machines" while machines are running is a plain lie. Placeholders are built
from the same classes as the real cards, so the content takes their place
without the layout jumping. The column with bars needs `sk-grow`: inside a
`flex` with no width of its own, percentages are computed from zero and the
bars collapse.
- Screens with lists from the NAS start from `localStorage` (`cache.ts`): on a
second open there is a picture from the first frame, and placeholders appear
only when there is genuinely nothing to show.
- `index.html` is served with `Cache-Control: no-cache`, files with a hash in the
name for a year. Without this Telegram shows the old build until the app is
restarted.
## Packaging and publishing
- There is **one way to install it: the DSM package.** Docker was dropped in
September 2026 — a second path meant the same settings documented twice, in
ten languages, and the two drifting apart. A new required setting is now
added in two places that CI keeps together: `config.Required` and the window
(`spk/ui/app.js`, its texts in `tools/make-dsmui-i18n.py`).
- The `.gitignore` entry for the built binary is written with a leading slash
(`/dsm-mini`): without it the pattern also matches the `cmd/dsm-mini`
directory, and the entry point quietly drops out of the repository.
- The **`.spk` package** is built with plain tar (`tools/build-spk.sh`), without
the Synology toolkit: the binary is static, a chroot with their environment is
not needed. The file order in the archive follows spksrc: `package.tgz`,
`INFO`, `scripts`, then the rest.
- `package` in INFO is the **identifier**, `displayname` is what people see.
`/var/packages/dsm-mini`, the upgrade path and every API call go by the
identifier, so it stays `dsm-mini` whatever the visible name becomes. The
visible name lives in four places that have to agree: `displayname` in
INFO, `dname` in the catalogue, `DISPLAY_NAME` in the spksrc recipe and the
name of the notification webhook in `internal/dsmnotify`, which is what DSM
lists next to the owner's phone.
- The package ships **`scripts/preupgrade` and `scripts/postupgrade`**, and
they may do nothing at all. Without them an upgrade from a package source
is refused at the `prepare` stage with
`{"code":261,"description":"package does not have preupgrade / postupgrade
script"}` in `/var/log/synopkgmgr.log`, and the person is shown "Invalid
file format, contact the package developer" — which says nothing about
scripts. **Installing the same .spk works**, logging only a harmless
`{"code":289,"description":"spk is not from synology"}`, so the gap is
invisible until somebody presses Update. This cost a month and seven
releases of guessing; the answer was in a log the Web API does not expose.
The whole account, including what was wrongly blamed first, is in
`docs/synology-api.md`.
- INFO carries **`checksum`, the md5 of `package.tgz`** because the developer
guide documents it. It was **not** the cause of "Invalid file format",
though this file said so for a while: 1.0.0-1 had no `checksum` at all and
installed from a source without complaint.
- The version in INFO ends with a **build number**, pinned at `-0`. A Synology
version is `major.minor.build-buildnumber`, and every package that installs
from a source on a live DSM carries one; ours did while it was `1.0.0-1` and
lost it when the suffix was dropped for looking like noise. `build-spk.sh`
and `make-feed.py` both append it, because Package Center compares the
catalogue's version against INFO's and the two have to be spelled the same
way. The tag and the file name stay plain — nothing compares those.
- The `arch` values in INFO are **Synology platform names**, not processors
(`epyc7002`, `rtd1296`). One package covers every model of the same
architecture.
- DSM 7 runs package scripts as the package user, not as root (`conf/privilege`
with `run-as: package`). Writing is only possible into the package's `var` —
which is also what survives an upgrade, unlike `target`.
- **The package user is `sc-dsm-mini`, group `synocommunity`**, named in
`conf/privilege` (`username`/`groupname`, documented since DSM 6.0-5940)
rather than left to default to the package id. That is what the SynoCommunity
framework calls it, and the two builds have to agree: DSM hands the data
folder to the new package user on an upgrade — the folder, **not the files
in it**. When our build ran as `dsm-mini`, Package Center offered it as an
ordinary update over the SynoCommunity build, and a live NAS ended up with
`config.env` it could not read, a stopped package and a Repair button;
`/var/log/packages/dsm-mini.log` had the `Permission denied`. CI checks the
name. The port is the same in both for the same reason: 58080.
- **There is no install wizard.** The package starts with no settings and is
set up in its window in the DSM main menu (see "The settings screen inside
DSM"). Every installation question the wizard had was also a setting the
window had to offer, so there were two forms, two sets of texts in ten
languages, and the wizard only ever ran once: installing the package over
itself re-asked nothing, and changing a setting meant the window, SSH, or an
uninstall that took the database along.
- The window asks **five** things, and every one of them is something only the
person installing knows: the DSM user and password, the bot token, the list
of ids and the public address. The DSM address and the service port have
defaults and sit under "Advanced" — the service runs on the NAS, so the
address is loopback, on whatever HTTPS port DSM describes in
`/usr/syno/etc/www/DSM.json`. A question whose answer is the same for
everyone is not a question.
- **The public address is optional**, the other four are required
(`config.Required`). Without it there is no Mini App — Telegram opens one
only over public HTTPS — but the bot works on its own: links, torrents,
`/status`, notifications, and nothing on the NAS is published. The bot
already left out the Mini App buttons without an address; what made the
address required was only the list of problems. A SynoCommunity tester did
not want to open ports at all, and the documentation now offers three ways
to an address — DSM's reverse proxy, Cloudflare Tunnel, Tailscale — two of
them with nothing opened on the router.
- **How the Mini App is reached is a switch in the window**: the bot only,
DDNS, an own domain on a static address, the home network only, another way
(a tunnel, Tailscale, a proxy elsewhere). The service still reads nothing
but `PUBLIC_URL`; the choice lives in the database (`access` in `meta`) and
decides what the window explains — the steps, the address a name has to
lead to (external for DDNS and a domain, the NAS's own local one for the
home network), the reverse proxy rule. "The bot only" clears the address on
the server, so the choice and the address never disagree. Before anybody
chooses, the window works it out from the address: none is the bot only, a
DSM DDNS name is DDNS, DSM's own rule is a domain, anything else another way.
- **The home network only is the hard way, because of the certificate.**
DSM's own Let's Encrypt needs the NAS reachable from the internet on port 80
— its own renewal message says so — and issues wildcards for Synology DDNS
names only. So the window says to import a certificate obtained through a
DNS check, and the name has to lead to the NAS's local address inside the
network (the service lists its own private addresses, leaving out docker,
bridges and VPN links; on a live NAS the network was `ovs_eth0`).
- Each field carries three things: a short label, an example shown in the
empty field and a line underneath saying what the setting is for. Whoever
fills this in has never seen the project and is being asked for a password
and a token — "DSM user" alone does not say which user, and the list of
allowed ids does not explain itself at all.
- DSM language codes (`rus`, `ger`, `ptb`) do not match Telegram's, and are what
`description_<lang>` in INFO uses. **Italian is `ita`** — `itn` was used here
once, and a suffix DSM does not know is not an error: DSM quietly shows
English, so only an Italian speaker would ever notice. The full list is in
`docs/synology-api.md`.
- The listing text lives in **`assets/store.json`**, once: `build-spk.sh` writes
it into INFO as `description` and `description_<lang>`, `make-feed.py` puts
the English one into the catalogue. One paragraph, no line breaks and no
markup — of the 30 packages on a live DSM, Synology's own included, not one
has a newline in its description.
- Screenshots for the listing come **only from the catalogue**, field
`snapshot`; the `.spk` has no field for them. Package Center does not fetch
them from the browser — the NAS does, through
`SYNO.Core.Package.Screenshot.Server`, so the address has to be reachable
from the NAS. `tools/make-store.py` composes the slides from the English
screenshots into `docs/store/`, and `make-feed.py` publishes them.
- The catalogue for DSM "Package Sources" is static files on GitHub Pages, one
per Synology platform name: Package Center expects a list **filtered by
architecture**, and static files cannot filter. It is published on a tag; the
`github-pages` environment has to allow `v*` tags, otherwise the job fails
before its first step.
- Every catalogue entry carries **`md5` and `size` of the .spk**. Package Center
reads both out of the feed and passes them to the backend as `checksum` and
`filesize` — visible in `PkgManApp.js` on the NAS, in the
`SYNO.Core.Package.Installation` `upgrade`/`install` call. `make-feed.py`
takes them from the file itself and refuses to build a catalogue without it,
so an entry cannot describe a package that is not there.
- The `link` in the catalogue points at a host that **does not compress** what
it serves. GitHub Pages does: asked with `Accept-Encoding: gzip` it returns
the .spk as a gzip stream, and no `md5` in the catalogue can describe both
that and the file. Release assets are served as they are, and the redirect
they answer with is harmless. `make-feed.py --verify` downloads its own link
and refuses to publish a catalogue that describes something else. This was
also blamed for "Invalid file format" and was **not** the cause.
- The package is built with `COPYFILE_DISABLE=1` and `--format=ustar`. macOS
tar writes an AppleDouble companion beside every file (`._INFO` next to
`INFO`) and hides them again when listing the archive, so a package built on
a Mac had twenty junk entries and started with `._package.tgz` while
`tar tvf` showed it clean. `build-spk.sh` reads the finished archive back
with Python and fails if the junk returns or the first entry is not
`package.tgz`.
- The catalogue is built from the **.spk files of the release**, downloaded back
with `gh release download`, not from a fresh build: `md5` has to describe the
exact file people will get, and a rebuild from a branch that moved on since
the tag is a different file.
- Every icon in the repository is rendered from **`assets/icon.svg`** by
`tools/make-icons.sh` — the package icons, the one in the SynoCommunity
recipe, the Mini App favicon and the one in the READMEs. The PNGs are
committed because none of the places that need them can run a renderer, so
the rule is: change the SVG, run the script, never touch a PNG by hand.
The arrow in the mark is a cut-out, not a white shape on top: at 40 px, the
size Package Center and Telegram really show, a thin outline disappears.
- The window's dictionary `spk/ui/i18n.js` is generated by
`tools/make-dsmui-i18n.py` and committed; CI regenerates it and fails if the
result differs. The same goes for the ten READMEs: a check compares the
language links, because a translation nothing links to looks perfectly fine
on its own.
- The recipe for SynoCommunity is in `contrib/spksrc/`, written **their** way
rather than ported from ours: their generic installer, their package user
`sc-dsm-mini`, modelled on `spk/ddns-go` and `spk/gentoo-chroot`, and built in
their container before being committed. Its README lists where it differs
from our package and why — no `SERVICE_PORT` for a loopback service, the kept
log. It carries a **copy** of the window in `src/app/` (spksrc builds from its
own tree); CI fails when the copy differs from `spk/ui/`, and only the Ext JS
wrapper is its own, for the namespace. On a release: `PKG_VERS`, `SPK_VERS`,
`tools/build-spksrc.sh digests`, and `SPK_REV` goes up — it never resets.
- **Their framework is a cache, not a project.** `tools/build-spksrc.sh` keeps
a clone of SynoCommunity/spksrc in `~/Library/Caches/dsm-mini/spksrc` and
copies the recipe into it on every build. It does not go into `~/Projects`,
where it once sat looking like a second project with a stale copy of the
recipe, nor into this repository's folder: 3 GB of somebody else's code,
Go toolchain sources included, would go through `gofmt -l .`, the IDE and
backups.
- **The two builds replace each other**: same package id, same package user,
same port. So our own catalogue can serve betas and SynoCommunity the
releases, and moving between them is an ordinary update that keeps the
settings. Checked on a live NAS with 1.0.13: our build started on files the
SynoCommunity build had created, an upgrade from our build to theirs kept
everything, and so did their uninstall with "Uninstall only" followed by an
install. The upgrade from theirs to ours has not been run yet. Anything that
makes the builds differ in where or as whom they keep files breaks this —
see the package user above.
- **The port is reserved in DSM** through a service file of our own,
`dsm-mini.sc` (`ui/` in our package with `conf/resource` `port-config`,
`src/` in the recipe through `FWPORTS`): DSM's port conflict check then
names dsm-mini for 58080 — checked on a live NAS with 1.0.14, and
`/usr/syno/bin/servicetool --conf-port-conflict-check --tcp 58080` needs no
root when called by its full path. `port_forward="no"`, which DSM's own files use too
(WS-Transfer in `SMBService.sc`): the service is loopback-only, so offering
to forward it on the router would be offering nothing. Not `SERVICE_PORT`,
which writes `"yes"`; and **never `NO_SERVICE_SHORTCUT`** — in spksrc it also
switches off `DSM_UI_CONFIG`, and the settings window would vanish from the
menu.
- The recipe's documentation page is `contrib/spksrc/docs/dsm-mini.md`; it goes
to SynoCommunity as `docs/packages/dsm-mini.md`, after their template, and
needs two lines of theirs as well: `mkdocs.yml` (under Downloads) and a row
in `docs/packages/index.md`, which lists every documented package in
alphabetical order. Without them the page builds and nothing links to it.
- **No brackets in the display name.** spksrc writes it into INFO unquoted
through the shell, so brackets are a syntax error, and elsewhere quoted into
`jq`, so escaping them leaks a backslash. The name is "DSM mini — Telegram
Mini App" in every place it lives.
## Notifications from DSM
DSM has no API for reading its notification feed (`SYNO.Core.DSMNotify`
answers 103), so the service registers itself as a **webhook provider** and
DSM calls it on every notification, over loopback.
- **The method is `post`, lower case.** DSM's check on create accepts `POST`
and stores it; the sender then refuses it on every send, `send_test` answers
a bare 4682, and nothing reaches the service. The reason is only in
`/var/log/synoscgi.log`: `Invalid HTTP method: [POST]. Only 'get' and 'post'
accepted.` The provider looks perfect in every listing meanwhile.
- The name DSM lists it under is the field `provider`; `target_name` is
accepted by create and dropped. It is the package's visible name.
- `Ensure` compares the method, the name, the headers and the body **exactly**
on every start and corrects what differs — that is how a provider an older
version registered wrongly gets fixed without anyone touching it.
- **The webhook lives and dies with the package.** It is registered on every
start; on a real uninstall `preuninst` calls `dsm-mini unregister-webhook`,
because nothing else removes it and DSM would go on calling a port nobody
listens on. Only when `SYNOPKG_PKG_STATUS` is `UNINSTALL`: DSM runs the old
version's `preuninst` during an upgrade too, and removing it there would
only have the next start recreate it under a new number. The uninstall never
waits on this — a failure is swallowed.
- `Ensure` also removes this service's providers on **other ports**, recognised
by `http://127.0.0.1:<port>/dsm/notify`. The SynoCommunity build moved the
port, and the provider on the old one stayed in DSM's list as an orphan
until it was deleted by hand. Anybody else's webhook is never touched.
- **The binary refuses unknown arguments** and starts the service only with
none. Older builds ignored them, so a script asking one for a one-off job
would have started the whole service instead — inside an uninstall, an
uninstall that never finishes.
- A forwarded notification is logged, and so is one that was not forwarded
because of the mode. Without both, "DSM called and nothing arrived" cannot
be answered from the log — and for an hour it could not be.
- **`sepchar` is a space.** It is what DSM puts in place of every space of the
message — an SMS gateway leftover, for text in a query string. Every preset
in DSM's own interface sets `" "`, JSON bodies included, and the field is only
shown for GET; ours was created empty, and "Test Message from NAS" reached
Telegram as "TestMessagefromNAS". Found by reading the provider back through
the API and `NotificationVueBundle.js` on the NAS, after the log said
"forwarded" and the person said "nothing arrived" — it had arrived, without
spaces.
## The settings screen inside DSM
The package registers a window in the DSM main menu: `dsmuidir="ui"` and
`dsmappname` in INFO, `spk/ui/config` describing the app, and a thin Ext JS
wrapper (`dsm-wrapper.js`) whose only job is an iframe. Everything a person
sees is a plain page — no framework, no build step, because it is served
straight out of the package by DSM's own web server.
It is also **where the package is set up**: there is no install wizard. The
service starts with no settings at all, serves this window and nothing else,
and starts the bot and the Mini App once the window has saved enough to start
on and **Start** has been pressed.
- **Start and Stop are the window's.** The process always runs — it is what
serves the window — and the bot and the Mini App run only while the window
says so. The choice is kept in the database (`app_enabled` in `meta`) and
survives a restart of the NAS; the status reads "stopped" whenever they are
off, for whatever reason. Start is refused while the settings are not
enough to start on, so that switched on never means switched on and doing
nothing. The first start of a version with the button settles it once
(`switchedOn` in `cmd/dsm-mini`): an installation that has its settings was
running before the upgrade and goes on running, a fresh one waits for Start.
Package Center's own Stop still stops everything, the window included.
- **After a fresh install Package Center says where to go next.** `postinst`
writes one line into `$SYNOPKG_TEMP_LOGFILE` on `INSTALL`, and Package
Center shows what is in that file in a message box once the installation is
done (`_showDoneMessage` in `PkgManApp.js` on the NAS). The language is
`SYNOPKG_DSM_LANGUAGE`, a DSM code; the lines are in `ui/installed.txt`,
generated with the window's dictionary. In spksrc the script's own output
goes to that file (`INSTALLER_OUTPUT`), but `service_postinst` runs through
`call_func … install_log`, which sends everything it prints to the package
log — so the recipe writes the file itself, as ours does; an `echo` would
never reach the box. Nothing on an upgrade, and nothing on an install that
finds `config.env` left by an earlier one ("Uninstall only" keeps it): that
package is set up already, and "not set up yet" would be untrue.
- **"Check the account"** signs in as the account typed into the window — with
the saved password when the field is empty and the account is the saved one
— and goes through what the service does with it, read-only: the sign-in,
Download Station, File Station and the shared folders it sees (without them
the bot cannot work), then every section of the Mini App, each with DSM's
code when refused. It signs in **exactly as the service does**, session name
included (`DownloadStation`). It used to take a name of its own
(`DsmMiniCheck`), to keep its sign-out apart from the service's session, and
a SynoCommunity tester's ordinary account — set up as the documentation says,
and signed in by the service — was refused right at the check's sign-in with
402; an administrator gets in under either name, so it never showed here.
The sign-out closes the check's own session by its SID, as DSM's guide
describes, and should DSM end the service's session anyway, the service
signs in again by itself (106, 107, 119). The log gets the account and the
outcome, never the password.
- **A save applies without restarting the package.** The service reads
`config.env` itself (not through the start script's environment, which would
keep the old values) and runs as a loop in one process: a save cancels the
current run with `errRestart`, the next one starts with the new file. The
window waits for the status's `since` to change — until it does, the answer
comes from the run that is about to stop. The notification mode lives in the
database and needs no restart at all.
- **Nothing that is wrong in the settings stops the process.** Missing values,
a refused password, a refused token, a Download Station that is not running:
each is a state the window shows at the top, in the person's language, with
DSM's own code where there is one. A package that exits on a bad setting is
shown by Package Center as stopped, with a Repair button that repairs
nothing — that is what people used to get. Only a port somebody else holds
still stops it, because then even the window cannot be served.
- A refused DSM sign-in is **not retried** by itself: repeating a wrong password
only adds failed sign-ins to DSM's log and to what its auto block counts.
DSM not answering at all is retried every half a minute — during boot it
comes up after the packages.
- **The window talks to DSM as the administrator who opened it**, through the
browser's own session (cookie and SynoToken), never as the service's account.
That is what lets it read the reverse proxy rules and create one before the
service has an account at all — and the service's account is not an
administrator anyway. DSM accepts that session over loopback although it was
made on another machine, with its IP checking on (`skip_ip_checking: false`)
— checked on DSM 7.4.
- **Files left by another package user** — the result of switching between
builds that ran as different users — are explained in the window, with the
one `chown` that hands them back. Saving new settings replaces `config.env`
anyway (a rename in a folder the service owns needs no permission on the old
file). A database that cannot be read is set aside as `*.unreadable` once
the service has settings to run on, and a log that cannot be appended to is
set aside by the start script, which would otherwise fail to start anything.
- **DSM sends the window's files with no `Cache-Control`**, only `ETag` and
`Last-Modified`, so a browser may keep the previous version's page for hours
after an upgrade. The wrapper opens `index.html?v=<now>` and the page loads
its scripts with the same value.
- **A browser session needs `X-SYNO-TOKEN`; an API session does not.** DSM has
CSRF protection on by default (`SYNO.Core.Security.DSM`,
`enable_csrf_protection`), and with it the session cookie alone is refused —
with a plain `success: false`, not an HTTP error. A session made through
`SYNO.API.Auth` is not refused, so the check passed every test and failed for
the first person who opened the screen in DSM. The page reads the token from
`SYNO.SDS.Session.SynoToken` on the desktop around it — same origin — rather
than taking it through a URL, where it would land in logs and referrers.
- **`/webman/3rdparty/` is served to anyone.** A request with no session gets
200, checked against a live NAS. So the path guards nothing and `api.cgi`
authorises nothing: it only carries the browser's `Cookie` header to the
service, which asks DSM whose session it is. The call is
`SYNO.Core.Desktop.Initdata`, whose `Session` block has `user` and
`is_admin`; there is no documented "who am I", and `SYNO.Core.CurrentUser`
answers 102. Anything less than an administrator is refused — the screen
edits the bot token and the NAS password.
- **The CGI cannot read `config.env`**: the file is 0600 and owned by the
package user, and DSM's web server runs as another. It still needs the
service's port, so the service writes that one value into `backend.conf` in
`UI_DIR` every time it starts listening — the window can move the port
without a package restart. The file is world-readable and holds nothing else.
- **The window reaches DSM on this machine by the address DSM describes
itself** (`/usr/syno/etc/www/DSM.json`), never by `DSM_URL` from the file:
it checks who is asking by calling DSM, so a wrong `DSM_URL` would lock
everybody out of the one place that could fix it.
- **Addresses in the window can only be this NAS.** `DSM_URL` and
`LISTEN_ADDR` are refused unless they are loopback: a tester put the NAS's
internet address into `DSM_URL` — a perfectly good URL — and the service
stopped reaching DSM; `0.0.0.0` in `LISTEN_ADDR` would publish the app to the
whole network. Only a value being changed is held to it: the window sends
every field back on a save, and a LAN address or the bare `:58080` an older
installation left — which work — must not block saving a new token. The
file itself is not held to this, for development.
- **Empty means the default.** A cleared field with a default drops the key
from `config.env`, and an empty value found in the file reads as the
default too — it used to be written as `DSM_URL=''` and read as an address
of nothing. `ALLOWED_USER_IDS` is the exception: empty there is "nobody".
- **Secrets are write-only.** The password and the token are never sent to the
browser; the screen shows "set" and an empty field. An empty field means
"leave it alone" — treating it as "erase" would lock the service out of the
NAS on the first save of an unrelated setting.
- `config.env` is rewritten through a temporary file, with values in single
quotes and the shell's escaping: a half-written file is a service that starts
with half its settings, and whoever reads the file with `.` by hand should
not be tripped by a password with a space.
- The dictionary is generated by `tools/make-dsmui-i18n.py` and committed; CI
fails if the two drift apart, and the generator refuses a translation that
lost a `{name}` placeholder.
## Languages
The interface and the bot are translated into ten languages; the language comes
from the Telegram user's `language_code`, an unknown one gets English.
- Dictionaries: `web/src/i18n/<code>.ts` (interface) and `internal/i18n/<code>.go`
(bot, notifications, API errors). English is the source of truth in both.
- **A new string is added to every dictionary at once.** On the frontend the type
enforces it (`Record<Key, Phrase>`), on the server the `internal/i18n` test
does: it compares keys, `{name}` substitutions and the absence of a foreign
script — a scrap of Russian text inside a Portuguese string would otherwise
only be noticed by a user.
- Plurals on the frontend go through `Intl.PluralRules` (the `one`, `few`, `many`
forms); Go has nothing like it, so the messages are written with the number
after a colon ("Active tasks: 5") and need no agreement.
- Numbers and dates are formatted through `Intl` with a locale tag, not by hand:
the decimal separator and the date order differ everywhere.
- API error messages travel as a **key**, not as text: `s.fail(w, r, err,
"api.readFolder")`. The person gets a translation, the log stays English, and
the DSM code goes in a separate field — digits read the same in any language.
- The bot's appearance (descriptions, commands, menu button) is set for every
language and **only when it changes**: across ten languages that is a couple
of dozen requests otherwise.
- **The name is not set from code.** It belongs to whoever created the bot, and
writing it on every start silently undid a rename made in @BotFather.
- **The avatar is set only when there is none.** `setMyProfilePhoto` arrived in
Bot API 9.4 (February 2026) — before that it really was a trip to @BotFather,
and a comment in this repository said so for a while after it stopped being
true. The current picture is read with `getUserProfilePhotos` on the bot's
own id: the documentation describes that method for users, but it answers for
a bot too — checked against the live API, both branches. An empty profile
gets `internal/bot/avatar.png`, a picture the owner chose is left alone.
## Build and run
```bash
cd web && npm install && npm run build # Mini App → internal/web/dist
cd .. && go build ./cmd/dsm-mini # a single binary with the frontend embedded
```
The SQLite driver is `modernc.org/sqlite`, pure Go. Swapping it for
`mattn/go-sqlite3` is not allowed: that one needs CGO, and with CGO the static
binary the package ships is gone — and with it the reason `build-spk.sh` needs
no Synology toolchain.
## Language of the project
**There is no Russian in the code.** Comments, names, log messages and error
text are in English: the project is open, and not only Russian speakers will
read it.
Russian lives only in **translations**: `internal/i18n/ru.go`,
`web/src/i18n/ru.ts`, the settings window's table in
`tools/make-dsmui-i18n.py` (and the `spk/ui/i18n.js` and `installed.txt`
built from it), the listing text in `assets/store.json`, plus
`DESCRIPTION_RUS` in the SynoCommunity recipe. The other nine languages live
in the same places.
Documentation follows the same rule: `README.md` and this file are English,
translations of the README are separate files (`README_RU.md` and the rest), and
the language links at the top of every one of them are kept in step.
Commit messages stay in Russian: the history has been written that way from the
first commit, and mixing the two would make it unreadable either way.
**No `Co-Authored-By` trailer.** Commits carry the author and nothing else. The
first ninety-one keep theirs — rewriting them would change every hash, move
eleven release tags and break the commit links in the merged pull request, for
a line in a message. New commits simply do not have it.
Text for people is written for people, not for developers: "the folder does not
exist" instead of "code 403" — in any language.