diff --git a/.env.docker.example b/.env.docker.example deleted file mode 100644 index a2886c0..0000000 --- a/.env.docker.example +++ /dev/null @@ -1,9 +0,0 @@ -IMAGE_TAG=0.3.0 -RUSTPAD_PORT=3000 -APP_HOST=0.0.0.0 -APP_PORT=3000 -DATABASE_URL=sqlite:///data/rustpad.db?mode=rwc -DATABASE_MAX_CONNECTIONS=8 -STATIC_DIR=/app/static -ASSET_VERSION=0.5.1 -RUST_LOG=rustpad=info,tower_http=info diff --git a/.env.example b/.env.example index c5bb4ae..8979e7d 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,12 @@ APP_HOST=0.0.0.0 APP_PORT=3000 # SQLite -DATABASE_URL=sqlite://rustpad.db?mode=rwc +DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc DATABASE_MAX_CONNECTIONS=8 # Assety i logowanie STATIC_DIR=static ASSET_VERSION=0.0.1 RUST_LOG=rustpad=debug,tower_http=info + +UPLOAD_MAX_SIZE_MB=20 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 86ee631..6caa88d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ rustpad.db-wal .env .env.docker *.log +data/db/.db* +data/files/* \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2da6791..902c0ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -332,6 +333,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -945,6 +955,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1305,13 +1332,14 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.6.0" +version = "0.0.1-dev" dependencies = [ "argon2", "axum", "chrono", "dotenvy", "futures-util", + "mime_guess", "rand_core 0.6.4", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 16be754..e3c58e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.6.0" +version = "0.0.1-dev" edition = "2024" rust-version = "1.85" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite" @@ -8,10 +8,11 @@ license = "MIT" [dependencies] argon2 = "0.5" -axum = { version = "0.8", features = ["ws"] } +axum = { version = "0.8", features = ["ws", "multipart"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" futures-util = "0.3" +mime_guess = "2" rand_core = { version = "0.6", features = ["getrandom"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/Dockerfile b/Dockerfile index 733b953..283f553 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,9 +23,10 @@ COPY --from=builder /app/static ./static USER rustpad ENV APP_HOST=0.0.0.0 \ APP_PORT=3000 \ - DATABASE_URL=sqlite:///data/rustpad.db?mode=rwc \ + DATABASE_URL=sqlite:///data/db/rustpad.db?mode=rwc \ DATABASE_MAX_CONNECTIONS=8 \ STATIC_DIR=/app/static \ + FILES_DIR=/data/files \ RUST_LOG=rustpad=info,tower_http=info EXPOSE 3000 diff --git a/README.md b/README.md index a9d2cf4..8f3808f 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,38 @@ # RustPad 0.6.0 -Współdzielony edytor Markdown z dwoma niezależnymi trybami: +Collaborative Markdown editor with standalone notes and workspaces. -- szybka notatka pod `/p/`, -- workspace z wieloma notatkami pod `/w/`. - -Oba tryby obsługują opcjonalne hasła, edycję na żywo, SQLite, historię zmian, podgląd Markdown i stan widoku zapisany w URL. - -## Uruchomienie lokalne +## Development setup ```bash -cp .env.example .env -cargo run +./dev.sh ``` -Otwórz `http://127.0.0.1:3000`. +The script creates `data/db` and `data/files`, builds the project, and runs it with Cargo. When Cargo is unavailable, it uses `docker compose up --build`. -## Docker +## Funkcje notatek workspace -```bash -cp .env.docker.example .env -docker compose up --build -``` +- real-time collaborative editing over WebSocket, +- nickname remembered in `localStorage`, +- change authors in history, +- line numbering enabled by default with a persistent toggle, +- owner color next to each line, +- upload images and files to `data/files/pads/_/` lub `data/files/notes/_/`, +- automatic Markdown link insertion after upload, +- Markdown i diagramy Mermaid, +- history with snippets, previews, and version restore. -Baza znajduje się w wolumenie `rustpad_data`. +## Dane -## Adresy +- SQLite: `data/db/rustpad.db`, +- files: `data/files/pads/_/` i `data/files/notes/_/`; the public URL has the form `/f//`. -```text -/p/notatka?view=split&mode=markdown -/w/workspace -/w/workspace/n/notatka?view=split&mode=markdown -``` +In Docker, both directories are located under `/data`. -## Frontend +## Publishing a note as a page -- `home.js` — tworzenie szybkiej notatki i workspace, -- `pad.js` — samodzielna notatka, -- `workspace.js` — lista i tworzenie notatek w workspace, -- `note.js` — notatka należąca do workspace, -- `url-state.js` — stan widoku w URL, -- `clipboard.js` — kopiowanie pełnego adresu, -- `socket.js` — połączenia WebSocket, -- `markdown.js` — renderowanie Markdown, -- `editor-format.js` — formatowanie tekstu, -- `api.js` — komunikacja HTTP, -- `session.js` — hasła workspace w `sessionStorage`. +Use the **Page** button in the editor. RustPad creates a permanent public `/s/` address, copies it to the clipboard, and opens it in a new tab. The page displays the current note content and renders Markdown, images, links, and Mermaid. Publishing a protected note requires the password, but the published link itself is public. -## Dane istniejące +## Limit uploadu -Tabele `pads` i `revisions` pozostają aktywne. Workspace są dodatkiem, nie zamiennikiem zwykłych notatek. Przed aktualizacją wykonaj kopię `rustpad.db`. +The maximum size of a single file is configured with `UPLOAD_MAX_SIZE_MB` w `.env`, np. `UPLOAD_MAX_SIZE_MB=50`. The default is 20 MB. After changing it, restart the project with `./dev.sh`. diff --git a/data/db/.gitkeep b/data/db/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..74e112d --- /dev/null +++ b/dev.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +mkdir -p data/db data/files + +export APP_HOST="${APP_HOST:-0.0.0.0}" +export APP_PORT="${APP_PORT:-3000}" +export DATABASE_URL="${DATABASE_URL:-sqlite://$(pwd)/data/db/rustpad.db?mode=rwc}" +export FILES_DIR="${FILES_DIR:-$(pwd)/data/files}" +export UPLOAD_MAX_SIZE_MB="${UPLOAD_MAX_SIZE_MB:-20}" +export STATIC_DIR="${STATIC_DIR:-$(pwd)/static}" +export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}" +# A new value on every run prevents stale HTML/JS cache issues. +export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}" + +if command -v cargo >/dev/null 2>&1; then + exec cargo run +elif command -v docker >/dev/null 2>&1; then + export IMAGE_TAG="${IMAGE_TAG:-dev}" + exec docker compose up --build --force-recreate --remove-orphans +else + echo "Brak cargo i docker. Zainstaluj Rust 1.85+ albo Docker." >&2 + exit 1 +fi diff --git a/docker-compose.yml b/docker-compose.yml index 2b46d93..d1f9b02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,15 +8,14 @@ services: environment: APP_HOST: ${APP_HOST:-0.0.0.0} APP_PORT: ${APP_PORT:-3000} - DATABASE_URL: ${DATABASE_URL:-sqlite:///data/rustpad.db?mode=rwc} + DATABASE_URL: ${DATABASE_URL:-sqlite:///data/db/rustpad.db?mode=rwc} DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8} STATIC_DIR: ${STATIC_DIR:-/app/static} - ASSET_VERSION: ${ASSET_VERSION:-0.0.1} + FILES_DIR: ${FILES_DIR:-/data/files} + UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20} + ASSET_VERSION: ${ASSET_VERSION:-dev} RUST_LOG: ${RUST_LOG:-rustpad=info,tower_http=info} ports: - "${RUSTPAD_PORT:-3000}:${APP_PORT:-3000}" volumes: - - rustpad_data:/data - -volumes: - rustpad_data: + - ./data:/data \ No newline at end of file diff --git a/make_zip.py b/make_zip.py new file mode 100644 index 0000000..a8a050b --- /dev/null +++ b/make_zip.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +import os +import sys +import zipfile +import subprocess +from pathlib import Path + + +def run_git_command(args, repo_path: Path) -> bytes: + result = subprocess.run( + ["git", *args], + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + return result.stdout + + +def get_files_to_archive(repo_path: Path) -> list[str]: + output = run_git_command( + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + repo_path, + ) + files = output.decode("utf-8", errors="surrogateescape").split("\0") + return [f for f in files if f] + + +def make_zip(repo_path: Path, output_zip: Path) -> None: + files = get_files_to_archive(repo_path) + + output_zip = output_zip.resolve() + if output_zip.exists(): + output_zip.unlink() + + with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for rel_path in files: + abs_path = repo_path / rel_path + + if not abs_path.exists(): + continue + + if abs_path.resolve() == output_zip: + continue + + zf.write(abs_path, arcname=rel_path) + + print(f"Created: {output_zip}") + print(f"Added files: {len(files)}") + + +def main(): + repo_path = Path.cwd() + + if len(sys.argv) > 1: + output_zip = Path(sys.argv[1]) + else: + output_zip = repo_path / f"{repo_path.name}.zip" + + try: + run_git_command(["rev-parse", "--show-toplevel"], repo_path) + except subprocess.CalledProcessError: + print("Error: this directory is not a Git repository.", file=sys.stderr) + sys.exit(1) + + make_zip(repo_path, output_zip) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/migrations/0002_workspaces.sql b/migrations/0002_workspaces.sql index 2f2f98f..32142d3 100644 --- a/migrations/0002_workspaces.sql +++ b/migrations/0002_workspaces.sql @@ -32,7 +32,7 @@ CREATE TABLE IF NOT EXISTS note_revisions ( CREATE INDEX IF NOT EXISTS idx_notes_workspace ON notes(workspace_id, updated_at DESC); CREATE INDEX IF NOT EXISTS idx_note_revisions_note ON note_revisions(note_id, id DESC); --- Zachowanie danych z wersji 0.4.x: stara notatka staje się workspace z jedną notatką. +-- Preserve data from version 0.4.x: the old note becomes a workspace with one note. INSERT OR IGNORE INTO workspaces (id, slug, title, password_hash, created_at, updated_at) SELECT id, slug, title, password_hash, created_at, updated_at FROM pads; diff --git a/migrations/0003_authors.sql b/migrations/0003_authors.sql new file mode 100644 index 0000000..0adfccc --- /dev/null +++ b/migrations/0003_authors.sql @@ -0,0 +1,4 @@ +ALTER TABLE notes ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE note_revisions ADD COLUMN author TEXT; +ALTER TABLE note_revisions ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE revisions ADD COLUMN author TEXT; diff --git a/migrations/0004_pad_owners.sql b/migrations/0004_pad_owners.sql new file mode 100644 index 0000000..8526873 --- /dev/null +++ b/migrations/0004_pad_owners.sql @@ -0,0 +1,2 @@ +ALTER TABLE pads ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE revisions ADD COLUMN owner_map TEXT NOT NULL DEFAULT '[]'; diff --git a/migrations/0005_published_pages.sql b/migrations/0005_published_pages.sql new file mode 100644 index 0000000..f84453b --- /dev/null +++ b/migrations/0005_published_pages.sql @@ -0,0 +1,8 @@ +CREATE TABLE published_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + pad_id INTEGER UNIQUE REFERENCES pads(id) ON DELETE CASCADE, + note_id INTEGER UNIQUE REFERENCES notes(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ((pad_id IS NOT NULL AND note_id IS NULL) OR (pad_id IS NULL AND note_id IS NOT NULL)) +); diff --git a/migrations/0006_file_tokens.sql b/migrations/0006_file_tokens.sql new file mode 100644 index 0000000..697ca5a --- /dev/null +++ b/migrations/0006_file_tokens.sql @@ -0,0 +1,5 @@ +ALTER TABLE pads ADD COLUMN file_token TEXT; +ALTER TABLE notes ADD COLUMN file_token TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_pads_file_token ON pads(file_token) WHERE file_token IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_file_token ON notes(file_token) WHERE file_token IS NOT NULL; diff --git a/rustpad.zip b/rustpad.zip new file mode 100644 index 0000000..31e76e2 Binary files /dev/null and b/rustpad.zip differ diff --git a/src/api.rs b/src/api.rs index 44f48c7..fee1696 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,6 @@ use axum::{ - extract::{Path, State}, - http::StatusCode, + extract::{Multipart, Path, State}, + http::{header, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; @@ -17,6 +17,18 @@ const MIN_PASSWORD_LENGTH: usize = 8; const MAX_PASSWORD_LENGTH: usize = 128; const MIN_WORKSPACE_SLUG_LENGTH: usize = 6; +#[derive(Debug, Serialize)] +pub struct PublishResponse { + url: String, +} + +#[derive(Debug, Serialize)] +pub struct PublicPageResponse { + title: String, + content: String, + updated_at: String, +} + #[derive(Debug, Deserialize)] pub struct CreateWorkspaceRequest { name: String, @@ -89,7 +101,7 @@ pub async fn create_workspace( State(state): State, Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { - let title = validate_name(&payload.name, "Nazwa workspace")?; + let title = validate_name(&payload.name, "Workspace name")?; let password = validate_password(payload.password.as_deref())?; let slug = unique_workspace_slug(&state, title).await?; @@ -144,10 +156,10 @@ pub async fn create_note( Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref()).await?; - let title = validate_name(&payload.name, "Nazwa notatki")?; + let title = validate_name(&payload.name, "Note name")?; let base = slugify(title); if base.is_empty() { - return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu")); + return Err(ApiError::bad_request("The name cannot be converted into a valid address")); } let slug = unique_note_slug(&state, workspace.id, &base).await?; @@ -211,11 +223,13 @@ pub async fn restore( .await?; let content = content.ok_or_else(ApiError::not_found_revision)?; let (revision_id, updated_at) = - db::save_revision(&state.db, note.id, workspace.id, &content).await?; + db::save_revision(&state.db, note.id, workspace.id, &content, Some("restore"), "[]").await?; let update = NoteUpdate { content, revision_id, updated_at, + author: Some("restore".into()), + owner_map: "[]".into(), }; let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(update); Ok(Json(serde_json::json!({"ok": true}))) @@ -262,7 +276,7 @@ fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> { let value = value.trim(); if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH { return Err(ApiError::bad_request(&format!( - "{field} musi mieć od 1 do {MAX_NAME_LENGTH} znaków" + "{field} must contain between 1 and {MAX_NAME_LENGTH} characters" ))); } Ok(value) @@ -275,7 +289,7 @@ fn validate_password(password: Option<&str>) -> Result, ApiError> { let length = password.chars().count(); if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) { return Err(ApiError::bad_request( - "Hasło musi mieć od 8 do 128 znaków", + "Password must contain between 8 and 128 characters", )); } Ok(Some(password)) @@ -284,7 +298,7 @@ fn validate_password(password: Option<&str>) -> Result, ApiError> { async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result { let base = slugify(title); if base.is_empty() { - return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu")); + return Err(ApiError::bad_request("The name cannot be converted into a valid address")); } let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH @@ -299,7 +313,7 @@ async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result, Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { - let title = validate_name(&payload.name, "Nazwa notatki")?; + let title = validate_name(&payload.name, "Note name")?; let password = validate_password(payload.password.as_deref())?; let base = slugify(title); if base.is_empty() { - return Err(ApiError::bad_request("Nazwa nie tworzy poprawnego adresu")); + return Err(ApiError::bad_request("The name cannot be converted into a valid address")); } let slug = unique_pad_slug(&state, &base).await?; db::create_pad(&state.db, &slug, title, password).await?; @@ -382,6 +396,40 @@ pub async fn pad_info( })) } +pub async fn publish_pad_page( + State(state): State, + Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let pad = authorized_pad(&state, &slug, payload.password.as_deref()).await?; + let token = db::publish_pad(&state.db, pad.id).await?; + Ok(Json(PublishResponse { url: format!("/s/{token}") })) +} + +pub async fn publish_note_page( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref()).await?; + let token = db::publish_note(&state.db, note.id).await?; + Ok(Json(PublishResponse { url: format!("/s/{token}") })) +} + +pub async fn public_page( + State(state): State, + Path(token): Path, +) -> Result, ApiError> { + let page = db::find_published_page(&state.db, &token) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok(Json(PublicPageResponse { + title: page.title, + content: page.content, + updated_at: db::normalize_timestamp(&page.updated_at), + })) +} + pub async fn pad_history( State(state): State, Path(slug): Path, @@ -405,11 +453,19 @@ pub async fn pad_restore( .fetch_optional(&state.db) .await?; let content = content.ok_or_else(ApiError::not_found_revision)?; - let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content).await?; + let owner_map: Option = sqlx::query_scalar("SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?") + .bind(payload.revision_id) + .bind(pad.id) + .fetch_optional(&state.db) + .await?; + 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 update = NoteUpdate { content, revision_id, updated_at, + author: Some("restore".into()), + owner_map, }; let _ = state.pad_channel(&slug).await.send(update); Ok(Json(serde_json::json!({"ok": true}))) @@ -439,7 +495,143 @@ async fn unique_pad_slug(state: &SharedState, base: &str) -> Result, + Path(slug): Path, + mut multipart: Multipart, +) -> Result, ApiError> { + let mut password: Option = None; + let mut file: Option<(String, Vec)> = None; + 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(); + if name == "password" { + password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?); + } else if name == "file" { + 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"))?; + if bytes.len() > state.upload_max_size_bytes { + return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); + } + file = Some((filename, bytes.to_vec())); + } + } + let pad = authorized_pad(&state, &slug, password.as_deref()).await?; + let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; + let safe = sanitize_filename(&original); + 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 path = dir.join(&stored); + if path.exists() { + 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); + path = dir.join(&stored); + } + tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?; + Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)}))) +} + +pub async fn upload_note_file( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, + mut multipart: Multipart, +) -> Result, ApiError> { + let mut password: Option = None; + let mut file: Option<(String, Vec)> = None; + 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(); + if name == "password" { + password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?); + } else if name == "file" { + 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"))?; + if bytes.len() > state.upload_max_size_bytes { + return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); + } + file = Some((filename, bytes.to_vec())); + } + } + let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, password.as_deref()).await?; + let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; + let safe = sanitize_filename(&original); + 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 path = dir.join(&stored); + if path.exists() { + 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); + path = dir.join(&stored); + } + tokio::fs::write(&path, bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?; + Ok(Json(serde_json::json!({"name": stored, "url": format!("/f/{}/{}", file_token, stored)}))) +} + + +pub async fn download_file( + State(state): State, + Path((token, filename)): Path<(String, String)>, +) -> Result { + serve_token_file(&state, &token, &filename).await +} + +pub async fn download_legacy_file( + State(state): State, + Path((directory, filename)): Path<(String, String)>, +) -> Result { + let Some((id_part, token)) = directory.split_once('_') else { + return 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? + .ok_or_else(ApiError::not_found_file)?; + if owner.id != id { + return Err(ApiError::not_found_file()); + } + serve_token_file(&state, token, &filename).await +} + +async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> Result { + let safe = sanitize_filename(filename); + if safe != filename { + return Err(ApiError::not_found_file()); + } + let owner = db::find_file_owner(&state.db, token).await? + .ok_or_else(ApiError::not_found_file)?; + let kind = match owner.kind { + db::FileOwnerKind::Pad => "pads", + db::FileOwnerKind::Note => "notes", + }; + let directory = format!("{}_{}", owner.id, token); + let canonical = std::path::Path::new(&state.files_dir).join(kind).join(&directory).join(&safe); + let legacy = std::path::Path::new(&state.files_dir).join(&directory).join(&safe); + let path = if canonical.is_file() { canonical } else { legacy }; + let bytes = tokio::fs::read(&path).await.map_err(|_| ApiError::not_found_file())?; + let mime = mime_guess::from_path(&safe).first_or_octet_stream(); + let mut response = bytes.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + 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")); + Ok(response) +} + +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 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() } } pub struct ApiError { @@ -454,28 +646,38 @@ impl ApiError { message: message.into(), } } + fn payload_too_large(max_bytes: usize) -> Self { + let max_mb = max_bytes / (1024 * 1024); + Self { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: format!("The file may be at most {max_mb} MB"), + } + } + fn not_found_file() -> Self { + Self { status: StatusCode::NOT_FOUND, message: "File not found".into() } + } fn unauthorized() -> Self { Self { status: StatusCode::UNAUTHORIZED, - message: "Nieprawidłowe hasło".into(), + message: "Invalid password".into(), } } fn not_found_workspace() -> Self { Self { status: StatusCode::NOT_FOUND, - message: "Nie znaleziono workspace".into(), + message: "Workspace not found".into(), } } fn not_found_note() -> Self { Self { status: StatusCode::NOT_FOUND, - message: "Nie znaleziono notatki".into(), + message: "Note not found".into(), } } fn not_found_revision() -> Self { Self { status: StatusCode::NOT_FOUND, - message: "Nie znaleziono wersji".into(), + message: "Revision not found".into(), } } fn internal(message: &str) -> Self { @@ -489,7 +691,7 @@ impl ApiError { impl From for ApiError { fn from(error: sqlx::Error) -> Self { tracing::error!(%error, "database error"); - Self::internal("Błąd bazy danych") + Self::internal("Database error") } } diff --git a/src/app.rs b/src/app.rs index 17f526e..cbb9c9e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, State}, + extract::{DefaultBodyLimit, Path, State}, http::{header, HeaderValue, StatusCode}, response::{Html, IntoResponse, Response}, routing::{get, post}, @@ -9,17 +9,23 @@ use tower_http::{services::ServeDir, trace::TraceLayer}; use crate::{api, db, state::SharedState, websocket}; -pub fn router(state: SharedState, static_dir: &str) -> Router { +pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize) -> Router { Router::new() .route("/", get(home)) .route("/p/{slug}", get(pad)) + .route("/s/{token}", get(public_page)) .route("/w/{workspace_slug}", get(workspace)) .route("/w/{workspace_slug}/n/{note_slug}", get(note)) .route("/health", get(health)) + .route("/f/{token}/{filename}", get(api::download_file)) + .route("/files/{directory}/{filename}", get(api::download_legacy_file)) + .route("/api/public/{token}", get(api::public_page)) .route("/api/pads", post(api::create_pad)) .route("/api/pads/{slug}", get(api::pad_info)) .route("/api/pads/{slug}/history", post(api::pad_history)) + .route("/api/pads/{slug}/publish", post(api::publish_pad_page)) .route("/api/pads/{slug}/restore", post(api::pad_restore)) + .route("/api/pads/{slug}/files", post(api::upload_pad_file)) .route("/api/workspaces", post(api::create_workspace)) .route("/api/workspaces/{workspace_slug}", get(api::workspace_info)) .route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace)) @@ -28,6 +34,10 @@ pub fn router(state: SharedState, static_dir: &str) -> Router { "/api/workspaces/{workspace_slug}/notes/{note_slug}", get(api::note_info), ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/publish", + post(api::publish_note_page), + ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}/history", post(api::history), @@ -36,6 +46,10 @@ pub fn router(state: SharedState, static_dir: &str) -> Router { "/api/workspaces/{workspace_slug}/notes/{note_slug}/restore", post(api::restore), ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/files", + post(api::upload_note_file), + ) .route("/ws/p/{slug}", get(websocket::upgrade_pad)) .route( "/ws/{workspace_slug}/{note_slug}", @@ -43,6 +57,7 @@ pub fn router(state: SharedState, static_dir: &str) -> Router { ) .nest_service("/assets", ServeDir::new(static_dir)) .fallback(not_found) + .layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024))) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -64,10 +79,10 @@ async fn pad( Ok(None) => error_response( StatusCode::NOT_FOUND, "404", - "Nie znaleziono notatki", - "Ta notatka nie istnieje albo została usunięta.", + "Note not found", + "This note does not exist or has been deleted.", "/", - "Strona główna", + "Home page", &state.asset_version, ), Err(error) => { @@ -77,6 +92,28 @@ async fn pad( } } +async fn public_page( + State(state): State, + Path(token): Path, +) -> Response { + match db::find_published_page(&state.db, &token).await { + Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version), + Ok(None) => error_response( + StatusCode::NOT_FOUND, + "404", + "Published page not found", + "The link is invalid or the published page has been removed.", + "/", + "Home page", + &state.asset_version, + ), + Err(error) => { + tracing::error!(%error, %token, "failed to load published page"); + internal_error(&state.asset_version) + } + } +} + async fn workspace( State(state): State, Path(workspace_slug): Path, @@ -89,10 +126,10 @@ async fn workspace( Ok(None) => error_response( StatusCode::NOT_FOUND, "404", - "Nie znaleziono workspace", - "Ten workspace nie istnieje albo został usunięty.", + "Workspace not found", + "This workspace does not exist or has been deleted.", "/", - "Strona główna", + "Home page", &state.asset_version, ), Err(error) => { @@ -112,10 +149,10 @@ async fn note( return error_response( StatusCode::NOT_FOUND, "404", - "Nie znaleziono workspace", - "Workspace tej notatki nie istnieje albo został usunięty.", + "Workspace not found", + "The workspace for this note does not exist or has been deleted.", "/", - "Strona główna", + "Home page", &state.asset_version, ); } @@ -130,10 +167,10 @@ async fn note( Ok(None) => error_response( StatusCode::NOT_FOUND, "404", - "Nie znaleziono notatki", - "Ta notatka nie istnieje albo została usunięta.", + "Note not found", + "This note does not exist or has been deleted.", &format!("/w/{workspace_slug}"), - "Wróć do workspace", + "Back do workspace", &state.asset_version, ), Err(error) => { @@ -147,10 +184,10 @@ async fn not_found(State(state): State) -> Response { error_response( StatusCode::NOT_FOUND, "404", - "Nie znaleziono strony", - "Sprawdź adres albo wróć na stronę główną.", + "Page not found", + "Check the address or return to the home page.", "/", - "Strona główna", + "Home page", &state.asset_version, ) } @@ -159,10 +196,10 @@ fn internal_error(asset_version: &str) -> Response { error_response( StatusCode::INTERNAL_SERVER_ERROR, "500", - "Błąd serwera", - "Nie udało się wczytać strony. Spróbuj ponownie za chwilę.", + "Server error", + "The page could not be loaded. Please try again shortly.", "/", - "Strona główna", + "Home page", asset_version, ) } @@ -199,7 +236,7 @@ fn versioned_html(template: &str, asset_version: &str) -> Response { fn no_store(response: &mut Response) { response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_static("no-cache, no-store, must-revalidate"), + HeaderValue::from_static("private, no-store"), ); } diff --git a/src/config.rs b/src/config.rs index 954ebc8..bf23bee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,8 @@ pub struct Config { pub database_url: String, pub database_max_connections: u32, pub static_dir: String, + pub files_dir: String, + pub upload_max_size_bytes: usize, pub asset_version: String, } @@ -15,13 +17,21 @@ impl Config { let host = env_var("APP_HOST", "127.0.0.1").parse()?; let port = env_var("APP_PORT", "3000").parse()?; let database_max_connections = env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?; + let upload_max_size_mb: usize = env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?; + if upload_max_size_mb == 0 { + return Err("UPLOAD_MAX_SIZE_MB > 0".into()); + } Ok(Self { host, port, - database_url: env_var("DATABASE_URL", "sqlite://rustpad.db?mode=rwc"), + database_url: env_var("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"), database_max_connections, static_dir: env_var("STATIC_DIR", "static"), + files_dir: env_var("FILES_DIR", "data/files"), + upload_max_size_bytes: upload_max_size_mb + .checked_mul(1024 * 1024) + .ok_or("UPLOAD_MAX_SIZE_MB to big")?, asset_version: env::var("ASSET_VERSION") .ok() .filter(|value| !value.trim().is_empty()) diff --git a/src/db.rs b/src/db.rs index f2416f3..35002f0 100644 --- a/src/db.rs +++ b/src/db.rs @@ -27,6 +27,7 @@ pub struct Note { pub content: String, pub created_at: String, pub updated_at: String, + pub owner_map: String, } #[derive(Debug, Serialize, FromRow)] @@ -34,6 +35,8 @@ pub struct Revision { pub id: i64, pub content: String, pub created_at: String, + pub author: Option, + pub owner_map: String, } pub async fn find_workspace(pool: &SqlitePool, slug: &str) -> Result, sqlx::Error> { @@ -86,7 +89,7 @@ pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) pub async fn list_notes(pool: &SqlitePool, workspace_id: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC", + "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC", ) .bind(workspace_id) .fetch_all(pool) @@ -99,7 +102,7 @@ pub async fn find_note( slug: &str, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE workspace_id = ? AND slug = ?", + "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE workspace_id = ? AND slug = ?", ) .bind(workspace_id) .bind(slug) @@ -123,7 +126,7 @@ pub async fn create_note( .await?; sqlx::query_as::<_, Note>( - "SELECT id, workspace_id, slug, title, content, created_at, updated_at FROM notes WHERE id = ?", + "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map FROM notes WHERE id = ?", ) .bind(result.last_insert_rowid()) .fetch_one(pool) @@ -135,10 +138,13 @@ pub async fn save_revision( note_id: i64, workspace_id: i64, content: &str, + author: Option<&str>, + owner_map: &str, ) -> Result<(i64, String), sqlx::Error> { let mut tx = pool.begin().await?; - sqlx::query("UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + sqlx::query("UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .bind(content) + .bind(owner_map) .bind(note_id) .execute(&mut *tx) .await?; @@ -146,9 +152,11 @@ pub async fn save_revision( .bind(workspace_id) .execute(&mut *tx) .await?; - let result = sqlx::query("INSERT INTO note_revisions (note_id, content) VALUES (?, ?)") + let result = sqlx::query("INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)") .bind(note_id) .bind(content) + .bind(author) + .bind(owner_map) .execute(&mut *tx) .await?; let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM notes WHERE id = ?") @@ -161,7 +169,7 @@ pub async fn save_revision( pub async fn list_revisions(pool: &SqlitePool, note_id: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, Revision>( - "SELECT id, content, created_at FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100", + "SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100", ) .bind(note_id) .fetch_all(pool) @@ -202,11 +210,12 @@ pub struct Pad { pub password_hash: Option, pub created_at: String, pub updated_at: String, + pub owner_map: String, } pub async fn find_pad(pool: &SqlitePool, slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Pad>( - "SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE slug = ?", + "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE slug = ?", ) .bind(slug) .fetch_optional(pool) @@ -230,7 +239,7 @@ pub async fn create_pad( .await?; sqlx::query_as::<_, Pad>( - "SELECT id, slug, title, content, password_hash, created_at, updated_at FROM pads WHERE id = ?", + "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map FROM pads WHERE id = ?", ) .bind(result.last_insert_rowid()) .fetch_one(pool) @@ -256,16 +265,21 @@ pub async fn save_pad_revision( pool: &SqlitePool, pad_id: i64, content: &str, + author: Option<&str>, + owner_map: &str, ) -> Result<(i64, String), sqlx::Error> { let mut tx = pool.begin().await?; - sqlx::query("UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") + sqlx::query("UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?") .bind(content) + .bind(owner_map) .bind(pad_id) .execute(&mut *tx) .await?; - let result = sqlx::query("INSERT INTO revisions (pad_id, content) VALUES (?, ?)") + let result = sqlx::query("INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)") .bind(pad_id) .bind(content) + .bind(author) + .bind(owner_map) .execute(&mut *tx) .await?; let updated_at: String = sqlx::query_scalar("SELECT updated_at FROM pads WHERE id = ?") @@ -281,9 +295,135 @@ pub async fn list_pad_revisions( pad_id: i64, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Revision>( - "SELECT id, content, created_at FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100", + "SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100", ) .bind(pad_id) .fetch_all(pool) .await } + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct PublishedPage { + pub token: String, + pub title: String, + pub content: String, + pub updated_at: String, +} + +pub async fn publish_pad(pool: &SqlitePool, pad_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE pad_id = ?") + .bind(pad_id) + .fetch_optional(pool) + .await? + { + return Ok(token); + } + let token = random_suffix(18); + sqlx::query("INSERT INTO published_pages (token, pad_id) VALUES (?, ?)") + .bind(&token) + .bind(pad_id) + .execute(pool) + .await?; + Ok(token) +} + +pub async fn publish_note(pool: &SqlitePool, note_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, String>("SELECT token FROM published_pages WHERE note_id = ?") + .bind(note_id) + .fetch_optional(pool) + .await? + { + return Ok(token); + } + let token = random_suffix(18); + sqlx::query("INSERT INTO published_pages (token, note_id) VALUES (?, ?)") + .bind(&token) + .bind(note_id) + .execute(pool) + .await?; + Ok(token) +} + +pub async fn find_published_page(pool: &SqlitePool, token: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, PublishedPage>( + "SELECT pp.token, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = ?", + ) + .bind(token) + .fetch_optional(pool) + .await +} + +pub async fn pad_file_token(pool: &SqlitePool, pad_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, Option>("SELECT file_token FROM pads WHERE id = ?") + .bind(pad_id) + .fetch_one(pool) + .await? + { + return Ok(token); + } + + let token = format!("p_{}", random_suffix(24)); + sqlx::query("UPDATE pads SET file_token = ? WHERE id = ? AND file_token IS NULL") + .bind(&token) + .bind(pad_id) + .execute(pool) + .await?; + + sqlx::query_scalar::<_, String>("SELECT file_token FROM pads WHERE id = ?") + .bind(pad_id) + .fetch_one(pool) + .await +} + +pub async fn note_file_token(pool: &SqlitePool, note_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, Option>("SELECT file_token FROM notes WHERE id = ?") + .bind(note_id) + .fetch_one(pool) + .await? + { + return Ok(token); + } + + let token = format!("n_{}", random_suffix(24)); + sqlx::query("UPDATE notes SET file_token = ? WHERE id = ? AND file_token IS NULL") + .bind(&token) + .bind(note_id) + .execute(pool) + .await?; + + sqlx::query_scalar::<_, String>("SELECT file_token FROM notes WHERE id = ?") + .bind(note_id) + .fetch_one(pool) + .await +} + + +#[derive(Debug, Clone, Copy)] +pub enum FileOwnerKind { + Pad, + Note, +} + +#[derive(Debug, Clone, Copy)] +pub struct FileOwner { + pub kind: FileOwnerKind, + pub id: i64, +} + +pub async fn find_file_owner(pool: &SqlitePool, token: &str) -> Result, sqlx::Error> { + if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM pads WHERE file_token = ?") + .bind(token) + .fetch_optional(pool) + .await? + { + return Ok(Some(FileOwner { kind: FileOwnerKind::Pad, id })); + } + if let Some(id) = sqlx::query_scalar::<_, i64>("SELECT id FROM notes WHERE file_token = ?") + .bind(token) + .fetch_optional(pool) + .await? + { + return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id })); + } + Ok(None) +} diff --git a/src/main.rs b/src/main.rs index aa355dd..cfdc906 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,14 +20,27 @@ async fn main() -> Result<(), Box> { init_tracing(); let config = Config::from_env()?; + if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) { + if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; } + } let db = SqlitePoolOptions::new() .max_connections(config.database_max_connections) .connect(&config.database_url) .await?; sqlx::migrate!().run(&db).await?; - let state = Arc::new(AppState::new(db, config.asset_version.clone())); - let app = app::router(state, &config.static_dir); + std::fs::create_dir_all(&config.files_dir)?; + let state = Arc::new(AppState::new( + db, + config.asset_version.clone(), + config.files_dir.clone(), + config.upload_max_size_bytes, + )); + let app = app::router( + state, + &config.static_dir, + config.upload_max_size_bytes, + ); let address = SocketAddr::new(config.host, config.port); let listener = TcpListener::bind(address).await?; diff --git a/src/state.rs b/src/state.rs index 7b94869..ec704c1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,5 +1,4 @@ use std::{collections::HashMap, sync::Arc}; - use sqlx::SqlitePool; use tokio::sync::{broadcast, RwLock}; @@ -10,40 +9,31 @@ pub struct NoteUpdate { pub content: String, pub revision_id: i64, pub updated_at: String, + pub author: Option, + pub owner_map: String, } #[derive(Debug)] pub struct AppState { pub db: SqlitePool, pub asset_version: String, + pub files_dir: String, + pub upload_max_size_bytes: usize, channels: RwLock>>, } impl AppState { - pub fn new(db: SqlitePool, asset_version: String) -> Self { - Self { - db, - asset_version, - channels: RwLock::new(HashMap::new()), - } + pub fn new(db: SqlitePool, asset_version: String, files_dir: String, upload_max_size_bytes: usize) -> Self { + Self { db, asset_version, files_dir, upload_max_size_bytes, channels: RwLock::new(HashMap::new()) } } - async fn channel_for_key(&self, key: String) -> broadcast::Sender { - 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; - 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 async fn note_channel(&self, workspace_slug: &str, note_slug: &str) -> broadcast::Sender { self.channel_for_key(format!("workspace:{workspace_slug}/{note_slug}")).await } - pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender { self.channel_for_key(format!("pad:{slug}")).await } diff --git a/src/websocket.rs b/src/websocket.rs index 3f60634..9fb2bac 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -1,292 +1,115 @@ -use axum::{ - extract::{ - ws::{Message, WebSocket}, - Path, State, WebSocketUpgrade, - }, - response::Response, -}; +use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, response::Response}; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; - -use crate::{ - db, - state::{NoteUpdate, SharedState}, -}; +use crate::{db, state::{NoteUpdate, SharedState}}; #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientMessage { - Authenticate { password: Option }, - Update { content: String }, + Authenticate { password: Option, nickname: Option }, + Update { content: String, owner_map: Option }, } #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ServerMessage { - Authenticated { - workspace_title: String, - note_title: String, - content: String, - }, - Document { - content: String, - revision_id: i64, - updated_at: String, - }, + Authenticated { workspace_title: String, note_title: String, content: String, owner_map: String }, + Document { content: String, revision_id: i64, updated_at: String, author: Option, owner_map: String }, Error { message: String }, } -pub async fn upgrade( - ws: WebSocketUpgrade, - Path((workspace_slug, note_slug)): Path<(String, String)>, - State(state): State, -) -> Response { +pub async fn upgrade(ws: WebSocketUpgrade, Path((workspace_slug, note_slug)): Path<(String, String)>, State(state): State) -> Response { 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, -) { - let Some(workspace) = db::find_workspace(&state.db, &workspace_slug) - .await - .ok() - .flatten() - else { - let _ = send_error(&mut socket, "Nie znaleziono workspace").await; - return; - }; - let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug) - .await - .ok() - .flatten() - else { - let _ = send_error(&mut socket, "Nie znaleziono notatki").await; - return; - }; - - let password = match socket.recv().await { +async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) { + let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Workspace not found").await; return; }; + let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug).await.ok().flatten() else { let _=send_error(&mut socket,"Note not found").await; return; }; + let (password, nickname) = match socket.recv().await { Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Authenticate { password }) => password, - _ => { - let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await; - return; - } - }, - _ => return, + Ok(ClientMessage::Authenticate { password, nickname }) => (password, clean_nickname(nickname)), + _ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; } + }, _ => return }; - - if !db::verify_workspace_password(&workspace, password.as_deref()) { - let _ = send_error(&mut socket, "Nieprawidłowe hasło").await; - return; - } - - if send( - &mut socket, - &ServerMessage::Authenticated { - workspace_title: workspace.title.clone(), - note_title: note.title.clone(), - content: note.content.clone(), + if !db::verify_workspace_password(&workspace, password.as_deref()) { let _=send_error(&mut socket,"Invalid password").await; 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 channel=state.note_channel(&workspace_slug,¬e_slug).await; + let mut updates=channel.subscribe(); + let (mut sender,mut receiver)=socket.split(); + loop { tokio::select! { + incoming=receiver.next()=>match incoming { + Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text) { + 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; } + let owner_map=owner_map.unwrap_or_else(||"[]".into()); + match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { + Ok((revision_id,updated_at))=>{let _=channel.send(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map});} + Err(error)=>warn!(%error,"failed to save revision"), + } + } + 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;} }, - ) - .await - .is_err() - { - return; - } - - let channel = state.note_channel(&workspace_slug, ¬e_slug).await; - let mut updates = channel.subscribe(); - let (mut sender, mut receiver) = socket.split(); - - loop { - tokio::select! { - incoming = receiver.next() => { - match incoming { - Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Update { content }) => { - if content.len() > 2_000_000 { - let _ = send_split(&mut sender, &ServerMessage::Error { message: "Dokument jest zbyt duży".into() }).await; - continue; - } - match db::save_revision(&state.db, note.id, workspace.id, &content).await { - Ok((revision_id, updated_at)) => { - let _ = channel.send(NoteUpdate { content, revision_id, updated_at }); - } - Err(error) => warn!(%error, "failed to save revision"), - } - } - 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; - } - } - } - update = updates.recv() => match update { - Ok(update) => { - if send_split(&mut sender, &ServerMessage::Document { - content: update.content, - revision_id: update.revision_id, - updated_at: update.updated_at, - }).await.is_err() { - break; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - if let Ok(Some(current)) = db::find_note(&state.db, workspace.id, ¬e_slug).await { - if send_split(&mut sender, &ServerMessage::Document { - content: current.content, - revision_id: 0, - updated_at: current.updated_at, - }).await.is_err() { - break; - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } + update=updates.recv()=>match update { + Ok(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;}, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_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, } - } -} - -async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> { - send(socket, &ServerMessage::Error { message: message.into() }).await -} - -async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> { - let payload = serde_json::to_string(message).expect("serializing server message cannot fail"); - socket.send(Message::Text(payload.into())).await -} - -async fn send_split( - sender: &mut futures_util::stream::SplitSink, - message: &ServerMessage, -) -> Result<(), axum::Error> { - let payload = serde_json::to_string(message).expect("serializing server message cannot fail"); - sender.send(Message::Text(payload.into())).await + }} } +fn clean_nickname(value: Option)->Option{value.map(|v|v.trim().chars().take(40).collect::()).filter(|v|!v.is_empty())} +async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error>{send(socket,&ServerMessage::Error{message:message.into()}).await} +async fn send(socket:&mut WebSocket,message:&ServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await} +async fn send_split(sender:&mut futures_util::stream::SplitSink,message:&ServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await} #[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag="type",rename_all="snake_case")] enum PadServerMessage { - Authenticated { title: String, content: String }, - Document { content: String, revision_id: i64, updated_at: String }, + Authenticated { title: String, content: String, owner_map: String }, + Document { content: String, revision_id: i64, updated_at: String, author: Option, owner_map: String }, Error { message: String }, } - -pub async fn upgrade_pad( - ws: WebSocketUpgrade, - Path(slug): Path, - State(state): State, -) -> Response { - ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug)) +pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path,State(state):State)->Response{ + ws.on_upgrade(move|socket|handle_pad_socket(socket,state,slug)) } - -async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) { - let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else { - let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Nie znaleziono notatki".into() }).await; - return; +async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ + let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;}; + let (password,nickname)=match socket.recv().await{ + Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ + Ok(ClientMessage::Authenticate{password,nickname})=>(password,clean_nickname(nickname)), + _=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;} + },_=>return }; - - let password = match socket.recv().await { - Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Authenticate { password }) => password, - _ => { - let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Wymagane uwierzytelnienie".into() }).await; - return; - } + if !db::verify_pad_password(&pad,password.as_deref()){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;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 channel=state.pad_channel(&slug).await; + let mut updates=channel.subscribe(); + let(mut sender,mut receiver)=socket.split(); + loop{tokio::select!{ + incoming=receiver.next()=>match incoming{ + Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ + Ok(ClientMessage::Update{content,owner_map})=>{ + if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; } + let owner_map=owner_map.unwrap_or_else(||"[]".into()); + 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(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}); + } + } + Ok(ClientMessage::Authenticate{..})=>{}, + 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;} }, - _ => return, - }; - - if !db::verify_pad_password(&pad, password.as_deref()) { - let _ = send_pad(&mut socket, &PadServerMessage::Error { message: "Nieprawidłowe hasło".into() }).await; - return; - } - - if send_pad(&mut socket, &PadServerMessage::Authenticated { - title: pad.title.clone(), - content: pad.content.clone(), - }).await.is_err() { - return; - } - - let channel = state.pad_channel(&slug).await; - let mut updates = channel.subscribe(); - let (mut sender, mut receiver) = socket.split(); - - loop { - tokio::select! { - incoming = receiver.next() => { - match incoming { - Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Update { content }) => { - if content.len() > 2_000_000 { - let _ = send_pad_split(&mut sender, &PadServerMessage::Error { message: "Dokument jest zbyt duży".into() }).await; - continue; - } - match db::save_pad_revision(&state.db, pad.id, &content).await { - Ok((revision_id, updated_at)) => { - let _ = channel.send(NoteUpdate { content, revision_id, updated_at }); - } - Err(error) => warn!(%error, "failed to save pad revision"), - } - } - Ok(ClientMessage::Authenticate { .. }) => {} - 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; - } - } - } - update = updates.recv() => match update { - Ok(update) => { - if send_pad_split(&mut sender, &PadServerMessage::Document { - content: update.content, - revision_id: update.revision_id, - updated_at: update.updated_at, - }).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, - }).await.is_err() { - break; - } - } - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - } + update=updates.recv()=>match update{ + Ok(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::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, } - } -} - -async fn send_pad(socket: &mut WebSocket, message: &PadServerMessage) -> Result<(), axum::Error> { - let payload = serde_json::to_string(message).expect("serializing pad server message cannot fail"); - socket.send(Message::Text(payload.into())).await -} - -async fn send_pad_split( - sender: &mut futures_util::stream::SplitSink, - message: &PadServerMessage, -) -> Result<(), axum::Error> { - let payload = serde_json::to_string(message).expect("serializing pad server message cannot fail"); - sender.send(Message::Text(payload.into())).await + }} } +async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await} +async fn send_pad_split(sender:&mut futures_util::stream::SplitSink,message:&PadServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await} diff --git a/static/error.html b/static/error.html index af9a516..dad101a 100644 --- a/static/error.html +++ b/static/error.html @@ -1,5 +1,5 @@ - + @@ -16,7 +16,7 @@

