pull/5130/merge
Yi-Ting Chiu 21 hours ago committed by GitHub
commit 6119ea75ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -106,6 +106,78 @@ Access Memos at `http://localhost:5230` and complete the initial setup.
**Pro Tip**: The data directory stores all your notes, uploads, and settings. Include it in your backup strategy!
### 🔒 Database Encryption (for SQLite)
<details>
Memos can protect its SQLite database with **SQLCipher** so that the on-disk file is unreadable without a passphrase. This is *encryption at rest*: the server keeps the key in memory while running, so it does not provide end-to-end encryption for clients.
> [!IMPORTANT]
> Losing the passphrase means losing your data. Store it safely (for example, in a password manager or a hardware secret vault).
#### Enable SQLCipher Builds
- **Docker (recommended)**
```bash
docker build \
--build-arg CGO_ENABLED=1 \
--build-arg MEMOS_BUILD_TAGS="memos_sqlcipher libsqlite3 sqlite_omit_load_extension" \
-t memos-sqlcipher \
-f scripts/Dockerfile .
docker run -d \
--name memos \
-p 5230:5230 \
-v ~/.memos:/var/opt/memos \
-e MEMOS_SQLITE_ENCRYPTION_KEY="your-super-secret-key" \
memos-sqlcipher
```
- **Manual build**
```bash
CGO_ENABLED=1 \
CGO_CFLAGS="-I/usr/include/sqlcipher -DSQLITE_HAS_CODEC" \
CGO_LDFLAGS="-lsqlcipher" \
go build -tags "memos_sqlcipher libsqlite3 sqlite_omit_load_extension" -o memos-sqlcipher ./bin/memos
./memos-sqlcipher --sqlite-encryption-key "your-super-secret-key" ...
```
#### Migration Plan for Existing Deployments
1. **Full backup**
```bash
cp ~/.memos/memos_prod.db ~/.memos/memos_prod.db.bak
cp ~/.memos/memos_prod.db-wal ~/.memos/memos_prod.db-wal.bak 2>/dev/null || true
cp ~/.memos/memos_prod.db-shm ~/.memos/memos_prod.db-shm.bak 2>/dev/null || true
```
2. **Stop every Memos instance** touching the database.
3. **Build the SQLCipher-capable binary or Docker image** using the instructions above. The resulting image already contains the `sqlcipher` CLI.
4. **Convert the database** using the SQLCipher CLI. You can do this without installing anything on the host:
```bash
docker run --rm \
-v ~/.memos:/data \
memos-sqlcipher \
sh -c "cd /data && sqlcipher memos_prod.db <<'EOS'\nATTACH DATABASE 'memos_encrypted.db' AS encrypted KEY 'your-super-secret-key';\nSELECT sqlcipher_export('encrypted');\nDETACH DATABASE encrypted;\nEOS"
```
If you prefer to run the command directly on the host, install `sqlcipher` (e.g. `brew install sqlcipher`, `apt install sqlcipher`) and execute the same `ATTACH ... sqlcipher_export` sequence locally.
6. **Swap the files**
```bash
mv memos_prod.db memos_prod.db.plaintext
mv memos_encrypted.db memos_prod.db
rm -f memos_prod.db-wal memos_prod.db-shm
```
7. **Start the SQLCipher build of Memos** and pass the same key (`MEMOS_SQLITE_ENCRYPTION_KEY` or `--sqlite-encryption-key`).
8. **Verify the upgrade**
- Log in and ensure your memos/attachments are intact.
- Confirm the file is encrypted: `sqlite3 memos_prod.db '.tables'` should now print `Error: file is not a database`.
</details>
## Sponsors
Memos is made possible by the generous support of our sponsors. Their contributions help ensure the project's continued development, maintenance, and growth.

