Compare commits
2
Commits
v0.1.0
...
2536f223b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2536f223b7 | ||
|
|
9f2fe4a416 |
No files matched your search
@@ -32,3 +32,25 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
|
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
|
||||||
|
|
||||||
|
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
|
||||||
|
# if this runner has no Docker: the release above is already published by then.
|
||||||
|
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
|
||||||
|
# with package write permission, for servers where the job token can't push.
|
||||||
|
- name: Build and push the container image
|
||||||
|
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
|
||||||
|
run: |
|
||||||
|
host=${GITHUB_SERVER_URL#*://}
|
||||||
|
owner=$(echo "$GITHUB_REPOSITORY_OWNER" | tr 'A-Z' 'a-z')
|
||||||
|
image="$host/$owner/mako"
|
||||||
|
echo "$REGISTRY_TOKEN" | docker login "$host" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
docker buildx create --name release --driver docker-container --use >/dev/null 2>&1 || docker buildx use release
|
||||||
|
tags="-t $image:$GITHUB_REF_NAME"
|
||||||
|
case "$GITHUB_REF_NAME" in
|
||||||
|
*-*) ;; # pre-releases (v1.0.0-rc1) don't move :latest
|
||||||
|
*) tags="$tags -t $image:latest" ;;
|
||||||
|
esac
|
||||||
|
docker buildx build --platform linux/amd64 $tags --push .
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A private notebook in the spirit of Obsidian and Notion, for people who would rather run their own.
|
A private notebook in the spirit of Obsidian and Notion, for people who would rather run their own.
|
||||||
Pages that nest inside each other, `[[links]]` between them with backlinks, a journal page for each
|
Pages that nest inside each other, `[[links]]` between them with backlinks, a journal page for each
|
||||||
day, tags, and quick search, all kept as plain markdown files. One small binary, no database, no
|
day, tags, and quick search, all kept as encrypted markdown files. One small binary, no database, no
|
||||||
dependencies. You sign in with **Authentik** and nothing else: there are no passwords in Mako.
|
dependencies. You sign in with **Authentik** and nothing else: there are no passwords in Mako.
|
||||||
|
|
||||||
Named for the mako shark, the fastest in the sea, because a notebook should keep up with you.
|
Named for the mako shark, the fastest in the sea, because a notebook should keep up with you.
|
||||||
@@ -116,23 +116,42 @@ written to it:
|
|||||||
| `oidc.client_id` | `MAKO_OIDC_CLIENT_ID` | |
|
| `oidc.client_id` | `MAKO_OIDC_CLIENT_ID` | |
|
||||||
| `oidc.client_secret` | `MAKO_OIDC_CLIENT_SECRET` | |
|
| `oidc.client_secret` | `MAKO_OIDC_CLIENT_SECRET` | |
|
||||||
| `allowed` | `MAKO_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated |
|
| `allowed` | `MAKO_ALLOWED` | optional; emails, usernames or Authentik ids, comma separated |
|
||||||
|
| `key` | `MAKO_KEY` | encrypts your pages; made for you, see Encryption below |
|
||||||
| `listen` | `MAKO_LISTEN` | default `127.0.0.1:8490` |
|
| `listen` | `MAKO_LISTEN` | default `127.0.0.1:8490` |
|
||||||
| `data_dir` | `MAKO_DATA` | default `data` |
|
| `data_dir` | `MAKO_DATA` | default `data` |
|
||||||
|
|
||||||
`mako.json` also holds a `secret` that signs session cookies. It is made on first run and written
|
`mako.json` also holds a `secret` that signs session cookies. It is made on first run and written
|
||||||
with mode `0600`. Changing it signs everyone out.
|
with mode `0600`. Changing it signs everyone out.
|
||||||
|
|
||||||
|
## Encryption
|
||||||
|
|
||||||
|
Every page is encrypted on disk with AES-256-GCM. Each person has a key of their own, made from the
|
||||||
|
`key` in `mako.json` together with who they are in Authentik (the issuer and their subject id). So one
|
||||||
|
person's files cannot be opened with another's key, and a file copied over another page's name stops
|
||||||
|
opening. Pages are decrypted in the server's memory while it runs, and your downloads are plain
|
||||||
|
markdown.
|
||||||
|
|
||||||
|
- **The key is made on first run** and written to `mako.json` (mode `0600`), or set `MAKO_KEY` (at least
|
||||||
|
32 characters, for example `openssl rand -base64 32`) to keep it out of the file.
|
||||||
|
- **Back the key up, apart from the notes.** Without it the pages cannot be read, and Mako refuses to
|
||||||
|
start a notebook it cannot decrypt rather than overwrite it. Do not change it. Unlike `secret`,
|
||||||
|
changing `key` does not just sign people out.
|
||||||
|
- **What it protects:** a stolen disk or backup of `data/`, and anyone who can read the notes folder but
|
||||||
|
not the settings. **What it does not:** someone who controls the running server, or who can read both
|
||||||
|
`mako.json` and `data/`. Who you are in Authentik is not secret, so it cannot be the key by itself.
|
||||||
|
- **Upgrading from 0.1.0:** existing plain files are read and saved again encrypted the first time
|
||||||
|
that person signs in, and a `key` is added to your settings file.
|
||||||
|
|
||||||
## Where your notes are
|
## Where your notes are
|
||||||
|
|
||||||
```
|
```
|
||||||
data/users/<hash of who you are>/
|
data/users/<hash of who you are>/
|
||||||
owner.txt your name and email, so the folder can be matched to a person
|
notes/20261031-143205-a1b2c3 one encrypted file per page (named .md)
|
||||||
notes/20261031-143205-a1b2c3.md one file per page
|
notes/daily-2026-10-31 the journal page for a day
|
||||||
notes/daily-2026-10-31.md the journal page for a day
|
|
||||||
trash/ deleted pages, until the trash is emptied
|
trash/ deleted pages, until the trash is emptied
|
||||||
```
|
```
|
||||||
|
|
||||||
A page file looks like this, so you can edit it with anything:
|
Inside, a page is markdown with a small header (shown here decrypted, and what the zip export holds):
|
||||||
|
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
@@ -146,8 +165,8 @@ pinned: true
|
|||||||
Flights and hotels, see [[Packing list]] #travel
|
Flights and hotels, see [[Packing list]] #travel
|
||||||
```
|
```
|
||||||
|
|
||||||
A plain `.md` file dropped into `notes/` is picked up too, with its file time as its date (name it with
|
A plain `.md` file dropped into `notes/` is picked up when that person's notebook next opens, with its
|
||||||
lower-case letters, numbers and dashes). Restart Mako to see files added by hand.
|
file time as its date, and is then encrypted (name it with lower-case letters, numbers and dashes).
|
||||||
|
|
||||||
## Security notes
|
## Security notes
|
||||||
|
|
||||||
@@ -161,7 +180,8 @@ lower-case letters, numbers and dashes). Restart Mako to see files added by hand
|
|||||||
person's requests can only ever reach their own folder.
|
person's requests can only ever reach their own folder.
|
||||||
- Every change from the browser needs a header a page on another site can't send. The page has a strict
|
- Every change from the browser needs a header a page on another site can't send. The page has a strict
|
||||||
Content-Security-Policy, and pages are never rendered as HTML.
|
Content-Security-Policy, and pages are never rendered as HTML.
|
||||||
- Pages are **not encrypted** on disk. Anyone who can read the server's disk can read them.
|
- Pages are encrypted on disk (see Encryption), with a key per person. The server itself, while it
|
||||||
|
runs, can read them: this is not end-to-end encryption.
|
||||||
|
|
||||||
## Honest limits
|
## Honest limits
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -99,7 +99,7 @@ func serve(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return configError{err}
|
return configError{err}
|
||||||
}
|
}
|
||||||
if !h.Exists() { // writing it now is what makes the generated secret stick
|
if h.NeedsSave() { // writing it now is what makes the generated secret and key stick
|
||||||
if err := h.Save(); err != nil {
|
if err := h.Save(); err != nil {
|
||||||
return fmt.Errorf("could not write %s: %w", *path, err)
|
return fmt.Errorf("could not write %s: %w", *path, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ services:
|
|||||||
MAKO_OIDC_CLIENT_SECRET: change-me
|
MAKO_OIDC_CLIENT_SECRET: change-me
|
||||||
# MAKO_ALLOWED: you@example.com # optional: limit who may sign in
|
# MAKO_ALLOWED: you@example.com # optional: limit who may sign in
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/config # mako.json (the signing secret) and data/ (your pages) live here
|
- ./config:/config # mako.json (signing secret and note key; back it up apart from data/) and data/ live here
|
||||||
+2
-1
@@ -155,7 +155,8 @@ MAKO_OIDC_CLIENT_SECRET=
|
|||||||
# everyone Authentik lets into the application gets a private notebook of their own.
|
# everyone Authentik lets into the application gets a private notebook of their own.
|
||||||
#MAKO_ALLOWED=you@example.com
|
#MAKO_ALLOWED=you@example.com
|
||||||
|
|
||||||
# Where the settings file is (it holds the signing secret).
|
# Where the settings file is. It holds the signing secret and the key that encrypts your notes:
|
||||||
|
# back it up, apart from $DATA_DIR. Without that key the notes cannot be read.
|
||||||
MAKO_CONFIG=$CONF_FILE
|
MAKO_CONFIG=$CONF_FILE
|
||||||
|
|
||||||
# Optional. These override what the settings file says.
|
# Optional. These override what the settings file says.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/mako/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OIDC is the OAuth client Authentik gives you for Mako.
|
// OIDC is the OAuth client Authentik gives you for Mako.
|
||||||
@@ -37,6 +39,10 @@ type Config struct {
|
|||||||
|
|
||||||
// Secret signs session cookies, so a restart does not sign you out. It is made on first run.
|
// Secret signs session cookies, so a restart does not sign you out. It is made on first run.
|
||||||
Secret string `json:"secret,omitempty"`
|
Secret string `json:"secret,omitempty"`
|
||||||
|
|
||||||
|
// Key encrypts the pages on disk (each person's key is made from it and who they are). It is made
|
||||||
|
// on first run. Unlike Secret it must never change or be lost: without it the pages cannot be read.
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults fills in what is missing.
|
// Defaults fills in what is missing.
|
||||||
@@ -52,6 +58,11 @@ func (c *Config) Defaults() {
|
|||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
c.Secret = base64.RawURLEncoding.EncodeToString(b)
|
c.Secret = base64.RawURLEncoding.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
if c.Key == "" {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
rand.Read(b)
|
||||||
|
c.Key = base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/")
|
c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/")
|
||||||
c.OIDC.Issuer = strings.TrimSpace(c.OIDC.Issuer)
|
c.OIDC.Issuer = strings.TrimSpace(c.OIDC.Issuer)
|
||||||
c.OIDC.ClientID = strings.TrimSpace(c.OIDC.ClientID)
|
c.OIDC.ClientID = strings.TrimSpace(c.OIDC.ClientID)
|
||||||
@@ -83,6 +94,9 @@ func checkURL(name, raw string) error {
|
|||||||
|
|
||||||
// Check says what is still missing before Mako can sign anyone in, in words a person can act on.
|
// Check says what is still missing before Mako can sign anyone in, in words a person can act on.
|
||||||
func (c Config) Check() error {
|
func (c Config) Check() error {
|
||||||
|
if len(c.Key) < vault.MinKey {
|
||||||
|
return fmt.Errorf("the encryption key (key, or MAKO_KEY) must be at least %d characters; leave it out to have one made, or make one with: openssl rand -base64 32", vault.MinKey)
|
||||||
|
}
|
||||||
var missing []string
|
var missing []string
|
||||||
if c.PublicURL == "" {
|
if c.PublicURL == "" {
|
||||||
missing = append(missing, " public_url (MAKO_PUBLIC_URL) where you open Mako, such as https://notes.example.com")
|
missing = append(missing, " public_url (MAKO_PUBLIC_URL) where you open Mako, such as https://notes.example.com")
|
||||||
@@ -121,6 +135,7 @@ type Holder struct {
|
|||||||
path string
|
path string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
file Config
|
file Config
|
||||||
|
made bool // the file is missing, or lacked a secret or key that was just made
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads the file. A missing file gives the defaults (and is created by the first Update).
|
// Load reads the file. A missing file gives the defaults (and is created by the first Update).
|
||||||
@@ -136,10 +151,21 @@ func Load(path string) (*Holder, error) {
|
|||||||
return nil, fmt.Errorf("%s: %w", path, err)
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.made = err != nil || h.file.Secret == "" || (h.file.Key == "" && !keyInEnv())
|
||||||
|
keyless := h.file.Key == "" && keyInEnv() // a key in the environment is used instead, so none is made
|
||||||
h.file.Defaults()
|
h.file.Defaults()
|
||||||
|
if keyless {
|
||||||
|
h.file.Key = ""
|
||||||
|
}
|
||||||
return h, nil
|
return h, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keyInEnv() bool { return strings.TrimSpace(os.Getenv("MAKO_KEY")) != "" }
|
||||||
|
|
||||||
|
// NeedsSave says whether the file should be written now: it is missing, or a secret or key was
|
||||||
|
// made that is only in memory. Writing it is what makes them stick.
|
||||||
|
func (h *Holder) NeedsSave() bool { return h.made }
|
||||||
|
|
||||||
func (h *Holder) Path() string { return h.path }
|
func (h *Holder) Path() string { return h.path }
|
||||||
|
|
||||||
// Exists says whether the file is on disk yet.
|
// Exists says whether the file is on disk yet.
|
||||||
@@ -162,6 +188,7 @@ func (h *Holder) Get() Config {
|
|||||||
env("MAKO_OIDC_ISSUER", &c.OIDC.Issuer)
|
env("MAKO_OIDC_ISSUER", &c.OIDC.Issuer)
|
||||||
env("MAKO_OIDC_CLIENT_ID", &c.OIDC.ClientID)
|
env("MAKO_OIDC_CLIENT_ID", &c.OIDC.ClientID)
|
||||||
env("MAKO_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
|
env("MAKO_OIDC_CLIENT_SECRET", &c.OIDC.ClientSecret)
|
||||||
|
env("MAKO_KEY", &c.Key)
|
||||||
if v := os.Getenv("MAKO_ALLOWED"); strings.TrimSpace(v) != "" {
|
if v := os.Getenv("MAKO_ALLOWED"); strings.TrimSpace(v) != "" {
|
||||||
c.Allowed = nil
|
c.Allowed = nil
|
||||||
for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) {
|
for _, a := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ';' || r == '\n' }) {
|
||||||
|
|||||||
+29
-7
@@ -1,5 +1,5 @@
|
|||||||
// Package store keeps pages as plain markdown files, one per page, so they stay readable and
|
// Package store keeps pages as markdown files, one per page, encrypted at rest (see package vault)
|
||||||
// portable with or without Mako. An in-memory index makes listing, searching and linking quick.
|
// and exportable as plain markdown. An in-memory index makes listing, searching and linking quick.
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -20,6 +20,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/mako/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters.
|
// MaxBody is the longest page, in bytes. MaxTitle is the longest title, in characters.
|
||||||
@@ -60,6 +62,7 @@ type Note struct {
|
|||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
dir string
|
dir string
|
||||||
|
box *vault.Cipher
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
by map[string]*Note
|
by map[string]*Note
|
||||||
}
|
}
|
||||||
@@ -72,11 +75,16 @@ var (
|
|||||||
// ValidID says whether s can be used as a page id (and so as a file name).
|
// ValidID says whether s can be used as a page id (and so as a file name).
|
||||||
func ValidID(s string) bool { return idRe.MatchString(s) }
|
func ValidID(s string) bool { return idRe.MatchString(s) }
|
||||||
|
|
||||||
// Open reads every page under dir, creating the folders if needed.
|
// Open reads every page under dir, creating the folders if needed. Pages are encrypted with box. A
|
||||||
func Open(dir string) (*Store, error) {
|
// plain markdown file found there is still read, and is saved again encrypted.
|
||||||
s := &Store{dir: dir, by: map[string]*Note{}}
|
func Open(dir string, box *vault.Cipher) (*Store, error) {
|
||||||
|
if box == nil {
|
||||||
|
return nil, errors.New("a notebook needs an encryption key")
|
||||||
|
}
|
||||||
|
s := &Store{dir: dir, box: box, by: map[string]*Note{}}
|
||||||
|
var plain []*Note
|
||||||
for _, sub := range []string{"notes", "trash"} {
|
for _, sub := range []string{"notes", "trash"} {
|
||||||
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||||
@@ -97,12 +105,26 @@ func Open(dir string) (*Store, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
sealed := vault.IsSealed(b)
|
||||||
|
if sealed {
|
||||||
|
if b, err = box.Open(b, id); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w; put the original key back (key in the settings file, or MAKO_KEY)", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
n := parse(id, string(b), info.ModTime())
|
n := parse(id, string(b), info.ModTime())
|
||||||
n.Trashed = sub == "trash"
|
n.Trashed = sub == "trash"
|
||||||
s.by[id] = n
|
s.by[id] = n
|
||||||
|
if !sealed {
|
||||||
|
plain = append(plain, n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.mend()
|
s.mend()
|
||||||
|
for _, n := range plain {
|
||||||
|
if err := s.write(n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +361,7 @@ func isNumber(s string) bool {
|
|||||||
func (s *Store) write(n *Note) error {
|
func (s *Store) write(n *Note) error {
|
||||||
path := s.path(n)
|
path := s.path(n)
|
||||||
tmp := filepath.Join(filepath.Dir(path), "."+n.ID+".tmp")
|
tmp := filepath.Join(filepath.Dir(path), "."+n.ID+".tmp")
|
||||||
if err := os.WriteFile(tmp, format(n), 0o644); err != nil {
|
if err := os.WriteFile(tmp, s.box.Seal(format(n), n.ID), 0o600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.Rename(tmp, path)
|
return os.Rename(tmp, path)
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.veillax.com/Veillax/mako/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func open(t *testing.T) (*Store, string) {
|
func open(t *testing.T) (*Store, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
s, err := Open(dir)
|
s, err := Open(dir, testBox(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -100,7 +102,7 @@ func TestCreateRoundTripsThroughDisk(t *testing.T) {
|
|||||||
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil {
|
if _, err := os.Stat(filepath.Join(dir, "notes", n.ID+".md")); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s2, err := Open(dir)
|
s2, err := Open(dir, testBox(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -126,7 +128,7 @@ func TestPlainMarkdownFileIsAPage(t *testing.T) {
|
|||||||
os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644)
|
os.WriteFile(path, []byte("# Dropped in\r\nby hand #manual\r\n"), 0o644)
|
||||||
mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
|
mod := time.Date(2025, 3, 4, 5, 6, 7, 0, time.UTC)
|
||||||
os.Chtimes(path, mod, mod)
|
os.Chtimes(path, mod, mod)
|
||||||
s2, err := Open(dir)
|
s2, err := Open(dir, testBox(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -209,7 +211,7 @@ func TestRenameRewritesLinks(t *testing.T) {
|
|||||||
if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) {
|
if other, _ := s.Get(b.ID); other.Body != b.Body || !other.Updated.Equal(b.Updated) {
|
||||||
t.Fatal("unrelated pages must not be touched")
|
t.Fatal("unrelated pages must not be touched")
|
||||||
}
|
}
|
||||||
s2, _ := Open(s.dir)
|
s2, _ := Open(s.dir, testBox(t))
|
||||||
if again, _ := s2.Get(a.ID); again.Body != want {
|
if again, _ := s2.Get(a.ID); again.Body != want {
|
||||||
t.Fatal("rewrite was not saved")
|
t.Fatal("rewrite was not saved")
|
||||||
}
|
}
|
||||||
@@ -255,7 +257,7 @@ func TestTreeParentsAndCycles(t *testing.T) {
|
|||||||
os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644)
|
os.WriteFile(filepath.Join(dir, "notes", "x1.md"), []byte("---\nparent: x2\n---\none"), 0o644)
|
||||||
os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644)
|
os.WriteFile(filepath.Join(dir, "notes", "x2.md"), []byte("---\nparent: x1\n---\ntwo"), 0o644)
|
||||||
os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644)
|
os.WriteFile(filepath.Join(dir, "notes", "x3.md"), []byte("---\nparent: ghost\n---\nthree"), 0o644)
|
||||||
s2, err := Open(dir)
|
s2, err := Open(dir, testBox(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -299,7 +301,7 @@ func TestTrashRestorePurge(t *testing.T) {
|
|||||||
if _, err := s.Create(NewNote{Title: "Child"}); err != nil {
|
if _, err := s.Create(NewNote{Title: "Child"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s2, _ := Open(dir)
|
s2, _ := Open(dir, testBox(t))
|
||||||
back, err := s2.Restore(n.ID)
|
back, err := s2.Restore(n.ID)
|
||||||
if err != nil || back.Title != "Child 2" || back.Parent != parent.ID {
|
if err != nil || back.Title != "Child 2" || back.Parent != parent.ID {
|
||||||
t.Fatalf("restore: %v %+v", err, back)
|
t.Fatalf("restore: %v %+v", err, back)
|
||||||
@@ -355,7 +357,7 @@ func TestDaily(t *testing.T) {
|
|||||||
t.Fatalf("a day can be linked by its date: %+v", got)
|
t.Fatalf("a day can be linked by its date: %+v", got)
|
||||||
}
|
}
|
||||||
// reload keeps the title from the date, and an empty day stays out of navigation
|
// reload keeps the title from the date, and an empty day stays out of navigation
|
||||||
s2, _ := Open(s.dir)
|
s2, _ := Open(s.dir, testBox(t))
|
||||||
if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" {
|
if n, _ := s2.Get(d1.ID); n.Title != "2026-10-31" {
|
||||||
t.Fatalf("title after reload: %q", n.Title)
|
t.Fatalf("title after reload: %q", n.Title)
|
||||||
}
|
}
|
||||||
@@ -465,7 +467,7 @@ func TestIDsCannotEscape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, dir := open(t)
|
_, dir := open(t)
|
||||||
os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644)
|
os.WriteFile(filepath.Join(dir, "notes", "Bad Name.md"), []byte("ignored"), 0o644)
|
||||||
s2, _ := Open(dir)
|
s2, _ := Open(dir, testBox(t))
|
||||||
if list, _ := s2.List(Query{}); len(list) != 0 {
|
if list, _ := s2.List(Query{}); len(list) != 0 {
|
||||||
t.Fatal("a file whose name is not a valid id should be ignored")
|
t.Fatal("a file whose name is not a valid id should be ignored")
|
||||||
}
|
}
|
||||||
@@ -502,3 +504,75 @@ func TestZipIsAnObsidianVault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testBox(t *testing.T) *vault.Cipher {
|
||||||
|
t.Helper()
|
||||||
|
b, err := vault.New("0123456789abcdef0123456789abcdef", "https://auth.example.com/", "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilesAreEncrypted(t *testing.T) {
|
||||||
|
s, dir := open(t)
|
||||||
|
n := mk(t, s, "Diary", "meet at the #harbour")
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, "notes", n.ID+".md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !vault.IsSealed(b) || bytes.Contains(b, []byte("harbour")) || bytes.Contains(b, []byte("Diary")) {
|
||||||
|
t.Fatalf("the page is on disk in the clear: %q", b)
|
||||||
|
}
|
||||||
|
// trashing keeps it encrypted, and everything reads back
|
||||||
|
if _, err := s.Trash(n.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ = os.ReadFile(filepath.Join(dir, "trash", n.ID+".md"))
|
||||||
|
if !vault.IsSealed(b) {
|
||||||
|
t.Fatalf("a trashed page is in the clear: %q", b)
|
||||||
|
}
|
||||||
|
s2, err := Open(dir, testBox(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := s2.Get(n.ID); err != nil || got.Body != "meet at the #harbour" || !got.Trashed {
|
||||||
|
t.Fatalf("reload: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlainFilesAreEncryptedWhenOpened(t *testing.T) {
|
||||||
|
_, dir := open(t)
|
||||||
|
path := filepath.Join(dir, "notes", "old-page.md")
|
||||||
|
os.WriteFile(path, []byte("---\ntitle: \"Old\"\n---\n\nwritten before encryption\n"), 0o644)
|
||||||
|
s, err := Open(dir, testBox(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n, err := s.Get("old-page"); err != nil || n.Title != "Old" {
|
||||||
|
t.Fatalf("%+v %v", n, err)
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(path)
|
||||||
|
if !vault.IsSealed(b) || bytes.Contains(b, []byte("before encryption")) {
|
||||||
|
t.Fatalf("still in the clear: %q", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongKeyRefusesInsteadOfLosingPages(t *testing.T) {
|
||||||
|
s, dir := open(t)
|
||||||
|
n := mk(t, s, "Keep", "me")
|
||||||
|
other, _ := vault.New("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "https://auth.example.com/", "alice")
|
||||||
|
if _, err := Open(dir, other); !errors.Is(err, vault.ErrWrongKey) {
|
||||||
|
t.Fatalf("want ErrWrongKey, got %v", err)
|
||||||
|
}
|
||||||
|
// a page file copied over another page's name does not open either
|
||||||
|
src := filepath.Join(dir, "notes", n.ID+".md")
|
||||||
|
b, _ := os.ReadFile(src)
|
||||||
|
os.WriteFile(filepath.Join(dir, "notes", "other-id.md"), b, 0o600)
|
||||||
|
if _, err := Open(dir, testBox(t)); !errors.Is(err, vault.ErrWrongKey) {
|
||||||
|
t.Fatalf("a swapped file opened: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Open(dir, nil); err == nil {
|
||||||
|
t.Fatal("opened with no key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Package vault encrypts pages at rest. Every person gets a key of their own, made from the server's
|
||||||
|
// master key and who they are in Authentik, so one person's files cannot be opened (or swapped in)
|
||||||
|
// with another's key. Who they are is not secret; the master key is what keeps the files private.
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrWrongKey means a file is encrypted but this key cannot open it: the master key was changed or
|
||||||
|
// lost, or the file was altered.
|
||||||
|
var ErrWrongKey = errors.New("these notes were saved with a different encryption key")
|
||||||
|
|
||||||
|
// magic starts every encrypted file, so a plain markdown file can still be told apart and read.
|
||||||
|
var magic = []byte("MAKO1\n")
|
||||||
|
|
||||||
|
// MinKey is the shortest master key accepted, in characters.
|
||||||
|
const MinKey = 32
|
||||||
|
|
||||||
|
// Cipher seals and opens the files of one person.
|
||||||
|
type Cipher struct{ aead cipher.AEAD }
|
||||||
|
|
||||||
|
// New makes the cipher for one person from the master key, the Authentik issuer and their subject.
|
||||||
|
func New(master, issuer, sub string) (*Cipher, error) {
|
||||||
|
if len(master) < MinKey {
|
||||||
|
return nil, fmt.Errorf("the encryption key must be at least %d characters", MinKey)
|
||||||
|
}
|
||||||
|
if sub == "" {
|
||||||
|
return nil, errors.New("no user to make an encryption key for")
|
||||||
|
}
|
||||||
|
key, err := hkdf.Key(sha256.New, []byte(master), nil, "mako notes v1|"+issuer+"|"+sub, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Cipher{aead}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSealed says whether data is an encrypted file.
|
||||||
|
func IsSealed(data []byte) bool { return bytes.HasPrefix(data, magic) }
|
||||||
|
|
||||||
|
// Seal encrypts a file. The name (the page id) is signed into it, so a file copied over another
|
||||||
|
// page's name no longer opens.
|
||||||
|
func (c *Cipher) Seal(plain []byte, name string) []byte {
|
||||||
|
nonce := make([]byte, c.aead.NonceSize())
|
||||||
|
rand.Read(nonce)
|
||||||
|
out := make([]byte, 0, len(magic)+len(nonce)+len(plain)+c.aead.Overhead())
|
||||||
|
out = append(out, magic...)
|
||||||
|
out = append(out, nonce...)
|
||||||
|
return c.aead.Seal(out, nonce, plain, []byte(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open decrypts a file made by Seal.
|
||||||
|
func (c *Cipher) Open(data []byte, name string) ([]byte, error) {
|
||||||
|
n := len(magic) + c.aead.NonceSize()
|
||||||
|
if !IsSealed(data) || len(data) < n+c.aead.Overhead() {
|
||||||
|
return nil, ErrWrongKey
|
||||||
|
}
|
||||||
|
plain, err := c.aead.Open(nil, data[len(magic):n], data[n:], []byte(name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrWrongKey
|
||||||
|
}
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const master = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
func must(t *testing.T, m, iss, sub string) *Cipher {
|
||||||
|
t.Helper()
|
||||||
|
c, err := New(m, iss, sub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoundTrip(t *testing.T) {
|
||||||
|
c := must(t, master, "https://a/", "alice")
|
||||||
|
plain := []byte("---\ntitle: \"Secret\"\n---\n\ndear diary")
|
||||||
|
sealed := c.Seal(plain, "page-1")
|
||||||
|
if !IsSealed(sealed) || bytes.Contains(sealed, []byte("diary")) || bytes.Contains(sealed, []byte("Secret")) {
|
||||||
|
t.Fatalf("not encrypted: %q", sealed)
|
||||||
|
}
|
||||||
|
got, err := c.Open(sealed, "page-1")
|
||||||
|
if err != nil || !bytes.Equal(got, plain) {
|
||||||
|
t.Fatalf("open: %q %v", got, err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(sealed, c.Seal(plain, "page-1")) {
|
||||||
|
t.Fatal("two seals of the same text must differ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongKeyNameOrDamage(t *testing.T) {
|
||||||
|
c := must(t, master, "https://a/", "alice")
|
||||||
|
sealed := c.Seal([]byte("hello"), "p1")
|
||||||
|
|
||||||
|
cases := map[string]*Cipher{
|
||||||
|
"another person": must(t, master, "https://a/", "bob"),
|
||||||
|
"another issuer": must(t, master, "https://b/", "alice"),
|
||||||
|
"another master key": must(t, strings.Repeat("z", 40), "https://a/", "alice"),
|
||||||
|
}
|
||||||
|
for name, other := range cases {
|
||||||
|
if _, err := other.Open(sealed, "p1"); !errors.Is(err, ErrWrongKey) {
|
||||||
|
t.Errorf("%s: want ErrWrongKey, got %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := c.Open(sealed, "p2"); !errors.Is(err, ErrWrongKey) {
|
||||||
|
t.Errorf("a file moved to another page name opened: %v", err)
|
||||||
|
}
|
||||||
|
bad := append([]byte{}, sealed...)
|
||||||
|
bad[len(bad)-1] ^= 1
|
||||||
|
if _, err := c.Open(bad, "p1"); !errors.Is(err, ErrWrongKey) {
|
||||||
|
t.Errorf("a damaged file opened: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := c.Open([]byte("plain text"), "p1"); !errors.Is(err, ErrWrongKey) {
|
||||||
|
t.Errorf("plain text opened: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewChecks(t *testing.T) {
|
||||||
|
if _, err := New("short", "i", "s"); err == nil {
|
||||||
|
t.Error("a short key was accepted")
|
||||||
|
}
|
||||||
|
if _, err := New(master, "i", ""); err == nil {
|
||||||
|
t.Error("an empty user was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-7
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"git.veillax.com/Veillax/mako/internal/config"
|
"git.veillax.com/Veillax/mako/internal/config"
|
||||||
"git.veillax.com/Veillax/mako/internal/oidc"
|
"git.veillax.com/Veillax/mako/internal/oidc"
|
||||||
"git.veillax.com/Veillax/mako/internal/store"
|
"git.veillax.com/Veillax/mako/internal/store"
|
||||||
|
"git.veillax.com/Veillax/mako/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static
|
//go:embed static
|
||||||
@@ -63,16 +65,19 @@ func (s *Server) notebook(c config.Config, sub, name, email string) (*store.Stor
|
|||||||
if st, ok := s.books[key]; ok {
|
if st, ok := s.books[key]; ok {
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
dir := filepath.Join(c.DataDir, "users", key)
|
box, err := vault.New(c.Key, c.OIDC.Issuer, sub)
|
||||||
st, err := store.Open(dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// A note for whoever looks after the server, so a folder can be matched to a person.
|
dir := filepath.Join(c.DataDir, "users", key)
|
||||||
who := fmt.Sprintf("%s <%s>\n", name, email)
|
st, err := store.Open(dir, box)
|
||||||
if b, _ := os.ReadFile(filepath.Join(dir, "owner.txt")); string(b) != who {
|
if err != nil {
|
||||||
os.WriteFile(filepath.Join(dir, "owner.txt"), []byte(who), 0o644)
|
log.Printf("could not open the notebook of %s <%s>: %v", name, email, err)
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Earlier versions left a plain note here naming the owner, which does not belong next to
|
||||||
|
// encrypted pages.
|
||||||
|
os.Remove(filepath.Join(dir, "owner.txt"))
|
||||||
s.books[key] = st
|
s.books[key] = st
|
||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
@@ -142,7 +147,7 @@ func (s *Server) api(fn handlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email)
|
st, err := s.notebook(c, ses.Sub, ses.Name, ses.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(w, http.StatusInternalServerError, "could not open your notes: "+err.Error())
|
fail(w, http.StatusInternalServerError, "could not open your notes; the server's log says why")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
||||||
|
|||||||
@@ -612,3 +612,68 @@ func TestFailCapitalizes(t *testing.T) {
|
|||||||
t.Fatalf("%q", out["error"])
|
t.Fatalf("%q", out["error"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNothingIsOnDiskInTheClear(t *testing.T) {
|
||||||
|
e := setup(t, nil)
|
||||||
|
a := e.signIn(alice)
|
||||||
|
e.do("POST", "/api/notes", map[string]string{"title": "Harbour plans", "body": "meet at midnight"}, a)
|
||||||
|
e.do("POST", "/api/notes", map[string]string{"body": "a quick thought"}, a)
|
||||||
|
files := 0
|
||||||
|
filepath.WalkDir(filepath.Join(e.dir, "data"), func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files++
|
||||||
|
b, _ := os.ReadFile(p)
|
||||||
|
for _, s := range []string{"Harbour", "midnight", "quick thought", "Alice", "alice@"} {
|
||||||
|
if bytes.Contains(b, []byte(s)) {
|
||||||
|
t.Errorf("%s holds %q in the clear", p, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if files < 2 {
|
||||||
|
t.Fatalf("only %d files written", files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyIsMadeOnceAndKept(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "mako.json")
|
||||||
|
h, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !h.NeedsSave() {
|
||||||
|
t.Fatal("a new file must be written")
|
||||||
|
}
|
||||||
|
h.Save()
|
||||||
|
key := h.Get().Key
|
||||||
|
if len(key) < 32 {
|
||||||
|
t.Fatalf("key %q", key)
|
||||||
|
}
|
||||||
|
h2, _ := config.Load(path)
|
||||||
|
if h2.NeedsSave() || h2.Get().Key != key {
|
||||||
|
t.Fatal("the key changed or was made again")
|
||||||
|
}
|
||||||
|
// a file from before encryption has a secret but no key: one is made and must be written
|
||||||
|
os.WriteFile(path, []byte(`{"secret":"old-secret"}`), 0o600)
|
||||||
|
h3, _ := config.Load(path)
|
||||||
|
if !h3.NeedsSave() || len(h3.Get().Key) < 32 {
|
||||||
|
t.Fatal("an old file gets no key")
|
||||||
|
}
|
||||||
|
// a key from the environment wins and is never written to the file
|
||||||
|
t.Setenv("MAKO_KEY", strings.Repeat("k", 40))
|
||||||
|
os.WriteFile(path, []byte(`{"secret":"old-secret"}`), 0o600)
|
||||||
|
h4, _ := config.Load(path)
|
||||||
|
h4.Save()
|
||||||
|
if h4.Get().Key != strings.Repeat("k", 40) {
|
||||||
|
t.Fatal("the environment key was not used")
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(path); strings.Contains(string(b), "kkkk") || strings.Contains(string(b), `"key"`) {
|
||||||
|
t.Fatalf("the environment key was written to the file: %s", b)
|
||||||
|
}
|
||||||
|
t.Setenv("MAKO_KEY", "too short")
|
||||||
|
if err := h4.Get().Check(); err == nil || !strings.Contains(err.Error(), "encryption key") {
|
||||||
|
t.Fatalf("a short key was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user