__ERROR_MESSAGE__

__PRIMARY_LABEL__ - +
diff --git a/static/home.html b/static/home.html index e79136c..8f50e7b 100644 --- a/static/home.html +++ b/static/home.html @@ -1,60 +1,60 @@ - + RustPad - +
-

Nowa przestrzeń

-

Utwórz szybką notatkę albo workspace z wieloma notatkami.

+

New workspace

+

Create a quick note or a workspace with multiple notes.

-

Notatka

-

Jeden dokument pod własnym linkiem.

+

Note

+

A single document with its own link.

- - -
/p/notatki-ze-spotkania0/80
+ + +
/p/meeting-notes0/80
-
opcjonalne, min. 8 znaków
-
+
optional, min. 8 characters
+
- +

Workspace

-

Przestrzeń z listą wielu notatek.

+

A workspace containing multiple notes.

- - -
/w/moj-projekt0/80
- Krótka lub zajęta nazwa otrzyma losowy sufiks. + + +
/w/my-project0/80
+ A short or unavailable name will receive a random suffix.
-
opcjonalne, min. 8 znaków
-
+
optional, min. 8 characters
+
- +
diff --git a/static/js/api.js b/static/js/api.js index c0950f8..55533c4 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -2,18 +2,14 @@ export async function api(path, options = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 12000); try { - const response = await fetch(path, { - ...options, - headers: { "content-type": "application/json", ...(options.headers || {}) }, - signal: controller.signal, - }); + const headers = new Headers(options.headers || {}); + if (!(options.body instanceof FormData) && !headers.has("content-type")) headers.set("content-type", "application/json"); + const response = await fetch(path, { ...options, headers, signal: controller.signal }); const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.error || `Błąd ${response.status}`); + if (!response.ok) throw new Error(data.error || `Error ${response.status}`); return data; } catch (error) { - if (error.name === "AbortError") throw new Error("Przekroczono czas odpowiedzi serwera"); + if (error.name === "AbortError") throw new Error("Timed out"); throw error; - } finally { - clearTimeout(timeout); - } + } finally { clearTimeout(timeout); } } diff --git a/static/js/clipboard.js b/static/js/clipboard.js index 88916a9..573f581 100644 --- a/static/js/clipboard.js +++ b/static/js/clipboard.js @@ -12,5 +12,5 @@ export async function copyText(text) { input.select(); const copied = document.execCommand("copy"); input.remove(); - if (!copied) throw new Error("Nie udało się skopiować linku"); + if (!copied) throw new Error("Failed to copy the link"); } diff --git a/static/js/editor-format.js b/static/js/editor-format.js index c545a33..1b7e652 100644 --- a/static/js/editor-format.js +++ b/static/js/editor-format.js @@ -17,7 +17,7 @@ export function applyFormat(editor, format) { if (format === "bullet") prefix("- "); if (format === "number") prefix((index) => `${index + 1}. `); if (format === "quote") prefix("> "); - if (format === "link") wrap("[", "](https://)", "opis linku"); + if (format === "link") wrap("[", "](https://)", "description"); editor.focus(); editor.dispatchEvent(new Event("input", { bubbles: true })); } diff --git a/static/js/home.js b/static/js/home.js index 3750d0e..e3f53f3 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -1,4 +1,4 @@ -import { api } from "./api.js?v=0.6.0"; +import { api } from "@rustpad/api"; function slugify(value, fallback) { return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback; @@ -21,7 +21,7 @@ function setBusy(button, busy, idleText, busyText) { button.textContent = busy ? busyText : idleText; } -bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "notatka"); +bindPreview("#pad-name", "#pad-slug-preview", "#pad-name-count", "/p/", "note"); bindPreview("#workspace-name", "#workspace-slug-preview", "#workspace-name-count", "/w/", "workspace"); document.querySelectorAll(".password-toggle").forEach((button) => { @@ -29,7 +29,7 @@ document.querySelectorAll(".password-toggle").forEach((button) => { const input = document.getElementById(button.dataset.target); const show = input.type === "password"; input.type = show ? "text" : "password"; - button.textContent = show ? "Ukryj" : "Pokaż"; + button.textContent = show ? "Hide" : "Show"; }); }); @@ -40,7 +40,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) => const button = document.querySelector("#pad-button"); const error = document.querySelector("#pad-error"); error.textContent = ""; - setBusy(button, true, "Utwórz notatkę", "Tworzenie…"); + setBusy(button, true, "Create note", "Creating…"); try { const payload = { name: name.value.trim() }; if (password.value) payload.password = password.value; @@ -50,7 +50,7 @@ document.querySelector("#pad-form").addEventListener("submit", async (event) => } catch (requestError) { error.textContent = requestError.message; } finally { - setBusy(button, false, "Utwórz notatkę", "Tworzenie…"); + setBusy(button, false, "Create note", "Creating…"); } }); @@ -61,7 +61,7 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even const button = document.querySelector("#workspace-button"); const error = document.querySelector("#workspace-error"); error.textContent = ""; - setBusy(button, true, "Utwórz workspace", "Tworzenie…"); + setBusy(button, true, "Create workspace", "Creating…"); try { const payload = { name: name.value.trim() }; if (password.value) payload.password = password.value; @@ -71,6 +71,6 @@ document.querySelector("#workspace-form").addEventListener("submit", async (even } catch (requestError) { error.textContent = requestError.message; } finally { - setBusy(button, false, "Utwórz workspace", "Tworzenie…"); + setBusy(button, false, "Create workspace", "Creating…"); } }); diff --git a/static/js/markdown.js b/static/js/markdown.js index 86ae262..2cc2791 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -1,26 +1,51 @@ function escapeHtml(value) { - return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); + return String(value).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); } + +function safeUrl(value) { + const url = String(value).trim(); + if (/^(https?:\/\/|\/|\.\/|\.\.\/|#)/i.test(url)) return escapeHtml(url); + return "#"; +} + function inline(value) { - return escapeHtml(value) + const tokens = []; + let html = escapeHtml(value); + html = html.replace(/!\[([^\]]*)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, alt, url, title) => { + const token = `\u0000IMG${tokens.length}\u0000`; + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; + tokens.push(`${alt}`); + return token; + }); + html = html .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/~~([^~]+)~~/g, "$1") .replace(/\*([^*]+)\*/g, "$1") - .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + .replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => { + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; + return `${label}`; + }); + return html.replace(/\u0000IMG(\d+)\u0000/g, (_, index) => tokens[Number(index)] || ""); } + export function renderMarkdown(source) { - let html = "", inCode = false, list = null; + let html = "", inCode = false, language = "", code = [], list = null; const closeList = () => { if (list) { html += ``; list = null; } }; - for (const line of source.split("\n")) { - if (line.startsWith("```")) { closeList(); html += inCode ? "" : "
"; inCode = !inCode; continue; }
-    if (inCode) { html += `${escapeHtml(line)}\n`; continue; }
-    const heading = line.match(/^(#{1,6})\s+(.+)$/);
-    const ul = line.match(/^\s*[-*+]\s+(.+)$/);
-    const ol = line.match(/^\s*\d+\.\s+(.+)$/);
+  const closeCode = () => {
+    const body = escapeHtml(code.join("\n"));
+    html += language.toLowerCase() === "mermaid"
+      ? `
${body}
` + : `
${body}
`; + code = []; language = ""; + }; + for (const line of String(source).split("\n")) { + if (line.startsWith("```")) { closeList(); if (inCode) closeCode(); else language = line.slice(3).trim(); inCode = !inCode; continue; } + if (inCode) { code.push(line); continue; } + const heading = line.match(/^(#{1,6})\s+(.+)$/), ul = line.match(/^\s*[-*+]\s+(.+)$/), ol = line.match(/^\s*\d+\.\s+(.+)$/); if (heading) { closeList(); const n = heading[1].length; html += `${inline(heading[2])}`; } else if (ul || ol) { const type = ul ? "ul" : "ol"; if (list !== type) { closeList(); html += `<${type}>`; list = type; } html += `
  • ${inline((ul || ol)[1])}
  • `; } else { closeList(); if (/^---+$/.test(line)) html += "
    "; else if (line.startsWith("> ")) html += `
    ${inline(line.slice(2))}
    `; else if (line.trim()) html += `

    ${inline(line)}

    `; else html += "
    "; } } - closeList(); if (inCode) html += "
    "; return html; + closeList(); if (inCode) closeCode(); return html; } diff --git a/static/js/note.js b/static/js/note.js index 9386572..f3159c8 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -1,53 +1,36 @@ -import { api } from "./api.js?v=0.6.0"; -import { copyText } from "./clipboard.js?v=0.6.0"; -import { applyFormat } from "./editor-format.js?v=0.6.0"; -import { renderMarkdown } from "./markdown.js?v=0.6.0"; -import { getPassword, setPassword } from "./session.js?v=0.6.0"; -import { NoteSocket } from "./socket.js?v=0.6.0"; -import { currentShareUrl, readEditorState, writeEditorState } from "./url-state.js?v=0.6.0"; +import { api } from "@rustpad/api"; +import { copyText } from "@rustpad/clipboard"; +import { applyFormat } from "@rustpad/editor-format"; +import { renderMarkdown } from "@rustpad/markdown"; +import { getNickname, getPassword, setNickname, setPassword } from "@rustpad/session"; +import { NoteSocket } from "@rustpad/socket"; +import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; -const parts = location.pathname.split("/").filter(Boolean); -const workspaceSlug = parts[1], noteSlug = parts[3]; -const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"); -const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"); -let password = getPassword(workspaceSlug), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(); - -function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1600); } -function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; } -function updateAddressLabel() { document.querySelector("#note-url").textContent = `${location.pathname}${location.search}`; } -function render() { - if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Podgląd Markdown"; } - else { preview.classList.add("preview--raw"); preview.textContent = editor.value; document.querySelector("#preview-label").textContent = "Tekst źródłowy"; } - document.querySelector("#characters").textContent = `${editor.value.length} znaków`; - const words = editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0; document.querySelector("#words").textContent = `${words} słów`; -} -function applyUi({ write = false, replace = false } = {}) { - editorWorkspace.className = `workspace view-${uiState.view}`; - document.querySelectorAll("[data-view]").forEach(button => { const active = button.dataset.view === uiState.view; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); }); - const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.setAttribute("aria-pressed", String(markdown)); modeToggle.textContent = markdown ? "Markdown" : "Tekst"; modeToggle.title = markdown ? "Kliknij, aby pokazać tekst bez interpretacji" : "Kliknij, aby interpretować Markdown"; - render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); -} -function applyRemote(content) { if (content === editor.value) return; const start = editor.selectionStart, end = editor.selectionEnd; applyingRemote = true; editor.value = content; editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); applyingRemote = false; render(); } -function connect() { - socket?.stop(); socket = new NoteSocket({ workspaceSlug, noteSlug, password, - onStatus: state => setStatus(state === "online" ? "online" : state === "offline" ? "offline" : null, state === "online" ? "Połączono" : state === "offline" ? "Ponowne łączenie…" : "Łączenie…"), - onAuthenticated: message => { if (passwordDialog.open) passwordDialog.close(); document.querySelector("#note-title").textContent = message.note_title; document.querySelector("#workspace-link").textContent = message.workspace_title; applyRemote(message.content); editor.focus(); }, - onDocument: message => { applyRemote(message.content); document.querySelector("#save-state").textContent = `Zapisano ${new Date(message.updated_at).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })}`; }, - onError: message => { document.querySelector("#password-error").textContent = message; if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } - }); socket.connect(); -} -async function initialize() { - try { info = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`); document.querySelector("#note-title").textContent = info.title; document.querySelector("#workspace-link").textContent = info.workspace_title; document.querySelector("#workspace-link").href = `/w/${encodeURIComponent(workspaceSlug)}`; document.querySelector("#back-workspace").href = `/w/${encodeURIComponent(workspaceSlug)}`; document.title = `${info.title} · ${info.workspace_title}`; applyUi({ write: true, replace: true }); if (info.protected && !password) passwordDialog.showModal(); else connect(); } catch (e) { document.body.innerHTML = `

    Nie znaleziono notatki

    ${e.message}

    Wróć do workspace
    `; } -} - -document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => { uiState = { ...uiState, view: button.dataset.view }; applyUi({ write: true }); })); -modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); -window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); -window.addEventListener("rustpad:urlchange", updateAddressLabel); -document.querySelector("#copy-link").addEventListener("click", async () => { try { const url = currentShareUrl(uiState); await copyText(url); toast("Skopiowano link z widokiem"); } catch (e) { toast(e.message); } }); -document.querySelectorAll("[data-format]").forEach(button => button.addEventListener("click", () => applyFormat(editor, button.dataset.format))); -editor.addEventListener("input", () => { render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Zapisywanie…"; saveTimer = setTimeout(() => socket?.update(editor.value), 250); }); -document.querySelector("#password-form").addEventListener("submit", event => { event.preventDefault(); password = document.querySelector("#open-password").value; setPassword(workspaceSlug, password); document.querySelector("#password-error").textContent = ""; connect(); }); -const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '

    Ładowanie…

    '; try { const revisions = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`, { method: "POST", body: JSON.stringify({ password: password || null }) }); list.innerHTML = revisions.length ? revisions.map(r => `
    `).join("") : '

    Brak historii.

    '; list.querySelectorAll("[data-revision]").forEach(button => button.addEventListener("click", async () => { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST", body: JSON.stringify({ password: password || null, revision_id: Number(button.dataset.revision) }) }); toast("Przywrócono wersję"); })); } catch (e) { list.innerHTML = `

    ${e.message}

    `; } }); -document.querySelector("#close-history").addEventListener("click", () => { historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); }); +const parts=location.pathname.split("/").filter(Boolean), workspaceSlug=parts[1], noteSlug=parts[3]; +const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"); +const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog"); +let password=getPassword(workspaceSlug), nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; +const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; +function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;} +function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} +function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;} +function updateAddressLabel(){document.querySelector("#note-url").textContent=`${location.pathname}${location.search}`;} +async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

    Failed to load Mermaid.

    '));}} +function renderGutter(){const lines=editor.value.split("\n");owners=owners.slice(0,lines.length);while(owners.length`
    ${i+1}
    `).join("");document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);} +function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));} +function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview";renderMermaid();}else{preview.classList.add("preview--raw");preview.textContent=editor.value;document.querySelector("#preview-label").textContent="Source text";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} +function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view}`;document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();} +function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} +function connect(){socket?.stop();socket=new NoteSocket({workspaceSlug,noteSlug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();document.querySelector("#note-title").textContent=m.note_title;document.querySelector("#workspace-link").textContent=m.workspace_title;applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} +async function initialize(){try{info=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`);document.querySelector("#note-title").textContent=info.title;document.querySelector("#workspace-link").textContent=info.workspace_title;document.querySelector("#workspace-link").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.querySelector("#back-workspace").href=`/w/${encodeURIComponent(workspaceSlug)}`;document.title=`${info.title} · ${info.workspace_title}`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}catch(e){document.body.innerHTML=`

    Note not found

    ${escapeHtml(e.message)}

    `;}} +document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}); +document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();}); +window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format))); +document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); +editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); +document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;setPassword(workspaceSlug,password);document.querySelector("#password-error").textContent="";connect();}); +const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

    Loading…

    ';try{const revisions=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
    ${escapeHtml(author)}

    ${snippet}

    `;}).join(""):'

    No history yet.

    ';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

    ${escapeHtml(e.message)}

    `;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); +document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{const file=e.target.files[0];if(!file)return;const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";}); +window.addEventListener("error",event=>{setStatus("offline","Application error");console.error(event.error||event.message);}); +window.addEventListener("unhandledrejection",event=>{setStatus("offline","Application error");console.error(event.reason);}); initialize(); diff --git a/static/js/pad.js b/static/js/pad.js index 5ddb6e0..8055e22 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -1,173 +1,34 @@ -import { api } from "./api.js?v=0.6.0"; -import { copyText } from "./clipboard.js?v=0.6.0"; -import { applyFormat } from "./editor-format.js?v=0.6.0"; -import { renderMarkdown } from "./markdown.js?v=0.6.0"; -import { PadSocket } from "./socket.js?v=0.6.0"; -import { currentShareUrl, readEditorState, writeEditorState } from "./url-state.js?v=0.6.0"; - -const slug = location.pathname.split("/").filter(Boolean)[1]; -const passwordKey = `rustpad:pad:${slug}:password`; -const editor = document.querySelector("#editor"); -const preview = document.querySelector("#preview"); -const editorWorkspace = document.querySelector("#editor-workspace"); -const modeToggle = document.querySelector("#mode-toggle"); -const passwordDialog = document.querySelector("#password-dialog"); -let password = sessionStorage.getItem(passwordKey) || ""; -let info; -let socket; -let saveTimer; -let applyingRemote = false; -let uiState = readEditorState(); - -function toast(text) { - const element = document.querySelector("#toast"); - element.textContent = text; - element.classList.add("visible"); - setTimeout(() => element.classList.remove("visible"), 1600); -} - -function setStatus(kind, text) { - document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; - document.querySelector("#status-text").textContent = text; -} - -function updateAddressLabel() { - document.querySelector("#pad-url").textContent = `${location.pathname}${location.search}`; -} - -function render() { - if (uiState.mode === "markdown") { - preview.classList.remove("preview--raw"); - preview.innerHTML = renderMarkdown(editor.value); - document.querySelector("#preview-label").textContent = "Podgląd Markdown"; - } else { - preview.classList.add("preview--raw"); - preview.textContent = editor.value; - document.querySelector("#preview-label").textContent = "Tekst źródłowy"; - } - document.querySelector("#characters").textContent = `${editor.value.length} znaków`; - const words = editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0; - document.querySelector("#words").textContent = `${words} słów`; -} - -function applyUi({ write = false, replace = false } = {}) { - editorWorkspace.className = `workspace view-${uiState.view}`; - document.querySelectorAll("[data-view]").forEach((button) => { - const active = button.dataset.view === uiState.view; - button.classList.toggle("active", active); - button.setAttribute("aria-pressed", String(active)); - }); - const markdown = uiState.mode === "markdown"; - modeToggle.classList.toggle("active", markdown); - modeToggle.setAttribute("aria-pressed", String(markdown)); - modeToggle.textContent = markdown ? "Markdown" : "Tekst"; - modeToggle.title = markdown ? "Pokaż tekst bez interpretacji" : "Interpretuj Markdown"; - render(); - if (write) writeEditorState(uiState, { replace }); - updateAddressLabel(); -} - -function applyRemote(content) { - if (content === editor.value) return; - const start = editor.selectionStart; - const end = editor.selectionEnd; - applyingRemote = true; - editor.value = content; - editor.setSelectionRange(Math.min(start, content.length), Math.min(end, content.length)); - applyingRemote = false; - render(); -} - -function connect() { - socket?.stop(); - socket = new PadSocket({ - slug, - password, - onStatus: (state) => setStatus(state === "online" ? "online" : state === "offline" ? "offline" : null, state === "online" ? "Połączono" : state === "offline" ? "Ponowne łączenie…" : "Łączenie…"), - onAuthenticated: (message) => { - if (passwordDialog.open) passwordDialog.close(); - document.querySelector("#pad-title").textContent = message.title; - applyRemote(message.content); - editor.focus(); - }, - onDocument: (message) => { - applyRemote(message.content); - document.querySelector("#save-state").textContent = `Zapisano ${new Date(message.updated_at).toLocaleTimeString("pl-PL", { hour: "2-digit", minute: "2-digit" })}`; - }, - onError: (message) => { - document.querySelector("#password-error").textContent = message; - if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); - }, - }); - socket.connect(); -} - -async function initialize() { - try { - info = await api(`/api/pads/${encodeURIComponent(slug)}`); - document.querySelector("#pad-title").textContent = info.title; - document.title = `${info.title} · RustPad`; - applyUi({ write: true, replace: true }); - if (info.protected && !password) passwordDialog.showModal(); - else connect(); - } catch (error) { - location.replace("/"); - } -} - -document.querySelectorAll("[data-view]").forEach((button) => button.addEventListener("click", () => { - uiState = { ...uiState, view: button.dataset.view }; - applyUi({ write: true }); -})); -modeToggle.addEventListener("click", () => { - uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; - applyUi({ write: true }); -}); -window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); -window.addEventListener("rustpad:urlchange", updateAddressLabel); -document.querySelector("#copy-link").addEventListener("click", async () => { - try { - await copyText(currentShareUrl(uiState)); - toast("Skopiowano link z widokiem"); - } catch (error) { - toast(error.message); - } -}); -document.querySelectorAll("[data-format]").forEach((button) => button.addEventListener("click", () => applyFormat(editor, button.dataset.format))); -editor.addEventListener("input", () => { - render(); - if (applyingRemote) return; - clearTimeout(saveTimer); - document.querySelector("#save-state").textContent = "Zapisywanie…"; - saveTimer = setTimeout(() => socket?.update(editor.value), 250); -}); -document.querySelector("#password-form").addEventListener("submit", (event) => { - event.preventDefault(); - password = document.querySelector("#open-password").value; - sessionStorage.setItem(passwordKey, password); - document.querySelector("#password-error").textContent = ""; - connect(); -}); -const historyPanel = document.querySelector("#history-panel"); -document.querySelector("#history-button").addEventListener("click", async () => { - historyPanel.setAttribute("aria-hidden", "false"); - document.body.classList.add("history-open"); - const list = document.querySelector("#history-list"); - list.innerHTML = '

    Ładowanie…

    '; - try { - const revisions = await api(`/api/pads/${encodeURIComponent(slug)}/history`, { method: "POST", body: JSON.stringify({ password: password || null }) }); - list.innerHTML = revisions.length ? revisions.map((revision) => `
    `).join("") : '

    Brak historii.

    '; - list.querySelectorAll("[data-revision]").forEach((button) => button.addEventListener("click", async () => { - await api(`/api/pads/${encodeURIComponent(slug)}/restore`, { method: "POST", body: JSON.stringify({ password: password || null, revision_id: Number(button.dataset.revision) }) }); - toast("Przywrócono wersję"); - })); - } catch (error) { - list.innerHTML = `

    ${error.message}

    `; - } -}); -document.querySelector("#close-history").addEventListener("click", () => { - historyPanel.setAttribute("aria-hidden", "true"); - document.body.classList.remove("history-open"); -}); +import { api } from "@rustpad/api"; +import { copyText } from "@rustpad/clipboard"; +import { applyFormat } from "@rustpad/editor-format"; +import { renderMarkdown } from "@rustpad/markdown"; +import { getNickname, setNickname } from "@rustpad/session"; +import { PadSocket } from "@rustpad/socket"; +import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; +const slug=location.pathname.split("/").filter(Boolean)[1]; +const editor=document.querySelector("#editor"), preview=document.querySelector("#preview"), editorWorkspace=document.querySelector("#editor-workspace"), gutter=document.querySelector("#line-gutter"); +const modeToggle=document.querySelector("#mode-toggle"), passwordDialog=document.querySelector("#password-dialog"), identityDialog=document.querySelector("#identity-dialog"); +let password=sessionStorage.getItem(`rustpad:pad:${slug}:password`)||"", nickname=getNickname(), info, socket, saveTimer, applyingRemote=false, uiState=readEditorState(), owners=[]; +const lineToggle=document.querySelector("#line-numbers-toggle"); lineToggle.checked=localStorage.getItem("rustpad:line-numbers")!=="off"; +function colorFor(name){let h=0;for(const c of name||"?")h=(h*31+c.charCodeAt(0))%360;return `hsl(${h} 70% 62%)`;} +function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} +function setStatus(kind,text){document.querySelector("#status-dot").className=`status__dot${kind?` is-${kind}`:""}`;document.querySelector("#status-text").textContent=text;} +function updateAddressLabel(){document.querySelector("#pad-url").textContent=`${location.pathname}${location.search}`;} +async function renderMermaid(){const nodes=preview.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

    Failed to load Mermaid.

    '));}} +function renderGutter(){const lines=editor.value.split("\n");owners=owners.slice(0,lines.length);while(owners.length`
    ${i+1}
    `).join("");document.body.classList.toggle("hide-line-numbers",!lineToggle.checked);} +function escapeHtml(v){return String(v).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));} +function render(){if(uiState.mode==="markdown"){preview.classList.remove("preview--raw");preview.innerHTML=renderMarkdown(editor.value);document.querySelector("#preview-label").textContent="Markdown + Mermaid preview";renderMermaid();}else{preview.classList.add("preview--raw");preview.textContent=editor.value;document.querySelector("#preview-label").textContent="Source text";}document.querySelector("#characters").textContent=`${editor.value.length} characters`;document.querySelector("#words").textContent=`${editor.value.trim()?editor.value.trim().split(/\s+/).length:0} words`;renderGutter();} +function applyUi({write=false,replace=false}={}){editorWorkspace.className=`workspace view-${uiState.view}`;document.querySelectorAll("[data-view]").forEach(b=>{const a=b.dataset.view===uiState.view;b.classList.toggle("active",a);b.setAttribute("aria-pressed",String(a));});const markdown=uiState.mode==="markdown";modeToggle.classList.toggle("active",markdown);modeToggle.textContent=markdown?"Markdown":"Text";render();if(write)writeEditorState(uiState,{replace});updateAddressLabel();} +function applyRemote(content,ownerMap){if(content===editor.value&&ownerMap==null)return;const start=editor.selectionStart,end=editor.selectionEnd;applyingRemote=true;editor.value=content;try{owners=JSON.parse(ownerMap||"[]");}catch{owners=[];}editor.setSelectionRange(Math.min(start,content.length),Math.min(end,content.length));applyingRemote=false;render();} +function connect(){socket?.stop();socket=new PadSocket({slug,password,nickname,onStatus:s=>setStatus(s==="online"?"online":s==="offline"?"offline":null,s==="online"?"Connected":s==="offline"?"Reconnecting…":"Connecting…"),onAuthenticated:m=>{if(passwordDialog.open)passwordDialog.close();document.querySelector("#pad-title").textContent=m.title;applyRemote(m.content,m.owner_map);editor.focus();},onDocument:m=>{applyRemote(m.content,m.owner_map);document.querySelector("#save-state").textContent=`${m.author?`${m.author} · `:""}${new Date(m.updated_at).toLocaleTimeString("en-US",{hour:"2-digit",minute:"2-digit"})}`;},onError:m=>{document.querySelector("#password-error").textContent=m;if(info?.protected&&!passwordDialog.open)passwordDialog.showModal();}});socket.connect();} +async function initialize(){try{info=await api(`/api/pads/${encodeURIComponent(slug)}`);document.querySelector("#pad-title").textContent=info.title;document.title=`${info.title} · RustPad`;applyUi({write:true,replace:true});if(!nickname){identityDialog.showModal();return;}document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}catch(e){document.body.innerHTML=`

    Note not found

    ${escapeHtml(e.message)}

    `;}} +document.querySelector("#identity-form").addEventListener("submit",e=>{e.preventDefault();nickname=document.querySelector("#nickname").value.trim();setNickname(nickname);identityDialog.close();document.querySelector("#current-user").textContent=nickname;document.querySelector("#current-user").style.setProperty("--owner",colorFor(nickname));if(info.protected&&!password)passwordDialog.showModal();else connect();}); +document.querySelectorAll("[data-view]").forEach(b=>b.addEventListener("click",()=>{uiState={...uiState,view:b.dataset.view};applyUi({write:true});}));modeToggle.addEventListener("click",()=>{uiState={...uiState,mode:uiState.mode==="markdown"?"text":"markdown"};applyUi({write:true});});lineToggle.addEventListener("change",()=>{localStorage.setItem("rustpad:line-numbers",lineToggle.checked?"on":"off");renderGutter();}); +window.addEventListener("popstate",()=>{uiState=readEditorState();applyUi();});window.addEventListener("rustpad:urlchange",updateAddressLabel);document.querySelector("#copy-link").addEventListener("click",async()=>{try{await copyText(currentShareUrl(uiState));toast("Link copied");}catch(e){toast(e.message);}});document.querySelectorAll("[data-format]").forEach(b=>b.addEventListener("click",()=>applyFormat(editor,b.dataset.format))); +document.querySelector("#publish-page").addEventListener("click",async()=>{try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/publish`,{method:"POST",body:JSON.stringify({password:password||null})});const url=new URL(result.url,location.origin).href;await copyText(url);toast("Page link copied");window.open(url,"_blank","noopener");}catch(error){toast(error.message);}}); +editor.addEventListener("scroll",()=>{gutter.scrollTop=editor.scrollTop;});editor.addEventListener("input",()=>{const newLines=editor.value.split("\n").length;const cursorLine=editor.value.slice(0,editor.selectionStart).split("\n").length-1;while(owners.lengthsocket?.update(editor.value,JSON.stringify(owners)),250);}); +document.querySelector("#password-form").addEventListener("submit",e=>{e.preventDefault();password=document.querySelector("#open-password").value;sessionStorage.setItem(`rustpad:pad:${slug}:password`,password);document.querySelector("#password-error").textContent="";connect();}); +const historyPanel=document.querySelector("#history-panel");document.querySelector("#history-button").addEventListener("click",async()=>{historyPanel.classList.add("open");historyPanel.setAttribute("aria-hidden","false");document.body.classList.add("history-open");const list=document.querySelector("#history-list");list.innerHTML='

    Loading…

    ';try{const revisions=await api(`/api/pads/${encodeURIComponent(slug)}/history`,{method:"POST",body:JSON.stringify({password:password||null})});list.innerHTML=revisions.length?revisions.map((r,i)=>{const snippet=escapeHtml(r.content.trim().split("\n").slice(0,3).join(" · ").slice(0,150)||"Empty note");const author=r.author||"Unknown author";return `
    ${escapeHtml(author)}

    ${snippet}

    `;}).join(""):'

    No history yet.

    ';for(const r of revisions){list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click",()=>{const el=list.querySelector(`#preview-${r.id}`);el.hidden=!el.hidden;el.textContent=r.content;});list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click",async()=>{await api(`/api/pads/${encodeURIComponent(slug)}/restore`,{method:"POST",body:JSON.stringify({password:password||null,revision_id:r.id})});toast("Version restored");});}}catch(e){list.innerHTML=`

    ${escapeHtml(e.message)}

    `;}});document.querySelector("#close-history").addEventListener("click",()=>{historyPanel.classList.remove("open");historyPanel.setAttribute("aria-hidden","true");document.body.classList.remove("history-open");}); +document.querySelector("#upload-button").addEventListener("click",()=>document.querySelector("#file-input").click());document.querySelector("#file-input").addEventListener("change",async e=>{const file=e.target.files[0];if(!file)return;const form=new FormData();form.append("password",password||"");form.append("file",file);try{const result=await api(`/api/pads/${encodeURIComponent(slug)}/files`,{method:"POST",body:form,headers:{}});const image=file.type.startsWith("image/");const text=image?`![${file.name}](${result.url})`:`[${file.name}](${result.url})`;editor.setRangeText(text,editor.selectionStart,editor.selectionEnd,"end");editor.dispatchEvent(new Event("input"));toast("File uploaded");}catch(err){toast(err.message);}e.target.value="";}); initialize(); diff --git a/static/js/public.js b/static/js/public.js new file mode 100644 index 0000000..71864f0 --- /dev/null +++ b/static/js/public.js @@ -0,0 +1,11 @@ +import { api } from "@rustpad/api"; +import { copyText } from "@rustpad/clipboard"; +import { renderMarkdown } from "@rustpad/markdown"; + +const token = location.pathname.split("/").filter(Boolean)[1]; +const content = document.querySelector("#public-content"); +function toast(text){const el=document.querySelector("#toast");el.textContent=text;el.classList.add("visible");setTimeout(()=>el.classList.remove("visible"),1800);} +async function renderMermaid(){const nodes=content.querySelectorAll(".mermaid");if(!nodes.length)return;try{const {default:mermaid}=await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs");mermaid.initialize({startOnLoad:false,theme:"dark",securityLevel:"strict"});await mermaid.run({nodes:[...nodes]});}catch{nodes.forEach(n=>n.insertAdjacentHTML("beforebegin",'

    Failed to load Mermaid.

    '));}} +async function initialize(){try{const page=await api(`/api/public/${encodeURIComponent(token)}`);document.querySelector("#public-title").textContent=page.title;document.querySelector("#public-meta").textContent=`Updated: ${new Date(page.updated_at).toLocaleString("en-US")}`;document.title=`${page.title} · RustPad`;content.innerHTML=renderMarkdown(page.content);await renderMermaid();}catch(error){content.innerHTML=`

    ${String(error.message)}

    `;}} +document.querySelector("#copy-public-link").addEventListener("click",async()=>{try{await copyText(location.href);toast("Link copied");}catch(error){toast(error.message);}}); +initialize(); diff --git a/static/js/session.js b/static/js/session.js index 4c85e24..97ec230 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -1,6 +1,5 @@ export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspaceSlug}:password`; } export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; } -export function setPassword(workspaceSlug, password) { - if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); - else sessionStorage.removeItem(passwordKey(workspaceSlug)); -} +export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); } +export function getNickname() { return localStorage.getItem("rustpad:nickname") || ""; } +export function setNickname(value) { localStorage.setItem("rustpad:nickname", value.trim()); } diff --git a/static/js/socket.js b/static/js/socket.js index 2590f98..e021fae 100644 --- a/static/js/socket.js +++ b/static/js/socket.js @@ -1,60 +1,11 @@ export class NoteSocket { - constructor({ workspaceSlug, noteSlug, password, onStatus, onAuthenticated, onDocument, onError }) { - Object.assign(this, { workspaceSlug, noteSlug, password, onStatus, onAuthenticated, onDocument, onError }); - this.socket = null; this.timer = null; this.closed = false; - } - connect() { - clearTimeout(this.timer); this.onStatus?.("connecting"); - const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - this.socket = new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); - this.socket.addEventListener("open", () => this.socket.send(JSON.stringify({ type: "authenticate", password: this.password || null }))); - this.socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); } - if (message.type === "authenticated") { this.onStatus?.("online"); this.onAuthenticated?.(message); } - if (message.type === "document") this.onDocument?.(message); - }); - this.socket.addEventListener("close", () => { if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); } }); - this.socket.addEventListener("error", () => this.socket.close()); - } - update(content) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: "update", content })); } - stop() { this.closed = true; clearTimeout(this.timer); this.socket?.close(); } + constructor({ workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }) { Object.assign(this, { workspaceSlug, noteSlug, password, nickname, onStatus, onAuthenticated, onDocument, onError }); this.socket=null; this.timer=null; this.closed=false; } + connect() { clearTimeout(this.timer); this.closed=false; this.onStatus?.("connecting"); const protocol=location.protocol==="https:"?"wss:":"ws:"; this.socket=new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`); this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null}))); this.socket.addEventListener("message",event=>{const m=JSON.parse(event.data); if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();} if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);} if(m.type==="document")this.onDocument?.(m);}); this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}}); this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();}); } + update(content, ownerMap="[]") { if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap})); } + stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();} } - export class PadSocket { - constructor({ slug, password, onStatus, onAuthenticated, onDocument, onError }) { - Object.assign(this, { slug, password, onStatus, onAuthenticated, onDocument, onError }); - this.socket = null; - this.timer = null; - this.closed = false; - } - connect() { - clearTimeout(this.timer); - this.closed = false; - this.onStatus?.("connecting"); - const protocol = location.protocol === "https:" ? "wss:" : "ws:"; - this.socket = new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`); - this.socket.addEventListener("open", () => this.socket.send(JSON.stringify({ type: "authenticate", password: this.password || null }))); - this.socket.addEventListener("message", (event) => { - const message = JSON.parse(event.data); - if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); } - if (message.type === "authenticated") { this.onStatus?.("online"); this.onAuthenticated?.(message); } - if (message.type === "document") this.onDocument?.(message); - }); - this.socket.addEventListener("close", () => { - if (!this.closed) { - this.onStatus?.("offline"); - this.timer = setTimeout(() => this.connect(), 1500); - } - }); - this.socket.addEventListener("error", () => this.socket.close()); - } - update(content) { - if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify({ type: "update", content })); - } - stop() { - this.closed = true; - clearTimeout(this.timer); - this.socket?.close(); - } + constructor({slug,password,nickname,onStatus,onAuthenticated,onDocument,onError}){Object.assign(this,{slug,password,nickname,onStatus,onAuthenticated,onDocument,onError});this.socket=null;this.timer=null;this.closed=false;} + connect(){clearTimeout(this.timer);this.closed=false;this.onStatus?.("connecting");const protocol=location.protocol==="https:"?"wss:":"ws:";this.socket=new WebSocket(`${protocol}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`);this.socket.addEventListener("open",()=>this.socket.send(JSON.stringify({type:"authenticate",password:this.password||null,nickname:this.nickname||null})));this.socket.addEventListener("message",e=>{const m=JSON.parse(e.data);if(m.type==="error"){this.onError?.(m.message);this.closed=true;this.socket.close();}if(m.type==="authenticated"){this.onStatus?.("online");this.onAuthenticated?.(m);}if(m.type==="document")this.onDocument?.(m);});this.socket.addEventListener("close",()=>{if(!this.closed){this.onStatus?.("offline");this.timer=setTimeout(()=>this.connect(),1500);}});this.socket.addEventListener("error",()=>{this.onError?.("Failed to connect to the WebSocket server");this.socket.close();});} + update(content,ownerMap="[]"){if(this.socket?.readyState===WebSocket.OPEN)this.socket.send(JSON.stringify({type:"update",content,owner_map:ownerMap}));} stop(){this.closed=true;clearTimeout(this.timer);this.socket?.close();} } diff --git a/static/js/workspace.js b/static/js/workspace.js index 7f4bb3a..502b50c 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -1,11 +1,11 @@ -import { api } from "./api.js?v=0.6.0"; import { copyText } from "./clipboard.js?v=0.6.0"; import { getPassword, setPassword } from "./session.js?v=0.6.0"; +import { api } from "@rustpad/api"; import { copyText } from "@rustpad/clipboard"; import { getPassword, setPassword } from "@rustpad/session"; const parts = location.pathname.split("/").filter(Boolean), slug = parts[1]; let info, password = getPassword(slug); const dialog = document.querySelector("#password-dialog"), notesList = document.querySelector("#notes-list"); function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1600); } -function renderNotes(notes) { notesList.innerHTML = notes.length ? notes.map(note => `

    ${escapeHtml(note.title)}

    Aktualizacja: ${new Date(note.updated_at).toLocaleString("pl-PL")}

    `).join("") : '

    Brak notatek.

    '; } +function renderNotes(notes) { notesList.innerHTML = notes.length ? notes.map(note => `

    ${escapeHtml(note.title)}

    Updated: ${new Date(note.updated_at).toLocaleString("en-US")}

    `).join("") : '

    No notes yet.

    '; } function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; } -async function openWorkspace() { try { const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) }); info = data.workspace; document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; document.title = `${info.title} · RustPad`; renderNotes(data.notes); if (dialog.open) dialog.close(); } catch (e) { if (info?.protected || e.message.includes("hasło")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); } else document.querySelector("#workspace-error").textContent = e.message; } } +async function openWorkspace() { try { const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ password: password || null }) }); info = data.workspace; document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; document.title = `${info.title} · RustPad`; renderNotes(data.notes); if (dialog.open) dialog.close(); } catch (e) { if (info?.protected || e.message.toLowerCase().includes("password")) { document.querySelector("#password-error").textContent = e.message; if (!dialog.open) dialog.showModal(); } else document.querySelector("#workspace-error").textContent = e.message; } } async function init() { try { info = await api(`/api/workspaces/${encodeURIComponent(slug)}`); document.querySelector("#workspace-title").textContent = info.title; document.querySelector("#workspace-url").textContent = location.pathname; if (info.protected && !password) dialog.showModal(); else openWorkspace(); } catch (e) { document.querySelector("#workspace-error").textContent = e.message; } } document.querySelector("#password-form").addEventListener("submit", e => { e.preventDefault(); password = document.querySelector("#open-password").value; setPassword(slug, password); openWorkspace(); }); document.querySelector("#new-note-button").addEventListener("click", () => document.querySelector("#note-dialog").showModal()); document.querySelector("#cancel-note").addEventListener("click", () => document.querySelector("#note-dialog").close()); document.querySelector("#note-form").addEventListener("submit", async e => { e.preventDefault(); const error = document.querySelector("#note-error"); error.textContent = ""; try { const note = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes`, { method: "POST", body: JSON.stringify({ name: document.querySelector("#note-name").value, password: password || null }) }); location.assign(`${note.url}?view=split&mode=markdown`); } catch (err) { error.textContent = err.message; } }); -document.querySelector("#copy-workspace-link").addEventListener("click", async () => { try { await copyText(new URL(location.pathname, location.origin).href); toast("Skopiowano link"); } catch (e) { toast(e.message); } }); init(); +document.querySelector("#copy-workspace-link").addEventListener("click", async () => { try { await copyText(new URL(location.pathname, location.origin).href); toast("Link copied"); } catch (e) { toast(e.message); } }); init(); diff --git a/static/note.html b/static/note.html index 7bfb120..3eaf26b 100644 --- a/static/note.html +++ b/static/note.html @@ -1,4 +1,5 @@ -Notatka · RustPad -
    RustPad

    Ładowanie…

    Łączenie…
    -
    Edytor
    Podgląd Markdown
    0 znaków · 0 słów
    Zmiany zapisują się automatycznie
    -

    Workspace chroniony

    Wróć
    +Note · RustPad +
    RustPad

    Loading…

    Connecting…
    +
    Editor
    Markdown preview
    0 characters · 0 words
    Changes are saved automatically
    +

    What should we call you?

    Your name will be shown next to changes and remembered on this device.

    +

    Protected workspace

    Back
    diff --git a/static/pad.html b/static/pad.html index b518979..e383171 100644 --- a/static/pad.html +++ b/static/pad.html @@ -1,19 +1,5 @@ - - - - - - - Notatka · RustPad - - - - -
    -
    RustPad

    Ładowanie…

    -
    Łączenie…
    -
    -
    Edytor
    Podgląd Markdown
    0 znaków · 0 słów
    Zmiany zapisują się automatycznie
    -

    Notatka chroniona

    Wróć
    - - +Note · RustPad +
    RustPad

    Loading…

    Connecting…
    +
    Editor
    Markdown preview
    0 characters · 0 words
    Changes are saved automatically
    +

    What should we call you?

    Your name will be shown next to changes and remembered on this device.

    +

    Protected note

    Back
    diff --git a/static/public.html b/static/public.html new file mode 100644 index 0000000..be0e160 --- /dev/null +++ b/static/public.html @@ -0,0 +1,23 @@ + + + + + + + Published note · RustPad + + + + +
    + RustPad + +
    +
    +

    Loading…

    +

    +
    +
    +
    + + diff --git a/static/styles.css b/static/styles.css index 803e5c6..26b90bc 100644 --- a/static/styles.css +++ b/static/styles.css @@ -205,3 +205,37 @@ dialog::backdrop { background: rgba(4,6,9,.82); } .home-layout--wide { width: min(100% - 24px, 560px); } .create-grid { grid-template-columns: 1fr; } } + +/* Collaborative editor additions */ +.editor-shell { display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 0; overflow: hidden; background: #0d1015; } +.line-gutter { width: 62px; overflow: hidden; padding: 24px 8px 24px 0; border-right: 1px solid var(--border); color: var(--muted-2); font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; user-select: none; } +.line-gutter div { height: 1.72em; padding-right: 8px; border-right: 3px solid var(--owner, transparent); } +.hide-line-numbers .editor-shell { grid-template-columns: 0 minmax(0, 1fr); } +.hide-line-numbers .line-gutter { width: 0; padding: 0; border: 0; } +.editor-shell textarea { padding-left: 18px; } +.line-toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: .78rem; white-space: nowrap; } +.line-toggle input { width: auto; min-height: auto; margin: 0; accent-color: var(--accent); } +.user-chip { display: inline-flex; align-items: center; gap: 7px; color: #dce2eb; font-size: .78rem; } +.user-chip::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--owner, var(--accent)); } +.dialog-copy { margin: 0 0 4px; color: var(--muted); line-height: 1.5; } +.history-header h2 { margin: 0; } +.history-header p { margin: 4px 0 0; color: var(--muted-2); font-size: .75rem; } +.revision__marker { border-color: var(--owner, #8b7af4); background: var(--owner, #8b7af4); } +.revision__meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +.revision__meta strong { font-size: .82rem; } +.revision__snippet { margin: 8px 0 0; color: var(--muted); font-size: .76rem; line-height: 1.45; } +.revision__preview { max-height: 180px; overflow: auto; margin-top: 10px; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: #0b0e13; color: #c9d0da; font: .72rem/1.5 ui-monospace, monospace; white-space: pre-wrap; } +.revision button + button { margin-left: 12px; } +.mermaid { overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #0a0d12; } +@media (max-width: 720px) { .line-gutter { width: 48px; padding-top: 18px; font-size: 15px; } .editor-shell textarea { padding: 18px 12px; font-size: 15px; } .user-chip { display: none; } } + +.markdown-body img { display: block; max-width: 100%; height: auto; margin: 16px auto; border-radius: 10px; } +.markdown-body a { overflow-wrap: anywhere; } +.public-page { min-height: 100vh; background: var(--background); } +.public-header { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between; min-height: 64px; padding: 0 max(20px, calc((100vw - 900px) / 2)); border-bottom: 1px solid var(--border); background: rgba(13,16,21,.92); backdrop-filter: blur(12px); } +.public-document { width: min(900px, calc(100% - 32px)); margin: 0 auto; padding: 56px 0 96px; } +.public-document > h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); line-height: 1.08; } +.public-meta { margin: 12px 0 36px; color: var(--muted); font-size: .8rem; } +.public-content { min-height: 240px; padding: 32px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); } +.public-content img { cursor: zoom-in; } +@media (max-width: 600px) { .public-document { padding-top: 32px; } .public-content { padding: 20px; } } diff --git a/static/workspace.html b/static/workspace.html index 1a37246..fb3ead0 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -1,5 +1,5 @@ -Workspace · RustPad -
    RustPad

    Ładowanie…

    -

    Notatki

    Wybierz notatkę albo utwórz nową.

    -

    Workspace chroniony

    Anuluj
    -

    Nowa notatka

    +Workspace · RustPad +
    RustPad

    Loading…

    +

    Notes

    Select a note or create a new one.

    +

    Protected workspace

    Cancel
    +

    New note