@ -25,15 +25,16 @@ var (
Short: `An open source, lightweight note-taking service. Easily capture and share your great thoughts.`,
Run: func(_ *cobra.Command, _ []string) {
instanceProfile := &profile.Profile{
Mode: viper.GetString("mode"),
Addr: viper.GetString("addr"),
Port: viper.GetInt("port"),
UNIXSock: viper.GetString("unix-sock"),
Data: viper.GetString("data"),
Driver: viper.GetString("driver"),
DSN: viper.GetString("dsn"),
InstanceURL: viper.GetString("instance-url"),
Version: version.GetCurrentVersion(viper.GetString("mode")),
Mode: viper.GetString("mode"),
Addr: viper.GetString("addr"),
Port: viper.GetInt("port"),
UNIXSock: viper.GetString("unix-sock"),
Data: viper.GetString("data"),
Driver: viper.GetString("driver"),
DSN: viper.GetString("dsn"),
SQLiteEncryptionKey: viper.GetString("sqlite-encryption-key"),
InstanceURL: viper.GetString("instance-url"),
Version: version.GetCurrentVersion(viper.GetString("mode")),
}
if err := instanceProfile.Validate(); err != nil {
panic(err)
@ -100,6 +101,7 @@ func init() {
rootCmd.PersistentFlags().String("data", "", "data directory")
rootCmd.PersistentFlags().String("driver", "sqlite", "database driver")
rootCmd.PersistentFlags().String("dsn", "", "database source name(aka. DSN)")
rootCmd.PersistentFlags().String("sqlite-encryption-key", "", "SQLCipher key used to unlock the SQLite database (requires binary built with memos_sqlcipher)")
rootCmd.PersistentFlags().String("instance-url", "", "the url of your memos instance")
if err := viper.BindPFlag("mode", rootCmd.PersistentFlags().Lookup("mode")); err != nil {
@ -123,6 +125,9 @@ func init() {
if err := viper.BindPFlag("dsn", rootCmd.PersistentFlags().Lookup("dsn")); err != nil {
panic(err)
}
if err := viper.BindPFlag("sqlite-encryption-key", rootCmd.PersistentFlags().Lookup("sqlite-encryption-key")); err != nil {
panic(err)
}
if err := viper.BindPFlag("instance-url", rootCmd.PersistentFlags().Lookup("instance-url")); err != nil {
panic(err)
}
@ -132,6 +137,9 @@ func init() {
if err := viper.BindEnv("instance-url", "MEMOS_INSTANCE_URL"); err != nil {
panic(err)
}
if err := viper.BindEnv("sqlite-encryption-key", "MEMOS_SQLITE_ENCRYPTION_KEY"); err != nil {
panic(err)
}
}
func printGreetings(profile *profile.Profile) {

@ -19,6 +19,7 @@ require (
github.com/labstack/echo/v4 v4.13.4
github.com/lib/pq v1.10.9
github.com/lithammer/shortuuid/v4 v4.2.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1

@ -285,6 +285,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=

@ -28,6 +28,8 @@ type Profile struct {
// Driver is the database driver
// sqlite, mysql
Driver string
// SQLiteEncryptionKey unlocks SQLCipher-protected SQLite databases when provided.
SQLiteEncryptionKey string
// Version is the current version of server
Version string
// InstanceURL is the url of your memos instance.
@ -88,5 +90,9 @@ func (p *Profile) Validate() error {
p.DSN = filepath.Join(dataDir, dbFile)
}
if p.SQLiteEncryptionKey != "" && p.Driver != "sqlite" {
return errors.New("sqlite encryption key is only supported when using the sqlite driver")
}
return nil
}

@ -1,4 +1,12 @@
FROM golang:1.25-alpine AS backend
ARG MEMOS_BUILD_TAGS=""
ARG CGO_ENABLED=0
ARG CGO_CFLAGS=""
ARG CGO_LDFLAGS=""
ENV CGO_ENABLED=${CGO_ENABLED}
ENV MEMOS_BUILD_TAGS=${MEMOS_BUILD_TAGS}
ENV CGO_CFLAGS=${CGO_CFLAGS}
ENV CGO_LDFLAGS=${CGO_LDFLAGS}
WORKDIR /backend-build
COPY go.mod go.sum ./
RUN go mod download
@ -7,13 +15,50 @@ COPY . .
# Refer to `pnpm release` in package.json for the build command.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -ldflags="-s -w" -o memos ./bin/memos/main.go
/bin/sh -eux <<'EOF'
if [ "${CGO_ENABLED}" = "1" ]; then
apk add --no-cache --virtual .build-deps build-base pkgconf
if printf "%s" "${MEMOS_BUILD_TAGS}" | grep -q "memos_sqlcipher"; then
apk add --no-cache --virtual .sqlcipher-build sqlcipher-dev
SQLCIPHER_CFLAGS="$(pkg-config --cflags sqlcipher)"
SQLCIPHER_LDFLAGS="$(pkg-config --libs sqlcipher)"
if [ ! -e /usr/lib/libsqlite3.so ]; then
ln -s /usr/lib/libsqlcipher.so /usr/lib/libsqlite3.so
fi
if [ -z "${CGO_CFLAGS}" ]; then
export CGO_CFLAGS="${SQLCIPHER_CFLAGS} -DSQLITE_HAS_CODEC"
else
export CGO_CFLAGS="${CGO_CFLAGS} ${SQLCIPHER_CFLAGS} -DSQLITE_HAS_CODEC"
fi
if [ -z "${CGO_LDFLAGS}" ]; then
export CGO_LDFLAGS="${SQLCIPHER_LDFLAGS}"
else
export CGO_LDFLAGS="${CGO_LDFLAGS} ${SQLCIPHER_LDFLAGS}"
fi
fi
fi
go build -ldflags="-s -w" -tags="${MEMOS_BUILD_TAGS}" -o memos ./bin/memos/main.go
if [ "${CGO_ENABLED}" = "1" ]; then
if apk info -e .sqlcipher-build >/dev/null 2>&1; then
apk del .sqlcipher-build
fi
apk del .build-deps
fi
EOF
# Make workspace with above generated files.
FROM alpine:latest AS monolithic
ARG MEMOS_BUILD_TAGS=""
WORKDIR /usr/local/memos
RUN apk add --no-cache tzdata
RUN if printf "%s" "$MEMOS_BUILD_TAGS" | grep -q "memos_sqlcipher"; then \
apk add --no-cache sqlcipher sqlcipher-libs && \
if [ -e /usr/lib/libsqlcipher.so ]; then ln -sf /usr/lib/libsqlcipher.so /usr/lib/libsqlite3.so; fi && \
if [ -e /usr/lib/libsqlcipher.so.0 ]; then ln -sf /usr/lib/libsqlcipher.so.0 /usr/lib/libsqlcipher.so; fi; \
fi
ENV TZ="UTC"
COPY --from=backend /backend-build/memos /usr/local/memos/

@ -0,0 +1,32 @@
//go:build !memos_sqlcipher
package sqlite
import (
"database/sql"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/profile"
// Import the pure-Go SQLite driver.
_ "modernc.org/sqlite"
)
func openSQLiteDB(profile *profile.Profile) (*sql.DB, error) {
if profile.SQLiteEncryptionKey != "" {
return nil, errors.New("sqlite encryption key provided but binary is not built with SQLCipher support; rebuild with -tags memos_sqlcipher")
}
sqliteDB, err := sql.Open(sqliteModernDriver, profile.DSN)
if err != nil {
return nil, errors.Wrapf(err, "failed to open db with dsn: %s", profile.DSN)
}
if err := configureSQLiteConnection(sqliteDB); err != nil {
sqliteDB.Close()
return nil, err
}
return sqliteDB, nil
}

@ -0,0 +1,49 @@
//go:build memos_sqlcipher
package sqlite
import (
"database/sql"
"fmt"
"strings"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/profile"
// Import the CGO-backed SQLCipher-compatible SQLite driver.
_ "github.com/mattn/go-sqlite3"
)
func openSQLiteDB(profile *profile.Profile) (*sql.DB, error) {
sqliteDB, err := sql.Open(sqliteCipherDriver, profile.DSN)
if err != nil {
return nil, errors.Wrapf(err, "failed to open db with dsn: %s", profile.DSN)
}
if err := applySQLiteEncryptionKey(sqliteDB, profile.SQLiteEncryptionKey); err != nil {
sqliteDB.Close()
return nil, err
}
if err := configureSQLiteConnection(sqliteDB); err != nil {
sqliteDB.Close()
return nil, err
}
return sqliteDB, nil
}
func applySQLiteEncryptionKey(db *sql.DB, key string) error {
if key == "" {
return nil
}
escapedKey := strings.ReplaceAll(key, "'", "''")
pragma := fmt.Sprintf("PRAGMA key = '%s'", escapedKey)
if _, err := db.Exec(pragma); err != nil {
return errors.Wrap(err, "failed to apply sqlite encryption key; verify the binary is linked against SQLCipher")
}
return nil
}

@ -3,12 +3,10 @@ package sqlite
import (
"context"
"database/sql"
"fmt"
"github.com/pkg/errors"
// Import the SQLite driver.
_ "modernc.org/sqlite"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/store"
)
@ -21,29 +19,21 @@ type DB struct {
// NewDB opens a database specified by its database driver name and a
// driver-specific data source name, usually consisting of at least a
// database name and connection information.
const (
sqliteBusyTimeout = 10000
sqliteModernDriver = "sqlite"
sqliteCipherDriver = "sqlite3"
)
func NewDB(profile *profile.Profile) (store.Driver, error) {
// Ensure a DSN is set before attempting to open the database.
if profile.DSN == "" {
return nil, errors.New("dsn required")
}
// Connect to the database with some sane settings:
// - No shared-cache: it's obsolete; WAL journal mode is a better solution.
// - No foreign key constraints: it's currently disabled by default, but it's a
// good practice to be explicit and prevent future surprises on SQLite upgrades.
// - Journal mode set to WAL: it's the recommended journal mode for most applications
// as it prevents locking issues.
//
// Notes:
// - When using the `modernc.org/sqlite` driver, each pragma must be prefixed with `_pragma=`.
//
// References:
// - https://pkg.go.dev/modernc.org/sqlite#Driver.Open
// - https://www.sqlite.org/sharedcache.html
// - https://www.sqlite.org/pragma.html
sqliteDB, err := sql.Open("sqlite", profile.DSN+"?_pragma=foreign_keys(0)&_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)")
sqliteDB, err := openSQLiteDB(profile)
if err != nil {
return nil, errors.Wrapf(err, "failed to open db with dsn: %s", profile.DSN)
return nil, err
}
driver := DB{db: sqliteDB, profile: profile}
@ -51,6 +41,20 @@ func NewDB(profile *profile.Profile) (store.Driver, error) {
return &driver, nil
}
func configureSQLiteConnection(db *sql.DB) error {
pragmas := []string{
"PRAGMA foreign_keys = OFF",
fmt.Sprintf("PRAGMA busy_timeout = %d", sqliteBusyTimeout),
"PRAGMA journal_mode = WAL",
}
for _, pragma := range pragmas {
if _, err := db.Exec(pragma); err != nil {
return errors.Wrapf(err, "failed to execute %s", pragma)
}
}
return nil
}
func (d *DB) GetDB() *sql.DB {
return d.db
}

Loading…
Cancel
Save