From dfa0828c38a443fd3c01f6ce81386867538a1032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 20 Jul 2026 15:59:15 +0200 Subject: [PATCH] work --- .env.docker.example | 9 - .env.example | 4 +- .gitignore | 2 + Cargo.lock | 30 ++- Cargo.toml | 5 +- Dockerfile | 3 +- README.md | 58 ++--- data/db/.gitkeep | 0 dev.sh | 25 ++ docker-compose.yml | 11 +- make_zip.py | 70 ++++++ migrations/0002_workspaces.sql | 2 +- migrations/0003_authors.sql | 4 + migrations/0004_pad_owners.sql | 2 + migrations/0005_published_pages.sql | 8 + migrations/0006_file_tokens.sql | 5 + rustpad.zip | Bin 0 -> 61282 bytes src/api.rs | 242 ++++++++++++++++++-- src/app.rs | 79 +++++-- src/config.rs | 12 +- src/db.rs | 162 ++++++++++++- src/main.rs | 17 +- src/state.rs | 26 +-- src/websocket.rs | 341 +++++++--------------------- static/error.html | 4 +- static/home.html | 40 ++-- static/js/api.js | 16 +- static/js/clipboard.js | 2 +- static/js/editor-format.js | 2 +- static/js/home.js | 14 +- static/js/markdown.js | 47 +++- static/js/note.js | 85 +++---- static/js/pad.js | 203 +++-------------- static/js/public.js | 11 + static/js/session.js | 7 +- static/js/socket.js | 63 +---- static/js/workspace.js | 8 +- static/note.html | 9 +- static/pad.html | 24 +- static/public.html | 23 ++ static/styles.css | 34 +++ static/workspace.html | 10 +- 42 files changed, 964 insertions(+), 755 deletions(-) delete mode 100644 .env.docker.example create mode 100644 data/db/.gitkeep create mode 100755 dev.sh create mode 100644 make_zip.py create mode 100644 migrations/0003_authors.sql create mode 100644 migrations/0004_pad_owners.sql create mode 100644 migrations/0005_published_pages.sql create mode 100644 migrations/0006_file_tokens.sql create mode 100644 rustpad.zip create mode 100644 static/js/public.js create mode 100644 static/public.html 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 0000000000000000000000000000000000000000..31e76e220aaacee4f7381ef7b37efa872a42218a GIT binary patch literal 61282 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 literal 0 HcmV?d00001 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