multidb
This commit is contained in:
+29
-3
@@ -1,14 +1,40 @@
|
|||||||
# Serwer deweloperski
|
# Application
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
|
|
||||||
# SQLite
|
# Port exposed by Docker Compose
|
||||||
|
RUSTPAD_PORT=8200
|
||||||
|
|
||||||
|
# Database
|
||||||
|
|
||||||
|
# SQLite — default
|
||||||
DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc
|
DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
# DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
# DATABASE_URL=mysql://rustpad:rustpad@mysql:3306/rustpad
|
||||||
|
|
||||||
DATABASE_MAX_CONNECTIONS=8
|
DATABASE_MAX_CONNECTIONS=8
|
||||||
|
|
||||||
# Assety i logowanie
|
# Static assets
|
||||||
STATIC_DIR=static
|
STATIC_DIR=static
|
||||||
ASSET_VERSION=0.0.1
|
ASSET_VERSION=0.0.1
|
||||||
|
|
||||||
|
# Logging
|
||||||
RUST_LOG=rustpad=debug,tower_http=info
|
RUST_LOG=rustpad=debug,tower_http=info
|
||||||
|
|
||||||
|
# Maximum upload size
|
||||||
UPLOAD_MAX_SIZE_MB=20
|
UPLOAD_MAX_SIZE_MB=20
|
||||||
|
|
||||||
|
# Optional PostgreSQL container configuration
|
||||||
|
POSTGRES_DB=rustpad
|
||||||
|
POSTGRES_USER=rustpad
|
||||||
|
POSTGRES_PASSWORD=rustpad
|
||||||
|
|
||||||
|
# Optional MySQL container configuration
|
||||||
|
MYSQL_DATABASE=rustpad
|
||||||
|
MYSQL_USER=rustpad
|
||||||
|
MYSQL_PASSWORD=rustpad
|
||||||
|
MYSQL_ROOT_PASSWORD=rustpad_root
|
||||||
@@ -6,4 +6,6 @@ rustpad.db-wal
|
|||||||
.env.docker
|
.env.docker
|
||||||
*.log
|
*.log
|
||||||
data/db/.db*
|
data/db/.db*
|
||||||
|
data/db/*/*
|
||||||
data/files/*
|
data/files/*
|
||||||
|
*.zip
|
||||||
+2
-2
@@ -3,7 +3,7 @@ name = "rustpad"
|
|||||||
version = "0.0.1-dev"
|
version = "0.0.1-dev"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite"
|
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -17,7 +17,7 @@ rand_core = { version = "0.6", features = ["getrandom"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
slug = "0.1"
|
slug = "0.1"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "any", "sqlite", "postgres", "mysql", "chrono", "migrate"] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync", "signal"] }
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ RUN apt-get update \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/target/release/rustpad /usr/local/bin/rustpad
|
COPY --from=builder /app/target/release/rustpad /usr/local/bin/rustpad
|
||||||
COPY --from=builder /app/static ./static
|
COPY --from=builder /app/static ./static
|
||||||
|
COPY --from=builder /app/migrations ./migrations
|
||||||
|
|
||||||
USER rustpad
|
USER rustpad
|
||||||
ENV APP_HOST=0.0.0.0 \
|
ENV APP_HOST=0.0.0.0 \
|
||||||
|
|||||||
@@ -36,3 +36,27 @@ Use the **Page** button in the editor. RustPad creates a permanent public `/s/<t
|
|||||||
## Limit uploadu
|
## Limit uploadu
|
||||||
|
|
||||||
The maximum size of a single file is configured with `UPLOAD_MAX_SIZE_MB` w `.env`, np. `UPLOAD_MAX_SIZE_MB=50`. The default is 20 MB. After changing it, restart the project with `./dev.sh`.
|
The maximum size of a single file is configured with `UPLOAD_MAX_SIZE_MB` w `.env`, np. `UPLOAD_MAX_SIZE_MB=50`. The default is 20 MB. After changing it, restart the project with `./dev.sh`.
|
||||||
|
|
||||||
|
## Wybór bazy danych
|
||||||
|
|
||||||
|
RustPad wybiera silnik na podstawie `DATABASE_URL`:
|
||||||
|
|
||||||
|
- SQLite: `sqlite:///data/db/rustpad.db?mode=rwc&journal_mode=WAL&busy_timeout=5000`
|
||||||
|
- PostgreSQL: `postgres://rustpad:rustpad@postgres:5432/rustpad`
|
||||||
|
- MySQL: `mysql://rustpad:rustpad@mysql:3306/rustpad`
|
||||||
|
|
||||||
|
SQLite pozostaje domyślną bazą dla developmentu i małych instalacji. Tryb WAL pozwala czytać podczas zapisu, ale SQLite nadal wykonuje tylko jeden zapis naraz. `busy_timeout=5000` powoduje krótkie oczekiwanie zamiast natychmiastowego błędu `database is locked`. Przy wielu równoczesnych edytorach lub wielu instancjach aplikacji zalecany jest PostgreSQL albo MySQL.
|
||||||
|
|
||||||
|
Opcjonalne bazy w Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PostgreSQL
|
||||||
|
docker compose --profile postgres up -d postgres
|
||||||
|
DATABASE_URL=postgres://rustpad:rustpad@postgres:5432/rustpad docker compose up -d rustpad
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
docker compose --profile mysql up -d mysql
|
||||||
|
DATABASE_URL=mysql://rustpad:rustpad@mysql:3306/rustpad docker compose up -d rustpad
|
||||||
|
```
|
||||||
|
|
||||||
|
Migracje są rozdzielone w `migrations/sqlite`, `migrations/postgres` i `migrations/mysql`. Zapytania aplikacji znajdują się centralnie w `src/queries.rs`, a `src/database.rs` odpowiada za wybór sterownika i konfigurację połączenia.
|
||||||
|
|||||||
+35
-1
@@ -8,7 +8,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_HOST: ${APP_HOST:-0.0.0.0}
|
APP_HOST: ${APP_HOST:-0.0.0.0}
|
||||||
APP_PORT: ${APP_PORT:-3000}
|
APP_PORT: ${APP_PORT:-3000}
|
||||||
DATABASE_URL: ${DATABASE_URL:-sqlite:///data/db/rustpad.db?mode=rwc}
|
DATABASE_URL: ${DATABASE_URL:-sqlite:///data/db/rustpad.db?mode=rwc&journal_mode=WAL&busy_timeout=5000}
|
||||||
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8}
|
DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8}
|
||||||
STATIC_DIR: ${STATIC_DIR:-/app/static}
|
STATIC_DIR: ${STATIC_DIR:-/app/static}
|
||||||
FILES_DIR: ${FILES_DIR:-/data/files}
|
FILES_DIR: ${FILES_DIR:-/data/files}
|
||||||
@@ -19,3 +19,37 @@ services:
|
|||||||
- "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}"
|
- "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18
|
||||||
|
profiles: ["pgsql"]
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-rustpad}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-rustpad}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-rustpad}
|
||||||
|
volumes:
|
||||||
|
- ./data/db/pgsql:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rustpad} -d ${POSTGRES_DB:-rustpad}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
profiles: ["mysql"]
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-rustpad}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-rustpad}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-rustpad}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
|
||||||
|
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
|
||||||
|
volumes:
|
||||||
|
- ./data/db/mysql:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-root}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS workspaces (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
slug TEXT NOT NULL UNIQUE,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
password_hash TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS notes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
workspace_id INTEGER NOT NULL,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
content TEXT NOT NULL DEFAULT '',
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE(workspace_id, slug)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS note_revisions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
note_id INTEGER NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_notes_workspace ON notes(workspace_id, updated_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_note_revisions_note ON note_revisions(note_id, id DESC);
|
|
||||||
|
|
||||||
-- Preserve data from version 0.4.x: the old note becomes a workspace with one note.
|
|
||||||
INSERT OR IGNORE INTO workspaces (id, slug, title, password_hash, created_at, updated_at)
|
|
||||||
SELECT id, slug, title, password_hash, created_at, updated_at FROM pads;
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO notes (id, workspace_id, slug, title, content, created_at, updated_at)
|
|
||||||
SELECT id, id, 'notatka', title, content, created_at, updated_at FROM pads;
|
|
||||||
|
|
||||||
INSERT OR IGNORE INTO note_revisions (id, note_id, content, created_at)
|
|
||||||
SELECT id, pad_id, content, created_at FROM revisions;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
ALTER TABLE notes ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
|
|
||||||
ALTER TABLE note_revisions ADD COLUMN author TEXT;
|
|
||||||
ALTER TABLE note_revisions ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
|
|
||||||
ALTER TABLE revisions ADD COLUMN author TEXT;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE pads ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
|
|
||||||
ALTER TABLE revisions ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]';
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE published_pages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
token TEXT NOT NULL UNIQUE,
|
|
||||||
pad_id INTEGER UNIQUE REFERENCES pads(id) ON DELETE CASCADE,
|
|
||||||
note_id INTEGER UNIQUE REFERENCES notes(id) ON DELETE CASCADE,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL))
|
|
||||||
);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
ALTER TABLE pads ADD COLUMN file_token TEXT;
|
|
||||||
ALTER TABLE notes ADD COLUMN file_token TEXT;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_pads_file_token ON pads(file_token) WHERE file_token IS NOT NULL;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_file_token ON notes(file_token) WHERE file_token IS NOT NULL;
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE pads (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(255) NOT NULL UNIQUE, title TEXT NOT NULL, content LONGTEXT NOT NULL DEFAULT (''), password_hash TEXT,
|
||||||
|
created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), updated_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), owner_map LONGTEXT NOT NULL DEFAULT ('[]'), file_token VARCHAR(255) UNIQUE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
CREATE TABLE revisions (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, pad_id BIGINT NOT NULL, content LONGTEXT NOT NULL, created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), author TEXT, owner_map LONGTEXT NOT NULL DEFAULT ('[]'),
|
||||||
|
CONSTRAINT fk_revisions_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE, INDEX idx_revisions_pad_id (pad_id, id DESC)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
CREATE TABLE workspaces (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(255) NOT NULL UNIQUE, title TEXT NOT NULL, password_hash TEXT,
|
||||||
|
created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), updated_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, workspace_id BIGINT NOT NULL, slug VARCHAR(255) NOT NULL, title TEXT NOT NULL, content LONGTEXT NOT NULL DEFAULT (''),
|
||||||
|
created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), updated_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), owner_map LONGTEXT NOT NULL DEFAULT ('[]'), file_token VARCHAR(255) UNIQUE,
|
||||||
|
CONSTRAINT fk_notes_workspace FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, UNIQUE KEY uq_notes_workspace_slug (workspace_id, slug), INDEX idx_notes_workspace (workspace_id, updated_at DESC)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
CREATE TABLE note_revisions (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, note_id BIGINT NOT NULL, content LONGTEXT NOT NULL, created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP), author TEXT, owner_map LONGTEXT NOT NULL DEFAULT ('[]'),
|
||||||
|
CONSTRAINT fk_note_revisions_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, INDEX idx_note_revisions_note (note_id, id DESC)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
CREATE TABLE published_pages (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, token VARCHAR(255) NOT NULL UNIQUE, pad_id BIGINT UNIQUE, note_id BIGINT UNIQUE, created_at VARCHAR(64) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT fk_published_pad FOREIGN KEY (pad_id) REFERENCES pads(id) ON DELETE CASCADE, CONSTRAINT fk_published_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||||
|
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL))
|
||||||
|
) ENGINE=InnoDB;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE pads (
|
||||||
|
id BIGSERIAL PRIMARY KEY, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', password_hash TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), owner_map TEXT NOT NULL DEFAULT '[]', file_token TEXT UNIQUE
|
||||||
|
);
|
||||||
|
CREATE TABLE revisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY, pad_id BIGINT NOT NULL REFERENCES pads(id) ON DELETE CASCADE, content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), author TEXT, owner_map TEXT NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_revisions_pad_id ON revisions(pad_id, id DESC);
|
||||||
|
CREATE TABLE workspaces (
|
||||||
|
id BIGSERIAL PRIMARY KEY, slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, password_hash TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text)
|
||||||
|
);
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id BIGSERIAL PRIMARY KEY, workspace_id BIGINT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, slug TEXT NOT NULL, title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
|
||||||
|
owner_map TEXT NOT NULL DEFAULT '[]', file_token TEXT UNIQUE, UNIQUE(workspace_id, slug)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_notes_workspace ON notes(workspace_id, updated_at DESC);
|
||||||
|
CREATE TABLE note_revisions (
|
||||||
|
id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), author TEXT, owner_map TEXT NOT NULL DEFAULT '[]'
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_note_revisions_note ON note_revisions(note_id, id DESC);
|
||||||
|
CREATE TABLE published_pages (
|
||||||
|
id BIGSERIAL PRIMARY KEY, token TEXT NOT NULL UNIQUE, pad_id BIGINT UNIQUE REFERENCES pads(id) ON DELETE CASCADE,
|
||||||
|
note_id BIGINT UNIQUE REFERENCES notes(id) ON DELETE CASCADE, created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
|
||||||
|
CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL))
|
||||||
|
);
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,43 @@
|
|||||||
|
use sqlx::{any::AnyPoolOptions, AnyPool};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DatabaseKind {
|
||||||
|
Sqlite,
|
||||||
|
Postgres,
|
||||||
|
MySql,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Database {
|
||||||
|
pool: AnyPool,
|
||||||
|
kind: DatabaseKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub async fn connect(url: &str, max_connections: u32) -> Result<Self, sqlx::Error> {
|
||||||
|
sqlx::any::install_default_drivers();
|
||||||
|
let kind = DatabaseKind::from_url(url)?;
|
||||||
|
let pool = AnyPoolOptions::new()
|
||||||
|
.max_connections(max_connections)
|
||||||
|
.connect(url)
|
||||||
|
.await?;
|
||||||
|
if kind == DatabaseKind::Sqlite {
|
||||||
|
sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await?;
|
||||||
|
sqlx::query("PRAGMA journal_mode = WAL").execute(&pool).await?;
|
||||||
|
sqlx::query("PRAGMA busy_timeout = 5000").execute(&pool).await?;
|
||||||
|
}
|
||||||
|
Ok(Self { pool, kind })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pool(&self) -> &AnyPool { &self.pool }
|
||||||
|
pub fn kind(&self) -> DatabaseKind { self.kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseKind {
|
||||||
|
fn from_url(url: &str) -> Result<Self, sqlx::Error> {
|
||||||
|
if url.starts_with("sqlite:") { Ok(Self::Sqlite) }
|
||||||
|
else if url.starts_with("postgres:") || url.starts_with("postgresql:") { Ok(Self::Postgres) }
|
||||||
|
else if url.starts_with("mysql:") { Ok(Self::MySql) }
|
||||||
|
else { Err(sqlx::Error::Configuration("DATABASE_URL must use sqlite://, postgres:// or mysql://".into())) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,23 @@ use argon2::{
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use rand_core::{OsRng, RngCore};
|
use rand_core::{OsRng, RngCore};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::{FromRow, SqlitePool};
|
use sqlx::FromRow;
|
||||||
|
use crate::{database::{Database, DatabaseKind}, queries};
|
||||||
|
use sqlx::{Any, Transaction};
|
||||||
|
|
||||||
|
|
||||||
|
async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result<i64, sqlx::Error> {
|
||||||
|
let query = match kind {
|
||||||
|
DatabaseKind::Sqlite => "SELECT last_insert_rowid()",
|
||||||
|
DatabaseKind::MySql => "SELECT LAST_INSERT_ID()",
|
||||||
|
DatabaseKind::Postgres => match table {
|
||||||
|
"note_revisions" => "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))",
|
||||||
|
"revisions" => "SELECT currval(pg_get_serial_sequence('revisions', 'id'))",
|
||||||
|
_ => unreachable!("unsupported identity table"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
sqlx::query_scalar(query).fetch_one(&mut **tx).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
@@ -39,36 +55,30 @@ pub struct Revision {
|
|||||||
pub owner_map: String,
|
pub owner_map: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
pub async fn find_workspace(pool: &Database, slug: &str) -> Result<Option<Workspace>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Workspace>(
|
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
|
||||||
"SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?",
|
|
||||||
)
|
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_workspace(
|
pub async fn create_workspace(
|
||||||
pool: &SqlitePool,
|
pool: &Database,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
) -> Result<Workspace, sqlx::Error> {
|
) -> Result<Workspace, sqlx::Error> {
|
||||||
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
||||||
let result = sqlx::query(
|
sqlx::query(queries::get(pool.kind(), queries::Q002))
|
||||||
"INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
.execute(pool)
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query_as::<_, Workspace>(
|
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
|
||||||
"SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE id = ?",
|
.bind(slug)
|
||||||
)
|
.fetch_one(pool.pool())
|
||||||
.bind(result.last_insert_rowid())
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,92 +97,84 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_notes(pool: &SqlitePool, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
|
pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result<Vec<Note>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Note>(
|
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003))
|
||||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC",
|
|
||||||
)
|
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_note(
|
pub async fn find_note(
|
||||||
pool: &SqlitePool,
|
pool: &Database,
|
||||||
workspace_id: i64,
|
workspace_id: i64,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
) -> Result<Option<Note>, sqlx::Error> {
|
) -> Result<Option<Note>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Note>(
|
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004))
|
||||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?",
|
|
||||||
)
|
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_note(
|
pub async fn create_note(
|
||||||
pool: &SqlitePool,
|
pool: &Database,
|
||||||
workspace_id: i64,
|
workspace_id: i64,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
) -> Result<Note, sqlx::Error> {
|
) -> Result<Note, sqlx::Error> {
|
||||||
let result = sqlx::query(
|
sqlx::query(queries::get(pool.kind(), queries::Q005))
|
||||||
"INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.execute(pool)
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query_as::<_, Note>(
|
sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004))
|
||||||
"SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE id = ?",
|
.bind(workspace_id)
|
||||||
)
|
.bind(slug)
|
||||||
.bind(result.last_insert_rowid())
|
.fetch_one(pool.pool())
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_revision(
|
pub async fn save_revision(
|
||||||
pool: &SqlitePool,
|
pool: &Database,
|
||||||
note_id: i64,
|
note_id: i64,
|
||||||
workspace_id: i64,
|
workspace_id: i64,
|
||||||
content: &str,
|
content: &str,
|
||||||
author: Option<&str>,
|
author: Option<&str>,
|
||||||
owner_map: &str,
|
owner_map: &str,
|
||||||
) -> Result<(i64, String), sqlx::Error> {
|
) -> Result<(i64, String), sqlx::Error> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.pool().begin().await?;
|
||||||
sqlx::query("UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
sqlx::query(queries::get(pool.kind(), queries::Q006))
|
||||||
.bind(content)
|
.bind(content)
|
||||||
.bind(owner_map)
|
.bind(owner_map)
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
sqlx::query(queries::get(pool.kind(), queries::Q007))
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let result = sqlx::query("INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)")
|
sqlx::query(queries::get(pool.kind(), queries::Q008))
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.bind(content)
|
.bind(content)
|
||||||
.bind(author)
|
.bind(author)
|
||||||
.bind(owner_map)
|
.bind(owner_map)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?")
|
let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?;
|
||||||
|
let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009))
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok((result.last_insert_rowid(), updated_at))
|
Ok((revision_id, updated_at))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_revisions(pool: &SqlitePool, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
|
pub async fn list_revisions(pool: &Database, note_id: i64) -> Result<Vec<Revision>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Revision>(
|
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010))
|
||||||
"SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100",
|
|
||||||
)
|
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,36 +215,30 @@ pub struct Pad {
|
|||||||
pub owner_map: String,
|
pub owner_map: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_pad(pool: &SqlitePool, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
pub async fn find_pad(pool: &Database, slug: &str) -> Result<Option<Pad>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Pad>(
|
sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011))
|
||||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?",
|
|
||||||
)
|
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_pad(
|
pub async fn create_pad(
|
||||||
pool: &SqlitePool,
|
pool: &Database,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
) -> Result<Pad, sqlx::Error> {
|
) -> Result<Pad, sqlx::Error> {
|
||||||
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
let password_hash = password.filter(|value| !value.is_empty()).map(hash_password);
|
||||||
let result = sqlx::query(
|
sqlx::query(queries::get(pool.kind(), queries::Q012))
|
||||||
"INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)",
|
|
||||||
)
|
|
||||||
.bind(slug)
|
.bind(slug)
|
||||||
.bind(title)
|
.bind(title)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
.execute(pool)
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query_as::<_, Pad>(
|
sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011))
|
||||||
"SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE id = ?",
|
.bind(slug)
|
||||||
)
|
.fetch_one(pool.pool())
|
||||||
.bind(result.last_insert_rowid())
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,43 +258,42 @@ pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_pad_revision(
|
pub async fn save_pad_revision(
|
||||||
pool: &SqlitePool,
|
pool: &Database,
|
||||||
pad_id: i64,
|
pad_id: i64,
|
||||||
content: &str,
|
content: &str,
|
||||||
author: Option<&str>,
|
author: Option<&str>,
|
||||||
owner_map: &str,
|
owner_map: &str,
|
||||||
) -> Result<(i64, String), sqlx::Error> {
|
) -> Result<(i64, String), sqlx::Error> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.pool().begin().await?;
|
||||||
sqlx::query("UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
sqlx::query(queries::get(pool.kind(), queries::Q013))
|
||||||
.bind(content)
|
.bind(content)
|
||||||
.bind(owner_map)
|
.bind(owner_map)
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let result = sqlx::query("INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)")
|
sqlx::query(queries::get(pool.kind(), queries::Q014))
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.bind(content)
|
.bind(content)
|
||||||
.bind(author)
|
.bind(author)
|
||||||
.bind(owner_map)
|
.bind(owner_map)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?")
|
let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?;
|
||||||
|
let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015))
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.fetch_one(&mut *tx)
|
.fetch_one(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
Ok((result.last_insert_rowid(), updated_at))
|
Ok((revision_id, updated_at))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_pad_revisions(
|
pub async fn list_pad_revisions(
|
||||||
pool: &SqlitePool,
|
pool: &Database,
|
||||||
pad_id: i64,
|
pad_id: i64,
|
||||||
) -> Result<Vec<Revision>, sqlx::Error> {
|
) -> Result<Vec<Revision>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Revision>(
|
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016))
|
||||||
"SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100",
|
|
||||||
)
|
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,90 +305,88 @@ pub struct PublishedPage {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn publish_pad(pool: &SqlitePool, pad_id: i64) -> Result<String, sqlx::Error> {
|
pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
|
||||||
if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE pad_id = ?")
|
if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017))
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool.pool())
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(token);
|
return Ok(token);
|
||||||
}
|
}
|
||||||
let token = random_suffix(18);
|
let token = random_suffix(18);
|
||||||
sqlx::query("INSERT INTO published_pages (token, pad_id) VALUES (?, ?)")
|
sqlx::query(queries::get(pool.kind(), queries::Q018))
|
||||||
.bind(&token)
|
.bind(&token)
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.execute(pool)
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn publish_note(pool: &SqlitePool, note_id: i64) -> Result<String, sqlx::Error> {
|
pub async fn publish_note(pool: &Database, note_id: i64) -> Result<String, sqlx::Error> {
|
||||||
if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE note_id = ?")
|
if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019))
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool.pool())
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(token);
|
return Ok(token);
|
||||||
}
|
}
|
||||||
let token = random_suffix(18);
|
let token = random_suffix(18);
|
||||||
sqlx::query("INSERT INTO published_pages (token, note_id) VALUES (?, ?)")
|
sqlx::query(queries::get(pool.kind(), queries::Q020))
|
||||||
.bind(&token)
|
.bind(&token)
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.execute(pool)
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_published_page(pool: &SqlitePool, token: &str) -> Result<Option<PublishedPage>, sqlx::Error> {
|
pub async fn find_published_page(pool: &Database, token: &str) -> Result<Option<PublishedPage>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, PublishedPage>(
|
sqlx::query_as::<_, PublishedPage>(queries::get(pool.kind(), queries::Q021))
|
||||||
"SELECT pp.token, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?",
|
|
||||||
)
|
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn pad_file_token(pool: &SqlitePool, pad_id: i64) -> Result<String, sqlx::Error> {
|
pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result<String, sqlx::Error> {
|
||||||
if let Some(token) = sqlx::query_scalar::<_, Option<String>>("SELECT file_token FROM pads WHERE id = ?")
|
if let Some(token) = sqlx::query_scalar::<_, Option<String>>(queries::get(pool.kind(), queries::Q022))
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool.pool())
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(token);
|
return Ok(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = format!("p_{}", random_suffix(24));
|
let token = format!("p_{}", random_suffix(24));
|
||||||
sqlx::query("UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL")
|
sqlx::query(queries::get(pool.kind(), queries::Q023))
|
||||||
.bind(&token)
|
.bind(&token)
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.execute(pool)
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query_scalar::<_, String>("SELECT file_token FROM pads WHERE id = ?")
|
sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q022))
|
||||||
.bind(pad_id)
|
.bind(pad_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn note_file_token(pool: &SqlitePool, note_id: i64) -> Result<String, sqlx::Error> {
|
pub async fn note_file_token(pool: &Database, note_id: i64) -> Result<String, sqlx::Error> {
|
||||||
if let Some(token) = sqlx::query_scalar::<_, Option<String>>("SELECT file_token FROM notes WHERE id = ?")
|
if let Some(token) = sqlx::query_scalar::<_, Option<String>>(queries::get(pool.kind(), queries::Q024))
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool.pool())
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(token);
|
return Ok(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = format!("n_{}", random_suffix(24));
|
let token = format!("n_{}", random_suffix(24));
|
||||||
sqlx::query("UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL")
|
sqlx::query(queries::get(pool.kind(), queries::Q025))
|
||||||
.bind(&token)
|
.bind(&token)
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.execute(pool)
|
.execute(pool.pool())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query_scalar::<_, String>("SELECT file_token FROM notes WHERE id = ?")
|
sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q024))
|
||||||
.bind(note_id)
|
.bind(note_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool.pool())
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,17 +403,17 @@ pub struct FileOwner {
|
|||||||
pub id: i64,
|
pub id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_file_owner(pool: &SqlitePool, token: &str) -> Result<Option<FileOwner>, sqlx::Error> {
|
pub async fn find_file_owner(pool: &Database, token: &str) -> Result<Option<FileOwner>, sqlx::Error> {
|
||||||
if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM pads WHERE file_token = ?")
|
if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q026))
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool.pool())
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(Some(FileOwner { kind: FileOwnerKind::Pad, id }));
|
return Ok(Some(FileOwner { kind: FileOwnerKind::Pad, id }));
|
||||||
}
|
}
|
||||||
if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM notes WHERE file_token = ?")
|
if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q027))
|
||||||
.bind(token)
|
.bind(token)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool.pool())
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id }));
|
return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id }));
|
||||||
|
|||||||
+14
-6
@@ -1,14 +1,16 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod database;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod queries;
|
||||||
mod state;
|
mod state;
|
||||||
mod websocket;
|
mod websocket;
|
||||||
|
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use sqlx::sqlite::SqlitePoolOptions;
|
use database::{Database, DatabaseKind};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
@@ -23,11 +25,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) {
|
if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) {
|
||||||
if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; }
|
if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; }
|
||||||
}
|
}
|
||||||
let db = SqlitePoolOptions::new()
|
let db = Database::connect(&config.database_url, config.database_max_connections).await?;
|
||||||
.max_connections(config.database_max_connections)
|
run_migrations(&db).await?;
|
||||||
.connect(&config.database_url)
|
|
||||||
.await?;
|
|
||||||
sqlx::migrate!().run(&db).await?;
|
|
||||||
|
|
||||||
std::fs::create_dir_all(&config.files_dir)?;
|
std::fs::create_dir_all(&config.files_dir)?;
|
||||||
let state = Arc::new(AppState::new(
|
let state = Arc::new(AppState::new(
|
||||||
@@ -78,3 +77,12 @@ async fn shutdown_signal() {
|
|||||||
let terminate = std::future::pending::<()>();
|
let terminate = std::future::pending::<()>();
|
||||||
tokio::select! { () = ctrl_c => {}, () = terminate => {} }
|
tokio::select! { () = ctrl_c => {}, () = terminate => {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError> {
|
||||||
|
let path = match db.kind() {
|
||||||
|
DatabaseKind::Sqlite => std::path::Path::new("migrations/sqlite"),
|
||||||
|
DatabaseKind::Postgres => std::path::Path::new("migrations/postgres"),
|
||||||
|
DatabaseKind::MySql => std::path::Path::new("migrations/mysql"),
|
||||||
|
};
|
||||||
|
sqlx::migrate::Migrator::new(path).await?.run(db.pool()).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
use std::{collections::HashMap, sync::{Mutex, OnceLock}};
|
||||||
|
use crate::database::DatabaseKind;
|
||||||
|
|
||||||
|
pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at FROM workspaces WHERE slug = ?";
|
||||||
|
pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)";
|
||||||
|
pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC";
|
||||||
|
pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?";
|
||||||
|
pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title) VALUES (?, ?, ?)";
|
||||||
|
pub const Q006: &str = "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
|
||||||
|
pub const Q007: &str = "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?";
|
||||||
|
pub const Q008: &str = "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)";
|
||||||
|
pub const Q009: &str = "SELECT updated_at FROM notes WHERE id = ?";
|
||||||
|
pub const Q010: &str = "SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100";
|
||||||
|
pub const Q011: &str = "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?";
|
||||||
|
pub const Q012: &str = "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)";
|
||||||
|
pub const Q013: &str = "UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
|
||||||
|
pub const Q014: &str = "INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)";
|
||||||
|
pub const Q015: &str = "SELECT updated_at FROM pads WHERE id = ?";
|
||||||
|
pub const Q016: &str = "SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100";
|
||||||
|
pub const Q017: &str = "SELECT token FROM published_pages WHERE pad_id = ?";
|
||||||
|
pub const Q018: &str = "INSERT INTO published_pages (token, pad_id) VALUES (?, ?)";
|
||||||
|
pub const Q019: &str = "SELECT token FROM published_pages WHERE note_id = ?";
|
||||||
|
pub const Q020: &str = "INSERT INTO published_pages (token, note_id) VALUES (?, ?)";
|
||||||
|
pub const Q021: &str = "SELECT pp.token, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?";
|
||||||
|
pub const Q022: &str = "SELECT file_token FROM pads WHERE id = ?";
|
||||||
|
pub const Q023: &str = "UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL";
|
||||||
|
pub const Q024: &str = "SELECT file_token FROM notes WHERE id = ?";
|
||||||
|
pub const Q025: &str = "UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL";
|
||||||
|
pub const Q026: &str = "SELECT id FROM pads WHERE file_token = ?";
|
||||||
|
pub const Q027: &str = "SELECT id FROM notes WHERE file_token = ?";
|
||||||
|
|
||||||
|
static POSTGRES_QUERIES: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new();
|
||||||
|
|
||||||
|
pub fn get(kind: DatabaseKind, query: &'static str) -> &'static str {
|
||||||
|
if kind != DatabaseKind::Postgres { return query; }
|
||||||
|
let cache = POSTGRES_QUERIES.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
|
let mut cache = cache.lock().expect("query cache lock poisoned");
|
||||||
|
if let Some(value) = cache.get(query) { return value; }
|
||||||
|
let cache_key = query;
|
||||||
|
let query = query.replace("CURRENT_TIMESTAMP", "(CURRENT_TIMESTAMP::text)");
|
||||||
|
let mut index = 0;
|
||||||
|
let mut converted = String::with_capacity(query.len() + 8);
|
||||||
|
for ch in query.chars() {
|
||||||
|
if ch == '?' {
|
||||||
|
index += 1;
|
||||||
|
converted.push('$');
|
||||||
|
converted.push_str(&index.to_string());
|
||||||
|
} else {
|
||||||
|
converted.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let converted = Box::leak(converted.into_boxed_str());
|
||||||
|
cache.insert(cache_key, converted);
|
||||||
|
converted
|
||||||
|
}
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
use sqlx::SqlitePool;
|
use crate::database::Database;
|
||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, RwLock};
|
||||||
|
|
||||||
const CHANNEL_CAPACITY: usize = 256;
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
@@ -15,7 +15,7 @@ pub struct NoteUpdate {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: SqlitePool,
|
pub db: Database,
|
||||||
pub asset_version: String,
|
pub asset_version: String,
|
||||||
pub files_dir: String,
|
pub files_dir: String,
|
||||||
pub upload_max_size_bytes: usize,
|
pub upload_max_size_bytes: usize,
|
||||||
@@ -23,7 +23,7 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(db: SqlitePool, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self {
|
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self {
|
||||||
Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) }
|
Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) }
|
||||||
}
|
}
|
||||||
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
async fn channel_for_key(&self, key: String) -> broadcast::Sender<NoteUpdate> {
|
||||||
|
|||||||
@@ -59,5 +59,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<footer class="site-footer">Author: <a href="https://linuxiarz.pl" rel="author noopener">@linuxiarz.pl</a></footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -239,3 +239,12 @@ dialog::backdrop { background: rgba(4,6,9,.82); }
|
|||||||
.public-content { min-height: 240px; padding: 32px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
|
.public-content { min-height: 240px; padding: 32px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); }
|
||||||
.public-content img { cursor: zoom-in; }
|
.public-content img { cursor: zoom-in; }
|
||||||
@media (max-width: 600px) { .public-document { padding-top: 32px; } .public-content { padding: 20px; } }
|
@media (max-width: 600px) { .public-document { padding-top: 32px; } .public-content { padding: 20px; } }
|
||||||
|
|
||||||
|
|
||||||
|
/* Layout safeguards and home footer */
|
||||||
|
.app-header__main, .document-heading { min-width: 0; }
|
||||||
|
.document-url { overflow-wrap: anywhere; }
|
||||||
|
.site-footer { width: min(1040px, calc(100% - 32px)); margin: -36px auto 28px; color: var(--muted-2); font-size: .76rem; text-align: center; }
|
||||||
|
.site-footer a { color: var(--muted); text-decoration: none; }
|
||||||
|
.site-footer a:hover { color: white; }
|
||||||
|
@media (max-width: 760px) { .site-footer { width: min(100% - 24px, 560px); margin-top: -20px; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user