Merge pull request 'S3 support' (#1) from s3_support into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
gru
2026-07-24 11:57:41 +02:00
30 changed files with 8170 additions and 1382 deletions
+19 -1
View File
@@ -33,6 +33,22 @@ RUST_LOG=rustpad=info,tower_http=warn
# Maximum upload size # Maximum upload size
UPLOAD_MAX_SIZE_MB=20 UPLOAD_MAX_SIZE_MB=20
# Attachment storage: local or s3
STORAGE_DRIVER=local
FILES_DIR=/data/files
# S3-compatible storage (AWS S3, Garage, Ceph, OpenStack, MinIO, R2...)
# For Docker Garage run: docker compose --profile s3 up -d
# STORAGE_DRIVER=s3
# S3_ENDPOINT=http://garage:3900
# S3_REGION=garage
# S3_BUCKET=attachments
# S3_ACCESS_KEY=GK0123456789abcdef0123456789abcdef
# S3_SECRET_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
# S3_FORCE_PATH_STYLE=true
# GARAGE_S3_PORT=3900
# GARAGE_ADMIN_PORT=3903
# Browser cache lifetime in seconds # Browser cache lifetime in seconds
ASSET_CACHE_MAX_AGE_SECONDS=600 ASSET_CACHE_MAX_AGE_SECONDS=600
FILE_CACHE_MAX_AGE_SECONDS=300 FILE_CACHE_MAX_AGE_SECONDS=300
@@ -48,10 +64,12 @@ MYSQL_USER=rustpad
MYSQL_PASSWORD=rustpad MYSQL_PASSWORD=rustpad
MYSQL_ROOT_PASSWORD=rustpad_root MYSQL_ROOT_PASSWORD=rustpad_root
# Optional account password reset via SMTP # Optional settings
REGISTRATION_ENABLED=false REGISTRATION_ENABLED=false
ACCOUNT_CONFIRMATION_REQUIRED=false ACCOUNT_CONFIRMATION_REQUIRED=false
SHARE_CONFIRMATION_REQUIRED=true
# smtp mailing
PUBLIC_URL=https://pad.example.com PUBLIC_URL=https://pad.example.com
# SMTP_HOST=smtp.example.com # SMTP_HOST=smtp.example.com
SMTP_PORT=587 SMTP_PORT=587
+2 -1
View File
@@ -12,4 +12,5 @@ data/files/*
*.zip *.zip
venv venv
.venv .venv
migrate/etherpad-dry-run-report.json migrate/etherpad-dry-run-report.json
data/garge
Generated
+1271 -59
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.0.10" version = "0.0.11"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
@@ -8,6 +8,10 @@ license = "MIT"
[dependencies] [dependencies]
argon2 = "0.5" argon2 = "0.5"
aws-config = "1"
aws-credential-types = "1"
aws-sdk-s3 = "1"
bytes = "1"
axum = { version = "0.8", features = ["ws", "multipart"] } axum = { version = "0.8", features = ["ws", "multipart"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15" dotenvy = "0.15"
+17
View File
@@ -87,3 +87,20 @@ Browser diagnostics are configured separately from backend logs with `FRONTEND_L
### Rejestracja i SMTP ### Rejestracja i SMTP
`REGISTRATION_ENABLED=true` włącza rejestrację. Po utworzeniu konta aplikacja wysyła przez SMTP wiadomość z nickiem i adresem `PUBLIC_URL`. `ACCOUNT_CONFIRMATION_REQUIRED=true` wymaga dodatkowo kliknięcia linku potwierdzającego przed logowaniem; domyślnie opcja jest wyłączona i wymaga skonfigurowanego SMTP. `REGISTRATION_ENABLED=true` włącza rejestrację. Po utworzeniu konta aplikacja wysyła przez SMTP wiadomość z nickiem i adresem `PUBLIC_URL`. `ACCOUNT_CONFIRMATION_REQUIRED=true` wymaga dodatkowo kliknięcia linku potwierdzającego przed logowaniem; domyślnie opcja jest wyłączona i wymaga skonfigurowanego SMTP.
## Attachment storage
RustPad supports two interchangeable attachment backends selected in `.env`:
- `STORAGE_DRIVER=local` stores files under `FILES_DIR` (default).
- `STORAGE_DRIVER=s3` uses any S3-compatible service such as AWS S3, Garage, Ceph RGW, OpenStack or MinIO.
The public application URLs remain `/f/{token}/{filename}` for both backends. RustPad checks access and streams the object through the API, so no bucket needs to be public and existing database records do not need migration.
For the optional Docker Garage service, set the S3 variables shown in `.env.example`, use strong unique credentials, and start:
```sh
docker compose --profile s3 up -d --build
```
Garage is a separate Compose service and the existing `pgsql` and `mysql` profiles remain unchanged. The included single-node setup is intended for local/self-hosted development without redundancy; production Garage deployments should use an appropriately designed multi-node configuration.
+18 -8
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
mkdir -p data/db data/files mkdir -p data/db data/files
@@ -11,15 +12,24 @@ export FILES_DIR="${FILES_DIR:-$(pwd)/data/files}"
export UPLOAD_MAX_SIZE_MB="${UPLOAD_MAX_SIZE_MB:-20}" export UPLOAD_MAX_SIZE_MB="${UPLOAD_MAX_SIZE_MB:-20}"
export STATIC_DIR="${STATIC_DIR:-$(pwd)/static}" export STATIC_DIR="${STATIC_DIR:-$(pwd)/static}"
export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}" export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}"
# A new value on every run prevents stale HTML/JS cache issues.
# Generate a new asset version on each run to prevent stale HTML and JavaScript.
export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}" export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}"
if command -v cargo >/dev/null 2>&1; then if command -v cargo >/dev/null 2>&1; then
exec cargo run echo "Cleaning RustPad build artifacts..."
elif command -v docker >/dev/null 2>&1; then cargo clean --package rustpad
export IMAGE_TAG="${IMAGE_TAG:-dev}"
exec docker compose up --build --force-recreate --remove-orphans echo "Starting RustPad..."
else exec cargo run --package rustpad
echo "Brak cargo i docker. Zainstaluj Rust 1.85+ albo Docker." >&2
exit 1
fi fi
if command -v docker >/dev/null 2>&1; then
export IMAGE_TAG="${IMAGE_TAG:-dev}"
echo "Starting RustPad with Docker..."
exec docker compose up --build --force-recreate --remove-orphans
fi
echo "Neither Cargo nor Docker was found. Install Rust 1.85+ or Docker." >&2
exit 1
+24 -2
View File
@@ -1,5 +1,5 @@
services: services:
rustpad: rustpad-app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
@@ -12,6 +12,13 @@ services:
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}
STORAGE_DRIVER: ${STORAGE_DRIVER:-local}
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
S3_SECRET_KEY: ${S3_SECRET_KEY:-}
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-false}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20}
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false} REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false}
ACCOUNT_CONFIRMATION_REQUIRED: ${ACCOUNT_CONFIRMATION_REQUIRED:-false} ACCOUNT_CONFIRMATION_REQUIRED: ${ACCOUNT_CONFIRMATION_REQUIRED:-false}
@@ -63,4 +70,19 @@ services:
timeout: 3s timeout: 3s
retries: 30 retries: 30
garage:
image: dxflrs/garage:v2.3.0
profiles: ["s3"]
restart: unless-stopped
command: ["/garage", "server", "--single-node", "--default-bucket"]
environment:
GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK0123456789abcdef0123456789abcdef}
GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-attachments}
ports:
- "${GARAGE_S3_PORT:-3900}:3900"
- "${GARAGE_ADMIN_PORT:-3903}:3903"
volumes:
- ./docker/garage/garage.toml:/etc/garage.toml:ro
- ./data/garage/meta:/var/lib/garage/meta
- ./data/garage/data:/var/lib/garage/data
+14
View File
@@ -0,0 +1,14 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "0.0.0.0:3901"
rpc_secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
[s3_api]
s3_region = "garage"
api_bind_addr = "0.0.0.0:3900"
root_domain = ".s3.garage.localhost"
[admin]
api_bind_addr = "0.0.0.0:3903"
@@ -0,0 +1,15 @@
CREATE TABLE resource_share_invitations (
token_hash VARCHAR(64) PRIMARY KEY,
resource_kind VARCHAR(16) NOT NULL,
resource_slug VARCHAR(255) NOT NULL,
user_id BIGINT NOT NULL,
permission VARCHAR(2) NOT NULL,
created_by BIGINT NOT NULL,
expires_at TEXT NOT NULL,
accepted_at TEXT NULL,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
UNIQUE KEY uq_resource_share_invitation (resource_kind, resource_slug, user_id),
CONSTRAINT fk_share_invitation_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_share_invitation_creator FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id);
@@ -0,0 +1,13 @@
CREATE TABLE resource_share_invitations (
token_hash TEXT PRIMARY KEY,
resource_kind TEXT NOT NULL,
resource_slug TEXT NOT NULL,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission TEXT NOT NULL CHECK(permission IN ('ro','rw')),
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text),
UNIQUE(resource_kind, resource_slug, user_id)
);
CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id);
@@ -0,0 +1,13 @@
CREATE TABLE resource_share_invitations (
token_hash TEXT PRIMARY KEY,
resource_kind TEXT NOT NULL,
resource_slug TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
permission TEXT NOT NULL CHECK(permission IN ('ro','rw')),
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(resource_kind, resource_slug, user_id)
);
CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id);
+416 -120
View File
@@ -1,12 +1,12 @@
use axum::{ use axum::{
extract::{Multipart, Path, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json, Json,
extract::{Multipart, Path, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
}; };
use serde::{Deserialize, Serialize};
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use rand_core::{OsRng, RngCore}; use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use slug::slugify; use slug::slugify;
@@ -141,8 +141,18 @@ pub async fn create_workspace(
let slug = unique_workspace_slug(&state, title).await?; let slug = unique_workspace_slug(&state, title).await?;
let workspace = db::create_workspace(&state.db, &slug, title, password).await?; let workspace = db::create_workspace(&state.db, &slug, title, password).await?;
if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? { if let Some(user) = crate::auth::optional_user(&state, &headers)
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_WORKSPACE)).bind(user.id).bind(&workspace.slug).execute(state.db.pool()).await?; .await
.map_err(|e| ApiError::forbidden(&e.message))?
{
sqlx::query(queries::get(
state.db.kind(),
queries::USER_ATTACH_WORKSPACE,
))
.bind(user.id)
.bind(&workspace.slug)
.execute(state.db.pool())
.await?;
} }
Ok(( Ok((
@@ -169,7 +179,13 @@ pub async fn open_workspace(
Path(workspace_slug): Path<String>, Path(workspace_slug): Path<String>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PasswordRequest>,
) -> Result<Json<WorkspaceOpenResponse>, ApiError> { ) -> Result<Json<WorkspaceOpenResponse>, ApiError> {
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let workspace = authorized_workspace(
&state,
&workspace_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
let notes = db::list_notes(&state.db, workspace.id) let notes = db::list_notes(&state.db, workspace.id)
.await? .await?
.into_iter() .into_iter()
@@ -195,16 +211,37 @@ pub async fn create_note(
Path(workspace_slug): Path<String>, Path(workspace_slug): Path<String>,
Json(payload): Json<CreateNoteRequest>, Json(payload): Json<CreateNoteRequest>,
) -> Result<(StatusCode, Json<NoteListItem>), ApiError> { ) -> Result<(StatusCode, Json<NoteListItem>), ApiError> {
let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let workspace = authorized_workspace(
&state,
&workspace_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
let title = validate_name(&payload.name, "Note name")?; let title = validate_name(&payload.name, "Note name")?;
let base = slugify(title); let base = slugify(title);
if base.is_empty() { if base.is_empty() {
return Err(ApiError::bad_request("The name cannot be converted into a valid address")); return Err(ApiError::bad_request(
"The name cannot be converted into a valid address",
));
} }
let slug = unique_note_slug(&state, workspace.id, &base).await?; let slug = unique_note_slug(&state, workspace.id, &base).await?;
let created_by = payload.created_by.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(|v| v.chars().take(40).collect::<String>()); let created_by = payload
let note = db::create_note(&state.db, workspace.id, &slug, title, payload.protect, created_by.as_deref()).await?; .created_by
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.chars().take(40).collect::<String>());
let note = db::create_note(
&state.db,
workspace.id,
&slug,
title,
payload.protect,
created_by.as_deref(),
)
.await?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(NoteListItem { Json(NoteListItem {
@@ -248,7 +285,14 @@ pub async fn history(
Path((workspace_slug, note_slug)): Path<(String, String)>, Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::Revision>>, ApiError> { ) -> Result<Json<Vec<db::Revision>>, ApiError> {
let (workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let (workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
let _ = workspace; let _ = workspace;
let revisions = db::list_revisions(&state.db, note.id) let revisions = db::list_revisions(&state.db, note.id)
.await? .await?
@@ -266,15 +310,29 @@ pub async fn restore(
Path((workspace_slug, note_slug)): Path<(String, String)>, Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<RestoreRequest>, Json(payload): Json<RestoreRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let (workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let (workspace, note) = authorized_note(
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028)) &state,
.bind(payload.revision_id) &workspace_slug,
.bind(note.id) &note_slug,
.fetch_optional(state.db.pool()) payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?; .await?;
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028))
.bind(payload.revision_id)
.bind(note.id)
.fetch_optional(state.db.pool())
.await?;
let content = content.ok_or_else(ApiError::not_found_revision)?; let content = content.ok_or_else(ApiError::not_found_revision)?;
let (revision_id, updated_at) = let (revision_id, updated_at) = db::save_revision(
db::save_revision(&state.db, note.id, workspace.id, &content, Some("restore"), "[]").await?; &state.db,
note.id,
workspace.id,
&content,
Some("restore"),
"[]",
)
.await?;
let update = NoteUpdate { let update = NoteUpdate {
content, content,
revision_id, revision_id,
@@ -282,7 +340,10 @@ pub async fn restore(
author: Some("restore".into()), author: Some("restore".into()),
owner_map: "[]".into(), owner_map: "[]".into(),
}; };
let _ = state.note_channel(&workspace_slug, &note_slug).await.send(RoomEvent::Document(update)); let _ = state
.note_channel(&workspace_slug, &note_slug)
.await
.send(RoomEvent::Document(update));
Ok(Json(serde_json::json!({"ok": true}))) Ok(Json(serde_json::json!({"ok": true})))
} }
@@ -296,8 +357,13 @@ pub async fn authorized_workspace(
.await? .await?
.ok_or_else(ApiError::not_found_workspace)?; .ok_or_else(ApiError::not_found_workspace)?;
let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await?; let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await?;
if workspace.is_private != 0 && !token_access { return Err(ApiError::forbidden("This workspace is private.")); } if workspace.is_private != 0 && !token_access {
if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password) && !token_access { return Err(ApiError::forbidden("This workspace is private."));
}
if workspace.password_hash.is_some()
&& !db::verify_workspace_password(&workspace, password)
&& !token_access
{
return Err(ApiError::unauthorized()); return Err(ApiError::unauthorized());
} }
Ok(workspace) Ok(workspace)
@@ -353,7 +419,9 @@ fn validate_password(password: Option<&str>) -> Result<Option<&str>, ApiError> {
async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> { async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result<String, ApiError> {
let base = slugify(title); let base = slugify(title);
if base.is_empty() { if base.is_empty() {
return Err(ApiError::bad_request("The name cannot be converted into a valid address")); return Err(ApiError::bad_request(
"The name cannot be converted into a valid address",
));
} }
let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH
@@ -376,7 +444,10 @@ async fn unique_note_slug(
workspace_id: i64, workspace_id: i64,
base: &str, base: &str,
) -> Result<String, ApiError> { ) -> Result<String, ApiError> {
if db::find_note(&state.db, workspace_id, base).await?.is_none() { if db::find_note(&state.db, workspace_id, base)
.await?
.is_none()
{
return Ok(base.to_owned()); return Ok(base.to_owned());
} }
for _ in 0..8 { for _ in 0..8 {
@@ -391,7 +462,6 @@ async fn unique_note_slug(
Err(ApiError::internal("Failed to create a unique address")) Err(ApiError::internal("Failed to create a unique address"))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct CreatePadRequest { pub struct CreatePadRequest {
name: String, name: String,
@@ -424,12 +494,21 @@ pub async fn create_pad(
let password = validate_password(payload.password.as_deref())?; let password = validate_password(payload.password.as_deref())?;
let base = slugify(title); let base = slugify(title);
if base.is_empty() { if base.is_empty() {
return Err(ApiError::bad_request("The name cannot be converted into a valid address")); return Err(ApiError::bad_request(
"The name cannot be converted into a valid address",
));
} }
let slug = unique_pad_slug(&state, &base).await?; let slug = unique_pad_slug(&state, &base).await?;
let pad = db::create_pad(&state.db, &slug, title, password).await?; let pad = db::create_pad(&state.db, &slug, title, password).await?;
if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? { if let Some(user) = crate::auth::optional_user(&state, &headers)
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)).bind(user.id).bind(&pad.slug).execute(state.db.pool()).await?; .await
.map_err(|e| ApiError::forbidden(&e.message))?
{
sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD))
.bind(user.id)
.bind(&pad.slug)
.execute(state.db.pool())
.await?;
} }
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
@@ -462,10 +541,18 @@ pub async fn publish_pad_page(
Path(slug): Path<String>, Path(slug): Path<String>,
Json(payload): Json<PublishRequest>, Json(payload): Json<PublishRequest>,
) -> Result<Json<PublishResponse>, ApiError> { ) -> Result<Json<PublishResponse>, ApiError> {
let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let pad = authorized_pad(
&state,
&slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
let token = db::publish_pad(&state.db, pad.id).await?; let token = db::publish_pad(&state.db, pad.id).await?;
db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?;
Ok(Json(PublishResponse { url: format!("/s/{token}") })) Ok(Json(PublishResponse {
url: format!("/s/{token}"),
}))
} }
pub async fn publish_note_page( pub async fn publish_note_page(
@@ -473,10 +560,19 @@ pub async fn publish_note_page(
Path((workspace_slug, note_slug)): Path<(String, String)>, Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PublishRequest>, Json(payload): Json<PublishRequest>,
) -> Result<Json<PublishResponse>, ApiError> { ) -> Result<Json<PublishResponse>, ApiError> {
let (_, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let (_, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
let token = db::publish_note(&state.db, note.id).await?; let token = db::publish_note(&state.db, note.id).await?;
db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?;
Ok(Json(PublishResponse { url: format!("/s/{token}") })) Ok(Json(PublishResponse {
url: format!("/s/{token}"),
}))
} }
pub async fn public_page( pub async fn public_page(
@@ -499,9 +595,17 @@ pub async fn update_public_task(
Path(token): Path<String>, Path(token): Path<String>,
Json(payload): Json<PublicTaskUpdateRequest>, Json(payload): Json<PublicTaskUpdateRequest>,
) -> Result<Json<PublicPageResponse>, ApiError> { ) -> Result<Json<PublicPageResponse>, ApiError> {
let current = db::find_published_page(&state.db, &token).await?.ok_or_else(ApiError::not_found_note)?; let current = db::find_published_page(&state.db, &token)
if !current.allow_task_updates { return Err(ApiError::forbidden("Task updates are disabled for this page")); } .await?
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked).await?.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
if !current.allow_task_updates {
return Err(ApiError::forbidden(
"Task updates are disabled for this page",
));
}
let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked)
.await?
.ok_or_else(ApiError::not_found_note)?;
Ok(Json(PublicPageResponse { Ok(Json(PublicPageResponse {
title: page.title, title: page.title,
content: page.content, content: page.content,
@@ -515,7 +619,13 @@ pub async fn pad_history(
Path(slug): Path<String>, Path(slug): Path<String>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::Revision>>, ApiError> { ) -> Result<Json<Vec<db::Revision>>, ApiError> {
let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let pad = authorized_pad(
&state,
&slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
let revisions = db::list_pad_revisions(&state.db, pad.id) let revisions = db::list_pad_revisions(&state.db, pad.id)
.await? .await?
.into_iter() .into_iter()
@@ -532,20 +642,28 @@ pub async fn pad_restore(
Path(slug): Path<String>, Path(slug): Path<String>,
Json(payload): Json<RestoreRequest>, Json(payload): Json<RestoreRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let pad = authorized_pad(
let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029)) &state,
.bind(payload.revision_id) &slug,
.bind(pad.id) payload.password.as_deref(),
.fetch_optional(state.db.pool()) payload.access_token.as_deref(),
)
.await?; .await?;
let content = content.ok_or_else(ApiError::not_found_revision)?; let content: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029))
let owner_map: Option<String> = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q030))
.bind(payload.revision_id) .bind(payload.revision_id)
.bind(pad.id) .bind(pad.id)
.fetch_optional(state.db.pool()) .fetch_optional(state.db.pool())
.await?; .await?;
let content = content.ok_or_else(ApiError::not_found_revision)?;
let owner_map: Option<String> =
sqlx::query_scalar(queries::get(state.db.kind(), queries::Q030))
.bind(payload.revision_id)
.bind(pad.id)
.fetch_optional(state.db.pool())
.await?;
let owner_map = owner_map.unwrap_or_else(|| "[]".into()); let owner_map = owner_map.unwrap_or_else(|| "[]".into());
let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?; let (revision_id, updated_at) =
db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?;
let update = NoteUpdate { let update = NoteUpdate {
content, content,
revision_id, revision_id,
@@ -553,7 +671,10 @@ pub async fn pad_restore(
author: Some("restore".into()), author: Some("restore".into()),
owner_map, owner_map,
}; };
let _ = state.pad_channel(&slug).await.send(RoomEvent::Document(update)); let _ = state
.pad_channel(&slug)
.await
.send(RoomEvent::Document(update));
Ok(Json(serde_json::json!({"ok": true}))) Ok(Json(serde_json::json!({"ok": true})))
} }
@@ -567,7 +688,9 @@ async fn authorized_pad(
.await? .await?
.ok_or_else(ApiError::not_found_note)?; .ok_or_else(ApiError::not_found_note)?;
let token_access = verify_resource_access_token(state, "pad", slug, access_token).await?; let token_access = verify_resource_access_token(state, "pad", slug, access_token).await?;
if pad.is_private != 0 && !token_access { return Err(ApiError::forbidden("This note is private.")); } if pad.is_private != 0 && !token_access {
return Err(ApiError::forbidden("This note is private."));
}
if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && !token_access { if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && !token_access {
return Err(ApiError::unauthorized()); return Err(ApiError::unauthorized());
} }
@@ -587,8 +710,6 @@ async fn unique_pad_slug(state: &SharedState, base: &str) -> Result<String, ApiE
Err(ApiError::internal("Failed to create a unique address")) Err(ApiError::internal("Failed to create a unique address"))
} }
pub async fn upload_pad_file( pub async fn upload_pad_file(
State(state): State<SharedState>, State(state): State<SharedState>,
Path(slug): Path<String>, Path(slug): Path<String>,
@@ -597,15 +718,32 @@ pub async fn upload_pad_file(
let mut password: Option<String> = None; let mut password: Option<String> = None;
let mut access_token: Option<String> = None; let mut access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None; let mut file: Option<(String, Vec<u8>)> = None;
while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? { while let Some(field) = multipart
.next_field()
.await
.map_err(|_| ApiError::bad_request("Invalid form data"))?
{
let name = field.name().unwrap_or_default().to_owned(); let name = field.name().unwrap_or_default().to_owned();
if name == "password" { if name == "password" {
password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?); password = Some(
field
.text()
.await
.map_err(|_| ApiError::bad_request("Invalid password"))?,
);
} else if name == "access_token" { } else if name == "access_token" {
access_token = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid access token"))?); access_token = Some(
field
.text()
.await
.map_err(|_| ApiError::bad_request("Invalid access token"))?,
);
} else if name == "file" { } else if name == "file" {
let filename = field.file_name().unwrap_or("plik").to_owned(); let filename = field.file_name().unwrap_or("plik").to_owned();
let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?; let bytes = field
.bytes()
.await
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
if bytes.len() > state.upload_max_size_bytes { if bytes.len() > state.upload_max_size_bytes {
return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
} }
@@ -616,20 +754,36 @@ pub async fn upload_pad_file(
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
let safe = sanitize_filename(&original); let safe = sanitize_filename(&original);
let file_token = db::pad_file_token(&state.db, pad.id).await?; let file_token = db::pad_file_token(&state.db, pad.id).await?;
let directory = format!("{}_{}", pad.id, file_token);
let dir = std::path::Path::new(&state.files_dir).join("pads").join(&directory);
tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?;
let mut stored = safe.clone(); let mut stored = safe.clone();
let mut path = dir.join(&stored); let mut key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
if path.exists() { if state
let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik"); .storage
let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default(); .exists(&key)
.await
.map_err(|_| ApiError::internal("Failed to check file storage"))?
{
let stem = std::path::Path::new(&safe)
.file_stem()
.and_then(|v| v.to_str())
.unwrap_or("plik");
let ext = std::path::Path::new(&safe)
.extension()
.and_then(|v| v.to_str())
.map(|v| format!(".{v}"))
.unwrap_or_default();
stored = format!("{stem}-{}{}", db::random_suffix(6), ext); stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
path = dir.join(&stored); key = crate::storage::object_key("pads", pad.id, &file_token, &stored);
} }
tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
let url = format!("/f/{}/{}", file_token, stored); let url = format!("/f/{}/{}", file_token, stored);
let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string(); let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds);
state
.storage
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?; db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url}))) Ok(Json(serde_json::json!({"name": stored, "url": url})))
} }
@@ -639,14 +793,24 @@ pub async fn pad_files(
Path(slug): Path<String>, Path(slug): Path<String>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::NoteFile>>, ApiError> { ) -> Result<Json<Vec<db::NoteFile>>, ApiError> {
let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let pad = authorized_pad(
&state,
&slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
let mut files = db::list_pad_files(&state.db, pad.id).await?; let mut files = db::list_pad_files(&state.db, pad.id).await?;
for file in &mut files { for file in &mut files {
let attached = pad.content.contains(&file.url); let attached = pad.content.contains(&file.url);
if attached != file.is_attached { if attached != file.is_attached {
db::set_pad_file_attached(&state.db, file.id, attached).await?; db::set_pad_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached; file.is_attached = attached;
file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; file.detached_at = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
} }
file.created_at = db::normalize_timestamp(&file.created_at); file.created_at = db::normalize_timestamp(&file.created_at);
} }
@@ -661,51 +825,101 @@ pub async fn upload_note_file(
let mut password: Option<String> = None; let mut password: Option<String> = None;
let mut access_token: Option<String> = None; let mut access_token: Option<String> = None;
let mut file: Option<(String, Vec<u8>)> = None; let mut file: Option<(String, Vec<u8>)> = None;
while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? { while let Some(field) = multipart
.next_field()
.await
.map_err(|_| ApiError::bad_request("Invalid form data"))?
{
let name = field.name().unwrap_or_default().to_owned(); let name = field.name().unwrap_or_default().to_owned();
if name == "password" { if name == "password" {
password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?); password = Some(
field
.text()
.await
.map_err(|_| ApiError::bad_request("Invalid password"))?,
);
} else if name == "access_token" { } else if name == "access_token" {
access_token = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid access token"))?); access_token = Some(
field
.text()
.await
.map_err(|_| ApiError::bad_request("Invalid access token"))?,
);
} else if name == "file" { } else if name == "file" {
let filename = field.file_name().unwrap_or("plik").to_owned(); let filename = field.file_name().unwrap_or("plik").to_owned();
let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?; let bytes = field
.bytes()
.await
.map_err(|_| ApiError::bad_request("Failed to read the file"))?;
if bytes.len() > state.upload_max_size_bytes { if bytes.len() > state.upload_max_size_bytes {
return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); return Err(ApiError::payload_too_large(state.upload_max_size_bytes));
} }
file = Some((filename, bytes.to_vec())); file = Some((filename, bytes.to_vec()));
} }
} }
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, password.as_deref(), access_token.as_deref()).await?; let (_workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
password.as_deref(),
access_token.as_deref(),
)
.await?;
let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?;
let safe = sanitize_filename(&original); let safe = sanitize_filename(&original);
let file_token = db::note_file_token(&state.db, note.id).await?; let file_token = db::note_file_token(&state.db, note.id).await?;
let directory = format!("{}_{}", note.id, file_token);
let dir = std::path::Path::new(&state.files_dir).join("notes").join(&directory);
tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?;
let mut stored = safe.clone(); let mut stored = safe.clone();
let mut path = dir.join(&stored); let mut key = crate::storage::object_key("notes", note.id, &file_token, &stored);
if path.exists() { if state
let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik"); .storage
let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default(); .exists(&key)
.await
.map_err(|_| ApiError::internal("Failed to check file storage"))?
{
let stem = std::path::Path::new(&safe)
.file_stem()
.and_then(|v| v.to_str())
.unwrap_or("plik");
let ext = std::path::Path::new(&safe)
.extension()
.and_then(|v| v.to_str())
.map(|v| format!(".{v}"))
.unwrap_or_default();
stored = format!("{stem}-{}{}", db::random_suffix(6), ext); stored = format!("{stem}-{}{}", db::random_suffix(6), ext);
path = dir.join(&stored); key = crate::storage::object_key("notes", note.id, &file_token, &stored);
} }
tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?;
let url = format!("/f/{}/{}", file_token, stored); let url = format!("/f/{}/{}", file_token, stored);
let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string(); let mime = mime_guess::from_path(&stored)
.first_or_octet_stream()
.to_string();
let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds);
state
.storage
.put(&key, bytes.clone().into(), &mime, &cache_control)
.await
.map_err(|_| ApiError::internal("Failed to save the file"))?;
db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?; db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?;
Ok(Json(serde_json::json!({"name": stored, "url": url}))) Ok(Json(serde_json::json!({"name": stored, "url": url})))
} }
pub async fn delete_note( pub async fn delete_note(
State(state): State<SharedState>, State(state): State<SharedState>,
Path((workspace_slug, note_slug)): Path<(String, String)>, Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let (_workspace, note) = authorized_note(
if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); } &state,
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
if note.protected {
return Err(ApiError::bad_request(
"This note is protected and cannot be deleted",
));
}
db::delete_note(&state.db, note.id).await?; db::delete_note(&state.db, note.id).await?;
Ok(Json(serde_json::json!({"ok": true}))) Ok(Json(serde_json::json!({"ok": true})))
} }
@@ -715,14 +929,25 @@ pub async fn note_files(
Path((workspace_slug, note_slug)): Path<(String, String)>, Path((workspace_slug, note_slug)): Path<(String, String)>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PasswordRequest>,
) -> Result<Json<Vec<db::NoteFile>>, ApiError> { ) -> Result<Json<Vec<db::NoteFile>>, ApiError> {
let (_workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let (_workspace, note) = authorized_note(
&state,
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
let mut files = db::list_note_files(&state.db, note.id).await?; let mut files = db::list_note_files(&state.db, note.id).await?;
for file in &mut files { for file in &mut files {
let attached = note.content.contains(&file.url); let attached = note.content.contains(&file.url);
if attached != file.is_attached { if attached != file.is_attached {
db::set_note_file_attached(&state.db, file.id, attached).await?; db::set_note_file_attached(&state.db, file.id, attached).await?;
file.is_attached = attached; file.is_attached = attached;
file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; file.detached_at = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
} }
file.created_at = db::normalize_timestamp(&file.created_at); file.created_at = db::normalize_timestamp(&file.created_at);
} }
@@ -734,21 +959,39 @@ pub async fn delete_note_file(
Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>, Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>,
Json(payload): Json<PasswordRequest>, Json(payload): Json<PasswordRequest>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let (workspace, note) = authorized_note(&state, &workspace_slug, &note_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let (workspace, note) = authorized_note(
if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() { &state,
&workspace_slug,
&note_slug,
payload.password.as_deref(),
payload.access_token.as_deref(),
)
.await?;
if workspace.password_hash.is_none()
|| payload.password.as_deref().unwrap_or_default().is_empty()
{
return Err(ApiError::unauthorized()); return Err(ApiError::unauthorized());
} }
let file = db::find_note_file(&state.db, note.id, file_id).await? let file = db::find_note_file(&state.db, note.id, file_id)
.await?
.ok_or_else(ApiError::not_found_file)?; .ok_or_else(ApiError::not_found_file)?;
let relative = file.url.trim_start_matches('/').split('/').collect::<Vec<_>>(); let relative = file
.url
.trim_start_matches('/')
.split('/')
.collect::<Vec<_>>();
if relative.len() == 3 && relative[0] == "f" { if relative.len() == 3 && relative[0] == "f" {
let directory = format!("{}_{}", note.id, relative[1]); let key = crate::storage::object_key(
let path = std::path::Path::new(&state.files_dir).join("notes").join(directory).join(sanitize_filename(relative[2])); "notes",
match tokio::fs::remove_file(&path).await { note.id,
Ok(()) => {}, relative[1],
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}, &sanitize_filename(relative[2]),
Err(_) => return Err(ApiError::internal("Failed to delete the file")), );
} state
.storage
.delete(&key)
.await
.map_err(|_| ApiError::internal("Failed to delete the file"))?;
} }
db::delete_note_file(&state.db, note.id, file_id).await?; db::delete_note_file(&state.db, note.id, file_id).await?;
Ok(Json(serde_json::json!({"ok": true}))) Ok(Json(serde_json::json!({"ok": true})))
@@ -769,7 +1012,8 @@ pub async fn download_legacy_file(
return Err(ApiError::not_found_file()); return Err(ApiError::not_found_file());
}; };
let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?; let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?;
let owner = db::find_file_owner(&state.db, token).await? let owner = db::find_file_owner(&state.db, token)
.await?
.ok_or_else(ApiError::not_found_file)?; .ok_or_else(ApiError::not_found_file)?;
if owner.id != id { if owner.id != id {
return Err(ApiError::not_found_file()); return Err(ApiError::not_found_file());
@@ -777,41 +1021,71 @@ pub async fn download_legacy_file(
serve_token_file(&state, token, &filename).await serve_token_file(&state, token, &filename).await
} }
async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> Result<Response, ApiError> { async fn serve_token_file(
state: &SharedState,
token: &str,
filename: &str,
) -> Result<Response, ApiError> {
let safe = sanitize_filename(filename); let safe = sanitize_filename(filename);
if safe != filename { if safe != filename {
return Err(ApiError::not_found_file()); return Err(ApiError::not_found_file());
} }
let owner = db::find_file_owner(&state.db, token).await? let owner = db::find_file_owner(&state.db, token)
.await?
.ok_or_else(ApiError::not_found_file)?; .ok_or_else(ApiError::not_found_file)?;
let kind = match owner.kind { let kind = match owner.kind {
db::FileOwnerKind::Pad => "pads", db::FileOwnerKind::Pad => "pads",
db::FileOwnerKind::Note => "notes", db::FileOwnerKind::Note => "notes",
}; };
let directory = format!("{}_{}", owner.id, token); let key = crate::storage::object_key(kind, owner.id, token, &safe);
let canonical = std::path::Path::new(&state.files_dir).join(kind).join(&directory).join(&safe); let legacy_key = crate::storage::legacy_key(owner.id, token, &safe);
let legacy = std::path::Path::new(&state.files_dir).join(&directory).join(&safe); let bytes = state
let path = if canonical.is_file() { canonical } else { legacy }; .storage
let bytes = tokio::fs::read(&path).await.map_err(|_| ApiError::not_found_file())?; .get_local_with_legacy(&key, &legacy_key)
.await
.map_err(|_| ApiError::not_found_file())?;
let mime = mime_guess::from_path(&safe).first_or_octet_stream(); let mime = mime_guess::from_path(&safe).first_or_octet_stream();
let mut response = bytes.into_response(); let mut response = bytes.into_response();
response.headers_mut().insert( response.headers_mut().insert(
header::CONTENT_TYPE, header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref()).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), HeaderValue::from_str(mime.as_ref())
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
);
response.headers_mut().insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
); );
response.headers_mut().insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
response.headers_mut().insert( response.headers_mut().insert(
header::CACHE_CONTROL, header::CACHE_CONTROL,
HeaderValue::from_str(&format!("public, max-age={}", state.file_cache_max_age_seconds)) HeaderValue::from_str(&format!(
.expect("valid file cache-control header"), "public, max-age={}",
state.file_cache_max_age_seconds
))
.expect("valid file cache-control header"),
); );
Ok(response) Ok(response)
} }
fn sanitize_filename(value: &str) -> String { fn sanitize_filename(value: &str) -> String {
let name = std::path::Path::new(value).file_name().and_then(|v| v.to_str()).unwrap_or("plik"); let name = std::path::Path::new(value)
let clean: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }).collect(); .file_name()
if clean.is_empty() || clean == "." || clean == ".." { "plik".into() } else { clean.chars().take(160).collect() } .and_then(|v| v.to_str())
.unwrap_or("plik");
let clean: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect();
if clean.is_empty() || clean == "." || clean == ".." {
"plik".into()
} else {
clean.chars().take(160).collect()
}
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -835,13 +1109,17 @@ pub async fn create_resource_access_token(
let slug = payload.slug.trim(); let slug = payload.slug.trim();
match kind { match kind {
"workspace" => { "workspace" => {
let workspace = db::find_workspace(&state.db, slug).await?.ok_or_else(ApiError::not_found_workspace)?; let workspace = db::find_workspace(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_workspace)?;
if !db::verify_workspace_password(&workspace, Some(payload.password.as_str())) { if !db::verify_workspace_password(&workspace, Some(payload.password.as_str())) {
return Err(ApiError::unauthorized()); return Err(ApiError::unauthorized());
} }
} }
"pad" => { "pad" => {
let pad = db::find_pad(&state.db, slug).await?.ok_or_else(ApiError::not_found_note)?; let pad = db::find_pad(&state.db, slug)
.await?
.ok_or_else(ApiError::not_found_note)?;
if !db::verify_pad_password(&pad, Some(payload.password.as_str())) { if !db::verify_pad_password(&pad, Some(payload.password.as_str())) {
return Err(ApiError::unauthorized()); return Err(ApiError::unauthorized());
} }
@@ -852,15 +1130,19 @@ pub async fn create_resource_access_token(
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes); OsRng.fill_bytes(&mut bytes);
let token = hex::encode(bytes); let token = hex::encode(bytes);
let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339(); let expires_at =
sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)")) (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339();
sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_INSERT))
.bind(hash_access_token(&token)) .bind(hash_access_token(&token))
.bind(kind) .bind(kind)
.bind(slug) .bind(slug)
.bind(&expires_at) .bind(&expires_at)
.execute(state.db.pool()) .execute(state.db.pool())
.await?; .await?;
Ok(Json(AccessTokenResponse { access_token: token, expires_at })) Ok(Json(AccessTokenResponse {
access_token: token,
expires_at,
}))
} }
pub async fn verify_resource_access_token( pub async fn verify_resource_access_token(
@@ -872,10 +1154,14 @@ pub async fn verify_resource_access_token(
let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(false); return Ok(false);
}; };
if crate::auth::resource_permission(state, kind, slug, Some(token)).await.map_err(|error| ApiError::forbidden(&error.message))?.is_some() { if crate::auth::resource_permission(state, kind, slug, Some(token))
.await
.map_err(|error| ApiError::forbidden(&error.message))?
.is_some()
{
return Ok(true); return Ok(true);
} }
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?")) let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT))
.bind(hash_access_token(token)) .bind(hash_access_token(token))
.bind(kind) .bind(kind)
.bind(slug) .bind(slug)
@@ -909,7 +1195,10 @@ impl ApiError {
} }
} }
fn not_found_file() -> Self { fn not_found_file() -> Self {
Self { status: StatusCode::NOT_FOUND, message: "File not found".into() } Self {
status: StatusCode::NOT_FOUND,
message: "File not found".into(),
}
} }
fn unauthorized() -> Self { fn unauthorized() -> Self {
Self { Self {
@@ -918,7 +1207,10 @@ impl ApiError {
} }
} }
fn forbidden(message: &str) -> Self { fn forbidden(message: &str) -> Self {
Self { status: StatusCode::FORBIDDEN, message: message.into() } Self {
status: StatusCode::FORBIDDEN,
message: message.into(),
}
} }
fn not_found_workspace() -> Self { fn not_found_workspace() -> Self {
Self { Self {
@@ -955,6 +1247,10 @@ impl From<sqlx::Error> for ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
(self.status, Json(serde_json::json!({"error": self.message}))).into_response() (
self.status,
Json(serde_json::json!({"error": self.message})),
)
.into_response()
} }
} }
+115 -40
View File
@@ -1,17 +1,22 @@
use axum::{ use axum::{
Router,
extract::{DefaultBodyLimit, Path, State}, extract::{DefaultBodyLimit, Path, State},
http::{header, HeaderValue, StatusCode}, http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{get, post}, routing::{get, post},
Router,
}; };
use tower::{service_fn, ServiceBuilder}; use tower::{ServiceBuilder, service_fn};
use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer}; use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer};
use crate::{api, auth, db, state::SharedState, websocket}; use crate::{api, auth, db, state::SharedState, websocket};
use std::convert::Infallible; use std::convert::Infallible;
pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize, asset_cache_max_age_seconds: u64) -> Router { pub fn router(
state: SharedState,
static_dir: &str,
upload_max_size_bytes: usize,
asset_cache_max_age_seconds: u64,
) -> Router {
let asset_version = state.asset_version.clone(); let asset_version = state.asset_version.clone();
let asset_not_found = service_fn(move |_request| { let asset_not_found = service_fn(move |_request| {
let asset_version = asset_version.clone(); let asset_version = asset_version.clone();
@@ -28,8 +33,9 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
} }
}); });
let asset_cache_control = HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}")) let asset_cache_control =
.expect("valid asset cache-control header"); HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}"))
.expect("valid asset cache-control header");
Router::new() Router::new()
.route("/", get(home)) .route("/", get(home))
@@ -40,7 +46,10 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
.route("/errors/private-workspace", get(private_workspace_error)) .route("/errors/private-workspace", get(private_workspace_error))
.route("/health", get(health)) .route("/health", get(health))
.route("/f/{token}/{filename}", get(api::download_file)) .route("/f/{token}/{filename}", get(api::download_file))
.route("/files/{directory}/{filename}", get(api::download_legacy_file)) .route(
"/files/{directory}/{filename}",
get(api::download_legacy_file),
)
.route("/api/auth/identity", post(auth::identity)) .route("/api/auth/identity", post(auth::identity))
.route("/api/access-token", post(api::create_resource_access_token)) .route("/api/access-token", post(api::create_resource_access_token))
.route("/api/auth/register", post(auth::register)) .route("/api/auth/register", post(auth::register))
@@ -48,12 +57,37 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
.route("/api/auth/confirm-account", post(auth::confirm_account)) .route("/api/auth/confirm-account", post(auth::confirm_account))
.route("/api/auth/me", get(auth::me)) .route("/api/auth/me", get(auth::me))
.route("/api/auth/logout", post(auth::logout)) .route("/api/auth/logout", post(auth::logout))
.route("/api/auth/resources", get(auth::resources).put(auth::update_resource).delete(auth::delete_resource)) .route(
.route("/api/auth/resources/privacy", post(auth::set_resource_privacy)) "/api/auth/resources",
.route("/api/auth/resources/sharing", get(auth::resource_sharing).post(auth::share_resource_users).delete(auth::remove_resource_user)) get(auth::resources)
.route("/api/auth/resources/share-links", post(auth::create_share_link).put(auth::update_share_link).delete(auth::revoke_share_link)) .put(auth::update_resource)
.delete(auth::delete_resource),
)
.route(
"/api/auth/resources/privacy",
post(auth::set_resource_privacy),
)
.route(
"/api/auth/resources/sharing",
get(auth::resource_sharing)
.post(auth::share_resource_users)
.delete(auth::remove_resource_user),
)
.route(
"/api/auth/resources/share-links",
post(auth::create_share_link)
.put(auth::update_share_link)
.delete(auth::revoke_share_link),
)
.route(
"/share-invitations/{token}/accept",
get(auth::accept_share_invitation),
)
.route("/api/auth/password-reset", post(auth::request_reset)) .route("/api/auth/password-reset", post(auth::request_reset))
.route("/api/auth/password-reset/confirm", post(auth::confirm_reset)) .route(
"/api/auth/password-reset/confirm",
post(auth::confirm_reset),
)
.route("/api/public/{token}", get(api::public_page)) .route("/api/public/{token}", get(api::public_page))
.route("/api/public/{token}/tasks", post(api::update_public_task)) .route("/api/public/{token}/tasks", post(api::update_public_task))
.route("/api/pads", post(api::create_pad)) .route("/api/pads", post(api::create_pad))
@@ -61,11 +95,20 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
.route("/api/pads/{slug}/history", post(api::pad_history)) .route("/api/pads/{slug}/history", post(api::pad_history))
.route("/api/pads/{slug}/publish", post(api::publish_pad_page)) .route("/api/pads/{slug}/publish", post(api::publish_pad_page))
.route("/api/pads/{slug}/restore", post(api::pad_restore)) .route("/api/pads/{slug}/restore", post(api::pad_restore))
.route("/api/pads/{slug}/files", post(api::upload_pad_file).put(api::pad_files)) .route(
"/api/pads/{slug}/files",
post(api::upload_pad_file).put(api::pad_files),
)
.route("/api/workspaces", post(api::create_workspace)) .route("/api/workspaces", post(api::create_workspace))
.route("/api/workspaces/{workspace_slug}", get(api::workspace_info)) .route("/api/workspaces/{workspace_slug}", get(api::workspace_info))
.route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace)) .route(
.route("/api/workspaces/{workspace_slug}/notes", post(api::create_note)) "/api/workspaces/{workspace_slug}/open",
post(api::open_workspace),
)
.route(
"/api/workspaces/{workspace_slug}/notes",
post(api::create_note),
)
.route( .route(
"/api/workspaces/{workspace_slug}/notes/{note_slug}", "/api/workspaces/{workspace_slug}/notes/{note_slug}",
get(api::note_info).delete(api::delete_note), get(api::note_info).delete(api::delete_note),
@@ -91,10 +134,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
axum::routing::delete(api::delete_note_file), axum::routing::delete(api::delete_note_file),
) )
.route("/ws/p/{slug}", get(websocket::upgrade_pad)) .route("/ws/p/{slug}", get(websocket::upgrade_pad))
.route( .route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade))
"/ws/{workspace_slug}/{note_slug}",
get(websocket::upgrade),
)
.route("/static", get(static_not_found)) .route("/static", get(static_not_found))
.route("/static/{*path}", get(static_not_found)) .route("/static/{*path}", get(static_not_found))
.nest_service( .nest_service(
@@ -108,12 +148,13 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize
) )
.fallback(not_found) .fallback(not_found)
.method_not_allowed_fallback(method_not_allowed) .method_not_allowed_fallback(method_not_allowed)
.layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024))) .layer(DefaultBodyLimit::max(
upload_max_size_bytes.saturating_add(1024 * 1024),
))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state) .with_state(state)
} }
async fn private_workspace_error(State(state): State<SharedState>) -> Response { async fn private_workspace_error(State(state): State<SharedState>) -> Response {
error_response( error_response(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
@@ -131,19 +172,26 @@ async fn health() -> &'static str {
} }
async fn home(State(state): State<SharedState>) -> Response { async fn home(State(state): State<SharedState>) -> Response {
versioned_html(include_str!("../static/home.html"), &state.asset_version, state.registration_enabled, &state.frontend_log_level) versioned_html(
include_str!("../static/home.html"),
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
)
} }
async fn pad( async fn pad(State(state): State<SharedState>, Path(slug): Path<String>) -> Response {
State(state): State<SharedState>,
Path(slug): Path<String>,
) -> Response {
match db::find_pad(&state.db, &slug).await { match db::find_pad(&state.db, &slug).await {
Ok(Some(pad)) => { Ok(Some(pad)) => {
let html = include_str!("../static/pad.html") let html = include_str!("../static/pad.html")
.replace("__PAD_TITLE__", &escape_html(&pad.title)); .replace("__PAD_TITLE__", &escape_html(&pad.title));
versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level) versioned_html(
}, &html,
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
)
}
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
@@ -160,12 +208,14 @@ async fn pad(
} }
} }
async fn public_page( async fn public_page(State(state): State<SharedState>, Path(token): Path<String>) -> Response {
State(state): State<SharedState>,
Path(token): Path<String>,
) -> Response {
match db::find_published_page(&state.db, &token).await { match db::find_published_page(&state.db, &token).await {
Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version, state.registration_enabled, &state.frontend_log_level), Ok(Some(_)) => versioned_html(
include_str!("../static/public.html"),
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
),
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
@@ -190,8 +240,13 @@ async fn workspace(
Ok(Some(workspace)) => { Ok(Some(workspace)) => {
let html = include_str!("../static/workspace.html") let html = include_str!("../static/workspace.html")
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)); .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title));
versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level) versioned_html(
}, &html,
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
)
}
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
@@ -237,8 +292,13 @@ async fn note(
.replace("__NOTE_TITLE__", &escape_html(&note.title)) .replace("__NOTE_TITLE__", &escape_html(&note.title))
.replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)) .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title))
.replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug));
versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level) versioned_html(
}, &html,
&state.asset_version,
state.registration_enabled,
&state.frontend_log_level,
)
}
Ok(None) => error_response( Ok(None) => error_response(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
"404", "404",
@@ -325,14 +385,26 @@ fn error_response(
response response
} }
fn versioned_html(template: &str, asset_version: &str, registration_enabled: bool, frontend_log_level: &str) -> Response { fn versioned_html(
template: &str,
asset_version: &str,
registration_enabled: bool,
frontend_log_level: &str,
) -> Response {
let frontend_config = format!( let frontend_config = format!(
r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}"}});</script>"#, r#"<script>window.__RUSTPAD_CONFIG__=Object.freeze({{frontendLogLevel:"{}"}});</script>"#,
escape_js_string(frontend_log_level), escape_js_string(frontend_log_level),
); );
let html = template let html = template
.replace("__ASSET_VERSION__", asset_version) .replace("__ASSET_VERSION__", asset_version)
.replace("__REGISTRATION_ENABLED__", if registration_enabled { "true" } else { "false" }) .replace(
"__REGISTRATION_ENABLED__",
if registration_enabled {
"true"
} else {
"false"
},
)
.replace("</head>", &format!("{frontend_config}</head>")); .replace("</head>", &format!("{frontend_config}</head>"));
let mut response = Html(html).into_response(); let mut response = Html(html).into_response();
no_store(&mut response); no_store(&mut response);
@@ -340,7 +412,10 @@ fn versioned_html(template: &str, asset_version: &str, registration_enabled: boo
} }
fn escape_js_string(value: &str) -> String { fn escape_js_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"").replace('<', "\\u003c") value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('<', "\\u003c")
} }
fn no_store(response: &mut Response) { fn no_store(response: &mut Response) {
+916 -218
View File
File diff suppressed because it is too large Load Diff
+47 -14
View File
@@ -8,6 +8,7 @@ pub struct Config {
pub database_max_connections: u32, pub database_max_connections: u32,
pub static_dir: String, pub static_dir: String,
pub files_dir: String, pub files_dir: String,
pub storage: crate::storage::StorageConfig,
pub upload_max_size_bytes: usize, pub upload_max_size_bytes: usize,
pub asset_version: String, pub asset_version: String,
pub asset_cache_max_age_seconds: u64, pub asset_cache_max_age_seconds: u64,
@@ -15,6 +16,7 @@ pub struct Config {
pub smtp: Option<crate::state::SmtpConfig>, pub smtp: Option<crate::state::SmtpConfig>,
pub registration_enabled: bool, pub registration_enabled: bool,
pub account_confirmation_required: bool, pub account_confirmation_required: bool,
pub share_confirmation_required: bool,
pub frontend_log_level: String, pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64, pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64, pub user_session_ttl_days: i64,
@@ -24,40 +26,62 @@ impl Config {
pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> { pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
let host = env_var("APP_HOST", "127.0.0.1").parse()?; let host = env_var("APP_HOST", "127.0.0.1").parse()?;
let port = env_var("APP_PORT", "3000").parse()?; let port = env_var("APP_PORT", "3000").parse()?;
let database_max_connections = let database_max_connections = env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?;
env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?;
let upload_max_size_mb: usize = let upload_max_size_mb: usize = env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?;
env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?; let anonymous_access_token_ttl_days =
let anonymous_access_token_ttl_days = env_positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; env_positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?;
let user_session_ttl_days = env_positive_i64("USER_SESSION_TTL_DAYS", 30)?; let user_session_ttl_days = env_positive_i64("USER_SESSION_TTL_DAYS", 30)?;
let files_dir = env_var("FILES_DIR", "data/files");
let storage = match env_var("STORAGE_DRIVER", "local")
.trim()
.to_ascii_lowercase()
.as_str()
{
"local" => crate::storage::StorageConfig::Local {
root: files_dir.clone().into(),
},
"s3" => crate::storage::StorageConfig::S3 {
endpoint: env::var("S3_ENDPOINT").ok(),
region: env_var("S3_REGION", "us-east-1"),
bucket: required_env("S3_BUCKET")?,
access_key: required_env("S3_ACCESS_KEY")?,
secret_key: required_env("S3_SECRET_KEY")?,
force_path_style: env_bool("S3_FORCE_PATH_STYLE", false)?,
},
_ => return Err("STORAGE_DRIVER must be local or s3".into()),
};
if upload_max_size_mb == 0 { if upload_max_size_mb == 0 {
return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into()); return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into());
} }
let smtp_host = std::env::var("SMTP_HOST").ok().filter(|v| !v.trim().is_empty()); let smtp_host = std::env::var("SMTP_HOST")
.ok()
.filter(|v| !v.trim().is_empty());
let smtp = if let Some(host) = smtp_host { let smtp = if let Some(host) = smtp_host {
Some(crate::state::SmtpConfig { Some(crate::state::SmtpConfig {
host, host,
port: env_var("SMTP_PORT", "587").parse()?, port: env_var("SMTP_PORT", "587").parse()?,
username: std::env::var("SMTP_USERNAME").unwrap_or_default(), username: std::env::var("SMTP_USERNAME").unwrap_or_default(),
password: std::env::var("SMTP_PASSWORD").unwrap_or_default(), password: std::env::var("SMTP_PASSWORD").unwrap_or_default(),
from: std::env::var("SMTP_FROM").map_err(|_| "SMTP_FROM is required when SMTP_HOST is set")?, from: std::env::var("SMTP_FROM")
public_url: std::env::var("PUBLIC_URL").map_err(|_| "PUBLIC_URL is required when SMTP_HOST is set")?, .map_err(|_| "SMTP_FROM is required when SMTP_HOST is set")?,
public_url: std::env::var("PUBLIC_URL")
.map_err(|_| "PUBLIC_URL is required when SMTP_HOST is set")?,
}) })
} else { None }; } else {
None
};
Ok(Self { Ok(Self {
host, host,
port, port,
database_url: env_var( database_url: env_var("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"),
"DATABASE_URL",
"sqlite:///data/db/rustpad.db?mode=rwc",
),
database_max_connections, database_max_connections,
static_dir: env_var("STATIC_DIR", "static"), static_dir: env_var("STATIC_DIR", "static"),
files_dir: env_var("FILES_DIR", "data/files"), files_dir,
storage,
upload_max_size_bytes: upload_max_size_mb upload_max_size_bytes: upload_max_size_mb
.checked_mul(1024 * 1024) .checked_mul(1024 * 1024)
.ok_or("UPLOAD_MAX_SIZE_MB is too large")?, .ok_or("UPLOAD_MAX_SIZE_MB is too large")?,
@@ -67,6 +91,7 @@ impl Config {
smtp, smtp,
registration_enabled: env_bool("REGISTRATION_ENABLED", false)?, registration_enabled: env_bool("REGISTRATION_ENABLED", false)?,
account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?,
share_confirmation_required: env_bool("SHARE_CONFIRMATION_REQUIRED", false)?,
frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?, frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?,
anonymous_access_token_ttl_days, anonymous_access_token_ttl_days,
user_session_ttl_days, user_session_ttl_days,
@@ -108,3 +133,11 @@ fn env_positive_i64(name: &str, default: i64) -> Result<i64, Box<dyn std::error:
fn env_nonnegative_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> { fn env_nonnegative_u64(name: &str, default: u64) -> Result<u64, Box<dyn std::error::Error>> {
Ok(env_var(name, &default.to_string()).parse()?) Ok(env_var(name, &default.to_string()).parse()?)
} }
fn required_env(name: &str) -> Result<String, Box<dyn std::error::Error>> {
let value = env::var(name).map_err(|_| format!("{name} is required when STORAGE_DRIVER=s3"))?;
if value.trim().is_empty() {
return Err(format!("{name} cannot be empty when STORAGE_DRIVER=s3").into());
}
Ok(value)
}
+21 -8
View File
@@ -1,5 +1,5 @@
use crate::queries; use crate::queries;
use sqlx::{any::AnyPoolOptions, AnyPool}; use sqlx::{AnyPool, any::AnyPoolOptions};
use tracing::{debug, info}; use tracing::{debug, info};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -26,9 +26,15 @@ impl Database {
.await?; .await?;
if kind == DatabaseKind::Sqlite { if kind == DatabaseKind::Sqlite {
debug!("applying SQLite connection pragmas"); debug!("applying SQLite connection pragmas");
sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON).execute(&pool).await?; sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON)
sqlx::query(queries::SQLITE_JOURNAL_WAL).execute(&pool).await?; .execute(&pool)
sqlx::query(queries::SQLITE_BUSY_TIMEOUT).execute(&pool).await?; .await?;
sqlx::query(queries::SQLITE_JOURNAL_WAL)
.execute(&pool)
.await?;
sqlx::query(queries::SQLITE_BUSY_TIMEOUT)
.execute(&pool)
.await?;
} }
info!(?kind, max_connections, "database pool ready"); info!(?kind, max_connections, "database pool ready");
Ok(Self { pool, kind }) Ok(Self { pool, kind })
@@ -44,9 +50,16 @@ impl Database {
impl DatabaseKind { impl DatabaseKind {
fn from_url(url: &str) -> Result<Self, sqlx::Error> { fn from_url(url: &str) -> Result<Self, sqlx::Error> {
if url.starts_with("sqlite:") { Ok(Self::Sqlite) } if url.starts_with("sqlite:") {
else if url.starts_with("postgres:") || url.starts_with("postgresql:") { Ok(Self::Postgres) } Ok(Self::Sqlite)
else if url.starts_with("mysql:") { Ok(Self::MySql) } } else if url.starts_with("postgres:") || url.starts_with("postgresql:") {
else { Err(sqlx::Error::Configuration("DATABASE_URL must use sqlite://, postgres:// or mysql://".into())) } 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(),
))
}
} }
} }
+268 -101
View File
@@ -1,15 +1,19 @@
use argon2::{ use crate::{
password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier, database::{Database, DatabaseKind},
queries,
}; };
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use chrono::{DateTime, NaiveDateTime, Utc}; use chrono::{DateTime, NaiveDateTime, Utc};
use rand_core::{OsRng, RngCore}; use rand_core::{OsRng, RngCore};
use serde::Serialize; use serde::Serialize;
use sqlx::FromRow; use sqlx::FromRow;
use crate::{database::{Database, DatabaseKind}, queries};
use sqlx::{Any, Transaction}; use sqlx::{Any, Transaction};
async fn inserted_id(
async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result<i64, sqlx::Error> { kind: DatabaseKind,
tx: &mut Transaction<'_, Any>,
table: &str,
) -> Result<i64, sqlx::Error> {
let query = match kind { let query = match kind {
DatabaseKind::Sqlite => queries::SQLITE_LAST_INSERT_ID, DatabaseKind::Sqlite => queries::SQLITE_LAST_INSERT_ID,
DatabaseKind::MySql => queries::MYSQL_LAST_INSERT_ID, DatabaseKind::MySql => queries::MYSQL_LAST_INSERT_ID,
@@ -92,9 +96,9 @@ pub struct Revision {
pub async fn find_workspace(pool: &Database, 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>(queries::get(pool.kind(), queries::Q001)) sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
.bind(slug) .bind(slug)
.fetch_optional(pool.pool()) .fetch_optional(pool.pool())
.await .await
} }
pub async fn create_workspace( pub async fn create_workspace(
@@ -103,22 +107,27 @@ pub async fn create_workspace(
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);
sqlx::query(queries::get(pool.kind(), queries::Q002)) sqlx::query(queries::get(pool.kind(), queries::Q002))
.bind(slug) .bind(slug)
.bind(title) .bind(title)
.bind(password_hash) .bind(password_hash)
.execute(pool.pool()) .execute(pool.pool())
.await?; .await?;
sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001))
.bind(slug) .bind(slug)
.fetch_one(pool.pool()) .fetch_one(pool.pool())
.await .await
} }
pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool { pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool {
match (&workspace.password_hash, password.filter(|value| !value.is_empty())) { match (
&workspace.password_hash,
password.filter(|value| !value.is_empty()),
) {
(None, _) => true, (None, _) => true,
(Some(hash), Some(password)) => PasswordHash::new(hash) (Some(hash), Some(password)) => PasswordHash::new(hash)
.ok() .ok()
@@ -177,13 +186,13 @@ pub async fn create_note(
created_by: Option<&str>, created_by: Option<&str>,
) -> Result<Note, sqlx::Error> { ) -> Result<Note, sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q005)) sqlx::query(queries::get(pool.kind(), queries::Q005))
.bind(workspace_id) .bind(workspace_id)
.bind(slug) .bind(slug)
.bind(title) .bind(title)
.bind(protected) .bind(protected)
.bind(created_by) .bind(created_by)
.execute(pool.pool()) .execute(pool.pool())
.await?; .await?;
find_note(pool, workspace_id, slug) find_note(pool, workspace_id, slug)
.await? .await?
@@ -227,9 +236,9 @@ pub async fn save_revision(
pub async fn list_revisions(pool: &Database, 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>(queries::get(pool.kind(), queries::Q010)) sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010))
.bind(note_id) .bind(note_id)
.fetch_all(pool.pool()) .fetch_all(pool.pool())
.await .await
} }
pub fn random_suffix(length: usize) -> String { pub fn random_suffix(length: usize) -> String {
@@ -265,7 +274,9 @@ pub fn normalize_timestamp(value: &str) -> String {
let offset_start = postgres.len() - 3; let offset_start = postgres.len() - 3;
let offset = &postgres[offset_start..]; let offset = &postgres[offset_start..];
if (offset.starts_with('+') || offset.starts_with('-')) if (offset.starts_with('+') || offset.starts_with('-'))
&& offset[1..].chars().all(|character| character.is_ascii_digit()) && offset[1..]
.chars()
.all(|character| character.is_ascii_digit())
{ {
postgres.push_str(":00"); postgres.push_str(":00");
} }
@@ -305,9 +316,9 @@ pub struct Pad {
pub async fn find_pad(pool: &Database, 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>(queries::get(pool.kind(), queries::Q011)) sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011))
.bind(slug) .bind(slug)
.fetch_optional(pool.pool()) .fetch_optional(pool.pool())
.await .await
} }
pub async fn create_pad( pub async fn create_pad(
@@ -316,22 +327,27 @@ pub async fn create_pad(
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);
sqlx::query(queries::get(pool.kind(), queries::Q012)) sqlx::query(queries::get(pool.kind(), queries::Q012))
.bind(slug) .bind(slug)
.bind(title) .bind(title)
.bind(password_hash) .bind(password_hash)
.execute(pool.pool()) .execute(pool.pool())
.await?; .await?;
sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011))
.bind(slug) .bind(slug)
.fetch_one(pool.pool()) .fetch_one(pool.pool())
.await .await
} }
pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool {
match (&pad.password_hash, password.filter(|value| !value.is_empty())) { match (
&pad.password_hash,
password.filter(|value| !value.is_empty()),
) {
(None, _) => true, (None, _) => true,
(Some(hash), Some(password)) => PasswordHash::new(hash) (Some(hash), Some(password)) => PasswordHash::new(hash)
.ok() .ok()
@@ -380,9 +396,9 @@ pub async fn list_pad_revisions(
pad_id: i64, pad_id: i64,
) -> Result<Vec<Revision>, sqlx::Error> { ) -> Result<Vec<Revision>, sqlx::Error> {
sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016)) sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016))
.bind(pad_id) .bind(pad_id)
.fetch_all(pool.pool()) .fetch_all(pool.pool())
.await .await
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@@ -407,7 +423,6 @@ struct PublishedPageRow {
updated_at: String, updated_at: String,
} }
#[derive(Debug, Clone, FromRow)] #[derive(Debug, Clone, FromRow)]
struct PostgresPublishedPageRow { struct PostgresPublishedPageRow {
token: String, token: String,
@@ -421,7 +436,15 @@ struct PostgresPublishedPageRow {
impl From<PostgresPublishedPageRow> for PublishedPage { impl From<PostgresPublishedPageRow> for PublishedPage {
fn from(value: PostgresPublishedPageRow) -> Self { fn from(value: PostgresPublishedPageRow) -> Self {
Self { token: value.token, pad_id: value.pad_id, note_id: value.note_id, allow_task_updates: value.allow_task_updates, title: value.title, content: value.content, updated_at: value.updated_at } Self {
token: value.token,
pad_id: value.pad_id,
note_id: value.note_id,
allow_task_updates: value.allow_task_updates,
title: value.title,
content: value.content,
updated_at: value.updated_at,
}
} }
} }
impl From<PublishedPageRow> for PublishedPage { impl From<PublishedPageRow> for PublishedPage {
@@ -472,78 +495,151 @@ pub async fn publish_note(pool: &Database, note_id: i64) -> Result<String, sqlx:
Ok(token) Ok(token)
} }
pub async fn find_published_page(pool: &Database, token: &str) -> Result<Option<PublishedPage>, sqlx::Error> { pub async fn find_published_page(
pool: &Database,
token: &str,
) -> Result<Option<PublishedPage>, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres { if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_as::<_, PostgresPublishedPageRow>(queries::Q021_POSTGRES) return Ok(
.bind(token).fetch_optional(pool.pool()).await?.map(Into::into)); sqlx::query_as::<_, PostgresPublishedPageRow>(queries::Q021_POSTGRES)
.bind(token)
.fetch_optional(pool.pool())
.await?
.map(Into::into),
);
} }
Ok(sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021)) Ok(
.bind(token).fetch_optional(pool.pool()).await?.map(Into::into)) sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021))
.bind(token)
.fetch_optional(pool.pool())
.await?
.map(Into::into),
)
} }
pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> { pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres { if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::Q044_POSTGRES) return Ok(sqlx::query_scalar::<_, bool>(queries::Q044_POSTGRES)
.bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(false)); .bind(pad_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(false));
} }
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044)) let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044))
.bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(0); .bind(pad_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(0);
Ok(value != 0) Ok(value != 0)
} }
pub async fn note_public_task_updates(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> { pub async fn note_public_task_updates(pool: &Database, note_id: i64) -> Result<bool, sqlx::Error> {
if pool.kind() == DatabaseKind::Postgres { if pool.kind() == DatabaseKind::Postgres {
return Ok(sqlx::query_scalar::<_, bool>(queries::Q045_POSTGRES) return Ok(sqlx::query_scalar::<_, bool>(queries::Q045_POSTGRES)
.bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(false)); .bind(note_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(false));
} }
let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045)) let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045))
.bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(0); .bind(note_id)
.fetch_optional(pool.pool())
.await?
.unwrap_or(0);
Ok(value != 0) Ok(value != 0)
} }
pub async fn set_pad_public_task_updates(pool: &Database, pad_id: i64, allow: bool) -> Result<(), sqlx::Error> { pub async fn set_pad_public_task_updates(
pool: &Database,
pad_id: i64,
allow: bool,
) -> Result<(), sqlx::Error> {
publish_pad(pool, pad_id).await?; publish_pad(pool, pad_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q040)); let mut query = sqlx::query(queries::get(pool.kind(), queries::Q040));
query = if pool.kind() == DatabaseKind::Postgres { query.bind(allow) } else { query.bind(if allow { 1i64 } else { 0i64 }) }; query = if pool.kind() == DatabaseKind::Postgres {
query.bind(allow)
} else {
query.bind(if allow { 1i64 } else { 0i64 })
};
query.bind(pad_id).execute(pool.pool()).await?; query.bind(pad_id).execute(pool.pool()).await?;
Ok(()) Ok(())
} }
pub async fn set_note_public_task_updates(pool: &Database, note_id: i64, allow: bool) -> Result<(), sqlx::Error> { pub async fn set_note_public_task_updates(
pool: &Database,
note_id: i64,
allow: bool,
) -> Result<(), sqlx::Error> {
publish_note(pool, note_id).await?; publish_note(pool, note_id).await?;
let mut query = sqlx::query(queries::get(pool.kind(), queries::Q041)); let mut query = sqlx::query(queries::get(pool.kind(), queries::Q041));
query = if pool.kind() == DatabaseKind::Postgres { query.bind(allow) } else { query.bind(if allow { 1i64 } else { 0i64 }) }; query = if pool.kind() == DatabaseKind::Postgres {
query.bind(allow)
} else {
query.bind(if allow { 1i64 } else { 0i64 })
};
query.bind(note_id).execute(pool.pool()).await?; query.bind(note_id).execute(pool.pool()).await?;
Ok(()) Ok(())
} }
pub async fn update_public_task(pool: &Database, token: &str, source_line: usize, checked: bool) -> Result<Option<PublishedPage>, sqlx::Error> { pub async fn update_public_task(
let Some(mut page) = find_published_page(pool, token).await? else { return Ok(None); }; pool: &Database,
if !page.allow_task_updates || source_line == 0 { return Ok(Some(page)); } token: &str,
source_line: usize,
checked: bool,
) -> Result<Option<PublishedPage>, sqlx::Error> {
let Some(mut page) = find_published_page(pool, token).await? else {
return Ok(None);
};
if !page.allow_task_updates || source_line == 0 {
return Ok(Some(page));
}
let mut lines: Vec<String> = page.content.split('\n').map(str::to_owned).collect(); let mut lines: Vec<String> = page.content.split('\n').map(str::to_owned).collect();
let Some(line) = lines.get_mut(source_line - 1) else { return Ok(Some(page)); }; let Some(line) = lines.get_mut(source_line - 1) else {
return Ok(Some(page));
};
let bytes = line.as_bytes(); let bytes = line.as_bytes();
let mut i = 0usize; let mut i = 0usize;
while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; } while i < bytes.len() && bytes[i].is_ascii_whitespace() {
if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') { return Ok(Some(page)); } i += 1;
}
if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') {
return Ok(Some(page));
}
i += 1; i += 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; } while i < bytes.len() && bytes[i].is_ascii_whitespace() {
if i + 2 >= bytes.len() || bytes[i] != b'[' || !matches!(bytes[i + 1], b' ' | b'x' | b'X') || bytes[i + 2] != b']' { return Ok(Some(page)); } i += 1;
}
if i + 2 >= bytes.len()
|| bytes[i] != b'['
|| !matches!(bytes[i + 1], b' ' | b'x' | b'X')
|| bytes[i + 2] != b']'
{
return Ok(Some(page));
}
line.replace_range(i + 1..i + 2, if checked { "x" } else { " " }); line.replace_range(i + 1..i + 2, if checked { "x" } else { " " });
page.content = lines.join("\n"); page.content = lines.join("\n");
if let Some(id) = page.pad_id { if let Some(id) = page.pad_id {
sqlx::query(queries::get(pool.kind(), queries::Q042)).bind(&page.content).bind(id).execute(pool.pool()).await?; sqlx::query(queries::get(pool.kind(), queries::Q042))
.bind(&page.content)
.bind(id)
.execute(pool.pool())
.await?;
} else if let Some(id) = page.note_id { } else if let Some(id) = page.note_id {
sqlx::query(queries::get(pool.kind(), queries::Q043)).bind(&page.content).bind(id).execute(pool.pool()).await?; sqlx::query(queries::get(pool.kind(), queries::Q043))
.bind(&page.content)
.bind(id)
.execute(pool.pool())
.await?;
} }
find_published_page(pool, token).await find_published_page(pool, token).await
} }
pub async fn pad_file_token(pool: &Database, 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>>(queries::get(pool.kind(), queries::Q022)) if let Some(token) =
.bind(pad_id) sqlx::query_scalar::<_, Option<String>>(queries::get(pool.kind(), queries::Q022))
.fetch_one(pool.pool()) .bind(pad_id)
.await? .fetch_one(pool.pool())
.await?
{ {
return Ok(token); return Ok(token);
} }
@@ -562,10 +658,11 @@ pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result<String, sqlx
} }
pub async fn note_file_token(pool: &Database, 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>>(queries::get(pool.kind(), queries::Q024)) if let Some(token) =
.bind(note_id) sqlx::query_scalar::<_, Option<String>>(queries::get(pool.kind(), queries::Q024))
.fetch_one(pool.pool()) .bind(note_id)
.await? .fetch_one(pool.pool())
.await?
{ {
return Ok(token); return Ok(token);
} }
@@ -583,7 +680,6 @@ pub async fn note_file_token(pool: &Database, note_id: i64) -> Result<String, sq
.await .await
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum FileOwnerKind { pub enum FileOwnerKind {
Pad, Pad,
@@ -596,25 +692,33 @@ pub struct FileOwner {
pub id: i64, pub id: i64,
} }
pub async fn find_file_owner(pool: &Database, 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>(queries::get(pool.kind(), queries::Q026)) if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q026))
.bind(token) .bind(token)
.fetch_optional(pool.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>(queries::get(pool.kind(), queries::Q027)) if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q027))
.bind(token) .bind(token)
.fetch_optional(pool.pool()) .fetch_optional(pool.pool())
.await? .await?
{ {
return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id })); return Ok(Some(FileOwner {
kind: FileOwnerKind::Note,
id,
}));
} }
Ok(None) Ok(None)
} }
#[derive(Debug, Clone, Serialize, FromRow)] #[derive(Debug, Clone, Serialize, FromRow)]
pub struct NoteFile { pub struct NoteFile {
pub id: i64, pub id: i64,
@@ -655,14 +759,29 @@ impl From<SqliteNoteFile> for NoteFile {
} }
pub async fn delete_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> { pub async fn delete_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q031)).bind(note_id).execute(pool.pool()).await?; sqlx::query(queries::get(pool.kind(), queries::Q031))
.bind(note_id)
.execute(pool.pool())
.await?;
Ok(()) Ok(())
} }
pub async fn register_note_file(pool: &Database, note_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> { pub async fn register_note_file(
pool: &Database,
note_id: i64,
filename: &str,
url: &str,
mime_type: &str,
size_bytes: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q032)) sqlx::query(queries::get(pool.kind(), queries::Q032))
.bind(note_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes) .bind(note_id)
.execute(pool.pool()).await?; .bind(filename)
.bind(url)
.bind(mime_type)
.bind(size_bytes)
.execute(pool.pool())
.await?;
Ok(()) Ok(())
} }
@@ -670,7 +789,11 @@ pub async fn list_note_files(pool: &Database, note_id: i64) -> Result<Vec<NoteFi
list_files(pool, queries::Q033, note_id).await list_files(pool, queries::Q033, note_id).await
} }
async fn list_files(pool: &Database, query: &'static str, owner_id: i64) -> Result<Vec<NoteFile>, sqlx::Error> { async fn list_files(
pool: &Database,
query: &'static str,
owner_id: i64,
) -> Result<Vec<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite { if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(query) return Ok(sqlx::query_as::<_, SqliteNoteFile>(query)
.bind(owner_id) .bind(owner_id)
@@ -686,17 +809,41 @@ async fn list_files(pool: &Database, query: &'static str, owner_id: i64) -> Resu
.await .await
} }
pub async fn set_note_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> { pub async fn set_note_file_attached(
let detached_at: Option<String> = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; pool: &Database,
sqlx::query(queries::get(pool.kind(), queries::Q034)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?; file_id: i64,
attached: bool,
) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
sqlx::query(queries::get(pool.kind(), queries::Q034))
.bind(attached)
.bind(detached_at)
.bind(file_id)
.execute(pool.pool())
.await?;
Ok(()) Ok(())
} }
pub async fn register_pad_file(
pub async fn register_pad_file(pool: &Database, pad_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> { pool: &Database,
pad_id: i64,
filename: &str,
url: &str,
mime_type: &str,
size_bytes: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q035)) sqlx::query(queries::get(pool.kind(), queries::Q035))
.bind(pad_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes) .bind(pad_id)
.execute(pool.pool()).await?; .bind(filename)
.bind(url)
.bind(mime_type)
.bind(size_bytes)
.execute(pool.pool())
.await?;
Ok(()) Ok(())
} }
@@ -704,14 +851,30 @@ pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result<Vec<NoteFile
list_files(pool, queries::Q036, pad_id).await list_files(pool, queries::Q036, pad_id).await
} }
pub async fn set_pad_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> { pub async fn set_pad_file_attached(
let detached_at: Option<String> = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; pool: &Database,
sqlx::query(queries::get(pool.kind(), queries::Q037)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?; file_id: i64,
attached: bool,
) -> Result<(), sqlx::Error> {
let detached_at: Option<String> = if attached {
None
} else {
Some(chrono::Utc::now().to_rfc3339())
};
sqlx::query(queries::get(pool.kind(), queries::Q037))
.bind(attached)
.bind(detached_at)
.bind(file_id)
.execute(pool.pool())
.await?;
Ok(()) Ok(())
} }
pub async fn find_note_file(
pub async fn find_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result<Option<NoteFile>, sqlx::Error> { pool: &Database,
note_id: i64,
file_id: i64,
) -> Result<Option<NoteFile>, sqlx::Error> {
if pool.kind() == DatabaseKind::Sqlite { if pool.kind() == DatabaseKind::Sqlite {
return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::Q038) return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::Q038)
.bind(file_id) .bind(file_id)
@@ -727,7 +890,11 @@ pub async fn find_note_file(pool: &Database, note_id: i64, file_id: i64) -> Resu
.await .await
} }
pub async fn delete_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result<(), sqlx::Error> { pub async fn delete_note_file(
pool: &Database,
note_id: i64,
file_id: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(queries::get(pool.kind(), queries::Q039)) sqlx::query(queries::get(pool.kind(), queries::Q039))
.bind(file_id) .bind(file_id)
.bind(note_id) .bind(note_id)
+32 -11
View File
@@ -1,11 +1,12 @@
mod api; mod api;
mod auth;
mod app; mod app;
mod auth;
mod config; mod config;
mod database; mod database;
mod db; mod db;
mod queries; mod queries;
mod state; mod state;
mod storage;
mod websocket; mod websocket;
use std::{net::SocketAddr, sync::Arc}; use std::{net::SocketAddr, sync::Arc};
@@ -30,11 +31,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
database_max_connections = config.database_max_connections, database_max_connections = config.database_max_connections,
static_dir = %config.static_dir, static_dir = %config.static_dir,
files_dir = %config.files_dir, files_dir = %config.files_dir,
storage_driver = match &config.storage { storage::StorageConfig::Local { .. } => "local", storage::StorageConfig::S3 { .. } => "s3" },
upload_max_size_bytes = config.upload_max_size_bytes, upload_max_size_bytes = config.upload_max_size_bytes,
asset_cache_max_age_seconds = config.asset_cache_max_age_seconds, asset_cache_max_age_seconds = config.asset_cache_max_age_seconds,
file_cache_max_age_seconds = config.file_cache_max_age_seconds, file_cache_max_age_seconds = config.file_cache_max_age_seconds,
registration_enabled = config.registration_enabled, registration_enabled = config.registration_enabled,
account_confirmation_required = config.account_confirmation_required, account_confirmation_required = config.account_confirmation_required,
share_confirmation_required = config.share_confirmation_required,
frontend_log_level = %config.frontend_log_level, frontend_log_level = %config.frontend_log_level,
anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days, anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days,
user_session_ttl_days = config.user_session_ttl_days, user_session_ttl_days = config.user_session_ttl_days,
@@ -42,8 +45,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
asset_version = %config.asset_version, asset_version = %config.asset_version,
"configuration loaded" "configuration loaded"
); );
if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) { if let Some(path) = config
if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; } .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)?;
}
} }
info!("connecting to database"); info!("connecting to database");
let db = Database::connect(&config.database_url, config.database_max_connections).await?; let db = Database::connect(&config.database_url, config.database_max_connections).await?;
@@ -51,17 +60,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
run_migrations(&db).await?; run_migrations(&db).await?;
info!(database_kind = ?db.kind(), "database migrations completed"); info!(database_kind = ?db.kind(), "database migrations completed");
std::fs::create_dir_all(&config.files_dir)?; let storage = storage::Storage::from_config(config.storage.clone()).await?;
info!(files_dir = %config.files_dir, "file storage ready"); info!(
storage_driver = storage.backend_name(),
"file storage ready"
);
let state = Arc::new(AppState::new( let state = Arc::new(AppState::new(
db, db,
config.asset_version.clone(), config.asset_version.clone(),
config.files_dir.clone(), storage,
config.upload_max_size_bytes, config.upload_max_size_bytes,
config.file_cache_max_age_seconds, config.file_cache_max_age_seconds,
config.smtp.clone(), config.smtp.clone(),
config.registration_enabled, config.registration_enabled,
config.account_confirmation_required, config.account_confirmation_required,
config.share_confirmation_required,
config.frontend_log_level.clone(), config.frontend_log_level.clone(),
config.anonymous_access_token_ttl_days, config.anonymous_access_token_ttl_days,
config.user_session_ttl_days, config.user_session_ttl_days,
@@ -120,12 +133,20 @@ async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError
DatabaseKind::Postgres => std::path::Path::new("migrations/postgres"), DatabaseKind::Postgres => std::path::Path::new("migrations/postgres"),
DatabaseKind::MySql => std::path::Path::new("migrations/mysql"), DatabaseKind::MySql => std::path::Path::new("migrations/mysql"),
}; };
sqlx::migrate::Migrator::new(path).await?.run(db.pool()).await sqlx::migrate::Migrator::new(path)
.await?
.run(db.pool())
.await
} }
fn database_kind_label(url: &str) -> &'static str { fn database_kind_label(url: &str) -> &'static str {
if url.starts_with("sqlite:") { "sqlite" } if url.starts_with("sqlite:") {
else if url.starts_with("postgres:") || url.starts_with("postgresql:") { "postgres" } "sqlite"
else if url.starts_with("mysql:") { "mysql" } } else if url.starts_with("postgres:") || url.starts_with("postgresql:") {
else { "unknown" } "postgres"
} else if url.starts_with("mysql:") {
"mysql"
} else {
"unknown"
}
} }
+104 -32
View File
@@ -1,6 +1,8 @@
use std::{collections::HashMap, sync::{Mutex, OnceLock}};
use crate::database::DatabaseKind; use crate::database::DatabaseKind;
use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
};
// Database bootstrap and identity helpers. // Database bootstrap and identity helpers.
pub const SQLITE_FOREIGN_KEYS_ON: &str = "PRAGMA foreign_keys = ON"; pub const SQLITE_FOREIGN_KEYS_ON: &str = "PRAGMA foreign_keys = ON";
@@ -8,55 +10,117 @@ pub const SQLITE_JOURNAL_WAL: &str = "PRAGMA journal_mode = WAL";
pub const SQLITE_BUSY_TIMEOUT: &str = "PRAGMA busy_timeout = 5000"; pub const SQLITE_BUSY_TIMEOUT: &str = "PRAGMA busy_timeout = 5000";
pub const SQLITE_LAST_INSERT_ID: &str = "SELECT last_insert_rowid()"; pub const SQLITE_LAST_INSERT_ID: &str = "SELECT last_insert_rowid()";
pub const MYSQL_LAST_INSERT_ID: &str = "SELECT LAST_INSERT_ID()"; pub const MYSQL_LAST_INSERT_ID: &str = "SELECT LAST_INSERT_ID()";
pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"; pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: &str =
pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('revisions', 'id'))"; "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))";
pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str =
"SELECT currval(pg_get_serial_sequence('revisions', 'id'))";
// Authentication queries. // Authentication queries.
pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)"; pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)";
pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?"; pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?";
pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?"; pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?";
pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?"; pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?";
pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str = "DELETE FROM account_confirmation_tokens WHERE user_id = ?"; pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str =
pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str = "INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; "DELETE FROM account_confirmation_tokens WHERE user_id = ?";
pub const AUTH_FIND_CONFIRMATION_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"; pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str =
pub const AUTH_CONFIRM_USER: &str = "UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"; "INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)";
pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str = "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?"; pub const AUTH_FIND_CONFIRMATION_TOKEN: &str =
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str = "DELETE FROM password_reset_tokens WHERE user_id = ?"; "SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?";
pub const AUTH_INSERT_RESET_TOKEN: &str = "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; pub const AUTH_CONFIRM_USER: &str =
pub const AUTH_FIND_RESET_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"; "UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?";
pub const AUTH_UPDATE_PASSWORD: &str = "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"; pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str =
pub const AUTH_MARK_RESET_TOKEN_USED: &str = "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?"; "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?";
pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str =
"DELETE FROM password_reset_tokens WHERE user_id = ?";
pub const AUTH_INSERT_RESET_TOKEN: &str =
"INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)";
pub const AUTH_FIND_RESET_TOKEN: &str =
"SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?";
pub const AUTH_UPDATE_PASSWORD: &str =
"UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?";
pub const AUTH_MARK_RESET_TOKEN_USED: &str =
"UPDATE password_reset_tokens SET used_at = ? WHERE token = ?";
pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?"; pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?";
pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?"; pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?";
pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"; pub const AUTH_INSERT_SESSION: &str =
pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE nickname_key = ?"; "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)";
pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = ?"; pub const AUTH_USER_BY_NICKNAME: &str =
"SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE nickname_key = ?";
pub const AUTH_USER_BY_EMAIL: &str =
"SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = ?";
pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"; pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?";
pub const USER_ATTACH_PAD: &str = "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"; pub const USER_ATTACH_PAD: &str =
"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?";
pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"; pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC";
pub const USER_LIST_PADS: &str = "SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"; pub const USER_LIST_PADS: &str = "SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC";
pub const USER_OWNS_WORKSPACE: &str = "SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"; pub const USER_OWNS_WORKSPACE: &str = "SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?";
pub const USER_OWNS_PAD: &str = "SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?"; pub const USER_OWNS_PAD: &str = "SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?";
pub const USER_DELETE_WORKSPACE: &str = "DELETE FROM workspaces WHERE slug = ?"; pub const USER_DELETE_WORKSPACE: &str = "DELETE FROM workspaces WHERE slug = ?";
pub const USER_DELETE_PAD: &str = "DELETE FROM pads WHERE slug = ?"; pub const USER_DELETE_PAD: &str = "DELETE FROM pads WHERE slug = ?";
pub const USER_SET_WORKSPACE_PASSWORD: &str = "UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; pub const USER_SET_WORKSPACE_PASSWORD: &str =
pub const USER_SET_PAD_PASSWORD: &str = "UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; "UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?";
pub const USER_SET_PAD_PASSWORD: &str =
"UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?";
pub const USER_SET_WORKSPACE_PRIVACY: &str =
"UPDATE workspaces SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?";
pub const USER_SET_PAD_PRIVACY: &str =
"UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?";
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: &str =
"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?";
pub const RESOURCE_ACCESS_TOKENS_INSERT: &str =
"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)";
pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: &str =
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?";
pub const RESOURCE_PERMISSION_DELETE_USER: &str =
"DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const RESOURCE_PERMISSION_INSERT: &str =
"INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)";
pub const SHARE_INVITATION_DELETE_USER: &str =
"DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const SHARE_INVITATION_INSERT: &str =
"INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)";
pub const SHARE_INVITATION_DELETE_TOKEN: &str =
"DELETE FROM resource_share_invitations WHERE token_hash = ?";
pub const SHARE_INVITATION_FIND_TOKEN: &str =
"SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?";
pub const SHARE_INVITATION_ACCEPT: &str =
"UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?";
pub const RESOURCE_SHARING_USERS: &str =
"SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email";
pub const RESOURCE_SHARING_LINKS: &str =
"SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC";
pub const RESOURCE_SHARING_PENDING: &str =
"SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email";
pub const SHARE_LINK_INSERT: &str =
"INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)";
pub const SHARE_LINK_UPDATE: &str =
"UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL";
pub const SHARE_LINK_REVOKE: &str =
"UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?";
pub const RESOURCE_PERMISSION_BY_USER: &str =
"SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?";
pub const SHARE_LINK_PERMISSION: &str =
"SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)";
pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = ?"; pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = ?";
pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"; 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, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"; pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by 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, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"; pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?";
pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"; pub const Q005: &str =
pub const Q006: &str = "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; "INSERT INTO notes (workspace_id, slug, title, protected, created_by) 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 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 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 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 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, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = ?"; pub const Q011: &str = "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = ?";
pub const Q012: &str = "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"; 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 Q013: &str =
pub const Q014: &str = "INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; "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 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 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 Q017: &str = "SELECT token FROM published_pages WHERE pad_id = ?";
@@ -74,10 +138,12 @@ pub const Q028: &str = "SELECT content FROM note_revisions WHERE id = ? AND note
pub const Q029: &str = "SELECT content FROM revisions WHERE id = ? AND pad_id = ?"; pub const Q029: &str = "SELECT content FROM revisions WHERE id = ? AND pad_id = ?";
pub const Q030: &str = "SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?"; pub const Q030: &str = "SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?";
pub const Q031: &str = "DELETE FROM notes WHERE id = ?"; pub const Q031: &str = "DELETE FROM notes WHERE id = ?";
pub const Q032: &str = "INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"; pub const Q032: &str =
"INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)";
pub const Q033: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC"; pub const Q033: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC";
pub const Q034: &str = "UPDATE note_files SET is_attached = ?, detached_at = ? WHERE id = ?"; pub const Q034: &str = "UPDATE note_files SET is_attached = ?, detached_at = ? WHERE id = ?";
pub const Q035: &str = "INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"; pub const Q035: &str =
"INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)";
pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC"; pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC";
pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?"; pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?";
pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?"; pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?";
@@ -86,10 +152,14 @@ pub const Q039: &str = "DELETE FROM note_files WHERE id = ? AND note_id = ?";
static POSTGRES_QUERIES: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new(); static POSTGRES_QUERIES: OnceLock<Mutex<HashMap<&'static str, &'static str>>> = OnceLock::new();
pub fn get(kind: DatabaseKind, query: &'static str) -> &'static str { pub fn get(kind: DatabaseKind, query: &'static str) -> &'static str {
if kind != DatabaseKind::Postgres { return query; } if kind != DatabaseKind::Postgres {
return query;
}
let cache = POSTGRES_QUERIES.get_or_init(|| Mutex::new(HashMap::new())); let cache = POSTGRES_QUERIES.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache.lock().expect("query cache lock poisoned"); let mut cache = cache.lock().expect("query cache lock poisoned");
if let Some(value) = cache.get(query) { return value; } if let Some(value) = cache.get(query) {
return value;
}
let cache_key = query; let cache_key = query;
let query = query.replace("CURRENT_TIMESTAMP", "(CURRENT_TIMESTAMP::text)"); let query = query.replace("CURRENT_TIMESTAMP", "(CURRENT_TIMESTAMP::text)");
let mut index = 0; let mut index = 0;
@@ -113,8 +183,10 @@ pub const Q041: &str = "UPDATE published_pages SET allow_task_updates = ? WHERE
pub const Q042: &str = "UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; pub const Q042: &str = "UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q043: &str = "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; pub const Q043: &str = "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?";
pub const Q044: &str = "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"; pub const Q044: &str =
pub const Q045: &str = "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"; "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?";
pub const Q045: &str =
"SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?";
pub const Q021_POSTGRES: &str = "SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, 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 = $1"; pub const Q021_POSTGRES: &str = "SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, 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 = $1";
pub const Q044_POSTGRES: &str = "SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"; pub const Q044_POSTGRES: &str = "SELECT allow_task_updates FROM published_pages WHERE pad_id = $1";
+102 -19
View File
@@ -1,13 +1,24 @@
use std::{collections::HashMap, sync::{Arc, atomic::{AtomicU64, Ordering}}};
use crate::database::Database; use crate::database::Database;
use tokio::sync::{broadcast, RwLock};
use serde::Serialize; use serde::Serialize;
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use tokio::sync::{RwLock, broadcast};
const CHANNEL_CAPACITY: usize = 256; const CHANNEL_CAPACITY: usize = 256;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SmtpConfig { pub struct SmtpConfig {
pub host: String, pub port: u16, pub username: String, pub password: String, pub from: String, pub public_url: String pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub from: String,
pub public_url: String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -36,12 +47,13 @@ pub enum RoomEvent {
pub struct AppState { pub struct AppState {
pub db: Database, pub db: Database,
pub asset_version: String, pub asset_version: String,
pub files_dir: String, pub storage: crate::storage::Storage,
pub upload_max_size_bytes: usize, pub upload_max_size_bytes: usize,
pub file_cache_max_age_seconds: u64, pub file_cache_max_age_seconds: u64,
pub smtp: Option<SmtpConfig>, pub smtp: Option<SmtpConfig>,
pub registration_enabled: bool, pub registration_enabled: bool,
pub account_confirmation_required: bool, pub account_confirmation_required: bool,
pub share_confirmation_required: bool,
pub frontend_log_level: String, pub frontend_log_level: String,
pub anonymous_access_token_ttl_days: i64, pub anonymous_access_token_ttl_days: i64,
pub user_session_ttl_days: i64, pub user_session_ttl_days: i64,
@@ -51,31 +63,98 @@ pub struct AppState {
} }
impl AppState { impl AppState {
pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option<SmtpConfig>, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { pub fn new(
Self { db, asset_version, files_dir, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) } db: Database,
asset_version: String,
storage: crate::storage::Storage,
upload_max_size_bytes: usize,
file_cache_max_age_seconds: u64,
smtp: Option<SmtpConfig>,
registration_enabled: bool,
account_confirmation_required: bool,
share_confirmation_required: bool,
frontend_log_level: String,
anonymous_access_token_ttl_days: i64,
user_session_ttl_days: i64,
) -> Self {
Self {
db,
asset_version,
storage,
upload_max_size_bytes,
file_cache_max_age_seconds,
smtp,
registration_enabled,
account_confirmation_required,
share_confirmation_required,
frontend_log_level,
anonymous_access_token_ttl_days,
user_session_ttl_days,
channels: RwLock::new(HashMap::new()),
presence: RwLock::new(HashMap::new()),
next_connection_id: AtomicU64::new(1),
}
} }
async fn channel_for_key(&self, key: String) -> broadcast::Sender<RoomEvent> { async fn channel_for_key(&self, key: String) -> broadcast::Sender<RoomEvent> {
if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } if let Some(sender) = self.channels.read().await.get(&key) {
return sender.clone();
}
let mut channels = self.channels.write().await; let mut channels = self.channels.write().await;
channels.entry(key).or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0).clone() channels
.entry(key)
.or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0)
.clone()
} }
pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String { format!("workspace:{workspace_slug}/{note_slug}") } pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String {
pub fn pad_room_key(slug: &str) -> String { format!("pad:{slug}") } format!("workspace:{workspace_slug}/{note_slug}")
pub async fn note_channel(&self, workspace_slug: &str, note_slug: &str) -> broadcast::Sender<RoomEvent> { self.channel_for_key(Self::note_room_key(workspace_slug, note_slug)).await } }
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> { self.channel_for_key(Self::pad_room_key(slug)).await } pub fn pad_room_key(slug: &str) -> String {
pub async fn join_room(&self, key: &str, nickname: String, color: Option<String>) -> (u64, Vec<PresenceUser>) { format!("pad:{slug}")
}
pub async fn note_channel(
&self,
workspace_slug: &str,
note_slug: &str,
) -> broadcast::Sender<RoomEvent> {
self.channel_for_key(Self::note_room_key(workspace_slug, note_slug))
.await
}
pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender<RoomEvent> {
self.channel_for_key(Self::pad_room_key(slug)).await
}
pub async fn join_room(
&self,
key: &str,
nickname: String,
color: Option<String>,
) -> (u64, Vec<PresenceUser>) {
let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed); let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed);
let mut presence = self.presence.write().await; let mut presence = self.presence.write().await;
let room = presence.entry(key.to_owned()).or_default(); let room = presence.entry(key.to_owned()).or_default();
room.insert(id, PresenceUser { name: nickname, color }); room.insert(
id,
PresenceUser {
name: nickname,
color,
},
);
(id, sorted_users(room)) (id, sorted_users(room))
} }
pub async fn update_room_color(&self, key: &str, id: u64, color: Option<String>) -> Vec<PresenceUser> { pub async fn update_room_color(
&self,
key: &str,
id: u64,
color: Option<String>,
) -> Vec<PresenceUser> {
let mut presence = self.presence.write().await; let mut presence = self.presence.write().await;
if let Some(room) = presence.get_mut(key) { if let Some(room) = presence.get_mut(key) {
if let Some(user) = room.get_mut(&id) { user.color = color; } if let Some(user) = room.get_mut(&id) {
user.color = color;
}
sorted_users(room) sorted_users(room)
} else { Vec::new() } } else {
Vec::new()
}
} }
pub async fn leave_room(&self, key: &str, id: u64) -> Vec<PresenceUser> { pub async fn leave_room(&self, key: &str, id: u64) -> Vec<PresenceUser> {
let mut presence = self.presence.write().await; let mut presence = self.presence.write().await;
@@ -83,9 +162,13 @@ impl AppState {
room.remove(&id); room.remove(&id);
let users = sorted_users(room); let users = sorted_users(room);
let empty = room.is_empty(); let empty = room.is_empty();
if empty { presence.remove(key); } if empty {
presence.remove(key);
}
users users
} else { Vec::new() } } else {
Vec::new()
}
} }
} }
+225
View File
@@ -0,0 +1,225 @@
use std::{path::PathBuf, sync::Arc};
use aws_config::Region;
use aws_credential_types::Credentials;
use aws_sdk_s3::{Client, config::Builder as S3ConfigBuilder, primitives::ByteStream};
use bytes::Bytes;
#[derive(Debug, Clone)]
pub enum StorageConfig {
Local {
root: PathBuf,
},
S3 {
endpoint: Option<String>,
region: String,
bucket: String,
access_key: String,
secret_key: String,
force_path_style: bool,
},
}
#[derive(Clone)]
pub enum Storage {
Local { root: PathBuf },
S3 { client: Client, bucket: Arc<str> },
}
impl std::fmt::Debug for Storage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Local { root } => f.debug_struct("LocalStorage").field("root", root).finish(),
Self::S3 { bucket, .. } => f.debug_struct("S3Storage").field("bucket", bucket).finish(),
}
}
}
impl Storage {
pub async fn from_config(config: StorageConfig) -> Result<Self, Box<dyn std::error::Error>> {
match config {
StorageConfig::Local { root } => {
tokio::fs::create_dir_all(&root).await?;
Ok(Self::Local { root })
}
StorageConfig::S3 {
endpoint,
region,
bucket,
access_key,
secret_key,
force_path_style,
} => {
let credentials =
Credentials::new(access_key, secret_key, None, None, "rustpad-env");
let shared = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(Region::new(region.clone()))
.credentials_provider(credentials)
.load()
.await;
let mut builder = S3ConfigBuilder::from(&shared)
.region(Region::new(region))
.force_path_style(force_path_style);
if let Some(endpoint) = endpoint.filter(|value| !value.trim().is_empty()) {
builder = builder.endpoint_url(endpoint);
}
Ok(Self::S3 {
client: Client::from_conf(builder.build()),
bucket: Arc::from(bucket),
})
}
}
}
pub fn backend_name(&self) -> &'static str {
match self {
Self::Local { .. } => "local",
Self::S3 { .. } => "s3",
}
}
pub async fn exists(&self, key: &str) -> Result<bool, StorageError> {
match self {
Self::Local { root } => Ok(root.join(key).is_file()),
Self::S3 { client, bucket } => match client
.head_object()
.bucket(bucket.as_ref())
.key(key)
.send()
.await
{
Ok(_) => Ok(true),
Err(error)
if error
.as_service_error()
.is_some_and(|service| service.is_not_found()) =>
{
Ok(false)
}
Err(error) => Err(StorageError::Backend(error.to_string())),
},
}
}
pub async fn put(
&self,
key: &str,
bytes: Bytes,
content_type: &str,
cache_control: &str,
) -> Result<(), StorageError> {
match self {
Self::Local { root } => {
let path = root.join(key);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(path, bytes).await?;
Ok(())
}
Self::S3 { client, bucket } => {
client
.put_object()
.bucket(bucket.as_ref())
.key(key)
.content_type(content_type)
.cache_control(cache_control)
.body(ByteStream::from(bytes))
.send()
.await
.map_err(|error| StorageError::Backend(error.to_string()))?;
Ok(())
}
}
}
pub async fn get(&self, key: &str) -> Result<Bytes, StorageError> {
match self {
Self::Local { root } => Ok(Bytes::from(tokio::fs::read(root.join(key)).await?)),
Self::S3 { client, bucket } => {
let output = client
.get_object()
.bucket(bucket.as_ref())
.key(key)
.send()
.await
.map_err(|error| StorageError::Backend(error.to_string()))?;
let bytes = output
.body
.collect()
.await
.map_err(|error| StorageError::Backend(error.to_string()))?
.into_bytes();
Ok(bytes)
}
}
}
pub async fn get_local_with_legacy(
&self,
key: &str,
legacy_key: &str,
) -> Result<Bytes, StorageError> {
match self {
Self::Local { root } => {
let canonical = root.join(key);
let path = if canonical.is_file() {
canonical
} else {
root.join(legacy_key)
};
Ok(Bytes::from(tokio::fs::read(path).await?))
}
Self::S3 { .. } => self.get(key).await,
}
}
pub async fn delete(&self, key: &str) -> Result<(), StorageError> {
match self {
Self::Local { root } => match tokio::fs::remove_file(root.join(key)).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
},
Self::S3 { client, bucket } => {
client
.delete_object()
.bucket(bucket.as_ref())
.key(key)
.send()
.await
.map_err(|error| StorageError::Backend(error.to_string()))?;
Ok(())
}
}
}
}
#[derive(Debug)]
pub enum StorageError {
Io(std::io::Error),
Backend(String),
}
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "{error}"),
Self::Backend(error) => f.write_str(error),
}
}
}
impl std::error::Error for StorageError {}
impl From<std::io::Error> for StorageError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
pub fn object_key(kind: &str, owner_id: i64, token: &str, filename: &str) -> String {
format!("{kind}/{owner_id}_{token}/{filename}")
}
pub fn legacy_key(owner_id: i64, token: &str, filename: &str) -> String {
format!("{owner_id}_{token}/{filename}")
}
+407 -130
View File
@@ -1,193 +1,470 @@
use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, response::Response}; use crate::{
auth, db,
state::{AppState, NoteUpdate, PresenceUser, RoomEvent, SharedState},
};
use axum::{
extract::{
Path, State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
response::Response,
};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use crate::{auth, db, state::{AppState, NoteUpdate, PresenceUser, RoomEvent, SharedState}};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tracing::{debug, info, warn};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage { enum ClientMessage {
Authenticate { password: Option<String>, access_token: Option<String>, nickname: Option<String>, session_token: Option<String>, color: Option<String> }, Authenticate {
Update { content: String, owner_map: Option<String> }, password: Option<String>,
Ping { nonce: u64 }, access_token: Option<String>,
Chat { text: String }, nickname: Option<String>,
SetColor { color: Option<String> }, session_token: Option<String>,
color: Option<String>,
},
Update {
content: String,
owner_map: Option<String>,
},
Ping {
nonce: u64,
},
Chat {
text: String,
},
SetColor {
color: Option<String>,
},
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
enum ServerMessage { enum ServerMessage {
Authenticated { workspace_title: String, note_title: String, content: String, owner_map: String }, Authenticated {
Document { content: String, revision_id: i64, updated_at: String, author: Option<String>, owner_map: String }, workspace_title: String,
Presence { users: Vec<PresenceUser> }, note_title: String,
Chat { sender: String, text: String }, content: String,
Pong { nonce: u64 }, owner_map: String,
Error { message: String }, },
Document {
content: String,
revision_id: i64,
updated_at: String,
author: Option<String>,
owner_map: String,
},
Presence {
users: Vec<PresenceUser>,
},
Chat {
sender: String,
text: String,
},
Pong {
nonce: u64,
},
Error {
message: String,
},
} }
pub async fn upgrade(ws: WebSocketUpgrade, Path((workspace_slug, note_slug)): Path<(String, String)>, State(state): State<SharedState>) -> Response { pub async fn upgrade(
ws: WebSocketUpgrade,
Path((workspace_slug, note_slug)): Path<(String, String)>,
State(state): State<SharedState>,
) -> Response {
ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug)) ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug))
} }
async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) { async fn handle_socket(
mut socket: WebSocket,
state: SharedState,
workspace_slug: String,
note_slug: String,
) {
info!(%workspace_slug, %note_slug, "note websocket connected"); info!(%workspace_slug, %note_slug, "note websocket connected");
let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); let _=send_error(&mut socket,"Workspace not found").await; return; }; let Some(workspace) = db::find_workspace(&state.db, &workspace_slug)
let Some(note) = db::find_note(&state.db, workspace.id, &note_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); let _=send_error(&mut socket,"Note not found").await; return; }; .await
.ok()
.flatten()
else {
warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found");
let _ = send_error(&mut socket, "Workspace not found").await;
return;
};
let Some(note) = db::find_note(&state.db, workspace.id, &note_slug)
.await
.ok()
.flatten()
else {
warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found");
let _ = send_error(&mut socket, "Note not found").await;
return;
};
let (password, access_token, nickname, session_token, color) = match socket.recv().await { let (password, access_token, nickname, session_token, color) = match socket.recv().await {
Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) { Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
Ok(ClientMessage::Authenticate { password, access_token, nickname, session_token, color }) => (password, access_token, clean_nickname(nickname), session_token, clean_color(color)), Ok(ClientMessage::Authenticate {
_ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; } password,
}, _ => return access_token,
nickname,
session_token,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
session_token,
clean_color(color),
),
_ => {
let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await;
return;
}
},
_ => return,
}; };
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { Ok(value) => value, Err(message) => { let _=send_error(&mut socket,&message).await; return; } }; let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
let permission = auth::resource_permission(&state, "workspace", &workspace_slug, session_token.as_deref().or(access_token.as_deref())).await.ok().flatten(); Ok(value) => value,
Err(message) => {
let _ = send_error(&mut socket, &message).await;
return;
}
};
let permission = auth::resource_permission(
&state,
"workspace",
&workspace_slug,
session_token.as_deref().or(access_token.as_deref()),
)
.await
.ok()
.flatten();
let password_ok = db::verify_workspace_password(&workspace, password.as_deref()); let password_ok = db::verify_workspace_password(&workspace, password.as_deref());
if workspace.is_private != 0 && permission.is_none() { let _=send_error(&mut socket,"This workspace is private").await; return; } if workspace.is_private != 0 && permission.is_none() {
if workspace.password_hash.is_some() && !password_ok && permission.is_none() { warn!(workspace_id = workspace.id, note_id = note.id, "note websocket rejected: invalid workspace password"); let _=send_error(&mut socket,"Invalid password").await; return; } let _ = send_error(&mut socket, "This workspace is private").await;
return;
}
if workspace.password_hash.is_some() && !password_ok && permission.is_none() {
warn!(
workspace_id = workspace.id,
note_id = note.id,
"note websocket rejected: invalid workspace password"
);
let _ = send_error(&mut socket, "Invalid password").await;
return;
}
let write_allowed = password_ok || permission.as_deref() != Some("ro"); let write_allowed = password_ok || permission.as_deref() != Some("ro");
info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated"); info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated");
if send(&mut socket,&ServerMessage::Authenticated { workspace_title:workspace.title.clone(), note_title:note.title.clone(), content:note.content.clone(), owner_map:note.owner_map.clone() }).await.is_err(){return;} if send(
&mut socket,
&ServerMessage::Authenticated {
workspace_title: workspace.title.clone(),
note_title: note.title.clone(),
content: note.content.clone(),
owner_map: note.owner_map.clone(),
},
)
.await
.is_err()
{
return;
}
let room_key = AppState::note_room_key(&workspace_slug, &note_slug); let room_key = AppState::note_room_key(&workspace_slug, &note_slug);
let channel=state.note_channel(&workspace_slug,&note_slug).await; let channel = state.note_channel(&workspace_slug, &note_slug).await;
let mut updates=channel.subscribe(); let mut updates = channel.subscribe();
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
let (connection_id, users) = state.join_room(&room_key, display_name.clone(), color).await; let (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color)
.await;
let _ = channel.send(RoomEvent::Presence(users)); let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1); let mut last_chat = Instant::now() - Duration::from_secs(1);
let (mut sender,mut receiver)=socket.split(); let (mut sender, mut receiver) = socket.split();
loop { tokio::select! { loop {
incoming=receiver.next()=>match incoming { tokio::select! {
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text) { incoming=receiver.next()=>match incoming {
Ok(ClientMessage::Update{content,owner_map})=>{ Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text) {
if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; } Ok(ClientMessage::Update{content,owner_map})=>{
if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; } if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; }
let owner_map=owner_map.unwrap_or_else(||"[]".into()); if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; }
match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { let owner_map=owner_map.unwrap_or_else(||"[]".into());
Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));} match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await {
Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"), Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));}
Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"),
}
} }
} Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; },
Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; }, Ok(ClientMessage::Chat{text})=>{
Ok(ClientMessage::Chat{text})=>{ let text=clean_chat(text);
let text=clean_chat(text); if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); }
if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } }
} Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); },
Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"),
Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"), },
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;}
}, },
Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;} update=updates.recv()=>match update {
}, Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;},
update=updates.recv()=>match update { Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;},
Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;}, Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;}, Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,&note_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, }
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
} }
}} }
let users = state.leave_room(&room_key, connection_id).await; let users = state.leave_room(&room_key, connection_id).await;
let _ = channel.send(RoomEvent::Presence(users)); let _ = channel.send(RoomEvent::Presence(users));
info!(workspace_id = workspace.id, note_id = note.id, "note websocket disconnected"); info!(
workspace_id = workspace.id,
note_id = note.id,
"note websocket disconnected"
);
} }
fn clean_nickname(value: Option<String>)->Option<String> { fn clean_nickname(value: Option<String>) -> Option<String> {
value.map(|v|v.trim().chars().take(40).collect::<String>()).filter(|v|!v.is_empty()) value
.map(|v| v.trim().chars().take(40).collect::<String>())
.filter(|v| !v.is_empty())
} }
fn clean_color(value: Option<String>) -> Option<String> { fn clean_color(value: Option<String>) -> Option<String> {
value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit())) value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| {
v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit())
})
} }
fn clean_chat(value: String) -> String { fn clean_chat(value: String) -> String {
value.chars().map(|c| if matches!(c, '\r' | '\n' | '\0') { ' ' } else { c }).collect::<String>().trim().chars().take(1000).collect() value
.chars()
.map(|c| {
if matches!(c, '\r' | '\n' | '\0') {
' '
} else {
c
}
})
.collect::<String>()
.trim()
.chars()
.take(1000)
.collect()
} }
async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error> { async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> {
send(socket,&ServerMessage::Error { send(
message:message.into() socket,
} &ServerMessage::Error {
).await message: message.into(),
},
)
.await
} }
async fn send(socket:&mut WebSocket,message:&ServerMessage)->Result<(),axum::Error> { async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> {
socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
} }
async fn send_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&ServerMessage)->Result<(),axum::Error> { async fn send_split(
sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &ServerMessage,
) -> Result<(), axum::Error> {
sender
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(tag="type",rename_all="snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
enum PadServerMessage { enum PadServerMessage {
Authenticated { title: String, content: String, owner_map: String }, Authenticated {
Document { content: String, revision_id: i64, updated_at: String, author: Option<String>, owner_map: String }, title: String,
Presence { users: Vec<PresenceUser> }, content: String,
Chat { sender: String, text: String }, owner_map: String,
Pong { nonce: u64 }, },
Error { message: String }, Document {
content: String,
revision_id: i64,
updated_at: String,
author: Option<String>,
owner_map: String,
},
Presence {
users: Vec<PresenceUser>,
},
Chat {
sender: String,
text: String,
},
Pong {
nonce: u64,
},
Error {
message: String,
},
} }
pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path<String>,State(state):State<SharedState>)->Response{ pub async fn upgrade_pad(
ws.on_upgrade(move|socket|handle_pad_socket(socket,state,slug)) ws: WebSocketUpgrade,
Path(slug): Path<String>,
State(state): State<SharedState>,
) -> Response {
ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug))
} }
async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) {
info!(%slug, "pad websocket connected"); info!(%slug, "pad websocket connected");
let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {warn!(%slug, "pad websocket rejected: pad not found");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;}; let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else {
let (password,access_token,nickname,session_token,color)=match socket.recv().await{ warn!(%slug, "pad websocket rejected: pad not found");
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){ let _ = send_pad(
Ok(ClientMessage::Authenticate{password,access_token,nickname,session_token,color})=>(password,access_token,clean_nickname(nickname),session_token,clean_color(color)), &mut socket,
_=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;} &PadServerMessage::Error {
},_=>return message: "Note not found".into(),
},
)
.await;
return;
}; };
let nickname=match auth::authorize_nickname(&state,nickname,session_token.clone()).await{Ok(value)=>value,Err(message)=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message}).await;return;}}; let (password, access_token, nickname, session_token, color) = match socket.recv().await {
let permission=auth::resource_permission(&state,"pad",&slug,session_token.as_deref().or(access_token.as_deref())).await.ok().flatten(); Some(Ok(Message::Text(text))) => match serde_json::from_str::<ClientMessage>(&text) {
let password_ok=db::verify_pad_password(&pad,password.as_deref()); Ok(ClientMessage::Authenticate {
if pad.is_private != 0 && permission.is_none(){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"This note is private".into()}).await;return;} password,
if pad.password_hash.is_some() && !password_ok && permission.is_none(){warn!(pad_id = pad.id, "pad websocket rejected: invalid password");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} access_token,
let write_allowed=password_ok || permission.as_deref()!=Some("ro"); nickname,
session_token,
color,
}) => (
password,
access_token,
clean_nickname(nickname),
session_token,
clean_color(color),
),
_ => {
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Wymagane uwierzytelnienie".into(),
},
)
.await;
return;
}
},
_ => return,
};
let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await {
Ok(value) => value,
Err(message) => {
let _ = send_pad(&mut socket, &PadServerMessage::Error { message }).await;
return;
}
};
let permission = auth::resource_permission(
&state,
"pad",
&slug,
session_token.as_deref().or(access_token.as_deref()),
)
.await
.ok()
.flatten();
let password_ok = db::verify_pad_password(&pad, password.as_deref());
if pad.is_private != 0 && permission.is_none() {
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "This note is private".into(),
},
)
.await;
return;
}
if pad.password_hash.is_some() && !password_ok && permission.is_none() {
warn!(pad_id = pad.id, "pad websocket rejected: invalid password");
let _ = send_pad(
&mut socket,
&PadServerMessage::Error {
message: "Invalid password".into(),
},
)
.await;
return;
}
let write_allowed = password_ok || permission.as_deref() != Some("ro");
info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated"); info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated");
if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){return;} if send_pad(
&mut socket,
&PadServerMessage::Authenticated {
title: pad.title.clone(),
content: pad.content.clone(),
owner_map: pad.owner_map.clone(),
},
)
.await
.is_err()
{
return;
}
let room_key = AppState::pad_room_key(&slug); let room_key = AppState::pad_room_key(&slug);
let channel=state.pad_channel(&slug).await; let channel = state.pad_channel(&slug).await;
let mut updates=channel.subscribe(); let mut updates = channel.subscribe();
let display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); let display_name = nickname.clone().unwrap_or_else(|| "Guest".into());
let (connection_id, users) = state.join_room(&room_key, display_name.clone(), color).await; let (connection_id, users) = state
.join_room(&room_key, display_name.clone(), color)
.await;
let _ = channel.send(RoomEvent::Presence(users)); let _ = channel.send(RoomEvent::Presence(users));
let mut last_chat = Instant::now() - Duration::from_secs(1); let mut last_chat = Instant::now() - Duration::from_secs(1);
let(mut sender,mut receiver)=socket.split(); let (mut sender, mut receiver) = socket.split();
loop{tokio::select!{ loop {
incoming=receiver.next()=>match incoming{ tokio::select! {
Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){ incoming=receiver.next()=>match incoming{
Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;} Some(Ok(Message::Text(text)))=>match serde_json::from_str::<ClientMessage>(&text){
if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; } Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;}
let owner_map=owner_map.unwrap_or_else(||"[]".into()); if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; }
if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{ let owner_map=owner_map.unwrap_or_else(||"[]".into());
let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map})); if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{
let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));
}
} }
} Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; },
Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; }, Ok(ClientMessage::Chat{text})=>{
Ok(ClientMessage::Chat{text})=>{ let text=clean_chat(text);
let text=clean_chat(text); if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); }
if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } }
} Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); },
Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, Ok(ClientMessage::Authenticate{..})=>{},
Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid pad websocket message"),
Err(error)=>warn!(%error,"invalid pad websocket message"), },
Some(Ok(Message::Close(_)))|None=>break,
Some(Ok(_))=>{},
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;}
}, },
Some(Ok(Message::Close(_)))|None=>break, update=updates.recv()=>match update{
Some(Ok(_))=>{}, Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;},
Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;} Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;},
}, Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
update=updates.recv()=>match update{ Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;}, Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;}, }
Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} },
Err(tokio::sync::broadcast::error::RecvError::Closed)=>break,
} }
}} }
let users = state.leave_room(&room_key, connection_id).await; let users = state.leave_room(&room_key, connection_id).await;
let _ = channel.send(RoomEvent::Presence(users)); let _ = channel.send(RoomEvent::Presence(users));
info!(pad_id = pad.id, "pad websocket disconnected"); info!(pad_id = pad.id, "pad websocket disconnected");
} }
async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error> { async fn send_pad(socket: &mut WebSocket, message: &PadServerMessage) -> Result<(), axum::Error> {
socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await socket
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
} }
async fn send_pad_split(sender:&mut futures_util::stream::SplitSink<WebSocket,Message>,message:&PadServerMessage)->Result<(),axum::Error> { async fn send_pad_split(
sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await sender: &mut futures_util::stream::SplitSink<WebSocket, Message>,
message: &PadServerMessage,
) -> Result<(), axum::Error> {
sender
.send(Message::Text(
serde_json::to_string(message).unwrap().into(),
))
.await
} }
+3618 -584
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -8,6 +9,7 @@
<title>__ERROR_TITLE__ · RustPad</title> <title>__ERROR_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"> <link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
</head> </head>
<body> <body>
<main class="error-page"> <main class="error-page">
<section class="error-card" aria-labelledby="error-title"> <section class="error-card" aria-labelledby="error-title">
@@ -21,4 +23,5 @@
</section> </section>
</main> </main>
</body> </body>
</html>
</html>
+32 -14
View File
@@ -1,13 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<title>RustPad</title> <title>RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"> <link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script> <script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/home.js?v=__ASSET_VERSION__"></script>
</head> </head>
<body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__"> <body class="home-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a></header> <header class="site-header home-header"><a class="brand home-brand" href="/">RustPad</a></header>
<main class="home-layout home-layout--wide"> <main class="home-layout home-layout--wide">
@@ -26,11 +30,15 @@
<div class="field"> <div class="field">
<label for="pad-name">Note name</label> <label for="pad-name">Note name</label>
<input id="pad-name" maxlength="80" required autocomplete="off" placeholder="Meeting notes"> <input id="pad-name" maxlength="80" required autocomplete="off" placeholder="Meeting notes">
<div class="field-meta"><span id="pad-slug-preview">/p/meeting-notes</span><span id="pad-name-count">0/80</span></div> <div class="field-meta"><span id="pad-slug-preview">/p/meeting-notes</span><span
id="pad-name-count">0/80</span></div>
</div> </div>
<div class="field"> <div class="field">
<div class="label-row"><label for="pad-password">Password</label><span>optional, min. 8 characters</span></div> <div class="label-row"><label for="pad-password">Password</label><span>optional, min. 8 characters</span>
<div class="password-input"><input id="pad-password" type="password" maxlength="128" autocomplete="new-password" placeholder="Note password"><button class="text-button password-toggle" type="button" data-target="pad-password">Show</button></div> </div>
<div class="password-input"><input id="pad-password" type="password" maxlength="128"
autocomplete="new-password" placeholder="Note password"><button class="text-button password-toggle"
type="button" data-target="pad-password">Show</button></div>
</div> </div>
<p id="pad-error" class="form-message error" role="alert"></p> <p id="pad-error" class="form-message error" role="alert"></p>
<button id="pad-button" class="primary-button" type="submit">Create note</button> <button id="pad-button" class="primary-button" type="submit">Create note</button>
@@ -46,11 +54,15 @@
<div class="field"> <div class="field">
<label for="workspace-name">Workspace name</label> <label for="workspace-name">Workspace name</label>
<input id="workspace-name" maxlength="80" required autocomplete="off" placeholder="My project"> <input id="workspace-name" maxlength="80" required autocomplete="off" placeholder="My project">
<div class="field-meta"><span id="workspace-slug-preview">/w/my-project</span><span id="workspace-name-count">0/80</span></div> <div class="field-meta"><span id="workspace-slug-preview">/w/my-project</span><span
id="workspace-name-count">0/80</span></div>
</div> </div>
<div class="field"> <div class="field">
<div class="label-row"><label for="workspace-password">Password</label><span>optional, min. 8 characters</span></div> <div class="label-row"><label for="workspace-password">Password</label><span>optional, min. 8
<div class="password-input"><input id="workspace-password" type="password" maxlength="128" autocomplete="new-password" placeholder="Workspace password"><button class="text-button password-toggle" type="button" data-target="workspace-password">Show</button></div> characters</span></div>
<div class="password-input"><input id="workspace-password" type="password" maxlength="128"
autocomplete="new-password" placeholder="Workspace password"><button class="text-button password-toggle"
type="button" data-target="workspace-password">Show</button></div>
</div> </div>
<p id="workspace-error" class="form-message error" role="alert"></p> <p id="workspace-error" class="form-message error" role="alert"></p>
<button id="workspace-button" class="primary-button" type="submit">Create workspace</button> <button id="workspace-button" class="primary-button" type="submit">Create workspace</button>
@@ -63,14 +75,16 @@
<div class="home-footer__inner"> <div class="home-footer__inner">
<div id="footer-account-guest" class="home-footer__account"> <div id="footer-account-guest" class="home-footer__account">
<button id="footer-login" class="footer-action" type="button">Log in</button> <button id="footer-login" class="footer-action" type="button">Log in</button>
<button id="footer-register" class="footer-action footer-action--primary" type="button">Register nickname</button> <button id="footer-register" class="footer-action footer-action--primary" type="button">Register
nickname</button>
</div> </div>
<div id="footer-account-user" class="home-footer__account" hidden> <div id="footer-account-user" class="home-footer__account" hidden>
<span id="footer-user-label" class="home-footer__user"></span> <span id="footer-user-label" class="home-footer__user"></span>
<button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button> <button id="footer-resources" class="footer-action footer-action--primary" type="button">My notes</button>
<button id="footer-logout" class="footer-action" type="button">Log out</button> <button id="footer-logout" class="footer-action" type="button">Log out</button>
</div> </div>
<span class="home-footer__author">Author: <a href="https://www.linuxiarz.pl" rel="author noopener">@linuxiarz.pl</a></span> <span class="home-footer__author">Author: <a href="https://www.linuxiarz.pl"
rel="author noopener">@linuxiarz.pl</a></span>
</div> </div>
</footer> </footer>
@@ -82,9 +96,12 @@
<p id="identity-copy" class="dialog-copy"></p> <p id="identity-copy" class="dialog-copy"></p>
</header> </header>
<div class="identity-fields"> <div class="identity-fields">
<label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true" placeholder="Your nickname"></label> <label>Nickname<input id="nickname" name="nickname" maxlength="40" autocomplete="off" data-bwignore="true"
<label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" required placeholder="you@example.com"></label> placeholder="Your nickname"></label>
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required placeholder="At least 8 characters"></label> <label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320"
autocomplete="username" required placeholder="you@example.com"></label>
<label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128"
autocomplete="current-password" required placeholder="At least 8 characters"></label>
</div> </div>
<button id="auth-submit" class="primary-button" type="submit">Log in</button> <button id="auth-submit" class="primary-button" type="submit">Log in</button>
<div class="identity-links"> <div class="identity-links">
@@ -95,7 +112,7 @@
<p id="identity-error" class="form-message" role="status"></p> <p id="identity-error" class="form-message" role="status"></p>
</form> </form>
</dialog> </dialog>
<dialog id="resources-dialog" class="app-dialog"> <dialog id="resources-dialog" class="app-dialog">
<div class="dialog-panel resources-panel"> <div class="dialog-panel resources-panel">
<button id="close-resources" class="modal-close" type="button" aria-label="Close dialog">×</button> <button id="close-resources" class="modal-close" type="button" aria-label="Close dialog">×</button>
<header class="resources-panel__header"> <header class="resources-panel__header">
@@ -107,4 +124,5 @@
</div> </div>
</dialog> </dialog>
</body> </body>
</html>
</html>
+1 -1
View File
@@ -280,7 +280,7 @@ export async function logoutCurrentSession() {
if (token) { if (token) {
try { try {
await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } }); await api("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
} catch {} } catch { }
} }
clearAuthSession(); clearAuthSession();
} }
+192 -5
View File
@@ -1,5 +1,192 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__NOTE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script></head> <!doctype html>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a id="workspace-link" class="brand" href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span><div class="document-heading"><h1 id="note-title">__NOTE_TITLE__</h1><p id="note-url" class="document-url"></p></div></div><div class="header-actions"><span class="user-color-control"><button id="current-user" class="user-chip" type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker" type="color" aria-label="Choose your color"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button" hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div></header> <html lang="en">
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="details">Collapsible section</button><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span> · <span id="socket-latency" title="WebSocket round-trip time">— ms</span> · <details id="room-details" class="room-details"><summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread" hidden></span></summary><div class="room-popover"><section class="room-users"><strong>In this room</strong><ul id="room-users"></ul></section><section class="room-chat"><div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after disconnect</span></div><div id="chat-messages" class="chat-messages" aria-live="polite"></div><form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000" autocomplete="off" placeholder="Write a message…" aria-label="Chat message"><button type="submit">Send</button></form></section></div></details></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog> <head>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a id="back-workspace" class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__NOTE_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/note.js?v=__ASSET_VERSION__"></script>
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header">
<div class="app-header__main"><a id="workspace-link" class="brand"
href="/w/__WORKSPACE_SLUG__">__WORKSPACE_TITLE__</a><span class="header-divider"></span>
<div class="document-heading">
<h1 id="note-title">__NOTE_TITLE__</h1>
<p id="note-url" class="document-url"></p>
</div>
</div>
<div class="header-actions"><span class="user-color-control"><button id="current-user" class="user-chip"
type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
type="color" aria-label="Choose your color"></span><button id="copy-link"
class="secondary-button">Copy link</button><button id="publish-page"
class="secondary-button">Page</button><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
type="checkbox"> Editable tasks on Page</label><button id="files-button"
class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button"
hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div>
</header>
<main class="editor-layout">
<section class="editor-panel">
<div class="editor-toolbar">
<div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button
data-format="italic" title="Italic"><em>I</em></button><button data-format="strike"
title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button
data-format="heading2">H2</button><button data-format="heading3">H3</button><button
data-format="heading4">H4</button><button data-format="bullet">• List</button><button
data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button
data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button
data-format="quote">Quote</button><button data-format="link">Link</button>
<details class="markdown-more">
<summary title="Extended Markdown">More</summary>
<div class="markdown-more-menu"><button type="button" data-format="details">Collapsible
section</button><button type="button" data-format="inline-code">Inline
code</button><button type="button" data-format="codeblock">Code block</button><button
type="button" data-format="table">Table</button><button type="button"
data-format="footnote">Footnote</button><button type="button"
data-format="definition">Definition</button><button type="button"
data-format="highlight">Highlight</button><button type="button"
data-format="subscript">Subscript</button><button type="button"
data-format="superscript">Superscript</button><button type="button"
data-format="horizontal-rule">Horizontal rule</button></div>
</details>
</div>
<div class="editor-controls"><label>Font<select id="font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
<option value="serif">Serif</option>
<option value="arial">Arial</option>
<option value="georgia">Georgia</option>
</select></label><label>Size<select id="font-size">
<option value="14" selected>14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
</select></label></div><button id="upload-button"
class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label
class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label>
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
aria-pressed="true">Markdown</button>
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
class="active">Split</button><button data-view="preview">Preview</button></div>
</div>
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label">Editor</div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
</div>
</div>
<div class="preview-column">
<div id="preview-label" class="column-label">Markdown preview</div>
<article id="preview" class="preview markdown-body"></article>
</div>
</div>
<footer class="editor-footer">
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> ·
<span class="footer-status status"><span id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
title="WebSocket round-trip time">— ms</span> · <details id="room-details" class="room-details">
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
hidden></span></summary>
<div class="room-popover">
<section class="room-users"><strong>In this room</strong>
<ul id="room-users"></ul>
</section>
<section class="room-chat">
<div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after
disconnect</span></div>
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000"
autocomplete="off" placeholder="Write a message…"
aria-label="Chat message"><button type="submit">Send</button></form>
</section>
</div>
</details>
</div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button"
aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link"
type="button">0 files</button> · <span id="save-state">Changes are saved
automatically</span></span>
</footer>
</section>
<aside id="history-panel" class="history-panel" aria-hidden="true">
<div class="history-header">
<div>
<h2>Change history</h2>
<p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button>
</div>
<div id="history-list" class="history-list"></div>
</aside>
</main>
<dialog id="shortcuts-dialog">
<div class="dialog-panel shortcuts-panel">
<div class="files-head">
<div>
<h2>Keyboard shortcuts</h2>
<p>Use Ctrl on Windows/Linux or Cmd on macOS.</p>
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
</div>
<div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div>
</div>
</dialog>
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
<div class="image-editor-panel files-panel">
<div class="files-head">
<div>
<h2>Note files</h2>
<p>Copy a direct link or ready Markdown/HTML code.</p>
</div><button id="close-files" class="icon-button" type="button">×</button>
</div>
<div id="files-list" class="files-list"></div>
</div>
</dialog>
<dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity"
class="modal-close" type="button" aria-label="Close dialog">×</button>
<h2>What should we call you?</h2>
<p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input
id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required
placeholder="Name or nickname">
<div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email"
name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
id="auth-submit" class="primary-button" type="submit">Log in and continue</button>
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
password?</button><button id="auth-back" class="text-button" type="button">Back to
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
account</button></div>
</section>
<p id="identity-error" class="form-message error" role="alert"></p>
</form>
</dialog>
<dialog id="password-dialog">
<form id="password-form" class="dialog-panel">
<h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password"
required placeholder="Password">
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
id="back-workspace" class="dialog-link" href="/">Back</a>
</form>
</dialog>
<div id="toast" class="toast"></div>
</body>
</html>
+191 -5
View File
@@ -1,5 +1,191 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__PAD_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script></head> <!doctype html>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__"><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="pad-title">__PAD_TITLE__</h1><p id="pad-url" class="document-url"></p></div></div><div class="header-actions"><span class="user-color-control"><button id="current-user" class="user-chip" type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker" type="color" aria-label="Choose your color"></span><button id="copy-link" class="secondary-button">Copy link</button><button id="publish-page" class="secondary-button">Page</button><label class="public-task-toggle" title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button id="files-button" class="secondary-button">Files</button><button id="history-button" class="secondary-button">History</button></div></header> <html lang="en">
<main class="editor-layout"><section class="editor-panel"><div class="editor-toolbar"><div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button data-format="italic" title="Italic"><em>I</em></button><button data-format="strike" title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button data-format="heading2">H2</button><button data-format="heading3">H3</button><button data-format="heading4">H4</button><button data-format="bullet">• List</button><button data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button data-format="quote">Quote</button><button data-format="link">Link</button><details class="markdown-more"><summary title="Extended Markdown">More</summary><div class="markdown-more-menu"><button type="button" data-format="details">Collapsible section</button><button type="button" data-format="inline-code">Inline code</button><button type="button" data-format="codeblock">Code block</button><button type="button" data-format="table">Table</button><button type="button" data-format="footnote">Footnote</button><button type="button" data-format="definition">Definition</button><button type="button" data-format="highlight">Highlight</button><button type="button" data-format="subscript">Subscript</button><button type="button" data-format="superscript">Superscript</button><button type="button" data-format="horizontal-rule">Horizontal rule</button></div></details></div><div class="editor-controls"><label>Font<select id="font-family"><option value="mono">Mono</option><option value="system">System</option><option value="serif">Serif</option><option value="arial">Arial</option><option value="georgia">Georgia</option></select></label><label>Size<select id="font-size"><option value="14" selected>14</option><option value="16">16</option><option value="18">18</option><option value="20">20</option><option value="22">22</option></select></label></div><button id="upload-button" class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label><div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active" aria-pressed="true">Markdown</button><div class="view-switch"><button data-view="edit">Edit</button><button data-view="split" class="active">Split</button><button data-view="preview">Preview</button></div></div><div id="editor-workspace" class="workspace view-split"><div class="editor-column"><div class="column-label">Editor</div><div class="editor-shell"><div id="line-gutter" class="line-gutter" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor" wrap="off" placeholder="Start writing…" spellcheck="false"></textarea></div></div><div class="preview-column"><div id="preview-label" class="column-label">Markdown preview</div><article id="preview" class="preview markdown-body"></article></div></div><footer class="editor-footer"><div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> · <span class="footer-status status"><span id="status-dot" class="status__dot"></span><span id="status-text">Connecting…</span></span> · <span id="socket-latency" title="WebSocket round-trip time">— ms</span> · <details id="room-details" class="room-details"><summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread" hidden></span></summary><div class="room-popover"><section class="room-users"><strong>In this room</strong><ul id="room-users"></ul></section><section class="room-chat"><div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after disconnect</span></div><div id="chat-messages" class="chat-messages" aria-live="polite"></div><form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000" autocomplete="off" placeholder="Write a message…" aria-label="Chat message"><button type="submit">Send</button></form></section></div></details></div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button" aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link" type="button">0 files</button> · <span id="save-state">Changes are saved automatically</span></span></footer></section><aside id="history-panel" class="history-panel" aria-hidden="true"><div class="history-header"><div><h2>Change history</h2><p>Author, time, and version preview</p></div><button id="close-history" class="icon-button">×</button></div><div id="history-list" class="history-list"></div></aside></main>
<dialog id="shortcuts-dialog"><div class="dialog-panel shortcuts-panel"><div class="files-head"><div><h2>Keyboard shortcuts</h2><p>Use Ctrl on Windows/Linux or Cmd on macOS.</p></div><button id="close-shortcuts" class="icon-button" type="button">×</button></div><div class="shortcut-grid"><kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div></div></dialog><dialog id="files-dialog" class="image-editor-dialog files-dialog"><div class="image-editor-panel files-panel"><div class="files-head"><div><h2>Note files</h2><p>Copy a direct link or ready Markdown/HTML code.</p></div><button id="close-files" class="icon-button" type="button">×</button></div><div id="files-list" class="files-list"></div></div></dialog><dialog id="identity-dialog"><form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity" class="modal-close" type="button" aria-label="Close dialog">×</button><h2>What should we call you?</h2><p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required placeholder="Name or nickname"><div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as guest</button><button id="show-register" class="text-button" type="button">Register</button><button id="show-login" class="text-button" type="button">Log in</button></div><section id="auth-panel" class="auth-panel" hidden><h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email" name="username" type="email" maxlength="320" autocomplete="username" placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password" type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button id="auth-submit" class="primary-button" type="submit">Log in and continue</button><div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot password?</button><button id="auth-back" class="text-button" type="button">Back to nickname</button><button id="logout-account" class="text-button" type="button">Log out saved account</button></div></section><p id="identity-error" class="form-message error" role="alert"></p></form></dialog> <head>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a class="dialog-link" href="/">Back</a></form></dialog><div id="toast" class="toast"></div></body></html> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__PAD_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/pad.js?v=__ASSET_VERSION__"></script>
</head>
<body class="pad-page" data-registration-enabled="__REGISTRATION_ENABLED__">
<header class="app-header">
<div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span>
<div class="document-heading">
<h1 id="pad-title">__PAD_TITLE__</h1>
<p id="pad-url" class="document-url"></p>
</div>
</div>
<div class="header-actions"><span class="user-color-control"><button id="current-user" class="user-chip"
type="button" title="Change your color"><span class="user-chip__dot" aria-hidden="true"></span><span
class="user-chip__name"></span></button><input id="user-color-picker" class="user-color-picker"
type="color" aria-label="Choose your color"></span><button id="copy-link"
class="secondary-button">Copy link</button><button id="publish-page"
class="secondary-button">Page</button><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
type="checkbox"> Editable tasks on Page</label><button id="files-button"
class="secondary-button">Files</button><button id="history-button"
class="secondary-button">History</button></div>
</header>
<main class="editor-layout">
<section class="editor-panel">
<div class="editor-toolbar">
<div class="toolbar-group"><button data-format="bold" title="Bold"><strong>B</strong></button><button
data-format="italic" title="Italic"><em>I</em></button><button data-format="strike"
title="Strikethrough"><s>S</s></button><button data-format="heading1">H1</button><button
data-format="heading2">H2</button><button data-format="heading3">H3</button><button
data-format="heading4">H4</button><button data-format="bullet">• List</button><button
data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List</button><button
data-format="task" title="Task list · Ctrl/Cmd+Shift+9">☑ Task</button><button
data-format="quote">Quote</button><button data-format="link">Link</button>
<details class="markdown-more">
<summary title="Extended Markdown">More</summary>
<div class="markdown-more-menu"><button type="button" data-format="details">Collapsible
section</button><button type="button" data-format="inline-code">Inline
code</button><button type="button" data-format="codeblock">Code block</button><button
type="button" data-format="table">Table</button><button type="button"
data-format="footnote">Footnote</button><button type="button"
data-format="definition">Definition</button><button type="button"
data-format="highlight">Highlight</button><button type="button"
data-format="subscript">Subscript</button><button type="button"
data-format="superscript">Superscript</button><button type="button"
data-format="horizontal-rule">Horizontal rule</button></div>
</details>
</div>
<div class="editor-controls"><label>Font<select id="font-family">
<option value="mono">Mono</option>
<option value="system">System</option>
<option value="serif">Serif</option>
<option value="arial">Arial</option>
<option value="georgia">Georgia</option>
</select></label><label>Size<select id="font-size">
<option value="14" selected>14</option>
<option value="16">16</option>
<option value="18">18</option>
<option value="20">20</option>
<option value="22">22</option>
</select></label></div><button id="upload-button"
class="toolbar-action">Image/file</button><input id="file-input" type="file" hidden><label
class="line-toggle"><input id="line-numbers-toggle" type="checkbox" checked> Lines</label><label
class="line-toggle"><input id="compact-toggle" type="checkbox" checked> Compact</label>
<div class="toolbar-fill"></div><button id="mode-toggle" class="markdown-toggle active"
aria-pressed="true">Markdown</button>
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
class="active">Split</button><button data-view="preview">Preview</button></div>
</div>
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label">Editor</div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
wrap="off" placeholder="Start writing…" spellcheck="false"></textarea>
</div>
</div>
<div class="preview-column">
<div id="preview-label" class="column-label">Markdown preview</div>
<article id="preview" class="preview markdown-body"></article>
</div>
</div>
<footer class="editor-footer">
<div class="footer-left"><span id="characters">0 characters</span> · <span id="words">0 words</span> ·
<span class="footer-status status"><span id="status-dot" class="status__dot"></span><span
id="status-text">Connecting…</span></span> · <span id="socket-latency"
title="WebSocket round-trip time">— ms</span> · <details id="room-details" class="room-details">
<summary><span id="room-count">0 users</span><span id="chat-unread" class="chat-unread"
hidden></span></summary>
<div class="room-popover">
<section class="room-users"><strong>In this room</strong>
<ul id="room-users"></ul>
</section>
<section class="room-chat">
<div class="room-chat__head"><strong>Room chat</strong><span>Messages disappear after
disconnect</span></div>
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" class="chat-form"><input id="chat-input" maxlength="1000"
autocomplete="off" placeholder="Write a message…"
aria-label="Chat message"><button type="submit">Send</button></form>
</section>
</div>
</details>
</div><span class="footer-right"><button id="shortcuts-button" class="footer-link" type="button"
aria-haspopup="dialog">Shortcuts</button> · <button id="footer-files" class="footer-link"
type="button">0 files</button> · <span id="save-state">Changes are saved
automatically</span></span>
</footer>
</section>
<aside id="history-panel" class="history-panel" aria-hidden="true">
<div class="history-header">
<div>
<h2>Change history</h2>
<p>Author, time, and version preview</p>
</div><button id="close-history" class="icon-button">×</button>
</div>
<div id="history-list" class="history-list"></div>
</aside>
</main>
<dialog id="shortcuts-dialog">
<div class="dialog-panel shortcuts-panel">
<div class="files-head">
<div>
<h2>Keyboard shortcuts</h2>
<p>Use Ctrl on Windows/Linux or Cmd on macOS.</p>
</div><button id="close-shortcuts" class="icon-button" type="button">×</button>
</div>
<div class="shortcut-grid">
<kbd>Ctrl/Cmd+Z</kbd><span>Undo</span><kbd>Ctrl/Cmd+B</kbd><span>Bold</span><kbd>Ctrl/Cmd+I</kbd><span>Italic</span><kbd>Ctrl/Cmd+Shift+X</kbd><span>Strikethrough</span><kbd>Ctrl/Cmd+K</kbd><span>Link</span><kbd>Ctrl/Cmd+Shift+7</kbd><span>Numbered
list</span><kbd>Ctrl/Cmd+Shift+8</kbd><span>Bullet list</span><kbd>Ctrl/Cmd+Shift+9</kbd><span>Task
list</span><kbd>Alt+1…4</kbd><span>Headings H1H4</span></div>
</div>
</dialog>
<dialog id="files-dialog" class="image-editor-dialog files-dialog">
<div class="image-editor-panel files-panel">
<div class="files-head">
<div>
<h2>Note files</h2>
<p>Copy a direct link or ready Markdown/HTML code.</p>
</div><button id="close-files" class="icon-button" type="button">×</button>
</div>
<div id="files-list" class="files-list"></div>
</div>
</dialog>
<dialog id="identity-dialog">
<form id="identity-form" autocomplete="on" class="dialog-panel identity-panel"><button id="close-identity"
class="modal-close" type="button" aria-label="Close dialog">×</button>
<h2>What should we call you?</h2>
<p class="dialog-copy">Use a free nickname without an account, or register it to reserve it.</p><input
id="nickname" maxlength="40" autocomplete="off" data-bwignore="true" required
placeholder="Name or nickname">
<div class="identity-actions"><button id="guest-continue" class="primary-button" type="submit">Continue as
guest</button><button id="show-register" class="text-button" type="button">Register</button><button
id="show-login" class="text-button" type="button">Log in</button></div>
<section id="auth-panel" class="auth-panel" hidden>
<h3 id="auth-mode-title">Log in</h3><label id="auth-email-field">E-mail<input id="auth-email"
name="username" type="email" maxlength="320" autocomplete="username"
placeholder="you@example.com"></label><label>Password<input id="auth-password" name="password"
type="password" minlength="8" maxlength="128" autocomplete="current-password"></label><button
id="auth-submit" class="primary-button" type="submit">Log in and continue</button>
<div class="identity-links"><button id="show-reset" class="text-button" type="button">Forgot
password?</button><button id="auth-back" class="text-button" type="button">Back to
nickname</button><button id="logout-account" class="text-button" type="button">Log out saved
account</button></div>
</section>
<p id="identity-error" class="form-message error" role="alert"></p>
</form>
</dialog>
<dialog id="password-dialog">
<form id="password-form" class="dialog-panel">
<h2>Protected note</h2><input id="open-password" type="password" autocomplete="current-password" required
placeholder="Password">
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
class="dialog-link" href="/">Back</a>
</form>
</dialog>
<div id="toast" class="toast"></div>
</body>
</html>
+7 -2
View File
@@ -1,13 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<title>Published note · RustPad</title> <title>Published note · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"> <link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/public.js?v=__ASSET_VERSION__"></script> <script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/public.js?v=__ASSET_VERSION__"></script>
</head> </head>
<body class="public-page"> <body class="public-page">
<header class="public-header"> <header class="public-header">
<a class="brand" href="/">RustPad</a> <a class="brand" href="/">RustPad</a>
@@ -20,4 +24,5 @@
</main> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
</body> </body>
</html>
</html>
+61 -5
View File
@@ -1,5 +1,61 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>__WORKSPACE_TITLE__ · RustPad</title><link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__"><script type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script><script type="module" src="/assets/js/workspace.js?v=__ASSET_VERSION__"></script></head> <!doctype html>
<body><header class="app-header"><div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span><div class="document-heading"><h1 id="workspace-title">__WORKSPACE_TITLE__</h1><p id="workspace-url" class="document-url"></p></div></div><div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div></header> <html lang="en">
<main class="workspace-page"><section class="workspace-top"><div><h2>Notes</h2><p>Select a note or create a new one.</p></div><button id="new-note-button" class="primary-button inline-button">New note</button></section><div class="notes-toolbar"><span class="notes-toolbar__label">View</span><div class="notes-view-switch" role="group" aria-label="Notes view"><button type="button" data-notes-view="grid" class="active" aria-pressed="true">Cards</button><button type="button" data-notes-view="table" aria-pressed="false">Table</button></div></div><p id="workspace-error" class="form-message error"></p><section id="notes-list" class="notes-grid" aria-live="polite"></section></main>
<dialog id="password-dialog"><form id="password-form" class="dialog-panel"><h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password" required placeholder="Password"><p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a href="/" class="dialog-link">Cancel</a></form></dialog> <head>
<dialog id="note-dialog"><form id="note-form" class="dialog-panel"><h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label class="dialog-check"><input id="note-protect" type="checkbox" checked> Protect this note from deletion</label><p id="note-error" class="form-message error"></p><div class="dialog-actions"><button type="button" id="cancel-note" class="secondary-button">Cancel</button><button class="primary-button">Create</button></div></form></dialog><div id="toast" class="toast"></div></body></html> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<title>__WORKSPACE_TITLE__ · RustPad</title>
<link rel="stylesheet" href="/assets/css/styles.css?v=__ASSET_VERSION__">
<script
type="importmap">{"imports":{"@rustpad/api":"/assets/js/api.js?v=__ASSET_VERSION__","@rustpad/clipboard":"/assets/js/clipboard.js?v=__ASSET_VERSION__","@rustpad/editor-format":"/assets/js/editor-format.js?v=__ASSET_VERSION__","@rustpad/markdown":"/assets/js/markdown.js?v=__ASSET_VERSION__","@rustpad/session":"/assets/js/session.js?v=__ASSET_VERSION__","@rustpad/socket":"/assets/js/socket.js?v=__ASSET_VERSION__","@rustpad/url-state":"/assets/js/url-state.js?v=__ASSET_VERSION__"}}</script>
<script type="module" src="/assets/js/workspace.js?v=__ASSET_VERSION__"></script>
</head>
<body>
<header class="app-header">
<div class="app-header__main"><a class="brand" href="/">RustPad</a><span class="header-divider"></span>
<div class="document-heading">
<h1 id="workspace-title">__WORKSPACE_TITLE__</h1>
<p id="workspace-url" class="document-url"></p>
</div>
</div>
<div class="header-actions"><button id="copy-workspace-link" class="secondary-button">Copy link</button></div>
</header>
<main class="workspace-page">
<section class="workspace-top">
<div>
<h2>Notes</h2>
<p>Select a note or create a new one.</p>
</div><button id="new-note-button" class="primary-button inline-button">New note</button>
</section>
<div class="notes-toolbar"><span class="notes-toolbar__label">View</span>
<div class="notes-view-switch" role="group" aria-label="Notes view"><button type="button"
data-notes-view="grid" class="active" aria-pressed="true">Cards</button><button type="button"
data-notes-view="table" aria-pressed="false">Table</button></div>
</div>
<p id="workspace-error" class="form-message error"></p>
<section id="notes-list" class="notes-grid" aria-live="polite"></section>
</main>
<dialog id="password-dialog">
<form id="password-form" class="dialog-panel">
<h2>Protected workspace</h2><input id="open-password" type="password" autocomplete="current-password"
required placeholder="Password">
<p id="password-error" class="form-message error"></p><button class="primary-button">Open</button><a
href="/" class="dialog-link">Cancel</a>
</form>
</dialog>
<dialog id="note-dialog">
<form id="note-form" class="dialog-panel">
<h2>New note</h2><input id="note-name" maxlength="80" required placeholder="Note name"><label
class="dialog-check"><input id="note-protect" type="checkbox" checked> Protect this note from
deletion</label>
<p id="note-error" class="form-message error"></p>
<div class="dialog-actions"><button type="button" id="cancel-note"
class="secondary-button">Cancel</button><button class="primary-button">Create</button></div>
</form>
</dialog>
<div id="toast" class="toast"></div>
</body>
</html>