mirror of https://github.com/usememos/memos.git
Merge 1bc5336084 into 228cc6105d
This commit is contained in:
commit
68ff0c2aff
72
README.md
72
README.md
|
|
@ -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) {
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -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
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -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…
Reference in New Issue