diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000..2a4cd2cb --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 38698902..da85536c 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,3 +23,27 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main with: extra-checks-script: packaging/check-app-specific.sh + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4cceb922..267479eb 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: figcraft app-dir: FigCraft + install-relative-path: Programs/ArtCraft/FigCraft + install-scope: perUser diff --git a/.gitignore b/.gitignore index 928d6b49..a2220288 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ plans/ /log/ .DS_Store *.profraw +.cache/ +.vscode/ diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 46b789a8..cfcdbf06 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -40,3 +40,9 @@ egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own l | Asset | Author | Source | Licence | Use | |---|---|---|---|---| | `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | + +## Arabic interface catalog + +| Path | Contributor | Source | Licence | Review | +|---|---|---|---|---| +| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending | diff --git a/CLAUDE.md b/CLAUDE.md index b2384311..f61412ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,7 @@ code. - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code (workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes. +- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are safety nets, not substitutes for `Result`. diff --git a/Cargo.lock b/Cargo.lock index 40a16fe7..8901fd6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,7 +253,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -855,7 +855,20 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] @@ -871,6 +884,28 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "foreign-types", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -907,12 +942,94 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + +[[package]] +name = "craft-quit" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "dispatch2", + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-fonts-build", + "craft-i18n", + "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -937,7 +1054,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "wayland-client", ] @@ -951,6 +1068,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -1078,6 +1204,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -1437,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1504,9 +1636,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.11.2" +version = "0.12.1" dependencies = [ "arboard", + "craft-menu", "craft-update", "craft-window", "eframe", @@ -1532,7 +1665,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1546,7 +1679,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "chrono", @@ -1557,12 +1690,13 @@ dependencies = [ "postcard", "serde", "serde_json", + "sha2", "thiserror 2.0.21", ] [[package]] name = "figcraft-collab-net" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1585,7 +1719,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.11.2" +version = "0.12.1" dependencies = [ "kurbo", "serde", @@ -1594,7 +1728,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1606,10 +1740,11 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "craft-docpath", + "deunicode", "figcraft-collab", "figcraft-color", "figcraft-doc", @@ -1622,6 +1757,7 @@ dependencies = [ "figcraft-render", "figcraft-svg", "figcraft-text", + "figcraft-vectordrawable", "image", "log", "regex", @@ -1636,7 +1772,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1645,13 +1781,14 @@ dependencies = [ "ruzstd", "serde", "serde_json", + "sha1", "sha2", "zip 2.6.1", ] [[package]] name = "figcraft-geom" -version = "0.11.2" +version = "0.12.1" dependencies = [ "kurbo", "serde", @@ -1659,7 +1796,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1667,7 +1804,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1683,7 +1820,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1692,7 +1829,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1709,7 +1846,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "serde", @@ -1722,7 +1859,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.11.2" +version = "0.12.1" dependencies = [ "crc32fast", "figcraft-color", @@ -1740,7 +1877,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.11.2" +version = "0.12.1" dependencies = [ "crc32fast", "ctrlc", @@ -1764,7 +1901,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1780,12 +1917,14 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.11.2" +version = "0.12.1" dependencies = [ + "core-text", "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", "log", + "objc2 0.6.5", "png", "skrifa 0.47.0", "unicode-bidi", @@ -1794,10 +1933,11 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "chrono", + "craft-ui", "craft-update", "egui", "egui_extras", @@ -1820,9 +1960,24 @@ dependencies = [ ] [[package]] -name = "figcraft-web" -version = "0.11.2" +name = "figcraft-vectordrawable" +version = "0.12.1" dependencies = [ + "figcraft-color", + "figcraft-doc", + "figcraft-geom", + "figcraft-pathops", + "figcraft-render", + "figcraft-text", + "roxmltree 0.21.2", + "thiserror 2.0.21", +] + +[[package]] +name = "figcraft-web" +version = "0.12.1" +dependencies = [ + "base64 0.22.1", "eframe", "egui", "figcraft-collab-net", @@ -1830,6 +1985,7 @@ dependencies = [ "figcraft-ui-egui", "js-sys", "log", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1851,6 +2007,17 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2337,9 +2504,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2350,43 +2517,110 @@ dependencies = [ ] [[package]] -name = "icu_locale_core" -version = "2.4.0" +name = "icu_decimal" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] [[package]] -name = "icu_normalizer" -version = "2.4.0" +name = "icu_locale_fallback" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_pattern" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] [[package]] name = "icu_properties" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", @@ -2399,18 +2633,21 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.4.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2418,6 +2655,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2649,6 +2910,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2669,7 +2940,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2782,6 +3053,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -2858,6 +3132,24 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "png", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" @@ -3356,7 +3648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3622,6 +3914,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4028,7 +4322,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4085,7 +4379,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4619,6 +4913,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tar" version = "0.4.46" @@ -4636,10 +4942,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4793,6 +5099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5103,6 +5410,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -5752,7 +6065,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5982,7 +6295,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", @@ -6090,11 +6403,20 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -6173,7 +6495,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-engine", "serde_json", @@ -6362,8 +6684,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6372,6 +6697,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index 3e1101e1..2bf5da74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.11.2" +version = "0.12.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" } figcraft-render = { path = "crates/render" } figcraft-svg = { path = "crates/svg" } figcraft-pdf = { path = "crates/pdf" } +figcraft-vectordrawable = { path = "crates/vectordrawable" } figcraft-format = { path = "crates/format" } figcraft-tools = { path = "crates/tools" } figcraft-collab = { path = "crates/collab" } @@ -34,13 +35,18 @@ figcraft-plugin = { path = "crates/plugin" } # craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. # Which paths stored in a document may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the # same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it # to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would # pull a second egui family version). -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs). +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button +# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work). +craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -68,11 +74,15 @@ skrifa = "0.47" harfrust = "0.13" unicode-bidi = "0.3" unicode-segmentation = "1.13" +# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero +# dependencies, BSD-3-Clause, no_std so the web build keeps it too). +deunicode = "1.6" linesweeper = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } # `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe). flate2 = { version = "1", default-features = false, features = ["rust_backend"] } ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } +sha1 = "0.11" sha2 = "0.10" base64 = "0.22" crc32fast = "1" @@ -95,6 +105,9 @@ webbrowser = "1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" # Style lints from newer toolchains that fight index-heavy pixel code. needless_range_loop = "allow" @@ -123,7 +136,7 @@ lto = "thin" codegen-units = 4 # The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size: -# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB). +# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB). # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" @@ -155,4 +168,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457 # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/README.md b/README.md index 2e91506a..e17cd74c 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil

Prototype and hand off

- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. + Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. diff --git a/ROADMAP.md b/ROADMAP.md index e403b02d..9604db66 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people. | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people. | Simplified Chinese | zh-Hans | 95% | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | -| Arabic | ar | 0% | none | +| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending | | French | fr | 95% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | @@ -114,7 +114,7 @@ native speakers' reviews of the nine language drafts need people. Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -127,11 +127,16 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)). +4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log +- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. +- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. +- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged. +- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. +- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). @@ -347,6 +352,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) | +| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. | +| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. | +| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. | +| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. | +| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | @@ -359,6 +370,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | +| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | | 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 0c04b732..740e885b 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -91,6 +91,15 @@ impl Args { Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") }) } + /// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort). + pub fn vd_compat(&self) -> CliResult { + match self.value("vd-compat") { + None => Ok(figcraft_doc::VdCompat::default()), + Some(s) => figcraft_doc::VdCompat::from_key(&s) + .ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))), + } + } + pub fn scale(&self) -> CliResult { match self.value("scale") { None => Ok(1.0), diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index f746becd..1ba9d60a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -4,7 +4,7 @@ use std::ffi::OsStr; use std::path::Path; use std::process::ExitCode; -use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings}; +use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat}; use figcraft_engine::{BatchScope, BatchStep, Session}; use serde_json::{Value, json}; @@ -25,7 +25,7 @@ fn ext(path: &str) -> String { fn image_format(e: &str) -> Option { match e { - "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e), + "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e), _ => None, } } @@ -225,6 +225,8 @@ pub struct Target { pub node: Option, /// PDF only. pub print: PrintSettings, + /// Android Vector Drawable (XML) only. + pub vd_compat: VdCompat, } pub fn export(file: &str, out: &str, t: &Target) -> CliResult { @@ -239,15 +241,20 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print); let fmt = match t.format.as_deref() { Some(f) => { - image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))? + image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))? } None => image_format(&ext(out)).unwrap_or(ExportFormat::Png), }; let target = node.map(|q| find_node(&doc, page, q)).transpose()?; let single = image_format(&ext(out)).is_some(); let bytes_of = |ids: &[NodeId]| { - let setting = ExportSetting { format: fmt, print: *print, ..Default::default() }; - figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string())) + let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() }; + let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?; + // What an Android Vector Drawable changed or left out, like the .fig notes. + for w in warnings { + eprintln!("note: {w}"); + } + Ok::<_, CliError>(bytes) }; if single { let bytes = match target { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 8c7881c6..971b3905 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -17,8 +17,8 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window USAGE: figcraft-cli info [--json] - figcraft-cli export -o [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] - [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] + figcraft-cli export -o [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME] + [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore] figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] @@ -144,12 +144,18 @@ fn run(argv: &[String]) -> CliResult { confined(&a, || files::info(&file, a.flag("json"))) } "export" => { - let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); + let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat(); let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; - let target = - files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? }; + let target = files::Target { + format: a.value("format"), + scale: a.scale()?, + page: a.value("page"), + node: a.value("node"), + print: a.print()?, + vd_compat: a.vd_compat()?, + }; confined(&a, || files::export(&file, &out, &target)) } "convert" => { diff --git a/apps/figcraft-cli/tests/it/main.rs b/apps/figcraft-cli/tests/it/main.rs index ae4b0488..2e628773 100644 --- a/apps/figcraft-cli/tests/it/main.rs +++ b/apps/figcraft-cli/tests/it/main.rs @@ -10,3 +10,5 @@ mod fonts; mod pdf; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod pdf_print; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod vector_drawable; diff --git a/apps/figcraft-cli/tests/it/vector_drawable.rs b/apps/figcraft-cli/tests/it/vector_drawable.rs new file mode 100644 index 00000000..b99c32cd --- /dev/null +++ b/apps/figcraft-cli/tests/it/vector_drawable.rs @@ -0,0 +1,41 @@ +//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end. + +use std::process::Command; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_figcraft-cli")) +} + +#[test] +fn xml_export_writes_a_drawable_and_prints_its_notes() { + let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let script = d.join("s.jsonl"); + std::fs::write( + &script, + r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##, + ) + .unwrap(); + let file = d.join("doc.figcraft"); + let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + let xml = d.join("icon.xml"); + let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let text = std::fs::read_to_string(&xml).unwrap(); + assert!(text.contains(" bool { shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_))) } +/// The links file keeps hashes only, never the codes or the token they wrap. +fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) { + let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap(); + for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] { + assert!(!links.contains(secret), "{links}"); + } + assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token"); + assert_eq!(links.matches("\"code\"").count(), 2); +} + #[test] fn a_file_shared_online_is_joined_by_its_codes() { let dir = TempDir::new("codes-share"); @@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() { let before_stop = ids(dir.path()); assert_eq!(before_stop.len(), 4); // On disk: hashes only, of the codes as of the links' tokens. - let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap(); - for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] { - assert!(!links.contains(secret), "{links}"); - } - assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token"); - assert_eq!(links.matches("\"code\"").count(), 2); + only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view); // Someone types the edit code, the way people type. let mut b = app("Bo", &url); assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting"); diff --git a/apps/figcraft-server/src/library_tests.rs b/apps/figcraft-server/src/library_tests.rs index f4703f20..5dd45435 100644 --- a/apps/figcraft-server/src/library_tests.rs +++ b/apps/figcraft-server/src/library_tests.rs @@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply { } } +/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in. +fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) { + let mut raw = Raw::connect(addr); + raw.send(&open(edit)); + assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit }); + raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None })); + let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") }; + let mut rng = Rng::new(5); + for round in 0..12 { + let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() }; + for _ in 0..1 + rng.below(8) { + let i = rng.below(junk.len()); + junk[i] = rng.next_u64() as u8; + } + raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk })); + assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}"); + } + let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() }; + raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) })); + assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 }); + raw.send(&ClientMsg::Library(LibraryReq::Usage)); + let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") }; + let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap(); + assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped"); + assert_eq!(spam.usage.components.len(), 1_000, "reports are capped"); + let mut raw = Raw::connect(addr); + raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() })); + assert!(matches!(raw.next(), ServerMsg::Refused { .. })); +} + #[test] fn publish_enable_update_and_usage() { let dir = TempDir::new("library"); @@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() { raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] })); assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view"))); - // Junk from an edit link: refused, the library unharmed. A wrong token isn't let in. let edit = Invite::parse(&p.edit).unwrap(); - let mut raw = Raw::connect(addr); - raw.send(&open(&edit)); - assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit }); - raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None })); - let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") }; - let mut rng = Rng::new(5); - for round in 0..12 { - let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() }; - for _ in 0..1 + rng.below(8) { - let i = rng.below(junk.len()); - junk[i] = rng.next_u64() as u8; - } - raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk })); - assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}"); - } - let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() }; - raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) })); - assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 }); - raw.send(&ClientMsg::Library(LibraryReq::Usage)); - let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") }; - let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap(); - assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped"); - assert_eq!(spam.usage.components.len(), 1_000, "reports are capped"); - let mut raw = Raw::connect(addr); - raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() })); - assert!(matches!(raw.next(), ServerMsg::Refused { .. })); + junk_is_refused(addr, &edit); assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2); // Kept on disk: the library is there after the server stops. diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 1ea420a5..90fce92b 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -52,6 +52,14 @@ use crate::codes::{self, Sealed}; use crate::directory::{Directory, DirectoryConfig}; use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE}; +mod backend; +mod files; +mod journal; +mod library; +mod links; + +pub use library::library_name; + pub const LOCK: &str = "LOCK"; pub const KEY: &str = "server.key"; pub const FILES: &str = "files"; @@ -541,847 +549,6 @@ impl Store { people: open.map_or(0, |o| o.people), } } - - /// Keep a document (its snapshot) as a new file with an edit link and a view link. - pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> { - self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) - } - - /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's - /// caps it gets, and the files and bytes kept by the files published from addresses in it. An - /// address that doesn't parse counts as itself only, ×1, by its exact text. - fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { - let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); - let kept: Vec<(&str, Vec<_>, u64)> = self - .files - .iter() - .filter_map(|(id, e)| { - let from = e.meta.ip.as_deref()?; - Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) - }) - .collect(); - let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); - let blocks = blocks_of(ip); - if blocks.is_empty() { - let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); - return vec![(1, files, bytes)]; - } - blocks - .into_iter() - .map(|(block, times)| { - let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); - (times, files, bytes) - }) - .collect() - } - - /// Keep a document published without the key from `ip`, within the caps on what one address - /// and everyone may keep: the file, its links and its owner token. - pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { - let h = &self.cfg.hosting; - let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes); - if !open { - return Err(KEY_ONLY.into()); - } - if !self.abuse.allow(ip, Action::Publish, Instant::now()) { - return Err("too many files published from your address lately: try again in an hour".into()); - } - let blocks = self.per_block(&ip.to_string()); - for &(times, files, _) in &blocks { - if files >= max_files.saturating_mul(times as usize) { - return Err(format!( - "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" - )); - } - } - let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); - let fits = |n: u64| { - for &(times, _, used) in &blocks { - let cap = max_ip.saturating_mul(u64::from(times)); - if used.saturating_add(n) > cap { - return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); - } - } - if used_all.saturating_add(n) > max_all { - return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); - } - Ok(()) - }; - let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?; - Ok((info, edit, view, owner.unwrap_or_default())) - } - - /// Keep a document as a new file: published without the key from `ip` (it gets an owner - /// token), else by the operator. `fits` checks its size against the caps that apply. - fn keep( - &mut self, - name: &str, - snapshot: &[Change], - ip: Option, - fits: &dyn Fn(u64) -> Result<(), String>, - ) -> Result<(FileInfo, NewLink, NewLink, Option), String> { - if self.files.len() >= self.cfg.max_files { - return Err(format!("the server keeps at most {} files", self.cfg.max_files)); - } - if let Some(why) = self.low_disk() { - return Err(why); - } - let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?; - if replica.document().node_count() > self.cfg.core.max_nodes { - return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); - } - let mut doc = replica.document().clone(); - let name = match clean_name(name) { - n if n.is_empty() => doc.name.clone(), - n => n, - }; - doc.name = name.clone(); - let server = Server::new(doc, "", self.cfg.core); - let state = server.state().map_err(|e| e.to_string())?; - let bytes = disk::encode_snapshot(1, &state)?; - if bytes.len() as u64 > self.cfg.max_file_bytes { - return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20)); - } - fits(bytes.len() as u64)?; - let id = loop { - let id = token::file_id()?; - if !self.files.contains_key(&id) && !self.dir(&id).exists() { - break id; - } - }; - let t = now(); - let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?; - let (view, view_token) = new_link(Access::View, "Published (view)", t)?; - let owner = ip.map(|_| token::secret()).transpose()?; - let meta = - Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 }; - let dir = self.dir(&id); - let written = fs::create_dir_all(&dir) - .map_err(|e| e.to_string()) - .and_then(|()| { - disk::sync_dir(Some(&self.root.join(FILES))); - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) - }) - .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) - .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) - // Last: a folder without its description is an upload cut short. - .and_then(|()| write_json(&dir.join(META), &meta)); - if let Err(e) = written { - let _ = fs::remove_dir_all(&dir); - return Err(format!("couldn't keep the file: {e}")); - } - let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 }; - self.files.insert(id.clone(), entry); - let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?; - match ip { - Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()), - None => log::info!("event=published file={id} bytes={} by=key", bytes.len()), - } - Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner)) - } - - /// The files kept, with what the operator moderates by. - pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec { - self.files - .iter() - .map(|(id, e)| FileRow { - info: self.info(id, e, open(id)), - opened: e.meta.opened, - expires: self.expires(&e.meta), - open: e.meta.owner.is_some(), - ip: e.meta.ip.clone().unwrap_or_default(), - reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX), - }) - .collect() - } - - /// Owner token `owner` manages file `file` (one published without the key). - fn owns(&self, file: &str, owner: &str) -> bool { - let presented = hash(owner); - self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h)) - } - - /// What became of the files these `(file id, owner token)` pairs own: each one's name, links - /// and expiry, or `None` when that token manages no file of that id here (which doesn't say - /// whether the id exists). Asking opens nothing: it doesn't count as using a file. - pub fn owned(&self, files: &[(String, String)]) -> AdminReply { - if files.len() > MAX_OWNED { - return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") }; - } - let one = |(file, owner): &(String, String)| { - let e = self.files.get(file).filter(|_| self.owns(file, owner))?; - Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) }) - }; - AdminReply::Owned(files.iter().map(one).collect()) - } - - /// Rename a file (its listing; the document inside keeps its own name). - pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> { - let name = clean_name(name); - if name.is_empty() { - return Err("give the file a name".into()); - } - let path = self.dir(file).join(META); - let entry = self.entry(file)?; - let before = std::mem::replace(&mut entry.meta.name, name); - if let Err(e) = write_json(&path, &entry.meta) { - entry.meta.name = before; - return Err(format!("couldn't rename the file: {e}")); - } - Ok(()) - } - - /// Delete the files published without the key that nobody opened for the idle time (not - /// those open now); their ids. - pub fn sweep(&mut self) -> Vec { - let t = now(); - self.next_sweep = t.saturating_add(SWEEP_EVERY); - let due: Vec = - self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); - for id in &due { - match self.remove(id) { - Ok(()) => log::info!("event=expired file={id}"), - Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"), - } - } - due - } - - /// Make a file look unused for `secs` more (tests: expiry without waiting). - #[cfg(test)] - pub fn age(&mut self, file: &str, secs: u64) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - let m = &mut e.meta; - (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); - let _ = write_json(&path, m); - } - } - - /// Make an open file's log refuse writes (tests: a full or failing disk). - #[cfg(test)] - pub fn break_log(&mut self, file: &str) { - let path = self.dir(file).join(LOG); - if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut()) - && let Ok(f) = File::open(path) - { - log.file = f; - } - } - - /// Note that a file was opened now (it is used: it doesn't expire). - fn touch(&mut self, file: &str) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - e.meta.opened = now(); - if let Err(err) = write_json(&path, &e.meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); - } - } - } - - /// What the server says about its hosting (with `file`'s expiry). - fn host_info(&self, file: Option<&str>) -> HostInfo { - let h = &self.cfg.hosting; - let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta)); - HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires } - } - - /// Block or unblock an address (an IPv6 address: its /64); the address as kept. - pub fn block(&mut self, ip: &str, on: bool) -> Result { - let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?); - if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED { - return Err(format!("at most {MAX_BLOCKED} addresses can be blocked")); - } - let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) }; - if changed { - let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect(); - write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?; - log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" }); - } - Ok(ip) - } - - /// The blocked addresses. - pub fn blocked(&self) -> Vec { - self.blocked.iter().map(IpAddr::to_string).collect() - } - - /// The reports kept, oldest first. - pub fn reports(&self) -> &[ReportInfo] { - &self.reports - } - - /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). - pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason = clean_reason(reason); - if reason.is_empty() { - return Err("say what is wrong with the file".into()); - } - let name = self.entry(file)?.meta.name.clone(); - if !self.abuse.allow(ip, Action::Report, Instant::now()) { - return Err("too many reports from your address lately: try again in an hour".into()); - } - self.reports.push(ReportInfo { - at: now(), - file: file.to_string(), - name, - link: link.unwrap_or_default().to_string(), - ip: ip.to_string(), - reason, - }); - if self.reports.len() > MAX_REPORTS { - let extra = self.reports.len() - MAX_REPORTS; - self.reports.drain(..extra); - } - write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?; - log::info!("event=report file={file} ip={ip}"); - Ok(()) - } - - /// The files kept. - pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { - self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect() - } - - /// A file's links. - pub fn links(&mut self, file: &str) -> Result, String> { - Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect()) - } - - /// A new link to a file. - pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result { - let max = self.cfg.max_links; - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - if entry.links.len() >= max { - return Err(format!("a file has at most {max} links: revoke one first")); - } - let (link, token) = new_link(access, &clean_name(label), now())?; - entry.links.push(link.clone()); - if let Err(e) = write_json(&path, &entry.links) { - entry.links.pop(); - return Err(format!("couldn't keep the link: {e}")); - } - Ok(NewLink { link: link.info(file), token }) - } - - /// The link of `entry` whose token `token` is (every link is compared, in constant time, so - /// timing doesn't tell which one matched). - fn link_of(entry: &Entry, token: &str) -> Option { - let presented = hash(token); - let mut found = None; - for (i, l) in entry.links.iter().enumerate() { - if same_secret(&presented, &l.hash) { - found = Some(i); - } - } - found - } - - /// These join codes no longer open anything: typing one says so. - fn forget_codes(&mut self, codes: impl IntoIterator) { - self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); - let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); - self.gone_codes.drain(..extra); - } - - /// Where the link with the join code `key` is: its file, its place among the file's links - /// and its token (every code is compared, so timing doesn't tell which one matched). - fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { - let mut found = None; - for (id, e) in &self.files { - for (i, l) in e.links.iter().enumerate() { - if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { - found = Some((id.clone(), i, token)); - } - } - } - found - } - - /// A new join code for the link of `file` whose token is `token` (it takes the place of the - /// link's earlier code): the code, shown this once. - pub fn set_code(&mut self, file: &str, token: &str) -> Result { - let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?; - // 45 random bits: one already in use is all but impossible, and never handed out twice. - let (code, key) = loop { - let code = codes::new_code()?; - let key = codes::Key::new(&code); - if self.find_code(&key).is_none() { - break (code, key); - } - }; - let sealed = key.seal(token)?; - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - let link = entry.links.get_mut(at).ok_or("that file has no such link")?; - let before = link.code.replace(sealed); - let id = link.id.clone(); - if let Err(e) = write_json(&path, &entry.links) { - if let Some(l) = entry.links.get_mut(at) { - l.code = before; - } - return Err(format!("couldn't keep the join code: {e}")); - } - self.forget_codes(before); - log::info!("event=code file={file} link={id}"); - Ok(code) - } - - /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against - /// the address (see `abuse`), and nothing is looked up for an address that is locked out. - pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> { - let now = Instant::now(); - if let Some(left) = self.abuse.code_locked(ip, now) { - return Err(format!( - "Too many wrong codes from your address: try again in {} minutes, or join with the invite link", - left.as_secs() / 60 + 1 - )); - } - if !self.abuse.allow(ip, Action::Code, now) { - return Err("Too many codes tried from your address: wait a minute and try again".into()); - } - if !self.abuse.codes_open(now) { - return Err(CODES_BUSY.into()); - } - // Anything that isn't a code's symbols is a wrong code too (and costs no hashing). - let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c)); - let found = key.as_ref().and_then(|k| self.find_code(k)); - self.abuse.code_tried(ip, found.is_some(), now); - match found { - Some((file, _, token)) => { - log::info!("event=code_used file={file} ip={ip}"); - Ok((file, token)) - } - None => { - let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash())); - Err(if gone { CODE_GONE } else { NO_CODE }.into()) - } - } - } - - /// Revoke a link. - pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?; - let gone = entry.links.remove(at); - if let Err(e) = write_json(&path, &entry.links) { - entry.links.insert(at, gone); - return Err(format!("couldn't revoke the link: {e}")); - } - log::info!("revoked link {link} of file {file}"); - self.forget_codes(gone.code); - Ok(()) - } - - /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no - /// longer served either way). - pub fn remove(&mut self, file: &str) -> Result<(), String> { - // Closed first: an open log would keep the folder from being deleted on Windows. - let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; - self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); - drop(entry); - let dir = self.dir(file); - fs::remove_dir_all(&dir) - .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?; - log::info!("removed file {file}"); - Ok(()) - } - - /// A file's team library for `ip`, unless its version is `have`; within what one address and - /// everyone may download (the size is known before anything is read). - fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { - let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); - let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; - if have == Some(lib.version) { - return Ok(LibraryReply::Unchanged { version: lib.version }); - } - let path = library_file(&dir, lib.version); - let size = fs::metadata(&path).map_or(0, |m| m.len()); - let now = Instant::now(); - if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { - return Err(BUSY_DOWNLOADS.into()); - } - Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? }) - } - - /// Keep `archive` as a file's team library: it must read as a library (with the limits files - /// using it read it with), be the file's library if it has one, and be a newer version. - // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile); - // move it to a worker if large libraries make that noticeable. - pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result { - let max = self.cfg.max_file_bytes; - if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() { - return Err(NO_PUBLIC_LIBRARY.into()); - } - if archive.len() as u64 > max { - return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20)); - } - // Everything it unpacks to counts against the same cap, so a small archive can't expand - // into a large library in memory. - let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) { - Ok(Some(lib)) => lib, - Ok(None) => return Err("that isn't a published library".into()), - Err(e) => return Err(format!("the library can't be read: {e}")), - }; - if lib.doc.node_count() > self.cfg.core.max_nodes { - return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes)); - } - let m = &lib.manifest; - let dir = self.dir(file); - let entry = self.entry(file)?; - if let Some(old) = &entry.meta.library { - if old.id != m.id { - return Err(format!("this file's library on the server is another one (\"{}\")", old.name)); - } - if m.version <= old.version { - return Err(format!("version {} is on the server already: publish again to follow it", old.version)); - } - // A jump would let a client use up the numbers and leave no newer version to publish. - if m.version > old.version.saturating_add(1) { - return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); - } - } else if m.version > MAX_FIRST_LIBRARY_VERSION { - return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); - } - // Each version has a file of its own, written before the description names it: a failed - // description write or a crash in between leaves the previous version served whole, never - // the new bytes under the old number. - let path = dir.join(library_name(m.version)); - disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; - let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); - if let Err(e) = write_json(&dir.join(META), &entry.meta) { - // Never served (the description doesn't name it); the next publish of it replaces it. - if let Err(r) = fs::remove_file(&path) { - log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); - } - entry.meta.library = before; - return Err(format!("couldn't keep the library: {e}")); - } - // The previous version isn't served any more. - if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { - log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); - } - log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); - Ok(m.version) - } - - /// A library's usage reports in memory (read from disk the first time; a damaged list starts - /// over: it is only statistics). `None` when the file has no library. - fn usage_of(&mut self, file: &str) -> Result, String> { - if self.entry(file)?.meta.library.is_none() { - return Ok(None); - } - if !self.usage.contains_key(file) { - let path = self.dir(file).join(USAGE); - let list = if path.exists() { - disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() - } else { - Vec::new() - }; - self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() }); - } - Ok(self.usage.get_mut(file)) - } - - /// The usage reports kept for a file's library (none when it has no library). - pub fn library_usage(&mut self, file: &str) -> Result, String> { - Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default()) - } - - /// Keep how one file uses a file's library (in memory; written when it changed, at most every - /// [`USAGE_WRITE_EVERY`] seconds). - pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> { - let name = clean_name(&usage.file); - if name.is_empty() { - return Ok(()); - } - let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); - components.sort(); - components.dedup_by_key(|c| c.0); - components.truncate(MAX_REPORTED); - let Some(u) = self.usage_of(file)? else { return Ok(()) }; - match u.list.iter_mut().find(|f| f.usage.file == name) { - Some(f) if f.usage.components == components => return Ok(()), - Some(f) => { - f.usage.components = components; - f.updated = now(); - } - None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }), - } - if u.list.len() > MAX_USERS { - u.list.sort_by_key(|f| std::cmp::Reverse(f.updated)); - u.list.truncate(MAX_USERS); - } - u.dirty = true; - if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) { - self.write_usage(false); - } - Ok(()) - } - - /// Write the usage reports that changed (`all`: whenever they were last written). - fn write_usage(&mut self, all: bool) { - let t = now(); - let root = self.root.join(FILES); - for (file, u) in &mut self.usage { - if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) { - if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) { - log::warn!("file {file}: library usage couldn't be kept: {e}"); - } - (u.dirty, u.written) = (false, t); - } - } - } - - /// Whether `file` has room for about `n` more bytes: its cap, and for a file published - /// without the key its address's share and everyone's. A log that took it over its cap is - /// compacted first (the state may be smaller than its history). - fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> { - if let Some(why) = self.low_disk() { - return Err(why); - } - let max = self.cfg.max_file_bytes; - let over = |s: &Store| s.bytes(file).saturating_add(n) > max; - // A compaction to make room at most once a minute per file (it rewrites the whole file). - let t = now(); - let may_compact = self.files.get(file).is_some_and(|e| { - t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty()) - }); - if over(self) && may_compact { - if let Some(e) = self.files.get_mut(file) { - e.forced = t; - } - self.compact(file, server); - } - if over(self) { - return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20)); - } - let h = &self.cfg.hosting; - let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; - let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); - for (times, _, used) in self.per_block(&ip) { - let cap = max_ip.saturating_mul(u64::from(times)); - if used.saturating_add(n) > cap { - return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); - } - } - let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); - if used_all.saturating_add(n) > max_all { - return Err("this server is full: that change wasn't kept".into()); - } - Ok(()) - } - - /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). - fn load(&mut self, file: &str) -> Result { - self.touch(file); - let dir = self.dir(file); - let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?; - let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; - let mut issues = Vec::new(); - let mut fresh_log = true; - let mut kept = 0; - let log_path = dir.join(LOG); - // A log that's there but can't be read (an IO error, or over a lowered cap) may hold - // confirmed edits: the file isn't opened, and the log is left as it is for another try. - let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { - Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), - r => r, - }; - // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). - let read = match read.map(|b| disk::read_log(&b)) { - Ok(Err(why)) => { - let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); - fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; - Err(format!("{why}; it was moved aside to {}", aside.display())) - } - Ok(Ok(log)) => Ok(log), - Err(why) => Err(why), - }; - match read { - Ok(log) if log.generation == generation => { - fresh_log = false; - for entry in log.entries { - if let Err(e) = server.replay(entry) { - issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped")); - fresh_log = true; - break; - } - kept += 1; - } - if let Some(why) = log.damaged { - issues.push(format!("file {file}: its log ended with {why}; that part was dropped")); - fresh_log = true; - } - } - // From before a compaction that was cut short, the snapshot has it all; or a generation - // field damaged, which no checksum covers. Replaying tells them apart without trusting - // it: entries the snapshot holds are skipped, so only edits it lacks change anything. - Ok(log) => { - let before = server.seq(); - let mut stopped = None; - for entry in log.entries { - if let Err(e) = server.replay(entry) { - stopped = Some(e); - break; - } - } - let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); - if recovered > 0 { - issues.push(format!( - "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ - {recovered} changes the snapshot didn't; they were recovered" - )); - } - if let Some(e) = stopped { - issues.push(format!( - "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ - making sense after {recovered} newer changes ({e}); the rest was dropped" - )); - } - } - Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), - } - for i in issues { - log::warn!("{i}"); - self.note(i); - } - server.journaling(true); - let file_handle = OpenOptions::new().append(true).open(dir.join(LOG)); - let log = match (fresh_log, file_handle) { - (false, Ok(f)) => { - let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len()); - // Replayed entries make it worth compacting once everyone has left. - Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } - } - // A new snapshot and log of the next generation, so appends start clean. - _ => { - let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; - self.rewrite(file, next, &server).map_err(|e| e.why)? - } - }; - self.entry(file)?.log = Some(log); - Ok(server) - } - - /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the - /// document (recent batches for reconnecting clients, deletions), which may take it over. - fn snapshot_cap(&self) -> u64 { - self.cfg.max_file_bytes.saturating_mul(2) - } - - /// Write `server`'s state as snapshot `generation` and start an empty log of it. - fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { - let dir = self.dir(file); - let kept = |why: String| RewriteError { why, snapshot_replaced: false }; - let state = server.state().map_err(|e| kept(e.to_string()))?; - let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; - // Never replace a readable snapshot with one that can't be opened again: the old snapshot - // and log still hold everything. - if bytes.len() as u64 > self.snapshot_cap() { - return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); - } - if bytes.len() as u64 > self.cfg.max_file_bytes { - log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); - } - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; - // From here the old log no longer goes with the snapshot on disk. - let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; - disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; - let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; - if let Ok(e) = self.entry(file) { - e.bytes = (bytes.len() + LOG_HEADER) as u64; - e.meta.seq = server.seq(); - let meta = e.meta.clone(); - if let Err(err) = write_json(&dir.join(META), &meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); - } - } - Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false }) - } - - /// Compactions so far. - pub fn compactions(&self) -> u64 { - self.compactions - } - - /// Why nothing more may be written when the data directory's disk is nearly full (less free - /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space - /// can't be told, nothing is refused. - fn low_disk(&mut self) -> Option { - let now = Instant::now(); - let free = match self.free { - Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free, - _ => { - let free = disk::free_bytes(&self.root); - self.free = Some((now, free)); - free - } - }; - free.filter(|f| *f < self.cfg.min_free_bytes) - .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string()) - } - - /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept. - fn note(&mut self, problem: String) { - if self.problems.len() >= MAX_PROBLEMS { - self.problems.remove(0); - } - self.problems.push(problem); - } - - /// Compact an open file: its state as the next snapshot, an empty log. - fn compact(&mut self, file: &str, server: &Server) { - self.compactions += 1; - let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; - let Some(next) = generation.checked_add(1) else { - log::error!("file {file}: compaction failed: the file's generation can't go any higher"); - return; - }; - match self.rewrite(file, next, server) { - Ok(log) => { - if let Ok(e) = self.entry(file) { - e.log = Some(log); - } - log::debug!("compacted file {file}"); - } - // The new snapshot holds everything, but a restart would drop whatever is appended to - // the old log now (its generation no longer matches): the file closes, the hub hears - // it at the next flush, and it opens again from the new snapshot. - Err(RewriteError { why, snapshot_replaced: true }) => { - self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); - log::error!("file {file}: compaction was cut short: {why}"); - if let Some(e) = self.files.get_mut(file) { - e.log = None; - } - self.cut_short.push(file.to_string()); - } - // The old snapshot and log still hold everything: carry on appending. - Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), - } - } - - /// Write and sync what's pending in every open log; the files whose log couldn't be written. - /// Those are closed here (their pending records dropped: nobody was told of them) and open - /// again from what is on disk. - fn flush_all(&mut self) -> Vec { - let mut failed = std::mem::take(&mut self.cut_short); - for (id, e) in &mut self.files { - if let Some(log) = e.log.as_mut() { - match flush_log(id, log) { - Ok(n) => e.bytes += n, - Err(()) => { - e.log = None; - failed.push(id.clone()); - } - } - } - } - failed - } } /// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced @@ -1391,38 +558,6 @@ struct RewriteError { snapshot_replaced: bool, } -/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log -/// can't be trusted to hold them). -fn flush_log(id: &str, log: &mut Log) -> Result { - if log.pending.is_empty() { - return Ok(0); - } - match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { - Ok(()) => { - let n = log.pending.len() as u64; - log.bytes += n; - log.pending.clear(); - Ok(n) - } - Err(e) => { - log::error!("file {id}: couldn't write its log: {e}"); - Err(()) - } - } -} - -/// The name of the file holding version `version` of a file's team library. -pub fn library_name(version: u64) -> String { - format!("library-{version}.bin") -} - -/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a -/// version published before each version had one. -fn library_file(dir: &Path, version: u64) -> PathBuf { - let path = dir.join(library_name(version)); - if path.exists() { path } else { dir.join(LIBRARY) } -} - fn write_json(path: &Path, value: &T) -> Result<(), String> { let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; disk::write_atomic(path, &text).map_err(|e| e.to_string()) @@ -1433,243 +568,3 @@ fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), let token = token::secret()?; Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token)) } - -impl Backend for Store { - fn admit(&mut self, file: &str, token: &str) -> Result { - let entry = self.entry(file)?; - let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); - link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) - .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) - } - - fn open(&mut self, file: &str) -> Result { - self.load(file).map_err(|e| { - let why = format!("file {file} can't be opened: {e}"); - log::error!("{why}"); - self.note(why); - "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string() - }) - } - - fn changed(&mut self, file: &str, server: &mut Server) { - let journal = server.take_journal(); - if journal.is_empty() { - return; - } - let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes); - let Some(e) = self.files.get_mut(file) else { return }; - let Some(log) = e.log.as_mut() else { return }; - for j in &journal { - match disk::record(j) { - Ok(r) => log.pending.extend(r), - Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"), - } - } - log.entries += journal.len(); - log.dirty = true; - e.meta.updated = now(); - if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes { - // The snapshot holds what's pending too. - self.compact(file, server); - } - } - - fn flush(&mut self) -> Vec { - self.flush_all() - } - - fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> { - self.room_for(file, server, bytes as u64) - } - - fn idle(&mut self, file: &str, server: &mut Server) -> bool { - let compact = self.files.get_mut(file).is_some_and(|entry| { - let snapshot_seq = entry.meta.seq; - entry.log.as_mut().is_some_and(|log| { - // Issued client ids and refusals are durable in the log; only edits need a - // new image-bearing snapshot. Still try a snapshot if syncing the log failed. - flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) - }) - }); - if compact { - self.compact(file, server); - } - // It closes anyway, with its state in the snapshot and durable log. - self.cut_short.retain(|f| f != file); - if let Some(e) = self.files.get_mut(file) { - e.log = None; - } - // Everyone just left: idle time counts from now. - self.touch(file); - true - } - - fn stop(&mut self, open: Vec<(&str, &mut Server)>) { - self.write_usage(true); - self.flush_all(); - for (file, server) in open { - if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { - self.compact(file, server); - } - } - } - - fn manage(&mut self, key: &str) -> Result<(), String> { - match same_secret(key, &self.key) { - true => Ok(()), - false if self.cfg.hosting.open => { - Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into()) - } - false => Err("wrong server key".into()), - } - } - - fn connect(&mut self, ip: IpAddr) -> Result<(), String> { - if now() >= self.next_sweep { - self.sweep(); - self.write_usage(false); - } - if self.blocked.contains(&ip) { - return Err("this server doesn't accept connections from your address".into()); - } - if !self.abuse.allow(ip, Action::Connect, Instant::now()) { - return Err("too many connections from your address lately: wait a minute and try again".into()); - } - Ok(()) - } - - fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool { - // Cursors and cursor chat (many people behind one address each send ~30 a second while - // they move) have a budget of their own, beside everything else. - let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message }; - self.abuse.allow(ip, what, Instant::now()) - } - - fn hosting(&mut self, file: &str) -> Option { - Some(self.host_info(Some(file))) - } - - fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { - if file.is_empty() && owner.is_empty() { - return Ok(self.host_info(None)); - } - if !self.owns(file, owner) { - return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into()); - } - Ok(self.host_info(Some(file))) - } - - fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { - let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() }); - let file = match &req { - Admin::Publish { name, snapshot } => { - return match self.publish_open(ip, name, snapshot) { - Ok((file, edit, view, owner)) => { - let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta)); - AdminReply::Hosted { file, edit, view, owner, expires }.into() - } - Err(e) => { - log::info!("event=publish_refused ip={ip} reason={e:?}"); - Handled::from(AdminReply::Failed { reason: e }) - } - }; - } - Admin::Owned { files } => return self.owned(files).into(), - Admin::Links { file } - | Admin::CreateLink { file, .. } - | Admin::Revoke { file, .. } - | Admin::Rename { file, .. } - | Admin::Code { file, .. } - | Admin::Remove { file } => file, - _ => return failed("that needs the server key"), - }; - if owns != Some(file.as_str()) { - return failed("an owner token manages only its own file"); - } - self.admin(req, open) - } - - fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - self.keep_report(file, link, ip, reason) - } - - fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { - let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); - match req { - Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { - Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), - Err(e) => failed(e), - }, - Admin::Files => AdminReply::Files(self.list(open)).into(), - Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), - Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), - Admin::Revoke { file, link } => match self.revoke(&file, &link) { - Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None }, - Err(e) => failed(e), - }, - Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()), - Admin::Listing => AdminReply::Listing(self.listing(open)).into(), - Admin::Reports => AdminReply::Reports(self.reports.clone()).into(), - Admin::Block { ip } => match self.block(&ip, true) { - Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) }, - Err(e) => failed(e), - }, - Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), - Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), - Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), - Admin::Owned { files } => self.owned(&files).into(), - // The hub removes people itself (it knows who is connected); it never asks the store. - Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), - Admin::Remove { file } => { - if !self.files.contains_key(&file) { - return failed("this file isn't on the server (it may have been removed)".into()); - } - let reply = match self.remove(&file) { - Ok(()) => AdminReply::Done, - Err(reason) => AdminReply::Failed { reason }, - }; - Handled { reply, revoked: None, removed: Some(file), blocked: None } - } - } - } - - fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply { - let failed = |reason: String| LibraryReply::Failed { reason }; - match req { - LibraryReq::Fetch { have, usage } => { - if let Some(u) = usage - && let Err(e) = self.report_usage(file, u) - { - log::warn!("file {file}: a library usage report couldn't be kept: {e}"); - } - self.fetch_library(ip, file, have).unwrap_or_else(failed) - } - LibraryReq::Publish { .. } if access != Access::Edit => { - failed("you can only view this file: publishing its library needs an edit link".into()) - } - LibraryReq::Publish { archive } => { - self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version }) - } - LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage), - } - } - - fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply { - // One address's share first (before everyone's budget is spent or anything is read). - if let PluginReq::Fetch { id, version } = &req - && let Some(size) = self.directory.size_of(id, version.as_deref()) - && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now()) - { - return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() }; - } - self.directory.handle(publisher, req) - } - - fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> { - self.resolve_code(ip, code) - } - - fn closing(&self) -> Closing { - Closing::Restarting - } -} diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs new file mode 100644 index 00000000..95fd8948 --- /dev/null +++ b/apps/figcraft-server/src/store/backend.rs @@ -0,0 +1,245 @@ +use super::journal::flush_log; +use super::{ + Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply, + LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret, +}; + +impl Backend for Store { + fn admit(&mut self, file: &str, token: &str) -> Result { + let entry = self.entry(file)?; + let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); + link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) + .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) + } + + fn open(&mut self, file: &str) -> Result { + self.load(file).map_err(|e| { + let why = format!("file {file} can't be opened: {e}"); + log::error!("{why}"); + self.note(why); + "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string() + }) + } + + fn changed(&mut self, file: &str, server: &mut Server) { + let journal = server.take_journal(); + if journal.is_empty() { + return; + } + let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes); + let Some(e) = self.files.get_mut(file) else { return }; + let Some(log) = e.log.as_mut() else { return }; + for j in &journal { + match disk::record(j) { + Ok(r) => log.pending.extend(r), + Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"), + } + } + log.entries += journal.len(); + log.dirty = true; + e.meta.updated = now(); + if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes { + // The snapshot holds what's pending too. + self.compact(file, server); + } + } + + fn flush(&mut self) -> Vec { + self.flush_all() + } + + fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> { + self.room_for(file, server, bytes as u64) + } + + fn idle(&mut self, file: &str, server: &mut Server) -> bool { + let compact = self.files.get_mut(file).is_some_and(|entry| { + let snapshot_seq = entry.meta.seq; + entry.log.as_mut().is_some_and(|log| { + // Issued client ids and refusals are durable in the log; only edits need a + // new image-bearing snapshot. Still try a snapshot if syncing the log failed. + flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) + }) + }); + if compact { + self.compact(file, server); + } + // It closes anyway, with its state in the snapshot and durable log. + self.cut_short.retain(|f| f != file); + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + // Everyone just left: idle time counts from now. + self.touch(file); + true + } + + fn stop(&mut self, open: Vec<(&str, &mut Server)>) { + self.write_usage(true); + self.flush_all(); + for (file, server) in open { + if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { + self.compact(file, server); + } + } + } + + fn manage(&mut self, key: &str) -> Result<(), String> { + match same_secret(key, &self.key) { + true => Ok(()), + false if self.cfg.hosting.open => { + Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into()) + } + false => Err("wrong server key".into()), + } + } + + fn connect(&mut self, ip: IpAddr) -> Result<(), String> { + if now() >= self.next_sweep { + self.sweep(); + self.write_usage(false); + } + if self.blocked.contains(&ip) { + return Err("this server doesn't accept connections from your address".into()); + } + if !self.abuse.allow(ip, Action::Connect, Instant::now()) { + return Err("too many connections from your address lately: wait a minute and try again".into()); + } + Ok(()) + } + + fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool { + // Cursors and cursor chat (many people behind one address each send ~30 a second while + // they move) have a budget of their own, beside everything else. + let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message }; + self.abuse.allow(ip, what, Instant::now()) + } + + fn hosting(&mut self, file: &str) -> Option { + Some(self.host_info(Some(file))) + } + + fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { + if file.is_empty() && owner.is_empty() { + return Ok(self.host_info(None)); + } + if !self.owns(file, owner) { + return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into()); + } + Ok(self.host_info(Some(file))) + } + + fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() }); + let file = match &req { + Admin::Publish { name, snapshot } => { + return match self.publish_open(ip, name, snapshot) { + Ok((file, edit, view, owner)) => { + let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta)); + AdminReply::Hosted { file, edit, view, owner, expires }.into() + } + Err(e) => { + log::info!("event=publish_refused ip={ip} reason={e:?}"); + Handled::from(AdminReply::Failed { reason: e }) + } + }; + } + Admin::Owned { files } => return self.owned(files).into(), + Admin::Links { file } + | Admin::CreateLink { file, .. } + | Admin::Revoke { file, .. } + | Admin::Rename { file, .. } + | Admin::Code { file, .. } + | Admin::Remove { file } => file, + _ => return failed("that needs the server key"), + }; + if owns != Some(file.as_str()) { + return failed("an owner token manages only its own file"); + } + self.admin(req, open) + } + + fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { + self.keep_report(file, link, ip, reason) + } + + fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); + match req { + Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { + Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), + Err(e) => failed(e), + }, + Admin::Files => AdminReply::Files(self.list(open)).into(), + Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), + Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), + Admin::Revoke { file, link } => match self.revoke(&file, &link) { + Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None }, + Err(e) => failed(e), + }, + Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()), + Admin::Listing => AdminReply::Listing(self.listing(open)).into(), + Admin::Reports => AdminReply::Reports(self.reports.clone()).into(), + Admin::Block { ip } => match self.block(&ip, true) { + Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) }, + Err(e) => failed(e), + }, + Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), + Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), + Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), + Admin::Owned { files } => self.owned(&files).into(), + // The hub removes people itself (it knows who is connected); it never asks the store. + Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), + Admin::Remove { file } => { + if !self.files.contains_key(&file) { + return failed("this file isn't on the server (it may have been removed)".into()); + } + let reply = match self.remove(&file) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + }; + Handled { reply, revoked: None, removed: Some(file), blocked: None } + } + } + } + + fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply { + let failed = |reason: String| LibraryReply::Failed { reason }; + match req { + LibraryReq::Fetch { have, usage } => { + if let Some(u) = usage + && let Err(e) = self.report_usage(file, u) + { + log::warn!("file {file}: a library usage report couldn't be kept: {e}"); + } + self.fetch_library(ip, file, have).unwrap_or_else(failed) + } + LibraryReq::Publish { .. } if access != Access::Edit => { + failed("you can only view this file: publishing its library needs an edit link".into()) + } + LibraryReq::Publish { archive } => { + self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version }) + } + LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage), + } + } + + fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply { + // One address's share first (before everyone's budget is spent or anything is read). + if let PluginReq::Fetch { id, version } = &req + && let Some(size) = self.directory.size_of(id, version.as_deref()) + && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now()) + { + return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() }; + } + self.directory.handle(publisher, req) + } + + fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> { + self.resolve_code(ip, code) + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs new file mode 100644 index 00000000..565916ed --- /dev/null +++ b/apps/figcraft-server/src/store/files.rs @@ -0,0 +1,304 @@ +use super::{ + Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER, + MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server, + Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json, +}; + +impl Store { + /// Keep a document (its snapshot) as a new file with an edit link and a view link. + pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> { + self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) + } + + /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's + /// caps it gets, and the files and bytes kept by the files published from addresses in it. An + /// address that doesn't parse counts as itself only, ×1, by its exact text. + pub(super) fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { + let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); + let kept: Vec<(&str, Vec<_>, u64)> = self + .files + .iter() + .filter_map(|(id, e)| { + let from = e.meta.ip.as_deref()?; + Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) + }) + .collect(); + let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); + let blocks = blocks_of(ip); + if blocks.is_empty() { + let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); + return vec![(1, files, bytes)]; + } + blocks + .into_iter() + .map(|(block, times)| { + let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); + (times, files, bytes) + }) + .collect() + } + + /// Keep a document published without the key from `ip`, within the caps on what one address + /// and everyone may keep: the file, its links and its owner token. + pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { + let h = &self.cfg.hosting; + let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes); + if !open { + return Err(KEY_ONLY.into()); + } + if !self.abuse.allow(ip, Action::Publish, Instant::now()) { + return Err("too many files published from your address lately: try again in an hour".into()); + } + let blocks = self.per_block(&ip.to_string()); + for &(times, files, _) in &blocks { + if files >= max_files.saturating_mul(times as usize) { + return Err(format!( + "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" + )); + } + } + let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); + let fits = |n: u64| { + for &(times, _, used) in &blocks { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); + } + } + if used_all.saturating_add(n) > max_all { + return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); + } + Ok(()) + }; + let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?; + Ok((info, edit, view, owner.unwrap_or_default())) + } + + /// Keep a document as a new file: published without the key from `ip` (it gets an owner + /// token), else by the operator. `fits` checks its size against the caps that apply. + fn keep( + &mut self, + name: &str, + snapshot: &[Change], + ip: Option, + fits: &dyn Fn(u64) -> Result<(), String>, + ) -> Result<(FileInfo, NewLink, NewLink, Option), String> { + if self.files.len() >= self.cfg.max_files { + return Err(format!("the server keeps at most {} files", self.cfg.max_files)); + } + if let Some(why) = self.low_disk() { + return Err(why); + } + let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?; + if replica.document().node_count() > self.cfg.core.max_nodes { + return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); + } + let mut doc = replica.document().clone(); + let name = match clean_name(name) { + n if n.is_empty() => doc.name.clone(), + n => n, + }; + doc.name = name.clone(); + let server = Server::new(doc, "", self.cfg.core); + let state = server.state().map_err(|e| e.to_string())?; + let bytes = disk::encode_snapshot(1, &state)?; + if bytes.len() as u64 > self.cfg.max_file_bytes { + return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20)); + } + fits(bytes.len() as u64)?; + let id = loop { + let id = token::file_id()?; + if !self.files.contains_key(&id) && !self.dir(&id).exists() { + break id; + } + }; + let t = now(); + let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?; + let (view, view_token) = new_link(Access::View, "Published (view)", t)?; + let owner = ip.map(|_| token::secret()).transpose()?; + let meta = + Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 }; + let dir = self.dir(&id); + let written = fs::create_dir_all(&dir) + .map_err(|e| e.to_string()) + .and_then(|()| { + disk::sync_dir(Some(&self.root.join(FILES))); + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) + }) + .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) + .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) + // Last: a folder without its description is an upload cut short. + .and_then(|()| write_json(&dir.join(META), &meta)); + if let Err(e) = written { + let _ = fs::remove_dir_all(&dir); + return Err(format!("couldn't keep the file: {e}")); + } + let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 }; + self.files.insert(id.clone(), entry); + let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?; + match ip { + Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()), + None => log::info!("event=published file={id} bytes={} by=key", bytes.len()), + } + Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner)) + } + + /// The files kept, with what the operator moderates by. + pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec { + self.files + .iter() + .map(|(id, e)| FileRow { + info: self.info(id, e, open(id)), + opened: e.meta.opened, + expires: self.expires(&e.meta), + open: e.meta.owner.is_some(), + ip: e.meta.ip.clone().unwrap_or_default(), + reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX), + }) + .collect() + } + + /// Owner token `owner` manages file `file` (one published without the key). + pub(super) fn owns(&self, file: &str, owner: &str) -> bool { + let presented = hash(owner); + self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h)) + } + + /// What became of the files these `(file id, owner token)` pairs own: each one's name, links + /// and expiry, or `None` when that token manages no file of that id here (which doesn't say + /// whether the id exists). Asking opens nothing: it doesn't count as using a file. + pub fn owned(&self, files: &[(String, String)]) -> AdminReply { + if files.len() > MAX_OWNED { + return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") }; + } + let one = |(file, owner): &(String, String)| { + let e = self.files.get(file).filter(|_| self.owns(file, owner))?; + Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) }) + }; + AdminReply::Owned(files.iter().map(one).collect()) + } + + /// Rename a file (its listing; the document inside keeps its own name). + pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> { + let name = clean_name(name); + if name.is_empty() { + return Err("give the file a name".into()); + } + let path = self.dir(file).join(META); + let entry = self.entry(file)?; + let before = std::mem::replace(&mut entry.meta.name, name); + if let Err(e) = write_json(&path, &entry.meta) { + entry.meta.name = before; + return Err(format!("couldn't rename the file: {e}")); + } + Ok(()) + } + + /// Delete the files published without the key that nobody opened for the idle time (not + /// those open now); their ids. + pub fn sweep(&mut self) -> Vec { + let t = now(); + self.next_sweep = t.saturating_add(SWEEP_EVERY); + let due: Vec = + self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); + for id in &due { + match self.remove(id) { + Ok(()) => log::info!("event=expired file={id}"), + Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"), + } + } + due + } + + /// Make a file look unused for `secs` more (tests: expiry without waiting). + #[cfg(test)] + pub fn age(&mut self, file: &str, secs: u64) { + let path = self.dir(file).join(META); + if let Some(e) = self.files.get_mut(file) { + let m = &mut e.meta; + (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); + let _ = write_json(&path, m); + } + } + + /// Make an open file's log refuse writes (tests: a full or failing disk). + #[cfg(test)] + pub fn break_log(&mut self, file: &str) { + let path = self.dir(file).join(LOG); + if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut()) + && let Ok(f) = std::fs::File::open(path) + { + log.file = f; + } + } + + /// Note that a file was opened now (it is used: it doesn't expire). + pub(super) fn touch(&mut self, file: &str) { + let path = self.dir(file).join(META); + if let Some(e) = self.files.get_mut(file) { + e.meta.opened = now(); + if let Err(err) = write_json(&path, &e.meta) { + log::warn!("file {file}: couldn't update {META}: {err}"); + } + } + } + + /// What the server says about its hosting (with `file`'s expiry). + pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo { + let h = &self.cfg.hosting; + let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta)); + HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires } + } + + /// Block or unblock an address (an IPv6 address: its /64); the address as kept. + pub fn block(&mut self, ip: &str, on: bool) -> Result { + let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?); + if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED { + return Err(format!("at most {MAX_BLOCKED} addresses can be blocked")); + } + let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) }; + if changed { + let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect(); + write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?; + log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" }); + } + Ok(ip) + } + + /// The blocked addresses. + pub fn blocked(&self) -> Vec { + self.blocked.iter().map(IpAddr::to_string).collect() + } + + /// The reports kept, oldest first. + pub fn reports(&self) -> &[ReportInfo] { + &self.reports + } + + /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). + pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { + let reason = clean_reason(reason); + if reason.is_empty() { + return Err("say what is wrong with the file".into()); + } + let name = self.entry(file)?.meta.name.clone(); + if !self.abuse.allow(ip, Action::Report, Instant::now()) { + return Err("too many reports from your address lately: try again in an hour".into()); + } + self.reports.push(ReportInfo { + at: now(), + file: file.to_string(), + name, + link: link.unwrap_or_default().to_string(), + ip: ip.to_string(), + reason, + }); + if self.reports.len() > MAX_REPORTS { + let extra = self.reports.len() - MAX_REPORTS; + self.reports.drain(..extra); + } + write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?; + log::info!("event=report file={file} ip={ip}"); + Ok(()) + } +} diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs new file mode 100644 index 00000000..5f8904fc --- /dev/null +++ b/apps/figcraft-server/src/store/journal.rs @@ -0,0 +1,273 @@ +use super::{ + FORCED_COMPACTION_EVERY, Instant, LOG, LOG_HEADER, Log, MAX_PROBLEMS, META, OpenOptions, RewriteError, SNAPSHOT, Server, Store, Write, disk, fs, + now, write_json, +}; + +impl Store { + /// Whether `file` has room for about `n` more bytes: its cap, and for a file published + /// without the key its address's share and everyone's. A log that took it over its cap is + /// compacted first (the state may be smaller than its history). + pub(super) fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> { + if let Some(why) = self.low_disk() { + return Err(why); + } + let max = self.cfg.max_file_bytes; + let over = |s: &Store| s.bytes(file).saturating_add(n) > max; + // A compaction to make room at most once a minute per file (it rewrites the whole file). + let t = now(); + let may_compact = self.files.get(file).is_some_and(|e| { + t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty()) + }); + if over(self) && may_compact { + if let Some(e) = self.files.get_mut(file) { + e.forced = t; + } + self.compact(file, server); + } + if over(self) { + return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20)); + } + let h = &self.cfg.hosting; + let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; + let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); + for (times, _, used) in self.per_block(&ip) { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); + } + } + let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); + if used_all.saturating_add(n) > max_all { + return Err("this server is full: that change wasn't kept".into()); + } + Ok(()) + } + + /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). + pub(super) fn load(&mut self, file: &str) -> Result { + self.touch(file); + let dir = self.dir(file); + let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?; + let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; + let mut issues = Vec::new(); + let mut fresh_log = true; + let mut kept = 0; + let log_path = dir.join(LOG); + // A log that's there but can't be read (an IO error, or over a lowered cap) may hold + // confirmed edits: the file isn't opened, and the log is left as it is for another try. + let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { + Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), + r => r, + }; + // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). + let read = match read.map(|b| disk::read_log(&b)) { + Ok(Err(why)) => { + let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); + fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; + Err(format!("{why}; it was moved aside to {}", aside.display())) + } + Ok(Ok(log)) => Ok(log), + Err(why) => Err(why), + }; + match read { + Ok(log) if log.generation == generation => { + fresh_log = false; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped")); + fresh_log = true; + break; + } + kept += 1; + } + if let Some(why) = log.damaged { + issues.push(format!("file {file}: its log ended with {why}; that part was dropped")); + fresh_log = true; + } + } + // From before a compaction that was cut short, the snapshot has it all; or a generation + // field damaged, which no checksum covers. Replaying tells them apart without trusting + // it: entries the snapshot holds are skipped, so only edits it lacks change anything. + Ok(log) => { + let before = server.seq(); + let mut stopped = None; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + stopped = Some(e); + break; + } + } + let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); + if recovered > 0 { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ + {recovered} changes the snapshot didn't; they were recovered" + )); + } + if let Some(e) = stopped { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ + making sense after {recovered} newer changes ({e}); the rest was dropped" + )); + } + } + Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), + } + for i in issues { + log::warn!("{i}"); + self.note(i); + } + server.journaling(true); + let file_handle = OpenOptions::new().append(true).open(dir.join(LOG)); + let log = match (fresh_log, file_handle) { + (false, Ok(f)) => { + let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len()); + // Replayed entries make it worth compacting once everyone has left. + Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } + } + // A new snapshot and log of the next generation, so appends start clean. + _ => { + let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; + self.rewrite(file, next, &server).map_err(|e| e.why)? + } + }; + self.entry(file)?.log = Some(log); + Ok(server) + } + + /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the + /// document (recent batches for reconnecting clients, deletions), which may take it over. + pub(super) fn snapshot_cap(&self) -> u64 { + self.cfg.max_file_bytes.saturating_mul(2) + } + + /// Write `server`'s state as snapshot `generation` and start an empty log of it. + fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { + let dir = self.dir(file); + let kept = |why: String| RewriteError { why, snapshot_replaced: false }; + let state = server.state().map_err(|e| kept(e.to_string()))?; + let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; + // Never replace a readable snapshot with one that can't be opened again: the old snapshot + // and log still hold everything. + if bytes.len() as u64 > self.snapshot_cap() { + return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); + } + if bytes.len() as u64 > self.cfg.max_file_bytes { + log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); + } + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; + // From here the old log no longer goes with the snapshot on disk. + let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; + disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; + let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; + if let Ok(e) = self.entry(file) { + e.bytes = (bytes.len() + LOG_HEADER) as u64; + e.meta.seq = server.seq(); + let meta = e.meta.clone(); + if let Err(err) = write_json(&dir.join(META), &meta) { + log::warn!("file {file}: couldn't update {META}: {err}"); + } + } + Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false }) + } + + /// Compactions so far. + pub fn compactions(&self) -> u64 { + self.compactions + } + + /// Why nothing more may be written when the data directory's disk is nearly full (less free + /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space + /// can't be told, nothing is refused. + pub(super) fn low_disk(&mut self) -> Option { + let now = Instant::now(); + let free = match self.free { + Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free, + _ => { + let free = disk::free_bytes(&self.root); + self.free = Some((now, free)); + free + } + }; + free.filter(|f| *f < self.cfg.min_free_bytes) + .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string()) + } + + /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept. + pub(super) fn note(&mut self, problem: String) { + if self.problems.len() >= MAX_PROBLEMS { + self.problems.remove(0); + } + self.problems.push(problem); + } + + /// Compact an open file: its state as the next snapshot, an empty log. + pub(super) fn compact(&mut self, file: &str, server: &Server) { + self.compactions += 1; + let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; + let Some(next) = generation.checked_add(1) else { + log::error!("file {file}: compaction failed: the file's generation can't go any higher"); + return; + }; + match self.rewrite(file, next, server) { + Ok(log) => { + if let Ok(e) = self.entry(file) { + e.log = Some(log); + } + log::debug!("compacted file {file}"); + } + // The new snapshot holds everything, but a restart would drop whatever is appended to + // the old log now (its generation no longer matches): the file closes, the hub hears + // it at the next flush, and it opens again from the new snapshot. + Err(RewriteError { why, snapshot_replaced: true }) => { + self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); + log::error!("file {file}: compaction was cut short: {why}"); + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + self.cut_short.push(file.to_string()); + } + // The old snapshot and log still hold everything: carry on appending. + Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), + } + } + + /// Write and sync what's pending in every open log; the files whose log couldn't be written. + /// Those are closed here (their pending records dropped: nobody was told of them) and open + /// again from what is on disk. + pub(super) fn flush_all(&mut self) -> Vec { + let mut failed = std::mem::take(&mut self.cut_short); + for (id, e) in &mut self.files { + if let Some(log) = e.log.as_mut() { + match flush_log(id, log) { + Ok(n) => e.bytes += n, + Err(()) => { + e.log = None; + failed.push(id.clone()); + } + } + } + } + failed + } +} + +/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log +/// can't be trusted to hold them). +pub(super) fn flush_log(id: &str, log: &mut Log) -> Result { + if log.pending.is_empty() { + return Ok(0); + } + match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { + Ok(()) => { + let n = log.pending.len() as u64; + log.bytes += n; + log.pending.clear(); + Ok(n) + } + Err(e) => { + log::error!("file {id}: couldn't write its log: {e}"); + Err(()) + } + } +} diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs new file mode 100644 index 00000000..55ca50ad --- /dev/null +++ b/apps/figcraft-server/src/store/library.rs @@ -0,0 +1,165 @@ +use super::{ + Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED, + MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now, + write_json, +}; + +impl Store { + /// A file's team library for `ip`, unless its version is `have`; within what one address and + /// everyone may download (the size is known before anything is read). + pub(super) fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { + let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); + let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; + if have == Some(lib.version) { + return Ok(LibraryReply::Unchanged { version: lib.version }); + } + let path = library_file(&dir, lib.version); + let size = fs::metadata(&path).map_or(0, |m| m.len()); + let now = Instant::now(); + if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { + return Err(BUSY_DOWNLOADS.into()); + } + Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? }) + } + + /// Keep `archive` as a file's team library: it must read as a library (with the limits files + /// using it read it with), be the file's library if it has one, and be a newer version. + // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile); + // move it to a worker if large libraries make that noticeable. + pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result { + let max = self.cfg.max_file_bytes; + if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() { + return Err(NO_PUBLIC_LIBRARY.into()); + } + if archive.len() as u64 > max { + return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20)); + } + // Everything it unpacks to counts against the same cap, so a small archive can't expand + // into a large library in memory. + let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) { + Ok(Some(lib)) => lib, + Ok(None) => return Err("that isn't a published library".into()), + Err(e) => return Err(format!("the library can't be read: {e}")), + }; + if lib.doc.node_count() > self.cfg.core.max_nodes { + return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes)); + } + let m = &lib.manifest; + let dir = self.dir(file); + let entry = self.entry(file)?; + if let Some(old) = &entry.meta.library { + if old.id != m.id { + return Err(format!("this file's library on the server is another one (\"{}\")", old.name)); + } + if m.version <= old.version { + return Err(format!("version {} is on the server already: publish again to follow it", old.version)); + } + // A jump would let a client use up the numbers and leave no newer version to publish. + if m.version > old.version.saturating_add(1) { + return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); + } + } else if m.version > MAX_FIRST_LIBRARY_VERSION { + return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); + } + // Each version has a file of its own, written before the description names it: a failed + // description write or a crash in between leaves the previous version served whole, never + // the new bytes under the old number. + let path = dir.join(library_name(m.version)); + disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; + let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); + if let Err(e) = write_json(&dir.join(META), &entry.meta) { + // Never served (the description doesn't name it); the next publish of it replaces it. + if let Err(r) = fs::remove_file(&path) { + log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); + } + entry.meta.library = before; + return Err(format!("couldn't keep the library: {e}")); + } + // The previous version isn't served any more. + if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { + log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); + } + log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); + Ok(m.version) + } + + /// A library's usage reports in memory (read from disk the first time; a damaged list starts + /// over: it is only statistics). `None` when the file has no library. + fn usage_of(&mut self, file: &str) -> Result, String> { + if self.entry(file)?.meta.library.is_none() { + return Ok(None); + } + if !self.usage.contains_key(file) { + let path = self.dir(file).join(USAGE); + let list = if path.exists() { + disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + } else { + Vec::new() + }; + self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() }); + } + Ok(self.usage.get_mut(file)) + } + + /// The usage reports kept for a file's library (none when it has no library). + pub fn library_usage(&mut self, file: &str) -> Result, String> { + Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default()) + } + + /// Keep how one file uses a file's library (in memory; written when it changed, at most every + /// [`USAGE_WRITE_EVERY`] seconds). + pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> { + let name = clean_name(&usage.file); + if name.is_empty() { + return Ok(()); + } + let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); + components.sort(); + components.dedup_by_key(|c| c.0); + components.truncate(MAX_REPORTED); + let Some(u) = self.usage_of(file)? else { return Ok(()) }; + match u.list.iter_mut().find(|f| f.usage.file == name) { + Some(f) if f.usage.components == components => return Ok(()), + Some(f) => { + f.usage.components = components; + f.updated = now(); + } + None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }), + } + if u.list.len() > MAX_USERS { + u.list.sort_by_key(|f| std::cmp::Reverse(f.updated)); + u.list.truncate(MAX_USERS); + } + u.dirty = true; + if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) { + self.write_usage(false); + } + Ok(()) + } + + /// Write the usage reports that changed (`all`: whenever they were last written). + pub(super) fn write_usage(&mut self, all: bool) { + let t = now(); + let root = self.root.join(FILES); + for (file, u) in &mut self.usage { + if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) { + if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) { + log::warn!("file {file}: library usage couldn't be kept: {e}"); + } + (u.dirty, u.written) = (false, t); + } + } + } +} + +/// The name of the file holding version `version` of a file's team library. +pub fn library_name(version: u64) -> String { + format!("library-{version}.bin") +} + +/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a +/// version published before each version had one. +fn library_file(dir: &Path, version: u64) -> PathBuf { + let path = dir.join(library_name(version)); + if path.exists() { path } else { dir.join(LIBRARY) } +} diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs new file mode 100644 index 00000000..6c279219 --- /dev/null +++ b/apps/figcraft-server/src/store/links.rs @@ -0,0 +1,157 @@ +use super::{ + Access, Action, CODE_GONE, CODES_BUSY, Entry, FileInfo, Instant, IpAddr, LINKS, LinkInfo, MAX_GONE_CODES, NO_CODE, NewLink, OpenFile, Sealed, + Store, clean_name, codes, fs, hash, new_link, now, same_secret, write_json, +}; + +impl Store { + /// The files kept. + pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { + self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect() + } + + /// A file's links. + pub fn links(&mut self, file: &str) -> Result, String> { + Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect()) + } + + /// A new link to a file. + pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result { + let max = self.cfg.max_links; + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + if entry.links.len() >= max { + return Err(format!("a file has at most {max} links: revoke one first")); + } + let (link, token) = new_link(access, &clean_name(label), now())?; + entry.links.push(link.clone()); + if let Err(e) = write_json(&path, &entry.links) { + entry.links.pop(); + return Err(format!("couldn't keep the link: {e}")); + } + Ok(NewLink { link: link.info(file), token }) + } + + /// The link of `entry` whose token `token` is (every link is compared, in constant time, so + /// timing doesn't tell which one matched). + pub(super) fn link_of(entry: &Entry, token: &str) -> Option { + let presented = hash(token); + let mut found = None; + for (i, l) in entry.links.iter().enumerate() { + if same_secret(&presented, &l.hash) { + found = Some(i); + } + } + found + } + + /// These join codes no longer open anything: typing one says so. + fn forget_codes(&mut self, codes: impl IntoIterator) { + self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); + let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); + self.gone_codes.drain(..extra); + } + + /// Where the link with the join code `key` is: its file, its place among the file's links + /// and its token (every code is compared, so timing doesn't tell which one matched). + fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { + let mut found = None; + for (id, e) in &self.files { + for (i, l) in e.links.iter().enumerate() { + if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { + found = Some((id.clone(), i, token)); + } + } + } + found + } + + /// A new join code for the link of `file` whose token is `token` (it takes the place of the + /// link's earlier code): the code, shown this once. + pub fn set_code(&mut self, file: &str, token: &str) -> Result { + let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?; + // 45 random bits: one already in use is all but impossible, and never handed out twice. + let (code, key) = loop { + let code = codes::new_code()?; + let key = codes::Key::new(&code); + if self.find_code(&key).is_none() { + break (code, key); + } + }; + let sealed = key.seal(token)?; + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + let link = entry.links.get_mut(at).ok_or("that file has no such link")?; + let before = link.code.replace(sealed); + let id = link.id.clone(); + if let Err(e) = write_json(&path, &entry.links) { + if let Some(l) = entry.links.get_mut(at) { + l.code = before; + } + return Err(format!("couldn't keep the join code: {e}")); + } + self.forget_codes(before); + log::info!("event=code file={file} link={id}"); + Ok(code) + } + + /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against + /// the address (see `abuse`), and nothing is looked up for an address that is locked out. + pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> { + let now = Instant::now(); + if let Some(left) = self.abuse.code_locked(ip, now) { + return Err(format!( + "Too many wrong codes from your address: try again in {} minutes, or join with the invite link", + left.as_secs() / 60 + 1 + )); + } + if !self.abuse.allow(ip, Action::Code, now) { + return Err("Too many codes tried from your address: wait a minute and try again".into()); + } + if !self.abuse.codes_open(now) { + return Err(CODES_BUSY.into()); + } + // Anything that isn't a code's symbols is a wrong code too (and costs no hashing). + let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c)); + let found = key.as_ref().and_then(|k| self.find_code(k)); + self.abuse.code_tried(ip, found.is_some(), now); + match found { + Some((file, _, token)) => { + log::info!("event=code_used file={file} ip={ip}"); + Ok((file, token)) + } + None => { + let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash())); + Err(if gone { CODE_GONE } else { NO_CODE }.into()) + } + } + } + + /// Revoke a link. + pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?; + let gone = entry.links.remove(at); + if let Err(e) = write_json(&path, &entry.links) { + entry.links.insert(at, gone); + return Err(format!("couldn't revoke the link: {e}")); + } + log::info!("revoked link {link} of file {file}"); + self.forget_codes(gone.code); + Ok(()) + } + + /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no + /// longer served either way). + pub fn remove(&mut self, file: &str) -> Result<(), String> { + // Closed first: an open log would keep the folder from being deleted on Windows. + let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; + self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); + drop(entry); + let dir = self.dir(file); + fs::remove_dir_all(&dir) + .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?; + log::info!("removed file {file}"); + Ok(()) + } +} diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index ffd44f3b..aa0fdffb 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -24,6 +24,8 @@ log = { workspace = true } # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" +base64 = { workspace = true } +serde_json = { workspace = true } js-sys = "0.3" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/api.rs b/apps/figcraft-web/src/api.rs new file mode 100644 index 00000000..621925cf --- /dev/null +++ b/apps/figcraft-web/src/api.rs @@ -0,0 +1,172 @@ +//! `window.figcraft`: the control channel for the page, as promises. +//! +//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent +//! driving FigCraft in a browser tab (or an `