Installation
Memo is one process and one file. There is no database server to install, no build step, and nothing that has to sit beside the binary.
Every board, every account, every picture and every share link lives in one SQLite file. Backing up an instance is copying that file, and moving an instance to another machine is copying it there.
Starting it
A release binary needs nothing beside it:
./memo --port 8088 --db /var/lib/memo/memo.sqlite
From a checkout, with Bun:
bun install
bun run start
The database appears on the first run, and the server prints the address it is listening on. The first account made on an install with no admin becomes the admin, because an install nobody can administer has no way to make one.
Behind a reverse proxy
Terminate TLS at the proxy and pass everything through, /ws included. Two
things the proxy has to get right:
- The WebSocket upgrade on
/ws, with a read timeout long enough for an idle board. A minute is not enough. People leave a board open all afternoon. --origin https://memo.example, the public address. Every CSRF and WebSocket origin check compares against it. Without it the check falls back to the host the request claimed, which still stops a cross-site form post but trusts whatever is in front.
--baseurl /memo serves the whole thing under a sub-path when it shares a host
with something else.
NixOS
The flake builds the same binary and ships a module that declares the rest of a deployment: the service, a reverse proxy, a certificate, a nightly backup and an update timer.
services.memo = {
enable = true;
domain = "memo.example.org";
openFirewall = true;
proxy.server = "nginx";
proxy.acme = {
enable = true;
email = "ops@example.org";
};
settings = {
lang = "fr";
auth = "oidc";
oidcIssuer = "https://id.example.org";
oidcClientId = "memo";
adminGroup = "memo-admins";
};
environmentFile = "/run/secrets/memo.env";
backup = {
enable = true;
keep = 30;
schedule = "*-*-* 03:30:00";
};
};
That creates the memo user, puts the database in /var/lib/memo, binds the
server to loopback because nginx is on 443, gets the certificate, and fills in
--origin from the domain. settings takes every option below by its flag
name without the dashes. The repository's README describes the module's own
options.
The options
Every option is a --flag and an environment variable both. A flag wins over
the environment, which wins over the default. The two passwords and
MEMO_SECRET are the exception: they are environment-only on purpose, because
ps shows a unit's arguments to every user on the machine.
memo --help prints the whole list with the defaults, and memo --version
prints the version. A flag memo does not know stops it there and then, before
the database is opened, so a mistyped command leaves nothing behind.
| Flag | Variable | Default | What it does |
|---|---|---|---|
--port | PORT | 8088 | Port to listen on |
--hostname | HOSTNAME | 0.0.0.0 | Address to bind, 127.0.0.1 under --auth=proxy |
--lang | DEFAULT_LANG | en | Language for whoever has not chosen one: en, fr, es, ru |
--baseurl | BASEURL | / | Path prefix when served under a sub-path |
--db | DB_PATH | memo.sqlite | The file holding every board |
--tls | TLS | off | Serve HTTPS directly, for development |
--tlsKey | TLS_KEY | local-dev-key.pem | Private key, with --tls |
--tlsCert | TLS_CERT | local-dev-cert.pem | Certificate, with --tls |
--logoUrl | LOGO_URL | A logo, when none was sent from the Appearance tab | |
--faviconUrl | FAVICON_URL | Replaces the favicon | |
--backupDir | BACKUP_DIR | backups | Where snapshots are written and looked for |
--backupKeep | BACKUP_KEEP | 14 | How many snapshots to keep |
--maxImages | MAX_IMAGES | 16 | How many pictures a board may hold |
A flag is on by its presence, so --tls alone. As a variable it takes 1,
true, yes or on; anything else, 0 included, is off.
DEFAULT_LANG is deliberately not spelled LANG. That one is already set to
your own locale on nearly every machine, and inheriting it would change what
visitors see because of how the server happened to be started.
Accounts
Memo always keeps accounts, and refuses to start without a backend: an install with no account system has no admin page and no way for anyone to find the boards they took part in. Opening a board and writing on it still needs no account. Making a new one is what asks for a sign-in.
One backend is live per process. Accounts are keyed on backend and subject, so
changing --auth on an existing instance leaves the old accounts in place but
unreachable, the admin one included.
| Flag | Variable | Default | What it does |
|---|---|---|---|
--auth | AUTH | local | local, proxy, ldap or oidc |
--origin | ORIGIN | The public origin, https://memo.example | |
--admin | ADMIN_SUBJECT | Account to make admin, once, while no admin exists | |
--adminGroup | ADMIN_GROUP | Directory group whose members are admins | |
--closedRegistration | CLOSED_REGISTRATION | off | Stop people creating local accounts themselves |
--allowAnonymousBoards | ALLOW_ANONYMOUS_BOARDS | off | Let people make a board without signing in |
--insecureCookies | INSECURE_COOKIES | off | Drop Secure from the session cookie, plain-HTTP dev only |
--allowPrivateFetch | ALLOW_PRIVATE_FETCH | off | Read addresses inside this machine and its network, dev only |
--adminGroup makes the directory authoritative: someone who loses the group
upstream loses the role here at their next sign-in. Without it the role is
whatever the admin page last set. --admin grants the role only while the
instance has no admin at all, so leaving it in a unit file cannot quietly
restore it after a revoke.
Passwords kept here (--auth=local)
Hashed with argon2id, at least ten characters. Sign-ups are open unless
--closedRegistration says otherwise.
Behind a forward-auth proxy (--auth=proxy)
Authelia, and anything else that authenticates in front and passes the result in headers. Memo mints no session of its own here, so signing out upstream signs out here.
| Flag | Variable | Default |
|---|---|---|
--trustedProxies | TRUSTED_PROXIES | |
--proxyUserHeader | PROXY_USER_HEADER | remote-user |
--proxyGroupsHeader | PROXY_GROUPS_HEADER | remote-groups |
--proxyNameHeader | PROXY_NAME_HEADER | remote-name |
--proxyLoginUrl | PROXY_LOGIN_URL | |
--proxyLogoutUrl | PROXY_LOGOUT_URL |
--trustedProxies is mandatory and the server refuses to start without it.
A header is only a header: anyone who can reach the port can send
Remote-User: alice and be alice. It takes addresses or CIDR ranges, checked
against the connection's peer address and never against anything in the
request.
The proxy must overwrite those headers rather than append to them, or a
client can add its own alongside. It must also cover /ws with the same
forward-auth. Miss that and every page load is authenticated while every socket
is anonymous, which shows up as an intermittent bug rather than as a security
failure.
The way in and the way out both belong to the portal. --proxyLoginUrl and
--proxyLogoutUrl say where they are; {back} in either is the address to
come back to, percent-encoded, and {back64} is the same in base64. Without
them Memo shows neither link, because a way out that signs nobody out is worse
than no way out at all.
--proxyLoginUrl 'https://auth.example/?rd={back}'
--proxyLogoutUrl 'https://auth.example/logout'
Under YunoHost's SSOwat, which is a forward-auth proxy, the header names are
not a preference, they are the security boundary. SSOwat clears every header a
client sent whose name starts with ynh_ or ynh- before setting its own, and
only those. Point --proxyUserHeader at anything else and a visitor can send
that header themselves.
--auth proxy
--trustedProxies 127.0.0.1
--proxyUserHeader YNH_USER
--proxyNameHeader YNH_USER_FULLNAME
--proxyLoginUrl 'https://memo.example/yunohost/sso?r={back64}'
--proxyLogoutUrl 'https://memo.example/yunohost/portalapi/logout'
--origin https://memo.example
SSOwat sends no groups, so --adminGroup has nothing to match. Name the first
admin with --admin instead, or grant the role from /admin.
A directory (--auth=ldap)
Memo searches with a service account, then binds as the person with the password they typed.
| Flag | Variable | Default |
|---|---|---|
--ldapUrl | LDAP_URL | |
--ldapBindDn | LDAP_BIND_DN | |
LDAP_BIND_PASSWORD | ||
--ldapBaseDn | LDAP_BASE_DN | |
--ldapUserFilter | LDAP_USER_FILTER | (uid={{username}}) |
--ldapUidAttr | LDAP_UID_ATTR | entryUUID |
--ldapGroupAttr | LDAP_GROUP_ATTR | memberOf |
The account is keyed on --ldapUidAttr, which has to be something that never
changes. Not the DN: moving somebody between organisational units would orphan
every board they own.
This is the only backend where Memo handles a directory password. If the
directory already sits behind Authelia or Keycloak, --auth=proxy or
--auth=oidc reaches the same accounts without that.
OpenID Connect (--auth=oidc)
| Flag | Variable | Default |
|---|---|---|
--oidcIssuer | OIDC_ISSUER | |
--oidcClientId | OIDC_CLIENT_ID | |
OIDC_CLIENT_SECRET | ||
--oidcScopes | OIDC_SCOPES | openid profile email groups |
Register <origin>/auth/callback as the redirect URI. Authorization code with
PKCE, and the claims come from the userinfo endpoint.
The admin page
An admin gets /admin: every board and every account, with a way to tick
several boards and delete them together, and a Backups tab showing the
copies of the database on the server, with a button to take one there and then,
and a way to download or delete each. A snapshot holds everything the instance
holds, private boards and password hashes included, so it is treated the way
the server itself is.
Each tab is an address of its own: /admin/boards, /admin/users,
/admin/backups and /admin/appearance. A tab is a link, so it can be
bookmarked, opened in another window or sent to the person who has to look at
it, and the browser's back button walks the tabs. /admin opens on the
boards.
The look of an instance
The tab opens on the name, the line and the mark every page carries in its
header. The name is what the header spells out and what the browser tab says;
leave it empty and the instance is called Memo. The line is what the home page
and the sign-in page carry beside the name; leave it empty and it stays the
translated one, which follows whoever is reading. The logo is a picture you
send, kept as one WebP and served to everybody at /logo.webp; send nothing
and the instance shows the logo Memo came with. Three buttons decide what a
header shows: the logo and the name, the logo alone, or the name alone.
--logoUrl still stands for an instance that never sent one from this page,
alone in the header as it always was. A logo sent here wins over it.
Below that, the tab lists every colour and size the stylesheet declares, with the value it takes in light mode beside the value it takes in dark mode. Post-it colours, box colours, the border round a board, the ink of a mind map, the background of a page: each is a field, and each field shows its default until you type over it. Leave a field empty and that property stays as the stylesheet left it.
The page paints what you type as you type it, colour or name: the header of this very page takes the name, the logo and the way of showing them as you change them, so you see where it lands before saving. The switcher in the bottom left corner flips the page between light and dark, which is how to check the other column. Saving says so in a word beside the buttons, since most of what it saves shows up elsewhere.
Under the list there is a box for CSS of your own, added to every page after everything else. Nothing checks it, so a forgotten brace stops the rest of it working. Save a little at a time.
What you save is kept in the database and served to everybody here as
/css/instance.css. Back to the defaults empties the lot: the name, the
logo and your own CSS included.
Backups
VACUUM INTO takes a consistent snapshot while the server is running, so there
is no downtime and no torn read. The Backups tab takes one, and so does the
script in a checkout:
bun run backup # -> backups/memo-<timestamp>.sqlite
bun run backup --out /srv/backups --keep 14
It opens the copy and checks the expected tables are in it before pruning older
ones, and exits non-zero if they are not. A systemd timer or a cron line is the
usual way to run it nightly; on NixOS backup.enable does it for you.
Restoring is copying a snapshot over --db with the server stopped.
Pictures
A board carries a background and up to --maxImages pictures, sixteen by
default, and an account carries one of its own. They live in the database
rather than beside it, so one file is still the whole instance: one snapshot
carries everything and a restore is a copy. Deleting a board takes its pictures
with it.
Everything is recoded on the way in and only the result is kept: WebP, never
wider than 1920, never enlarged, under 600 kB. /admin shows what pictures
weigh per board and in total, which is the instrument rather than the limit.
Wiki accounts
A board can read a wiki as an account on it, and through a password on the whole
site. Those passwords are kept in the database encrypted with AES-256-GCM under
a key taken from MEMO_SECRET. Without that variable, Memo makes a random key
the first time it needs one and keeps it beside the database, in <db>.secret,
readable by its own user only.
A snapshot of the database holds the encrypted passwords and not the key. Keep
the two apart, and keep the key: lose it and every board has to be given its
wiki accounts again. Set MEMO_SECRET from a secrets file rather than on the
command line.
The pictures of small cards from a wiki are copied into the database too, 320 pixels wide at most, shared by every board that shows them, and dropped once a day when no board shows them any more.
Worth knowing
- Secrets are read from the environment only. Use
EnvironmentFilein a systemd unit. - Names the server answers to itself,
admin,login,logout,register,s,ws,api,css,imagesandvendoramong others, cannot be board names. An existing board with such a name is left alone and logged at startup; rename it from the admin page. - Locking a board makes you its owner, and so does creating one while signed in. Share links come read-only or editable, are stored hashed, and are shown once when created. Revoking a grant or a link disconnects whoever was using it.
- An API key made by an admin account carries the admin role. The developer guide has what a key reaches.
