This commit is contained in:
Mateusz Gruszczyński
2026-07-20 20:27:05 +02:00
parent 48c92dc382
commit 15ee0ad993
22 changed files with 377 additions and 188 deletions
+30 -4
View File
@@ -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
UPLOAD_MAX_SIZE_MB=20 # Maximum upload size
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
+3 -1
View File
@@ -6,4 +6,6 @@ rustpad.db-wal
.env.docker .env.docker
*.log *.log
data/db/.db* data/db/.db*
data/files/* data/db/*/*
data/files/*
*.zip
+2 -2
View File
@@ -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"
+1
View File
@@ -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 \
+24
View File
@@ -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.
+36 -2
View File
@@ -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}
@@ -18,4 +18,38 @@ services:
ports: ports:
- "${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
-43
View File
@@ -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;
-4
View File
@@ -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;
-2
View File
@@ -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 '[]';
-8
View File
@@ -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))
);
-5
View File
@@ -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;
+26
View File
@@ -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;
+29
View File
@@ -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
View File
Binary file not shown.
+43
View File
@@ -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())) }
}
}
+101 -108
View File
@@ -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
View File
@@ -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
}
+55
View File
@@ -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
View File
@@ -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> {
+1
View File
@@ -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>
+9
View File
@@ -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; } }