diff --git a/.gitignore b/.gitignore index 285d491d..9b96286a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,150 @@ -# Binaries for programs and plugins +# --- OS --- +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Thumbs.db +Desktop.ini +*~ + +# --- Editors / IDEs --- +.vscode/ +.idea/ +*.swp +*.swo +*.sublime-project +*.sublime-workspace +*.code-workspace + +# --- Env / secrets (keep example env files tracked) --- +.env +.env.* +!.env.example +!.env.*.example +*.pem +*.priv +*.key +!*.pub + +# --- Go --- *.exe *.exe~ *.dll *.so *.dylib - - -# Test binary, built with `go test -c` *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out +*.prof +*.coverprofile +coverage.out +coverage.html +assets_vfsdata.go +# vendor/ # uncomment if using vendoring +# Local toolchains (scripts/download-go-node.sh) +.tools/ +# Ad-hoc binaries built at repo root +/rmdecode +/rmfakecloud +/rmdoc2png +# --- Node / JavaScript / TypeScript (Vite, pnpm) --- +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +.pnpm-store/ +.npm/ +.yarn/ +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* .eslintcache -data/ +*.tsbuildinfo +.turbo/ +.cache/ +ui/dist/ +ui/build/ +ui/.vite/ + +# --- Python (scripts, rmscene, rmc) --- +__pycache__/ +*.py[cod] +*$py.class +*.pyo +.Python +.venv/ +venv/ +ENV/ +env/ +.env/ +*.egg +*.egg-info/ +.eggs/ dist/ -node_modules/ -assets_vfsdata.go -.vscode/ -assets_vfsdata.go -# Dependency directories (remove the comment below to include it) -# vendor/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.tox/ +.coverage +.coverage.* +htmlcov/ +.ipynb_checkpoints/ -# mkdoc build +# --- C / C++ / CMake / Emscripten (librm_lines WASM) --- +CMakeCache.txt +CMakeFiles/ +cmake-build-*/ +cmake_install.cmake +compile_commands.json +CTestTestfile.cmake +Makefile.cmake +*.o +*.obj +*.a +*.lib +*.la +*.lo +*.gch +*.pch +*.d +*.gcda +*.gcno +*.gcov +.ninja_deps +.ninja_log +build.ninja +ui/extra/simplerenderer/build/ +ui/extra/simplerenderer/cmake-build-*/ +# Intermediate WASM/JS from local emscripten builds (shipped copies live under ui/public/assets/) +ui/extra/simplerenderer/*.wasm +ui/extra/simplerenderer/*.js +ui/extra/simplerenderer/*.wasm.map +ui/extra/simplerenderer/*.js.map +!ui/public/assets/ + +# --- Docs (mkdocs) --- site/ + +# --- App runtime / local data --- +data/ +*.log +tmp/ +temp/ +*.tmp + +# --- Build / release output --- +/dist/ +*.zip +*.tar +*.tar.gz +*.tgz + +# --- Device firmware reference binaries (local reverse-engineering copies) --- +refrences/ +references/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..5ef7400a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "ui/extra/simplerenderer/librm_lines"] + path = ui/extra/simplerenderer/librm_lines + url = https://github.com/RedTTGMoss/librm_lines +[submodule "third_party/rmscene"] + path = third_party/rmscene + url = https://github.com/ricklupton/rmscene.git +[submodule "third_party/rmc"] + path = third_party/rmc + url = https://github.com/ricklupton/rmc.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c82103d..3272fa3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +# Unreleased + +## Features + +- `.rm` lines decoder: `cmd/rmdecode` and `internal/rmdecode` — export all writings to **SVG** or **PDF** (`-format` / `-o`); v3/v5 in Go, v6 via `rmc`; v6 text summary via Python + `rmscene` (see `internal/rmdecode/README.md`). +- **`cmd/rmdoc2png`**: extract every `*.rm` from a `.rmdoc` zip and write paginated PNGs (`001_….png`, …; v3/v5 in Go + gg; v6 via `scripts/rmscene_v6_to_png.py` + Pillow + vendored `third_party/rmscene`). +- Document type from `.content` file when present and size > 4 bytes (pdf, epub, notebook). +- Template thumbnails use `iconData` from `.template` file when available (base64 SVG). +- EPUB table of contents (TOC) uses black font color. +- PDF object fallback: "Alternative text: Download PDF" when plugin cannot render. + +## Changed + +- PDFs rendered solely as raw `application/pdf` via browser/Adobe plugin; removed react-pdf and PNG+overlay viewer. +- Error boundary ("Something went wrong") adds meta refresh to `/` and link to home. + +--- + # 0.0.25 ## Features diff --git a/README.md b/README.md index ae5cb49f..bc1b2308 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,22 @@ See the [documentation](https://ddvk.github.io/rmfakecloud/remarkable/setup/) fo run `./dev.sh` which should start the UI and backend +### `.rm` page decoder (lines format) + +To inspect raw handwriting pages (`*.rm`), see **[internal/rmdecode/README.md](internal/rmdecode/README.md)**. It documents **v3/v5** (Go) vs **v6** (Python + [rmscene](https://github.com/ricklupton/rmscene)), and points to **[rmc](https://github.com/ricklupton/rmc)** for production-grade v6 SVG/PDF export from the ecosystem. + +The **rmscene** library is included as a git submodule at **`third_party/rmscene`**. After a fresh clone run: `git submodule update --init --recursive`. + +```bash +go run ./cmd/rmdecode /path/to/page.rm +go run ./cmd/rmdecode -o strokes.svg -format svg /path/to/page.rm +go run ./cmd/rmdecode -o strokes.pdf /path/to/page.rm +``` + +v3/v5 SVG/PDF are rendered in Go; v6 SVG/PDF use the `rmc` CLI if installed. + +**`.rmdoc` → PNG (all pages, paged filenames):** `go run ./cmd/rmdoc2png -o outdir doc.rmdoc` — writes `001_name.png`, `002_…` for each `*.rm` (v3/v5 in Go; v6 needs Python + Pillow + vendored [rmscene](https://github.com/ricklupton/rmscene)). See `internal/rmdecode/README.md`. + ### Caveats/ WARNING - (applies when you don't have security, version <= 0.0.3) connecting to the api will delete all your files, unless you mark them as not synced `synced:false` prior to syncing (advisable just to disconnect, reconnect the cloud) diff --git a/cmd/rmdecode/main.go b/cmd/rmdecode/main.go new file mode 100644 index 00000000..88192d9e --- /dev/null +++ b/cmd/rmdecode/main.go @@ -0,0 +1,208 @@ +// Command rmdecode decodes a reMarkable .rm page file to text/markdown/SVG/PDF. +// +// go run ./cmd/rmdecode page.rm +// go run ./cmd/rmdecode -format svg -o out.svg page.rm +// go run ./cmd/rmdecode -o out.pdf page.rm +// +// Version 3/5: decoded in Go (juruen/rmapi); SVG/PDF rendered in Go. +// Version 6: markdown/SVG/PDF via `rmc` (pip install rmc / pipx install rmc); optional text summary via scripts/rmdecode_v6_summary.py. +package main + +import ( + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/ddvk/rmfakecloud/internal/rmdecode" + "github.com/juruen/rmapi/encoding/rm" +) + +func main() { + outPath := flag.String("o", "", "output file (default: stdout for text); extension .svg/.pdf/.md selects format") + formatFlag := flag.String("format", "", "output type: text (default), markdown, svg, or pdf (optional if inferred from -o)") + v6Script := flag.String("v6-script", "", "path to rmdecode_v6_summary.py (default: ./scripts/rmdecode_v6_summary.py)") + flag.Parse() + args := flag.Args() + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: rmdecode [-format text|markdown|svg|pdf] [-o path] ") + os.Exit(1) + } + path := args[0] + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + ver, err := rmdecode.ParseVersion(data) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "header version: %d\n", ver) + + format := strings.ToLower(strings.TrimSpace(*formatFlag)) + if format == "" && *outPath != "" { + switch strings.ToLower(filepath.Ext(*outPath)) { + case ".svg": + format = "svg" + case ".pdf": + format = "pdf" + case ".md", ".markdown": + format = "markdown" + case ".txt": + format = "text" + default: + format = "text" + } + } + if format == "" { + format = "text" + } + + switch format { + case "text", "markdown", "svg", "pdf": + default: + fmt.Fprintf(os.Stderr, "invalid -format %q (use text, markdown, svg, or pdf)\n", format) + os.Exit(1) + } + + if format == "svg" || format == "pdf" || format == "markdown" { + if *outPath == "" { + fmt.Fprintln(os.Stderr, "markdown/svg/pdf output require -o (or use -o - for stdout)") + os.Exit(1) + } + } + + switch ver { + case 3, 5: + page, err := rmdecode.DecodeLegacy(data) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if err := writeLegacy(format, page, *outPath); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + case 6: + if err := writeV6(format, path, *outPath, *v6Script); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + default: + fmt.Fprintf(os.Stderr, "unsupported lines format version %d\n", ver) + os.Exit(1) + } +} + +func writeLegacy(format string, page *rm.Rm, outPath string) error { + switch format { + case "text": + out := os.Stdout + if outPath != "" { + f, err := os.Create(outPath) + if err != nil { + return err + } + defer f.Close() + out = f + } + _, err := io.WriteString(out, page.String()) + return err + case "svg": + s, err := rmdecode.RenderWritingsSVG(page) + if err != nil { + return err + } + return writeStringOut(outPath, s) + case "pdf": + b, err := rmdecode.RenderWritingsPDF(page) + if err != nil { + return err + } + return writeBytesOut(outPath, b) + case "markdown": + return fmt.Errorf("markdown is only supported for v6 via rmc") + } + return fmt.Errorf("unknown format %q", format) +} + +func writeV6(format, inPath, outPath, v6Script string) error { + switch format { + case "text": + script := v6Script + if script == "" { + wd, err := os.Getwd() + if err != nil { + return err + } + script = filepath.Join(wd, "scripts", "rmdecode_v6_summary.py") + } + if _, err := os.Stat(script); err != nil { + return fmt.Errorf("v6 text: script not found: %s (use -v6-script or run from repo root)", script) + } + cmd := exec.Command("python3", script, inPath) + if py := buildPythonPathForV6(); py != "" { + cmd.Env = append(os.Environ(), "PYTHONPATH="+py) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + case "markdown", "svg", "pdf": + rmc, err := exec.LookPath("rmc") + if err != nil { + return fmt.Errorf("v6 %s: install the `rmc` tool (pip install rmc or pipx install rmc): %w", format, err) + } + if outPath == "-" { + return fmt.Errorf("v6 %s: use a file path with -o (rmc does not stream to stdout here)", format) + } + cmd := exec.Command(rmc, "-t", format, "-o", outPath, inPath) + if py := buildPythonPathForV6(); py != "" { + cmd.Env = append(os.Environ(), "PYTHONPATH="+py) + } + cmd.Stderr = os.Stderr + return cmd.Run() + } + return fmt.Errorf("unknown format %q", format) +} + +func buildPythonPathForV6() string { + parts := make([]string, 0, 3) + if p := strings.TrimSpace(os.Getenv("RMFAKECLOUD_RMSCENE_SRC")); p != "" { + if filepath.Base(p) == "rmscene" { + p = filepath.Dir(p) + } + parts = append(parts, p) + } + if root := strings.TrimSpace(os.Getenv("RMFAKECLOUD_ROOT")); root != "" { + parts = append(parts, filepath.Join(root, "third_party", "rmscene", "src")) + } + if existing := strings.TrimSpace(os.Getenv("PYTHONPATH")); existing != "" { + parts = append(parts, existing) + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, string(os.PathListSeparator)) +} + +func writeStringOut(outPath, s string) error { + if outPath == "-" { + _, err := io.WriteString(os.Stdout, s) + return err + } + return os.WriteFile(outPath, []byte(s), 0644) +} + +func writeBytesOut(outPath string, b []byte) error { + if outPath == "-" { + _, err := os.Stdout.Write(b) + return err + } + return os.WriteFile(outPath, b, 0644) +} diff --git a/cmd/rmdoc2png/main.go b/cmd/rmdoc2png/main.go new file mode 100644 index 00000000..1ee27271 --- /dev/null +++ b/cmd/rmdoc2png/main.go @@ -0,0 +1,180 @@ +// Command rmdoc2png extracts every .rm page from a .rmdoc zip and writes one PNG per page +// with zero-padded page numbers (paging). +// +// - v3/v5 .rm: rendered in Go (internal/rmdecode, github.com/fogleman/gg). +// - v6 .rm: rendered via scripts/rmscene_v6_to_png.py (Pillow + vendored rmscene). +// +// Run from the repository root (or set RMFAKECLOUD_ROOT). +// +// go run ./cmd/rmdoc2png -o outdir document.rmdoc +package main + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/ddvk/rmfakecloud/internal/rmdecode" + "github.com/juruen/rmapi/encoding/rm" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: rmdoc2png [-o outdir] ") + os.Exit(1) + } + outDir := "" + args := os.Args[1:] + for len(args) > 0 && args[0] == "-o" { + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "usage: rmdoc2png [-o outdir] ") + os.Exit(1) + } + outDir = args[1] + args = args[2:] + } + if len(args) != 1 { + fmt.Fprintln(os.Stderr, "usage: rmdoc2png [-o outdir] ") + os.Exit(1) + } + rmdocPath := args[0] + if outDir == "" { + base := strings.TrimSuffix(filepath.Base(rmdocPath), filepath.Ext(rmdocPath)) + outDir = base + "_png" + } + if err := os.MkdirAll(outDir, 0755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + repoRoot := findRepoRoot() + if repoRoot == "" { + if e := os.Getenv("RMFAKECLOUD_ROOT"); e != "" { + repoRoot = e + } + } + v6Script := filepath.Join(repoRoot, "scripts", "rmscene_v6_to_png.py") + if _, err := os.Stat(v6Script); err != nil { + fmt.Fprintf(os.Stderr, "rmdoc2png: need repo root with scripts/rmscene_v6_to_png.py (cwd or RMFAKECLOUD_ROOT): %v\n", err) + os.Exit(1) + } + + zr, err := zip.OpenReader(rmdocPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer zr.Close() + + var rmEntries []*zip.File + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + if !strings.EqualFold(filepath.Ext(f.Name), ".rm") { + continue + } + rmEntries = append(rmEntries, f) + } + if len(rmEntries) == 0 { + fmt.Fprintln(os.Stderr, "no .rm files found in archive") + os.Exit(1) + } + + // Stable order: zip iteration order (usually alphabetical within archive). + // Page numbers are 1-based for output filenames. + for n, f := range rmEntries { + pageNum := n + 1 + rc, err := f.Open() + if err != nil { + fmt.Fprintf(os.Stderr, "skip %s: %v\n", f.Name, err) + continue + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "skip %s: %v\n", f.Name, err) + continue + } + + stem := strings.TrimSuffix(f.Name, filepath.Ext(f.Name)) + stem = strings.TrimPrefix(stem, "/") + safe := strings.ReplaceAll(stem, "/", "_") + safe = strings.ReplaceAll(safe, "\\", "_") + if safe == "" { + safe = "page" + } + outPath := filepath.Join(outDir, fmt.Sprintf("%03d_%s.png", pageNum, safe)) + + ver, err := rmdecode.ParseVersion(data) + if err != nil { + fmt.Fprintf(os.Stderr, "skip %s: %v\n", f.Name, err) + continue + } + + var pngData []byte + switch ver { + case 3, 5: + var page rm.Rm + if err := page.UnmarshalBinary(data); err != nil { + fmt.Fprintf(os.Stderr, "skip %s: %v\n", f.Name, err) + continue + } + pngData, err = rmdecode.RenderWritingsPNG(&page) + if err != nil { + fmt.Fprintf(os.Stderr, "skip %s: %v\n", f.Name, err) + continue + } + case 6: + cmd := exec.Command("python3", v6Script, "-") + cmd.Stdin = bytes.NewReader(data) + cmd.Dir = repoRoot + out, err := cmd.Output() + if err != nil { + if x, ok := err.(*exec.ExitError); ok { + fmt.Fprintf(os.Stderr, "skip %s: python: %s\n", f.Name, string(x.Stderr)) + } else { + fmt.Fprintf(os.Stderr, "skip %s: %v\n", f.Name, err) + } + continue + } + pngData = out + default: + fmt.Fprintf(os.Stderr, "skip %s: unsupported lines version %d\n", f.Name, ver) + continue + } + + if err := os.WriteFile(outPath, pngData, 0644); err != nil { + fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "wrote %s (page %d, version %d)\n", outPath, pageNum, ver) + } +} + +func findRepoRoot() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for i := 0; i < 32; i++ { + goMod := filepath.Join(dir, "go.mod") + script := filepath.Join(dir, "scripts", "rmscene_v6_to_png.py") + if st, err := os.Stat(goMod); err == nil && !st.IsDir() { + if _, err := os.Stat(script); err == nil { + return dir + } + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} diff --git a/docs/install/configuration.md b/docs/install/configuration.md index f90466a8..b5e0a262 100644 --- a/docs/install/configuration.md +++ b/docs/install/configuration.md @@ -12,6 +12,8 @@ The configuration is made through environment variables. | `RM_HTTPS_COOKIE` | For the UI, force cookies to be available only via https | | `RM_TRUST_PROXY` | Trust the proxy for client ip addresses (X-Forwarded-For/X-Real-IP) default false | | `HASH_SCHEMA_VERSION` | Hash tree schema version: "3" or "4" (default: 3) | +| `RMFAKECLOUD_ALLOW_SU` | Enable admin **su** (impersonate another user) in the web UI (default: `false`). Env-only — not editable from the web UI. When disabled, `POST /su` is forbidden; an active su session can still leave via `POST /su/leave`. | +| `RMFAKECLOUD_RMC_SRC` | Path to the [rmc](https://github.com/ricklupton/rmc) source `src` directory (e.g. `/home/aaron/Documents/rmc-main/src`) for v6 `.rm` conversion. Also editable by admins under **Admin → Rendering** (saved to `DATADIR/server_settings.json`, which overrides this env value). | ## Handwriting recognition diff --git a/go.mod b/go.mod index 65d8dffa..8db661fd 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,20 @@ module github.com/ddvk/rmfakecloud -go 1.23.3 +go 1.24.0 -toolchain go1.24.1 +toolchain go1.24.4 require ( + github.com/akeil/rmtool v0.0.0-20210103130743-18a76adaa7d3 github.com/apognu/gocal v0.9.1 github.com/danjacques/gofslock v0.0.0-20240212154529-d899e02bfe22 github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 + github.com/fogleman/gg v1.3.0 github.com/gin-gonic/gin v1.9.1 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.1 + github.com/jung-kurt/gofpdf v1.16.2 github.com/juruen/rmapi v0.0.25 github.com/mochi-mqtt/server/v2 v2.7.9 github.com/poundifdef/go-remarkable2pdf v0.2.0 @@ -40,19 +43,25 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.19.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/gorilla/i18n v0.0.0-20150820051429-8b358169da46 // indirect + github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 // indirect + github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/jung-kurt/gofpdf v1.16.2 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/llgcode/draw2d v0.0.0-20260213073409-1c39bbefe083 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pdfcpu/pdfcpu v0.3.8 // indirect github.com/pelletier/go-toml/v2 v2.2.0 // indirect + github.com/phpdave11/gofpdi v1.0.7 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/xid v1.4.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -63,13 +72,14 @@ require ( github.com/unidoc/unichart v0.3.0 // indirect github.com/unidoc/unitype v0.4.0 // indirect golang.org/x/arch v0.7.0 // indirect - golang.org/x/image v0.18.0 // indirect + golang.org/x/image v0.36.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.18.0 // indirect golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect ) diff --git a/go.sum b/go.sum index c3e2fdd0..326fb341 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,10 @@ github.com/adrg/sysfont v0.1.2/go.mod h1:6d3l7/BSjX9VaeXWJt9fcrftFaD/t7l11xgSywC github.com/adrg/xdg v0.3.0/go.mod h1:7I2hH/IT30IsupOpKZ5ue7/qNi3CoKzD6tL3HwpaRMQ= github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= +github.com/akeil/rmtool v0.0.0-20210103130743-18a76adaa7d3 h1:mAM7qzO3tb5SHAW7aeNsctiARtWd5nXtQmj5Cjqyyuw= +github.com/akeil/rmtool v0.0.0-20210103130743-18a76adaa7d3/go.mod h1:/LNBAUB3vTtcJSxrw3v/x+UDnOTy6VrUHcm+/xkcJnc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/apognu/gocal v0.9.1 h1:e3vlb+YV5wXvqBxYsC6GvkuUAEnRipkvoA1P79gwspM= github.com/apognu/gocal v0.9.1/go.mod h1:5tNvJsQGJHwS3KqWxHAFZzavC4k42jrJ3ouVmOzS/AM= github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= @@ -76,12 +80,16 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-gl/gl v0.0.0-20180407155706-68e253793080/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk= +github.com/go-gl/glfw v0.0.0-20180426074136-46a8d530c326/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -97,6 +105,8 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -148,16 +158,23 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/i18n v0.0.0-20150820051429-8b358169da46 h1:N+R2A3fGIr5GucoRMu2xpqyQWQlfY31orbofBCdjMz8= github.com/gorilla/i18n v0.0.0-20150820051429-8b358169da46/go.mod h1:2Yoiy15Cf7Q3NFwfaJquh7Mk1uGI09ytcD7CUhn8j7s= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hhrutter/lzw v0.0.0-20190827003112-58b82c5a41cc/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk= +github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650 h1:1yY/RQWNSBjJe2GDCIYoLmpWVidrooriUr4QS/zaATQ= +github.com/hhrutter/lzw v0.0.0-20190829144645-6f07a24e8650/go.mod h1:yJBvOcu1wLQ9q9XZmfiPfur+3dQJuIhYQsMGLYcItZk= +github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7 h1:o1wMw7uTNyA58IlEdDpxIrtFHTgnvYzA8sCQz8luv94= +github.com/hhrutter/tiff v0.0.0-20190829141212-736cae8d0bc7/go.mod h1:WkUxfS2JUu3qPo6tRld7ISb8HiC0gVSU91kooBMDVok= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= @@ -184,6 +201,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/llgcode/draw2d v0.0.0-20200930101115-bfaf5d914d1e/go.mod h1:mVa0dA29Db2S4LVqDYLlsePDzRJLDfdhVZiI15uY0FA= +github.com/llgcode/draw2d v0.0.0-20260213073409-1c39bbefe083 h1:zCxBRUEE9kvdmLfHV9+pXEzSWAUhauanhZl/jZpNmuk= +github.com/llgcode/draw2d v0.0.0-20260213073409-1c39bbefe083/go.mod h1:9uKxeU+VF044WOWtgMjxn1LRfMiQtWwB81X5jGTOo5s= +github.com/llgcode/ps v0.0.0-20150911083025-f1443b32eedb/go.mod h1:1l8ky+Ew27CMX29uG+a2hNOKpeNYEQjjtiALiBlFQbY= +github.com/llgcode/ps v0.0.0-20210114104736-f4b0c5d1e02e h1:ZAvbj5hI/G/EbAYAcj4yCXUNiFKefEhH0qfImDDD0/8= +github.com/llgcode/ps v0.0.0-20210114104736-f4b0c5d1e02e/go.mod h1:1l8ky+Ew27CMX29uG+a2hNOKpeNYEQjjtiALiBlFQbY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mochi-mqtt/server/v2 v2.7.9 h1:y0g4vrSLAag7T07l2oCzOa/+nKVLoazKEWAArwqBNYI= @@ -197,10 +220,15 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pdfcpu/pdfcpu v0.3.8 h1:wdKii186dzmr/aP/fkJl2s9yT3TZcwc1VqgfabNymGI= +github.com/pdfcpu/pdfcpu v0.3.8/go.mod h1:EfJ1EIo3n5+YlGF53DGe1yF1wQLiqK1eqGDN5LuKALs= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/phpdave11/gofpdi v1.0.7 h1:k2oy4yhkQopCK+qW8KjCla0iU2RpDow+QUDmH9DDt44= github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poundifdef/go-remarkable2pdf v0.2.0 h1:WDRh/ZBkpEOLPLj3lVfoHrQpyVkOQv2A3XOpViRZ0mE= @@ -218,6 +246,7 @@ github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= @@ -282,12 +311,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190823064033-3a9bac650e44/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= +golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -356,6 +389,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -406,8 +440,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -538,12 +572,15 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go index d1fbeb92..31c0199c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -23,6 +23,7 @@ import ( "github.com/ddvk/rmfakecloud/internal/ui" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" ) const ( @@ -116,7 +117,6 @@ func (app *App) Stop() { } } - // NewApp constructs an app func NewApp(cfg *config.Config) App { debugMode := log.GetLevel() >= log.DebugLevel @@ -154,6 +154,7 @@ func NewApp(cfg *config.Config) App { // Register the middleware // router.Use(cors.New(corsConfig)) + router.Use(userAgentLoggerMiddleware()) if debugMode { router.Use(requestLoggerMiddleware()) @@ -180,7 +181,18 @@ func NewApp(cfg *config.Config) App { app.registerRoutes(router) - uiApp := ui.New(cfg, fsStorage, codeConnector, ntfHub, pcStore, fsStorage, fsStorage, roomMgr, app.mqttBroker) + issueDeviceToken := func(uid, deviceID, deviceDesc string) (string, error) { + claims := &DeviceClaims{ + UserID: uid, + DeviceID: deviceID, + DeviceDesc: deviceDesc, + StandardClaims: jwt.StandardClaims{ + Audience: APIUsage, + }, + } + return common.SignClaims(claims, cfg.JWTSecretKey) + } + uiApp := ui.New(cfg, fsStorage, codeConnector, ntfHub, pcStore, fsStorage, fsStorage, issueDeviceToken, roomMgr, app.mqttBroker) uiApp.RegisterRoutes(router) storageapp := fs.NewApp(cfg, fsStorage) diff --git a/internal/app/codeconnector.go b/internal/app/codeconnector.go index 0a782225..693f5550 100644 --- a/internal/app/codeconnector.go +++ b/internal/app/codeconnector.go @@ -10,8 +10,13 @@ import ( log "github.com/sirupsen/logrus" ) +type codeEntry struct { + uid string + expiresAt time.Time +} + type inMemoryCodeConnector struct { - dict map[string]string + dict map[string]codeEntry uids map[string]string lock sync.Mutex codeValidity time.Duration @@ -19,21 +24,21 @@ type inMemoryCodeConnector struct { // CodeConnector matches a code to users type CodeConnector interface { - //NewCode generates one time code for a user + // NewCode generates one time code for a user NewCode(uid string) (code string, err error) - - //ConsumeCode a code and returns the uid if ofound + // ConsumeCode consumes a code and returns the uid if found ConsumeCode(code string) (uid string, err error) + // CodeStatus returns expiration and whether the user's code is still valid + CodeStatus(uid string) (expiresAt time.Time, valid bool) } // NewCodeConnector constructor func NewCodeConnector() CodeConnector { return &inMemoryCodeConnector{ - dict: make(map[string]string), + dict: make(map[string]codeEntry), uids: make(map[string]string), codeValidity: time.Minute * 5, } - } func (conn *inMemoryCodeConnector) NewCode(uid string) (string, error) { @@ -41,11 +46,12 @@ func (conn *inMemoryCodeConnector) NewCode(uid string) (string, error) { if err != nil { return "", err } + expiresAt := time.Now().Add(conn.codeValidity) conn.lock.Lock() - conn.dict[code] = uid if oldcode, ok := conn.uids[uid]; ok { delete(conn.dict, oldcode) } + conn.dict[code] = codeEntry{uid: uid, expiresAt: expiresAt} conn.uids[uid] = code conn.lock.Unlock() go func() { @@ -53,7 +59,6 @@ func (conn *inMemoryCodeConnector) NewCode(uid string) (string, error) { if _, err := conn.ConsumeCode(code); err == nil { log.Infof("removed unused code: %s for uid: %s ", code, uid) } - }() return code, nil } @@ -84,14 +89,29 @@ func newUserCode() (code string, err error) { // return code, nil } -// ConsumeCode return the userId matching the +// ConsumeCode returns the userId matching the code func (conn *inMemoryCodeConnector) ConsumeCode(code string) (string, error) { conn.lock.Lock() defer conn.lock.Unlock() - if uid, ok := conn.dict[code]; ok { + if ent, ok := conn.dict[code]; ok { delete(conn.dict, code) - delete(conn.uids, uid) - return uid, nil + delete(conn.uids, ent.uid) + return ent.uid, nil } return "", errors.New("code not found") } + +// CodeStatus returns the expiration time and whether the user's code is still valid +func (conn *inMemoryCodeConnector) CodeStatus(uid string) (expiresAt time.Time, valid bool) { + conn.lock.Lock() + defer conn.lock.Unlock() + code, ok := conn.uids[uid] + if !ok { + return time.Time{}, false + } + ent, ok := conn.dict[code] + if !ok { + return time.Time{}, false + } + return ent.expiresAt, true +} diff --git a/internal/app/handlers.go b/internal/app/handlers.go index 7404d7b8..8bca8d7c 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -1,6 +1,7 @@ package app import ( + "bufio" "bytes" "crypto/rand" "encoding/base64" @@ -112,6 +113,15 @@ func (app *App) newDevice(c *gin.Context) { return } + if user, err := app.userStorer.GetUser(uid); err == nil && user != nil { + user.UpsertRegisteredDevice(tokenRequest.DeviceID, tokenRequest.DeviceDesc, tokenRequest.DeviceLink) + if err := app.userStorer.UpdateUser(user); err != nil { + log.Warn("could not persist registered device: ", err) + } + } else if err != nil { + log.Warn("could not load user for device registration: ", err) + } + c.String(http.StatusOK, tokenString) } @@ -123,6 +133,14 @@ func (app *App) deleteDevice(c *gin.Context) { return } log.Info("Logging out: ", deviceToken.UserID) + if user, err := app.userStorer.GetUser(deviceToken.UserID); err == nil && user != nil { + user.RemoveRegisteredDevice(deviceToken.DeviceID) + if err := app.userStorer.UpdateUser(user); err != nil { + log.Warn("could not update user after device logout: ", err) + } + } else if err != nil { + log.Warn("could not load user on device logout: ", err) + } c.Status(http.StatusNoContent) } @@ -899,7 +917,13 @@ func (app *App) blobStorageRead(c *gin.Context) { defer reader.Close() common.AddHashHeader(c, hash) - c.DataFromReader(http.StatusOK, size, "application/octet-stream", reader, nil) + br := bufio.NewReader(reader) + ct := "application/octet-stream" + if b, err := br.Peek(5); err == nil && string(b) == "%PDF-" { + ct = "application/pdf" + } + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, size, ct, br, nil) } func (app *App) blobStorageWrite(c *gin.Context) { @@ -1032,6 +1056,17 @@ func (app *App) integrationsUpload(c *gin.Context) { name := common.QueryS("name", c) fileType := common.QueryS("fileType", c) + ro, err := integrations.IsReadOnly(app.userStorer, uid, integrationID) + if err != nil { + log.Error(fmt.Errorf("can't check integration mode, %v", err)) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + if ro { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "integration folder is read-only"}) + return + } + integrationProvider, err := integrations.GetStorageIntegrationProvider(app.userStorer, uid, integrationID) if err != nil { @@ -1072,7 +1107,13 @@ func (app *App) integrationsGetFile(c *gin.Context) { defer reader.Close() - c.DataFromReader(http.StatusOK, size, "application/octet-stream", reader, nil) + br := bufio.NewReader(reader) + ct := "application/octet-stream" + if b, err := br.Peek(5); err == nil && string(b) == "%PDF-" { + ct = "application/pdf" + } + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, size, ct, br, nil) } func (app *App) integrationsList(c *gin.Context) { diff --git a/internal/app/middleware.go b/internal/app/middleware.go index 47daeab0..fb4d1099 100644 --- a/internal/app/middleware.go +++ b/internal/app/middleware.go @@ -15,6 +15,7 @@ import ( const ( authLog = "[auth-middleware]" requestLog = "[requestlogging-middleware]" + userAgentLog = "[user-agent-middleware]" syncDefault = "sync:default" syncNew = "sync:tortoise" syncNewLimited = "sync:fox" // Display cloud limit messages @@ -95,3 +96,14 @@ func requestLoggerMiddleware() gin.HandlerFunc { c.Next() } } + +func userAgentLoggerMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + ua := c.Request.UserAgent() + if ua == "" { + ua = "-" + } + c.Next() + log.Infof("%s %s %s %d ua=%q", userAgentLog, c.Request.Method, c.Request.URL.Path, c.Writer.Status(), ua) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index d4979076..8f9bd8f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/ddvk/rmfakecloud/internal/email" log "github.com/sirupsen/logrus" @@ -81,6 +82,12 @@ const ( envMQTTPort = "MQTT_PORT" envICEServers = "ICE_SERVERS" envHashSchemaVersion = "HASH_SCHEMA_VERSION" + // envRmrlPython if set, path to a Python interpreter with the `rmrl` package installed; used for optional higher-fidelity notebook→PDF export (see rmrl). + envRmrlPython = "RMFAKECLOUD_RMRL_PYTHON" + // envAllowSu enables admin "su" (impersonate another user) in the web UI. Not editable via the web UI. + envAllowSu = "RMFAKECLOUD_ALLOW_SU" + // EnvRMCSrc is also defined in rmdecode; keep the name identical for docs/env help. + EnvRMCSrc = "RMFAKECLOUD_RMC_SRC" ) // Config config @@ -106,6 +113,13 @@ type Config struct { MQTTPort string ICEServers []interface{} HashSchemaVersion string + // RmrlPython optional interpreter (e.g. /usr/bin/python3) to run `python -m rmrl` for PDF export of notebooks when compatible. + RmrlPython string + // AllowSu enables admin impersonation (POST /ui/api/su). Env-only; not configurable from the web UI. + AllowSu bool + // RmcSrc is the rmc package "src" directory for v6 .rm conversion (RMFAKECLOUD_RMC_SRC). + // Editable by admins in the web UI; persisted under DATADIR/server_settings.json. + RmcSrc string } // Verify verify @@ -142,6 +156,18 @@ func (cfg *Config) Verify() { } else { log.Info("No ICE servers configured - screenshare will only work on local networks") } + + if strings.TrimSpace(cfg.RmrlPython) != "" { + log.Infof("rmrl PDF export enabled (%s=%q); install templates under XDG data rmrl/templates if needed", envRmrlPython, cfg.RmrlPython) + } + if cfg.AllowSu { + log.Infof("admin su (impersonation) enabled (%s=true)", envAllowSu) + } else { + log.Infof("admin su (impersonation) disabled (set %s=true to enable)", envAllowSu) + } + if strings.TrimSpace(cfg.RmcSrc) != "" { + log.Infof("rmc source configured (%s=%q)", EnvRMCSrc, cfg.RmcSrc) + } } // FromEnv config from environment values @@ -256,7 +282,6 @@ func FromEnv() *Config { map[string]string{"url": "stun:stun.l.google.com:19302", "username": "", "credential": ""}, } } - iceServers = normalizeICEServers(iceServers) hashSchemaVersion := os.Getenv(envHashSchemaVersion) if hashSchemaVersion == "" { @@ -265,6 +290,8 @@ func FromEnv() *Config { log.Fatalf("%s must be either '3' or '4', got: %s", envHashSchemaVersion, hashSchemaVersion) } + allowSu, _ := strconv.ParseBool(os.Getenv(envAllowSu)) + cfg := Config{ Port: port, StorageURL: uploadURL, @@ -284,78 +311,15 @@ func FromEnv() *Config { MQTTPort: mqttPort, ICEServers: iceServers, HashSchemaVersion: hashSchemaVersion, + RmrlPython: strings.TrimSpace(os.Getenv(envRmrlPython)), + AllowSu: allowSu, + RmcSrc: strings.TrimSpace(os.Getenv(EnvRMCSrc)), } + cfg.LoadServerSettings() + cfg.ApplyRuntimeEnv() return &cfg } -// normalizeICEServers expands "urls" arrays into singular "url" entries; xochitl rejects anything else -func normalizeICEServers(servers []interface{}) []interface{} { - normalized := make([]interface{}, 0, len(servers)) - for _, s := range servers { - m, ok := toStringMap(s) - if !ok { - normalized = append(normalized, s) - continue - } - _, hasURL := m["url"] - _, hasURLs := m["urls"] - urls := collectICEURLs(m) - if len(urls) == 0 { - if !hasURL && !hasURLs { - normalized = append(normalized, s) - } - continue - } - for _, u := range urls { - entry := map[string]interface{}{"url": u} - for k, v := range m { - if k == "url" || k == "urls" { - continue - } - entry[k] = v - } - normalized = append(normalized, entry) - } - } - return normalized -} - -func toStringMap(v interface{}) (map[string]interface{}, bool) { - switch t := v.(type) { - case map[string]interface{}: - return t, true - case map[string]string: - m := make(map[string]interface{}, len(t)) - for k, val := range t { - m[k] = val - } - return m, true - default: - return nil, false - } -} - -func collectICEURLs(m map[string]interface{}) []string { - var urls []string - add := func(v interface{}) { - switch t := v.(type) { - case string: - if t != "" { - urls = append(urls, t) - } - case []interface{}: - for _, item := range t { - if str, ok := item.(string); ok && str != "" { - urls = append(urls, str) - } - } - } - } - add(m["url"]) - add(m["urls"]) - return urls -} - // EnvVars env vars usage func EnvVars() string { return fmt.Sprintf(` @@ -377,6 +341,11 @@ General: %s Send auth cookie only via https %s Trust the proxy for X-Forwarded-For/X-Real-IP (set only if behind a proxy) %s Hash tree schema version: "3" or "4" (default: 3) + %s Enable admin "su" (impersonate another user) in the web UI (default: false). Env-only; not editable from the UI. + %s Path to rmc source "src" dir for v6 .rm→SVG/PDF (also editable by admins in the web UI). + +Optional notebook PDF (rmrl, reMarkable-like rendering): + %s Path to Python 3 with pip package "rmrl" installed. When set, notebook PDF download uses rmrl when possible (v3/v5 .rm), with fallback to the built-in renderer. Install line templates in XDG data dir (e.g. ~/.local/share/rmrl/templates). MQTT (for screenshare): %s MQTT TCP port (default: 8883) @@ -414,6 +383,10 @@ myScript hwr (needs a developer account): envHTTPSCookie, envTrustProxy, envHashSchemaVersion, + envAllowSu, + EnvRMCSrc, + + envRmrlPython, envMQTTPort, envICEServers, diff --git a/internal/integrations/integrations.go b/internal/integrations/integrations.go index fa17c6b2..fa6b73e4 100644 --- a/internal/integrations/integrations.go +++ b/internal/integrations/integrations.go @@ -10,6 +10,7 @@ import ( "time" "github.com/ddvk/rmfakecloud/internal/messages" + "github.com/ddvk/rmfakecloud/internal/model" "github.com/ddvk/rmfakecloud/internal/storage" "github.com/sirupsen/logrus" ) @@ -27,6 +28,13 @@ const ( type IntegrationProvider interface{} +type scopedIntegration struct { + cfg model.IntegrationConfig + shared bool + readOnly bool + ownerUser string +} + // StorageIntegrationProvider abstracts 3rd party integrations type StorageIntegrationProvider interface { IntegrationProvider @@ -50,27 +58,27 @@ type CalendarIntegrationProvider interface { // getIntegrationProvider finds the integration provider for the user func getIntegrationProvider(storer storage.UserStorer, uid, integrationid string) (IntegrationProvider, error) { - usr, err := storer.GetUser(uid) + effective, err := effectiveIntegrations(storer, uid) if err != nil { return nil, err } - for _, intg := range usr.Integrations { - if intg.ID != integrationid { + for _, intg := range effective { + if intg.cfg.ID != integrationid { continue } - switch intg.Provider { + switch intg.cfg.Provider { case WebhookProvider: - return newWebhook(intg), nil + return newWebhook(intg.cfg), nil case DropboxProvider: - return newDropbox(intg), nil + return newDropbox(intg.cfg), nil case FtpProvider: - return newFTP(intg), nil + return newFTP(intg.cfg), nil case LocalfsProvider: - return newLocalFS(intg), nil + return newLocalFS(intg.cfg), nil case WebdavProvider: - return newWebDav(intg), nil + return newWebDav(intg.cfg), nil case IcsProvider: - return newICS(intg), nil + return newICS(intg.cfg), nil } } return nil, fmt.Errorf("integration not found or no implementation %s", integrationid) @@ -162,19 +170,22 @@ func ProviderType(n string) string { // List lists the integrations func List(userstorer storage.UserStorer, uid string) (*messages.IntegrationsResponse, error) { - user, err := userstorer.GetUser(uid) + effective, err := effectiveIntegrations(userstorer, uid) if err != nil { return nil, err } res := &messages.IntegrationsResponse{} - for _, userIntg := range user.Integrations { + for _, entry := range effective { + userIntg := entry.cfg resIntg := messages.Integration{ ID: userIntg.ID, Name: userIntg.Name, Provider: fixProviderName(userIntg.Provider), ProviderType: ProviderType(userIntg.Provider), - UserID: uid, + UserID: entry.ownerUser, + Shared: entry.shared, + ReadOnly: entry.readOnly, } res.Integrations = append(res.Integrations, resIntg) @@ -183,6 +194,67 @@ func List(userstorer storage.UserStorer, uid string) (*messages.IntegrationsResp return res, nil } +func effectiveIntegrations(storer storage.UserStorer, uid string) ([]scopedIntegration, error) { + user, err := storer.GetUser(uid) + if err != nil { + return nil, err + } + if user == nil { + return nil, fmt.Errorf("user not found: %s", uid) + } + out := make([]scopedIntegration, 0, len(user.Integrations)+len(user.SharedIntegrations)) + for _, cfg := range user.Integrations { + cfg.Shared = false + out = append(out, scopedIntegration{ + cfg: cfg, + shared: false, + readOnly: cfg.ReadOnly, + ownerUser: user.ID, + }) + } + users, err := storer.GetUsers() + if err != nil { + return out, nil + } + for _, u := range users { + if u == nil || !u.IsAdmin { + continue + } + for _, cfg := range u.SharedIntegrations { + readOnly := cfg.ReadOnly + if user.IsAdmin && u.ID == user.ID { + readOnly = cfg.ReadOnly + } else { + // Shared integrations are always non-writable for other users. + readOnly = true + } + cfg.Shared = true + cfg.ReadOnly = readOnly + out = append(out, scopedIntegration{ + cfg: cfg, + shared: true, + readOnly: readOnly, + ownerUser: u.ID, + }) + } + } + return out, nil +} + +// IsReadOnly reports whether the integration should be treated as read-only for this user. +func IsReadOnly(storer storage.UserStorer, uid, integrationID string) (bool, error) { + effective, err := effectiveIntegrations(storer, uid) + if err != nil { + return false, err + } + for _, intg := range effective { + if intg.cfg.ID == integrationID { + return intg.readOnly, nil + } + } + return false, fmt.Errorf("integration not found: %s", integrationID) +} + func visitDir(root, currentPath string, depth int, parentFolder *messages.IntegrationFolder, readDir func(string) ([]fs.FileInfo, error)) error { if depth < 1 { diff --git a/internal/messages/messages.go b/internal/messages/messages.go index eb13411f..96e8a023 100644 --- a/internal/messages/messages.go +++ b/internal/messages/messages.go @@ -148,6 +148,7 @@ type DeviceTokenRequest struct { Code string `json:"code"` DeviceDesc string `json:"deviceDesc"` DeviceID string `json:"deviceID"` + DeviceLink string `json:"link,omitempty"` } // SyncCompleted sync ended @@ -209,6 +210,8 @@ type Integration struct { Provider string `json:"provider"` ProviderType string `json:"providerType"` UserID string `json:"userID"` + Shared bool `json:"shared,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` } type IntegrationFile struct { diff --git a/internal/model/user.go b/internal/model/user.go index 62ab58e7..9efa2bed 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "errors" "fmt" + "net/url" "regexp" "strings" "time" @@ -25,6 +26,8 @@ const ( ) var emailWhiteList *regexp.Regexp +var yearRegex *regexp.Regexp +var serialLikeRegex *regexp.Regexp func init() { var err error @@ -32,21 +35,32 @@ func init() { if err != nil { log.Fatal(err) } + yearRegex, err = regexp.Compile(`\b(19|20)\d{2}\b`) + if err != nil { + log.Fatal(err) + } + serialLikeRegex, err = regexp.Compile(`\bRM[0-9A-Z]{3,}\b`) + if err != nil { + log.Fatal(err) + } } // User holds the user profile type User struct { - ID string - Email string - EmailVerified bool - Password string - Name string - Nickname string - GivenName string - FamilyName string - CreatedAt time.Time - UpdatedAt time.Time + ID string + Email string + EmailVerified bool + Password string + PasswordChangedAt time.Time `yaml:"passwordchangedat,omitempty"` + LastLoginAt time.Time `yaml:"lastloginat,omitempty"` + QuotaBytes int64 `yaml:"quotabytes,omitempty"` + Name string + Nickname string + GivenName string + FamilyName string + CreatedAt time.Time + UpdatedAt time.Time // IsAdmin indicates if the user can managed others users in this instance. IsAdmin bool // Sync15 if the user should use this sync type (which uses a lot less bandwidth). @@ -55,6 +69,185 @@ type User struct { AdditionalScopes []string // Integrations stores the list of "Integrations" as shown on the tablet. Integrations []IntegrationConfig + // SharedIntegrations are admin-managed integrations visible to non-admin users. + SharedIntegrations []IntegrationConfig `yaml:"sharedintegrations,omitempty"` + // RegisteredDevices are reMarkable clients that completed pairing (see newDevice). + RegisteredDevices []RegisteredDevice `yaml:"registereddevices,omitempty"` +} + +// RegisteredDevice is a tablet client that obtained a device token. +type RegisteredDevice struct { + DeviceID string `yaml:"deviceid,omitempty"` + DeviceDesc string `yaml:"devicedesc,omitempty"` + DeviceLink string `yaml:"devicelink,omitempty"` + Make string `yaml:"make,omitempty"` + Model string `yaml:"model,omitempty"` + Year string `yaml:"year,omitempty"` + RegisteredAt time.Time `yaml:"registeredat,omitempty"` + LastSeen time.Time `yaml:"lastseen,omitempty"` +} + +// UpsertRegisteredDevice records or updates a paired device for this user. +func (u *User) UpsertRegisteredDevice(deviceID, desc, link string) { + make, model, year := inferDeviceInfo(deviceID, desc, link) + now := time.Now() + for i := range u.RegisteredDevices { + if u.RegisteredDevices[i].DeviceID == deviceID { + u.RegisteredDevices[i].DeviceDesc = desc + if link != "" { + u.RegisteredDevices[i].DeviceLink = link + } + if make != "" { + u.RegisteredDevices[i].Make = make + } + if model != "" { + u.RegisteredDevices[i].Model = model + } + if year != "" { + u.RegisteredDevices[i].Year = year + } + u.RegisteredDevices[i].LastSeen = now + u.UpdatedAt = now + return + } + } + u.RegisteredDevices = append(u.RegisteredDevices, RegisteredDevice{ + DeviceID: deviceID, + DeviceDesc: desc, + DeviceLink: link, + Make: make, + Model: model, + Year: year, + RegisteredAt: now, + LastSeen: now, + }) + u.UpdatedAt = now +} + +func inferDeviceInfo(deviceID, desc, link string) (string, string, string) { + make := "" + model := "" + year := "" + + ls := strings.ToLower(desc + " " + link) + if strings.Contains(ls, "remarkable") || strings.Contains(ls, "re-markable") || strings.Contains(ls, "rm2") || strings.Contains(ls, "rm1") { + make = "reMarkable" + } + if strings.Contains(ls, "paper pro") || strings.Contains(ls, "paperpro") { + model = "Paper Pro" + } else if strings.Contains(ls, "remarkable 2") || strings.Contains(ls, "rm2") { + model = "2" + } else if strings.Contains(ls, "remarkable 1") || strings.Contains(ls, "rm1") { + model = "1" + } + + if u, err := url.Parse(link); err == nil { + q := u.Query() + if v := strings.TrimSpace(q.Get("make")); v != "" { + make = v + } + if v := strings.TrimSpace(q.Get("manufacturer")); v != "" { + make = v + } + if v := strings.TrimSpace(q.Get("brand")); v != "" { + make = v + } + if v := strings.TrimSpace(q.Get("model")); v != "" { + model = v + } + if v := strings.TrimSpace(q.Get("year")); v != "" { + year = v + } + } + for _, cand := range serialCandidates(deviceID, desc, link) { + if mapped, ok := modelFromSerial(cand); ok { + model = mapped + if make == "" { + make = "reMarkable" + } + break + } + } + if year == "" { + if m := yearRegex.FindString(ls); m != "" { + year = m + } + } + return make, model, year +} + +func serialCandidates(deviceID, desc, link string) []string { + out := make([]string, 0, 8) + push := func(s string) { + s = strings.TrimSpace(s) + if s == "" { + return + } + out = append(out, s) + } + push(deviceID) + if u, err := url.Parse(link); err == nil { + q := u.Query() + for _, k := range []string{"serial", "serialNumber", "deviceSerial", "sn"} { + push(q.Get(k)) + } + } + for _, m := range serialLikeRegex.FindAllString(strings.ToUpper(desc+" "+link), -1) { + push(m) + } + return out +} + +func modelFromSerial(serial string) (string, bool) { + s := strings.ToUpper(strings.TrimSpace(serial)) + s = strings.ReplaceAll(s, "-", "") + s = strings.ReplaceAll(s, " ", "") + prefix6 := s + if len(prefix6) > 6 { + prefix6 = prefix6[:6] + } + if len(prefix6) >= 5 { + key := prefix6[:5] + switch key { + case "RM02A": + return "reMarkable Paper Pro", true + case "RM03A": + return "reMarkable Paper Pro Move", true + case "RM110": + return "reMarkable 2", true + case "RM102": + return "reMarkable 1", true + case "RM12A": + return "TBA", true + } + } + return "", false +} + +// GetRegisteredDevice returns a stored device entry if present. +func (u *User) GetRegisteredDevice(deviceID string) (RegisteredDevice, bool) { + for _, d := range u.RegisteredDevices { + if d.DeviceID == deviceID { + return d, true + } + } + return RegisteredDevice{}, false +} + +// RemoveRegisteredDevice drops a device from the registry (e.g. tablet logout). +func (u *User) RemoveRegisteredDevice(deviceID string) { + if deviceID == "" { + return + } + j := 0 + for _, d := range u.RegisteredDevices { + if d.DeviceID != deviceID { + u.RegisteredDevices[j] = d + j++ + } + } + u.RegisteredDevices = u.RegisteredDevices[:j] + u.UpdatedAt = time.Now() } // IntegrationConfig config for various integrations @@ -62,6 +255,10 @@ type IntegrationConfig struct { ID string Provider string Name string + // Shared marks this integration as admin-managed and visible to non-admin users. + Shared bool `yaml:"shared,omitempty" json:"shared,omitempty"` + // ReadOnly marks this integration folder as non-writable. + ReadOnly bool `yaml:"readonly,omitempty" json:"readOnly,omitempty"` // WebDav // FTP Username string `yaml:"username,omitempty"` @@ -134,13 +331,14 @@ func NewUser(userID string, rawPassword string) (*User, error) { sanitizedID := sanitizeEmail(userID) return &User{ - ID: sanitizedID, - Email: sanitizedID, - EmailVerified: true, - Password: password, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Sync15: true, + ID: sanitizedID, + Email: sanitizedID, + EmailVerified: true, + Password: password, + PasswordChangedAt: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Sync15: true, }, nil } @@ -152,6 +350,10 @@ func (u *User) GenID() (err error) { // SetPassword sets the user password (and hashes it) func (u *User) SetPassword(raw string) (err error) { u.Password, err = hashPassword(raw) + if err == nil { + u.PasswordChangedAt = time.Now() + u.UpdatedAt = time.Now() + } return } diff --git a/internal/model/user_device_inference_test.go b/internal/model/user_device_inference_test.go new file mode 100644 index 00000000..abcc9d9d --- /dev/null +++ b/internal/model/user_device_inference_test.go @@ -0,0 +1,25 @@ +package model + +import "testing" + +func TestModelFromSerial(t *testing.T) { + tests := []struct { + serial string + want string + }{ + {"RM02A12345", "reMarkable Paper Pro"}, + {"RM03A-9999", "reMarkable Paper Pro Move"}, + {"RM110ABCDE", "reMarkable 2"}, + {"RM102ABCDE", "reMarkable 1"}, + {"RM12A00001", "TBA"}, + } + for _, tt := range tests { + got, ok := modelFromSerial(tt.serial) + if !ok { + t.Fatalf("expected mapping for %q", tt.serial) + } + if got != tt.want { + t.Fatalf("serial %q => %q, want %q", tt.serial, got, tt.want) + } + } +} diff --git a/internal/model/user_serial_test.go b/internal/model/user_serial_test.go new file mode 100644 index 00000000..d8489586 --- /dev/null +++ b/internal/model/user_serial_test.go @@ -0,0 +1,27 @@ +package model + +import "testing" + +func TestModelFromSerialMappings(t *testing.T) { + tests := []struct { + serial string + model string + ok bool + }{ + {serial: "RM02A123456", model: "reMarkable Paper Pro", ok: true}, + {serial: "RM03A999999", model: "reMarkable Paper Pro Move", ok: true}, + {serial: "RM110ABCDEF", model: "reMarkable 2", ok: true}, + {serial: "RM102000001", model: "reMarkable 1", ok: true}, + {serial: "RM12A111111", model: "TBA", ok: true}, + {serial: "UNKNOWN123", model: "", ok: false}, + } + for _, tt := range tests { + got, ok := modelFromSerial(tt.serial) + if ok != tt.ok { + t.Fatalf("serial %q: expected ok=%v, got %v", tt.serial, tt.ok, ok) + } + if got != tt.model { + t.Fatalf("serial %q: expected model=%q, got %q", tt.serial, tt.model, got) + } + } +} diff --git a/internal/pdfraster/pdfraster.go b/internal/pdfraster/pdfraster.go new file mode 100644 index 00000000..49e1ccd2 --- /dev/null +++ b/internal/pdfraster/pdfraster.go @@ -0,0 +1,162 @@ +package pdfraster + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +const ( + // EnvPDFRenderer selects the PDF→PNG backend. + // Values: "auto" (default), "pdftoppm", "mutool", "unipdf". + // Out-of-process tools mirror xochitl_pdf_renderer (crash-isolated PDF engine). + EnvPDFRenderer = "RMFAKECLOUD_PDF_RENDERER" + + // DevicePortraitWidth is the reMarkable portrait page width used for raster targets. + DevicePortraitWidth = 1404 +) + +// RenderFunc optionally overrides the final unipdf fallback (injected by exporter to +// avoid an import cycle with unidoc). +var UnipdfFallback func(pdfBytes []byte, pageNum int) ([]byte, error) + +// RenderPage rasters one PDF page (1-based) to PNG. +func RenderPage(pdfBytes []byte, pageNum int) ([]byte, error) { + if len(pdfBytes) == 0 { + return nil, fmt.Errorf("empty pdf") + } + if pageNum < 1 { + return nil, fmt.Errorf("page %d out of range", pageNum) + } + + prefer := strings.ToLower(strings.TrimSpace(os.Getenv(EnvPDFRenderer))) + if prefer == "" { + prefer = "auto" + } + + var errs []string + try := func(name string, fn func() ([]byte, error)) ([]byte, error) { + b, err := fn() + if err == nil { + return b, nil + } + errs = append(errs, name+": "+err.Error()) + return nil, err + } + + runAuto := func() ([]byte, error) { + if b, err := try("pdftoppm", func() ([]byte, error) { return withPdftoppm(pdfBytes, pageNum) }); err == nil { + return b, nil + } + if b, err := try("mutool", func() ([]byte, error) { return withMutool(pdfBytes, pageNum) }); err == nil { + return b, nil + } + if UnipdfFallback != nil { + if b, err := try("unipdf", func() ([]byte, error) { return UnipdfFallback(pdfBytes, pageNum) }); err == nil { + return b, nil + } + } + return nil, fmt.Errorf("pdf raster failed: %s", strings.Join(errs, " | ")) + } + + switch prefer { + case "pdftoppm": + if b, err := try("pdftoppm", func() ([]byte, error) { return withPdftoppm(pdfBytes, pageNum) }); err == nil { + return b, nil + } + case "mutool": + if b, err := try("mutool", func() ([]byte, error) { return withMutool(pdfBytes, pageNum) }); err == nil { + return b, nil + } + case "unipdf": + if UnipdfFallback == nil { + return nil, fmt.Errorf("unipdf fallback not registered") + } + return UnipdfFallback(pdfBytes, pageNum) + default: + return runAuto() + } + + // Preferred tool failed — still try the rest. + return runAuto() +} + +func withPdftoppm(pdfBytes []byte, pageNum int) ([]byte, error) { + bin, err := exec.LookPath("pdftoppm") + if err != nil { + return nil, err + } + tmpDir, err := os.MkdirTemp("", "pdftoppm-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + inPath := filepath.Join(tmpDir, "in.pdf") + if err := os.WriteFile(inPath, pdfBytes, 0600); err != nil { + return nil, err + } + outPrefix := filepath.Join(tmpDir, "page") + page := strconv.Itoa(pageNum) + cmd := exec.Command(bin, + "-png", + "-f", page, + "-l", page, + "-singlefile", + "-cropbox", + "-scale-to-x", strconv.Itoa(DevicePortraitWidth), + "-scale-to-y", "-1", + inPath, + outPrefix, + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return nil, fmt.Errorf("%w: %s", err, msg) + } + return nil, err + } + return os.ReadFile(outPrefix + ".png") +} + +func withMutool(pdfBytes []byte, pageNum int) ([]byte, error) { + bin, err := exec.LookPath("mutool") + if err != nil { + return nil, err + } + tmpDir, err := os.MkdirTemp("", "mutool-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + inPath := filepath.Join(tmpDir, "in.pdf") + outPath := filepath.Join(tmpDir, "page.png") + if err := os.WriteFile(inPath, pdfBytes, 0600); err != nil { + return nil, err + } + cmd := exec.Command(bin, "draw", + "-o", outPath, + "-F", "png", + "-w", strconv.Itoa(DevicePortraitWidth), + "-b", "CropBox", + inPath, + strconv.Itoa(pageNum), + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return nil, fmt.Errorf("%w: %s", err, msg) + } + return nil, err + } + return os.ReadFile(outPath) +} diff --git a/internal/pdfraster/pdfraster_test.go b/internal/pdfraster/pdfraster_test.go new file mode 100644 index 00000000..309503d0 --- /dev/null +++ b/internal/pdfraster/pdfraster_test.go @@ -0,0 +1,22 @@ +package pdfraster + +import ( + "os" + "testing" +) + +func TestRenderPagePdftoppm(t *testing.T) { + pdfPath := "../../../test/test.pdf" + b, err := os.ReadFile(pdfPath) + if err != nil { + t.Skip("test.pdf missing:", err) + } + t.Setenv(EnvPDFRenderer, "pdftoppm") + out, err := RenderPage(b, 1) + if err != nil { + t.Fatal(err) + } + if len(out) < 100 || out[0] != 0x89 { + t.Fatalf("expected PNG output, got %d bytes", len(out)) + } +} diff --git a/internal/rmdecode/README.md b/internal/rmdecode/README.md new file mode 100644 index 00000000..003e8639 --- /dev/null +++ b/internal/rmdecode/README.md @@ -0,0 +1,128 @@ +# .rm (reMarkable lines) decoder + +This repo includes tooling to inspect raw page files (`.rm` / “lines” format): + +- **v3 / v5** — decoded in **Go** via [`github.com/juruen/rmapi/encoding/rm`](https://github.com/juruen/rmapi) (`internal/rmdecode`, `cmd/rmdecode`). +- **v6** (tablet OS 3.x) — summaries via **Python + [rmscene](https://github.com/ricklupton/rmscene)** (`scripts/rmdecode_v6_summary.py`). Upstream **rmscene** is vendored as a git submodule at **`third_party/rmscene`** (see below). + +Production-grade v6 tooling in the wider ecosystem is **[rmscene](https://github.com/ricklupton/rmscene) + [rmc](https://github.com/ricklupton/rmc)** (SVG/PDF/Markdown export and other conversions). This repository uses **rmc for v6 conversions** and **Go/legacy tools for v3/v5**. + +## Versions + +| Header prefix | Decoder | +|---------------|---------| +| `version=3` / `version=5` | Go: `DecodeLegacy` → `github.com/juruen/rmapi/encoding/rm` | +| `version=6` (tablet OS 3.x) | Tagged blocks / scene tree via [rmscene](https://github.com/ricklupton/rmscene); conversions use [rmc](https://github.com/ricklupton/rmc) (SVG/PDF/Markdown). | + +## CLI + +From the repo root: + +```bash +# Text dump (v3/v5) or JSON summary (v6; needs rmscene) +go run ./cmd/rmdecode /path/to/page.rm + +# All writings → SVG (v3/v5 native; v6 needs `rmc` on PATH) +go run ./cmd/rmdecode -format svg -o out.svg /path/to/page.rm + +# All writings → PDF +go run ./cmd/rmdecode -format pdf -o out.pdf /path/to/page.rm +# or infer format from extension: +go run ./cmd/rmdecode -o out.svg /path/to/page.rm +``` + +- **v3 / v5**: SVG/PDF are generated in Go (`RenderWritingsSVG`, `RenderWritingsPDF`). +- **v6**: Markdown/SVG/PDF are produced by invoking **`rmc`** (`pipx install rmc` or `pip install rmc`). Text summary remains available via Python + **rmscene** (`scripts/rmdecode_v6_summary.py`). + +### Submodule `third_party/rmscene` + +After cloning this repo, initialize submodules: + +```bash +git submodule update --init --recursive +``` + +Use the vendored source without PyPI (set `PYTHONPATH` to the package root): + +```bash +export PYTHONPATH="$(pwd)/third_party/rmscene/src" +python3 scripts/rmdecode_v6_summary.py /path/to/page.rm +``` + +Alternatively install from PyPI or editable from the submodule (venv recommended): + +```bash +pip install 'rmscene>=0.6' +# or: pip install -e ./third_party/rmscene +``` + +Full SVG/PDF export for v6 (not included in this repo’s CLI): + +```bash +pipx install rmc # or: pip install rmc +rmc -t svg -o out.svg page.rm +``` + +### `.rmdoc` → PNG for every `.rm` page (paging) + +From the repo root, after `git submodule update --init --recursive` (for **v6** via vendored **rmscene**): + +```bash +pip install Pillow # for v6 pages +go run ./cmd/rmdoc2png -o ./out_pngs path/to/document.rmdoc +``` + +- Walks the **zip** and processes every `*.rm` member in archive order. +- Output files: **`001_.png`**, **`002_…`**, … (1-based page index + original stem). +- **v3/v5**: rendered in Go (`RenderWritingsPNG`, [gg](https://github.com/fogleman/gg)). +- **v6**: `scripts/rmscene_v6_to_png.py` + `third_party/rmscene` + **Pillow**. +- Default output directory is `_png` if `-o` is omitted. + +### Web UI notebook page PNGs (`GET /documents/:id/page/:n`) + +For **non-PDF** documents, the server renders each page’s `.rm` with **`EncodeRmPageToPNG`**: + +- **v3** — prefers `lines2png` (from lines-are-beautiful) when installed; falls back to Go (`RenderWritingsPNG`). +- **v5** — pure Go (`RenderWritingsPNG`). +- **v6** — `python3` + `scripts/rmscene_v6_to_png.py` (needs **Pillow** and the **rmscene** path). Set either: + + - **`RMFAKECLOUD_V6_PNG_SCRIPT`** — full path to `rmscene_v6_to_png.py`, or + - **`RMFAKECLOUD_ROOT`** — repo root so the server can find `scripts/rmscene_v6_to_png.py`. + +If v6 tooling is missing or decoding fails, the server **falls back** to the legacy PDF→PNG raster path. + +### Web UI notebook page overlay SVGs (`GET /documents/:id/page/:n/overlay.svg`) + +For page overlays, v3/v5 keep using the in-process Go renderer. +For **v6**, the server now tries **`rmc`** first for better fidelity, then falls back to the legacy stroke renderer. +For **v3**, the server prefers `lines2svg` (from lines-are-beautiful) for stroke rendering when installed, then falls back to an embedded Go renderer. + +Configuration options for v6 overlay rendering: + +- **`RMFAKECLOUD_RMC_BIN`** — full path to an `rmc` executable (or leave unset and use `rmc` from `PATH`). +- **`RMFAKECLOUD_RMC_SRC`** — optional `rmc` source `src` directory for module mode (`python3 -m rmc.cli`), e.g. `/home/aaron/Downloads/rmc-main/src`. +- **`RMFAKECLOUD_RMSCENE_SRC`** — optional `rmscene` source import root; accepts either `.../src` or `.../src/rmscene`. +- **`RMFAKECLOUD_ROOT`** — if set, module mode also adds `/third_party/rmscene/src` to `PYTHONPATH`. + +Configuration options for v3 stroke rendering (lines-are-beautiful): + +- **`RMFAKECLOUD_LINES2SVG_BIN`** — full path to `lines2svg` (optional; otherwise PATH lookup). +- **`RMFAKECLOUD_LINES2PNG_BIN`** — full path to `lines2png` (optional; otherwise PATH lookup). + +### PDF page backgrounds (device-like OOP raster) + +Web UI backgrounds / composites use an out-of-process PDF engine first (same idea as +`xochitl_pdf_renderer` + PDFium on the tablet): + +- **`RMFAKECLOUD_PDF_RENDERER`** — `auto` (default), `pdftoppm`, `mutool`, or `unipdf`. + - `auto`: try `pdftoppm` → `mutool` → unipdf + - Target width: **1404** px (device portrait) + +Server composites (`GET /documents/:id/page/:n`) multiply-blend PDF background × ink PNG. +Device-sized thumbs: `GET /documents/:id/page/:n/thumb` (~384×512). + + +## References + +- [Older binary layout (v3)](https://plasma.ninja/blog/devices/remarkable/binary/format/2017/12/26/reMarkable-lines-file-format.html) +- [Notes on v6 vs v5](https://github.com/chemag/maxio/blob/master/version6.md) diff --git a/internal/rmdecode/encode_page_png.go b/internal/rmdecode/encode_page_png.go new file mode 100644 index 00000000..f314ae51 --- /dev/null +++ b/internal/rmdecode/encode_page_png.go @@ -0,0 +1,87 @@ +package rmdecode + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// EnvV6PNGScript is the full path to scripts/rmscene_v6_to_png.py (optional). +const EnvV6PNGScript = "RMFAKECLOUD_V6_PNG_SCRIPT" + +// EnvRepoRoot is the repository root containing scripts/ (optional; used to find v6 script). +const EnvRepoRoot = "RMFAKECLOUD_ROOT" + +// EncodeRmPageToPNG converts raw .rm page bytes to PNG (1404×1872). +// v3: prefers lines2png when installed (no silent polyline fallback if the tool exists). +// v5: Go RenderWritingsPNG. +// v6: prefers rmc→PDF→OOP raster; falls back to scripts/rmscene_v6_to_png.py. +func EncodeRmPageToPNG(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty .rm data") + } + ver, err := ParseVersion(data) + if err != nil { + return nil, err + } + switch ver { + case 3: + // Prefer lines-are-beautiful for stroke-faithful v3 rendering when installed. + if png, err := RenderV3PNGWithLines2PNG(data); err == nil { + return png, nil + } else if Lines2PNGAvailable() { + // Tool exists but failed — do not silently degrade to black polylines. + return nil, fmt.Errorf("lines2png available but failed: %w", err) + } + page, err := DecodeLegacy(data) + if err != nil { + return nil, err + } + return RenderWritingsPNG(page) + case 5: + page, err := DecodeLegacy(data) + if err != nil { + return nil, err + } + return RenderWritingsPNG(page) + case 6: + return encodeV6RmToPNG(data) + default: + return nil, fmt.Errorf("unsupported .rm version %d", ver) + } +} + +func encodeV6RmToPNG(data []byte) ([]byte, error) { + // Prefer rmc (production-grade strokes) → PDF → OOP raster, like device fidelity. + if png, err := RenderV6PNGWithRMC(data); err == nil { + return png, nil + } + + script := os.Getenv(EnvV6PNGScript) + if script == "" { + if root := os.Getenv(EnvRepoRoot); root != "" { + script = filepath.Join(root, "scripts", "rmscene_v6_to_png.py") + } + } + if script == "" { + return nil, fmt.Errorf("v6 .rm requires rmc (set %s/%s) or %s/%s for scripts/rmscene_v6_to_png.py", EnvRMCBin, EnvRMCSrc, EnvV6PNGScript, EnvRepoRoot) + } + if st, err := os.Stat(script); err != nil || st.IsDir() { + return nil, fmt.Errorf("v6 PNG script %q: %w", script, err) + } + cmd := exec.Command("python3", script, "-") + cmd.Stdin = bytes.NewReader(data) + if py := buildPythonPathForV6(""); py != "" { + cmd.Env = append(os.Environ(), "PYTHONPATH="+py) + } + out, err := cmd.Output() + if err != nil { + if x, ok := err.(*exec.ExitError); ok && len(x.Stderr) > 0 { + return nil, fmt.Errorf("v6 python: %w: %s", err, string(x.Stderr)) + } + return nil, fmt.Errorf("v6 python: %w", err) + } + return out, nil +} diff --git a/internal/rmdecode/lines2tools.go b/internal/rmdecode/lines2tools.go new file mode 100644 index 00000000..cbb4e8eb --- /dev/null +++ b/internal/rmdecode/lines2tools.go @@ -0,0 +1,96 @@ +package rmdecode + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + // EnvLines2SVGBin points to a lines-are-beautiful lines2svg executable. + EnvLines2SVGBin = "RMFAKECLOUD_LINES2SVG_BIN" + // EnvLines2PNGBin points to a lines-are-beautiful lines2png executable. + EnvLines2PNGBin = "RMFAKECLOUD_LINES2PNG_BIN" +) + +func renderV3WithLinesTool(data []byte, envVar, fallbackBin, outName string) ([]byte, error) { + ver, err := ParseVersion(data) + if err != nil { + return nil, err + } + if ver != 3 { + return nil, fmt.Errorf("%s renderer supports v3 only, got v%d", fallbackBin, ver) + } + + bin := strings.TrimSpace(os.Getenv(envVar)) + if bin == "" { + bin, err = exec.LookPath(fallbackBin) + if err != nil { + return nil, fmt.Errorf("%s not found (set %s): %w", fallbackBin, envVar, err) + } + } + + tmpDir, err := os.MkdirTemp("", fallbackBin+"-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + // lines-are-beautiful expects notebook pages as 0.rm, 1.rm... + inPath := filepath.Join(tmpDir, "0.rm") + if err := os.WriteFile(inPath, data, 0600); err != nil { + return nil, err + } + + cmd := exec.Command(bin, tmpDir) + cmd.Dir = tmpDir + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return nil, fmt.Errorf("%s: %w: %s", fallbackBin, err, msg) + } + return nil, fmt.Errorf("%s: %w", fallbackBin, err) + } + + outPath := filepath.Join(tmpDir, outName) + out, err := os.ReadFile(outPath) + if err != nil { + return nil, fmt.Errorf("%s output not found: %w", fallbackBin, err) + } + return out, nil +} + +func RenderV3SVGWithLines2SVG(data []byte) (string, error) { + b, err := renderV3WithLinesTool(data, EnvLines2SVGBin, "lines2svg", "test-0.svg") + if err != nil { + return "", err + } + return string(b), nil +} + +func RenderV3PNGWithLines2PNG(data []byte) ([]byte, error) { + return renderV3WithLinesTool(data, EnvLines2PNGBin, "lines2png", "test.png") +} + +// Lines2SVGAvailable reports whether lines2svg is configured or on PATH. +func Lines2SVGAvailable() bool { + if strings.TrimSpace(os.Getenv(EnvLines2SVGBin)) != "" { + return true + } + _, err := exec.LookPath("lines2svg") + return err == nil +} + +// Lines2PNGAvailable reports whether lines2png is configured or on PATH. +func Lines2PNGAvailable() bool { + if strings.TrimSpace(os.Getenv(EnvLines2PNGBin)) != "" { + return true + } + _, err := exec.LookPath("lines2png") + return err == nil +} diff --git a/internal/rmdecode/overlay_svg.go b/internal/rmdecode/overlay_svg.go new file mode 100644 index 00000000..88176b4a --- /dev/null +++ b/internal/rmdecode/overlay_svg.go @@ -0,0 +1,73 @@ +package rmdecode + +import ( + "fmt" + "regexp" + "strings" +) + +// opaquePageBackground matches full-page white/light rects that some exporters +// embed (breaks transparent overlay on PDF/EPUB backgrounds). +var opaquePageBackground = regexp.MustCompile( + `(?i)]*\b(?:width\s*=\s*["'](?:100%|1404)["'][^>]*height\s*=\s*["'](?:100%|1872)["']|height\s*=\s*["'](?:100%|1872)["'][^>]*width\s*=\s*["'](?:100%|1404)["'])[^>]*\bfill\s*=\s*["'](?:#?fff(?:fff)?|white|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\))["'][^>]*/>`, +) + +// EnsureTransparentOverlaySVG strips opaque full-page backgrounds so the SVG +// can sit on PDF/EPUB (or blank paper) as a transparent ink layer. +func EnsureTransparentOverlaySVG(svg string) string { + s := opaquePageBackground.ReplaceAllString(svg, "") + // Also drop a leading white rect with no size attrs that fills via CSS. + s = regexp.MustCompile(`(?i)]*\bfill\s*=\s*["'](?:#?fff(?:fff)?|white)["'][^>]*\bwidth\s*=\s*["']100%["'][^>]*/>`).ReplaceAllString(s, "") + return strings.TrimSpace(s) +} + +// RenderRmOverlaySVG renders raw .rm bytes to a transparent SVG overlay. +// This is the app’s primary ink path for PDF/EPUB overlays and standalone notebooks. +// +// v6 → rmc (RMFAKECLOUD_RMC_SRC / rmc binary) +// v3 → lines2svg when available, else embedded eraser-aware SVG +// v5 → Go polyline overlay +func RenderRmOverlaySVG(data []byte) (string, error) { + if len(data) == 0 { + return "", fmt.Errorf("empty .rm data") + } + ver, err := ParseVersion(data) + if err != nil { + return "", err + } + switch ver { + case 6: + s, err := RenderV6SVGWithRMC(data) + if err != nil { + return "", err + } + return EnsureTransparentOverlaySVG(s), nil + case 3: + if s, err := RenderV3SVGWithLines2SVG(data); err == nil { + return EnsureTransparentOverlaySVG(s), nil + } else if Lines2SVGAvailable() { + return "", fmt.Errorf("lines2svg available but failed: %w", err) + } + page, err := DecodeLegacy(data) + if err != nil { + return "", err + } + s, err := RenderV3SVGOverlayEmbedded(page) + if err != nil { + return "", err + } + return EnsureTransparentOverlaySVG(s), nil + case 5: + page, err := DecodeLegacy(data) + if err != nil { + return "", err + } + s, err := RenderV3SVGOverlayEmbedded(page) + if err != nil { + return "", err + } + return EnsureTransparentOverlaySVG(s), nil + default: + return "", fmt.Errorf("unsupported .rm version %d", ver) + } +} diff --git a/internal/rmdecode/python_env.go b/internal/rmdecode/python_env.go new file mode 100644 index 00000000..59d1abad --- /dev/null +++ b/internal/rmdecode/python_env.go @@ -0,0 +1,148 @@ +package rmdecode + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +func normalizePythonImportRoot(p string) string { + p = strings.TrimSpace(p) + if p == "" { + return "" + } + // If user passes ".../src/rmscene" or ".../src/rmc", use ".../src" as PYTHONPATH root. + base := filepath.Base(p) + if base == "rmscene" || base == "rmc" { + return filepath.Dir(p) + } + return p +} + +var ( + repoRootOnce sync.Once + repoRootVal string +) + +// FindRepoRoot walks up from cwd (and optionally RMFAKECLOUD_ROOT) looking for go.mod +// plus third_party/rmscene (vendored tooling). +func FindRepoRoot() string { + repoRootOnce.Do(func() { + if e := strings.TrimSpace(os.Getenv(EnvRepoRoot)); e != "" { + if isRepoRoot(e) { + repoRootVal = e + return + } + } + dir, err := os.Getwd() + if err != nil { + return + } + for i := 0; i < 32; i++ { + if isRepoRoot(dir) { + repoRootVal = dir + return + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + }) + return repoRootVal +} + +func isRepoRoot(dir string) bool { + if st, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil || st.IsDir() { + return false + } + // Prefer layout that includes vendored Python tooling. + if st, err := os.Stat(filepath.Join(dir, "third_party", "rmscene", "src")); err == nil && st.IsDir() { + return true + } + if st, err := os.Stat(filepath.Join(dir, "scripts", "rmscene_v6_to_png.py")); err == nil && !st.IsDir() { + return true + } + return false +} + +// VendoredRMCSrc returns third_party/rmc/src when present in the repo. +func VendoredRMCSrc() string { + root := FindRepoRoot() + if root == "" { + return "" + } + p := filepath.Join(root, "third_party", "rmc", "src") + if st, err := os.Stat(filepath.Join(p, "rmc")); err == nil && st.IsDir() { + return p + } + return "" +} + +// VendoredRMSceneSrc returns third_party/rmscene/src when present. +func VendoredRMSceneSrc() string { + root := FindRepoRoot() + if root == "" { + return "" + } + p := filepath.Join(root, "third_party", "rmscene", "src") + if st, err := os.Stat(filepath.Join(p, "rmscene")); err == nil && st.IsDir() { + return p + } + return "" +} + +// EffectiveRMCSrc returns RMFAKECLOUD_RMC_SRC if set, else the vendored third_party/rmc/src. +func EffectiveRMCSrc() string { + if s := strings.TrimSpace(os.Getenv(EnvRMCSrc)); s != "" { + return normalizePythonImportRoot(s) + } + return VendoredRMCSrc() +} + +// buildPythonPathForV6 builds a PYTHONPATH that can import rmc + rmscene from: +// - optional extra path (usually rmc src) +// - RMFAKECLOUD_RMSCENE_SRC / vendored third_party/rmscene/src +// - RMFAKECLOUD_ROOT / discovered repo root +func buildPythonPathForV6(extra string) string { + parts := make([]string, 0, 6) + if e := normalizePythonImportRoot(extra); e != "" { + parts = append(parts, e) + } + if s := EffectiveRMCSrc(); s != "" && s != normalizePythonImportRoot(extra) { + parts = append(parts, s) + } + if s := normalizePythonImportRoot(os.Getenv(EnvRMSSceneSrc)); s != "" { + parts = append(parts, s) + } + if s := VendoredRMSceneSrc(); s != "" { + parts = append(parts, s) + } + if root := FindRepoRoot(); root != "" { + parts = append(parts, filepath.Join(root, "third_party", "rmscene", "src")) + parts = append(parts, filepath.Join(root, "third_party", "rmc", "src")) + } + if existing := strings.TrimSpace(os.Getenv("PYTHONPATH")); existing != "" { + parts = append(parts, existing) + } + // Dedupe while preserving order. + seen := map[string]struct{}{} + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + if len(out) == 0 { + return "" + } + return strings.Join(out, string(os.PathListSeparator)) +} diff --git a/internal/rmdecode/render.go b/internal/rmdecode/render.go new file mode 100644 index 00000000..0c25535a --- /dev/null +++ b/internal/rmdecode/render.go @@ -0,0 +1,105 @@ +package rmdecode + +import ( + "bytes" + "fmt" + + "github.com/jung-kurt/gofpdf" + "github.com/juruen/rmapi/encoding/rm" +) + +// Nominal reMarkable 2 page size in points (matches exporter). +const ( + PageWidthPt = 1404 + PageHeightPt = 1872 +) + +// RenderWritingsSVG returns a standalone SVG document with a white background and all +// non-eraser strokes from a v3/v5 decoded page. +func RenderWritingsSVG(page *rm.Rm) (string, error) { + if page == nil { + return "", fmt.Errorf("page is nil") + } + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf( + ``, + PageWidthPt, PageHeightPt, PageWidthPt, PageHeightPt, + )) + buf.WriteString(fmt.Sprintf(``)) + + for _, layer := range page.Layers { + for _, line := range layer.Lines { + if len(line.Points) < 2 { + continue + } + if line.BrushType == rm.Eraser || line.BrushType == rm.EraseArea { + continue + } + w := float64(line.BrushSize) / 10.0 + if w <= 0 { + w = 1 + } + buf.WriteString(``) + } + } + buf.WriteString(``) + return buf.String(), nil +} + +// RenderWritingsPDF returns a single-page PDF with a white background and all +// non-eraser strokes (v3/v5 only). +func RenderWritingsPDF(page *rm.Rm) ([]byte, error) { + if page == nil { + return nil, fmt.Errorf("page is nil") + } + pdf := gofpdf.NewCustom(&gofpdf.InitType{ + OrientationStr: "P", + UnitStr: "pt", + Size: gofpdf.SizeType{Wd: float64(PageWidthPt), Ht: float64(PageHeightPt)}, + }) + pdf.AddPage() + pdf.SetMargins(0, 0, 0) + pdf.SetAutoPageBreak(false, 0) + // White page + pdf.SetFillColor(255, 255, 255) + pdf.Rect(0, 0, float64(PageWidthPt), float64(PageHeightPt), "F") + + pdf.SetDrawColor(0, 0, 0) + pdf.SetLineCapStyle("round") + pdf.SetLineJoinStyle("round") + + for _, layer := range page.Layers { + for _, line := range layer.Lines { + if len(line.Points) < 2 { + continue + } + if line.BrushType == rm.Eraser || line.BrushType == rm.EraseArea { + continue + } + w := float64(line.BrushSize) / 10.0 + if w <= 0 { + w = 1 + } + pdf.SetLineWidth(w) + for i := 1; i < len(line.Points); i++ { + a, b := line.Points[i-1], line.Points[i] + pdf.Line(float64(a.X), float64(a.Y), float64(b.X), float64(b.Y)) + } + } + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/rmdecode/render_png.go b/internal/rmdecode/render_png.go new file mode 100644 index 00000000..490e8137 --- /dev/null +++ b/internal/rmdecode/render_png.go @@ -0,0 +1,64 @@ +package rmdecode + +import ( + "bytes" + "fmt" + "image/png" + + "github.com/fogleman/gg" + "github.com/juruen/rmapi/encoding/rm" +) + +// RenderWritingsPNG renders all non-eraser strokes to a white RGBA page and encodes PNG. +func RenderWritingsPNG(page *rm.Rm) ([]byte, error) { + if page == nil { + return nil, fmt.Errorf("page is nil") + } + dc := gg.NewContext(PageWidthPt, PageHeightPt) + dc.SetRGB(1, 1, 1) + dc.Clear() + dc.SetRGB(0, 0, 0) + + for _, layer := range page.Layers { + for _, line := range layer.Lines { + if len(line.Points) < 2 { + continue + } + if line.BrushType == rm.Eraser || line.BrushType == rm.EraseArea { + continue + } + w := float64(line.BrushSize) / 10.0 + if w <= 0 { + w = 1 + } + dc.SetLineWidth(w) + dc.SetLineCap(gg.LineCapRound) + dc.SetLineJoin(gg.LineJoinRound) + p0 := line.Points[0] + dc.MoveTo(float64(p0.X), float64(p0.Y)) + for i := 1; i < len(line.Points); i++ { + p := line.Points[i] + dc.LineTo(float64(p.X), float64(p.Y)) + } + dc.Stroke() + } + } + + var buf bytes.Buffer + if err := png.Encode(&buf, dc.Image()); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// RenderBlankNotebookPNG returns a white PNG at the standard notebook size (no strokes). +func RenderBlankNotebookPNG() ([]byte, error) { + dc := gg.NewContext(PageWidthPt, PageHeightPt) + dc.SetRGB(1, 1, 1) + dc.Clear() + var buf bytes.Buffer + if err := png.Encode(&buf, dc.Image()); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/internal/rmdecode/render_png_test.go b/internal/rmdecode/render_png_test.go new file mode 100644 index 00000000..ca8d7654 --- /dev/null +++ b/internal/rmdecode/render_png_test.go @@ -0,0 +1,19 @@ +package rmdecode + +import ( + "bytes" + "testing" + + "github.com/juruen/rmapi/encoding/rm" +) + +func TestRenderWritingsPNG_Empty(t *testing.T) { + page := &rm.Rm{Layers: []rm.Layer{}} + b, err := RenderWritingsPNG(page) + if err != nil { + t.Fatal(err) + } + if len(b) < 100 || !bytes.HasPrefix(b, []byte{0x89, 0x50, 0x4e, 0x47}) { + t.Fatalf("expected PNG signature, got %d bytes", len(b)) + } +} diff --git a/internal/rmdecode/render_test.go b/internal/rmdecode/render_test.go new file mode 100644 index 00000000..d4fe560b --- /dev/null +++ b/internal/rmdecode/render_test.go @@ -0,0 +1,37 @@ +package rmdecode + +import ( + "strings" + "testing" + + "github.com/juruen/rmapi/encoding/rm" +) + +func TestRenderWritingsSVG_Empty(t *testing.T) { + page := &rm.Rm{Layers: []rm.Layer{}} + s, err := RenderWritingsSVG(page) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(s, "`, + PageWidthPt, PageHeightPt, PageWidthPt, PageHeightPt, + )) + maskID := 0 + for layerID, layer := range page.Layers { + maskID = renderV3Layer(layerID, layer, maskID, &out) + } + out.WriteString(``) + return out.String(), nil +} + +func renderV3Layer(layerID int, layer rm.Layer, maskSeed int, out *bytes.Buffer) int { + groups := make([]v3RenderGroup, 0) + cur := v3RenderGroup{} + for _, ln := range layer.Lines { + if isEraserBrush(ln.BrushType) { + if len(cur.strokes) > 0 { + cur.erasures = append(cur.erasures, ln) + } + continue + } + if len(cur.erasures) > 0 { + groups = append(groups, cur) + cur = v3RenderGroup{} + } + cur.strokes = append(cur.strokes, ln) + } + groups = append(groups, cur) + + layerLabel := fmt.Sprintf("layer-%d", layerID) + for i := len(groups) - 1; i >= 0; i-- { + g := groups[i] + if len(g.erasures) > 0 { + maskLabel := fmt.Sprintf("%s-mask-%d", layerLabel, maskSeed) + maskSeed++ + out.WriteString(``) + out.WriteString(``) + for _, e := range g.erasures { + renderV3ErasePath(e, out) + } + out.WriteString(``) + out.WriteString(``) + } + for _, g := range groups { + for _, s := range g.strokes { + renderV3StrokePath(s, out) + } + out.WriteString(``) + } + return maskSeed +} + +func renderV3ErasePath(line rm.Line, out *bytes.Buffer) { + if len(line.Points) < 2 { + return + } + out.WriteString(``) +} + +func renderV3StrokePath(line rm.Line, out *bytes.Buffer) { + if len(line.Points) < 2 { + return + } + out.WriteString(``) +} + +func renderPathData(line rm.Line, out *bytes.Buffer) { + for i, p := range line.Points { + if i == 0 { + out.WriteString(fmt.Sprintf("M%g,%g", p.X, p.Y)) + } else { + out.WriteString(fmt.Sprintf("L%g,%g", p.X, p.Y)) + } + } +} + +func legacyStrokeWidth(line rm.Line) float64 { + base := float64(line.BrushSize) + if base > 10 { + base = base / 10 + } + if base <= 0 { + base = 1 + } + if isHighlighterBrush(line.BrushType) || isEraserBrush(line.BrushType) { + return 20*base + 0.01 + } + w := 18*base - 32 + if w <= 0 { + return base + } + return w +} + +func isEraserBrush(bt rm.BrushType) bool { + return bt == rm.Eraser || bt == rm.EraseArea +} + +func isHighlighterBrush(bt rm.BrushType) bool { + return bt == rm.Highlighter || bt == rm.HighlighterV5 || int(bt) == 5 || int(bt) == 18 +} diff --git a/internal/rmdecode/render_v3_embedded_test.go b/internal/rmdecode/render_v3_embedded_test.go new file mode 100644 index 00000000..440af90a --- /dev/null +++ b/internal/rmdecode/render_v3_embedded_test.go @@ -0,0 +1,62 @@ +package rmdecode + +import ( + "strings" + "testing" + + "github.com/juruen/rmapi/encoding/rm" +) + +func TestRenderV3SVGOverlayEmbedded_Nil(t *testing.T) { + _, err := RenderV3SVGOverlayEmbedded(nil) + if err == nil { + t.Fatal("expected error for nil page") + } +} + +func TestRenderV3SVGOverlayEmbedded_EraserMask(t *testing.T) { + page := &rm.Rm{ + Layers: []rm.Layer{ + { + Lines: []rm.Line{ + { + BrushType: rm.TiltPencil, + BrushSize: 20, + BrushColor: rm.Black, + Points: []rm.Point{ + {X: 10, Y: 10}, + {X: 20, Y: 20}, + }, + }, + { + BrushType: rm.EraseArea, + BrushSize: 20, + Points: []rm.Point{ + {X: 11, Y: 11}, + {X: 12, Y: 12}, + }, + }, + { + BrushType: rm.TiltPencil, + BrushSize: 20, + BrushColor: rm.Black, + Points: []rm.Point{ + {X: 30, Y: 30}, + {X: 40, Y: 40}, + }, + }, + }, + }, + }, + } + s, err := RenderV3SVGOverlayEmbedded(page) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(s, "= '0' && rest[i] <= '9' { + i++ + } + if i == 0 { + return 0, fmt.Errorf("no version number in header") + } + return strconv.Atoi(rest[:i]) +} + +// DecodeLegacy parses v3 or v5 .rm bytes into rmapi's Rm struct. +func DecodeLegacy(data []byte) (*rm.Rm, error) { + v, err := ParseVersion(data) + if err != nil { + return nil, err + } + if v != 3 && v != 5 { + return nil, fmt.Errorf("DecodeLegacy: expected version 3 or 5, got %d (use rmscene for v6)", v) + } + var page rm.Rm + if err := page.UnmarshalBinary(data); err != nil { + return nil, err + } + return &page, nil +} diff --git a/internal/rmdecode/rmdecode_test.go b/internal/rmdecode/rmdecode_test.go new file mode 100644 index 00000000..ebcbd865 --- /dev/null +++ b/internal/rmdecode/rmdecode_test.go @@ -0,0 +1,35 @@ +package rmdecode + +import ( + "strings" + "testing" +) + +func TestParseVersion_V6SampleHeader(t *testing.T) { + // v6 header is exactly 43 bytes: prefix + "6" + spaces + hdr := []byte("reMarkable .lines file, version=6" + strings.Repeat(" ", 10)) + if len(hdr) != HeaderLen { + t.Fatalf("test header length %d, want %d", len(hdr), HeaderLen) + } + v, err := ParseVersion(hdr) + if err != nil { + t.Fatal(err) + } + if v != 6 { + t.Fatalf("version %d, want 6", v) + } +} + +func TestParseVersion_V5(t *testing.T) { + hdr := []byte("reMarkable .lines file, version=5 ") + if len(hdr) != HeaderLen { + t.Fatal("bad test header length") + } + v, err := ParseVersion(hdr) + if err != nil { + t.Fatal(err) + } + if v != 5 { + t.Fatalf("version %d, want 5", v) + } +} diff --git a/internal/storage/epub/cover.go b/internal/storage/epub/cover.go new file mode 100644 index 00000000..916a6498 --- /dev/null +++ b/internal/storage/epub/cover.go @@ -0,0 +1,118 @@ +package epub + +import ( + "archive/zip" + "errors" + "io" + "path" + "regexp" + "sort" + "strings" +) + +var ( + imgSrcRE = regexp.MustCompile(`(?i)]+src\s*=\s*["']([^"']+)["']`) + // SVG (some EPUBs) + imageHrefRE = regexp.MustCompile(`(?i)]+href\s*=\s*["']([^"']+)["']`) +) + +// FindCoverImagePath returns a zip-relative path to an image file suitable for a thumbnail. +// It scans for these XHTML/HTML files (case-insensitive basename, any directory): +// cover.xhtml, cover.html, cover.htm, then any *0000.xhtml (e.g. part0000.xhtml) — in that priority order. +// The first (or ) pointing to a raster or SVG inside the zip wins. +func FindCoverImagePath(zr *zip.Reader) (string, error) { + type cand struct { + path string + pri int + } + var cands []cand + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + base := strings.ToLower(path.Base(f.Name)) + var pri int + switch base { + case "cover.xhtml": + pri = 1 + case "cover.html": + pri = 2 + case "cover.htm": + pri = 3 + default: + if strings.HasSuffix(base, "0000.xhtml") { + pri = 4 + } else { + continue + } + } + cands = append(cands, cand{f.Name, pri}) + } + sort.Slice(cands, func(i, j int) bool { + if cands[i].pri != cands[j].pri { + return cands[i].pri < cands[j].pri + } + return cands[i].path < cands[j].path + }) + + for _, c := range cands { + rc, err := OpenZipFile(zr, c.path) + if err != nil { + continue + } + b, err := io.ReadAll(rc) + _ = rc.Close() + if err != nil { + continue + } + var src string + if m := imgSrcRE.FindSubmatch(b); len(m) >= 2 { + src = strings.TrimSpace(string(m[1])) + } else if m := imageHrefRE.FindSubmatch(b); len(m) >= 2 { + src = strings.TrimSpace(string(m[1])) + } + if src == "" { + continue + } + imgPath := resolveImgHref(c.path, src) + if imgPath == "" { + continue + } + if _, err := OpenZipFile(zr, imgPath); err != nil { + continue + } + ext := strings.ToLower(path.Ext(imgPath)) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg": + return imgPath, nil + } + } + return "", errors.New("no cover image found") +} + +func resolveImgHref(htmlPath, src string) string { + src = strings.TrimSpace(src) + if i := strings.IndexByte(src, '?'); i >= 0 { + src = src[:i] + } + if src == "" { + return "" + } + if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") { + return "" + } + if strings.HasPrefix(src, "mailto:") { + return "" + } + htmlDir := path.Dir(htmlPath) + if strings.HasPrefix(src, "/") { + src = strings.TrimPrefix(path.Clean(src), "/") + return src + } + out := path.Join(htmlDir, src) + out = path.Clean(out) + if strings.HasPrefix(out, "..") { + return "" + } + return out +} diff --git a/internal/storage/epub/cover_test.go b/internal/storage/epub/cover_test.go new file mode 100644 index 00000000..e6be4300 --- /dev/null +++ b/internal/storage/epub/cover_test.go @@ -0,0 +1,35 @@ +package epub + +import ( + "archive/zip" + "bytes" + "testing" +) + +func TestFindCoverImagePath(t *testing.T) { + buf := new(bytes.Buffer) + zw := zip.NewWriter(buf) + must := func(err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + } + w, err := zw.Create("OEBPS/cover.xhtml") + must(err) + _, err = w.Write([]byte(``)) + must(err) + w, err = zw.Create("OEBPS/images/c.jpg") + must(err) + _, err = w.Write([]byte{0xff, 0xd8, 0xff, 0xe0}) // fake JPEG header + must(err) + must(zw.Close()) + + zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + must(err) + p, err := FindCoverImagePath(zr) + must(err) + if p != "OEBPS/images/c.jpg" { + t.Fatalf("got %q", p) + } +} diff --git a/internal/storage/epub/manifest.go b/internal/storage/epub/manifest.go new file mode 100644 index 00000000..1f766df9 --- /dev/null +++ b/internal/storage/epub/manifest.go @@ -0,0 +1,171 @@ +package epub + +import ( + "archive/zip" + "encoding/xml" + "errors" + "io" + "path" + "strings" +) + +// ErrNotEpub is returned when the archive does not appear to be a valid EPUB. +var ErrNotEpub = errors.New("not a valid EPUB") + +// Manifest holds the reading order (spine) and base path for resolving relative URLs. +type Manifest struct { + Spine []string `json:"spine"` // paths relative to zip root (e.g. OEBPS/ch1.xhtml) + BasePath string `json:"basePath"` // directory of the OPF, for relative resolution +} + +type containerRoot struct { + XMLName xml.Name `xml:"container"` + RootFiles struct { + Rootfile []struct { + FullPath string `xml:"full-path,attr"` + MediaType string `xml:"media-type,attr"` + } `xml:"rootfile"` + } `xml:"rootfiles"` +} + +type opfPackage struct { + XMLName xml.Name `xml:"http://www.idpf.org/2007/opf package"` + Manifest struct { + Items []struct { + ID string `xml:"id,attr"` + Href string `xml:"href,attr"` + MediaType string `xml:"media-type,attr"` + } `xml:"item"` + } `xml:"manifest"` + Spine struct { + Itemrefs []struct { + IDRef string `xml:"idref,attr"` + } `xml:"itemref"` + } `xml:"spine"` +} + +// ReadManifest parses the EPUB zip and returns the spine (ordered content paths) and OPF base path. +func ReadManifest(zr *zip.Reader) (*Manifest, error) { + // 1. Find and parse META-INF/container.xml + containerFile, err := openZipFile(zr, "META-INF/container.xml") + if err != nil { + return nil, err + } + defer containerFile.Close() + containerBytes, err := io.ReadAll(containerFile) + if err != nil { + return nil, err + } + var c containerRoot + if err := xml.Unmarshal(containerBytes, &c); err != nil { + return nil, err + } + if len(c.RootFiles.Rootfile) == 0 { + return nil, ErrNotEpub + } + rootPath := c.RootFiles.Rootfile[0].FullPath + rootPath = strings.TrimPrefix(path.Clean("/"+rootPath), "/") + + // 2. Parse OPF + opfFile, err := openZipFile(zr, rootPath) + if err != nil { + return nil, err + } + defer opfFile.Close() + opfBytes, err := io.ReadAll(opfFile) + if err != nil { + return nil, err + } + var pkg opfPackage + if err := xml.Unmarshal(opfBytes, &pkg); err != nil { + return nil, err + } + opfDir := path.Dir(rootPath) + if opfDir == "." { + opfDir = "" + } + idToHref := make(map[string]string) + for _, it := range pkg.Manifest.Items { + idToHref[it.ID] = it.Href + } + var spine []string + for _, ref := range pkg.Spine.Itemrefs { + href, ok := idToHref[ref.IDRef] + if !ok { + continue + } + // Resolve href relative to OPF directory + fullPath := path.Join(opfDir, href) + fullPath = path.Clean(fullPath) + if strings.HasPrefix(fullPath, "..") { + continue + } + spine = append(spine, fullPath) + } + if len(spine) == 0 { + return nil, ErrNotEpub + } + return &Manifest{ + Spine: spine, + BasePath: opfDir, + }, nil +} + +func openZipFile(zr *zip.Reader, name string) (io.ReadCloser, error) { + name = path.Clean(name) + if strings.HasPrefix(name, "..") { + return nil, errors.New("invalid path") + } + for _, f := range zr.File { + clean := path.Clean(f.Name) + if clean == name || f.Name == name { + return f.Open() + } + } + return nil, errors.New("file not found") +} + +// OpenZipFile returns a reader for a file inside the zip by path (relative to zip root). +// Path must not contain "..". +func OpenZipFile(zr *zip.Reader, filePath string) (io.ReadCloser, error) { + filePath = path.Clean(filePath) + if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "..") { + return nil, errors.New("invalid path") + } + for _, f := range zr.File { + clean := path.Clean(f.Name) + if clean == filePath { + return f.Open() + } + } + return nil, errors.New("file not found") +} + +// ContentType returns a suitable Content-Type for an EPUB resource by extension. +func ContentType(filePath string) string { + ext := strings.ToLower(path.Ext(filePath)) + switch ext { + case ".xhtml", ".html", ".htm": + return "application/xhtml+xml" + case ".css": + return "text/css" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".gif": + return "image/gif" + case ".svg": + return "image/svg+xml" + case ".woff": + return "font/woff" + case ".woff2": + return "font/woff2" + case ".ttf": + return "font/ttf" + case ".otf": + return "font/otf" + default: + return "application/octet-stream" + } +} diff --git a/internal/storage/exporter/annotations_svg.go b/internal/storage/exporter/annotations_svg.go new file mode 100644 index 00000000..6e801336 --- /dev/null +++ b/internal/storage/exporter/annotations_svg.go @@ -0,0 +1,68 @@ +package exporter + +import ( + "bytes" + "fmt" + + "github.com/juruen/rmapi/encoding/rm" +) + +const ( + rmWidth = 1404 + rmHeight = 1872 +) + +func svgHeader() string { + return fmt.Sprintf(``, rmWidth, rmHeight, rmWidth, rmHeight) +} + +// RenderPageAnnotationsSVG renders the handwriting (.rm) content for a page as an SVG overlay. +// The output is intended to be layered on top of a background PNG of the original PDF page. +func RenderPageAnnotationsSVG(a *MyArchive, pageNum int) (string, error) { + if a == nil { + return "", fmt.Errorf("archive is nil") + } + if pageNum < 1 || pageNum > len(a.Pages) { + return "", fmt.Errorf("page %d out of range (1-%d)", pageNum, len(a.Pages)) + } + p := a.Pages[pageNum-1] + if p.Data == nil { + return svgHeader() + ``, nil + } + + var buf bytes.Buffer + buf.WriteString(svgHeader()) + // no background rect: we want a transparent overlay + + for _, layer := range p.Data.Layers { + for _, line := range layer.Lines { + if len(line.Points) < 2 { + continue + } + if line.BrushType == rm.Eraser || line.BrushType == rm.EraseArea { + continue + } + // Force black-only: stroke always black, no fill. + w := float64(line.BrushSize) / 10.0 + if w <= 0 { + w = 1 + } + + // Build a simple polyline path. + buf.WriteString(``) + } + } + + buf.WriteString(``) + return buf.String(), nil +} + diff --git a/internal/storage/exporter/background_png.go b/internal/storage/exporter/background_png.go new file mode 100644 index 00000000..adc8a291 --- /dev/null +++ b/internal/storage/exporter/background_png.go @@ -0,0 +1,79 @@ +package exporter + +import ( + "bytes" + "fmt" + "image/png" + "io" + + "github.com/ddvk/rmfakecloud/internal/pdfraster" + pdf "github.com/unidoc/unipdf/v3/model" + "github.com/unidoc/unipdf/v3/render" +) + +func init() { + pdfraster.UnipdfFallback = renderPayloadPDFBytesUnipdf +} + +// RenderPDFBytesToPNG rasters one PDF page (1-based) to PNG via pdfraster. +func RenderPDFBytesToPNG(pdfBytes []byte, pageNum int) ([]byte, error) { + return pdfraster.RenderPage(pdfBytes, pageNum) +} + +// RenderPayloadPagePNG renders a single page of the *payload* PDF to PNG. +// This is intended for PDF documents where we want the background without handwriting. +// pageNum is 1-based. Returns PNG bytes. +// +// Prefer out-of-process PDF engines (pdftoppm / mutool), matching the device’s +// xochitl_pdf_renderer architecture; fall back to unipdf when those are missing. +func RenderPayloadPagePNG(a *MyArchive, pageNum int) ([]byte, error) { + if a == nil || a.PayloadReader == nil { + return nil, fmt.Errorf("no payload reader") + } + if _, err := a.PayloadReader.Seek(0, io.SeekStart); err != nil { + return nil, err + } + b, err := io.ReadAll(a.PayloadReader) + if err != nil { + return nil, err + } + return pdfraster.RenderPage(b, pageNum) +} + +// RenderPayloadPagePNGReader is like RenderPayloadPagePNG but returns a ReadCloser. +func RenderPayloadPagePNGReader(a *MyArchive, pageNum int) (io.ReadCloser, error) { + b, err := RenderPayloadPagePNG(a, pageNum) + if err != nil { + return nil, err + } + return NewSeekCloser(b), nil +} + +func renderPayloadPDFBytesUnipdf(pdfBytes []byte, pageNum int) ([]byte, error) { + pdfReader, err := pdf.NewPdfReader(bytes.NewReader(pdfBytes)) + if err != nil { + return nil, fmt.Errorf("open pdf: %w", err) + } + numPages, err := pdfReader.GetNumPages() + if err != nil { + return nil, err + } + if pageNum < 1 || pageNum > numPages { + return nil, fmt.Errorf("page %d out of range (1-%d)", pageNum, numPages) + } + page, err := pdfReader.GetPage(pageNum) + if err != nil { + return nil, err + } + device := render.NewImageDevice() + device.OutputWidth = pdfraster.DevicePortraitWidth + img, err := device.Render(page) + if err != nil { + return nil, fmt.Errorf("render page: %w", err) + } + var out bytes.Buffer + if err := png.Encode(&out, img); err != nil { + return nil, err + } + return out.Bytes(), nil +} diff --git a/internal/storage/exporter/composite_png.go b/internal/storage/exporter/composite_png.go new file mode 100644 index 00000000..f229f671 --- /dev/null +++ b/internal/storage/exporter/composite_png.go @@ -0,0 +1,125 @@ +package exporter + +import ( + "bytes" + "fmt" + "image" + "image/draw" + "image/png" + "math" +) + +// DeviceThumbSize matches on-device thumbnail storage (~384×512 portrait). +var ( + DeviceThumbPortrait = image.Pt(384, 512) + DeviceThumbLandscape = image.Pt(512, 384) +) + +// MultiplyBlendPNGs composites a white-backed ink PNG over a background PNG using +// multiply blending (same idea as the web DocumentPageThumb and the device’s +// highlighter darken / ink-over-paper composite). +// +// Both inputs are PNG bytes. The result is scaled to fit target (nil = ink size). +func MultiplyBlendPNGs(bgPNG, inkPNG []byte, target *image.Point) ([]byte, error) { + bgImg, err := png.Decode(bytes.NewReader(bgPNG)) + if err != nil { + return nil, fmt.Errorf("decode background: %w", err) + } + inkImg, err := png.Decode(bytes.NewReader(inkPNG)) + if err != nil { + return nil, fmt.Errorf("decode ink: %w", err) + } + + bg := toNRGBA(bgImg) + ink := toNRGBA(inkImg) + + // Scale ink to background bounds (PDF page aspect vs notebook 1404×1872). + if ink.Bounds().Dx() != bg.Bounds().Dx() || ink.Bounds().Dy() != bg.Bounds().Dy() { + ink = scaleNRGBA(ink, bg.Bounds().Dx(), bg.Bounds().Dy()) + } + + out := image.NewNRGBA(bg.Bounds()) + w, h := bg.Bounds().Dx(), bg.Bounds().Dy() + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + bi := bg.PixOffset(x, y) + ii := ink.PixOffset(x, y) + out.Pix[bi+0] = mul8(bg.Pix[bi+0], ink.Pix[ii+0]) + out.Pix[bi+1] = mul8(bg.Pix[bi+1], ink.Pix[ii+1]) + out.Pix[bi+2] = mul8(bg.Pix[bi+2], ink.Pix[ii+2]) + out.Pix[bi+3] = 255 + } + } + + if target != nil && target.X > 0 && target.Y > 0 { + out = scaleNRGBA(out, target.X, target.Y) + } + + var buf bytes.Buffer + if err := png.Encode(&buf, out); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// ScalePNGToThumb scales a PNG to device thumbnail size, preserving orientation. +func ScalePNGToThumb(pngBytes []byte) ([]byte, error) { + img, err := png.Decode(bytes.NewReader(pngBytes)) + if err != nil { + return nil, err + } + b := img.Bounds() + var target image.Point + if b.Dx() >= b.Dy() { + target = DeviceThumbLandscape + } else { + target = DeviceThumbPortrait + } + scaled := scaleNRGBA(toNRGBA(img), target.X, target.Y) + var buf bytes.Buffer + if err := png.Encode(&buf, scaled); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func mul8(a, b uint8) uint8 { + return uint8((uint16(a) * uint16(b)) / 255) +} + +func toNRGBA(src image.Image) *image.NRGBA { + if n, ok := src.(*image.NRGBA); ok { + return n + } + b := src.Bounds() + dst := image.NewNRGBA(b) + draw.Draw(dst, b, src, b.Min, draw.Src) + return dst +} + +func scaleNRGBA(src *image.NRGBA, tw, th int) *image.NRGBA { + if tw < 1 { + tw = 1 + } + if th < 1 { + th = 1 + } + dst := image.NewNRGBA(image.Rect(0, 0, tw, th)) + sw, sh := src.Bounds().Dx(), src.Bounds().Dy() + for y := 0; y < th; y++ { + sy := int(math.Floor(float64(y) * float64(sh) / float64(th))) + if sy >= sh { + sy = sh - 1 + } + for x := 0; x < tw; x++ { + sx := int(math.Floor(float64(x) * float64(sw) / float64(tw))) + if sx >= sw { + sx = sw - 1 + } + si := src.PixOffset(sx, sy) + di := dst.PixOffset(x, y) + copy(dst.Pix[di:di+4], src.Pix[si:si+4]) + } + } + return dst +} diff --git a/internal/storage/exporter/composite_png_test.go b/internal/storage/exporter/composite_png_test.go new file mode 100644 index 00000000..a4501554 --- /dev/null +++ b/internal/storage/exporter/composite_png_test.go @@ -0,0 +1,70 @@ +package exporter + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" +) + +func TestMultiplyBlendPNGs(t *testing.T) { + bg := image.NewNRGBA(image.Rect(0, 0, 2, 2)) + ink := image.NewNRGBA(image.Rect(0, 0, 2, 2)) + for y := 0; y < 2; y++ { + for x := 0; x < 2; x++ { + bg.SetNRGBA(x, y, color.NRGBA{R: 200, G: 200, B: 200, A: 255}) + ink.SetNRGBA(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + } + } + ink.SetNRGBA(0, 0, color.NRGBA{R: 0, G: 0, B: 0, A: 255}) + + var bgBuf, inkBuf bytes.Buffer + if err := png.Encode(&bgBuf, bg); err != nil { + t.Fatal(err) + } + if err := png.Encode(&inkBuf, ink); err != nil { + t.Fatal(err) + } + + out, err := MultiplyBlendPNGs(bgBuf.Bytes(), inkBuf.Bytes(), nil) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatal(err) + } + n := toNRGBA(img) + // Black ink × gray bg → black + r, g, b, _ := n.At(0, 0).RGBA() + if r>>8 != 0 || g>>8 != 0 || b>>8 != 0 { + t.Fatalf("expected black at ink pixel, got %d,%d,%d", r>>8, g>>8, b>>8) + } + // White ink × gray bg → gray unchanged + r, g, b, _ = n.At(1, 1).RGBA() + if r>>8 != 200 || g>>8 != 200 || b>>8 != 200 { + t.Fatalf("expected gray where ink white, got %d,%d,%d", r>>8, g>>8, b>>8) + } +} + +func TestScalePNGToThumb(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 1404, 1872)) + src.SetNRGBA(0, 0, color.NRGBA{R: 10, G: 20, B: 30, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, src); err != nil { + t.Fatal(err) + } + out, err := ScalePNGToThumb(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatal(err) + } + b := img.Bounds() + if b.Dx() != DeviceThumbPortrait.X || b.Dy() != DeviceThumbPortrait.Y { + t.Fatalf("thumb size %dx%d, want %dx%d", b.Dx(), b.Dy(), DeviceThumbPortrait.X, DeviceThumbPortrait.Y) + } +} diff --git a/internal/storage/exporter/png.go b/internal/storage/exporter/png.go new file mode 100644 index 00000000..19c5480b --- /dev/null +++ b/internal/storage/exporter/png.go @@ -0,0 +1,55 @@ +package exporter + +import ( + "bytes" + "fmt" + "image/png" + "io" + + pdf "github.com/unidoc/unipdf/v3/model" + "github.com/unidoc/unipdf/v3/render" +) + +// RenderPagePNG renders a single page of the archive (as exported to PDF) to PNG. +// pageNum is 1-based. Returns PNG bytes. +func RenderPagePNG(a *MyArchive, pageNum int) ([]byte, error) { + var pdfBuf bytes.Buffer + if err := RenderRmapi(a, &pdfBuf); err != nil { + return nil, err + } + pdfReader, err := pdf.NewPdfReader(bytes.NewReader(pdfBuf.Bytes())) + if err != nil { + return nil, fmt.Errorf("open pdf: %w", err) + } + numPages, err := pdfReader.GetNumPages() + if err != nil { + return nil, err + } + if pageNum < 1 || pageNum > numPages { + return nil, fmt.Errorf("page %d out of range (1-%d)", pageNum, numPages) + } + page, err := pdfReader.GetPage(pageNum) + if err != nil { + return nil, err + } + device := render.NewImageDevice() + device.OutputWidth = 1404 + img, err := device.Render(page) + if err != nil { + return nil, fmt.Errorf("render page: %w", err) + } + var out bytes.Buffer + if err := png.Encode(&out, img); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +// RenderPagePNGReader is like RenderPagePNG but returns a ReadCloser. +func RenderPagePNGReader(a *MyArchive, pageNum int) (io.ReadCloser, error) { + b, err := RenderPagePNG(a, pageNum) + if err != nil { + return nil, err + } + return NewSeekCloser(b), nil +} diff --git a/internal/storage/filetypes.go b/internal/storage/filetypes.go index e1bf49e7..62716f66 100644 --- a/internal/storage/filetypes.go +++ b/internal/storage/filetypes.go @@ -5,6 +5,7 @@ const ( PageFileExt = ".pagedata" ContentFileExt = ".content" RmFileExt = ".rm" + TemplateFileExt = ".template" //ZipFileExt zip file extension ZipFileExt = ".zip" diff --git a/internal/storage/fs/blobstore.go b/internal/storage/fs/blobstore.go index cffa85f0..9b978da5 100644 --- a/internal/storage/fs/blobstore.go +++ b/internal/storage/fs/blobstore.go @@ -5,10 +5,12 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "net/url" "os" "path" + "path/filepath" "strconv" "strings" "time" @@ -16,7 +18,9 @@ import ( "github.com/danjacques/gofslock/fslock" "github.com/ddvk/rmfakecloud/internal/common" "github.com/ddvk/rmfakecloud/internal/config" + "github.com/ddvk/rmfakecloud/internal/rmdecode" "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/epub" "github.com/ddvk/rmfakecloud/internal/storage/exporter" "github.com/ddvk/rmfakecloud/internal/storage/models" "github.com/google/uuid" @@ -69,8 +73,10 @@ func (fs *FileSystemStorage) BlobStorage(uid string) *LocalBlobStorage { } } -// ExportRmDoc exports a document as a zip of all blobs -func (fs *FileSystemStorage) ExportRmDoc(uid, docid string) (io.ReadCloser, error) { +// Export exports a document. +// For PDF-type documents, the original payload bytes are streamed unchanged (no re-render). +// For notebooks and other types, the PDF is produced via rmtool render. +func (fs *FileSystemStorage) Export(uid, docid string) (r io.ReadCloser, err error) { tree, err := fs.GetCachedTree(uid) if err != nil { return nil, err @@ -81,43 +87,251 @@ func (fs *FileSystemStorage) ExportRmDoc(uid, docid string) (io.ReadCloser, erro } ls := fs.BlobStorage(uid) + // PDF: send stored blob as-is (binary), no processing. + if doc.PayloadTypeFromFiles() == "pdf" { + for _, f := range doc.Files { + if strings.EqualFold(f.EntryName, docid+storage.PdfFileExt) { + return ls.GetReader(f.Hash) + } + } + for _, f := range doc.Files { + if strings.EqualFold(strings.ToLower(path.Ext(f.EntryName)), storage.PdfFileExt) { + return ls.GetReader(f.Hash) + } + } + return nil, fmt.Errorf("pdf payload not found for document %s", docid) + } + + if strings.TrimSpace(fs.Cfg.RmrlPython) != "" { + rc, err := renderPDFRmrlFromHashDoc(fs.Cfg.RmrlPython, doc, ls) + if err == nil { + return rc, nil + } + log.Warn("rmrl PDF export failed, using rmtool: ", err) + } + reader, writer := io.Pipe() go func() { - zw := zip.NewWriter(writer) - var writeErr error - for _, entry := range doc.Files { - blob, err := ls.GetReader(entry.Hash) - if err != nil { - writeErr = err - break + rc, e := renderPDFRmtool(doc, ls) + if e != nil { + log.Error(e) + _ = writer.Close() + return + } + defer rc.Close() + if _, err := io.Copy(writer, rc); err != nil { + log.Error(err) + _ = writer.Close() + return + } + _ = writer.Close() + }() + return reader, err +} + +// PDFInlineFilename returns a safe filename for Content-Disposition (visible name + .pdf when possible). +func (fs *FileSystemStorage) PDFInlineFilename(uid, docid string) string { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return docid + ".pdf" + } + doc, err := tree.FindDoc(docid) + if err != nil { + return docid + ".pdf" + } + name := strings.TrimSpace(doc.DocumentName) + if name == "" { + return docid + ".pdf" + } + base := filepath.Base(name) + if strings.EqualFold(filepath.Ext(base), storage.PdfFileExt) { + return sanitizeFileName(base) + } + return sanitizeFileName(strings.TrimSuffix(base, filepath.Ext(base))) + storage.PdfFileExt +} + +// GetTemplate returns the raw .template file for a given entry (if present). +func (fs *FileSystemStorage) GetTemplate(uid, docid string) (r io.ReadCloser, err error) { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return nil, err + } + doc, err := tree.FindDoc(docid) + if err != nil { + return nil, err + } + ls := fs.BlobStorage(uid) + for _, f := range doc.Files { + if strings.HasSuffix(strings.ToLower(f.EntryName), storage.TemplateFileExt) { + return ls.GetReader(f.Hash) + } + } + return nil, errors.New("template not found") +} + +// GetEpub returns the raw .epub file for a document (if present). +func (fs *FileSystemStorage) GetEpub(uid, docid string) (io.ReadCloser, error) { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return nil, err + } + doc, err := tree.FindDoc(docid) + if err != nil { + return nil, err + } + ls := fs.BlobStorage(uid) + for _, f := range doc.Files { + if strings.HasSuffix(strings.ToLower(f.EntryName), storage.EpubFileExt) { + return ls.GetReader(f.Hash) + } + } + return nil, errors.New("epub not found") +} + +// GetEpubManifest parses the EPUB and returns spine and base path as JSON. +func (fs *FileSystemStorage) GetEpubManifest(uid, docid string) (*epub.Manifest, error) { + rc, err := fs.GetEpub(uid, docid) + if err != nil { + return nil, err + } + defer rc.Close() + // zip.Reader needs ReaderAt; load into memory + b, err := io.ReadAll(rc) + if err != nil { + return nil, err + } + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + return nil, err + } + return epub.ReadManifest(zr) +} + +// GetEpubFile returns a reader for a file inside the document's EPUB (path relative to zip root). +func (fs *FileSystemStorage) GetEpubFile(uid, docid, filePath string) (io.ReadCloser, string, error) { + rc, err := fs.GetEpub(uid, docid) + if err != nil { + return nil, "", err + } + defer rc.Close() + b, err := io.ReadAll(rc) + if err != nil { + return nil, "", err + } + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + return nil, "", err + } + f, err := epub.OpenZipFile(zr, filePath) + if err != nil { + return nil, "", err + } + contentType := epub.ContentType(filePath) + return f, contentType, nil +} + +// GetEpubCoverThumb opens the EPUB, finds a cover image (see epub.FindCoverImagePath), and returns that file. +func (fs *FileSystemStorage) GetEpubCoverThumb(uid, docid string) (io.ReadCloser, string, error) { + rc, err := fs.GetEpub(uid, docid) + if err != nil { + return nil, "", err + } + defer rc.Close() + b, err := io.ReadAll(rc) + if err != nil { + return nil, "", err + } + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + return nil, "", err + } + imgPath, err := epub.FindCoverImagePath(zr) + if err != nil { + return nil, "", err + } + return fs.GetEpubFile(uid, docid, imgPath) +} + +// GetDocumentMetadata returns document type, hasWritings, and page count for the given doc. +func (fs *FileSystemStorage) GetDocumentMetadata(uid, docid string) (docType string, hasWritings bool, pageCount int, err error) { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return "", false, 0, err + } + doc, err := tree.FindDoc(docid) + if err != nil { + return "", false, 0, err + } + if t := doc.PayloadTypeFromFiles(); t != "" { + docType = t + } else { + docType = doc.PayloadType + } + if docType == "" { + docType = "notebook" + } + hasWritings = doc.HasWritings() + for _, f := range doc.Files { + if strings.HasSuffix(strings.ToLower(f.EntryName), storage.ContentFileExt) { + if f.Size > 4 { + rc, err := fs.BlobStorage(uid).GetReader(f.Hash) + if err != nil { + return docType, hasWritings, 0, err + } + var content models.ContentFile + if err := json.NewDecoder(rc).Decode(&content); err != nil { + rc.Close() + return docType, hasWritings, 0, err + } + rc.Close() + if content.FileType != "" { + docType = content.FileType + } + if content.PageCount > 0 { + pageCount = content.PageCount + } else { + pageCount = len(content.Pages) + } + return docType, hasWritings, pageCount, nil } - fw, err := zw.Create(entry.EntryName) + break + } + } + return docType, hasWritings, pageCount, nil +} + +// GetDocumentOrientation returns the orientation from the document's .content file ("portrait", "landscape", or ""). +func (fs *FileSystemStorage) GetDocumentOrientation(uid, docid string) (string, error) { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return "", err + } + doc, err := tree.FindDoc(docid) + if err != nil { + return "", err + } + for _, f := range doc.Files { + if strings.HasSuffix(strings.ToLower(f.EntryName), storage.ContentFileExt) { + rc, err := fs.BlobStorage(uid).GetReader(f.Hash) if err != nil { - blob.Close() - writeErr = err - break + return "", err } - _, err = io.Copy(fw, blob) - blob.Close() - if err != nil { - writeErr = err - break + defer rc.Close() + var content models.ContentFile + if err := json.NewDecoder(rc).Decode(&content); err != nil { + return "", err } + return content.Orientation, nil } - if writeErr != nil { - log.Error(writeErr) - zw.Close() - writer.CloseWithError(writeErr) - return - } - zw.Close() - writer.Close() - }() - return reader, nil + } + return "", nil } -// Export exports a document -func (fs *FileSystemStorage) Export(uid, docid string) (r io.ReadCloser, err error) { +// ExportPagePNG exports a single page of the document as PNG (1-based page number). +// - PDF documents: payload background (OOP PDF raster) × ink (.rm) multiply composite when writings exist. +// - Notebooks / non-PDF: strokes rendered with rmdecode → raw PNG bytes (cached as page-*-rmdecode-*). +// On cache hit, returns the stored PNG file unchanged. +func (fs *FileSystemStorage) ExportPagePNG(uid, docid string, pageNum int) (io.ReadCloser, error) { tree, err := fs.GetCachedTree(uid) if err != nil { return nil, err @@ -126,23 +340,279 @@ func (fs *FileSystemStorage) Export(uid, docid string) (r io.ReadCloser, err err if err != nil { return nil, err } + docHash := doc.Hash + cacheDir := fs.getPathFromUser(uid, CacheDir) + _ = os.MkdirAll(cacheDir, 0700) + safeDoc := common.Sanitize(docid) + ls := fs.BlobStorage(uid) + payload := doc.PayloadTypeFromFiles() + var cachePath string + var gen func() ([]byte, error) + + if payload == "pdf" { + cachePath = path.Join(cacheDir, "renders", safeDoc, fmt.Sprintf("page-%d-composite-v2-%s.png", pageNum, docHash)) + gen = func() ([]byte, error) { + return exportPDFPageCompositePNG(doc, ls, docid, pageNum) + } + } else { + // Notebook, template, or no payload: render .rm with rmdecode (raw PNG). + cachePath = path.Join(cacheDir, "renders", safeDoc, fmt.Sprintf("page-%d-rmdecode-v2-%s.png", pageNum, docHash)) + gen = func() ([]byte, error) { + return exportNotebookPagePNGWithRmdecode(doc, ls, docid, pageNum) + } + } + + if docHash != "" { + if r, err := os.Open(cachePath); err == nil { + return r, nil + } + } + + b, err := gen() + if err != nil { + // Fallback: legacy full-document PDF raster (slow, may include backgrounds). + log.Warn("ExportPagePNG primary path failed, falling back to RenderPagePNG: ", err) + archive, e2 := models.ArchiveFromHashDoc(doc, ls) + if e2 != nil { + return nil, e2 + } + rc, e3 := exporter.RenderPagePNGReader(archive, pageNum) + if e3 != nil { + return nil, e3 + } + if docHash == "" { + return rc, nil + } + fb, e4 := io.ReadAll(rc) + _ = rc.Close() + if e4 != nil { + return nil, e4 + } + _ = os.MkdirAll(path.Dir(cachePath), 0700) + _ = os.WriteFile(cachePath, fb, 0600) + return exporter.NewSeekCloser(fb), nil + } + if docHash == "" { + return exporter.NewSeekCloser(b), nil + } + _ = os.MkdirAll(path.Dir(cachePath), 0700) + _ = os.WriteFile(cachePath, b, 0600) + return exporter.NewSeekCloser(b), nil +} + +// exportPDFPageCompositePNG builds a device-like page: PDF background × ink multiply. +func exportPDFPageCompositePNG(doc *models.HashDoc, ls *LocalBlobStorage, docid string, pageNum int) ([]byte, error) { archive, err := models.ArchiveFromHashDoc(doc, ls) if err != nil { return nil, err } - reader, writer := io.Pipe() - go func() { - err = exporter.RenderRmapi(archive, writer) - if err != nil { - log.Error(err) - writer.Close() - return + bg, err := exporter.RenderPayloadPagePNG(archive, pageNum) + if err != nil { + return nil, err + } + ink, inkErr := exportNotebookPagePNGWithRmdecode(doc, ls, docid, pageNum) + if inkErr != nil || len(ink) == 0 { + // No writings (or blank page): background only. + return bg, nil + } + // Blank notebook PNG is all white — multiply leaves bg unchanged, but skip the work + // when the .rm blob was missing (exportNotebook returns blank). Detect nearly-empty + // by checking whether a .rm existed via a second read is heavy; just blend. + comp, err := exporter.MultiplyBlendPNGs(bg, ink, nil) + if err != nil { + log.Warn("composite blend failed, returning background: ", err) + return bg, nil + } + return comp, nil +} + +// ExportPageThumbPNG returns a device-sized (~384×512) composite thumbnail for a page. +func (fs *FileSystemStorage) ExportPageThumbPNG(uid, docid string, pageNum int) (io.ReadCloser, error) { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return nil, err + } + doc, err := tree.FindDoc(docid) + if err != nil { + return nil, err + } + docHash := doc.Hash + cacheDir := fs.getPathFromUser(uid, CacheDir) + _ = os.MkdirAll(cacheDir, 0700) + safeDoc := common.Sanitize(docid) + cachePath := path.Join(cacheDir, "renders", safeDoc, fmt.Sprintf("page-%d-thumb-v2-%s.png", pageNum, docHash)) + if docHash != "" { + if r, err := os.Open(cachePath); err == nil { + return r, nil } - writer.Close() - }() - return reader, err + } + + full, err := fs.ExportPagePNG(uid, docid, pageNum) + if err != nil { + return nil, err + } + defer full.Close() + raw, err := io.ReadAll(full) + if err != nil { + return nil, err + } + thumb, err := exporter.ScalePNGToThumb(raw) + if err != nil { + return nil, err + } + if docHash != "" { + _ = os.MkdirAll(path.Dir(cachePath), 0700) + _ = os.WriteFile(cachePath, thumb, 0600) + } + return exporter.NewSeekCloser(thumb), nil +} + +// ExportPageBackgroundPNG renders the original payload (PDF) page as PNG (without handwriting). +func (fs *FileSystemStorage) ExportPageBackgroundPNG(uid, docid string, pageNum int) (io.ReadCloser, error) { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return nil, err + } + doc, err := tree.FindDoc(docid) + if err != nil { + return nil, err + } + // Cache key: pdf blob hash (changes when PDF payload changes) + pdfHash := "" + for _, f := range doc.Files { + if strings.EqualFold(f.EntryName, docid+storage.PdfFileExt) { + pdfHash = f.Hash + break + } + } + cacheDir := fs.getPathFromUser(uid, CacheDir) + _ = os.MkdirAll(cacheDir, 0700) + safeDoc := common.Sanitize(docid) + cachePath := path.Join(cacheDir, "renders", safeDoc, fmt.Sprintf("page-%d-bg-oop-%s.png", pageNum, pdfHash)) + if pdfHash != "" { + if r, err := os.Open(cachePath); err == nil { + return r, nil + } + } + ls := fs.BlobStorage(uid) + archive, err := models.ArchiveFromHashDoc(doc, ls) + if err != nil { + return nil, err + } + rc, err := exporter.RenderPayloadPagePNGReader(archive, pageNum) + if err != nil { + return nil, err + } + if pdfHash == "" { + return rc, nil + } + // Best-effort write-through cache (regenerated automatically when hashes change). + _ = os.MkdirAll(path.Dir(cachePath), 0700) + b, readErr := io.ReadAll(rc) + _ = rc.Close() + if readErr != nil { + return exporter.NewSeekCloser(b), nil + } + _ = os.WriteFile(cachePath, b, 0600) + return exporter.NewSeekCloser(b), nil +} + +// ExportPageOverlaySVG renders the handwriting (.rm) content for a page as SVG. +func (fs *FileSystemStorage) ExportPageOverlaySVG(uid, docid string, pageNum int) (io.ReadCloser, error) { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return nil, err + } + doc, err := tree.FindDoc(docid) + if err != nil { + return nil, err + } + // Cache key: per-page .rm blob hash (changes when a page drawing changes) + // Determine page UUID from .content pages array. + type contentPages struct { + Pages []string `json:"pages"` + } + var pageID string + contentHash := "" + for _, f := range doc.Files { + if strings.EqualFold(f.EntryName, docid+storage.ContentFileExt) { + contentHash = f.Hash + break + } + } + if contentHash != "" { + if rc, err := fs.BlobStorage(uid).GetReader(contentHash); err == nil { + var cp contentPages + if err := json.NewDecoder(rc).Decode(&cp); err == nil { + if pageNum >= 1 && pageNum <= len(cp.Pages) { + pageID = cp.Pages[pageNum-1] + } + } + _ = rc.Close() + } + } + ls := fs.BlobStorage(uid) + rmHash := "" + if pageID != "" { + want := pageID + storage.RmFileExt + for _, f := range doc.Files { + if strings.EqualFold(f.EntryName, want) { + rmHash = f.Hash + break + } + } + } + cacheDir := fs.getPathFromUser(uid, CacheDir) + _ = os.MkdirAll(cacheDir, 0700) + safeDoc := common.Sanitize(docid) + cachePath := path.Join(cacheDir, "renders", safeDoc, fmt.Sprintf("page-%d-ov-v3-%s.svg", pageNum, rmHash)) + if rmHash != "" { + if r, err := os.Open(cachePath); err == nil { + return r, nil + } + } + + // Transparent SVG overlay is the app’s ink layer for PDF/EPUB/notebooks. + // Prefer rmc (via RMFAKECLOUD_RMC_SRC) for v6; lines2svg/embedded for v3. + if rmHash != "" { + if rc, err := ls.GetReader(rmHash); err == nil { + rmData, readErr := io.ReadAll(rc) + _ = rc.Close() + if readErr == nil { + if s, rErr := rmdecode.RenderRmOverlaySVG(rmData); rErr == nil { + b := []byte(s) + _ = os.MkdirAll(path.Dir(cachePath), 0700) + _ = os.WriteFile(cachePath, b, 0600) + return exporter.NewSeekCloser(b), nil + } else { + log.Warn("overlay SVG render failed; falling back: ", rErr) + if ver, vErr := rmdecode.ParseVersion(rmData); vErr == nil && ver == 6 && rmdecode.RMCAvailable() { + return nil, fmt.Errorf("v6 overlay: rmc required but failed: %w", rErr) + } + if ver, vErr := rmdecode.ParseVersion(rmData); vErr == nil && ver == 3 && rmdecode.Lines2SVGAvailable() { + return nil, fmt.Errorf("v3 overlay: lines2svg available but failed: %w", rErr) + } + } + } + } + } + + archive, err := models.ArchiveFromHashDoc(doc, ls) + if err != nil { + return nil, err + } + s, err := exporter.RenderPageAnnotationsSVG(archive, pageNum) + if err != nil { + return nil, err + } + b := []byte(rmdecode.EnsureTransparentOverlaySVG(s)) + if rmHash != "" { + _ = os.MkdirAll(path.Dir(cachePath), 0700) + _ = os.WriteFile(cachePath, b, 0600) + } + return exporter.NewSeekCloser(b), nil } // UpdateBlobDocument updates metadata @@ -239,6 +709,10 @@ func (fs *FileSystemStorage) CreateBlobFolder(uid, foldername, parent string) (d } metadataReader, metahash, size, err := createMetadataFile(metadata) + if err != nil { + return nil, err + } + log.Info("meta hash: ", metahash) err = blobStorage.Write(metahash, metadataReader) if err != nil { @@ -325,20 +799,339 @@ func updateTree(tree *models.HashTree, storage *LocalBlobStorage, treeMutation f // CreateBlobDocument creates a new document func (fs *FileSystemStorage) CreateBlobDocument(uid, filename, parent string, stream io.Reader) (doc *storage.Document, err error) { - ext := path.Ext(filename) + origExt := path.Ext(filename) + ext := strings.ToLower(origExt) switch ext { - case storage.EpubFileExt, storage.PdfFileExt, storage.RmDocFileExt: + case storage.EpubFileExt, storage.PdfFileExt, storage.RmDocFileExt, storage.TemplateFileExt: default: return nil, errors.New("unsupported extension: " + ext) } if ext == storage.RmDocFileExt { - return fs.createFromRmDoc(uid, parent, stream) + // Decode .rmdoc container. + // We will read embedded JSON, but override with what we actually find + // (payload/template presence, file sizes, parent, filename). + tmpFile, err := os.CreateTemp("", "rmdoc-*") + if err != nil { + return nil, err + } + defer os.Remove(tmpFile.Name()) + + if _, err = io.Copy(tmpFile, stream); err != nil { + tmpFile.Close() + return nil, err + } + if err = tmpFile.Close(); err != nil { + return nil, err + } + + zr, err := zip.OpenReader(tmpFile.Name()) + if err != nil { + return nil, err + } + defer zr.Close() + + // identify doc id (uuid prefix) from metadata/content/payload/template entries + var docid string + for _, f := range zr.File { + base := path.Base(f.Name) + low := strings.ToLower(base) + switch { + case strings.HasSuffix(low, storage.MetadataFileExt), + strings.HasSuffix(low, storage.ContentFileExt), + strings.HasSuffix(low, storage.TemplateFileExt), + strings.HasSuffix(low, storage.PdfFileExt), + strings.HasSuffix(low, storage.EpubFileExt): + docid = strings.TrimSuffix(base, path.Ext(base)) + break + } + if docid != "" { + break + } + } + if docid == "" { + return nil, errors.New("rmdoc: could not determine document id") + } + + // Find relevant entries + var metaFile, contentFile, templateFile, pdfFile, epubFile *zip.File + rmFiles := []*zip.File{} + pagedataFiles := []*zip.File{} + for _, f := range zr.File { + low := strings.ToLower(f.Name) + switch { + case f.Name == docid+storage.MetadataFileExt: + metaFile = f + case f.Name == docid+storage.ContentFileExt: + contentFile = f + case f.Name == docid+storage.TemplateFileExt: + templateFile = f + case f.Name == docid+storage.PdfFileExt: + pdfFile = f + case f.Name == docid+storage.EpubFileExt: + epubFile = f + case strings.HasSuffix(low, storage.PageFileExt): + pagedataFiles = append(pagedataFiles, f) + case strings.HasSuffix(low, storage.RmFileExt): + rmFiles = append(rmFiles, f) + } + } + + // Decide what this rmdoc represents (template vs document) and which payload to use. + payloadFile := pdfFile + payloadExt := storage.PdfFileExt + if payloadFile == nil && epubFile != nil { + payloadFile = epubFile + payloadExt = storage.EpubFileExt + } + isTemplate := templateFile != nil && payloadFile == nil + + // Load embedded metadata/content JSON (if present) + embeddedMeta := models.MetadataFile{} + metaOK := false + if metaFile != nil { + rc, err := metaFile.Open() + if err != nil { + return nil, err + } + metaBytes, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, err + } + if err := json.Unmarshal(metaBytes, &embeddedMeta); err == nil { + metaOK = true + } + } + + embeddedContent := models.ContentFile{} + contentOK := false + if contentFile != nil { + rc, err := contentFile.Open() + if err != nil { + return nil, err + } + contentBytes, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, err + } + if err := json.Unmarshal(contentBytes, &embeddedContent); err == nil { + contentOK = true + } + } + + // Build corrected metadata/content: trust embedded values when they don't conflict with our findings. + now := models.FromTime(time.Now()) + // Prefer the embedded visibleName (user-facing name) if present. + // Fall back to the uploaded filename base if metadata is missing/invalid. + docName := "" + if metaOK && embeddedMeta.DocumentName != "" { + docName = embeddedMeta.DocumentName + } + if docName == "" { + docName = strings.TrimSuffix(filename, origExt) + } + + correctedMeta := embeddedMeta + if !metaOK { + correctedMeta = models.MetadataFile{} + correctedMeta.CreatedTime = now + correctedMeta.LastModified = now + } + correctedMeta.DocumentName = docName + correctedMeta.Parent = parent + correctedMeta.Version = 1 + correctedMeta.Synced = true + correctedMeta.MetadataModified = true + if correctedMeta.CreatedTime == "" { + correctedMeta.CreatedTime = now + } + if correctedMeta.LastModified == "" { + correctedMeta.LastModified = now + } + if isTemplate { + correctedMeta.CollectionType = common.EntryType("TemplateType") + } else { + correctedMeta.CollectionType = common.DocumentType + } + + correctedContent := embeddedContent + if !contentOK { + correctedContent = models.ContentFile{} + } + if isTemplate { + // templates often have empty content + correctedContent.FileType = "template" + } else { + correctedContent.FileType = strings.TrimPrefix(payloadExt, ".") + } + + // Create the doc and store all relevant files as blobs using *our* hashes. + blobStorage := fs.BlobStorage(uid) + tree, err := fs.GetCachedTree(uid) + if err != nil { + return nil, err + } + + hashDoc := models.NewHashDocWithMeta(docid, correctedMeta) + hashDoc.PayloadType = correctedContent.FileType + + writeZipEntry := func(zf *zip.File) (hash string, size int64, err error) { + // write to temp file so we can hash+write without double-reading + tf, err := os.CreateTemp(fs.getUserBlobPath(uid), "rmdoc-entry-*") + if err != nil { + return "", 0, err + } + defer os.Remove(tf.Name()) + defer tf.Close() + + rc, err := zf.Open() + if err != nil { + return "", 0, err + } + defer rc.Close() + + tee := io.TeeReader(rc, tf) + hash, size, err = models.Hash(tee) + if err != nil { + return "", size, err + } + if _, err := tf.Seek(0, io.SeekStart); err != nil { + return "", size, err + } + if err := blobStorage.Write(hash, tf); err != nil { + return "", size, err + } + return hash, size, nil + } + + // metadata blob (corrected) + metaJSON, err := json.Marshal(correctedMeta) + if err != nil { + return nil, err + } + metaReader := bytes.NewReader(metaJSON) + metaHash, metaSize, err := models.Hash(metaReader) + if err != nil { + return nil, err + } + if _, err := metaReader.Seek(0, io.SeekStart); err != nil { + return nil, err + } + if err := blobStorage.Write(metaHash, metaReader); err != nil { + return nil, err + } + if err := hashDoc.AddFile(models.NewHashEntry(metaHash, docid+storage.MetadataFileExt, metaSize)); err != nil { + return nil, err + } + + // content blob (corrected) + // ensure SizeInBytes matches the payload/template size we actually store + var payloadSize int64 + if isTemplate && templateFile != nil { + payloadSize = int64(templateFile.UncompressedSize64) + } else if payloadFile != nil { + payloadSize = int64(payloadFile.UncompressedSize64) + } + if payloadSize > 0 { + correctedContent.SizeInBytes = fmt.Sprintf("%d", payloadSize) + } + contentJSON, err := json.Marshal(correctedContent) + if err != nil { + return nil, err + } + contentReader := bytes.NewReader(contentJSON) + contentHash, contentSize, err := models.Hash(contentReader) + if err != nil { + return nil, err + } + if _, err := contentReader.Seek(0, io.SeekStart); err != nil { + return nil, err + } + if err := blobStorage.Write(contentHash, contentReader); err != nil { + return nil, err + } + if err := hashDoc.AddFile(models.NewHashEntry(contentHash, docid+storage.ContentFileExt, contentSize)); err != nil { + return nil, err + } + + // pagedata (if present) + for _, pf := range pagedataFiles { + h, s, err := writeZipEntry(pf) + if err != nil { + return nil, err + } + if err := hashDoc.AddFile(models.NewHashEntry(h, pf.Name, s)); err != nil { + return nil, err + } + } + + // rm pages + for _, rf := range rmFiles { + h, s, err := writeZipEntry(rf) + if err != nil { + return nil, err + } + if err := hashDoc.AddFile(models.NewHashEntry(h, rf.Name, s)); err != nil { + return nil, err + } + } + + // template or payload + if isTemplate { + if templateFile == nil { + return nil, errors.New("rmdoc: template type but no .template found") + } + h, s, err := writeZipEntry(templateFile) + if err != nil { + return nil, err + } + if err := hashDoc.AddFile(models.NewHashEntry(h, docid+storage.TemplateFileExt, s)); err != nil { + return nil, err + } + ext = storage.TemplateFileExt + } else { + if payloadFile == nil { + return nil, errors.New("rmdoc: no supported document payload (pdf/epub) found") + } + h, s, err := writeZipEntry(payloadFile) + if err != nil { + return nil, err + } + if err := hashDoc.AddFile(models.NewHashEntry(h, docid+payloadExt, s)); err != nil { + return nil, err + } + ext = payloadExt + } + + indexReader, err := hashDoc.IndexReader() + if err != nil { + return nil, err + } + if err := blobStorage.Write(hashDoc.Hash, indexReader); err != nil { + return nil, err + } + + if err := updateTree(tree, blobStorage, func(t *models.HashTree) error { + return tree.Add(hashDoc) + }); err != nil { + return nil, err + } + + return &storage.Document{ + ID: docid, + Type: correctedMeta.CollectionType, + Parent: "", + Name: docName, + }, nil } + //TODO: zips and rm blobPath := fs.getUserBlobPath(uid) docid := uuid.New().String() - docName := strings.TrimSuffix(filename, ext) + //create metadata + docName := strings.TrimSuffix(filename, origExt) tree, err := fs.GetCachedTree(uid) if err != nil { @@ -347,9 +1140,13 @@ func (fs *FileSystemStorage) CreateBlobDocument(uid, filename, parent string, st log.Info("Creating metadata... parent: ", parent) + collectionType := common.DocumentType + if ext == storage.TemplateFileExt { + collectionType = common.EntryType("TemplateType") + } metadata := models.MetadataFile{ DocumentName: docName, - CollectionType: common.DocumentType, + CollectionType: collectionType, Parent: parent, Version: 1, CreatedTime: models.FromTime(time.Now()), @@ -378,7 +1175,10 @@ func (fs *FileSystemStorage) CreateBlobDocument(uid, filename, parent string, st return } - content := createContent(ext) + content := "{}" + if ext != storage.TemplateFileExt { + content = createContent(ext) + } contentReader := strings.NewReader(content) contentHash, size, err := models.Hash(contentReader) @@ -455,146 +1255,6 @@ func (fs *FileSystemStorage) CreateBlobDocument(uid, filename, parent string, st return } -func (fs *FileSystemStorage) createFromRmDoc(uid, parent string, stream io.Reader) (*storage.Document, error) { - data, err := io.ReadAll(stream) - if err != nil { - return nil, err - } - - zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) - if err != nil { - return nil, err - } - - var metadataEntry *zip.File - for _, f := range zr.File { - if strings.HasSuffix(f.Name, storage.MetadataFileExt) { - metadataEntry = f - break - } - } - if metadataEntry == nil { - return nil, errors.New("rmdoc: no .metadata file found in archive") - } - - docid := strings.TrimSuffix(metadataEntry.Name, storage.MetadataFileExt) - - mr, err := metadataEntry.Open() - if err != nil { - return nil, err - } - metaBytes, err := io.ReadAll(mr) - mr.Close() - if err != nil { - return nil, err - } - - var metadata models.MetadataFile - if err := json.Unmarshal(metaBytes, &metadata); err != nil { - return nil, err - } - - if parent != "" { - metadata.Parent = parent - } - metadata.Synced = true - metadata.MetadataModified = true - - blobStorage := fs.BlobStorage(uid) - - metaReader := bytes.NewReader(metaBytes) - metaHash, metaSize, err := models.Hash(metaReader) - if err != nil { - return nil, err - } - metaReader.Seek(0, io.SeekStart) - if err := blobStorage.Write(metaHash, metaReader); err != nil { - return nil, err - } - - hashDoc := models.NewHashDocWithMeta(docid, metadata) - hashDoc.PayloadType = metadata.DocumentName - - for _, f := range zr.File { - if strings.HasSuffix(f.Name, storage.ContentFileExt) { - cr, err := f.Open() - if err == nil { - var contentFile models.ContentFile - contentBytes, err := io.ReadAll(cr) - cr.Close() - if err == nil { - if json.Unmarshal(contentBytes, &contentFile) == nil && contentFile.FileType != "" { - hashDoc.PayloadType = contentFile.FileType - } - } - } - break - } - } - - entry := models.NewHashEntry(metaHash, metadataEntry.Name, metaSize) - if err := hashDoc.AddFile(entry); err != nil { - return nil, err - } - - for _, f := range zr.File { - if f.Name == metadataEntry.Name { - continue - } - - rc, err := f.Open() - if err != nil { - return nil, err - } - fileData, err := io.ReadAll(rc) - rc.Close() - if err != nil { - return nil, err - } - - reader := bytes.NewReader(fileData) - fileHash, fileSize, err := models.Hash(reader) - if err != nil { - return nil, err - } - reader.Seek(0, io.SeekStart) - if err := blobStorage.Write(fileHash, reader); err != nil { - return nil, err - } - - entry := models.NewHashEntry(fileHash, f.Name, fileSize) - if err := hashDoc.AddFile(entry); err != nil { - return nil, err - } - } - - indexReader, err := hashDoc.IndexReader() - if err != nil { - return nil, err - } - if err := blobStorage.Write(hashDoc.Hash, indexReader); err != nil { - return nil, err - } - - tree, err := fs.GetCachedTree(uid) - if err != nil { - return nil, err - } - err = updateTree(tree, blobStorage, func(t *models.HashTree) error { - return tree.Add(hashDoc) - }) - if err != nil { - return nil, err - } - - return &storage.Document{ - ID: docid, - Type: metadata.CollectionType, - Parent: metadata.Parent, - Name: metadata.DocumentName, - }, nil -} - func createMetadataFile(metadata models.MetadataFile) (r io.Reader, filehash string, size int64, err error) { jsn, err := json.Marshal(metadata) if err != nil { @@ -684,6 +1344,29 @@ func (fs *FileSystemStorage) LoadBlob(uid, blobid string) (reader io.ReadCloser, return reader, generation, fi.Size(), "crc32c=" + hash, err } +// GetRawBlob reads a raw blob (returns just the reader and error - wrapper around LoadBlob) +func (fs *FileSystemStorage) GetRawBlob(uid, hash string) (stream io.ReadCloser, err error) { + reader, _, _, _, err := fs.LoadBlob(uid, hash) + return reader, err +} + +func (fs *FileSystemStorage) GetBlobDocumentTree(uid, docid string) (m map[string]string, err error) { + tree, err := fs.GetCachedTree(uid) + if err != nil { + return nil, err + } + doc, err := tree.FindDoc(docid) + if err != nil { + return nil, err + } + output := make(map[string]string, len(doc.Files)) + for _, entry := range doc.Files { + output[entry.EntryName] = entry.Hash + } + + return output, nil +} + // StoreBlob stores a document func (fs *FileSystemStorage) StoreBlob(uid, id string, stream io.Reader, lastGen int64) (generation int64, err error) { generation = 1 diff --git a/internal/storage/fs/documents.go b/internal/storage/fs/documents.go index f6a4335a..1324d7b1 100644 --- a/internal/storage/fs/documents.go +++ b/internal/storage/fs/documents.go @@ -8,6 +8,7 @@ import ( "os" "path" "path/filepath" + "strings" "time" "github.com/golang-jwt/jwt/v4" @@ -69,22 +70,14 @@ func (fs *FileSystemStorage) ExportDocument(uid, id, outputType string, exportOp return nil, fmt.Errorf("cant find raw document %v", err) } - outputFilePath := path.Join(cacheDirPath, sanitizedID+"-annotated.pdf") - outStat, err := os.Stat(outputFilePath) - - // exists and not older - if err == nil && !rawStat.ModTime().After(outStat.ModTime()) { - return os.Open(outputFilePath) - } - - size := rawStat.Size() - arch := &exporter.MyArchive{} zipFile, err := os.Open(zipFilePath) if err != nil { return nil, err } defer zipFile.Close() - err = arch.Read(zipFile, size) + + arch := &exporter.MyArchive{} + err = arch.Read(zipFile, rawStat.Size()) if err != nil { return nil, err } @@ -93,6 +86,28 @@ func (fs *FileSystemStorage) ExportDocument(uid, id, outputType string, exportOp arch.PayloadReader = exporter.NewSeekCloser(arch.Payload) } + // PDF: return original file bytes only (no annotation merge / re-render). + if strings.EqualFold(arch.Content.FileType, "pdf") && arch.PayloadReader != nil { + if _, err := arch.PayloadReader.Seek(0, io.SeekStart); err != nil { + return nil, err + } + return arch.PayloadReader, nil + } + + if strings.TrimSpace(fs.Cfg.RmrlPython) != "" { + if r, ok := tryExportPDFViaRmrl(fs.Cfg.RmrlPython, zipFilePath); ok { + return r, nil + } + } + + outputFilePath := path.Join(cacheDirPath, sanitizedID+"-annotated.pdf") + outStat, err := os.Stat(outputFilePath) + + // exists and not older + if err == nil && !rawStat.ModTime().After(outStat.ModTime()) { + return os.Open(outputFilePath) + } + outputFile, err := os.Create(outputFilePath) if err != nil { return nil, err diff --git a/internal/storage/fs/notebook_page_png.go b/internal/storage/fs/notebook_page_png.go new file mode 100644 index 00000000..93bb177c --- /dev/null +++ b/internal/storage/fs/notebook_page_png.go @@ -0,0 +1,87 @@ +package fs + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/ddvk/rmfakecloud/internal/rmdecode" + "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/models" +) + +type contentPagesOnly struct { + Pages []string `json:"pages"` +} + +// readPageRmBlob loads the raw .rm bytes for a 1-based page index. If the page exists in +// .content but has no .rm file yet, returns (nil, "", nil). Other errors: missing content, bad index. +func readPageRmBlob(doc *models.HashDoc, ls *LocalBlobStorage, docid string, pageNum int) (data []byte, rmHash string, err error) { + var cp contentPagesOnly + found := false + for _, f := range doc.Files { + if strings.EqualFold(f.EntryName, docid+storage.ContentFileExt) { + found = true + rc, e := ls.GetReader(f.Hash) + if e != nil { + return nil, "", e + } + e = json.NewDecoder(rc).Decode(&cp) + _ = rc.Close() + if e != nil { + return nil, "", e + } + break + } + } + if !found { + return nil, "", fmt.Errorf("missing .content for document") + } + if pageNum < 1 || pageNum > len(cp.Pages) { + return nil, "", fmt.Errorf("page %d out of range (1-%d)", pageNum, len(cp.Pages)) + } + pageID := cp.Pages[pageNum-1] + want := pageID + storage.RmFileExt + for _, f := range doc.Files { + if strings.EqualFold(f.EntryName, want) { + rmHash = f.Hash + rc, e := ls.GetReader(f.Hash) + if e != nil { + return nil, "", e + } + data, e = io.ReadAll(rc) + _ = rc.Close() + if e != nil { + return nil, "", e + } + return data, rmHash, nil + } + } + return nil, "", nil +} + +// exportNotebookPagePNGWithRmdecode renders strokes to PNG via rmdecode (v3/v5 Go; v6 optional python). +func exportNotebookPagePNGWithRmdecode( + doc *models.HashDoc, + ls *LocalBlobStorage, + docid string, + pageNum int, +) ([]byte, error) { + rmData, _, err := readPageRmBlob(doc, ls, docid, pageNum) + if err != nil { + return nil, err + } + if len(rmData) == 0 { + b, err := rmdecode.RenderBlankNotebookPNG() + if err != nil { + return nil, err + } + return b, nil + } + b, err := rmdecode.EncodeRmPageToPNG(rmData) + if err != nil { + return nil, err + } + return b, nil +} diff --git a/internal/storage/fs/rmrl_pdf.go b/internal/storage/fs/rmrl_pdf.go new file mode 100644 index 00000000..2cfd108d --- /dev/null +++ b/internal/storage/fs/rmrl_pdf.go @@ -0,0 +1,254 @@ +package fs + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path" + "strings" + "time" + + "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/models" + log "github.com/sirupsen/logrus" +) + +const rmrlRunTimeout = 4 * time.Minute + +// renderPDFRmrlFile runs the rmrl CLI on a reMarkable document zip (cloud / rmapi-style bundle). +// pythonPath is the interpreter (e.g. /usr/bin/python3); the rmrl package must be installed for that interpreter. +func renderPDFRmrlFile(pythonPath, zipPath string) ([]byte, error) { + pythonPath = strings.TrimSpace(pythonPath) + if pythonPath == "" { + return nil, fmt.Errorf("empty python path") + } + ctx, cancel := context.WithTimeout(context.Background(), rmrlRunTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, pythonPath, "-m", "rmrl", zipPath) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return nil, fmt.Errorf("%w: %s", err, msg) + } + return nil, err + } + if len(out) < 8 || !bytes.HasPrefix(out, []byte("%PDF")) { + return nil, fmt.Errorf("rmrl did not produce PDF output") + } + return out, nil +} + +// buildRmrlDocumentZip builds an in-memory zip in the layout rmrl's ZipSource expects: +// {docId}.content, optional {docId}.pdf, {docId}.pagedata, and {docId}/{pageId}.rm +func buildRmrlDocumentZip(doc *models.HashDoc, ls hashReader) ([]byte, error) { + if doc == nil { + return nil, fmt.Errorf("doc is nil") + } + docid := doc.EntryName + var contentBytes []byte + for _, f := range doc.Files { + if strings.EqualFold(path.Ext(f.EntryName), storage.ContentFileExt) { + rc, err := ls.GetReader(f.Hash) + if err != nil { + return nil, err + } + b, err := readAll(rc) + if err != nil { + return nil, err + } + contentBytes = b + break + } + } + if contentBytes == nil { + return nil, fmt.Errorf("missing content file") + } + + buf := new(bytes.Buffer) + zw := zip.NewWriter(buf) + + w, err := zw.Create(docid + storage.ContentFileExt) + if err != nil { + return nil, err + } + if _, err := w.Write(contentBytes); err != nil { + _ = zw.Close() + return nil, err + } + + for _, f := range doc.Files { + ext := strings.ToLower(path.Ext(f.EntryName)) + switch ext { + case storage.PageFileExt: + rc, err := ls.GetReader(f.Hash) + if err != nil { + _ = zw.Close() + return nil, err + } + b, err := readAll(rc) + if err != nil { + _ = zw.Close() + return nil, err + } + name := docid + storage.PageFileExt + fw, err := zw.Create(name) + if err != nil { + _ = zw.Close() + return nil, err + } + if _, err := fw.Write(b); err != nil { + _ = zw.Close() + return nil, err + } + case storage.PdfFileExt: + rc, err := ls.GetReader(f.Hash) + if err != nil { + _ = zw.Close() + return nil, err + } + b, err := readAll(rc) + if err != nil { + _ = zw.Close() + return nil, err + } + fw, err := zw.Create(docid + storage.PdfFileExt) + if err != nil { + _ = zw.Close() + return nil, err + } + if _, err := fw.Write(b); err != nil { + _ = zw.Close() + return nil, err + } + case storage.RmFileExt: + base := path.Base(f.EntryName) + rc, err := ls.GetReader(f.Hash) + if err != nil { + _ = zw.Close() + return nil, err + } + b, err := readAll(rc) + if err != nil { + _ = zw.Close() + return nil, err + } + inner := docid + "/" + base + fw, err := zw.Create(inner) + if err != nil { + _ = zw.Close() + return nil, err + } + if _, err := fw.Write(b); err != nil { + _ = zw.Close() + return nil, err + } + } + } + + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// renderPDFRmrlFromHashDoc writes a temporary zip and runs rmrl (for sync 1.5 blob documents). +func renderPDFRmrlFromHashDoc(pythonPath string, doc *models.HashDoc, ls hashReader) (io.ReadCloser, error) { + zb, err := buildRmrlDocumentZip(doc, ls) + if err != nil { + return nil, err + } + tmp, err := os.CreateTemp("", "rmfakecloud-rmrl-*.zip") + if err != nil { + return nil, err + } + tmpPath := tmp.Name() + cleanup := func() { _ = os.Remove(tmpPath) } + + if _, err := tmp.Write(zb); err != nil { + _ = tmp.Close() + cleanup() + return nil, err + } + if err := tmp.Close(); err != nil { + cleanup() + return nil, err + } + + out, err := renderPDFRmrlFile(pythonPath, tmpPath) + cleanup() + if err != nil { + return nil, err + } + return io.NopCloser(bytes.NewReader(out)), nil +} + +// rmZipLooksRmrlCompatible returns true if the zip has a top-level *.content and +// at least one {docId}/*.rm path (rmrl's expected layout; see rmrl/sources.py). +func rmZipLooksRmrlCompatible(zr *zip.Reader) bool { + var contentName string + rmCount := 0 + for _, f := range zr.File { + n := f.Name + if strings.Contains(n, "..") { + continue + } + if strings.HasSuffix(strings.ToLower(n), ".content") && !strings.Contains(n, "/") { + contentName = path.Base(n) + } + low := strings.ToLower(n) + if strings.HasSuffix(low, ".rm") { + rmCount++ + } + } + if contentName == "" || rmCount == 0 { + return false + } + docStem := strings.TrimSuffix(contentName, ".content") + // Flat page files: PAGE.rm next to content (rmrl tries {ID}/pid.rm first, then {ID}/pagenum.rm) + hasUnderDoc := false + for _, f := range zr.File { + low := strings.ToLower(f.Name) + if !strings.HasSuffix(low, ".rm") { + continue + } + dir := path.Dir(f.Name) + baseDir := path.Base(dir) + if baseDir == docStem { + hasUnderDoc = true + break + } + } + // rmrl resolves strokes from {docId}/{pageId}.rm (see rmrl/document.py). + return hasUnderDoc +} + +// tryExportPDFViaRmrl runs rmrl on an on-disk zip if the bundle looks compatible. +func tryExportPDFViaRmrl(pythonPath, zipPath string) (io.ReadCloser, bool) { + pythonPath = strings.TrimSpace(pythonPath) + if pythonPath == "" { + return nil, false + } + r, err := zip.OpenReader(zipPath) + if err != nil { + log.Debug("rmrl: open zip: ", err) + return nil, false + } + defer r.Close() + if !rmZipLooksRmrlCompatible(&r.Reader) { + return nil, false + } + out, err := renderPDFRmrlFile(pythonPath, zipPath) + if err != nil { + log.Warn("rmrl export failed (sync 1.0 zip): ", err) + return nil, false + } + return io.NopCloser(bytes.NewReader(out)), true +} + diff --git a/internal/storage/fs/rmtool_pdf.go b/internal/storage/fs/rmtool_pdf.go new file mode 100644 index 00000000..e7287a4d --- /dev/null +++ b/internal/storage/fs/rmtool_pdf.go @@ -0,0 +1,167 @@ +package fs + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "path" + "strings" + "time" + + rmtool "github.com/akeil/rmtool" + rmrender "github.com/akeil/rmtool/pkg/render" + + "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/models" +) + +type rmtoolMeta struct { + id string + version uint + name string + typ rmtool.NotebookType + parent string + pinned bool + mod time.Time +} + +func (m *rmtoolMeta) ID() string { return m.id } +func (m *rmtoolMeta) Version() uint { return m.version } +func (m *rmtoolMeta) Name() string { return m.name } +func (m *rmtoolMeta) SetName(n string) { m.name = n } +func (m *rmtoolMeta) Type() rmtool.NotebookType { return m.typ } +func (m *rmtoolMeta) Pinned() bool { return m.pinned } +func (m *rmtoolMeta) SetPinned(p bool) { m.pinned = p } +func (m *rmtoolMeta) LastModified() time.Time { return m.mod } +func (m *rmtoolMeta) Parent() string { return m.parent } +func (m *rmtoolMeta) Validate() error { return nil } + +// rmtoolRepo is an in-memory repository for a single document. +// It implements rmtool.Repository so we can use rmtool's PDF renderer. +type rmtoolRepo struct { + files map[string][]byte // relative path -> bytes (e.g. ".content", ".pdf", ".rm") + pageIDs []string +} + +func (r *rmtoolRepo) List() ([]rmtool.Meta, error) { return nil, fmt.Errorf("not implemented") } +func (r *rmtoolRepo) Update(meta rmtool.Meta) error { return fmt.Errorf("not implemented") } +func (r *rmtoolRepo) Upload(d *rmtool.Document) error { return fmt.Errorf("not implemented") } +func (r *rmtoolRepo) Reader(id string, version uint, p ...string) (io.ReadCloser, error) { + key := path.Join(p...) + b, ok := r.files[key] + if !ok { + return nil, fmt.Errorf("not found: %s", key) + } + return io.NopCloser(bytes.NewReader(b)), nil +} +func (r *rmtoolRepo) PagePrefix(pageID string, pageIndex int) string { + // rmtool Document.Drawing asks for PagePrefix(docID, idx) and then appends ".rm". + // We map index -> actual page UUID from the .content file. + if pageIndex >= 0 && pageIndex < len(r.pageIDs) { + return r.pageIDs[pageIndex] + } + return pageID +} + +type contentPages struct { + FileType string `json:"fileType"` + Pages []string `json:"pages"` +} + +func readAll(rc io.ReadCloser) ([]byte, error) { + defer rc.Close() + return io.ReadAll(rc) +} + +type hashReader interface { + GetReader(hash string) (io.ReadCloser, error) +} + +// renderPDFRmtool renders a multi-page PDF on the fly using rmtool's renderer. +// For PDFs it overlays drawings as a separate PDF layer; for notebooks it renders drawings as bitmaps. +func renderPDFRmtool(doc *models.HashDoc, ls hashReader) (io.ReadCloser, error) { + if doc == nil { + return nil, fmt.Errorf("doc is nil") + } + + docid := doc.EntryName + files := map[string][]byte{} + + // Read and store .content (needed to list page IDs and file type). + var contentBytes []byte + for _, f := range doc.Files { + if strings.EqualFold(path.Ext(f.EntryName), storage.ContentFileExt) { + rc, err := ls.GetReader(f.Hash) + if err != nil { + return nil, err + } + b, err := readAll(rc) + if err != nil { + return nil, err + } + contentBytes = b + files[docid+storage.ContentFileExt] = b + break + } + } + if contentBytes == nil { + return nil, fmt.Errorf("missing content file") + } + + cp := contentPages{} + if err := json.Unmarshal(contentBytes, &cp); err != nil { + return nil, fmt.Errorf("parse content: %w", err) + } + + // Store all .rm page files. In rmfakecloud storage they are stored as ".rm". + for _, f := range doc.Files { + if strings.EqualFold(path.Ext(f.EntryName), storage.RmFileExt) { + rc, err := ls.GetReader(f.Hash) + if err != nil { + return nil, err + } + b, err := readAll(rc) + if err != nil { + return nil, err + } + files[path.Base(f.EntryName)] = b + } + } + + // Store payload PDF if present. + for _, f := range doc.Files { + if strings.EqualFold(path.Ext(f.EntryName), storage.PdfFileExt) { + rc, err := ls.GetReader(f.Hash) + if err != nil { + return nil, err + } + b, err := readAll(rc) + if err != nil { + return nil, err + } + files[docid+storage.PdfFileExt] = b + break + } + } + + repo := &rmtoolRepo{files: files, pageIDs: cp.Pages} + meta := &rmtoolMeta{ + id: docid, + version: uint(doc.MetadataFile.Version), + name: doc.MetadataFile.DocumentName, + typ: rmtool.DocumentType, + mod: time.Now(), + } + d, err := rmtool.ReadDocument(repo, meta) + if err != nil { + return nil, err + } + + var out bytes.Buffer + if err := rmrender.Pdf(d, &out); err != nil { + return nil, err + } + return io.NopCloser(bytes.NewReader(out.Bytes())), nil +} + diff --git a/internal/storage/models/hashdoc.go b/internal/storage/models/hashdoc.go index b46a9cf0..369f75bf 100644 --- a/internal/storage/models/hashdoc.go +++ b/internal/storage/models/hashdoc.go @@ -9,11 +9,13 @@ import ( "errors" "fmt" "io" + "path" "sort" "strconv" "strings" "github.com/ddvk/rmfakecloud/internal/common" + "github.com/ddvk/rmfakecloud/internal/storage" log "github.com/sirupsen/logrus" ) @@ -85,6 +87,35 @@ func (d *HashDoc) MetadataReader() (hash string, reader io.Reader, err error) { return } +// HasWritings returns true if the document has any .rm annotation pages. +func (d *HashDoc) HasWritings() bool { + for _, f := range d.Files { + if strings.ToLower(path.Ext(f.EntryName)) == storage.RmFileExt { + return true + } + } + return false +} + +// PayloadTypeFromFiles returns document type from file extensions only. +// Returns "epub", "pdf", "template", or "" (caller should use "notebook" or d.PayloadType when ""). +func (d *HashDoc) PayloadTypeFromFiles() string { + low := func(s string) string { return strings.ToLower(s) } + for _, f := range d.Files { + name := low(f.EntryName) + if strings.HasSuffix(name, storage.EpubFileExt) { + return "epub" + } + if strings.HasSuffix(name, storage.PdfFileExt) { + return "pdf" + } + if strings.HasSuffix(name, storage.TemplateFileExt) { + return "template" + } + } + return "" +} + // AddFile adds an entry func (d *HashDoc) AddFile(e *HashEntry) error { d.Files = append(d.Files, e) @@ -96,6 +127,7 @@ func (d *HashDoc) AddFile(e *HashEntry) error { return d.Rehash() } + type ErrDocumentExists struct { DocID string Name string @@ -180,7 +212,9 @@ func (d *HashDoc) readContent(hash string, r RemoteStorage) error { if err != nil { log.Printf("cannot read content %s %v", hash, err) } - d.PayloadType = contentFile.FileType + if len(contentBytes) > 4 && contentFile.FileType != "" { + d.PayloadType = contentFile.FileType + } if len(contentFile.SizeInBytes) > 0 { d.Size, err = strconv.ParseInt(contentFile.SizeInBytes, 10, 64) diff --git a/internal/storage/models/metadatafile.go b/internal/storage/models/metadatafile.go index 72c25082..28327a3c 100644 --- a/internal/storage/models/metadatafile.go +++ b/internal/storage/models/metadatafile.go @@ -12,6 +12,7 @@ import ( type MetadataFile struct { DocumentName string `json:"visibleName"` CollectionType common.EntryType `json:"type"` + Source string `json:"source,omitempty"` // e.g. "com.remarkable.methods" Parent string `json:"parent"` CreatedTime string `json:"createdTime"` LastModified string `json:"lastModified"` diff --git a/internal/ui/backend10.go b/internal/ui/backend10.go index feb09c28..32fe342f 100644 --- a/internal/ui/backend10.go +++ b/internal/ui/backend10.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "io" "time" @@ -8,6 +9,7 @@ import ( "github.com/ddvk/rmfakecloud/internal/common" "github.com/ddvk/rmfakecloud/internal/messages" "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/epub" "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" log "github.com/sirupsen/logrus" ) @@ -16,6 +18,7 @@ const webDevice = "web" type backend10 struct { documentHandler documentHandler + blobHandler blobHandler hub *hub.Hub } @@ -59,6 +62,16 @@ func (d *backend10) CreateDocument(uid, filename, parent string, stream io.Reade } func (d *backend10) GetDocumentTree(uid string) (tree *viewmodel.DocumentTree, err error) { + // Prefer hash-tree metadata (same as sync 1.5) so document types and previews work. + if d.blobHandler != nil { + tree, err = documentTreeFromBlob(d.blobHandler, uid) + if err == nil && tree != nil { + return tree, nil + } + if err != nil { + log.Warn(uiLogger, ui10, " hash tree failed, falling back to legacy metadata: ", err) + } + } documents, err := d.documentHandler.GetAllMetadata(uid) if err != nil { return nil, err @@ -74,7 +87,7 @@ func (d *backend10) GetDocumentTree(uid string) (tree *viewmodel.DocumentTree, e Parent: d.Parent, Name: d.VissibleName, Type: d.Type, - FileType: "TODO", + FileType: "notebook", LastModified: lastMod, }) @@ -90,6 +103,76 @@ func (d *backend10) Export(uid, docID, exporttype string, opt storage.ExportOpti return r, nil } +func (d *backend10) PDFInlineFilename(uid, docid string) string { + if d.blobHandler != nil { + return d.blobHandler.PDFInlineFilename(uid, docid) + } + return docid + ".pdf" +} + +func (d *backend10) GetDocumentMetadata(uid, docid string) (docType string, hasWritings bool, pageCount int, err error) { + if d.blobHandler == nil { + return "", false, 0, errors.New("blob handler not configured") + } + return d.blobHandler.GetDocumentMetadata(uid, docid) +} + +func (d *backend10) ExportPagePNG(uid, docid string, pageNum int) (io.ReadCloser, error) { + if d.blobHandler == nil { + return nil, errors.New("blob handler not configured") + } + return d.blobHandler.ExportPagePNG(uid, docid, pageNum) +} + +func (d *backend10) ExportPageBackgroundPNG(uid, docid string, pageNum int) (io.ReadCloser, error) { + if d.blobHandler == nil { + return nil, errors.New("blob handler not configured") + } + return d.blobHandler.ExportPageBackgroundPNG(uid, docid, pageNum) +} + +func (d *backend10) ExportPageThumbPNG(uid, docid string, pageNum int) (io.ReadCloser, error) { + if d.blobHandler == nil { + return nil, errors.New("blob handler not configured") + } + return d.blobHandler.ExportPageThumbPNG(uid, docid, pageNum) +} + +func (d *backend10) ExportPageOverlaySVG(uid, docid string, pageNum int) (io.ReadCloser, error) { + if d.blobHandler == nil { + return nil, errors.New("blob handler not configured") + } + return d.blobHandler.ExportPageOverlaySVG(uid, docid, pageNum) +} + +func (d *backend10) GetEpubManifest(uid, docid string) (*epub.Manifest, error) { + if d.blobHandler == nil { + return nil, errors.New("blob handler not configured") + } + return d.blobHandler.GetEpubManifest(uid, docid) +} + +func (d *backend10) GetEpubFile(uid, docid, filePath string) (io.ReadCloser, string, error) { + if d.blobHandler == nil { + return nil, "", errors.New("blob handler not configured") + } + return d.blobHandler.GetEpubFile(uid, docid, filePath) +} + +func (d *backend10) GetEpubCoverThumb(uid, docid string) (io.ReadCloser, string, error) { + if d.blobHandler == nil { + return nil, "", errors.New("blob handler not configured") + } + return d.blobHandler.GetEpubCoverThumb(uid, docid) +} + +func (d *backend10) GetTemplate(uid, docid string) (io.ReadCloser, error) { + if d.blobHandler == nil { + return nil, errors.New("blob handler not configured") + } + return d.blobHandler.GetTemplate(uid, docid) +} + func (d *backend10) UpdateDocument(uid, docID, name, parent string) (err error) { metadata, err := d.documentHandler.GetMetadata(uid, docID) if err != nil { @@ -130,3 +213,11 @@ func (d *backend10) DeleteDocument(uid, docID string) (err error) { d.hub.Notify(uid, webDevice, ntf, messages.DocDeletedEvent) return nil } + +func (d *backend10) GetRawBlob(uid, hash string) (reader io.ReadCloser, err error) { + return nil, errors.New("cannot get raw blob on the older backend version") +} + +func (d *backend10) GetBlobDocumentTree(uid, docid string) (m map[string]string, err error) { + return nil, errors.New("cannot use the blob API on the older backend version") +} diff --git a/internal/ui/backend15.go b/internal/ui/backend15.go index 135df89d..6aaa44d7 100644 --- a/internal/ui/backend15.go +++ b/internal/ui/backend15.go @@ -5,6 +5,7 @@ import ( "github.com/ddvk/rmfakecloud/internal/app/hub" "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/epub" "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" "github.com/google/uuid" ) @@ -15,21 +16,53 @@ type backend15 struct { } func (b *backend15) GetDocumentTree(uid string) (tree *viewmodel.DocumentTree, err error) { - hashTree, err := b.blobHandler.GetCachedTree(uid) - if err != nil { - return nil, err - } - - return viewmodel.DocTreeFromHashTree(hashTree), nil + return documentTreeFromBlob(b.blobHandler, uid) } func (b *backend15) Export(uid, docid, exporttype string, opt storage.ExportOption) (r io.ReadCloser, err error) { - if exporttype == "rmdoc" { - return b.blobHandler.ExportRmDoc(uid, docid) - } r, err = b.blobHandler.Export(uid, docid) return } +func (b *backend15) PDFInlineFilename(uid, docid string) string { + return b.blobHandler.PDFInlineFilename(uid, docid) +} + +func (b *backend15) GetTemplate(uid, docid string) (r io.ReadCloser, err error) { + return b.blobHandler.GetTemplate(uid, docid) +} + +func (b *backend15) GetDocumentMetadata(uid, docid string) (docType string, hasWritings bool, pageCount int, err error) { + return b.blobHandler.GetDocumentMetadata(uid, docid) +} + +func (b *backend15) ExportPagePNG(uid, docid string, pageNum int) (io.ReadCloser, error) { + return b.blobHandler.ExportPagePNG(uid, docid, pageNum) +} + +func (b *backend15) ExportPageBackgroundPNG(uid, docid string, pageNum int) (io.ReadCloser, error) { + return b.blobHandler.ExportPageBackgroundPNG(uid, docid, pageNum) +} + +func (b *backend15) ExportPageThumbPNG(uid, docid string, pageNum int) (io.ReadCloser, error) { + return b.blobHandler.ExportPageThumbPNG(uid, docid, pageNum) +} + +func (b *backend15) ExportPageOverlaySVG(uid, docid string, pageNum int) (io.ReadCloser, error) { + return b.blobHandler.ExportPageOverlaySVG(uid, docid, pageNum) +} + +func (b *backend15) GetEpubManifest(uid, docid string) (*epub.Manifest, error) { + return b.blobHandler.GetEpubManifest(uid, docid) +} + +func (b *backend15) GetEpubFile(uid, docid, filePath string) (io.ReadCloser, string, error) { + return b.blobHandler.GetEpubFile(uid, docid, filePath) +} + +func (b *backend15) GetEpubCoverThumb(uid, docid string) (io.ReadCloser, string, error) { + return b.blobHandler.GetEpubCoverThumb(uid, docid) +} + func (b *backend15) CreateDocument(uid, filename, parent string, stream io.Reader) (doc *storage.Document, err error) { doc, err = b.blobHandler.CreateBlobDocument(uid, filename, parent, stream) return @@ -49,3 +82,11 @@ func (b *backend15) DeleteDocument(uid, docID string) (err error) { func (b *backend15) Sync(uid string) { b.h.NotifySync(uid, uuid.NewString()) } + +func (b *backend15) GetRawBlob(uid, hash string) (stream io.ReadCloser, err error) { + return b.blobHandler.GetRawBlob(uid, hash) +} + +func (b *backend15) GetBlobDocumentTree(uid, docid string) (m map[string]string, err error) { + return b.blobHandler.GetBlobDocumentTree(uid, docid) +} diff --git a/internal/ui/claims.go b/internal/ui/claims.go index fc5bba66..d563cac5 100644 --- a/internal/ui/claims.go +++ b/internal/ui/claims.go @@ -6,6 +6,8 @@ import "github.com/golang-jwt/jwt/v4" type WebUserClaims struct { UserID string `json:"UserID"` BrowserID string `json:"BrowserID"` + SuBy string `json:"SuBy,omitempty"` + AllowSu bool `json:"AllowSu,omitempty"` Email string Scopes string `json:"scopes,omitempty"` Roles []string diff --git a/internal/ui/doc_tree.go b/internal/ui/doc_tree.go new file mode 100644 index 00000000..1e0d6e08 --- /dev/null +++ b/internal/ui/doc_tree.go @@ -0,0 +1,46 @@ +package ui + +import ( + "github.com/ddvk/rmfakecloud/internal/ui/methods" + "github.com/ddvk/rmfakecloud/internal/ui/templates" + "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" +) + +// documentTreeFromBlob builds the web UI tree from the hash-tree (sync 1.5+ storage), +// merging builtin templates/methods. Used for both sync backends so types and previews match. +func documentTreeFromBlob(blobHandler blobHandler, uid string) (*viewmodel.DocumentTree, error) { + hashTree, err := blobHandler.GetCachedTree(uid) + if err != nil { + return nil, err + } + tree := viewmodel.DocTreeFromHashTree(hashTree) + tDir := templates.BuiltinTemplatesDirectory() + if len(tree.Templates) > 0 { + if d, ok := tree.Templates[0].(*viewmodel.Directory); ok { + tDir.Entries = append(tDir.Entries, d.Entries...) + } + } + tree.Templates = []viewmodel.Entry{tDir} + mDir := methods.BuiltinMethodsDirectory() + if len(tree.Methods) > 0 { + if d, ok := tree.Methods[0].(*viewmodel.Directory); ok { + mDir.Entries = append(mDir.Entries, d.Entries...) + } + } + tree.Methods = []viewmodel.Entry{mDir} + for _, e := range tDir.Entries { + if doc, ok := e.(*viewmodel.Document); ok { + if o, err := blobHandler.GetDocumentOrientation(uid, doc.ID); err == nil { + doc.Orientation = o + } + } + } + for _, e := range mDir.Entries { + if doc, ok := e.(*viewmodel.Document); ok { + if o, err := blobHandler.GetDocumentOrientation(uid, doc.ID); err == nil { + doc.Orientation = o + } + } + } + return tree, nil +} diff --git a/internal/ui/epub_website.go b/internal/ui/epub_website.go new file mode 100644 index 00000000..b5c2df5a --- /dev/null +++ b/internal/ui/epub_website.go @@ -0,0 +1,231 @@ +package ui + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "io" + "net/http" + "path" + "path/filepath" + "strings" + + "github.com/ddvk/rmfakecloud/internal/common" + "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/epub" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +const ( + containerPath = "META-INF/container.xml" +) + +type containerRootfile struct { + FullPath string `xml:"full-path,attr"` +} + +type containerRootfiles struct { + Rootfile containerRootfile `xml:"rootfile"` +} + +type containerXML struct { + Rootfiles containerRootfiles `xml:"rootfiles"` +} + +type opfItem struct { + ID string `xml:"id,attr"` + Href string `xml:"href,attr"` +} + +type opfManifest struct { + Items []opfItem `xml:"item"` +} + +type opfItemref struct { + IDRef string `xml:"idref,attr"` +} + +type opfSpine struct { + ItemRefs []opfItemref `xml:"itemref"` +} + +type opfPackage struct { + Manifest opfManifest `xml:"manifest"` + Spine opfSpine `xml:"spine"` +} + +var epubContentTypes = map[string]string{ + ".html": "text/html", ".xhtml": "text/html", ".htm": "text/html", + ".css": "text/css", + ".svg": "image/svg+xml", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp", + ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", + ".mp3": "audio/mpeg", ".mp4": "video/mp4", + ".ncx": "application/x-dtbncx+xml", + ".opf": "application/oebps-package+xml", +} + +func getEpubFirstSpinePath(zr *zip.Reader) (string, error) { + containerFile, err := openZipPath(zr, containerPath) + if err != nil { + return "", err + } + defer containerFile.Close() + containerData, err := io.ReadAll(containerFile) + if err != nil { + return "", err + } + var c containerXML + if err := xml.Unmarshal(containerData, &c); err != nil { + return "", err + } + opfPath := strings.TrimSpace(c.Rootfiles.Rootfile.FullPath) + if opfPath == "" { + return "", nil + } + opfFile, err := openZipPath(zr, opfPath) + if err != nil { + return "", err + } + defer opfFile.Close() + opfData, err := io.ReadAll(opfFile) + if err != nil { + return "", err + } + var pkg opfPackage + if err := xml.Unmarshal(opfData, &pkg); err != nil { + return "", err + } + manifestByID := make(map[string]string) + for _, it := range pkg.Manifest.Items { + manifestByID[it.ID] = it.Href + } + if len(pkg.Spine.ItemRefs) == 0 { + return "", nil + } + firstID := pkg.Spine.ItemRefs[0].IDRef + href, ok := manifestByID[firstID] + if !ok || href == "" { + return "", nil + } + opfDir := filepath.Dir(opfPath) + if opfDir == "." { + return strings.TrimLeft(href, "/"), nil + } + // href is relative to OPF directory; normalize to zip path + joined := filepath.Join(opfDir, href) + return filepath.ToSlash(joined), nil +} + +func openZipPath(zr *zip.Reader, name string) (io.ReadCloser, error) { + name = strings.TrimPrefix(filepath.ToSlash(path.Clean(name)), "/") + if strings.HasPrefix(name, "..") { + return nil, nil + } + for _, f := range zr.File { + entry := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(f.Name)), "/") + if entry == name { + return f.Open() + } + } + return nil, nil +} + +func getEpubContentType(name string) string { + ext := strings.ToLower(path.Ext(name)) + if ct, ok := epubContentTypes[ext]; ok { + return ct + } + return "application/octet-stream" +} + +// getDocumentEpub serves the EPUB as a website: unpacked files by path, redirect / to first spine item. +func (app *ReactAppWrapper) getDocumentEpub(c *gin.Context) { + uid := userID(c) + docid := common.ParamS(docIDParam, c) + pathParam := c.Param("path") + pathParam = strings.TrimPrefix(pathParam, "/") + pathParam = filepath.ToSlash(path.Clean(pathParam)) + if strings.HasPrefix(pathParam, "..") { + c.AbortWithStatus(http.StatusBadRequest) + return + } + + backend := app.getBackend(c) + + // Path "manifest" returns EPUB spine/manifest as JSON (mymod API). + if pathParam == "manifest" { + type epubManifestBackend interface { + GetEpubManifest(uid, docid string) (*epub.Manifest, error) + } + if eb, ok := backend.(epubManifestBackend); ok { + manifest, err := eb.GetEpubManifest(uid, docid) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, manifest) + return + } + c.AbortWithStatus(http.StatusNotFound) + return + } + + rc, err := backend.Export(uid, docid, "epub", storage.ExportWithAnnotations) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer rc.Close() + + body, err := io.ReadAll(rc) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + if pathParam == "" || pathParam == "." { + first, err := getEpubFirstSpinePath(zr) + if err != nil || first == "" { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.Redirect(http.StatusFound, "./"+first) + return + } + + var found *zip.File + for _, f := range zr.File { + entry := filepath.ToSlash(filepath.Clean(f.Name)) + entry = strings.TrimPrefix(entry, "/") + if entry == pathParam { + found = f + break + } + } + if found == nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + r, err := found.Open() + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer r.Close() + contentType := getEpubContentType(found.Name) + c.Header("Content-Type", contentType) + c.Header("Cache-Control", "private, max-age=300") + _, _ = io.Copy(c.Writer, r) +} diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 54c5b579..53a5982d 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -4,14 +4,22 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" + "path" + "sort" + "strconv" + "strings" "time" "github.com/ddvk/rmfakecloud/internal/common" "github.com/ddvk/rmfakecloud/internal/integrations" "github.com/ddvk/rmfakecloud/internal/model" "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/epub" "github.com/ddvk/rmfakecloud/internal/storage/models" + "github.com/ddvk/rmfakecloud/internal/ui/methods" + "github.com/ddvk/rmfakecloud/internal/ui/templates" "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v4" @@ -23,8 +31,10 @@ import ( const ( userIDContextKey = "userID" browserIDContextKey = "browserID" + suByContextKey = "suBy" isSync15Key = "sync15" docIDParam = "docid" + blobIDParam = "blobid" intIDParam = "intid" uiLogger = "[ui] " ui10 = " [10] " @@ -129,32 +139,12 @@ func (app *ReactAppWrapper) login(c *gin.Context) { c.AbortWithStatus(http.StatusUnauthorized) return } - - scopes := "" - if user.Sync15 { - scopes = isSync15Key - } - expiresAfter := 24 * time.Hour - expires := time.Now().Add(expiresAfter) - claims := &WebUserClaims{ - UserID: user.ID, - BrowserID: uuid.NewString(), - Email: user.Email, - Scopes: scopes, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(expires), - Issuer: "rmFake WEB", - Audience: []string{WebUsage}, - }, - } - if user.IsAdmin { - claims.Roles = []string{AdminRole} - } else { - claims.Roles = []string{"User"} + user.LastLoginAt = time.Now() + if err := app.userStorer.UpdateUser(user); err != nil { + log.Warn(uiLogger, "persist last login: ", err) } - tokenString, err := common.SignClaims(claims, app.cfg.JWTSecretKey) - + tokenString, expiresAfter, err := app.issueWebTokenForUser(user, uuid.NewString(), false, "") if err != nil { log.Error(err) c.AbortWithStatus(http.StatusInternalServerError) @@ -232,8 +222,8 @@ func (app *ReactAppWrapper) newCode(c *gin.Context) { c.AbortWithStatusJSON(http.StatusInternalServerError, viewmodel.NewErrorResponse("Unable to generate new code")) return } - - c.JSON(http.StatusOK, code) + expiresAt, _ := app.codeConnector.CodeStatus(user.ID) + c.JSON(http.StatusOK, gin.H{"code": code, "expiresAt": expiresAt.Unix()}) } func (app *ReactAppWrapper) getBackend(c *gin.Context) backend { @@ -262,14 +252,15 @@ func (app *ReactAppWrapper) listDocuments(c *gin.Context) { } c.JSON(http.StatusOK, tree) } + func (app *ReactAppWrapper) getDocument(c *gin.Context) { uid := userID(c) docid := common.ParamS(docIDParam, c) - exportType := c.DefaultQuery("type", "pdf") + exportType := "pdf" var exportOption storage.ExportOption = 0 - log.Info("exporting ", docid, " as ", exportType) + log.Info("exporting ", docid) backend := app.getBackend(c) reader, err := backend.Export(uid, docid, exportType, exportOption) @@ -280,28 +271,38 @@ func (app *ReactAppWrapper) getDocument(c *gin.Context) { } defer reader.Close() - - if exportType == "rmdoc" { - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.rmdoc\"", docid)) - } - - c.DataFromReader(http.StatusOK, -1, "application/octet-stream", reader, nil) + // Raw PDF bytes; filename uses visible document name when available. + filename := backend.PDFInlineFilename(uid, docid) + c.Header("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename)) + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, -1, "application/pdf", reader, nil) } func (app *ReactAppWrapper) getDocumentMetadata(c *gin.Context) { uid := userID(c) docid := common.ParamS(docIDParam, c) - // if err != nil { - // log.Error(err) - // c.AbortWithStatus(http.StatusInternalServerError) - // return - // } - log.Info(uid, docid) - c.JSON(http.StatusOK, "TODO") - + type metadataGetter interface { + GetDocumentMetadata(uid, docid string) (docType string, hasWritings bool, pageCount int, err error) + } + backend := app.getBackend(c) + mg, ok := backend.(metadataGetter) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + docType, hasWritings, pageCount, err := mg.GetDocumentMetadata(uid, docid) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, gin.H{ + "type": docType, + "hasWritings": hasWritings, + "pageCount": pageCount, + }) } -// move rename func (app *ReactAppWrapper) updateDocument(c *gin.Context) { upd := viewmodel.UpdateDoc{} if err := c.ShouldBindJSON(&upd); err != nil { @@ -320,6 +321,7 @@ func (app *ReactAppWrapper) updateDocument(c *gin.Context) { c.Status(http.StatusOK) } + func (app *ReactAppWrapper) deleteDocument(c *gin.Context) { uid := userID(c) docid := c.Param("docid") @@ -416,11 +418,18 @@ func (app *ReactAppWrapper) getAppUsers(c *gin.Context) { uilist := make([]viewmodel.User, 0) for _, u := range users { usr := viewmodel.User{ - ID: u.ID, - Email: u.Email, - Name: u.Name, - CreatedAt: u.CreatedAt, - IsAdmin: u.IsAdmin, + ID: u.ID, + Email: u.Email, + Name: u.Name, + CreatedAt: u.CreatedAt, + PasswordChangedAt: u.PasswordChangedAt, + LastLoginAt: u.LastLoginAt, + QuotaBytes: ptrInt64(u.QuotaBytes), + IsAdmin: u.IsAdmin, + } + usr.FileUsageBytes = app.userFileUsageBytes(u) + for _, d := range u.RegisteredDevices { + usr.RegisteredDevices = append(usr.RegisteredDevices, toVMRegisteredDevice(d)) } uilist = append(uilist, usr) } @@ -450,10 +459,17 @@ func (app *ReactAppWrapper) getUser(c *gin.Context) { } vmUser := &viewmodel.User{ - ID: user.ID, - Email: user.Email, - Name: user.Name, - CreatedAt: user.CreatedAt, + ID: user.ID, + Email: user.Email, + Name: user.Name, + CreatedAt: user.CreatedAt, + PasswordChangedAt: user.PasswordChangedAt, + LastLoginAt: user.LastLoginAt, + QuotaBytes: ptrInt64(user.QuotaBytes), + } + vmUser.FileUsageBytes = app.userFileUsageBytes(user) + for _, d := range user.RegisteredDevices { + vmUser.RegisteredDevices = append(vmUser.RegisteredDevices, toVMRegisteredDevice(d)) } for _, i := range user.Integrations { vmUser.Integrations = append(vmUser.Integrations, i.Name) @@ -487,6 +503,13 @@ func (app *ReactAppWrapper) updateUser(c *gin.Context) { if req.Email != "" { user.Email = req.Email } + if req.QuotaBytes != nil { + if *req.QuotaBytes < 0 { + badReq(c, "quotaBytes must be >= 0") + return + } + user.QuotaBytes = *req.QuotaBytes + } err = app.userStorer.UpdateUser(user) if err != nil { @@ -495,6 +518,7 @@ func (app *ReactAppWrapper) updateUser(c *gin.Context) { } c.Status(http.StatusAccepted) } + func (app *ReactAppWrapper) deleteUser(c *gin.Context) { uid := c.Param(useridParam) if uid == userID(c) { @@ -547,7 +571,7 @@ func (app *ReactAppWrapper) listIntegrations(c *gin.Context) { return } - c.JSON(http.StatusOK, user.Integrations) + c.JSON(http.StatusOK, app.effectiveIntegrationsForUser(user)) } func warnLocalfsEdition(c *gin.Context, int *model.IntegrationConfig) { @@ -568,6 +592,10 @@ func (app *ReactAppWrapper) createIntegration(c *gin.Context) { badReq(c, err.Error()) return } + if int.Shared && !IsAdmin(c) { + c.AbortWithStatusJSON(http.StatusForbidden, viewmodel.NewErrorResponse("only admins can create shared integrations")) + return + } if int.Provider == integrations.LocalfsProvider { int.ID = uuid.NewString() @@ -585,7 +613,11 @@ func (app *ReactAppWrapper) createIntegration(c *gin.Context) { } int.ID = uuid.NewString() - user.Integrations = append(user.Integrations, int) + if int.Shared { + user.SharedIntegrations = append(user.SharedIntegrations, int) + } else { + user.Integrations = append(user.Integrations, int) + } err = app.userStorer.UpdateUser(user) @@ -610,7 +642,7 @@ func (app *ReactAppWrapper) getIntegration(c *gin.Context) { return } - for _, integration := range user.Integrations { + for _, integration := range app.effectiveIntegrationsForUser(user) { if integration.ID == intid { c.JSON(http.StatusOK, integration) return @@ -647,6 +679,7 @@ func (app *ReactAppWrapper) updateIntegration(c *gin.Context) { for idx, integration := range user.Integrations { if integration.ID == intid { int.ID = integration.ID + int.Shared = false user.Integrations[idx] = int err = app.userStorer.UpdateUser(user) @@ -661,6 +694,23 @@ func (app *ReactAppWrapper) updateIntegration(c *gin.Context) { return } } + if IsAdmin(c) { + for idx, integration := range user.SharedIntegrations { + if integration.ID == intid { + int.ID = integration.ID + int.Shared = true + user.SharedIntegrations[idx] = int + err = app.userStorer.UpdateUser(user) + if err != nil { + log.Error("error updating user", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, int) + return + } + } + } c.AbortWithStatus(http.StatusNotFound) } @@ -693,6 +743,21 @@ func (app *ReactAppWrapper) deleteIntegration(c *gin.Context) { return } } + if IsAdmin(c) { + for idx, integration := range user.SharedIntegrations { + if integration.ID == intid { + user.SharedIntegrations = append(user.SharedIntegrations[:idx], user.SharedIntegrations[idx+1:]...) + err = app.userStorer.UpdateUser(user) + if err != nil { + log.Error("error updating user", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.Status(http.StatusAccepted) + return + } + } + } c.AbortWithStatus(http.StatusNotFound) } @@ -894,3 +959,520 @@ func (app *ReactAppWrapper) screenshareDeleteRoom(c *gin.Context) { c.Status(http.StatusNoContent) } +func suBy(c *gin.Context) string { + return c.GetString(suByContextKey) +} + +func (app *ReactAppWrapper) issueWebTokenForUser(user *model.User, browserID string, keepAdmin bool, suByUserID string) (string, time.Duration, error) { + if user == nil { + return "", 0, fmt.Errorf("user is nil") + } + scopes := "" + if user.Sync15 { + scopes = isSync15Key + } + expiresAfter := 24 * time.Hour + expires := time.Now().Add(expiresAfter) + claims := &WebUserClaims{ + UserID: user.ID, + BrowserID: browserID, + SuBy: suByUserID, + AllowSu: app.cfg.AllowSu, + Email: user.Email, + Scopes: scopes, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expires), + Issuer: "rmFake WEB", + Audience: []string{WebUsage}, + }, + } + if user.IsAdmin || keepAdmin { + // Keep Admin first to satisfy the current frontend role guard. + claims.Roles = []string{AdminRole, "User"} + } else { + claims.Roles = []string{"User"} + } + tokenString, err := common.SignClaims(claims, app.cfg.JWTSecretKey) + if err != nil { + return "", 0, err + } + return tokenString, expiresAfter, nil +} + +func (app *ReactAppWrapper) newCodeStatus(c *gin.Context) { + uid := userID(c) + expiresAt, valid := app.codeConnector.CodeStatus(uid) + if !valid { + c.JSON(http.StatusOK, gin.H{"valid": false}) + return + } + c.JSON(http.StatusOK, gin.H{"valid": true, "expiresAt": expiresAt.Unix()}) +} + +func (app *ReactAppWrapper) listRegisteredDevices(c *gin.Context) { + uid := userID(c) + user, err := app.userStorer.GetUser(uid) + if err != nil || user == nil { + log.Error(uiLogger, "list devices: ", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, viewmodel.NewErrorResponse("unable to load profile")) + return + } + out := make([]viewmodel.RegisteredDeviceEntry, 0, len(user.RegisteredDevices)) + for _, d := range user.RegisteredDevices { + out = append(out, toVMRegisteredDevice(d)) + } + sort.Slice(out, func(i, j int) bool { + return out[i].LastSeen > out[j].LastSeen + }) + c.JSON(http.StatusOK, viewmodel.RegisteredDevicesResponse{Devices: out}) +} + +func (app *ReactAppWrapper) reissueRegisteredDevice(c *gin.Context) { + if app.issueDeviceToken == nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, viewmodel.NewErrorResponse("device token signing not configured")) + return + } + var req viewmodel.ReissueDeviceRequest + if err := c.ShouldBindJSON(&req); err != nil { + badReq(c, err.Error()) + return + } + uid := userID(c) + user, err := app.userStorer.GetUser(uid) + if err != nil || user == nil { + log.Error(uiLogger, "reissue device: ", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, viewmodel.NewErrorResponse("unable to load profile")) + return + } + reg, ok := user.GetRegisteredDevice(req.DeviceID) + if !ok { + c.AbortWithStatusJSON(http.StatusNotFound, viewmodel.NewErrorResponse("device not registered for this account")) + return + } + desc := reg.DeviceDesc + if strings.TrimSpace(req.DeviceDesc) != "" { + desc = strings.TrimSpace(req.DeviceDesc) + } + token, err := app.issueDeviceToken(uid, req.DeviceID, desc) + if err != nil { + log.Error(uiLogger, "reissue device token: ", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, viewmodel.NewErrorResponse("could not issue token")) + return + } + user.UpsertRegisteredDevice(req.DeviceID, desc, req.DeviceLink) + if err := app.userStorer.UpdateUser(user); err != nil { + log.Warn(uiLogger, "reissue device persist: ", err) + } + c.JSON(http.StatusOK, viewmodel.ReissueDeviceResponse{Token: token}) +} + +func toVMRegisteredDevice(d model.RegisteredDevice) viewmodel.RegisteredDeviceEntry { + e := viewmodel.RegisteredDeviceEntry{ + DeviceID: d.DeviceID, + DeviceDesc: d.DeviceDesc, + DeviceLink: d.DeviceLink, + Make: d.Make, + Model: d.Model, + Year: d.Year, + } + if !d.RegisteredAt.IsZero() { + e.RegisteredAt = d.RegisteredAt.UTC().Format(time.RFC3339) + } + if !d.LastSeen.IsZero() { + e.LastSeen = d.LastSeen.UTC().Format(time.RFC3339) + } + return e +} + +func (app *ReactAppWrapper) getTemplate(c *gin.Context) { + uid := userID(c) + docid := common.ParamS(docIDParam, c) + + backend := app.getBackend(c) + // only sync15 backends have templates + type templateGetter interface { + GetTemplate(uid, docid string) (io.ReadCloser, error) + } + tg, ok := backend.(templateGetter) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + reader, err := tg.GetTemplate(uid, docid) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusNotFound) + return + } + defer reader.Close() + + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", docid+storage.TemplateFileExt)) + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, -1, "application/octet-stream", reader, nil) +} + +func (app *ReactAppWrapper) getBuiltinTemplate(c *gin.Context) { + id := c.Param("id") + svg := templates.GetSVG(id) + if svg == "" { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.Header("Content-Type", "image/svg+xml") + c.Header("X-Content-Type-Options", "nosniff") + c.String(http.StatusOK, svg) +} + +func (app *ReactAppWrapper) getBuiltinMethod(c *gin.Context) { + id := c.Param("id") + svg := methods.GetSVG(id) + if svg == "" { + c.AbortWithStatus(http.StatusNotFound) + return + } + c.Header("Content-Type", "image/svg+xml") + c.Header("X-Content-Type-Options", "nosniff") + c.String(http.StatusOK, svg) +} + +func (app *ReactAppWrapper) getDocumentPage(c *gin.Context) { + uid := userID(c) + docid := common.ParamS(docIDParam, c) + pagenumStr := c.Param("pagenum") + pagenum, err := strconv.Atoi(pagenumStr) + if err != nil || pagenum < 1 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + type pagePNGExporter interface { + ExportPagePNG(uid, docid string, pageNum int) (io.ReadCloser, error) + } + backend := app.getBackend(c) + pe, ok := backend.(pagePNGExporter) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + reader, err := pe.ExportPagePNG(uid, docid, pagenum) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer reader.Close() + c.Header("Content-Type", "image/png") + c.Header("Content-Disposition", fmt.Sprintf("inline; filename=%q", fmt.Sprintf("page-%d.png", pagenum))) + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, -1, "image/png", reader, nil) +} + +func (app *ReactAppWrapper) getDocumentPageBackground(c *gin.Context) { + uid := userID(c) + docid := common.ParamS(docIDParam, c) + pagenumStr := c.Param("pagenum") + pagenum, err := strconv.Atoi(pagenumStr) + if err != nil || pagenum < 1 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + type pageBackgroundExporter interface { + ExportPageBackgroundPNG(uid, docid string, pageNum int) (io.ReadCloser, error) + } + backend := app.getBackend(c) + pe, ok := backend.(pageBackgroundExporter) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + reader, err := pe.ExportPageBackgroundPNG(uid, docid, pagenum) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer reader.Close() + c.Header("Content-Type", "image/png") + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, -1, "image/png", reader, nil) +} + +func (app *ReactAppWrapper) getDocumentPageThumb(c *gin.Context) { + uid := userID(c) + docid := common.ParamS(docIDParam, c) + pagenumStr := c.Param("pagenum") + pagenum, err := strconv.Atoi(pagenumStr) + if err != nil || pagenum < 1 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + type pageThumbExporter interface { + ExportPageThumbPNG(uid, docid string, pageNum int) (io.ReadCloser, error) + } + backend := app.getBackend(c) + pe, ok := backend.(pageThumbExporter) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + reader, err := pe.ExportPageThumbPNG(uid, docid, pagenum) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer reader.Close() + c.Header("Content-Type", "image/png") + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, -1, "image/png", reader, nil) +} + +func (app *ReactAppWrapper) getDocumentPageOverlay(c *gin.Context) { + uid := userID(c) + docid := common.ParamS(docIDParam, c) + pagenumStr := c.Param("pagenum") + pagenum, err := strconv.Atoi(pagenumStr) + if err != nil || pagenum < 1 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + type pageOverlayExporter interface { + ExportPageOverlaySVG(uid, docid string, pageNum int) (io.ReadCloser, error) + } + backend := app.getBackend(c) + pe, ok := backend.(pageOverlayExporter) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + reader, err := pe.ExportPageOverlaySVG(uid, docid, pagenum) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer reader.Close() + c.Header("Content-Type", "image/svg+xml") + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, -1, "image/svg+xml", reader, nil) +} + +func (app *ReactAppWrapper) getEpubPath(c *gin.Context) { + uid := userID(c) + docid := common.ParamS(docIDParam, c) + pathParam := c.Param("path") + pathParam = strings.TrimPrefix(path.Clean("/"+pathParam), "/") + if pathParam == "" || strings.Contains(pathParam, "..") { + c.AbortWithStatus(http.StatusBadRequest) + return + } + type epubBackend interface { + GetEpubManifest(uid, docid string) (*epub.Manifest, error) + GetEpubFile(uid, docid, filePath string) (io.ReadCloser, string, error) + GetEpubCoverThumb(uid, docid string) (io.ReadCloser, string, error) + } + backend := app.getBackend(c) + eb, ok := backend.(epubBackend) + if !ok { + c.AbortWithStatus(http.StatusNotFound) + return + } + if pathParam == "cover-thumb" { + reader, contentType, err := eb.GetEpubCoverThumb(uid, docid) + if err != nil { + log.Debug("epub cover-thumb: ", err) + c.AbortWithStatus(http.StatusNotFound) + return + } + defer reader.Close() + c.Header("Content-Type", contentType) + c.Header("Cache-Control", "public, max-age=86400") + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, -1, contentType, reader, nil) + return + } + if pathParam == "manifest" { + manifest, err := eb.GetEpubManifest(uid, docid) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, manifest) + return + } + reader, contentType, err := eb.GetEpubFile(uid, docid, pathParam) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusNotFound) + return + } + defer reader.Close() + c.Header("Content-Type", contentType) + c.Header("X-Content-Type-Options", "nosniff") + c.DataFromReader(http.StatusOK, -1, contentType, reader, nil) +} + +func (app *ReactAppWrapper) getRawBlob(c *gin.Context) { + uid := userID(c) + blobid := common.ParamS(blobIDParam, c) + backend := app.getBackend(c) + reader, err := backend.GetRawBlob(uid, blobid) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer reader.Close() + c.DataFromReader(http.StatusOK, -1, "application/octet-stream", reader, nil) +} + +func (app *ReactAppWrapper) getBlobTree(c *gin.Context) { + uid := userID(c) + docid := common.ParamS(docIDParam, c) + backend := app.getBackend(c) + files, err := backend.GetBlobDocumentTree(uid, docid) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, files) +} + +func (app *ReactAppWrapper) userFileUsageBytes(user *model.User) int64 { + if user == nil { + return 0 + } + backend, ok := app.backends[userSyncVersion(user)] + if !ok || backend == nil { + return 0 + } + tree, err := backend.GetDocumentTree(user.ID) + if err != nil || tree == nil { + if err != nil { + log.Warn(uiLogger, "file usage: ", user.ID, ": ", err) + } + return 0 + } + total := int64(0) + total += sumEntrySizes(tree.Entries) + total += sumEntrySizes(tree.Trash) + return total +} + +func userSyncVersion(user *model.User) common.SyncVersion { + if user != nil && user.Sync15 { + return common.Sync15 + } + return common.Sync10 +} + +func sumEntrySizes(entries []viewmodel.Entry) int64 { + total := int64(0) + for _, entry := range entries { + switch x := entry.(type) { + case *viewmodel.Document: + total += x.Size + case *viewmodel.Directory: + total += sumEntrySizes(x.Entries) + } + } + return total +} + +func ptrInt64(v int64) *int64 { + return &v +} + +func (app *ReactAppWrapper) suUser(c *gin.Context) { + if app.cfg == nil || !app.cfg.AllowSu { + c.AbortWithStatusJSON(http.StatusForbidden, viewmodel.NewErrorResponse("su is disabled (set RMFAKECLOUD_ALLOW_SU=true)")) + return + } + var req viewmodel.SuRequest + if err := c.ShouldBindJSON(&req); err != nil { + badReq(c, err.Error()) + return + } + target, err := app.userStorer.GetUser(req.UserID) + if err != nil || target == nil { + c.AbortWithStatusJSON(http.StatusNotFound, viewmodel.NewErrorResponse("target user not found")) + return + } + + // su switches document context to target user while preserving admin powers + // so admins can continue switching across users to inspect files. + target.LastLoginAt = time.Now() + if err := app.userStorer.UpdateUser(target); err != nil { + log.Warn(uiLogger, "persist su last login: ", err) + } + rootAdmin := userID(c) + if prior := suBy(c); prior != "" { + rootAdmin = prior + } + suByUserID := "" + if target.ID != rootAdmin { + suByUserID = rootAdmin + } + tokenString, expiresAfter, err := app.issueWebTokenForUser(target, uuid.NewString(), true, suByUserID) + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(cookieName, tokenString, int(expiresAfter.Seconds()), "/", "", app.cfg.HTTPSCookie, true) + c.String(http.StatusOK, tokenString) +} + +func (app *ReactAppWrapper) leaveSu(c *gin.Context) { + rootAdmin := suBy(c) + if rootAdmin == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, viewmodel.NewErrorResponse("not in su session")) + return + } + adminUser, err := app.userStorer.GetUser(rootAdmin) + if err != nil || adminUser == nil { + c.AbortWithStatusJSON(http.StatusNotFound, viewmodel.NewErrorResponse("original admin not found")) + return + } + tokenString, expiresAfter, err := app.issueWebTokenForUser(adminUser, uuid.NewString(), false, "") + if err != nil { + log.Error(err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(cookieName, tokenString, int(expiresAfter.Seconds()), "/", "", app.cfg.HTTPSCookie, true) + c.String(http.StatusOK, tokenString) +} + +func (app *ReactAppWrapper) effectiveIntegrationsForUser(user *model.User) []model.IntegrationConfig { + if user == nil { + return nil + } + out := make([]model.IntegrationConfig, 0, len(user.Integrations)+len(user.SharedIntegrations)) + for _, cfg := range user.Integrations { + cfg.Shared = false + out = append(out, cfg) + } + users, err := app.userStorer.GetUsers() + if err != nil { + return out + } + for _, u := range users { + if u == nil || !u.IsAdmin { + continue + } + for _, cfg := range u.SharedIntegrations { + cfg.Shared = true + if user.IsAdmin && u.ID == user.ID { + cfg.ReadOnly = cfg.ReadOnly + } else { + cfg.ReadOnly = true + } + out = append(out, cfg) + } + } + return out +} diff --git a/internal/ui/methods/methods.go b/internal/ui/methods/methods.go new file mode 100644 index 00000000..d9cd7a04 --- /dev/null +++ b/internal/ui/methods/methods.go @@ -0,0 +1,131 @@ +package methods + +import ( + "fmt" + "math" + "time" + + "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" +) + +// reMarkable 2 nominal dimensions (points at 1404x1872) +const width = 1404 +const height = 1872 + +var builtins = []struct { + id string + name string + svg string +}{ + {id: "cornell", name: "Cornell Notes", svg: cornellSVG()}, + {id: "outline", name: "Outline", svg: outlineSVG()}, + {id: "mindmap", name: "Mind Map", svg: mindmapSVG()}, + {id: "flowchart", name: "Flowchart", svg: flowchartSVG()}, + {id: "checklist", name: "Checklist", svg: checklistSVG()}, +} + +func cornellSVG() string { + // Left column ~25%, right margin, bottom summary area + cueW := width / 4 + summaryH := height / 5 + return fmt.Sprintf(` + + + +Cues +Notes +Summary +`, + width, height, width, height, + cueW, cueW, height, + height-summaryH, width, height-summaryH, + cueW/2-20, cueW+40, + height-summaryH+32, + ) +} + +func outlineSVG() string { + indent := 80 + lineH := 52 + lines := "" + for i := 0; i < 28; i++ { + y := 60 + i*lineH + level := i % 4 + x := 40 + level*indent + lines += fmt.Sprintf(``, x, y+20, width-40, y+20) + } + return fmt.Sprintf(`%s`, width, height, width, height, lines) +} + +func mindmapSVG() string { + cx, cy := width/2, height/2 + r := 120 + nodes := 6 + lines := "" + for i := 0; i < nodes; i++ { + angle := float64(i) * (2 * math.Pi / float64(nodes)) + x := int(float64(cx) + float64(r)*math.Cos(angle)) + y := int(float64(cy) + float64(r)*math.Sin(angle)) + lines += fmt.Sprintf(``, cx, cy, x, y) + lines += fmt.Sprintf(``, x, y) + } + lines += fmt.Sprintf(``, cx, cy) + return fmt.Sprintf(`%sTopic`, width, height, width, height, lines, cx, cy+8) +} + +func flowchartSVG() string { + // Simple flowchart: start -> process -> decision -> end + bw, bh := 200, 56 + x1, y := width/2-bw/2, 200 + x2 := width/2 - 80 + x3 := width/2 - 40 + lines := fmt.Sprintf(``, x1, y, bw, bh) + lines += fmt.Sprintf(``, x2, 320, bh) + lines += fmt.Sprintf(``, x3, 420, x3-50, 500, x3+50, 500) + lines += fmt.Sprintf(``, width/2-80, 160, bh) + lines += `` + lines += `` + lines += `` + return fmt.Sprintf(`%s`, width, height, width, height, lines) +} + +func checklistSVG() string { + lines := "" + step := 56 + for y := 80; y < height-80; y += step { + lines += fmt.Sprintf(``, y) + lines += fmt.Sprintf(``, y+14, width-40, y+14) + } + return fmt.Sprintf(`%s`, width, height, width, height, lines) +} + +// BuiltinMethodsDirectory returns a Directory entry "Methods" with method documents as children. +func BuiltinMethodsDirectory() *viewmodel.Directory { + children := make([]viewmodel.Entry, 0, len(builtins)) + for _, b := range builtins { + children = append(children, &viewmodel.Document{ + ID: b.id, + Name: b.name, + DocumentType: "method", + LastModified: time.Time{}, + Size: 0, + }) + } + return &viewmodel.Directory{ + ID: "methods", + Name: "rm Methods", + Entries: children, + LastModified: time.Time{}, + IsFolder: true, + } +} + +// GetSVG returns the SVG content for a method ID, or empty string if not found. +func GetSVG(id string) string { + for _, b := range builtins { + if b.id == id { + return b.svg + } + } + return "" +} diff --git a/internal/ui/middleware.go b/internal/ui/middleware.go index ec457b78..e0364216 100644 --- a/internal/ui/middleware.go +++ b/internal/ui/middleware.go @@ -70,6 +70,9 @@ func (app *ReactAppWrapper) authMiddleware() gin.HandlerFunc { brid := claims.BrowserID c.Set(browserIDContextKey, brid) + if claims.SuBy != "" { + c.Set(suByContextKey, claims.SuBy) + } for _, r := range claims.Roles { if r == AdminRole { c.Set(AdminRole, true) diff --git a/internal/ui/routes.go b/internal/ui/routes.go index a463f7f2..4ef51473 100644 --- a/internal/ui/routes.go +++ b/internal/ui/routes.go @@ -41,6 +41,10 @@ func (app *ReactAppWrapper) RegisterRoutes(router *gin.Engine) { c.SetCookie(cookieName, "/", -1, "", "", false, true) c.Status(http.StatusOK) }) + r.POST("logout", func(c *gin.Context) { + c.SetCookie(cookieName, "/", -1, "", "", false, true) + c.Status(http.StatusOK) + }) //with authentication auth := r.Group("") auth.Use(app.authMiddleware()) @@ -86,6 +90,20 @@ func (app *ReactAppWrapper) RegisterRoutes(router *gin.Engine) { auth.GET("integrations/:intid/metadata/*path", app.getMetadataIntegration) auth.GET("integrations/:intid/download/*path", app.downloadThroughIntegration) + auth.GET("newcode/status", app.newCodeStatus) + auth.GET("devices", app.listRegisteredDevices) + auth.POST("devices/reissue", app.reissueRegisteredDevice) + auth.GET("documents/:docid/template", app.getTemplate) + auth.GET("templates/:id", app.getBuiltinTemplate) + auth.GET("methods/:id", app.getBuiltinMethod) + auth.GET("documents/:docid/page/:pagenum", app.getDocumentPage) + auth.GET("documents/:docid/page/:pagenum/background", app.getDocumentPageBackground) + auth.GET("documents/:docid/page/:pagenum/thumb", app.getDocumentPageThumb) + auth.GET("documents/:docid/page/:pagenum/overlay.svg", app.getDocumentPageOverlay) + auth.GET("documents/:docid/epub/*path", app.getEpubPath) + auth.GET("blobs/:blobid", app.getRawBlob) + auth.GET("documents/:docid/blobs", app.getBlobTree) + auth.POST("su/leave", app.leaveSu) ss := auth.Group("screenshare") ss.GET("room", app.screenshareJoinActive) ss.GET("room/:roomId", app.screenshareGetRoom) @@ -96,6 +114,7 @@ func (app *ReactAppWrapper) RegisterRoutes(router *gin.Engine) { //admin admin := auth.Group("") admin.Use(app.adminMiddleware()) + admin.POST("su", app.suUser) admin.GET("users/:userid", app.getUser) admin.DELETE("users/:userid", app.deleteUser) admin.PUT("users", app.updateUser) diff --git a/internal/ui/templates/templates.go b/internal/ui/templates/templates.go new file mode 100644 index 00000000..cadf45fb --- /dev/null +++ b/internal/ui/templates/templates.go @@ -0,0 +1,106 @@ +package templates + +import ( + "fmt" + "time" + + "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" +) + +// reMarkable 2 nominal dimensions (points at 1404x1872) +const width = 1404 +const height = 1872 + +var builtins = []struct { + id string + name string + svg string +}{ + { + id: "blank", + name: "Blank", + svg: blankSVG(), + }, + { + id: "lined", + name: "Lined", + svg: linedSVG(), + }, + { + id: "grid", + name: "Grid", + svg: gridSVG(), + }, + { + id: "dotted", + name: "Dotted", + svg: dottedSVG(), + }, +} + +func blankSVG() string { + return fmt.Sprintf(``, width, height, width, height) +} + +func linedSVG() string { + lines := "" + step := 48 + for y := step; y < height; y += step { + lines += fmt.Sprintf(``, y, width, y) + } + return fmt.Sprintf(`%s`, width, height, width, height, lines) +} + +func gridSVG() string { + lines := "" + step := 36 + for x := step; x < width; x += step { + lines += fmt.Sprintf(``, x, x, height) + } + for y := step; y < height; y += step { + lines += fmt.Sprintf(``, y, width, y) + } + return fmt.Sprintf(`%s`, width, height, width, height, lines) +} + +func dottedSVG() string { + dots := "" + step := 24 + for y := step; y < height; y += step { + for x := step; x < width; x += step { + dots += fmt.Sprintf(``, x, y) + } + } + return fmt.Sprintf(`%s`, width, height, width, height, dots) +} + +// BuiltinTemplatesDirectory returns a Directory entry "Templates" with template documents as children. +func BuiltinTemplatesDirectory() *viewmodel.Directory { + children := make([]viewmodel.Entry, 0, len(builtins)) + for _, b := range builtins { + children = append(children, &viewmodel.Document{ + ID: b.id, + Name: b.name, + DocumentType: "template", + LastModified: time.Time{}, + Size: 0, + }) + } + return &viewmodel.Directory{ + ID: "templates", + Name: "Templates", + Entries: children, + LastModified: time.Time{}, + IsFolder: true, + } +} + +// GetSVG returns the SVG content for a template ID, or empty string if not found. +func GetSVG(id string) string { + for _, b := range builtins { + if b.id == id { + return b.svg + } + } + return "" +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6a7d6c98..f2ab3729 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -14,6 +14,7 @@ import ( "github.com/ddvk/rmfakecloud/internal/messages" "github.com/ddvk/rmfakecloud/internal/screenshare" "github.com/ddvk/rmfakecloud/internal/storage" + "github.com/ddvk/rmfakecloud/internal/storage/epub" "github.com/ddvk/rmfakecloud/internal/storage/models" "github.com/ddvk/rmfakecloud/internal/ui/viewmodel" webui "github.com/ddvk/rmfakecloud/ui" @@ -23,14 +24,18 @@ import ( type backend interface { GetDocumentTree(uid string) (tree *viewmodel.DocumentTree, err error) Export(uid, doc, exporttype string, opt storage.ExportOption) (stream io.ReadCloser, err error) + PDFInlineFilename(uid, docid string) string CreateDocument(uid, name, parent string, stream io.Reader) (doc *storage.Document, err error) CreateFolder(uid, name, parent string) (doc *storage.Document, err error) UpdateDocument(uid, docID, name, parent string) (err error) DeleteDocument(uid, docID string) (err error) Sync(uid string) + GetRawBlob(uid, hash string) (stream io.ReadCloser, err error) + GetBlobDocumentTree(uid, docid string) (m map[string]string, err error) } type codeGenerator interface { NewCode(string) (string, error) + CodeStatus(uid string) (expiresAt time.Time, valid bool) } type documentHandler interface { @@ -50,7 +55,19 @@ type blobHandler interface { DeleteBlobDocument(uid, docID string) (err error) CreateBlobFolder(uid, name, parent string) (doc *storage.Document, err error) Export(uid, docid string) (io.ReadCloser, error) - ExportRmDoc(uid, docid string) (io.ReadCloser, error) + GetTemplate(uid, docid string) (io.ReadCloser, error) + GetDocumentMetadata(uid, docid string) (docType string, hasWritings bool, pageCount int, err error) + GetDocumentOrientation(uid, docid string) (orientation string, err error) + ExportPagePNG(uid, docid string, pageNum int) (io.ReadCloser, error) + ExportPageBackgroundPNG(uid, docid string, pageNum int) (io.ReadCloser, error) + ExportPageThumbPNG(uid, docid string, pageNum int) (io.ReadCloser, error) + ExportPageOverlaySVG(uid, docid string, pageNum int) (io.ReadCloser, error) + GetEpubManifest(uid, docid string) (*epub.Manifest, error) + GetEpubFile(uid, docid, filePath string) (io.ReadCloser, string, error) + GetEpubCoverThumb(uid, docid string) (io.ReadCloser, string, error) + PDFInlineFilename(uid, docid string) string + GetRawBlob(uid, hash string) (stream io.ReadCloser, err error) + GetBlobDocumentTree(uid, docid string) (m map[string]string, err error) } type notificationHub interface { @@ -60,6 +77,9 @@ type notificationHub interface { Sync(uid string) error } +// DeviceTokenIssuer signs a device API JWT (same claims as POST /token/json/2/device/new). +type DeviceTokenIssuer func(uid, deviceID, deviceDesc string) (token string, err error) + type mqttBridge interface { PublishSignaling(userID, clientID string, payload []byte) HasConnectedClient(userID string) bool @@ -67,16 +87,17 @@ type mqttBridge interface { // ReactAppWrapper encapsulates an app type ReactAppWrapper struct { - fs http.FileSystem - prefix string - cfg *config.Config - userStorer storage.UserStorer - codeConnector codeGenerator - h *hub.Hub - passcodeStore passcodestore.Store - backends map[common.SyncVersion]backend - roomManager *screenshare.RoomManager - mqtt mqttBridge + fs http.FileSystem + prefix string + cfg *config.Config + userStorer storage.UserStorer + codeConnector codeGenerator + h *hub.Hub + passcodeStore passcodestore.Store + backends map[common.SyncVersion]backend + issueDeviceToken DeviceTokenIssuer + roomManager *screenshare.RoomManager + mqtt mqttBridge } // hack for serving index.html on / @@ -91,6 +112,7 @@ func New(cfg *config.Config, pcStore passcodestore.Store, docHandler documentHandler, blobHandler blobHandler, + issueDeviceToken DeviceTokenIssuer, roomManager *screenshare.RoomManager, mqttBroker mqttBridge) *ReactAppWrapper { @@ -104,16 +126,18 @@ func New(cfg *config.Config, } backend10 := &backend10{ documentHandler: docHandler, + blobHandler: blobHandler, hub: h, } staticWrapper := ReactAppWrapper{ - fs: common.NewLastModifiedFS(http.FS(sub), time.Now()), - prefix: "/assets", - cfg: cfg, - userStorer: userStorer, - codeConnector: codeConnector, - h: h, - passcodeStore: pcStore, + fs: common.NewLastModifiedFS(http.FS(sub), time.Now()), + prefix: "/assets", + cfg: cfg, + userStorer: userStorer, + codeConnector: codeConnector, + h: h, + passcodeStore: pcStore, + issueDeviceToken: issueDeviceToken, backends: map[common.SyncVersion]backend{ common.Sync10: backend10, common.Sync15: backend15, diff --git a/internal/ui/viewmodel/models.go b/internal/ui/viewmodel/models.go index d58ed65f..748e5ce9 100644 --- a/internal/ui/viewmodel/models.go +++ b/internal/ui/viewmodel/models.go @@ -35,16 +35,48 @@ type ChangeEmailForm struct { type ErrorResponse struct { Error string `json:"error"` } + func NewErrorResponse(errormsg string) ErrorResponse { - return ErrorResponse { + return ErrorResponse{ Error: errormsg, } } +// RegisteredDeviceEntry is a safe JSON view of a paired tablet (no secrets). +type RegisteredDeviceEntry struct { + DeviceID string `json:"deviceId"` + DeviceDesc string `json:"deviceDesc"` + DeviceLink string `json:"deviceLink,omitempty"` + Make string `json:"make,omitempty"` + Model string `json:"model,omitempty"` + Year string `json:"year,omitempty"` + RegisteredAt string `json:"registeredAt,omitempty"` + LastSeen string `json:"lastSeen,omitempty"` +} + +// RegisteredDevicesResponse lists devices for the logged-in user. +type RegisteredDevicesResponse struct { + Devices []RegisteredDeviceEntry `json:"devices"` +} + +// ReissueDeviceRequest asks for a new device JWT without a pairing code (web session only). +type ReissueDeviceRequest struct { + DeviceID string `json:"deviceId" binding:"required"` + DeviceDesc string `json:"deviceDesc"` + DeviceLink string `json:"deviceLink,omitempty"` +} + +// ReissueDeviceResponse returns the raw device token for the tablet. +type ReissueDeviceResponse struct { + Token string `json:"token"` +} + // DocumentTree a tree of documents type DocumentTree struct { - Entries []Entry - Trash []Entry + Entries []Entry + Trash []Entry + Templates []Entry // [ Directory ] for frontend; optional + Methods []Entry // [ Directory ] for frontend; synced + builtin merged in backend } type InternalDoc struct { @@ -57,6 +89,9 @@ type InternalDoc struct { CurrentPage int Parent string Size int64 + HasWritings bool + Orientation string // from .content: "portrait", "landscape", or "" + Pinned bool // starred in metadata } func makeFolder(d *InternalDoc) (entry *Directory) { @@ -75,35 +110,103 @@ func makeDocument(d *InternalDoc) (entry Entry) { Name: d.Name, LastModified: d.LastModified, DocumentType: d.FileType, + Collection: d.Type, Size: d.Size, + HasWritings: d.HasWritings, + Orientation: d.Orientation, + Pinned: d.Pinned, } return } -// DocTreeFromHashTree from hash tree +// methodsSource is the reMarkable metadata source for rm Methods. +const methodsSource = "com.remarkable.methods" + +// templateType is the collection type for Templates (device/synced templates). +const templateType = common.EntryType("TemplateType") + +// fileTypeFromDoc returns document type by file extension, .content fileType (when size > 4), or "notebook". +func fileTypeFromDoc(d *models.HashDoc) string { + if t := d.PayloadTypeFromFiles(); t != "" { + return t + } + if d.PayloadType != "" { + return d.PayloadType + } + return "notebook" +} + +// DocTreeFromHashTree from hash tree. Templates and Methods are separated into their own sections. func DocTreeFromHashTree(tree *models.HashTree) *DocumentTree { docs := make([]*InternalDoc, 0) + templateDocs := make([]*InternalDoc, 0) + methodDocs := make([]*InternalDoc, 0) for _, d := range tree.Docs { if d.Deleted { continue } - lastModified, err := models.ToTime(d.LastModified) if err != nil { log.Warn("incorrect lastmodified for: ", d.DocumentName, " value: ", d.LastModified, " ", err) } - docs = append(docs, &InternalDoc{ + ft := fileTypeFromDoc(d) + internalDoc := &InternalDoc{ ID: d.EntryName, Parent: d.MetadataFile.Parent, Name: d.MetadataFile.DocumentName, Type: d.MetadataFile.CollectionType, LastModified: lastModified, - FileType: d.PayloadType, + FileType: ft, Size: d.Size, - }) + HasWritings: d.HasWritings(), + Pinned: d.MetadataFile.Pinned, + } + if d.MetadataFile.Source == methodsSource { + methodDocs = append(methodDocs, internalDoc) + continue + } + if d.MetadataFile.CollectionType == templateType { + templateDocs = append(templateDocs, internalDoc) + continue + } + docs = append(docs, internalDoc) } + dt := DocTreeFromRawMetadata(docs) + dt.Templates = templateEntriesToDirectory(templateDocs) + dt.Methods = methodEntriesToDirectory(methodDocs) + return dt +} - return DocTreeFromRawMetadata(docs) +// templateEntriesToDirectory returns a single Directory (as []Entry) for synced template documents. +func templateEntriesToDirectory(templateDocs []*InternalDoc) []Entry { + children := make([]Entry, 0, len(templateDocs)) + for _, d := range templateDocs { + children = append(children, makeDocument(d)) + } + dir := &Directory{ + ID: "templates", + Name: "Templates", + Entries: children, + LastModified: time.Time{}, + IsFolder: true, + } + return []Entry{dir} +} + +// methodEntriesToDirectory returns a single Directory (as []Entry) for method documents with type by extension. +func methodEntriesToDirectory(methodDocs []*InternalDoc) []Entry { + children := make([]Entry, 0, len(methodDocs)) + for _, d := range methodDocs { + children = append(children, makeDocument(d)) + } + dir := &Directory{ + ID: "methods", + Name: "rm Methods", + Entries: children, + LastModified: time.Time{}, + IsFolder: true, + } + return []Entry{dir} } // DocTreeFromRawMetadata from raw metadata @@ -176,8 +279,10 @@ func DocTreeFromRawMetadata(documents []*InternalDoc) *DocumentTree { } tree := DocumentTree{ - Entries: rootEntries, - Trash: trashEntries, + Entries: rootEntries, + Trash: trashEntries, + Templates: nil, + Methods: nil, } return &tree @@ -198,11 +303,15 @@ type Directory struct { // Document is a single document type Document struct { - ID string `json:"id"` - Name string `json:"name"` - DocumentType string `json:"type"` //notebook, pdf, epub - LastModified time.Time `json:"lastModified"` - Size int64 `json:"size"` + ID string `json:"id"` + Name string `json:"name"` + DocumentType string `json:"type"` // notebook, pdf, epub + Collection common.EntryType `json:"collectionType"` + LastModified time.Time `json:"lastModified"` + Size int64 `json:"size"` + HasWritings bool `json:"hasWritings"` + Orientation string `json:"orientation,omitempty"` // from .content: "portrait", "landscape", or empty if both/unspecified + Pinned bool `json:"pinned,omitempty"` } // DocumentList is a list of documents @@ -212,13 +321,18 @@ type DocumentList struct { // User user model type User struct { - ID string `json:"userid"` - Email string `json:"email"` - Name string `json:"name"` - NewPassword string `json:"newpassword,omitempty"` - IsAdmin bool `json:"isAdmin"` - CreatedAt time.Time - Integrations []string `json:"integrations,omitempty"` + ID string `json:"userid"` + Email string `json:"email"` + Name string `json:"name"` + NewPassword string `json:"newpassword,omitempty"` + IsAdmin bool `json:"isAdmin"` + CreatedAt time.Time + PasswordChangedAt time.Time + LastLoginAt time.Time + FileUsageBytes int64 + QuotaBytes *int64 `json:"quotaBytes,omitempty"` + Integrations []string `json:"integrations,omitempty"` + RegisteredDevices []RegisteredDeviceEntry `json:"registeredDevices,omitempty"` } // NewUser new user creation @@ -228,6 +342,11 @@ type NewUser struct { NewPassword string `json:"newpassword" binding:"required"` } +// SuRequest asks server to issue a web token for target user. +type SuRequest struct { + UserID string `json:"userid" binding:"required"` +} + // UpdateDoc with somethin type UpdateDoc struct { DocumentID string `json:"documentId" binding:"required"` diff --git a/other/rmfakecloud.env b/other/rmfakecloud.env index 6aeff3f8..7e292b56 100644 --- a/other/rmfakecloud.env +++ b/other/rmfakecloud.env @@ -1,3 +1,5 @@ +#RMFAKECLOUD_ALLOW_SU=true +#RMFAKECLOUD_RMC_SRC=/home/aaron/Documents/rmc-main/src # env variables JWT_SECRET_KEY=tbd DATADIR=/var/rmfakecloud/ diff --git a/scripts/download-go-node.sh b/scripts/download-go-node.sh new file mode 100755 index 00000000..fc8b4e53 --- /dev/null +++ b/scripts/download-go-node.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Download Go and Node.js (JavaScript runtime) for local development. +# +# - Node.js: official release archives from GitHub (nodejs/node releases). +# - Go: official binary tarballs from https://go.dev/dl/ (the Go project does not +# publish Linux/macOS binary archives on github.com/golang/go releases). +# +# Usage: +# ./scripts/download-go-node.sh +# DEST=~/opt/toolchains GO_VER=1.24.4 NODE_VER=22.12.0 ./scripts/download-go-node.sh +# +# After extraction, add to PATH e.g.: +# export PATH="$PWD/.tools/go/bin:$PWD/.tools/node/bin:$PATH" + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEST="${DEST:-$ROOT/.tools}" +GO_VER="${GO_VER:-1.24.4}" +NODE_VER="${NODE_VER:-22.12.0}" + +mkdir -p "$DEST/downloads" + +case "$(uname -s)" in + Linux) + GO_OS_ARCH="linux-amd64" + NODE_OS="linux" + ;; + Darwin) + case "$(uname -m)" in + arm64) GO_OS_ARCH="darwin-arm64" ;; + *) GO_OS_ARCH="darwin-amd64" ;; + esac + NODE_OS="darwin" + ;; + *) + echo "Unsupported OS: $(uname -s)" >&2 + exit 1 + ;; +esac + +case "$(uname -m)" in + x86_64) NODE_ARCH="x64" ;; + aarch64|arm64) NODE_ARCH="arm64" ;; + *) + echo "Unsupported arch: $(uname -m)" >&2 + exit 1 + ;; +esac + +GO_TGZ="go${GO_VER}.${GO_OS_ARCH}.tar.gz" +GO_URL="https://go.dev/dl/${GO_TGZ}" +NODE_TARXZ="node-v${NODE_VER}-${NODE_OS}-${NODE_ARCH}.tar.xz" +NODE_URL="https://github.com/nodejs/node/releases/download/v${NODE_VER}/${NODE_TARXZ}" + +echo "Downloading Go ${GO_VER} from go.dev ..." +curl -fsSL -o "$DEST/downloads/$GO_TGZ" "$GO_URL" +echo "Extracting Go to $DEST/go ..." +rm -rf "$DEST/go" +mkdir -p "$DEST" +tar -C "$DEST" -xzf "$DEST/downloads/$GO_TGZ" + +echo "Downloading Node.js ${NODE_VER} from GitHub (nodejs/node releases) ..." +curl -fsSL -L -o "$DEST/downloads/$NODE_TARXZ" "$NODE_URL" +echo "Extracting Node to $DEST/node ..." +rm -rf "$DEST/node" +mkdir -p "$DEST" +tar -C "$DEST" -xJf "$DEST/downloads/$NODE_TARXZ" +mv "$DEST/node-v${NODE_VER}-${NODE_OS}-${NODE_ARCH}" "$DEST/node" + +echo "Done." +echo " Go: $DEST/go/bin/go" +echo " Node: $DEST/node/bin/node" +echo " npm: $DEST/node/bin/npm" +echo "Add to PATH:" +echo " export PATH=\"$DEST/go/bin:$DEST/node/bin:\$PATH\"" diff --git a/scripts/rmdecode_v6_summary.py b/scripts/rmdecode_v6_summary.py new file mode 100755 index 00000000..1acf93d5 --- /dev/null +++ b/scripts/rmdecode_v6_summary.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Summarize a version-6 reMarkable .rm file using rmscene. + + pip install 'rmscene>=0.6' + +Usage: + python3 scripts/rmdecode_v6_summary.py /path/to/page.rm +""" + +from __future__ import annotations + +import json +import sys + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: rmdecode_v6_summary.py ", file=sys.stderr) + return 1 + path = sys.argv[1] + try: + from rmscene import read_tree + from rmscene.scene_items import GlyphRange, Line, Text + except ImportError as e: + json.dump( + { + "error": "rmscene not installed", + "hint": "pip install 'rmscene>=0.6'", + "detail": str(e), + }, + sys.stdout, + indent=2, + ) + print() + return 2 + + with open(path, "rb") as f: + tree = read_tree(f) + + n_lines = 0 + n_points = 0 + n_text = 0 + n_glyph = 0 + tools: dict[str, int] = {} + colors: dict[str, int] = {} + + for item in tree.walk(): + if isinstance(item, Line): + n_lines += 1 + n_points += len(item.points) + tname = item.tool.name if hasattr(item.tool, "name") else str(item.tool) + cname = item.color.name if hasattr(item.color, "name") else str(item.color) + tools[tname] = tools.get(tname, 0) + 1 + colors[cname] = colors.get(cname, 0) + 1 + elif isinstance(item, Text): + n_text += 1 + elif isinstance(item, GlyphRange): + n_glyph += 1 + + out = { + "version": 6, + "lines": n_lines, + "points": n_points, + "text_blocks": n_text, + "glyph_ranges": n_glyph, + "tools": tools, + "colors": colors, + } + json.dump(out, sys.stdout, indent=2) + print() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/rmscene_v6_to_png.py b/scripts/rmscene_v6_to_png.py new file mode 100644 index 00000000..ecda994d --- /dev/null +++ b/scripts/rmscene_v6_to_png.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Render a single v6 .rm page to PNG (1404×1872) using rmscene + Pillow. + +Reads from a file path, or from stdin when the argument is '-'. + + pip install Pillow # if not already installed + +Usage: + python3 scripts/rmscene_v6_to_png.py page.rm > page.png + python3 scripts/rmscene_v6_to_png.py - < page.rm > page.png +""" + +from __future__ import annotations + +import pathlib +import sys +from io import BytesIO + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_REPO_ROOT / "third_party" / "rmscene" / "src")) + +from rmscene import read_tree # noqa: E402 +from rmscene.scene_items import Line # noqa: E402 +from rmscene.scene_items import Pen # noqa: E402 + +W, H = 1404, 1872 + + +def main() -> int: + try: + from PIL import Image, ImageDraw + except ImportError as e: + print("Pillow required: pip install Pillow", file=sys.stderr) + print(str(e), file=sys.stderr) + return 2 + + if len(sys.argv) < 2: + print("usage: rmscene_v6_to_png.py ", file=sys.stderr) + return 1 + path = sys.argv[1] + if path == "-": + data = sys.stdin.buffer.read() + else: + with open(path, "rb") as f: + data = f.read() + + tree = read_tree(BytesIO(data)) + img = Image.new("RGB", (W, H), "white") + draw = ImageDraw.Draw(img) + + for item in tree.walk(): + if not isinstance(item, Line): + continue + if item.tool in (Pen.ERASER, Pen.ERASER_AREA): + continue + if len(item.points) < 2: + continue + sw = max(int(item.points[0].width) // 4, 1) + pts = [(float(p.x), float(p.y)) for p in item.points] + for i in range(len(pts) - 1): + draw.line([pts[i], pts[i + 1]], fill="black", width=sw) + + buf = BytesIO() + img.save(buf, format="PNG") + sys.stdout.buffer.write(buf.getvalue()) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/third_party/rmc b/third_party/rmc new file mode 160000 index 00000000..da87813a --- /dev/null +++ b/third_party/rmc @@ -0,0 +1 @@ +Subproject commit da87813a31496d156ca6ea8a27bf5128670fb45a diff --git a/third_party/rmscene b/third_party/rmscene new file mode 160000 index 00000000..ac2eeef1 --- /dev/null +++ b/third_party/rmscene @@ -0,0 +1 @@ +Subproject commit ac2eeef1796092bf14a21891fade06f0e6e22a16 diff --git a/ui/.gitignore b/ui/.gitignore index 4d29575d..939d28b9 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -4,15 +4,24 @@ /node_modules /.pnp .pnp.js +.pnp.* +.pnpm-store/ # testing /coverage -# production +# production / Vite /build +/dist +.vite/ +*.tsbuildinfo +.eslintcache # misc .DS_Store +.env +.env.* +!.env.example .env.local .env.development.local .env.test.local @@ -21,3 +30,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* diff --git a/ui/extra/simplerenderer/CMakeLists.txt b/ui/extra/simplerenderer/CMakeLists.txt new file mode 100644 index 00000000..e1006fab --- /dev/null +++ b/ui/extra/simplerenderer/CMakeLists.txt @@ -0,0 +1,17 @@ +cmake_minimum_required(VERSION 3.30) +project(rm_lines_simple_renderer) + +set(CMAKE_CXX_STANDARD 20) +include_directories(librm_lines/rm_lines/headers) + +add_subdirectory(librm_lines) + +add_executable(rm_lines_simple_renderer src/main.cpp) +target_link_libraries(rm_lines_simple_renderer rm_lines nlohmann_json::nlohmann_json) +target_link_options(rm_lines_simple_renderer PRIVATE + -sMODULARIZE=1 + -sEXPORT_NAME=RmRenderer + -sEXPORTED_RUNTIME_METHODS=['FS_writeFile','FS_readFile','callMain'] + -sINVOKE_RUN=0 + -sALLOW_MEMORY_GROWTH +) diff --git a/ui/extra/simplerenderer/diff-colors b/ui/extra/simplerenderer/diff-colors new file mode 100644 index 00000000..c1708467 --- /dev/null +++ b/ui/extra/simplerenderer/diff-colors @@ -0,0 +1,56 @@ +diff --git a/rm_lines/headers/common/scene_items.h b/rm_lines/headers/common/scene_items.h +index d3ac174..3c686b7 100644 +--- a/rm_lines/headers/common/scene_items.h ++++ b/rm_lines/headers/common/scene_items.h +@@ -38,10 +38,6 @@ enum PenColor { + GRAY = 1, + WHITE = 2, + +- YELLOW = 3, +- GREEN = 4, +- PINK = 5, +- + BLUE = 6, + RED = 7, + +@@ -53,11 +49,10 @@ enum PenColor { + // that might contain additional color information. + HIGHLIGHT = 9, + +- GREEN_2 = 10, ++ GREEN = 10, + CYAN = 11, + MAGENTA = 12, +- +- YELLOW_2 = 13, ++ YELLOW = 13, + }; + + struct Point { +diff --git a/rm_lines/headers/renderer/rm_lines_stroker/rm_pens/colors.h b/rm_lines/headers/renderer/rm_lines_stroker/rm_pens/colors.h +index b7c38af..faf599c 100644 +--- a/rm_lines/headers/renderer/rm_lines_stroker/rm_pens/colors.h ++++ b/rm_lines/headers/renderer/rm_lines_stroker/rm_pens/colors.h +@@ -6,16 +6,17 @@ constexpr std::pair rMPallet[] = { + {BLACK, Color(0, 0, 0, 255)}, + {GRAY, Color(125, 125, 125, 255)}, + {WHITE, Color(255, 255, 255, 255)}, +- {YELLOW, Color(255, 255, 99, 255)}, +- {GREEN, Color(0, 255, 0, 255)}, +- {PINK, Color(255, 20, 147, 255)}, ++ {BLACK, Color(0, 0, 0, 255)}, ++ {BLACK, Color(0, 0, 0, 255)}, ++ {BLACK, Color(0, 0, 0, 255)}, + {BLUE, Color(0, 98, 204, 255)}, + {RED, Color(217, 7, 7, 255)}, + {GRAY_OVERLAP, Color(125, 125, 125, 255)}, +- {GREEN_2, Color(145, 218, 113, 255)}, ++ {HIGHLIGHT, Color(255, 255, 0, 255)}, ++ {GREEN, Color(145, 218, 113, 255)}, + {CYAN, Color(116, 210, 232, 255)}, + {MAGENTA, Color(192, 127, 210, 255)}, +- {YELLOW_2, Color(250, 231, 25, 255)} ++ {YELLOW, Color(250, 231, 25, 255)} + }; + + inline Color blendMultiply(const Color base, const Color blend, const float blend_amount) { diff --git a/ui/extra/simplerenderer/librm_lines b/ui/extra/simplerenderer/librm_lines new file mode 160000 index 00000000..b1391310 --- /dev/null +++ b/ui/extra/simplerenderer/librm_lines @@ -0,0 +1 @@ +Subproject commit b139131056c6866079ab1f2c146bf6a6773dc61c diff --git a/ui/extra/simplerenderer/librm_lines.patch b/ui/extra/simplerenderer/librm_lines.patch new file mode 100644 index 00000000..0c2b674b --- /dev/null +++ b/ui/extra/simplerenderer/librm_lines.patch @@ -0,0 +1,65 @@ +diff --git a/CMakeLists.txt b/CMakeLists.txt +index b96274e..540cc5a 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -27,7 +27,7 @@ file(GLOB RM_PENS_FILES rm_lines/src/renderer/rm_lines_stroker/rm_pens/*.cpp) + file(GLOB RM_TEMPLATE_FILES rm_lines/src/renderer/rm_lines_stroker/templates/*.cpp) + file(GLOB STB rm_lines/src/stb/*.cpp) + +-add_library(rm_lines SHARED rm_lines/src/library.cpp ++add_library(rm_lines STATIC rm_lines/src/library.cpp + rm_lines/src/scene_tree/scene_tree_export.cpp + rm_lines/src/reader/tagged_block_reader.cpp + rm_lines/src/v5/reader.cpp +diff --git a/rm_lines/src/library.cpp b/rm_lines/src/library.cpp +index 7b1a7c8..861093e 100644 +--- a/rm_lines/src/library.cpp ++++ b/rm_lines/src/library.cpp +@@ -58,47 +58,3 @@ off_t getFileSize(FILE *file) { + + return size; + } +- +-#ifdef _WIN32 +-#include +-#include +-#pragma comment(lib, "dbghelp.lib") +- +-std::string getStackTrace() { +- void* stack[100]; +- unsigned short frames = CaptureStackBackTrace(0, 100, stack, NULL); +- SYMBOL_INFO* symbol = (SYMBOL_INFO*)calloc(sizeof(SYMBOL_INFO) + 256 * sizeof(char), 1); +- symbol->MaxNameLen = 255; +- symbol->SizeOfStruct = sizeof(SYMBOL_INFO); +- +- std::string stackTrace; +- HANDLE process = GetCurrentProcess(); +- SymInitialize(process, NULL, TRUE); +- +- for (unsigned int i = 0; i < frames; i++) { +- SymFromAddr(process, (DWORD64)(stack[i]), 0, symbol); +- stackTrace += std::to_string(frames - i - 1) + ": " + symbol->Name + "\n"; +- } +- +- free(symbol); +- return stackTrace; +-} +- +-#else +-#include +-#include +- +-std::string getStackTrace() { +- void *buffer[100]; +- int nptrs = backtrace(buffer, 100); +- char **strings = backtrace_symbols(buffer, nptrs); +- +- std::ostringstream stackTrace; +- for (int i = 0; i < nptrs; i++) { +- stackTrace << i << ": " << strings[i] << "\n"; +- } +- +- free(strings); +- return stackTrace.str(); +-} +-#endif diff --git a/ui/extra/simplerenderer/src/main.cpp b/ui/extra/simplerenderer/src/main.cpp new file mode 100644 index 00000000..6f13638d --- /dev/null +++ b/ui/extra/simplerenderer/src/main.cpp @@ -0,0 +1,70 @@ +#include "library.h" +#include "renderer/renderer_export.h" +#include "scene_tree/scene_tree_export.h" +#include +#include +#include +#include + +static inline void write32BitInt(std::ostream &ostr, uint32_t i) { + uint8_t data[4] = { + (uint8_t) ((i >> 24) & 0xFF), + (uint8_t) ((i >> 16) & 0xFF), + (uint8_t) ((i >> 8) & 0xFF), + (uint8_t) ((i >> 0) & 0xFF), + }; + ostr.write((char*) data, sizeof(data)); +} + +int main(int argc, char **argv){ + if(argc != 3) { + std::cerr << "Usage: " << *argv << " " << std::endl; + return -2; + } + std::string in(argv[1]); + std::string out(argv[2]); + + // Render the file: + const char *treeId = buildTree(in.c_str()); + if(!treeId) { + std::cerr << "Failed to build the tree!" << std::endl; + return -1; + } + + auto tree = getSceneTree(treeId); + std::optional renderer; + try { + renderer = Renderer(tree.get(), NOTEBOOK, false); + } catch (const std::exception &e) { + std::cerr << "Failed to create renderer!" << std::endl; + destroyTree(treeId); + return -1; + } + std::ofstream outputFile(out.c_str()); + + Rect track(0, 0, 0, 0); + for(auto &ref : renderer->layers) { + auto layer = renderer->getSizeTracker(ref.groupId); + if (layer->getBottom() > track.getBottom()) { + track.setBottom(layer->getBottom()); + } + if (layer->getTop() < track.getTop()) { + track.setTop(layer->getTop()); + } + if (layer->getLeft() < track.getLeft()) { + track.setLeft(layer->getLeft()); + } + if (layer->getRight() > track.getRight()) { + track.setRight(layer->getRight()); + } + } + uint32_t width = std::max((uint32_t) (track.getRight() - track.getLeft()), renderer->paperSize.first); + uint32_t height = std::max((uint32_t) (track.getBottom() - track.getTop()), renderer->paperSize.second); + uint32_t size = width * height * 4; + uint32_t *rawFrame = (uint32_t*) malloc(size); + renderer->getFrame(rawFrame, size, Vector(track.getLeft(), track.getTop()), Vector(width, height), Vector(width, height), true); + write32BitInt(outputFile, width); + write32BitInt(outputFile, height); + outputFile.write((char *) rawFrame, size); + destroyTree(treeId); +} diff --git a/ui/index.html b/ui/index.html index 1eb06d6c..a43f857c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,11 +3,13 @@ + rmfakecloud
+ diff --git a/ui/package.json b/ui/package.json index 8b7ebde9..e2a50f87 100644 --- a/ui/package.json +++ b/ui/package.json @@ -13,6 +13,7 @@ "dependencies": { "@tanstack/react-table": "^8.21.3", "bootstrap": "^5.3.3", + "epubjs": "^0.3.93", "jwt-decode": "^4.0.0", "moment": "^2.30.1", "mqtt": "^5.15.1", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 760748a5..0b03d01d 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: bootstrap: specifier: ^5.3.3 version: 5.3.3(@popperjs/core@2.11.8) + epubjs: + specifier: ^0.3.93 + version: 0.3.93 jwt-decode: specifier: ^4.0.0 version: 4.0.0 @@ -554,6 +557,10 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/localforage@0.0.34': + resolution: {integrity: sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==} + deprecated: This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed! + '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} @@ -645,6 +652,11 @@ packages: peerDependencies: vite: ^4 || ^5 + '@xmldom/xmldom@0.7.13': + resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} + engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -790,6 +802,12 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -797,6 +815,10 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + d@1.0.2: + resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} + engines: {node: '>=0.12'} + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -842,6 +864,20 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + epubjs@0.3.93: + resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==} + + es5-ext@0.10.64: + resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} + engines: {node: '>=0.10'} + + es6-iterator@2.0.3: + resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} + + es6-symbol@3.1.4: + resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} + engines: {node: '>=0.12'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -884,6 +920,10 @@ packages: jiti: optional: true + esniff@2.0.1: + resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} + engines: {node: '>=0.10'} + espree@10.3.0: resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -904,6 +944,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-emitter@0.3.5: + resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -912,6 +955,9 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + ext@1.7.0: + resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1022,6 +1068,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immutable@4.3.7: resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} @@ -1073,6 +1122,9 @@ packages: isarray@0.0.1: resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1095,6 +1147,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwt-decode@4.0.0: resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} engines: {node: '>=18'} @@ -1106,6 +1161,15 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.1.1: + resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + localforage@1.10.0: + resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1113,6 +1177,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1130,6 +1197,9 @@ packages: make-event-props@1.6.2: resolution: {integrity: sha512-iDwf7mA03WPiR8QxvcVHmVWEPfMY1RZXerDVNCRYW7dUr2ppH3J58Rwb39/WG39yTZdRSxr3x+2v22tvI0VEvA==} + marks-pane@1.0.9: + resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -1205,6 +1275,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-tick@1.1.0: + resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -1271,6 +1344,9 @@ packages: path-to-regexp@1.9.0: resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} + path-webpack@0.0.3: + resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==} + path2d@0.2.2: resolution: {integrity: sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==} engines: {node: '>=6'} @@ -1415,6 +1491,9 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -1466,6 +1545,9 @@ packages: rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1614,6 +1696,9 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1651,6 +1736,9 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1708,6 +1796,9 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type@2.7.3: + resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -2164,6 +2255,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/localforage@0.0.34': + dependencies: + localforage: 1.10.0 + '@types/node@25.5.0': dependencies: undici-types: 7.18.2 @@ -2282,6 +2377,8 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@xmldom/xmldom@0.7.13': {} + abbrev@1.1.1: optional: true @@ -2444,6 +2541,10 @@ snapshots: console-control-strings@1.1.0: optional: true + core-js@3.49.0: {} + + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2452,6 +2553,11 @@ snapshots: csstype@3.1.3: {} + d@1.0.2: + dependencies: + es5-ext: 0.10.64 + type: 2.7.3 + debug@4.3.7: dependencies: ms: 2.1.3 @@ -2489,6 +2595,36 @@ snapshots: emoji-regex@8.0.0: optional: true + epubjs@0.3.93: + dependencies: + '@types/localforage': 0.0.34 + '@xmldom/xmldom': 0.7.13 + core-js: 3.49.0 + event-emitter: 0.3.5 + jszip: 3.10.1 + localforage: 1.10.0 + lodash: 4.18.1 + marks-pane: 1.0.9 + path-webpack: 0.0.3 + + es5-ext@0.10.64: + dependencies: + es6-iterator: 2.0.3 + es6-symbol: 3.1.4 + esniff: 2.0.1 + next-tick: 1.1.0 + + es6-iterator@2.0.3: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + es6-symbol: 3.1.4 + + es6-symbol@3.1.4: + dependencies: + d: 1.0.2 + ext: 1.7.0 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2573,6 +2709,13 @@ snapshots: transitivePeerDependencies: - supports-color + esniff@2.0.1: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-emitter: 0.3.5 + type: 2.7.3 + espree@10.3.0: dependencies: acorn: 8.14.0 @@ -2591,10 +2734,19 @@ snapshots: esutils@2.0.3: {} + event-emitter@0.3.5: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-target-shim@5.0.1: {} events@3.3.0: {} + ext@1.7.0: + dependencies: + type: 2.7.3 + fast-deep-equal@3.1.3: {} fast-glob@3.3.2: @@ -2722,6 +2874,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + immutable@4.3.7: optional: true @@ -2766,6 +2920,8 @@ snapshots: isarray@0.0.1: {} + isarray@1.0.0: {} + isexe@2.0.0: {} js-sdsl@4.3.0: {} @@ -2782,6 +2938,13 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwt-decode@4.0.0: {} keyv@4.5.4: @@ -2793,12 +2956,26 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.1.1: + dependencies: + immediate: 3.0.6 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + localforage@1.10.0: + dependencies: + lie: 3.1.1 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 lodash.merge@4.6.2: {} + lodash@4.18.1: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -2814,6 +2991,8 @@ snapshots: make-event-props@1.6.2: {} + marks-pane@1.0.9: {} + memoize-one@5.2.1: {} merge-refs@1.3.0(@types/react@18.3.12): @@ -2899,6 +3078,8 @@ snapshots: natural-compare@1.4.0: {} + next-tick@1.1.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -2968,6 +3149,8 @@ snapshots: dependencies: isarray: 0.0.1 + path-webpack@0.0.3: {} + path2d@0.2.2: optional: true @@ -3149,6 +3332,16 @@ snapshots: dependencies: loose-envify: 1.4.0 + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -3223,6 +3416,8 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} sass-embedded-android-arm64@1.81.0: @@ -3336,6 +3531,8 @@ snapshots: set-blocking@2.0.0: optional: true + setimmediate@1.0.5: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3373,6 +3570,10 @@ snapshots: strip-ansi: 6.0.1 optional: true + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -3429,6 +3630,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type@2.7.3: {} + typedarray@0.0.6: {} typescript-eslint@8.15.0(eslint@9.15.0)(typescript@5.6.3): diff --git a/ui/public/assets/rm_lines_simple_renderer.js b/ui/public/assets/rm_lines_simple_renderer.js new file mode 100644 index 00000000..ed9e78da --- /dev/null +++ b/ui/public/assets/rm_lines_simple_renderer.js @@ -0,0 +1,4942 @@ +// This code implements the `-sMODULARIZE` settings by taking the generated +// JS program code (INNER_JS_CODE) and wrapping it in a factory function. + +// Single threaded MINIMAL_RUNTIME programs do not need access to +// document.currentScript, so a simple export declaration is enough. +var RmRenderer = (() => { + // When MODULARIZE this JS may be executed later, + // after document.currentScript is gone, so we save it. + // In EXPORT_ES6 mode we can just use 'import.meta.url'. + var _scriptName = globalThis.document?.currentScript?.src; + return async function(moduleArg = {}) { + var moduleRtn; + +// include: shell.js +// include: minimum_runtime_check.js +(function() { + // "30.0.0" -> 300000 + function humanReadableVersionToPacked(str) { + str = str.split('-')[0]; // Remove any trailing part from e.g. "12.53.3-alpha" + var vers = str.split('.').slice(0, 3); + while(vers.length < 3) vers.push('00'); + vers = vers.map((n, i, arr) => n.padStart(2, '0')); + return vers.join(''); + } + // 300000 -> "30.0.0" + var packedVersionToHumanReadable = n => [n / 10000 | 0, (n / 100 | 0) % 100, n % 100].join('.'); + + var TARGET_NOT_SUPPORTED = 2147483647; + + var currentNodeVersion = typeof process !== 'undefined' && process?.versions?.node ? humanReadableVersionToPacked(process.versions.node) : TARGET_NOT_SUPPORTED; + if (currentNodeVersion < 160000) { + throw new Error(`This emscripten-generated code requires node v${ packedVersionToHumanReadable(160000) } (detected v${packedVersionToHumanReadable(currentNodeVersion)})`); + } + + var currentSafariVersion = typeof navigator !== 'undefined' && navigator?.userAgent?.includes("Safari/") && navigator.userAgent.match(/Version\/(\d+\.?\d*\.?\d*)/) ? humanReadableVersionToPacked(navigator.userAgent.match(/Version\/(\d+\.?\d*\.?\d*)/)[1]) : TARGET_NOT_SUPPORTED; + if (currentSafariVersion < 150000) { + throw new Error(`This emscripten-generated code requires Safari v${ packedVersionToHumanReadable(150000) } (detected v${currentSafariVersion})`); + } + + var currentFirefoxVersion = typeof navigator !== 'undefined' && navigator?.userAgent?.match(/Firefox\/(\d+(?:\.\d+)?)/) ? parseFloat(navigator.userAgent.match(/Firefox\/(\d+(?:\.\d+)?)/)[1]) : TARGET_NOT_SUPPORTED; + if (currentFirefoxVersion < 79) { + throw new Error(`This emscripten-generated code requires Firefox v79 (detected v${currentFirefoxVersion})`); + } + + var currentChromeVersion = typeof navigator !== 'undefined' && navigator?.userAgent?.match(/Chrome\/(\d+(?:\.\d+)?)/) ? parseFloat(navigator.userAgent.match(/Chrome\/(\d+(?:\.\d+)?)/)[1]) : TARGET_NOT_SUPPORTED; + if (currentChromeVersion < 85) { + throw new Error(`This emscripten-generated code requires Chrome v85 (detected v${currentChromeVersion})`); + } +})(); + +// end include: minimum_runtime_check.js +// The Module object: Our interface to the outside world. We import +// and export values on it. There are various ways Module can be used: +// 1. Not defined. We create it here +// 2. A function parameter, function(moduleArg) => Promise +// 3. pre-run appended it, var Module = {}; ..generated code.. +// 4. External script tag defines var Module. +// We need to check if Module already exists (e.g. case 3 above). +// Substitution will be replaced with actual code on later stage of the build, +// this way Closure Compiler will not mangle it (e.g. case 4. above). +// Note that if you want to run closure, and also to use Module +// after the generated code, you will need to define var Module = {}; +// before the code. Then that object will be used in the code, and you +// can continue to use Module afterwards as well. +var Module = moduleArg; + +// Determine the runtime environment we are in. You can customize this by +// setting the ENVIRONMENT setting at compile time (see settings.js). + +// Attempt to auto-detect the environment +var ENVIRONMENT_IS_WEB = !!globalThis.window; +var ENVIRONMENT_IS_WORKER = !!globalThis.WorkerGlobalScope; +// N.b. Electron.js environment is simultaneously a NODE-environment, but +// also a web environment. +var ENVIRONMENT_IS_NODE = globalThis.process?.versions?.node && globalThis.process?.type != 'renderer'; +var ENVIRONMENT_IS_SHELL = !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_NODE && !ENVIRONMENT_IS_WORKER; + +// --pre-jses are emitted after the Module integration code, so that they can +// refer to Module (if they choose; they can also define Module) + + +var arguments_ = []; +var thisProgram = './this.program'; +var quit_ = (status, toThrow) => { + throw toThrow; +}; + +if (typeof __filename != 'undefined') { // Node + _scriptName = __filename; +} else +if (ENVIRONMENT_IS_WORKER) { + _scriptName = self.location.href; +} + +// `/` should be present at the end if `scriptDirectory` is not empty +var scriptDirectory = ''; +function locateFile(path) { + if (Module['locateFile']) { + return Module['locateFile'](path, scriptDirectory); + } + return scriptDirectory + path; +} + +// Hooks that are implemented differently in different runtime environments. +var readAsync, readBinary; + +if (ENVIRONMENT_IS_NODE) { + const isNode = globalThis.process?.versions?.node && globalThis.process?.type != 'renderer'; + if (!isNode) throw new Error('not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)'); + + // These modules will usually be used on Node.js. Load them eagerly to avoid + // the complexity of lazy-loading. + var fs = require('fs'); + + scriptDirectory = __dirname + '/'; + +// include: node_shell_read.js +readBinary = (filename) => { + // We need to re-wrap `file://` strings to URLs. + filename = isFileURI(filename) ? new URL(filename) : filename; + var ret = fs.readFileSync(filename); + assert(Buffer.isBuffer(ret)); + return ret; +}; + +readAsync = async (filename, binary = true) => { + // See the comment in the `readBinary` function. + filename = isFileURI(filename) ? new URL(filename) : filename; + var ret = fs.readFileSync(filename, binary ? undefined : 'utf8'); + assert(binary ? Buffer.isBuffer(ret) : typeof ret == 'string'); + return ret; +}; +// end include: node_shell_read.js + if (process.argv.length > 1) { + thisProgram = process.argv[1].replace(/\\/g, '/'); + } + + arguments_ = process.argv.slice(2); + + quit_ = (status, toThrow) => { + process.exitCode = status; + throw toThrow; + }; + +} else +if (ENVIRONMENT_IS_SHELL) { + +} else + +// Note that this includes Node.js workers when relevant (pthreads is enabled). +// Node.js workers are detected as a combination of ENVIRONMENT_IS_WORKER and +// ENVIRONMENT_IS_NODE. +if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { + try { + scriptDirectory = new URL('.', _scriptName).href; // includes trailing slash + } catch { + // Must be a `blob:` or `data:` URL (e.g. `blob:http://site.com/etc/etc`), we cannot + // infer anything from them. + } + + if (!(globalThis.window || globalThis.WorkerGlobalScope)) throw new Error('not compiled for this environment (did you build to HTML and try to run it not on the web, or set ENVIRONMENT to something - like node - and run it someplace else - like on the web?)'); + + { +// include: web_or_worker_shell_read.js +if (ENVIRONMENT_IS_WORKER) { + readBinary = (url) => { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + xhr.responseType = 'arraybuffer'; + xhr.send(null); + return new Uint8Array(/** @type{!ArrayBuffer} */(xhr.response)); + }; + } + + readAsync = async (url) => { + // Fetch has some additional restrictions over XHR, like it can't be used on a file:// url. + // See https://github.com/github/fetch/pull/92#issuecomment-140665932 + // Cordova or Electron apps are typically loaded from a file:// url. + // So use XHR on webview if URL is a file URL. + if (isFileURI(url)) { + return new Promise((resolve, reject) => { + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + xhr.onload = () => { + if (xhr.status == 200 || (xhr.status == 0 && xhr.response)) { // file URLs can return 0 + resolve(xhr.response); + return; + } + reject(xhr.status); + }; + xhr.onerror = reject; + xhr.send(null); + }); + } + var response = await fetch(url, { credentials: 'same-origin' }); + if (response.ok) { + return response.arrayBuffer(); + } + throw new Error(response.status + ' : ' + response.url); + }; +// end include: web_or_worker_shell_read.js + } +} else +{ + throw new Error('environment detection error'); +} + +var out = console.log.bind(console); +var err = console.error.bind(console); + +var IDBFS = 'IDBFS is no longer included by default; build with -lidbfs.js'; +var PROXYFS = 'PROXYFS is no longer included by default; build with -lproxyfs.js'; +var WORKERFS = 'WORKERFS is no longer included by default; build with -lworkerfs.js'; +var FETCHFS = 'FETCHFS is no longer included by default; build with -lfetchfs.js'; +var ICASEFS = 'ICASEFS is no longer included by default; build with -licasefs.js'; +var JSFILEFS = 'JSFILEFS is no longer included by default; build with -ljsfilefs.js'; +var OPFS = 'OPFS is no longer included by default; build with -lopfs.js'; + +var NODEFS = 'NODEFS is no longer included by default; build with -lnodefs.js'; + +// perform assertions in shell.js after we set up out() and err(), as otherwise +// if an assertion fails it cannot print the message + +assert(!ENVIRONMENT_IS_SHELL, 'shell environment detected but not enabled at build time. Add `shell` to `-sENVIRONMENT` to enable.'); + +// end include: shell.js + +// include: preamble.js +// === Preamble library stuff === + +// Documentation for the public APIs defined in this file must be updated in: +// site/source/docs/api_reference/preamble.js.rst +// A prebuilt local version of the documentation is available at: +// site/build/text/docs/api_reference/preamble.js.txt +// You can also build docs locally as HTML or other formats in site/ +// An online HTML version (which may be of a different version of Emscripten) +// is up at http://kripken.github.io/emscripten-site/docs/api_reference/preamble.js.html + +var wasmBinary; + +if (!globalThis.WebAssembly) { + err('no native wasm support detected'); +} + +// Wasm globals + +//======================================== +// Runtime essentials +//======================================== + +// whether we are quitting the application. no code should run after this. +// set in exit() and abort() +var ABORT = false; + +// set by exit() and abort(). Passed to 'onExit' handler. +// NOTE: This is also used as the process return code code in shell environments +// but only when noExitRuntime is false. +var EXITSTATUS; + +// In STRICT mode, we only define assert() when ASSERTIONS is set. i.e. we +// don't define it at all in release modes. This matches the behaviour of +// MINIMAL_RUNTIME. +// TODO(sbc): Make this the default even without STRICT enabled. +/** @type {function(*, string=)} */ +function assert(condition, text) { + if (!condition) { + abort('Assertion failed' + (text ? ': ' + text : '')); + } +} + +// We used to include malloc/free by default in the past. Show a helpful error in +// builds with assertions. +function _malloc() { + abort('malloc() called but not included in the build - add `_malloc` to EXPORTED_FUNCTIONS'); +} +function _free() { + // Show a helpful error since we used to include free by default in the past. + abort('free() called but not included in the build - add `_free` to EXPORTED_FUNCTIONS'); +} + +/** + * Indicates whether filename is delivered via file protocol (as opposed to http/https) + * @noinline + */ +var isFileURI = (filename) => filename.startsWith('file://'); + +// include: runtime_common.js +// include: runtime_stack_check.js +// Initializes the stack cookie. Called at the startup of main and at the startup of each thread in pthreads mode. +function writeStackCookie() { + var max = _emscripten_stack_get_end(); + assert((max & 3) == 0); + // If the stack ends at address zero we write our cookies 4 bytes into the + // stack. This prevents interference with SAFE_HEAP and ASAN which also + // monitor writes to address zero. + if (max == 0) { + max += 4; + } + // The stack grow downwards towards _emscripten_stack_get_end. + // We write cookies to the final two words in the stack and detect if they are + // ever overwritten. + HEAPU32[((max)>>2)] = 0x02135467; + HEAPU32[(((max)+(4))>>2)] = 0x89BACDFE; + // Also test the global address 0 for integrity. + HEAPU32[((0)>>2)] = 1668509029; +} + +function checkStackCookie() { + if (ABORT) return; + var max = _emscripten_stack_get_end(); + // See writeStackCookie(). + if (max == 0) { + max += 4; + } + var cookie1 = HEAPU32[((max)>>2)]; + var cookie2 = HEAPU32[(((max)+(4))>>2)]; + if (cookie1 != 0x02135467 || cookie2 != 0x89BACDFE) { + abort(`Stack overflow! Stack cookie has been overwritten at ${ptrToString(max)}, expected hex dwords 0x89BACDFE and 0x2135467, but received ${ptrToString(cookie2)} ${ptrToString(cookie1)}`); + } + // Also test the global address 0 for integrity. + if (HEAPU32[((0)>>2)] != 0x63736d65 /* 'emsc' */) { + abort('Runtime error: The application has corrupted its heap memory area (address zero)!'); + } +} +// end include: runtime_stack_check.js +// include: runtime_exceptions.js +// end include: runtime_exceptions.js +// include: runtime_debug.js +var runtimeDebug = true; // Switch to false at runtime to disable logging at the right times + +// Used by XXXXX_DEBUG settings to output debug messages. +function dbg(...args) { + if (!runtimeDebug && typeof runtimeDebug != 'undefined') return; + // TODO(sbc): Make this configurable somehow. Its not always convenient for + // logging to show up as warnings. + console.warn(...args); +} + +// Endianness check +(() => { + var h16 = new Int16Array(1); + var h8 = new Int8Array(h16.buffer); + h16[0] = 0x6373; + if (h8[0] !== 0x73 || h8[1] !== 0x63) abort('Runtime error: expected the system to be little-endian! (Run with -sSUPPORT_BIG_ENDIAN to bypass)'); +})(); + +function consumedModuleProp(prop) { + if (!Object.getOwnPropertyDescriptor(Module, prop)) { + Object.defineProperty(Module, prop, { + configurable: true, + set() { + abort(`Attempt to set \`Module.${prop}\` after it has already been processed. This can happen, for example, when code is injected via '--post-js' rather than '--pre-js'`); + + } + }); + } +} + +function makeInvalidEarlyAccess(name) { + return () => assert(false, `call to '${name}' via reference taken before Wasm module initialization`); + +} + +function ignoredModuleProp(prop) { + if (Object.getOwnPropertyDescriptor(Module, prop)) { + abort(`\`Module.${prop}\` was supplied but \`${prop}\` not included in INCOMING_MODULE_JS_API`); + } +} + +// forcing the filesystem exports a few things by default +function isExportedByForceFilesystem(name) { + return name === 'FS_createPath' || + name === 'FS_createDataFile' || + name === 'FS_createPreloadedFile' || + name === 'FS_preloadFile' || + name === 'FS_unlink' || + name === 'addRunDependency' || + // The old FS has some functionality that WasmFS lacks. + name === 'FS_createLazyFile' || + name === 'FS_createDevice' || + name === 'removeRunDependency'; +} + +function missingLibrarySymbol(sym) { + + // Any symbol that is not included from the JS library is also (by definition) + // not exported on the Module object. + unexportedRuntimeSymbol(sym); +} + +function unexportedRuntimeSymbol(sym) { + if (!Object.getOwnPropertyDescriptor(Module, sym)) { + Object.defineProperty(Module, sym, { + configurable: true, + get() { + var msg = `'${sym}' was not exported. add it to EXPORTED_RUNTIME_METHODS (see the Emscripten FAQ)`; + if (isExportedByForceFilesystem(sym)) { + msg += '. Alternatively, forcing filesystem support (-sFORCE_FILESYSTEM) can export this for you'; + } + abort(msg); + }, + }); + } +} + +// end include: runtime_debug.js +var readyPromiseResolve, readyPromiseReject; + +// Memory management +var +/** @type {!Int8Array} */ + HEAP8, +/** @type {!Uint8Array} */ + HEAPU8, +/** @type {!Int16Array} */ + HEAP16, +/** @type {!Uint16Array} */ + HEAPU16, +/** @type {!Int32Array} */ + HEAP32, +/** @type {!Uint32Array} */ + HEAPU32, +/** @type {!Float32Array} */ + HEAPF32, +/** @type {!Float64Array} */ + HEAPF64; + +// BigInt64Array type is not correctly defined in closure +var +/** not-@type {!BigInt64Array} */ + HEAP64, +/* BigUint64Array type is not correctly defined in closure +/** not-@type {!BigUint64Array} */ + HEAPU64; + +var runtimeInitialized = false; + + + +function updateMemoryViews() { + var b = wasmMemory.buffer; + HEAP8 = new Int8Array(b); + HEAP16 = new Int16Array(b); + HEAPU8 = new Uint8Array(b); + HEAPU16 = new Uint16Array(b); + HEAP32 = new Int32Array(b); + HEAPU32 = new Uint32Array(b); + HEAPF32 = new Float32Array(b); + HEAPF64 = new Float64Array(b); + HEAP64 = new BigInt64Array(b); + HEAPU64 = new BigUint64Array(b); +} + +// include: memoryprofiler.js +// end include: memoryprofiler.js +// end include: runtime_common.js +assert(globalThis.Int32Array && globalThis.Float64Array && Int32Array.prototype.subarray && Int32Array.prototype.set, + 'JS engine does not provide full typed array support'); + +function preRun() { + if (Module['preRun']) { + if (typeof Module['preRun'] == 'function') Module['preRun'] = [Module['preRun']]; + while (Module['preRun'].length) { + addOnPreRun(Module['preRun'].shift()); + } + } + consumedModuleProp('preRun'); + // Begin ATPRERUNS hooks + callRuntimeCallbacks(onPreRuns); + // End ATPRERUNS hooks +} + +function initRuntime() { + assert(!runtimeInitialized); + runtimeInitialized = true; + + checkStackCookie(); + + // Begin ATINITS hooks + if (!Module['noFSInit'] && !FS.initialized) FS.init(); +TTY.init(); + // End ATINITS hooks + + wasmExports['__wasm_call_ctors'](); + + // Begin ATPOSTCTORS hooks + FS.ignorePermissions = false; + // End ATPOSTCTORS hooks +} + +function preMain() { + checkStackCookie(); + // No ATMAINS hooks +} + +function postRun() { + checkStackCookie(); + // PThreads reuse the runtime from the main thread. + + if (Module['postRun']) { + if (typeof Module['postRun'] == 'function') Module['postRun'] = [Module['postRun']]; + while (Module['postRun'].length) { + addOnPostRun(Module['postRun'].shift()); + } + } + consumedModuleProp('postRun'); + + // Begin ATPOSTRUNS hooks + callRuntimeCallbacks(onPostRuns); + // End ATPOSTRUNS hooks +} + +/** @param {string|number=} what */ +function abort(what) { + Module['onAbort']?.(what); + + what = 'Aborted(' + what + ')'; + // TODO(sbc): Should we remove printing and leave it up to whoever + // catches the exception? + err(what); + + ABORT = true; + + // Use a wasm runtime error, because a JS error might be seen as a foreign + // exception, which means we'd run destructors on it. We need the error to + // simply make the program stop. + // FIXME This approach does not work in Wasm EH because it currently does not assume + // all RuntimeErrors are from traps; it decides whether a RuntimeError is from + // a trap or not based on a hidden field within the object. So at the moment + // we don't have a way of throwing a wasm trap from JS. TODO Make a JS API that + // allows this in the wasm spec. + + // Suppress closure compiler warning here. Closure compiler's builtin extern + // definition for WebAssembly.RuntimeError claims it takes no arguments even + // though it can. + // TODO(https://github.com/google/closure-compiler/pull/3913): Remove if/when upstream closure gets fixed. + /** @suppress {checkTypes} */ + var e = new WebAssembly.RuntimeError(what); + + readyPromiseReject?.(e); + // Throw the error whether or not MODULARIZE is set because abort is used + // in code paths apart from instantiation where an exception is expected + // to be thrown when abort is called. + throw e; +} + +function createExportWrapper(name, nargs) { + return (...args) => { + assert(runtimeInitialized, `native function \`${name}\` called before runtime initialization`); + var f = wasmExports[name]; + assert(f, `exported native function \`${name}\` not found`); + // Only assert for too many arguments. Too few can be valid since the missing arguments will be zero filled. + assert(args.length <= nargs, `native function \`${name}\` called with ${args.length} args but expects ${nargs}`); + return f(...args); + }; +} + +var wasmBinaryFile; + +function findWasmBinary() { + return locateFile('rm_lines_simple_renderer.wasm'); +} + +function getBinarySync(file) { + if (file == wasmBinaryFile && wasmBinary) { + return new Uint8Array(wasmBinary); + } + if (readBinary) { + return readBinary(file); + } + // Throwing a plain string here, even though it not normally adviables since + // this gets turning into an `abort` in instantiateArrayBuffer. + throw 'both async and sync fetching of the wasm failed'; +} + +async function getWasmBinary(binaryFile) { + // If we don't have the binary yet, load it asynchronously using readAsync. + if (!wasmBinary) { + // Fetch the binary using readAsync + try { + var response = await readAsync(binaryFile); + return new Uint8Array(response); + } catch { + // Fall back to getBinarySync below; + } + } + + // Otherwise, getBinarySync should be able to get it synchronously + return getBinarySync(binaryFile); +} + +async function instantiateArrayBuffer(binaryFile, imports) { + try { + var binary = await getWasmBinary(binaryFile); + var instance = await WebAssembly.instantiate(binary, imports); + return instance; + } catch (reason) { + err(`failed to asynchronously prepare wasm: ${reason}`); + + // Warn on some common problems. + if (isFileURI(binaryFile)) { + err(`warning: Loading from a file URI (${binaryFile}) is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing`); + } + abort(reason); + } +} + +async function instantiateAsync(binary, binaryFile, imports) { + if (!binary + // Don't use streaming for file:// delivered objects in a webview, fetch them synchronously. + && !isFileURI(binaryFile) + // Avoid instantiateStreaming() on Node.js environment for now, as while + // Node.js v18.1.0 implements it, it does not have a full fetch() + // implementation yet. + // + // Reference: + // https://github.com/emscripten-core/emscripten/pull/16917 + && !ENVIRONMENT_IS_NODE + ) { + try { + var response = fetch(binaryFile, { credentials: 'same-origin' }); + var instantiationResult = await WebAssembly.instantiateStreaming(response, imports); + return instantiationResult; + } catch (reason) { + // We expect the most common failure cause to be a bad MIME type for the binary, + // in which case falling back to ArrayBuffer instantiation should work. + err(`wasm streaming compile failed: ${reason}`); + err('falling back to ArrayBuffer instantiation'); + // fall back of instantiateArrayBuffer below + }; + } + return instantiateArrayBuffer(binaryFile, imports); +} + +function getWasmImports() { + // prepare imports + return { + 'env': wasmImports, + 'wasi_snapshot_preview1': wasmImports, + } +} + +// Create the wasm instance. +// Receives the wasm imports, returns the exports. +async function createWasm() { + // Load the wasm module and create an instance of using native support in the JS engine. + // handle a generated wasm instance, receiving its exports and + // performing other necessary setup + /** @param {WebAssembly.Module=} module*/ + function receiveInstance(instance, module) { + wasmExports = instance.exports; + + + + assignWasmExports(wasmExports); + + updateMemoryViews(); + + return wasmExports; + } + + // Prefer streaming instantiation if available. + // Async compilation can be confusing when an error on the page overwrites Module + // (for example, if the order of elements is wrong, and the one defining Module is + // later), so we save Module and check it later. + var trueModule = Module; + function receiveInstantiationResult(result) { + // 'result' is a ResultObject object which has both the module and instance. + // receiveInstance() will swap in the exports (to Module.asm) so they can be called + assert(Module === trueModule, 'the Module object should not be replaced during async compilation - perhaps the order of HTML elements is wrong?'); + trueModule = null; + // TODO: Due to Closure regression https://github.com/google/closure-compiler/issues/3193, the above line no longer optimizes out down to the following line. + // When the regression is fixed, can restore the above PTHREADS-enabled path. + return receiveInstance(result['instance']); + } + + var info = getWasmImports(); + + // User shell pages can write their own Module.instantiateWasm = function(imports, successCallback) callback + // to manually instantiate the Wasm module themselves. This allows pages to + // run the instantiation parallel to any other async startup actions they are + // performing. + // Also pthreads and wasm workers initialize the wasm instance through this + // path. + if (Module['instantiateWasm']) { + return new Promise((resolve, reject) => { + try { + Module['instantiateWasm'](info, (inst, mod) => { + resolve(receiveInstance(inst, mod)); + }); + } catch(e) { + err(`Module.instantiateWasm callback failed with error: ${e}`); + reject(e); + } + }); + } + + wasmBinaryFile ??= findWasmBinary(); + var result = await instantiateAsync(wasmBinary, wasmBinaryFile, info); + var exports = receiveInstantiationResult(result); + return exports; +} + +// end include: preamble.js + +// Begin JS library code + + + class ExitStatus { + name = 'ExitStatus'; + constructor(status) { + this.message = `Program terminated with exit(${status})`; + this.status = status; + } + } + + var callRuntimeCallbacks = (callbacks) => { + while (callbacks.length > 0) { + // Pass the module as the first argument. + callbacks.shift()(Module); + } + }; + var onPostRuns = []; + var addOnPostRun = (cb) => onPostRuns.push(cb); + + var onPreRuns = []; + var addOnPreRun = (cb) => onPreRuns.push(cb); + + + + /** + * @param {number} ptr + * @param {string} type + */ + function getValue(ptr, type = 'i8') { + if (type.endsWith('*')) type = '*'; + switch (type) { + case 'i1': return HEAP8[ptr]; + case 'i8': return HEAP8[ptr]; + case 'i16': return HEAP16[((ptr)>>1)]; + case 'i32': return HEAP32[((ptr)>>2)]; + case 'i64': return HEAP64[((ptr)>>3)]; + case 'float': return HEAPF32[((ptr)>>2)]; + case 'double': return HEAPF64[((ptr)>>3)]; + case '*': return HEAPU32[((ptr)>>2)]; + default: abort(`invalid type for getValue: ${type}`); + } + } + + var noExitRuntime = true; + + var ptrToString = (ptr) => { + assert(typeof ptr === 'number', `ptrToString expects a number, got ${typeof ptr}`); + // Convert to 32-bit unsigned value + ptr >>>= 0; + return '0x' + ptr.toString(16).padStart(8, '0'); + }; + + + /** + * @param {number} ptr + * @param {number} value + * @param {string} type + */ + function setValue(ptr, value, type = 'i8') { + if (type.endsWith('*')) type = '*'; + switch (type) { + case 'i1': HEAP8[ptr] = value; break; + case 'i8': HEAP8[ptr] = value; break; + case 'i16': HEAP16[((ptr)>>1)] = value; break; + case 'i32': HEAP32[((ptr)>>2)] = value; break; + case 'i64': HEAP64[((ptr)>>3)] = BigInt(value); break; + case 'float': HEAPF32[((ptr)>>2)] = value; break; + case 'double': HEAPF64[((ptr)>>3)] = value; break; + case '*': HEAPU32[((ptr)>>2)] = value; break; + default: abort(`invalid type for setValue: ${type}`); + } + } + + var stackRestore = (val) => __emscripten_stack_restore(val); + + var stackSave = () => _emscripten_stack_get_current(); + + var warnOnce = (text) => { + warnOnce.shown ||= {}; + if (!warnOnce.shown[text]) { + warnOnce.shown[text] = 1; + if (ENVIRONMENT_IS_NODE) text = 'warning: ' + text; + err(text); + } + }; + + var UTF8Decoder = globalThis.TextDecoder && new TextDecoder(); + + var findStringEnd = (heapOrArray, idx, maxBytesToRead, ignoreNul) => { + var maxIdx = idx + maxBytesToRead; + if (ignoreNul) return maxIdx; + // TextDecoder needs to know the byte length in advance, it doesn't stop on + // null terminator by itself. + // As a tiny code save trick, compare idx against maxIdx using a negation, + // so that maxBytesToRead=undefined/NaN means Infinity. + while (heapOrArray[idx] && !(idx >= maxIdx)) ++idx; + return idx; + }; + + + /** + * Given a pointer 'idx' to a null-terminated UTF8-encoded string in the given + * array that contains uint8 values, returns a copy of that string as a + * Javascript String object. + * heapOrArray is either a regular array, or a JavaScript typed array view. + * @param {number=} idx + * @param {number=} maxBytesToRead + * @param {boolean=} ignoreNul - If true, the function will not stop on a NUL character. + * @return {string} + */ + var UTF8ArrayToString = (heapOrArray, idx = 0, maxBytesToRead, ignoreNul) => { + + var endPtr = findStringEnd(heapOrArray, idx, maxBytesToRead, ignoreNul); + + // When using conditional TextDecoder, skip it for short strings as the overhead of the native call is not worth it. + if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) { + return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr)); + } + var str = ''; + while (idx < endPtr) { + // For UTF8 byte structure, see: + // http://en.wikipedia.org/wiki/UTF-8#Description + // https://www.ietf.org/rfc/rfc2279.txt + // https://tools.ietf.org/html/rfc3629 + var u0 = heapOrArray[idx++]; + if (!(u0 & 0x80)) { str += String.fromCharCode(u0); continue; } + var u1 = heapOrArray[idx++] & 63; + if ((u0 & 0xE0) == 0xC0) { str += String.fromCharCode(((u0 & 31) << 6) | u1); continue; } + var u2 = heapOrArray[idx++] & 63; + if ((u0 & 0xF0) == 0xE0) { + u0 = ((u0 & 15) << 12) | (u1 << 6) | u2; + } else { + if ((u0 & 0xF8) != 0xF0) warnOnce('Invalid UTF-8 leading byte ' + ptrToString(u0) + ' encountered when deserializing a UTF-8 string in wasm memory to a JS string!'); + u0 = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | (heapOrArray[idx++] & 63); + } + + if (u0 < 0x10000) { + str += String.fromCharCode(u0); + } else { + var ch = u0 - 0x10000; + str += String.fromCharCode(0xD800 | (ch >> 10), 0xDC00 | (ch & 0x3FF)); + } + } + return str; + }; + + /** + * Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the + * emscripten HEAP, returns a copy of that string as a Javascript String object. + * + * @param {number} ptr + * @param {number=} maxBytesToRead - An optional length that specifies the + * maximum number of bytes to read. You can omit this parameter to scan the + * string until the first 0 byte. If maxBytesToRead is passed, and the string + * at [ptr, ptr+maxBytesToReadr[ contains a null byte in the middle, then the + * string will cut short at that byte index. + * @param {boolean=} ignoreNul - If true, the function will not stop on a NUL character. + * @return {string} + */ + var UTF8ToString = (ptr, maxBytesToRead, ignoreNul) => { + assert(typeof ptr == 'number', `UTF8ToString expects a number (got ${typeof ptr})`); + return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead, ignoreNul) : ''; + }; + var ___assert_fail = (condition, filename, line, func) => + abort(`Assertion failed: ${UTF8ToString(condition)}, at: ` + [filename ? UTF8ToString(filename) : 'unknown filename', line, func ? UTF8ToString(func) : 'unknown function']); + + class ExceptionInfo { + // excPtr - Thrown object pointer to wrap. Metadata pointer is calculated from it. + constructor(excPtr) { + this.excPtr = excPtr; + this.ptr = excPtr - 24; + } + + set_type(type) { + HEAPU32[(((this.ptr)+(4))>>2)] = type; + } + + get_type() { + return HEAPU32[(((this.ptr)+(4))>>2)]; + } + + set_destructor(destructor) { + HEAPU32[(((this.ptr)+(8))>>2)] = destructor; + } + + get_destructor() { + return HEAPU32[(((this.ptr)+(8))>>2)]; + } + + set_caught(caught) { + caught = caught ? 1 : 0; + HEAP8[(this.ptr)+(12)] = caught; + } + + get_caught() { + return HEAP8[(this.ptr)+(12)] != 0; + } + + set_rethrown(rethrown) { + rethrown = rethrown ? 1 : 0; + HEAP8[(this.ptr)+(13)] = rethrown; + } + + get_rethrown() { + return HEAP8[(this.ptr)+(13)] != 0; + } + + // Initialize native structure fields. Should be called once after allocated. + init(type, destructor) { + this.set_adjusted_ptr(0); + this.set_type(type); + this.set_destructor(destructor); + } + + set_adjusted_ptr(adjustedPtr) { + HEAPU32[(((this.ptr)+(16))>>2)] = adjustedPtr; + } + + get_adjusted_ptr() { + return HEAPU32[(((this.ptr)+(16))>>2)]; + } + } + + var exceptionLast = 0; + + var uncaughtExceptionCount = 0; + var ___cxa_throw = (ptr, type, destructor) => { + var info = new ExceptionInfo(ptr); + // Initialize ExceptionInfo content after it was allocated in __cxa_allocate_exception. + info.init(type, destructor); + exceptionLast = ptr; + uncaughtExceptionCount++; + assert(false, 'Exception thrown, but exception catching is not enabled. Compile with -sNO_DISABLE_EXCEPTION_CATCHING or -sEXCEPTION_CATCHING_ALLOWED=[..] to catch.'); + }; + + /** @suppress {duplicate } */ + var syscallGetVarargI = () => { + assert(SYSCALLS.varargs != undefined); + // the `+` prepended here is necessary to convince the JSCompiler that varargs is indeed a number. + var ret = HEAP32[((+SYSCALLS.varargs)>>2)]; + SYSCALLS.varargs += 4; + return ret; + }; + var syscallGetVarargP = syscallGetVarargI; + + + var PATH = { + isAbs:(path) => path.charAt(0) === '/', + splitPath:(filename) => { + var splitPathRe = /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/; + return splitPathRe.exec(filename).slice(1); + }, + normalizeArray:(parts, allowAboveRoot) => { + // if the path tries to go above the root, `up` ends up > 0 + var up = 0; + for (var i = parts.length - 1; i >= 0; i--) { + var last = parts[i]; + if (last === '.') { + parts.splice(i, 1); + } else if (last === '..') { + parts.splice(i, 1); + up++; + } else if (up) { + parts.splice(i, 1); + up--; + } + } + // if the path is allowed to go above the root, restore leading ..s + if (allowAboveRoot) { + for (; up; up--) { + parts.unshift('..'); + } + } + return parts; + }, + normalize:(path) => { + var isAbsolute = PATH.isAbs(path), + trailingSlash = path.slice(-1) === '/'; + // Normalize the path + path = PATH.normalizeArray(path.split('/').filter((p) => !!p), !isAbsolute).join('/'); + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + return (isAbsolute ? '/' : '') + path; + }, + dirname:(path) => { + var result = PATH.splitPath(path), + root = result[0], + dir = result[1]; + if (!root && !dir) { + // No dirname whatsoever + return '.'; + } + if (dir) { + // It has a dirname, strip trailing slash + dir = dir.slice(0, -1); + } + return root + dir; + }, + basename:(path) => path && path.match(/([^\/]+|\/)\/*$/)[1], + join:(...paths) => PATH.normalize(paths.join('/')), + join2:(l, r) => PATH.normalize(l + '/' + r), + }; + + var initRandomFill = () => { + // This block is not needed on v19+ since crypto.getRandomValues is builtin + if (ENVIRONMENT_IS_NODE) { + var nodeCrypto = require('crypto'); + return (view) => nodeCrypto.randomFillSync(view); + } + + return (view) => crypto.getRandomValues(view); + }; + var randomFill = (view) => { + // Lazily init on the first invocation. + (randomFill = initRandomFill())(view); + }; + + + + var PATH_FS = { + resolve:(...args) => { + var resolvedPath = '', + resolvedAbsolute = false; + for (var i = args.length - 1; i >= -1 && !resolvedAbsolute; i--) { + var path = (i >= 0) ? args[i] : FS.cwd(); + // Skip empty and invalid entries + if (typeof path != 'string') { + throw new TypeError('Arguments to path.resolve must be strings'); + } else if (!path) { + return ''; // an invalid portion invalidates the whole thing + } + resolvedPath = path + '/' + resolvedPath; + resolvedAbsolute = PATH.isAbs(path); + } + // At this point the path should be resolved to a full absolute path, but + // handle relative paths to be safe (might happen when process.cwd() fails) + resolvedPath = PATH.normalizeArray(resolvedPath.split('/').filter((p) => !!p), !resolvedAbsolute).join('/'); + return ((resolvedAbsolute ? '/' : '') + resolvedPath) || '.'; + }, + relative:(from, to) => { + from = PATH_FS.resolve(from).slice(1); + to = PATH_FS.resolve(to).slice(1); + function trim(arr) { + var start = 0; + for (; start < arr.length; start++) { + if (arr[start] !== '') break; + } + var end = arr.length - 1; + for (; end >= 0; end--) { + if (arr[end] !== '') break; + } + if (start > end) return []; + return arr.slice(start, end - start + 1); + } + var fromParts = trim(from.split('/')); + var toParts = trim(to.split('/')); + var length = Math.min(fromParts.length, toParts.length); + var samePartsLength = length; + for (var i = 0; i < length; i++) { + if (fromParts[i] !== toParts[i]) { + samePartsLength = i; + break; + } + } + var outputParts = []; + for (var i = samePartsLength; i < fromParts.length; i++) { + outputParts.push('..'); + } + outputParts = outputParts.concat(toParts.slice(samePartsLength)); + return outputParts.join('/'); + }, + }; + + + + var FS_stdin_getChar_buffer = []; + + var lengthBytesUTF8 = (str) => { + var len = 0; + for (var i = 0; i < str.length; ++i) { + // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code + // unit, not a Unicode code point of the character! So decode + // UTF16->UTF32->UTF8. + // See http://unicode.org/faq/utf_bom.html#utf16-3 + var c = str.charCodeAt(i); // possibly a lead surrogate + if (c <= 0x7F) { + len++; + } else if (c <= 0x7FF) { + len += 2; + } else if (c >= 0xD800 && c <= 0xDFFF) { + len += 4; ++i; + } else { + len += 3; + } + } + return len; + }; + + var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + assert(typeof str === 'string', `stringToUTF8Array expects a string (got ${typeof str})`); + // Parameter maxBytesToWrite is not optional. Negative values, 0, null, + // undefined and false each don't write out any bytes. + if (!(maxBytesToWrite > 0)) + return 0; + + var startIdx = outIdx; + var endIdx = outIdx + maxBytesToWrite - 1; // -1 for string null terminator. + for (var i = 0; i < str.length; ++i) { + // For UTF8 byte structure, see http://en.wikipedia.org/wiki/UTF-8#Description + // and https://www.ietf.org/rfc/rfc2279.txt + // and https://tools.ietf.org/html/rfc3629 + var u = str.codePointAt(i); + if (u <= 0x7F) { + if (outIdx >= endIdx) break; + heap[outIdx++] = u; + } else if (u <= 0x7FF) { + if (outIdx + 1 >= endIdx) break; + heap[outIdx++] = 0xC0 | (u >> 6); + heap[outIdx++] = 0x80 | (u & 63); + } else if (u <= 0xFFFF) { + if (outIdx + 2 >= endIdx) break; + heap[outIdx++] = 0xE0 | (u >> 12); + heap[outIdx++] = 0x80 | ((u >> 6) & 63); + heap[outIdx++] = 0x80 | (u & 63); + } else { + if (outIdx + 3 >= endIdx) break; + if (u > 0x10FFFF) warnOnce('Invalid Unicode code point ' + ptrToString(u) + ' encountered when serializing a JS string to a UTF-8 string in wasm memory! (Valid unicode code points should be in range 0-0x10FFFF).'); + heap[outIdx++] = 0xF0 | (u >> 18); + heap[outIdx++] = 0x80 | ((u >> 12) & 63); + heap[outIdx++] = 0x80 | ((u >> 6) & 63); + heap[outIdx++] = 0x80 | (u & 63); + // Gotcha: if codePoint is over 0xFFFF, it is represented as a surrogate pair in UTF-16. + // We need to manually skip over the second code unit for correct iteration. + i++; + } + } + // Null-terminate the pointer to the buffer. + heap[outIdx] = 0; + return outIdx - startIdx; + }; + /** @type {function(string, boolean=, number=)} */ + var intArrayFromString = (stringy, dontAddNull, length) => { + var len = length > 0 ? length : lengthBytesUTF8(stringy)+1; + var u8array = new Array(len); + var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length); + if (dontAddNull) u8array.length = numBytesWritten; + return u8array; + }; + var FS_stdin_getChar = () => { + if (!FS_stdin_getChar_buffer.length) { + var result = null; + if (ENVIRONMENT_IS_NODE) { + // we will read data by chunks of BUFSIZE + var BUFSIZE = 256; + var buf = Buffer.alloc(BUFSIZE); + var bytesRead = 0; + + // For some reason we must suppress a closure warning here, even though + // fd definitely exists on process.stdin, and is even the proper way to + // get the fd of stdin, + // https://github.com/nodejs/help/issues/2136#issuecomment-523649904 + // This started to happen after moving this logic out of library_tty.js, + // so it is related to the surrounding code in some unclear manner. + /** @suppress {missingProperties} */ + var fd = process.stdin.fd; + + try { + bytesRead = fs.readSync(fd, buf, 0, BUFSIZE); + } catch(e) { + // Cross-platform differences: on Windows, reading EOF throws an + // exception, but on other OSes, reading EOF returns 0. Uniformize + // behavior by treating the EOF exception to return 0. + if (e.toString().includes('EOF')) bytesRead = 0; + else throw e; + } + + if (bytesRead > 0) { + result = buf.slice(0, bytesRead).toString('utf-8'); + } + } else + if (globalThis.window?.prompt) { + // Browser. + result = window.prompt('Input: '); // returns null on cancel + if (result !== null) { + result += '\n'; + } + } else + {} + if (!result) { + return null; + } + FS_stdin_getChar_buffer = intArrayFromString(result, true); + } + return FS_stdin_getChar_buffer.shift(); + }; + var TTY = { + ttys:[], + init() { + // https://github.com/emscripten-core/emscripten/pull/1555 + // if (ENVIRONMENT_IS_NODE) { + // // currently, FS.init does not distinguish if process.stdin is a file or TTY + // // device, it always assumes it's a TTY device. because of this, we're forcing + // // process.stdin to UTF8 encoding to at least make stdin reading compatible + // // with text files until FS.init can be refactored. + // process.stdin.setEncoding('utf8'); + // } + }, + shutdown() { + // https://github.com/emscripten-core/emscripten/pull/1555 + // if (ENVIRONMENT_IS_NODE) { + // // inolen: any idea as to why node -e 'process.stdin.read()' wouldn't exit immediately (with process.stdin being a tty)? + // // isaacs: because now it's reading from the stream, you've expressed interest in it, so that read() kicks off a _read() which creates a ReadReq operation + // // inolen: I thought read() in that case was a synchronous operation that just grabbed some amount of buffered data if it exists? + // // isaacs: it is. but it also triggers a _read() call, which calls readStart() on the handle + // // isaacs: do process.stdin.pause() and i'd think it'd probably close the pending call + // process.stdin.pause(); + // } + }, + register(dev, ops) { + TTY.ttys[dev] = { input: [], output: [], ops: ops }; + FS.registerDevice(dev, TTY.stream_ops); + }, + stream_ops:{ + open(stream) { + var tty = TTY.ttys[stream.node.rdev]; + if (!tty) { + throw new FS.ErrnoError(43); + } + stream.tty = tty; + stream.seekable = false; + }, + close(stream) { + // flush any pending line data + stream.tty.ops.fsync(stream.tty); + }, + fsync(stream) { + stream.tty.ops.fsync(stream.tty); + }, + read(stream, buffer, offset, length, pos /* ignored */) { + if (!stream.tty || !stream.tty.ops.get_char) { + throw new FS.ErrnoError(60); + } + var bytesRead = 0; + for (var i = 0; i < length; i++) { + var result; + try { + result = stream.tty.ops.get_char(stream.tty); + } catch (e) { + throw new FS.ErrnoError(29); + } + if (result === undefined && bytesRead === 0) { + throw new FS.ErrnoError(6); + } + if (result === null || result === undefined) break; + bytesRead++; + buffer[offset+i] = result; + } + if (bytesRead) { + stream.node.atime = Date.now(); + } + return bytesRead; + }, + write(stream, buffer, offset, length, pos) { + if (!stream.tty || !stream.tty.ops.put_char) { + throw new FS.ErrnoError(60); + } + try { + for (var i = 0; i < length; i++) { + stream.tty.ops.put_char(stream.tty, buffer[offset+i]); + } + } catch (e) { + throw new FS.ErrnoError(29); + } + if (length) { + stream.node.mtime = stream.node.ctime = Date.now(); + } + return i; + }, + }, + default_tty_ops:{ + get_char(tty) { + return FS_stdin_getChar(); + }, + put_char(tty, val) { + if (val === null || val === 10) { + out(UTF8ArrayToString(tty.output)); + tty.output = []; + } else { + if (val != 0) tty.output.push(val); // val == 0 would cut text output off in the middle. + } + }, + fsync(tty) { + if (tty.output?.length > 0) { + out(UTF8ArrayToString(tty.output)); + tty.output = []; + } + }, + ioctl_tcgets(tty) { + // typical setting + return { + c_iflag: 25856, + c_oflag: 5, + c_cflag: 191, + c_lflag: 35387, + c_cc: [ + 0x03, 0x1c, 0x7f, 0x15, 0x04, 0x00, 0x01, 0x00, 0x11, 0x13, 0x1a, 0x00, + 0x12, 0x0f, 0x17, 0x16, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ] + }; + }, + ioctl_tcsets(tty, optional_actions, data) { + // currently just ignore + return 0; + }, + ioctl_tiocgwinsz(tty) { + return [24, 80]; + }, + }, + default_tty1_ops:{ + put_char(tty, val) { + if (val === null || val === 10) { + err(UTF8ArrayToString(tty.output)); + tty.output = []; + } else { + if (val != 0) tty.output.push(val); + } + }, + fsync(tty) { + if (tty.output?.length > 0) { + err(UTF8ArrayToString(tty.output)); + tty.output = []; + } + }, + }, + }; + + + var mmapAlloc = (size) => { + abort('internal error: mmapAlloc called but `emscripten_builtin_memalign` native symbol not exported'); + }; + var MEMFS = { + ops_table:null, + mount(mount) { + return MEMFS.createNode(null, '/', 16895, 0); + }, + createNode(parent, name, mode, dev) { + if (FS.isBlkdev(mode) || FS.isFIFO(mode)) { + // no supported + throw new FS.ErrnoError(63); + } + MEMFS.ops_table ||= { + dir: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr, + lookup: MEMFS.node_ops.lookup, + mknod: MEMFS.node_ops.mknod, + rename: MEMFS.node_ops.rename, + unlink: MEMFS.node_ops.unlink, + rmdir: MEMFS.node_ops.rmdir, + readdir: MEMFS.node_ops.readdir, + symlink: MEMFS.node_ops.symlink + }, + stream: { + llseek: MEMFS.stream_ops.llseek + } + }, + file: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr + }, + stream: { + llseek: MEMFS.stream_ops.llseek, + read: MEMFS.stream_ops.read, + write: MEMFS.stream_ops.write, + mmap: MEMFS.stream_ops.mmap, + msync: MEMFS.stream_ops.msync + } + }, + link: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr, + readlink: MEMFS.node_ops.readlink + }, + stream: {} + }, + chrdev: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr + }, + stream: FS.chrdev_stream_ops + } + }; + var node = FS.createNode(parent, name, mode, dev); + if (FS.isDir(node.mode)) { + node.node_ops = MEMFS.ops_table.dir.node; + node.stream_ops = MEMFS.ops_table.dir.stream; + node.contents = {}; + } else if (FS.isFile(node.mode)) { + node.node_ops = MEMFS.ops_table.file.node; + node.stream_ops = MEMFS.ops_table.file.stream; + node.usedBytes = 0; // The actual number of bytes used in the typed array, as opposed to contents.length which gives the whole capacity. + // When the byte data of the file is populated, this will point to either a typed array, or a normal JS array. Typed arrays are preferred + // for performance, and used by default. However, typed arrays are not resizable like normal JS arrays are, so there is a small disk size + // penalty involved for appending file writes that continuously grow a file similar to std::vector capacity vs used -scheme. + node.contents = null; + } else if (FS.isLink(node.mode)) { + node.node_ops = MEMFS.ops_table.link.node; + node.stream_ops = MEMFS.ops_table.link.stream; + } else if (FS.isChrdev(node.mode)) { + node.node_ops = MEMFS.ops_table.chrdev.node; + node.stream_ops = MEMFS.ops_table.chrdev.stream; + } + node.atime = node.mtime = node.ctime = Date.now(); + // add the new node to the parent + if (parent) { + parent.contents[name] = node; + parent.atime = parent.mtime = parent.ctime = node.atime; + } + return node; + }, + getFileDataAsTypedArray(node) { + if (!node.contents) return new Uint8Array(0); + if (node.contents.subarray) return node.contents.subarray(0, node.usedBytes); // Make sure to not return excess unused bytes. + return new Uint8Array(node.contents); + }, + expandFileStorage(node, newCapacity) { + var prevCapacity = node.contents ? node.contents.length : 0; + if (prevCapacity >= newCapacity) return; // No need to expand, the storage was already large enough. + // Don't expand strictly to the given requested limit if it's only a very small increase, but instead geometrically grow capacity. + // For small filesizes (<1MB), perform size*2 geometric increase, but for large sizes, do a much more conservative size*1.125 increase to + // avoid overshooting the allocation cap by a very large margin. + var CAPACITY_DOUBLING_MAX = 1024 * 1024; + newCapacity = Math.max(newCapacity, (prevCapacity * (prevCapacity < CAPACITY_DOUBLING_MAX ? 2.0 : 1.125)) >>> 0); + if (prevCapacity != 0) newCapacity = Math.max(newCapacity, 256); // At minimum allocate 256b for each file when expanding. + var oldContents = node.contents; + node.contents = new Uint8Array(newCapacity); // Allocate new storage. + if (node.usedBytes > 0) node.contents.set(oldContents.subarray(0, node.usedBytes), 0); // Copy old data over to the new storage. + }, + resizeFileStorage(node, newSize) { + if (node.usedBytes == newSize) return; + if (newSize == 0) { + node.contents = null; // Fully decommit when requesting a resize to zero. + node.usedBytes = 0; + } else { + var oldContents = node.contents; + node.contents = new Uint8Array(newSize); // Allocate new storage. + if (oldContents) { + node.contents.set(oldContents.subarray(0, Math.min(newSize, node.usedBytes))); // Copy old data over to the new storage. + } + node.usedBytes = newSize; + } + }, + node_ops:{ + getattr(node) { + var attr = {}; + // device numbers reuse inode numbers. + attr.dev = FS.isChrdev(node.mode) ? node.id : 1; + attr.ino = node.id; + attr.mode = node.mode; + attr.nlink = 1; + attr.uid = 0; + attr.gid = 0; + attr.rdev = node.rdev; + if (FS.isDir(node.mode)) { + attr.size = 4096; + } else if (FS.isFile(node.mode)) { + attr.size = node.usedBytes; + } else if (FS.isLink(node.mode)) { + attr.size = node.link.length; + } else { + attr.size = 0; + } + attr.atime = new Date(node.atime); + attr.mtime = new Date(node.mtime); + attr.ctime = new Date(node.ctime); + // NOTE: In our implementation, st_blocks = Math.ceil(st_size/st_blksize), + // but this is not required by the standard. + attr.blksize = 4096; + attr.blocks = Math.ceil(attr.size / attr.blksize); + return attr; + }, + setattr(node, attr) { + for (const key of ["mode", "atime", "mtime", "ctime"]) { + if (attr[key] != null) { + node[key] = attr[key]; + } + } + if (attr.size !== undefined) { + MEMFS.resizeFileStorage(node, attr.size); + } + }, + lookup(parent, name) { + throw new FS.ErrnoError(44); + }, + mknod(parent, name, mode, dev) { + return MEMFS.createNode(parent, name, mode, dev); + }, + rename(old_node, new_dir, new_name) { + var new_node; + try { + new_node = FS.lookupNode(new_dir, new_name); + } catch (e) {} + if (new_node) { + if (FS.isDir(old_node.mode)) { + // if we're overwriting a directory at new_name, make sure it's empty. + for (var i in new_node.contents) { + throw new FS.ErrnoError(55); + } + } + FS.hashRemoveNode(new_node); + } + // do the internal rewiring + delete old_node.parent.contents[old_node.name]; + new_dir.contents[new_name] = old_node; + old_node.name = new_name; + new_dir.ctime = new_dir.mtime = old_node.parent.ctime = old_node.parent.mtime = Date.now(); + }, + unlink(parent, name) { + delete parent.contents[name]; + parent.ctime = parent.mtime = Date.now(); + }, + rmdir(parent, name) { + var node = FS.lookupNode(parent, name); + for (var i in node.contents) { + throw new FS.ErrnoError(55); + } + delete parent.contents[name]; + parent.ctime = parent.mtime = Date.now(); + }, + readdir(node) { + return ['.', '..', ...Object.keys(node.contents)]; + }, + symlink(parent, newname, oldpath) { + var node = MEMFS.createNode(parent, newname, 0o777 | 40960, 0); + node.link = oldpath; + return node; + }, + readlink(node) { + if (!FS.isLink(node.mode)) { + throw new FS.ErrnoError(28); + } + return node.link; + }, + }, + stream_ops:{ + read(stream, buffer, offset, length, position) { + var contents = stream.node.contents; + if (position >= stream.node.usedBytes) return 0; + var size = Math.min(stream.node.usedBytes - position, length); + assert(size >= 0); + if (size > 8 && contents.subarray) { // non-trivial, and typed array + buffer.set(contents.subarray(position, position + size), offset); + } else { + for (var i = 0; i < size; i++) buffer[offset + i] = contents[position + i]; + } + return size; + }, + write(stream, buffer, offset, length, position, canOwn) { + // The data buffer should be a typed array view + assert(!(buffer instanceof ArrayBuffer)); + // If the buffer is located in main memory (HEAP), and if + // memory can grow, we can't hold on to references of the + // memory buffer, as they may get invalidated. That means we + // need to do copy its contents. + if (buffer.buffer === HEAP8.buffer) { + canOwn = false; + } + + if (!length) return 0; + var node = stream.node; + node.mtime = node.ctime = Date.now(); + + if (buffer.subarray && (!node.contents || node.contents.subarray)) { // This write is from a typed array to a typed array? + if (canOwn) { + assert(position === 0, 'canOwn must imply no weird position inside the file'); + node.contents = buffer.subarray(offset, offset + length); + node.usedBytes = length; + return length; + } else if (node.usedBytes === 0 && position === 0) { // If this is a simple first write to an empty file, do a fast set since we don't need to care about old data. + node.contents = buffer.slice(offset, offset + length); + node.usedBytes = length; + return length; + } else if (position + length <= node.usedBytes) { // Writing to an already allocated and used subrange of the file? + node.contents.set(buffer.subarray(offset, offset + length), position); + return length; + } + } + + // Appending to an existing file and we need to reallocate, or source data did not come as a typed array. + MEMFS.expandFileStorage(node, position+length); + if (node.contents.subarray && buffer.subarray) { + // Use typed array write which is available. + node.contents.set(buffer.subarray(offset, offset + length), position); + } else { + for (var i = 0; i < length; i++) { + node.contents[position + i] = buffer[offset + i]; // Or fall back to manual write if not. + } + } + node.usedBytes = Math.max(node.usedBytes, position + length); + return length; + }, + llseek(stream, offset, whence) { + var position = offset; + if (whence === 1) { + position += stream.position; + } else if (whence === 2) { + if (FS.isFile(stream.node.mode)) { + position += stream.node.usedBytes; + } + } + if (position < 0) { + throw new FS.ErrnoError(28); + } + return position; + }, + mmap(stream, length, position, prot, flags) { + if (!FS.isFile(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + var ptr; + var allocated; + var contents = stream.node.contents; + // Only make a new copy when MAP_PRIVATE is specified. + if (!(flags & 2) && contents && contents.buffer === HEAP8.buffer) { + // We can't emulate MAP_SHARED when the file is not backed by the + // buffer we're mapping to (e.g. the HEAP buffer). + allocated = false; + ptr = contents.byteOffset; + } else { + allocated = true; + ptr = mmapAlloc(length); + if (!ptr) { + throw new FS.ErrnoError(48); + } + if (contents) { + // Try to avoid unnecessary slices. + if (position > 0 || position + length < contents.length) { + if (contents.subarray) { + contents = contents.subarray(position, position + length); + } else { + contents = Array.prototype.slice.call(contents, position, position + length); + } + } + HEAP8.set(contents, ptr); + } + } + return { ptr, allocated }; + }, + msync(stream, buffer, offset, length, mmapFlags) { + MEMFS.stream_ops.write(stream, buffer, 0, length, offset, false); + // should we check if bytesWritten and length are the same? + return 0; + }, + }, + }; + + var FS_modeStringToFlags = (str) => { + var flagModes = { + 'r': 0, + 'r+': 2, + 'w': 512 | 64 | 1, + 'w+': 512 | 64 | 2, + 'a': 1024 | 64 | 1, + 'a+': 1024 | 64 | 2, + }; + var flags = flagModes[str]; + if (typeof flags == 'undefined') { + throw new Error(`Unknown file open mode: ${str}`); + } + return flags; + }; + + var FS_getMode = (canRead, canWrite) => { + var mode = 0; + if (canRead) mode |= 292 | 73; + if (canWrite) mode |= 146; + return mode; + }; + + + + + var strError = (errno) => UTF8ToString(_strerror(errno)); + + var ERRNO_CODES = { + 'EPERM': 63, + 'ENOENT': 44, + 'ESRCH': 71, + 'EINTR': 27, + 'EIO': 29, + 'ENXIO': 60, + 'E2BIG': 1, + 'ENOEXEC': 45, + 'EBADF': 8, + 'ECHILD': 12, + 'EAGAIN': 6, + 'EWOULDBLOCK': 6, + 'ENOMEM': 48, + 'EACCES': 2, + 'EFAULT': 21, + 'ENOTBLK': 105, + 'EBUSY': 10, + 'EEXIST': 20, + 'EXDEV': 75, + 'ENODEV': 43, + 'ENOTDIR': 54, + 'EISDIR': 31, + 'EINVAL': 28, + 'ENFILE': 41, + 'EMFILE': 33, + 'ENOTTY': 59, + 'ETXTBSY': 74, + 'EFBIG': 22, + 'ENOSPC': 51, + 'ESPIPE': 70, + 'EROFS': 69, + 'EMLINK': 34, + 'EPIPE': 64, + 'EDOM': 18, + 'ERANGE': 68, + 'ENOMSG': 49, + 'EIDRM': 24, + 'ECHRNG': 106, + 'EL2NSYNC': 156, + 'EL3HLT': 107, + 'EL3RST': 108, + 'ELNRNG': 109, + 'EUNATCH': 110, + 'ENOCSI': 111, + 'EL2HLT': 112, + 'EDEADLK': 16, + 'ENOLCK': 46, + 'EBADE': 113, + 'EBADR': 114, + 'EXFULL': 115, + 'ENOANO': 104, + 'EBADRQC': 103, + 'EBADSLT': 102, + 'EDEADLOCK': 16, + 'EBFONT': 101, + 'ENOSTR': 100, + 'ENODATA': 116, + 'ETIME': 117, + 'ENOSR': 118, + 'ENONET': 119, + 'ENOPKG': 120, + 'EREMOTE': 121, + 'ENOLINK': 47, + 'EADV': 122, + 'ESRMNT': 123, + 'ECOMM': 124, + 'EPROTO': 65, + 'EMULTIHOP': 36, + 'EDOTDOT': 125, + 'EBADMSG': 9, + 'ENOTUNIQ': 126, + 'EBADFD': 127, + 'EREMCHG': 128, + 'ELIBACC': 129, + 'ELIBBAD': 130, + 'ELIBSCN': 131, + 'ELIBMAX': 132, + 'ELIBEXEC': 133, + 'ENOSYS': 52, + 'ENOTEMPTY': 55, + 'ENAMETOOLONG': 37, + 'ELOOP': 32, + 'EOPNOTSUPP': 138, + 'EPFNOSUPPORT': 139, + 'ECONNRESET': 15, + 'ENOBUFS': 42, + 'EAFNOSUPPORT': 5, + 'EPROTOTYPE': 67, + 'ENOTSOCK': 57, + 'ENOPROTOOPT': 50, + 'ESHUTDOWN': 140, + 'ECONNREFUSED': 14, + 'EADDRINUSE': 3, + 'ECONNABORTED': 13, + 'ENETUNREACH': 40, + 'ENETDOWN': 38, + 'ETIMEDOUT': 73, + 'EHOSTDOWN': 142, + 'EHOSTUNREACH': 23, + 'EINPROGRESS': 26, + 'EALREADY': 7, + 'EDESTADDRREQ': 17, + 'EMSGSIZE': 35, + 'EPROTONOSUPPORT': 66, + 'ESOCKTNOSUPPORT': 137, + 'EADDRNOTAVAIL': 4, + 'ENETRESET': 39, + 'EISCONN': 30, + 'ENOTCONN': 53, + 'ETOOMANYREFS': 141, + 'EUSERS': 136, + 'EDQUOT': 19, + 'ESTALE': 72, + 'ENOTSUP': 138, + 'ENOMEDIUM': 148, + 'EILSEQ': 25, + 'EOVERFLOW': 61, + 'ECANCELED': 11, + 'ENOTRECOVERABLE': 56, + 'EOWNERDEAD': 62, + 'ESTRPIPE': 135, + }; + + var asyncLoad = async (url) => { + var arrayBuffer = await readAsync(url); + assert(arrayBuffer, `Loading data file "${url}" failed (no arrayBuffer).`); + return new Uint8Array(arrayBuffer); + }; + + + var FS_createDataFile = (...args) => FS.createDataFile(...args); + + var getUniqueRunDependency = (id) => { + var orig = id; + while (1) { + if (!runDependencyTracking[id]) return id; + id = orig + Math.random(); + } + }; + + var runDependencies = 0; + + + var dependenciesFulfilled = null; + + var runDependencyTracking = { + }; + + var runDependencyWatcher = null; + var removeRunDependency = (id) => { + runDependencies--; + + Module['monitorRunDependencies']?.(runDependencies); + + assert(id, 'removeRunDependency requires an ID'); + assert(runDependencyTracking[id]); + delete runDependencyTracking[id]; + if (runDependencies == 0) { + if (runDependencyWatcher !== null) { + clearInterval(runDependencyWatcher); + runDependencyWatcher = null; + } + if (dependenciesFulfilled) { + var callback = dependenciesFulfilled; + dependenciesFulfilled = null; + callback(); // can add another dependenciesFulfilled + } + } + }; + + + var addRunDependency = (id) => { + runDependencies++; + + Module['monitorRunDependencies']?.(runDependencies); + + assert(id, 'addRunDependency requires an ID') + assert(!runDependencyTracking[id]); + runDependencyTracking[id] = 1; + if (runDependencyWatcher === null && globalThis.setInterval) { + // Check for missing dependencies every few seconds + runDependencyWatcher = setInterval(() => { + if (ABORT) { + clearInterval(runDependencyWatcher); + runDependencyWatcher = null; + return; + } + var shown = false; + for (var dep in runDependencyTracking) { + if (!shown) { + shown = true; + err('still waiting on run dependencies:'); + } + err(`dependency: ${dep}`); + } + if (shown) { + err('(end of list)'); + } + }, 10000); + // Prevent this timer from keeping the runtime alive if nothing + // else is. + runDependencyWatcher.unref?.() + } + }; + + + var preloadPlugins = []; + var FS_handledByPreloadPlugin = async (byteArray, fullname) => { + // Ensure plugins are ready. + if (typeof Browser != 'undefined') Browser.init(); + + for (var plugin of preloadPlugins) { + if (plugin['canHandle'](fullname)) { + assert(plugin['handle'].constructor.name === 'AsyncFunction', 'Filesystem plugin handlers must be async functions (See #24914)') + return plugin['handle'](byteArray, fullname); + } + } + // In no plugin handled this file then return the original/unmodified + // byteArray. + return byteArray; + }; + var FS_preloadFile = async (parent, name, url, canRead, canWrite, dontCreateFile, canOwn, preFinish) => { + // TODO we should allow people to just pass in a complete filename instead + // of parent and name being that we just join them anyways + var fullname = name ? PATH_FS.resolve(PATH.join2(parent, name)) : parent; + var dep = getUniqueRunDependency(`cp ${fullname}`); // might have several active requests for the same fullname + addRunDependency(dep); + + try { + var byteArray = url; + if (typeof url == 'string') { + byteArray = await asyncLoad(url); + } + + byteArray = await FS_handledByPreloadPlugin(byteArray, fullname); + preFinish?.(); + if (!dontCreateFile) { + FS_createDataFile(parent, name, byteArray, canRead, canWrite, canOwn); + } + } finally { + removeRunDependency(dep); + } + }; + var FS_createPreloadedFile = (parent, name, url, canRead, canWrite, onload, onerror, dontCreateFile, canOwn, preFinish) => { + FS_preloadFile(parent, name, url, canRead, canWrite, dontCreateFile, canOwn, preFinish).then(onload).catch(onerror); + }; + var FS = { + root:null, + mounts:[], + devices:{ + }, + streams:[], + nextInode:1, + nameTable:null, + currentPath:"/", + initialized:false, + ignorePermissions:true, + filesystems:null, + syncFSRequests:0, + readFiles:{ + }, + ErrnoError:class extends Error { + name = 'ErrnoError'; + // We set the `name` property to be able to identify `FS.ErrnoError` + // - the `name` is a standard ECMA-262 property of error objects. Kind of good to have it anyway. + // - when using PROXYFS, an error can come from an underlying FS + // as different FS objects have their own FS.ErrnoError each, + // the test `err instanceof FS.ErrnoError` won't detect an error coming from another filesystem, causing bugs. + // we'll use the reliable test `err.name == "ErrnoError"` instead + constructor(errno) { + super(runtimeInitialized ? strError(errno) : ''); + this.errno = errno; + for (var key in ERRNO_CODES) { + if (ERRNO_CODES[key] === errno) { + this.code = key; + break; + } + } + } + }, + FSStream:class { + shared = {}; + get object() { + return this.node; + } + set object(val) { + this.node = val; + } + get isRead() { + return (this.flags & 2097155) !== 1; + } + get isWrite() { + return (this.flags & 2097155) !== 0; + } + get isAppend() { + return (this.flags & 1024); + } + get flags() { + return this.shared.flags; + } + set flags(val) { + this.shared.flags = val; + } + get position() { + return this.shared.position; + } + set position(val) { + this.shared.position = val; + } + }, + FSNode:class { + node_ops = {}; + stream_ops = {}; + readMode = 292 | 73; + writeMode = 146; + mounted = null; + constructor(parent, name, mode, rdev) { + if (!parent) { + parent = this; // root node sets parent to itself + } + this.parent = parent; + this.mount = parent.mount; + this.id = FS.nextInode++; + this.name = name; + this.mode = mode; + this.rdev = rdev; + this.atime = this.mtime = this.ctime = Date.now(); + } + get read() { + return (this.mode & this.readMode) === this.readMode; + } + set read(val) { + val ? this.mode |= this.readMode : this.mode &= ~this.readMode; + } + get write() { + return (this.mode & this.writeMode) === this.writeMode; + } + set write(val) { + val ? this.mode |= this.writeMode : this.mode &= ~this.writeMode; + } + get isFolder() { + return FS.isDir(this.mode); + } + get isDevice() { + return FS.isChrdev(this.mode); + } + }, + lookupPath(path, opts = {}) { + if (!path) { + throw new FS.ErrnoError(44); + } + opts.follow_mount ??= true + + if (!PATH.isAbs(path)) { + path = FS.cwd() + '/' + path; + } + + // limit max consecutive symlinks to 40 (SYMLOOP_MAX). + linkloop: for (var nlinks = 0; nlinks < 40; nlinks++) { + // split the absolute path + var parts = path.split('/').filter((p) => !!p); + + // start at the root + var current = FS.root; + var current_path = '/'; + + for (var i = 0; i < parts.length; i++) { + var islast = (i === parts.length-1); + if (islast && opts.parent) { + // stop resolving + break; + } + + if (parts[i] === '.') { + continue; + } + + if (parts[i] === '..') { + current_path = PATH.dirname(current_path); + if (FS.isRoot(current)) { + path = current_path + '/' + parts.slice(i + 1).join('/'); + // We're making progress here, don't let many consecutive ..'s + // lead to ELOOP + nlinks--; + continue linkloop; + } else { + current = current.parent; + } + continue; + } + + current_path = PATH.join2(current_path, parts[i]); + try { + current = FS.lookupNode(current, parts[i]); + } catch (e) { + // if noent_okay is true, suppress a ENOENT in the last component + // and return an object with an undefined node. This is needed for + // resolving symlinks in the path when creating a file. + if ((e?.errno === 44) && islast && opts.noent_okay) { + return { path: current_path }; + } + throw e; + } + + // jump to the mount's root node if this is a mountpoint + if (FS.isMountpoint(current) && (!islast || opts.follow_mount)) { + current = current.mounted.root; + } + + // by default, lookupPath will not follow a symlink if it is the final path component. + // setting opts.follow = true will override this behavior. + if (FS.isLink(current.mode) && (!islast || opts.follow)) { + if (!current.node_ops.readlink) { + throw new FS.ErrnoError(52); + } + var link = current.node_ops.readlink(current); + if (!PATH.isAbs(link)) { + link = PATH.dirname(current_path) + '/' + link; + } + path = link + '/' + parts.slice(i + 1).join('/'); + continue linkloop; + } + } + return { path: current_path, node: current }; + } + throw new FS.ErrnoError(32); + }, + getPath(node) { + var path; + while (true) { + if (FS.isRoot(node)) { + var mount = node.mount.mountpoint; + if (!path) return mount; + return mount[mount.length-1] !== '/' ? `${mount}/${path}` : mount + path; + } + path = path ? `${node.name}/${path}` : node.name; + node = node.parent; + } + }, + hashName(parentid, name) { + var hash = 0; + + for (var i = 0; i < name.length; i++) { + hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; + } + return ((parentid + hash) >>> 0) % FS.nameTable.length; + }, + hashAddNode(node) { + var hash = FS.hashName(node.parent.id, node.name); + node.name_next = FS.nameTable[hash]; + FS.nameTable[hash] = node; + }, + hashRemoveNode(node) { + var hash = FS.hashName(node.parent.id, node.name); + if (FS.nameTable[hash] === node) { + FS.nameTable[hash] = node.name_next; + } else { + var current = FS.nameTable[hash]; + while (current) { + if (current.name_next === node) { + current.name_next = node.name_next; + break; + } + current = current.name_next; + } + } + }, + lookupNode(parent, name) { + var errCode = FS.mayLookup(parent); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + var hash = FS.hashName(parent.id, name); + for (var node = FS.nameTable[hash]; node; node = node.name_next) { + var nodeName = node.name; + if (node.parent.id === parent.id && nodeName === name) { + return node; + } + } + // if we failed to find it in the cache, call into the VFS + return FS.lookup(parent, name); + }, + createNode(parent, name, mode, rdev) { + assert(typeof parent == 'object') + var node = new FS.FSNode(parent, name, mode, rdev); + + FS.hashAddNode(node); + + return node; + }, + destroyNode(node) { + FS.hashRemoveNode(node); + }, + isRoot(node) { + return node === node.parent; + }, + isMountpoint(node) { + return !!node.mounted; + }, + isFile(mode) { + return (mode & 61440) === 32768; + }, + isDir(mode) { + return (mode & 61440) === 16384; + }, + isLink(mode) { + return (mode & 61440) === 40960; + }, + isChrdev(mode) { + return (mode & 61440) === 8192; + }, + isBlkdev(mode) { + return (mode & 61440) === 24576; + }, + isFIFO(mode) { + return (mode & 61440) === 4096; + }, + isSocket(mode) { + return (mode & 49152) === 49152; + }, + flagsToPermissionString(flag) { + var perms = ['r', 'w', 'rw'][flag & 3]; + if ((flag & 512)) { + perms += 'w'; + } + return perms; + }, + nodePermissions(node, perms) { + if (FS.ignorePermissions) { + return 0; + } + // return 0 if any user, group or owner bits are set. + if (perms.includes('r') && !(node.mode & 292)) { + return 2; + } else if (perms.includes('w') && !(node.mode & 146)) { + return 2; + } else if (perms.includes('x') && !(node.mode & 73)) { + return 2; + } + return 0; + }, + mayLookup(dir) { + if (!FS.isDir(dir.mode)) return 54; + var errCode = FS.nodePermissions(dir, 'x'); + if (errCode) return errCode; + if (!dir.node_ops.lookup) return 2; + return 0; + }, + mayCreate(dir, name) { + if (!FS.isDir(dir.mode)) { + return 54; + } + try { + var node = FS.lookupNode(dir, name); + return 20; + } catch (e) { + } + return FS.nodePermissions(dir, 'wx'); + }, + mayDelete(dir, name, isdir) { + var node; + try { + node = FS.lookupNode(dir, name); + } catch (e) { + return e.errno; + } + var errCode = FS.nodePermissions(dir, 'wx'); + if (errCode) { + return errCode; + } + if (isdir) { + if (!FS.isDir(node.mode)) { + return 54; + } + if (FS.isRoot(node) || FS.getPath(node) === FS.cwd()) { + return 10; + } + } else { + if (FS.isDir(node.mode)) { + return 31; + } + } + return 0; + }, + mayOpen(node, flags) { + if (!node) { + return 44; + } + if (FS.isLink(node.mode)) { + return 32; + } else if (FS.isDir(node.mode)) { + if (FS.flagsToPermissionString(flags) !== 'r' // opening for write + || (flags & (512 | 64))) { // TODO: check for O_SEARCH? (== search for dir only) + return 31; + } + } + return FS.nodePermissions(node, FS.flagsToPermissionString(flags)); + }, + checkOpExists(op, err) { + if (!op) { + throw new FS.ErrnoError(err); + } + return op; + }, + MAX_OPEN_FDS:4096, + nextfd() { + for (var fd = 0; fd <= FS.MAX_OPEN_FDS; fd++) { + if (!FS.streams[fd]) { + return fd; + } + } + throw new FS.ErrnoError(33); + }, + getStreamChecked(fd) { + var stream = FS.getStream(fd); + if (!stream) { + throw new FS.ErrnoError(8); + } + return stream; + }, + getStream:(fd) => FS.streams[fd], + createStream(stream, fd = -1) { + assert(fd >= -1); + + // clone it, so we can return an instance of FSStream + stream = Object.assign(new FS.FSStream(), stream); + if (fd == -1) { + fd = FS.nextfd(); + } + stream.fd = fd; + FS.streams[fd] = stream; + return stream; + }, + closeStream(fd) { + FS.streams[fd] = null; + }, + dupStream(origStream, fd = -1) { + var stream = FS.createStream(origStream, fd); + stream.stream_ops?.dup?.(stream); + return stream; + }, + doSetAttr(stream, node, attr) { + var setattr = stream?.stream_ops.setattr; + var arg = setattr ? stream : node; + setattr ??= node.node_ops.setattr; + FS.checkOpExists(setattr, 63) + setattr(arg, attr); + }, + chrdev_stream_ops:{ + open(stream) { + var device = FS.getDevice(stream.node.rdev); + // override node's stream ops with the device's + stream.stream_ops = device.stream_ops; + // forward the open call + stream.stream_ops.open?.(stream); + }, + llseek() { + throw new FS.ErrnoError(70); + }, + }, + major:(dev) => ((dev) >> 8), + minor:(dev) => ((dev) & 0xff), + makedev:(ma, mi) => ((ma) << 8 | (mi)), + registerDevice(dev, ops) { + FS.devices[dev] = { stream_ops: ops }; + }, + getDevice:(dev) => FS.devices[dev], + getMounts(mount) { + var mounts = []; + var check = [mount]; + + while (check.length) { + var m = check.pop(); + + mounts.push(m); + + check.push(...m.mounts); + } + + return mounts; + }, + syncfs(populate, callback) { + if (typeof populate == 'function') { + callback = populate; + populate = false; + } + + FS.syncFSRequests++; + + if (FS.syncFSRequests > 1) { + err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`); + } + + var mounts = FS.getMounts(FS.root.mount); + var completed = 0; + + function doCallback(errCode) { + assert(FS.syncFSRequests > 0); + FS.syncFSRequests--; + return callback(errCode); + } + + function done(errCode) { + if (errCode) { + if (!done.errored) { + done.errored = true; + return doCallback(errCode); + } + return; + } + if (++completed >= mounts.length) { + doCallback(null); + } + }; + + // sync all mounts + mounts.forEach((mount) => { + if (!mount.type.syncfs) { + return done(null); + } + mount.type.syncfs(mount, populate, done); + }); + }, + mount(type, opts, mountpoint) { + if (typeof type == 'string') { + // The filesystem was not included, and instead we have an error + // message stored in the variable. + throw type; + } + var root = mountpoint === '/'; + var pseudo = !mountpoint; + var node; + + if (root && FS.root) { + throw new FS.ErrnoError(10); + } else if (!root && !pseudo) { + var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); + + mountpoint = lookup.path; // use the absolute path + node = lookup.node; + + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + + if (!FS.isDir(node.mode)) { + throw new FS.ErrnoError(54); + } + } + + var mount = { + type, + opts, + mountpoint, + mounts: [] + }; + + // create a root node for the fs + var mountRoot = type.mount(mount); + mountRoot.mount = mount; + mount.root = mountRoot; + + if (root) { + FS.root = mountRoot; + } else if (node) { + // set as a mountpoint + node.mounted = mount; + + // add the new mount to the current mount's children + if (node.mount) { + node.mount.mounts.push(mount); + } + } + + return mountRoot; + }, + unmount(mountpoint) { + var lookup = FS.lookupPath(mountpoint, { follow_mount: false }); + + if (!FS.isMountpoint(lookup.node)) { + throw new FS.ErrnoError(28); + } + + // destroy the nodes for this mount, and all its child mounts + var node = lookup.node; + var mount = node.mounted; + var mounts = FS.getMounts(mount); + + Object.keys(FS.nameTable).forEach((hash) => { + var current = FS.nameTable[hash]; + + while (current) { + var next = current.name_next; + + if (mounts.includes(current.mount)) { + FS.destroyNode(current); + } + + current = next; + } + }); + + // no longer a mountpoint + node.mounted = null; + + // remove this mount from the child mounts + var idx = node.mount.mounts.indexOf(mount); + assert(idx !== -1); + node.mount.mounts.splice(idx, 1); + }, + lookup(parent, name) { + return parent.node_ops.lookup(parent, name); + }, + mknod(path, mode, dev) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + var name = PATH.basename(path); + if (!name) { + throw new FS.ErrnoError(28); + } + if (name === '.' || name === '..') { + throw new FS.ErrnoError(20); + } + var errCode = FS.mayCreate(parent, name); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.mknod) { + throw new FS.ErrnoError(63); + } + return parent.node_ops.mknod(parent, name, mode, dev); + }, + statfs(path) { + return FS.statfsNode(FS.lookupPath(path, {follow: true}).node); + }, + statfsStream(stream) { + // We keep a separate statfsStream function because noderawfs overrides + // it. In noderawfs, stream.node is sometimes null. Instead, we need to + // look at stream.path. + return FS.statfsNode(stream.node); + }, + statfsNode(node) { + // NOTE: None of the defaults here are true. We're just returning safe and + // sane values. Currently nodefs and rawfs replace these defaults, + // other file systems leave them alone. + var rtn = { + bsize: 4096, + frsize: 4096, + blocks: 1e6, + bfree: 5e5, + bavail: 5e5, + files: FS.nextInode, + ffree: FS.nextInode - 1, + fsid: 42, + flags: 2, + namelen: 255, + }; + + if (node.node_ops.statfs) { + Object.assign(rtn, node.node_ops.statfs(node.mount.opts.root)); + } + return rtn; + }, + create(path, mode = 0o666) { + mode &= 4095; + mode |= 32768; + return FS.mknod(path, mode, 0); + }, + mkdir(path, mode = 0o777) { + mode &= 511 | 512; + mode |= 16384; + return FS.mknod(path, mode, 0); + }, + mkdirTree(path, mode) { + var dirs = path.split('/'); + var d = ''; + for (var dir of dirs) { + if (!dir) continue; + if (d || PATH.isAbs(path)) d += '/'; + d += dir; + try { + FS.mkdir(d, mode); + } catch(e) { + if (e.errno != 20) throw e; + } + } + }, + mkdev(path, mode, dev) { + if (typeof dev == 'undefined') { + dev = mode; + mode = 0o666; + } + mode |= 8192; + return FS.mknod(path, mode, dev); + }, + symlink(oldpath, newpath) { + if (!PATH_FS.resolve(oldpath)) { + throw new FS.ErrnoError(44); + } + var lookup = FS.lookupPath(newpath, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var newname = PATH.basename(newpath); + var errCode = FS.mayCreate(parent, newname); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.symlink) { + throw new FS.ErrnoError(63); + } + return parent.node_ops.symlink(parent, newname, oldpath); + }, + rename(old_path, new_path) { + var old_dirname = PATH.dirname(old_path); + var new_dirname = PATH.dirname(new_path); + var old_name = PATH.basename(old_path); + var new_name = PATH.basename(new_path); + // parents must exist + var lookup, old_dir, new_dir; + + // let the errors from non existent directories percolate up + lookup = FS.lookupPath(old_path, { parent: true }); + old_dir = lookup.node; + lookup = FS.lookupPath(new_path, { parent: true }); + new_dir = lookup.node; + + if (!old_dir || !new_dir) throw new FS.ErrnoError(44); + // need to be part of the same mount + if (old_dir.mount !== new_dir.mount) { + throw new FS.ErrnoError(75); + } + // source must exist + var old_node = FS.lookupNode(old_dir, old_name); + // old path should not be an ancestor of the new path + var relative = PATH_FS.relative(old_path, new_dirname); + if (relative.charAt(0) !== '.') { + throw new FS.ErrnoError(28); + } + // new path should not be an ancestor of the old path + relative = PATH_FS.relative(new_path, old_dirname); + if (relative.charAt(0) !== '.') { + throw new FS.ErrnoError(55); + } + // see if the new path already exists + var new_node; + try { + new_node = FS.lookupNode(new_dir, new_name); + } catch (e) { + // not fatal + } + // early out if nothing needs to change + if (old_node === new_node) { + return; + } + // we'll need to delete the old entry + var isdir = FS.isDir(old_node.mode); + var errCode = FS.mayDelete(old_dir, old_name, isdir); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + // need delete permissions if we'll be overwriting. + // need create permissions if new doesn't already exist. + errCode = new_node ? + FS.mayDelete(new_dir, new_name, isdir) : + FS.mayCreate(new_dir, new_name); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!old_dir.node_ops.rename) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(old_node) || (new_node && FS.isMountpoint(new_node))) { + throw new FS.ErrnoError(10); + } + // if we are going to change the parent, check write permissions + if (new_dir !== old_dir) { + errCode = FS.nodePermissions(old_dir, 'w'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + } + // remove the node from the lookup hash + FS.hashRemoveNode(old_node); + // do the underlying fs rename + try { + old_dir.node_ops.rename(old_node, new_dir, new_name); + // update old node (we do this here to avoid each backend + // needing to) + old_node.parent = new_dir; + } catch (e) { + throw e; + } finally { + // add the node back to the hash (in case node_ops.rename + // changed its name) + FS.hashAddNode(old_node); + } + }, + rmdir(path) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + var name = PATH.basename(path); + var node = FS.lookupNode(parent, name); + var errCode = FS.mayDelete(parent, name, true); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.rmdir) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + parent.node_ops.rmdir(parent, name); + FS.destroyNode(node); + }, + readdir(path) { + var lookup = FS.lookupPath(path, { follow: true }); + var node = lookup.node; + var readdir = FS.checkOpExists(node.node_ops.readdir, 54); + return readdir(node); + }, + unlink(path) { + var lookup = FS.lookupPath(path, { parent: true }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var name = PATH.basename(path); + var node = FS.lookupNode(parent, name); + var errCode = FS.mayDelete(parent, name, false); + if (errCode) { + // According to POSIX, we should map EISDIR to EPERM, but + // we instead do what Linux does (and we must, as we use + // the musl linux libc). + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.unlink) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + parent.node_ops.unlink(parent, name); + FS.destroyNode(node); + }, + readlink(path) { + var lookup = FS.lookupPath(path); + var link = lookup.node; + if (!link) { + throw new FS.ErrnoError(44); + } + if (!link.node_ops.readlink) { + throw new FS.ErrnoError(28); + } + return link.node_ops.readlink(link); + }, + stat(path, dontFollow) { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + var node = lookup.node; + var getattr = FS.checkOpExists(node.node_ops.getattr, 63); + return getattr(node); + }, + fstat(fd) { + var stream = FS.getStreamChecked(fd); + var node = stream.node; + var getattr = stream.stream_ops.getattr; + var arg = getattr ? stream : node; + getattr ??= node.node_ops.getattr; + FS.checkOpExists(getattr, 63) + return getattr(arg); + }, + lstat(path) { + return FS.stat(path, true); + }, + doChmod(stream, node, mode, dontFollow) { + FS.doSetAttr(stream, node, { + mode: (mode & 4095) | (node.mode & ~4095), + ctime: Date.now(), + dontFollow + }); + }, + chmod(path, mode, dontFollow) { + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + node = lookup.node; + } else { + node = path; + } + FS.doChmod(null, node, mode, dontFollow); + }, + lchmod(path, mode) { + FS.chmod(path, mode, true); + }, + fchmod(fd, mode) { + var stream = FS.getStreamChecked(fd); + FS.doChmod(stream, stream.node, mode, false); + }, + doChown(stream, node, dontFollow) { + FS.doSetAttr(stream, node, { + timestamp: Date.now(), + dontFollow + // we ignore the uid / gid for now + }); + }, + chown(path, uid, gid, dontFollow) { + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: !dontFollow }); + node = lookup.node; + } else { + node = path; + } + FS.doChown(null, node, dontFollow); + }, + lchown(path, uid, gid) { + FS.chown(path, uid, gid, true); + }, + fchown(fd, uid, gid) { + var stream = FS.getStreamChecked(fd); + FS.doChown(stream, stream.node, false); + }, + doTruncate(stream, node, len) { + if (FS.isDir(node.mode)) { + throw new FS.ErrnoError(31); + } + if (!FS.isFile(node.mode)) { + throw new FS.ErrnoError(28); + } + var errCode = FS.nodePermissions(node, 'w'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + FS.doSetAttr(stream, node, { + size: len, + timestamp: Date.now() + }); + }, + truncate(path, len) { + if (len < 0) { + throw new FS.ErrnoError(28); + } + var node; + if (typeof path == 'string') { + var lookup = FS.lookupPath(path, { follow: true }); + node = lookup.node; + } else { + node = path; + } + FS.doTruncate(null, node, len); + }, + ftruncate(fd, len) { + var stream = FS.getStreamChecked(fd); + if (len < 0 || (stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(28); + } + FS.doTruncate(stream, stream.node, len); + }, + utime(path, atime, mtime) { + var lookup = FS.lookupPath(path, { follow: true }); + var node = lookup.node; + var setattr = FS.checkOpExists(node.node_ops.setattr, 63); + setattr(node, { + atime: atime, + mtime: mtime + }); + }, + open(path, flags, mode = 0o666) { + if (path === "") { + throw new FS.ErrnoError(44); + } + flags = typeof flags == 'string' ? FS_modeStringToFlags(flags) : flags; + if ((flags & 64)) { + mode = (mode & 4095) | 32768; + } else { + mode = 0; + } + var node; + var isDirPath; + if (typeof path == 'object') { + node = path; + } else { + isDirPath = path.endsWith("/"); + // noent_okay makes it so that if the final component of the path + // doesn't exist, lookupPath returns `node: undefined`. `path` will be + // updated to point to the target of all symlinks. + var lookup = FS.lookupPath(path, { + follow: !(flags & 131072), + noent_okay: true + }); + node = lookup.node; + path = lookup.path; + } + // perhaps we need to create the node + var created = false; + if ((flags & 64)) { + if (node) { + // if O_CREAT and O_EXCL are set, error out if the node already exists + if ((flags & 128)) { + throw new FS.ErrnoError(20); + } + } else if (isDirPath) { + throw new FS.ErrnoError(31); + } else { + // node doesn't exist, try to create it + // Ignore the permission bits here to ensure we can `open` this new + // file below. We use chmod below the apply the permissions once the + // file is open. + node = FS.mknod(path, mode | 0o777, 0); + created = true; + } + } + if (!node) { + throw new FS.ErrnoError(44); + } + // can't truncate a device + if (FS.isChrdev(node.mode)) { + flags &= ~512; + } + // if asked only for a directory, then this must be one + if ((flags & 65536) && !FS.isDir(node.mode)) { + throw new FS.ErrnoError(54); + } + // check permissions, if this is not a file we just created now (it is ok to + // create and write to a file with read-only permissions; it is read-only + // for later use) + if (!created) { + var errCode = FS.mayOpen(node, flags); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + } + // do truncation if necessary + if ((flags & 512) && !created) { + FS.truncate(node, 0); + } + // we've already handled these, don't pass down to the underlying vfs + flags &= ~(128 | 512 | 131072); + + // register the stream with the filesystem + var stream = FS.createStream({ + node, + path: FS.getPath(node), // we want the absolute path to the node + flags, + seekable: true, + position: 0, + stream_ops: node.stream_ops, + // used by the file family libc calls (fopen, fwrite, ferror, etc.) + ungotten: [], + error: false + }); + // call the new stream's open function + if (stream.stream_ops.open) { + stream.stream_ops.open(stream); + } + if (created) { + FS.chmod(node, mode & 0o777); + } + if (Module['logReadFiles'] && !(flags & 1)) { + if (!(path in FS.readFiles)) { + FS.readFiles[path] = 1; + } + } + return stream; + }, + close(stream) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (stream.getdents) stream.getdents = null; // free readdir state + try { + if (stream.stream_ops.close) { + stream.stream_ops.close(stream); + } + } catch (e) { + throw e; + } finally { + FS.closeStream(stream.fd); + } + stream.fd = null; + }, + isClosed(stream) { + return stream.fd === null; + }, + llseek(stream, offset, whence) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (!stream.seekable || !stream.stream_ops.llseek) { + throw new FS.ErrnoError(70); + } + if (whence != 0 && whence != 1 && whence != 2) { + throw new FS.ErrnoError(28); + } + stream.position = stream.stream_ops.llseek(stream, offset, whence); + stream.ungotten = []; + return stream.position; + }, + read(stream, buffer, offset, length, position) { + assert(offset >= 0); + if (length < 0 || position < 0) { + throw new FS.ErrnoError(28); + } + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if ((stream.flags & 2097155) === 1) { + throw new FS.ErrnoError(8); + } + if (FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(31); + } + if (!stream.stream_ops.read) { + throw new FS.ErrnoError(28); + } + var seeking = typeof position != 'undefined'; + if (!seeking) { + position = stream.position; + } else if (!stream.seekable) { + throw new FS.ErrnoError(70); + } + var bytesRead = stream.stream_ops.read(stream, buffer, offset, length, position); + if (!seeking) stream.position += bytesRead; + return bytesRead; + }, + write(stream, buffer, offset, length, position, canOwn) { + assert(offset >= 0); + if (length < 0 || position < 0) { + throw new FS.ErrnoError(28); + } + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if ((stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(8); + } + if (FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(31); + } + if (!stream.stream_ops.write) { + throw new FS.ErrnoError(28); + } + if (stream.seekable && stream.flags & 1024) { + // seek to the end before writing in append mode + FS.llseek(stream, 0, 2); + } + var seeking = typeof position != 'undefined'; + if (!seeking) { + position = stream.position; + } else if (!stream.seekable) { + throw new FS.ErrnoError(70); + } + var bytesWritten = stream.stream_ops.write(stream, buffer, offset, length, position, canOwn); + if (!seeking) stream.position += bytesWritten; + return bytesWritten; + }, + mmap(stream, length, position, prot, flags) { + // User requests writing to file (prot & PROT_WRITE != 0). + // Checking if we have permissions to write to the file unless + // MAP_PRIVATE flag is set. According to POSIX spec it is possible + // to write to file opened in read-only mode with MAP_PRIVATE flag, + // as all modifications will be visible only in the memory of + // the current process. + if ((prot & 2) !== 0 + && (flags & 2) === 0 + && (stream.flags & 2097155) !== 2) { + throw new FS.ErrnoError(2); + } + if ((stream.flags & 2097155) === 1) { + throw new FS.ErrnoError(2); + } + if (!stream.stream_ops.mmap) { + throw new FS.ErrnoError(43); + } + if (!length) { + throw new FS.ErrnoError(28); + } + return stream.stream_ops.mmap(stream, length, position, prot, flags); + }, + msync(stream, buffer, offset, length, mmapFlags) { + assert(offset >= 0); + if (!stream.stream_ops.msync) { + return 0; + } + return stream.stream_ops.msync(stream, buffer, offset, length, mmapFlags); + }, + ioctl(stream, cmd, arg) { + if (!stream.stream_ops.ioctl) { + throw new FS.ErrnoError(59); + } + return stream.stream_ops.ioctl(stream, cmd, arg); + }, + readFile(path, opts = {}) { + opts.flags = opts.flags || 0; + opts.encoding = opts.encoding || 'binary'; + if (opts.encoding !== 'utf8' && opts.encoding !== 'binary') { + abort(`Invalid encoding type "${opts.encoding}"`); + } + var stream = FS.open(path, opts.flags); + var stat = FS.stat(path); + var length = stat.size; + var buf = new Uint8Array(length); + FS.read(stream, buf, 0, length, 0); + if (opts.encoding === 'utf8') { + buf = UTF8ArrayToString(buf); + } + FS.close(stream); + return buf; + }, + writeFile(path, data, opts = {}) { + opts.flags = opts.flags || 577; + var stream = FS.open(path, opts.flags, opts.mode); + if (typeof data == 'string') { + data = new Uint8Array(intArrayFromString(data, true)); + } + if (ArrayBuffer.isView(data)) { + FS.write(stream, data, 0, data.byteLength, undefined, opts.canOwn); + } else { + abort('Unsupported data type'); + } + FS.close(stream); + }, + cwd:() => FS.currentPath, + chdir(path) { + var lookup = FS.lookupPath(path, { follow: true }); + if (lookup.node === null) { + throw new FS.ErrnoError(44); + } + if (!FS.isDir(lookup.node.mode)) { + throw new FS.ErrnoError(54); + } + var errCode = FS.nodePermissions(lookup.node, 'x'); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + FS.currentPath = lookup.path; + }, + createDefaultDirectories() { + FS.mkdir('/tmp'); + FS.mkdir('/home'); + FS.mkdir('/home/web_user'); + }, + createDefaultDevices() { + // create /dev + FS.mkdir('/dev'); + // setup /dev/null + FS.registerDevice(FS.makedev(1, 3), { + read: () => 0, + write: (stream, buffer, offset, length, pos) => length, + llseek: () => 0, + }); + FS.mkdev('/dev/null', FS.makedev(1, 3)); + // setup /dev/tty and /dev/tty1 + // stderr needs to print output using err() rather than out() + // so we register a second tty just for it. + TTY.register(FS.makedev(5, 0), TTY.default_tty_ops); + TTY.register(FS.makedev(6, 0), TTY.default_tty1_ops); + FS.mkdev('/dev/tty', FS.makedev(5, 0)); + FS.mkdev('/dev/tty1', FS.makedev(6, 0)); + // setup /dev/[u]random + // use a buffer to avoid overhead of individual crypto calls per byte + var randomBuffer = new Uint8Array(1024), randomLeft = 0; + var randomByte = () => { + if (randomLeft === 0) { + randomFill(randomBuffer); + randomLeft = randomBuffer.byteLength; + } + return randomBuffer[--randomLeft]; + }; + FS.createDevice('/dev', 'random', randomByte); + FS.createDevice('/dev', 'urandom', randomByte); + // we're not going to emulate the actual shm device, + // just create the tmp dirs that reside in it commonly + FS.mkdir('/dev/shm'); + FS.mkdir('/dev/shm/tmp'); + }, + createSpecialDirectories() { + // create /proc/self/fd which allows /proc/self/fd/6 => readlink gives the + // name of the stream for fd 6 (see test_unistd_ttyname) + FS.mkdir('/proc'); + var proc_self = FS.mkdir('/proc/self'); + FS.mkdir('/proc/self/fd'); + FS.mount({ + mount() { + var node = FS.createNode(proc_self, 'fd', 16895, 73); + node.stream_ops = { + llseek: MEMFS.stream_ops.llseek, + }; + node.node_ops = { + lookup(parent, name) { + var fd = +name; + var stream = FS.getStreamChecked(fd); + var ret = { + parent: null, + mount: { mountpoint: 'fake' }, + node_ops: { readlink: () => stream.path }, + id: fd + 1, + }; + ret.parent = ret; // make it look like a simple root node + return ret; + }, + readdir() { + return Array.from(FS.streams.entries()) + .filter(([k, v]) => v) + .map(([k, v]) => k.toString()); + } + }; + return node; + } + }, {}, '/proc/self/fd'); + }, + createStandardStreams(input, output, error) { + // TODO deprecate the old functionality of a single + // input / output callback and that utilizes FS.createDevice + // and instead require a unique set of stream ops + + // by default, we symlink the standard streams to the + // default tty devices. however, if the standard streams + // have been overwritten we create a unique device for + // them instead. + if (input) { + FS.createDevice('/dev', 'stdin', input); + } else { + FS.symlink('/dev/tty', '/dev/stdin'); + } + if (output) { + FS.createDevice('/dev', 'stdout', null, output); + } else { + FS.symlink('/dev/tty', '/dev/stdout'); + } + if (error) { + FS.createDevice('/dev', 'stderr', null, error); + } else { + FS.symlink('/dev/tty1', '/dev/stderr'); + } + + // open default streams for the stdin, stdout and stderr devices + var stdin = FS.open('/dev/stdin', 0); + var stdout = FS.open('/dev/stdout', 1); + var stderr = FS.open('/dev/stderr', 1); + assert(stdin.fd === 0, `invalid handle for stdin (${stdin.fd})`); + assert(stdout.fd === 1, `invalid handle for stdout (${stdout.fd})`); + assert(stderr.fd === 2, `invalid handle for stderr (${stderr.fd})`); + }, + staticInit() { + FS.nameTable = new Array(4096); + + FS.mount(MEMFS, {}, '/'); + + FS.createDefaultDirectories(); + FS.createDefaultDevices(); + FS.createSpecialDirectories(); + + FS.filesystems = { + 'MEMFS': MEMFS, + }; + }, + init(input, output, error) { + assert(!FS.initialized, 'FS.init was previously called. If you want to initialize later with custom parameters, remove any earlier calls (note that one is automatically added to the generated code)'); + FS.initialized = true; + + // Allow Module.stdin etc. to provide defaults, if none explicitly passed to us here + input ??= Module['stdin']; + output ??= Module['stdout']; + error ??= Module['stderr']; + + FS.createStandardStreams(input, output, error); + }, + quit() { + FS.initialized = false; + // force-flush all streams, so we get musl std streams printed out + _fflush(0); + // close all of our streams + for (var stream of FS.streams) { + if (stream) { + FS.close(stream); + } + } + }, + findObject(path, dontResolveLastLink) { + var ret = FS.analyzePath(path, dontResolveLastLink); + if (!ret.exists) { + return null; + } + return ret.object; + }, + analyzePath(path, dontResolveLastLink) { + // operate from within the context of the symlink's target + try { + var lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); + path = lookup.path; + } catch (e) { + } + var ret = { + isRoot: false, exists: false, error: 0, name: null, path: null, object: null, + parentExists: false, parentPath: null, parentObject: null + }; + try { + var lookup = FS.lookupPath(path, { parent: true }); + ret.parentExists = true; + ret.parentPath = lookup.path; + ret.parentObject = lookup.node; + ret.name = PATH.basename(path); + lookup = FS.lookupPath(path, { follow: !dontResolveLastLink }); + ret.exists = true; + ret.path = lookup.path; + ret.object = lookup.node; + ret.name = lookup.node.name; + ret.isRoot = lookup.path === '/'; + } catch (e) { + ret.error = e.errno; + }; + return ret; + }, + createPath(parent, path, canRead, canWrite) { + parent = typeof parent == 'string' ? parent : FS.getPath(parent); + var parts = path.split('/').reverse(); + while (parts.length) { + var part = parts.pop(); + if (!part) continue; + var current = PATH.join2(parent, part); + try { + FS.mkdir(current); + } catch (e) { + if (e.errno != 20) throw e; + } + parent = current; + } + return current; + }, + createFile(parent, name, properties, canRead, canWrite) { + var path = PATH.join2(typeof parent == 'string' ? parent : FS.getPath(parent), name); + var mode = FS_getMode(canRead, canWrite); + return FS.create(path, mode); + }, + createDataFile(parent, name, data, canRead, canWrite, canOwn) { + var path = name; + if (parent) { + parent = typeof parent == 'string' ? parent : FS.getPath(parent); + path = name ? PATH.join2(parent, name) : parent; + } + var mode = FS_getMode(canRead, canWrite); + var node = FS.create(path, mode); + if (data) { + if (typeof data == 'string') { + var arr = new Array(data.length); + for (var i = 0, len = data.length; i < len; ++i) arr[i] = data.charCodeAt(i); + data = arr; + } + // make sure we can write to the file + FS.chmod(node, mode | 146); + var stream = FS.open(node, 577); + FS.write(stream, data, 0, data.length, 0, canOwn); + FS.close(stream); + FS.chmod(node, mode); + } + }, + createDevice(parent, name, input, output) { + var path = PATH.join2(typeof parent == 'string' ? parent : FS.getPath(parent), name); + var mode = FS_getMode(!!input, !!output); + FS.createDevice.major ??= 64; + var dev = FS.makedev(FS.createDevice.major++, 0); + // Create a fake device that a set of stream ops to emulate + // the old behavior. + FS.registerDevice(dev, { + open(stream) { + stream.seekable = false; + }, + close(stream) { + // flush any pending line data + if (output?.buffer?.length) { + output(10); + } + }, + read(stream, buffer, offset, length, pos /* ignored */) { + var bytesRead = 0; + for (var i = 0; i < length; i++) { + var result; + try { + result = input(); + } catch (e) { + throw new FS.ErrnoError(29); + } + if (result === undefined && bytesRead === 0) { + throw new FS.ErrnoError(6); + } + if (result === null || result === undefined) break; + bytesRead++; + buffer[offset+i] = result; + } + if (bytesRead) { + stream.node.atime = Date.now(); + } + return bytesRead; + }, + write(stream, buffer, offset, length, pos) { + for (var i = 0; i < length; i++) { + try { + output(buffer[offset+i]); + } catch (e) { + throw new FS.ErrnoError(29); + } + } + if (length) { + stream.node.mtime = stream.node.ctime = Date.now(); + } + return i; + } + }); + return FS.mkdev(path, mode, dev); + }, + forceLoadFile(obj) { + if (obj.isDevice || obj.isFolder || obj.link || obj.contents) return true; + if (globalThis.XMLHttpRequest) { + abort("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread."); + } else { // Command-line. + try { + obj.contents = readBinary(obj.url); + } catch (e) { + throw new FS.ErrnoError(29); + } + } + }, + createLazyFile(parent, name, url, canRead, canWrite) { + // Lazy chunked Uint8Array (implements get and length from Uint8Array). + // Actual getting is abstracted away for eventual reuse. + class LazyUint8Array { + lengthKnown = false; + chunks = []; // Loaded chunks. Index is the chunk number + get(idx) { + if (idx > this.length-1 || idx < 0) { + return undefined; + } + var chunkOffset = idx % this.chunkSize; + var chunkNum = (idx / this.chunkSize)|0; + return this.getter(chunkNum)[chunkOffset]; + } + setDataGetter(getter) { + this.getter = getter; + } + cacheLength() { + // Find length + var xhr = new XMLHttpRequest(); + xhr.open('HEAD', url, false); + xhr.send(null); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort("Couldn't load " + url + ". Status: " + xhr.status); + var datalength = Number(xhr.getResponseHeader("Content-length")); + var header; + var hasByteServing = (header = xhr.getResponseHeader("Accept-Ranges")) && header === "bytes"; + var usesGzip = (header = xhr.getResponseHeader("Content-Encoding")) && header === "gzip"; + + var chunkSize = 1024*1024; // Chunk size in bytes + + if (!hasByteServing) chunkSize = datalength; + + // Function to get a range from the remote URL. + var doXHR = (from, to) => { + if (from > to) abort("invalid range (" + from + ", " + to + ") or no bytes requested!"); + if (to > datalength-1) abort("only " + datalength + " bytes available! programmer error!"); + + // TODO: Use mozResponseArrayBuffer, responseStream, etc. if available. + var xhr = new XMLHttpRequest(); + xhr.open('GET', url, false); + if (datalength !== chunkSize) xhr.setRequestHeader("Range", "bytes=" + from + "-" + to); + + // Some hints to the browser that we want binary data. + xhr.responseType = 'arraybuffer'; + if (xhr.overrideMimeType) { + xhr.overrideMimeType('text/plain; charset=x-user-defined'); + } + + xhr.send(null); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) abort("Couldn't load " + url + ". Status: " + xhr.status); + if (xhr.response !== undefined) { + return new Uint8Array(/** @type{Array} */(xhr.response || [])); + } + return intArrayFromString(xhr.responseText || '', true); + }; + var lazyArray = this; + lazyArray.setDataGetter((chunkNum) => { + var start = chunkNum * chunkSize; + var end = (chunkNum+1) * chunkSize - 1; // including this byte + end = Math.min(end, datalength-1); // if datalength-1 is selected, this is the last block + if (typeof lazyArray.chunks[chunkNum] == 'undefined') { + lazyArray.chunks[chunkNum] = doXHR(start, end); + } + if (typeof lazyArray.chunks[chunkNum] == 'undefined') abort('doXHR failed!'); + return lazyArray.chunks[chunkNum]; + }); + + if (usesGzip || !datalength) { + // if the server uses gzip or doesn't supply the length, we have to download the whole file to get the (uncompressed) length + chunkSize = datalength = 1; // this will force getter(0)/doXHR do download the whole file + datalength = this.getter(0).length; + chunkSize = datalength; + out("LazyFiles on gzip forces download of the whole file when length is accessed"); + } + + this._length = datalength; + this._chunkSize = chunkSize; + this.lengthKnown = true; + } + get length() { + if (!this.lengthKnown) { + this.cacheLength(); + } + return this._length; + } + get chunkSize() { + if (!this.lengthKnown) { + this.cacheLength(); + } + return this._chunkSize; + } + } + + if (globalThis.XMLHttpRequest) { + if (!ENVIRONMENT_IS_WORKER) abort('Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc'); + var lazyArray = new LazyUint8Array(); + var properties = { isDevice: false, contents: lazyArray }; + } else { + var properties = { isDevice: false, url: url }; + } + + var node = FS.createFile(parent, name, properties, canRead, canWrite); + // This is a total hack, but I want to get this lazy file code out of the + // core of MEMFS. If we want to keep this lazy file concept I feel it should + // be its own thin LAZYFS proxying calls to MEMFS. + if (properties.contents) { + node.contents = properties.contents; + } else if (properties.url) { + node.contents = null; + node.url = properties.url; + } + // Add a function that defers querying the file size until it is asked the first time. + Object.defineProperties(node, { + usedBytes: { + get: function() { return this.contents.length; } + } + }); + // override each stream op with one that tries to force load the lazy file first + var stream_ops = {}; + var keys = Object.keys(node.stream_ops); + keys.forEach((key) => { + var fn = node.stream_ops[key]; + stream_ops[key] = (...args) => { + FS.forceLoadFile(node); + return fn(...args); + }; + }); + function writeChunks(stream, buffer, offset, length, position) { + var contents = stream.node.contents; + if (position >= contents.length) + return 0; + var size = Math.min(contents.length - position, length); + assert(size >= 0); + if (contents.slice) { // normal array + for (var i = 0; i < size; i++) { + buffer[offset + i] = contents[position + i]; + } + } else { + for (var i = 0; i < size; i++) { // LazyUint8Array from sync binary XHR + buffer[offset + i] = contents.get(position + i); + } + } + return size; + } + // use a custom read function + stream_ops.read = (stream, buffer, offset, length, position) => { + FS.forceLoadFile(node); + return writeChunks(stream, buffer, offset, length, position) + }; + // use a custom mmap function + stream_ops.mmap = (stream, length, position, prot, flags) => { + FS.forceLoadFile(node); + var ptr = mmapAlloc(length); + if (!ptr) { + throw new FS.ErrnoError(48); + } + writeChunks(stream, HEAP8, ptr, length, position); + return { ptr, allocated: true }; + }; + node.stream_ops = stream_ops; + return node; + }, + absolutePath() { + abort('FS.absolutePath has been removed; use PATH_FS.resolve instead'); + }, + createFolder() { + abort('FS.createFolder has been removed; use FS.mkdir instead'); + }, + createLink() { + abort('FS.createLink has been removed; use FS.symlink instead'); + }, + joinPath() { + abort('FS.joinPath has been removed; use PATH.join instead'); + }, + mmapAlloc() { + abort('FS.mmapAlloc has been replaced by the top level function mmapAlloc'); + }, + standardizePath() { + abort('FS.standardizePath has been removed; use PATH.normalize instead'); + }, + }; + + var SYSCALLS = { + DEFAULT_POLLMASK:5, + calculateAt(dirfd, path, allowEmpty) { + if (PATH.isAbs(path)) { + return path; + } + // relative path + var dir; + if (dirfd === -100) { + dir = FS.cwd(); + } else { + var dirstream = SYSCALLS.getStreamFromFD(dirfd); + dir = dirstream.path; + } + if (path.length == 0) { + if (!allowEmpty) { + throw new FS.ErrnoError(44);; + } + return dir; + } + return dir + '/' + path; + }, + writeStat(buf, stat) { + HEAPU32[((buf)>>2)] = stat.dev; + HEAPU32[(((buf)+(4))>>2)] = stat.mode; + HEAPU32[(((buf)+(8))>>2)] = stat.nlink; + HEAPU32[(((buf)+(12))>>2)] = stat.uid; + HEAPU32[(((buf)+(16))>>2)] = stat.gid; + HEAPU32[(((buf)+(20))>>2)] = stat.rdev; + HEAP64[(((buf)+(24))>>3)] = BigInt(stat.size); + HEAP32[(((buf)+(32))>>2)] = 4096; + HEAP32[(((buf)+(36))>>2)] = stat.blocks; + var atime = stat.atime.getTime(); + var mtime = stat.mtime.getTime(); + var ctime = stat.ctime.getTime(); + HEAP64[(((buf)+(40))>>3)] = BigInt(Math.floor(atime / 1000)); + HEAPU32[(((buf)+(48))>>2)] = (atime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(56))>>3)] = BigInt(Math.floor(mtime / 1000)); + HEAPU32[(((buf)+(64))>>2)] = (mtime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(72))>>3)] = BigInt(Math.floor(ctime / 1000)); + HEAPU32[(((buf)+(80))>>2)] = (ctime % 1000) * 1000 * 1000; + HEAP64[(((buf)+(88))>>3)] = BigInt(stat.ino); + return 0; + }, + writeStatFs(buf, stats) { + HEAPU32[(((buf)+(4))>>2)] = stats.bsize; + HEAPU32[(((buf)+(60))>>2)] = stats.bsize; + HEAP64[(((buf)+(8))>>3)] = BigInt(stats.blocks); + HEAP64[(((buf)+(16))>>3)] = BigInt(stats.bfree); + HEAP64[(((buf)+(24))>>3)] = BigInt(stats.bavail); + HEAP64[(((buf)+(32))>>3)] = BigInt(stats.files); + HEAP64[(((buf)+(40))>>3)] = BigInt(stats.ffree); + HEAPU32[(((buf)+(48))>>2)] = stats.fsid; + HEAPU32[(((buf)+(64))>>2)] = stats.flags; // ST_NOSUID + HEAPU32[(((buf)+(56))>>2)] = stats.namelen; + }, + doMsync(addr, stream, len, flags, offset) { + if (!FS.isFile(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + if (flags & 2) { + // MAP_PRIVATE calls need not to be synced back to underlying fs + return 0; + } + var buffer = HEAPU8.slice(addr, addr + len); + FS.msync(stream, buffer, offset, len, flags); + }, + getStreamFromFD(fd) { + var stream = FS.getStreamChecked(fd); + return stream; + }, + varargs:undefined, + getStr(ptr) { + var ret = UTF8ToString(ptr); + return ret; + }, + }; + function ___syscall_fcntl64(fd, cmd, varargs) { + SYSCALLS.varargs = varargs; + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + switch (cmd) { + case 0: { + var arg = syscallGetVarargI(); + if (arg < 0) { + return -28; + } + while (FS.streams[arg]) { + arg++; + } + var newStream; + newStream = FS.dupStream(stream, arg); + return newStream.fd; + } + case 1: + case 2: + return 0; // FD_CLOEXEC makes no sense for a single process. + case 3: + return stream.flags; + case 4: { + var arg = syscallGetVarargI(); + stream.flags |= arg; + return 0; + } + case 12: { + var arg = syscallGetVarargP(); + var offset = 0; + // We're always unlocked. + HEAP16[(((arg)+(offset))>>1)] = 2; + return 0; + } + case 13: + case 14: + // Pretend that the locking is successful. These are process-level locks, + // and Emscripten programs are a single process. If we supported linking a + // filesystem between programs, we'd need to do more here. + // See https://github.com/emscripten-core/emscripten/issues/23697 + return 0; + } + return -28; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_ioctl(fd, op, varargs) { + SYSCALLS.varargs = varargs; + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + switch (op) { + case 21509: { + if (!stream.tty) return -59; + return 0; + } + case 21505: { + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tcgets) { + var termios = stream.tty.ops.ioctl_tcgets(stream); + var argp = syscallGetVarargP(); + HEAP32[((argp)>>2)] = termios.c_iflag || 0; + HEAP32[(((argp)+(4))>>2)] = termios.c_oflag || 0; + HEAP32[(((argp)+(8))>>2)] = termios.c_cflag || 0; + HEAP32[(((argp)+(12))>>2)] = termios.c_lflag || 0; + for (var i = 0; i < 32; i++) { + HEAP8[(argp + i)+(17)] = termios.c_cc[i] || 0; + } + return 0; + } + return 0; + } + case 21510: + case 21511: + case 21512: { + if (!stream.tty) return -59; + return 0; // no-op, not actually adjusting terminal settings + } + case 21506: + case 21507: + case 21508: { + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tcsets) { + var argp = syscallGetVarargP(); + var c_iflag = HEAP32[((argp)>>2)]; + var c_oflag = HEAP32[(((argp)+(4))>>2)]; + var c_cflag = HEAP32[(((argp)+(8))>>2)]; + var c_lflag = HEAP32[(((argp)+(12))>>2)]; + var c_cc = [] + for (var i = 0; i < 32; i++) { + c_cc.push(HEAP8[(argp + i)+(17)]); + } + return stream.tty.ops.ioctl_tcsets(stream.tty, op, { c_iflag, c_oflag, c_cflag, c_lflag, c_cc }); + } + return 0; // no-op, not actually adjusting terminal settings + } + case 21519: { + if (!stream.tty) return -59; + var argp = syscallGetVarargP(); + HEAP32[((argp)>>2)] = 0; + return 0; + } + case 21520: { + if (!stream.tty) return -59; + return -28; // not supported + } + case 21537: + case 21531: { + var argp = syscallGetVarargP(); + return FS.ioctl(stream, op, argp); + } + case 21523: { + // TODO: in theory we should write to the winsize struct that gets + // passed in, but for now musl doesn't read anything on it + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tiocgwinsz) { + var winsize = stream.tty.ops.ioctl_tiocgwinsz(stream.tty); + var argp = syscallGetVarargP(); + HEAP16[((argp)>>1)] = winsize[0]; + HEAP16[(((argp)+(2))>>1)] = winsize[1]; + } + return 0; + } + case 21524: { + // TODO: technically, this ioctl call should change the window size. + // but, since emscripten doesn't have any concept of a terminal window + // yet, we'll just silently throw it away as we do TIOCGWINSZ + if (!stream.tty) return -59; + return 0; + } + case 21515: { + if (!stream.tty) return -59; + return 0; + } + default: return -28; // not supported + } + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + + function ___syscall_openat(dirfd, path, flags, varargs) { + SYSCALLS.varargs = varargs; + try { + + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + var mode = varargs ? syscallGetVarargI() : 0; + return FS.open(path, flags, mode).fd; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return -e.errno; + } + } + + var __abort_js = () => + abort('native code called abort()'); + + var stringToUTF8 = (str, outPtr, maxBytesToWrite) => { + assert(typeof maxBytesToWrite == 'number', 'stringToUTF8(str, outPtr, maxBytesToWrite) is missing the third parameter that specifies the length of the output buffer!'); + return stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); + }; + + var __tzset_js = (timezone, daylight, std_name, dst_name) => { + // TODO: Use (malleable) environment variables instead of system settings. + var currentYear = new Date().getFullYear(); + var winter = new Date(currentYear, 0, 1); + var summer = new Date(currentYear, 6, 1); + var winterOffset = winter.getTimezoneOffset(); + var summerOffset = summer.getTimezoneOffset(); + + // Local standard timezone offset. Local standard time is not adjusted for + // daylight savings. This code uses the fact that getTimezoneOffset returns + // a greater value during Standard Time versus Daylight Saving Time (DST). + // Thus it determines the expected output during Standard Time, and it + // compares whether the output of the given date the same (Standard) or less + // (DST). + var stdTimezoneOffset = Math.max(winterOffset, summerOffset); + + // timezone is specified as seconds west of UTC ("The external variable + // `timezone` shall be set to the difference, in seconds, between + // Coordinated Universal Time (UTC) and local standard time."), the same + // as returned by stdTimezoneOffset. + // See http://pubs.opengroup.org/onlinepubs/009695399/functions/tzset.html + HEAPU32[((timezone)>>2)] = stdTimezoneOffset * 60; + + HEAP32[((daylight)>>2)] = Number(winterOffset != summerOffset); + + var extractZone = (timezoneOffset) => { + // Why inverse sign? + // Read here https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getTimezoneOffset + var sign = timezoneOffset >= 0 ? "-" : "+"; + + var absOffset = Math.abs(timezoneOffset) + var hours = String(Math.floor(absOffset / 60)).padStart(2, "0"); + var minutes = String(absOffset % 60).padStart(2, "0"); + + return `UTC${sign}${hours}${minutes}`; + } + + var winterName = extractZone(winterOffset); + var summerName = extractZone(summerOffset); + assert(winterName); + assert(summerName); + assert(lengthBytesUTF8(winterName) <= 16, `timezone name truncated to fit in TZNAME_MAX (${winterName})`); + assert(lengthBytesUTF8(summerName) <= 16, `timezone name truncated to fit in TZNAME_MAX (${summerName})`); + if (summerOffset < winterOffset) { + // Northern hemisphere + stringToUTF8(winterName, std_name, 17); + stringToUTF8(summerName, dst_name, 17); + } else { + stringToUTF8(winterName, dst_name, 17); + stringToUTF8(summerName, std_name, 17); + } + }; + + var getHeapMax = () => + // Stay one Wasm page short of 4GB: while e.g. Chrome is able to allocate + // full 4GB Wasm memories, the size will wrap back to 0 bytes in Wasm side + // for any code that deals with heap sizes, which would require special + // casing all heap size related code to treat 0 specially. + 2147483648; + + var alignMemory = (size, alignment) => { + assert(alignment, "alignment argument is required"); + return Math.ceil(size / alignment) * alignment; + }; + + var growMemory = (size) => { + var oldHeapSize = wasmMemory.buffer.byteLength; + var pages = ((size - oldHeapSize + 65535) / 65536) | 0; + try { + // round size grow request up to wasm page size (fixed 64KB per spec) + wasmMemory.grow(pages); // .grow() takes a delta compared to the previous size + updateMemoryViews(); + return 1 /*success*/; + } catch(e) { + err(`growMemory: Attempted to grow heap from ${oldHeapSize} bytes to ${size} bytes, but got error: ${e}`); + } + // implicit 0 return to save code size (caller will cast "undefined" into 0 + // anyhow) + }; + var _emscripten_resize_heap = (requestedSize) => { + var oldSize = HEAPU8.length; + // With CAN_ADDRESS_2GB or MEMORY64, pointers are already unsigned. + requestedSize >>>= 0; + // With multithreaded builds, races can happen (another thread might increase the size + // in between), so return a failure, and let the caller retry. + assert(requestedSize > oldSize); + + // Memory resize rules: + // 1. Always increase heap size to at least the requested size, rounded up + // to next page multiple. + // 2a. If MEMORY_GROWTH_LINEAR_STEP == -1, excessively resize the heap + // geometrically: increase the heap size according to + // MEMORY_GROWTH_GEOMETRIC_STEP factor (default +20%), At most + // overreserve by MEMORY_GROWTH_GEOMETRIC_CAP bytes (default 96MB). + // 2b. If MEMORY_GROWTH_LINEAR_STEP != -1, excessively resize the heap + // linearly: increase the heap size by at least + // MEMORY_GROWTH_LINEAR_STEP bytes. + // 3. Max size for the heap is capped at 2048MB-WASM_PAGE_SIZE, or by + // MAXIMUM_MEMORY, or by ASAN limit, depending on which is smallest + // 4. If we were unable to allocate as much memory, it may be due to + // over-eager decision to excessively reserve due to (3) above. + // Hence if an allocation fails, cut down on the amount of excess + // growth, in an attempt to succeed to perform a smaller allocation. + + // A limit is set for how much we can grow. We should not exceed that + // (the wasm binary specifies it, so if we tried, we'd fail anyhow). + var maxHeapSize = getHeapMax(); + if (requestedSize > maxHeapSize) { + err(`Cannot enlarge memory, requested ${requestedSize} bytes, but the limit is ${maxHeapSize} bytes!`); + return false; + } + + // Loop through potential heap size increases. If we attempt a too eager + // reservation that fails, cut down on the attempted size and reserve a + // smaller bump instead. (max 3 times, chosen somewhat arbitrarily) + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); // ensure geometric growth + // but limit overreserving (default to capping at +96MB overgrowth at most) + overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296 ); + + var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536)); + + var replacement = growMemory(newSize); + if (replacement) { + + return true; + } + } + err(`Failed to grow the heap from ${oldSize} bytes to ${newSize} bytes, not enough memory!`); + return false; + }; + + var ENV = { + }; + + var getExecutableName = () => thisProgram || './this.program'; + var getEnvStrings = () => { + if (!getEnvStrings.strings) { + // Default values. + // Browser language detection #8751 + var lang = ((typeof navigator == 'object' && navigator.language) || 'C').replace('-', '_') + '.UTF-8'; + var env = { + 'USER': 'web_user', + 'LOGNAME': 'web_user', + 'PATH': '/', + 'PWD': '/', + 'HOME': '/home/web_user', + 'LANG': lang, + '_': getExecutableName() + }; + // Apply the user-provided values, if any. + for (var x in ENV) { + // x is a key in ENV; if ENV[x] is undefined, that means it was + // explicitly set to be so. We allow user code to do that to + // force variables with default values to remain unset. + if (ENV[x] === undefined) delete env[x]; + else env[x] = ENV[x]; + } + var strings = []; + for (var x in env) { + strings.push(`${x}=${env[x]}`); + } + getEnvStrings.strings = strings; + } + return getEnvStrings.strings; + }; + + var _environ_get = (__environ, environ_buf) => { + var bufSize = 0; + var envp = 0; + for (var string of getEnvStrings()) { + var ptr = environ_buf + bufSize; + HEAPU32[(((__environ)+(envp))>>2)] = ptr; + bufSize += stringToUTF8(string, ptr, Infinity) + 1; + envp += 4; + } + return 0; + }; + + + var _environ_sizes_get = (penviron_count, penviron_buf_size) => { + var strings = getEnvStrings(); + HEAPU32[((penviron_count)>>2)] = strings.length; + var bufSize = 0; + for (var string of strings) { + bufSize += lengthBytesUTF8(string) + 1; + } + HEAPU32[((penviron_buf_size)>>2)] = bufSize; + return 0; + }; + + function _fd_close(fd) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + FS.close(stream); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + /** @param {number=} offset */ + var doReadv = (stream, iov, iovcnt, offset) => { + var ret = 0; + for (var i = 0; i < iovcnt; i++) { + var ptr = HEAPU32[((iov)>>2)]; + var len = HEAPU32[(((iov)+(4))>>2)]; + iov += 8; + var curr = FS.read(stream, HEAP8, ptr, len, offset); + if (curr < 0) return -1; + ret += curr; + if (curr < len) break; // nothing more to read + if (typeof offset != 'undefined') { + offset += curr; + } + } + return ret; + }; + + function _fd_read(fd, iov, iovcnt, pnum) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var num = doReadv(stream, iov, iovcnt); + HEAPU32[((pnum)>>2)] = num; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + var INT53_MAX = 9007199254740992; + + var INT53_MIN = -9007199254740992; + var bigintToI53Checked = (num) => (num < INT53_MIN || num > INT53_MAX) ? NaN : Number(num); + function _fd_seek(fd, offset, whence, newOffset) { + offset = bigintToI53Checked(offset); + + + try { + + if (isNaN(offset)) return 61; + var stream = SYSCALLS.getStreamFromFD(fd); + FS.llseek(stream, offset, whence); + HEAP64[((newOffset)>>3)] = BigInt(stream.position); + if (stream.getdents && offset === 0 && whence === 0) stream.getdents = null; // reset readdir state + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + ; + } + + /** @param {number=} offset */ + var doWritev = (stream, iov, iovcnt, offset) => { + var ret = 0; + for (var i = 0; i < iovcnt; i++) { + var ptr = HEAPU32[((iov)>>2)]; + var len = HEAPU32[(((iov)+(4))>>2)]; + iov += 8; + var curr = FS.write(stream, HEAP8, ptr, len, offset); + if (curr < 0) return -1; + ret += curr; + if (curr < len) { + // No more space to write. + break; + } + if (typeof offset != 'undefined') { + offset += curr; + } + } + return ret; + }; + + function _fd_write(fd, iov, iovcnt, pnum) { + try { + + var stream = SYSCALLS.getStreamFromFD(fd); + var num = doWritev(stream, iov, iovcnt); + HEAPU32[((pnum)>>2)] = num; + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + function _random_get(buffer, size) { + try { + + randomFill(HEAPU8.subarray(buffer, buffer + size)); + return 0; + } catch (e) { + if (typeof FS == 'undefined' || !(e.name === 'ErrnoError')) throw e; + return e.errno; + } + } + + + var runtimeKeepaliveCounter = 0; + var keepRuntimeAlive = () => noExitRuntime || runtimeKeepaliveCounter > 0; + var _proc_exit = (code) => { + EXITSTATUS = code; + if (!keepRuntimeAlive()) { + Module['onExit']?.(code); + ABORT = true; + } + quit_(code, new ExitStatus(code)); + }; + + + /** @param {boolean|number=} implicit */ + var exitJS = (status, implicit) => { + EXITSTATUS = status; + + checkUnflushedContent(); + + // if exit() was called explicitly, warn the user if the runtime isn't actually being shut down + if (keepRuntimeAlive() && !implicit) { + var msg = `program exited (with status: ${status}), but keepRuntimeAlive() is set (counter=${runtimeKeepaliveCounter}) due to an async operation, so halting execution but not exiting the runtime or preventing further async execution (you can use emscripten_force_exit, if you want to force a true shutdown)`; + readyPromiseReject?.(msg); + err(msg); + } + + _proc_exit(status); + }; + + var handleException = (e) => { + // Certain exception types we do not treat as errors since they are used for + // internal control flow. + // 1. ExitStatus, which is thrown by exit() + // 2. "unwind", which is thrown by emscripten_unwind_to_js_event_loop() and others + // that wish to return to JS event loop. + if (e instanceof ExitStatus || e == 'unwind') { + return EXITSTATUS; + } + checkStackCookie(); + if (e instanceof WebAssembly.RuntimeError) { + if (_emscripten_stack_get_current() <= 0) { + err('Stack overflow detected. You can try increasing -sSTACK_SIZE (currently set to 65536)'); + } + } + quit_(1, e); + }; + + + + var stackAlloc = (sz) => __emscripten_stack_alloc(sz); + var stringToUTF8OnStack = (str) => { + var size = lengthBytesUTF8(str) + 1; + var ret = stackAlloc(size); + stringToUTF8(str, ret, size); + return ret; + }; + + var FS_writeFile = (...args) => FS.writeFile(...args); + + var FS_readFile = (...args) => FS.readFile(...args); + + FS.createPreloadedFile = FS_createPreloadedFile; + FS.preloadFile = FS_preloadFile; + FS.staticInit();; +// End JS library code + +// include: postlibrary.js +// This file is included after the automatically-generated JS library code +// but before the wasm module is created. + +{ + + // Begin ATMODULES hooks + if (Module['noExitRuntime']) noExitRuntime = Module['noExitRuntime']; +if (Module['preloadPlugins']) preloadPlugins = Module['preloadPlugins']; +if (Module['print']) out = Module['print']; +if (Module['printErr']) err = Module['printErr']; +if (Module['wasmBinary']) wasmBinary = Module['wasmBinary']; + // End ATMODULES hooks + + checkIncomingModuleAPI(); + + if (Module['arguments']) arguments_ = Module['arguments']; + if (Module['thisProgram']) thisProgram = Module['thisProgram']; + + // Assertions on removed incoming Module JS APIs. + assert(typeof Module['memoryInitializerPrefixURL'] == 'undefined', 'Module.memoryInitializerPrefixURL option was removed, use Module.locateFile instead'); + assert(typeof Module['pthreadMainPrefixURL'] == 'undefined', 'Module.pthreadMainPrefixURL option was removed, use Module.locateFile instead'); + assert(typeof Module['cdInitializerPrefixURL'] == 'undefined', 'Module.cdInitializerPrefixURL option was removed, use Module.locateFile instead'); + assert(typeof Module['filePackagePrefixURL'] == 'undefined', 'Module.filePackagePrefixURL option was removed, use Module.locateFile instead'); + assert(typeof Module['read'] == 'undefined', 'Module.read option was removed'); + assert(typeof Module['readAsync'] == 'undefined', 'Module.readAsync option was removed (modify readAsync in JS)'); + assert(typeof Module['readBinary'] == 'undefined', 'Module.readBinary option was removed (modify readBinary in JS)'); + assert(typeof Module['setWindowTitle'] == 'undefined', 'Module.setWindowTitle option was removed (modify emscripten_set_window_title in JS)'); + assert(typeof Module['TOTAL_MEMORY'] == 'undefined', 'Module.TOTAL_MEMORY has been renamed Module.INITIAL_MEMORY'); + assert(typeof Module['ENVIRONMENT'] == 'undefined', 'Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option (for example, -sENVIRONMENT=web or -sENVIRONMENT=node)'); + assert(typeof Module['STACK_SIZE'] == 'undefined', 'STACK_SIZE can no longer be set at runtime. Use -sSTACK_SIZE at link time') + // If memory is defined in wasm, the user can't provide it, or set INITIAL_MEMORY + assert(typeof Module['wasmMemory'] == 'undefined', 'Use of `wasmMemory` detected. Use -sIMPORTED_MEMORY to define wasmMemory externally'); + assert(typeof Module['INITIAL_MEMORY'] == 'undefined', 'Detected runtime INITIAL_MEMORY setting. Use -sIMPORTED_MEMORY to define wasmMemory dynamically'); + + if (Module['preInit']) { + if (typeof Module['preInit'] == 'function') Module['preInit'] = [Module['preInit']]; + while (Module['preInit'].length > 0) { + Module['preInit'].shift()(); + } + } + consumedModuleProp('preInit'); +} + +// Begin runtime exports + Module['callMain'] = callMain; + Module['FS_readFile'] = FS_readFile; + Module['FS_writeFile'] = FS_writeFile; + var missingLibrarySymbols = [ + 'writeI53ToI64', + 'writeI53ToI64Clamped', + 'writeI53ToI64Signaling', + 'writeI53ToU64Clamped', + 'writeI53ToU64Signaling', + 'readI53FromI64', + 'readI53FromU64', + 'convertI32PairToI53', + 'convertI32PairToI53Checked', + 'convertU32PairToI53', + 'getTempRet0', + 'setTempRet0', + 'createNamedFunction', + 'zeroMemory', + 'withStackSave', + 'inetPton4', + 'inetNtop4', + 'inetPton6', + 'inetNtop6', + 'readSockaddr', + 'writeSockaddr', + 'readEmAsmArgs', + 'jstoi_q', + 'autoResumeAudioContext', + 'getDynCaller', + 'dynCall', + 'runtimeKeepalivePush', + 'runtimeKeepalivePop', + 'callUserCallback', + 'maybeExit', + 'asmjsMangle', + 'HandleAllocator', + 'getNativeTypeSize', + 'addOnInit', + 'addOnPostCtor', + 'addOnPreMain', + 'addOnExit', + 'STACK_SIZE', + 'STACK_ALIGN', + 'POINTER_SIZE', + 'ASSERTIONS', + 'ccall', + 'cwrap', + 'convertJsFunctionToWasm', + 'getEmptyTableSlot', + 'updateTableMap', + 'getFunctionAddress', + 'addFunction', + 'removeFunction', + 'intArrayToString', + 'AsciiToString', + 'stringToAscii', + 'UTF16ToString', + 'stringToUTF16', + 'lengthBytesUTF16', + 'UTF32ToString', + 'stringToUTF32', + 'lengthBytesUTF32', + 'stringToNewUTF8', + 'writeArrayToMemory', + 'registerKeyEventCallback', + 'maybeCStringToJsString', + 'findEventTarget', + 'getBoundingClientRect', + 'fillMouseEventData', + 'registerMouseEventCallback', + 'registerWheelEventCallback', + 'registerUiEventCallback', + 'registerFocusEventCallback', + 'fillDeviceOrientationEventData', + 'registerDeviceOrientationEventCallback', + 'fillDeviceMotionEventData', + 'registerDeviceMotionEventCallback', + 'screenOrientation', + 'fillOrientationChangeEventData', + 'registerOrientationChangeEventCallback', + 'fillFullscreenChangeEventData', + 'registerFullscreenChangeEventCallback', + 'JSEvents_requestFullscreen', + 'JSEvents_resizeCanvasForFullscreen', + 'registerRestoreOldStyle', + 'hideEverythingExceptGivenElement', + 'restoreHiddenElements', + 'setLetterbox', + 'softFullscreenResizeWebGLRenderTarget', + 'doRequestFullscreen', + 'fillPointerlockChangeEventData', + 'registerPointerlockChangeEventCallback', + 'registerPointerlockErrorEventCallback', + 'requestPointerLock', + 'fillVisibilityChangeEventData', + 'registerVisibilityChangeEventCallback', + 'registerTouchEventCallback', + 'fillGamepadEventData', + 'registerGamepadEventCallback', + 'registerBeforeUnloadEventCallback', + 'fillBatteryEventData', + 'registerBatteryEventCallback', + 'setCanvasElementSize', + 'getCanvasElementSize', + 'jsStackTrace', + 'getCallstack', + 'convertPCtoSourceLocation', + 'checkWasiClock', + 'wasiRightsToMuslOFlags', + 'wasiOFlagsToMuslOFlags', + 'safeSetTimeout', + 'setImmediateWrapped', + 'safeRequestAnimationFrame', + 'clearImmediateWrapped', + 'registerPostMainLoop', + 'registerPreMainLoop', + 'getPromise', + 'makePromise', + 'idsToPromises', + 'makePromiseCallback', + 'findMatchingCatch', + 'Browser_asyncPrepareDataCounter', + 'isLeapYear', + 'ydayFromDate', + 'arraySum', + 'addDays', + 'getSocketFromFD', + 'getSocketAddress', + 'FS_mkdirTree', + '_setNetworkCallback', + 'heapObjectForWebGLType', + 'toTypedArrayIndex', + 'webgl_enable_ANGLE_instanced_arrays', + 'webgl_enable_OES_vertex_array_object', + 'webgl_enable_WEBGL_draw_buffers', + 'webgl_enable_WEBGL_multi_draw', + 'webgl_enable_EXT_polygon_offset_clamp', + 'webgl_enable_EXT_clip_control', + 'webgl_enable_WEBGL_polygon_mode', + 'emscriptenWebGLGet', + 'computeUnpackAlignedImageSize', + 'colorChannelsInGlTextureFormat', + 'emscriptenWebGLGetTexPixelData', + 'emscriptenWebGLGetUniform', + 'webglGetUniformLocation', + 'webglPrepareUniformLocationsBeforeFirstUse', + 'webglGetLeftBracePos', + 'emscriptenWebGLGetVertexAttrib', + '__glGetActiveAttribOrUniform', + 'writeGLArray', + 'registerWebGlEventCallback', + 'runAndAbortIfError', + 'ALLOC_NORMAL', + 'ALLOC_STACK', + 'allocate', + 'writeStringToMemory', + 'writeAsciiToMemory', + 'demangle', + 'stackTrace', +]; +missingLibrarySymbols.forEach(missingLibrarySymbol) + + var unexportedSymbols = [ + 'run', + 'out', + 'err', + 'abort', + 'wasmMemory', + 'wasmExports', + 'HEAPF32', + 'HEAPF64', + 'HEAP8', + 'HEAPU8', + 'HEAP16', + 'HEAPU16', + 'HEAP32', + 'HEAPU32', + 'HEAP64', + 'HEAPU64', + 'writeStackCookie', + 'checkStackCookie', + 'INT53_MAX', + 'INT53_MIN', + 'bigintToI53Checked', + 'stackSave', + 'stackRestore', + 'stackAlloc', + 'ptrToString', + 'exitJS', + 'getHeapMax', + 'growMemory', + 'ENV', + 'ERRNO_CODES', + 'strError', + 'DNS', + 'Protocols', + 'Sockets', + 'timers', + 'warnOnce', + 'readEmAsmArgsArray', + 'getExecutableName', + 'handleException', + 'keepRuntimeAlive', + 'asyncLoad', + 'alignMemory', + 'mmapAlloc', + 'wasmTable', + 'getUniqueRunDependency', + 'noExitRuntime', + 'addRunDependency', + 'removeRunDependency', + 'addOnPreRun', + 'addOnPostRun', + 'freeTableIndexes', + 'functionsInTableMap', + 'setValue', + 'getValue', + 'PATH', + 'PATH_FS', + 'UTF8Decoder', + 'UTF8ArrayToString', + 'UTF8ToString', + 'stringToUTF8Array', + 'stringToUTF8', + 'lengthBytesUTF8', + 'intArrayFromString', + 'UTF16Decoder', + 'stringToUTF8OnStack', + 'JSEvents', + 'specialHTMLTargets', + 'findCanvasEventTarget', + 'currentFullscreenStrategy', + 'restoreOldWindowedStyle', + 'UNWIND_CACHE', + 'ExitStatus', + 'getEnvStrings', + 'doReadv', + 'doWritev', + 'initRandomFill', + 'randomFill', + 'emSetImmediate', + 'emClearImmediate_deps', + 'emClearImmediate', + 'promiseMap', + 'uncaughtExceptionCount', + 'exceptionLast', + 'exceptionCaught', + 'ExceptionInfo', + 'Browser', + 'requestFullscreen', + 'requestFullScreen', + 'setCanvasSize', + 'getUserMedia', + 'createContext', + 'getPreloadedImageData__data', + 'wget', + 'MONTH_DAYS_REGULAR', + 'MONTH_DAYS_LEAP', + 'MONTH_DAYS_REGULAR_CUMULATIVE', + 'MONTH_DAYS_LEAP_CUMULATIVE', + 'SYSCALLS', + 'preloadPlugins', + 'FS_createPreloadedFile', + 'FS_preloadFile', + 'FS_modeStringToFlags', + 'FS_getMode', + 'FS_stdin_getChar_buffer', + 'FS_stdin_getChar', + 'FS_unlink', + 'FS_createPath', + 'FS_createDevice', + 'FS', + 'FS_root', + 'FS_mounts', + 'FS_devices', + 'FS_streams', + 'FS_nextInode', + 'FS_nameTable', + 'FS_currentPath', + 'FS_initialized', + 'FS_ignorePermissions', + 'FS_filesystems', + 'FS_syncFSRequests', + 'FS_readFiles', + 'FS_lookupPath', + 'FS_getPath', + 'FS_hashName', + 'FS_hashAddNode', + 'FS_hashRemoveNode', + 'FS_lookupNode', + 'FS_createNode', + 'FS_destroyNode', + 'FS_isRoot', + 'FS_isMountpoint', + 'FS_isFile', + 'FS_isDir', + 'FS_isLink', + 'FS_isChrdev', + 'FS_isBlkdev', + 'FS_isFIFO', + 'FS_isSocket', + 'FS_flagsToPermissionString', + 'FS_nodePermissions', + 'FS_mayLookup', + 'FS_mayCreate', + 'FS_mayDelete', + 'FS_mayOpen', + 'FS_checkOpExists', + 'FS_nextfd', + 'FS_getStreamChecked', + 'FS_getStream', + 'FS_createStream', + 'FS_closeStream', + 'FS_dupStream', + 'FS_doSetAttr', + 'FS_chrdev_stream_ops', + 'FS_major', + 'FS_minor', + 'FS_makedev', + 'FS_registerDevice', + 'FS_getDevice', + 'FS_getMounts', + 'FS_syncfs', + 'FS_mount', + 'FS_unmount', + 'FS_lookup', + 'FS_mknod', + 'FS_statfs', + 'FS_statfsStream', + 'FS_statfsNode', + 'FS_create', + 'FS_mkdir', + 'FS_mkdev', + 'FS_symlink', + 'FS_rename', + 'FS_rmdir', + 'FS_readdir', + 'FS_readlink', + 'FS_stat', + 'FS_fstat', + 'FS_lstat', + 'FS_doChmod', + 'FS_chmod', + 'FS_lchmod', + 'FS_fchmod', + 'FS_doChown', + 'FS_chown', + 'FS_lchown', + 'FS_fchown', + 'FS_doTruncate', + 'FS_truncate', + 'FS_ftruncate', + 'FS_utime', + 'FS_open', + 'FS_close', + 'FS_isClosed', + 'FS_llseek', + 'FS_read', + 'FS_write', + 'FS_mmap', + 'FS_msync', + 'FS_ioctl', + 'FS_cwd', + 'FS_chdir', + 'FS_createDefaultDirectories', + 'FS_createDefaultDevices', + 'FS_createSpecialDirectories', + 'FS_createStandardStreams', + 'FS_staticInit', + 'FS_init', + 'FS_quit', + 'FS_findObject', + 'FS_analyzePath', + 'FS_createFile', + 'FS_createDataFile', + 'FS_forceLoadFile', + 'FS_createLazyFile', + 'FS_absolutePath', + 'FS_createFolder', + 'FS_createLink', + 'FS_joinPath', + 'FS_mmapAlloc', + 'FS_standardizePath', + 'MEMFS', + 'TTY', + 'PIPEFS', + 'SOCKFS', + 'tempFixedLengthArray', + 'miniTempWebGLFloatBuffers', + 'miniTempWebGLIntBuffers', + 'GL', + 'AL', + 'GLUT', + 'EGL', + 'GLEW', + 'IDBStore', + 'SDL', + 'SDL_gfx', + 'allocateUTF8', + 'allocateUTF8OnStack', + 'print', + 'printErr', + 'jstoi_s', +]; +unexportedSymbols.forEach(unexportedRuntimeSymbol); + + // End runtime exports + // Begin JS library exports + // End JS library exports + +// end include: postlibrary.js + +function checkIncomingModuleAPI() { + ignoredModuleProp('fetchSettings'); +} + +// Imports from the Wasm binary. +var _main = Module['_main'] = makeInvalidEarlyAccess('_main'); +var _fflush = makeInvalidEarlyAccess('_fflush'); +var _strerror = makeInvalidEarlyAccess('_strerror'); +var _emscripten_stack_get_end = makeInvalidEarlyAccess('_emscripten_stack_get_end'); +var _emscripten_stack_get_base = makeInvalidEarlyAccess('_emscripten_stack_get_base'); +var _emscripten_stack_init = makeInvalidEarlyAccess('_emscripten_stack_init'); +var _emscripten_stack_get_free = makeInvalidEarlyAccess('_emscripten_stack_get_free'); +var __emscripten_stack_restore = makeInvalidEarlyAccess('__emscripten_stack_restore'); +var __emscripten_stack_alloc = makeInvalidEarlyAccess('__emscripten_stack_alloc'); +var _emscripten_stack_get_current = makeInvalidEarlyAccess('_emscripten_stack_get_current'); +var wasmMemory = makeInvalidEarlyAccess('wasmMemory'); +var wasmTable = makeInvalidEarlyAccess('wasmTable'); + +function assignWasmExports(wasmExports) { + _main = Module['_main'] = createExportWrapper('__main_argc_argv', 2); + _fflush = createExportWrapper('fflush', 1); + _strerror = createExportWrapper('strerror', 1); + _emscripten_stack_get_end = wasmExports['emscripten_stack_get_end']; + _emscripten_stack_get_base = wasmExports['emscripten_stack_get_base']; + _emscripten_stack_init = wasmExports['emscripten_stack_init']; + _emscripten_stack_get_free = wasmExports['emscripten_stack_get_free']; + __emscripten_stack_restore = wasmExports['_emscripten_stack_restore']; + __emscripten_stack_alloc = wasmExports['_emscripten_stack_alloc']; + _emscripten_stack_get_current = wasmExports['emscripten_stack_get_current']; + wasmMemory = wasmExports['memory']; + wasmTable = wasmExports['__indirect_function_table']; +} + +var wasmImports = { + /** @export */ + __assert_fail: ___assert_fail, + /** @export */ + __cxa_throw: ___cxa_throw, + /** @export */ + __syscall_fcntl64: ___syscall_fcntl64, + /** @export */ + __syscall_ioctl: ___syscall_ioctl, + /** @export */ + __syscall_openat: ___syscall_openat, + /** @export */ + _abort_js: __abort_js, + /** @export */ + _tzset_js: __tzset_js, + /** @export */ + emscripten_resize_heap: _emscripten_resize_heap, + /** @export */ + environ_get: _environ_get, + /** @export */ + environ_sizes_get: _environ_sizes_get, + /** @export */ + fd_close: _fd_close, + /** @export */ + fd_read: _fd_read, + /** @export */ + fd_seek: _fd_seek, + /** @export */ + fd_write: _fd_write, + /** @export */ + random_get: _random_get +}; + + +// include: postamble.js +// === Auto-generated postamble setup entry stuff === + +var calledRun; + +function callMain(args = []) { + assert(runDependencies == 0, 'cannot call main when async dependencies remain! (listen on Module["onRuntimeInitialized"])'); + assert(typeof onPreRuns === 'undefined' || onPreRuns.length == 0, 'cannot call main when preRun functions remain to be called'); + + var entryFunction = _main; + + args.unshift(thisProgram); + + var argc = args.length; + var argv = stackAlloc((argc + 1) * 4); + var argv_ptr = argv; + args.forEach((arg) => { + HEAPU32[((argv_ptr)>>2)] = stringToUTF8OnStack(arg); + argv_ptr += 4; + }); + HEAPU32[((argv_ptr)>>2)] = 0; + + try { + + var ret = entryFunction(argc, argv); + + // if we're not running an evented main loop, it's time to exit + exitJS(ret, /* implicit = */ true); + return ret; + } catch (e) { + return handleException(e); + } +} + +function stackCheckInit() { + // This is normally called automatically during __wasm_call_ctors but need to + // get these values before even running any of the ctors so we call it redundantly + // here. + _emscripten_stack_init(); + // TODO(sbc): Move writeStackCookie to native to to avoid this. + writeStackCookie(); +} + +function run(args = arguments_) { + + if (runDependencies > 0) { + dependenciesFulfilled = run; + return; + } + + stackCheckInit(); + + preRun(); + + // a preRun added a dependency, run will be called later + if (runDependencies > 0) { + dependenciesFulfilled = run; + return; + } + + function doRun() { + // run may have just been called through dependencies being fulfilled just in this very frame, + // or while the async setStatus time below was happening + assert(!calledRun); + calledRun = true; + Module['calledRun'] = true; + + if (ABORT) return; + + initRuntime(); + + preMain(); + + readyPromiseResolve?.(Module); + Module['onRuntimeInitialized']?.(); + consumedModuleProp('onRuntimeInitialized'); + + var noInitialRun = Module['noInitialRun'] || true; + if (!noInitialRun) callMain(args); + + postRun(); + } + + if (Module['setStatus']) { + Module['setStatus']('Running...'); + setTimeout(() => { + setTimeout(() => Module['setStatus'](''), 1); + doRun(); + }, 1); + } else + { + doRun(); + } + checkStackCookie(); +} + +function checkUnflushedContent() { + // Compiler settings do not allow exiting the runtime, so flushing + // the streams is not possible. but in ASSERTIONS mode we check + // if there was something to flush, and if so tell the user they + // should request that the runtime be exitable. + // Normally we would not even include flush() at all, but in ASSERTIONS + // builds we do so just for this check, and here we see if there is any + // content to flush, that is, we check if there would have been + // something a non-ASSERTIONS build would have not seen. + // How we flush the streams depends on whether we are in SYSCALLS_REQUIRE_FILESYSTEM=0 + // mode (which has its own special function for this; otherwise, all + // the code is inside libc) + var oldOut = out; + var oldErr = err; + var has = false; + out = err = (x) => { + has = true; + } + try { // it doesn't matter if it fails + _fflush(0); + // also flush in the JS FS layer + ['stdout', 'stderr'].forEach((name) => { + var info = FS.analyzePath('/dev/' + name); + if (!info) return; + var stream = info.object; + var rdev = stream.rdev; + var tty = TTY.ttys[rdev]; + if (tty?.output?.length) { + has = true; + } + }); + } catch(e) {} + out = oldOut; + err = oldErr; + if (has) { + warnOnce('stdio streams had content in them that was not flushed. you should set EXIT_RUNTIME to 1 (see the Emscripten FAQ), or make sure to emit a newline when you printf etc.'); + } +} + +var wasmExports; + +// In modularize mode the generated code is within a factory function so we +// can use await here (since it's not top-level-await). +wasmExports = await (createWasm()); + +run(); + +// end include: postamble.js + +// include: postamble_modularize.js +// In MODULARIZE mode we wrap the generated code in a factory function +// and return either the Module itself, or a promise of the module. +// +// We assign to the `moduleRtn` global here and configure closure to see +// this as and extern so it won't get minified. + +if (runtimeInitialized) { + moduleRtn = Module; +} else { + // Set up the promise that indicates the Module is initialized + moduleRtn = new Promise((resolve, reject) => { + readyPromiseResolve = resolve; + readyPromiseReject = reject; + }); +} + +// Assertion for attempting to access module properties on the incoming +// moduleArg. In the past we used this object as the prototype of the module +// and assigned properties to it, but now we return a distinct object. This +// keeps the instance private until it is ready (i.e the promise has been +// resolved). +for (const prop of Object.keys(Module)) { + if (!(prop in moduleArg)) { + Object.defineProperty(moduleArg, prop, { + configurable: true, + get() { + abort(`Access to module property ('${prop}') is no longer possible via the module constructor argument; Instead, use the result of the module constructor.`) + } + }); + } +} +// end include: postamble_modularize.js + + + + return moduleRtn; + }; +})(); + +// Export using a UMD style export, or ES6 exports if selected +if (typeof exports === 'object' && typeof module === 'object') { + module.exports = RmRenderer; + // This default export looks redundant, but it allows TS to import this + // commonjs style module. + module.exports.default = RmRenderer; +} else if (typeof define === 'function' && define['amd']) + define([], () => RmRenderer); + diff --git a/ui/public/assets/rm_lines_simple_renderer.wasm b/ui/public/assets/rm_lines_simple_renderer.wasm new file mode 100755 index 00000000..5c3fc94f Binary files /dev/null and b/ui/public/assets/rm_lines_simple_renderer.wasm differ diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 57e40ec2..8d13d331 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -7,15 +7,18 @@ import { AuthProvider } from "./common/useAuthContext"; import Role from "./common/Role"; import { PrivateRoute } from "./components/PrivateRoute"; import Navigationbar from "./components/Navigation"; +import SuBanner from "./components/SuBanner"; import PasscodeResets from "./components/PasscodeResets"; import Login from "./pages/Login"; import Home from "./pages/Home"; import Connect from "./pages/Connect"; import Documents from "./pages/Documents"; +import ViewPdf from "./pages/ViewPdf"; import Integrations from "./pages/Integrations"; import Profile from "./pages/Profile"; import Admin from "./pages/Admin"; +import Logout from "./pages/Logout"; import ScreenShare from "./pages/ScreenShare"; import NoMatch from "./pages/404"; @@ -23,12 +26,6 @@ import "react-toastify/dist/ReactToastify.css"; import "./App.scss" -import { pdfjs } from "react-pdf"; -pdfjs.GlobalWorkerOptions.workerSrc = new URL( - 'pdfjs-dist/build/pdf.worker.min.mjs', - import.meta.url, -).toString(); - export default function App() { useEffect(() => { @@ -41,10 +38,12 @@ export default function App() {
+
+ @@ -53,6 +52,7 @@ export default function App() { + diff --git a/ui/src/App.scss b/ui/src/App.scss index 577534fd..5531baa1 100644 --- a/ui/src/App.scss +++ b/ui/src/App.scss @@ -97,7 +97,7 @@ div[role="button"] > p { gap: 2em; } -canvas { +canvas:not(.rmrender) { margin: auto; width: auto !important; height: auto !important; @@ -274,3 +274,13 @@ main h2 { .flex-spacer { flex: 1; } + +div.rmrender-wrapper { + display: flex; + justify-content: center; +} + +div.rmrender-wrapper canvas { + max-width: 50%; + margin: auto; +} diff --git a/ui/src/common/blobapi.ts b/ui/src/common/blobapi.ts new file mode 100644 index 00000000..8dcd32db --- /dev/null +++ b/ui/src/common/blobapi.ts @@ -0,0 +1,53 @@ +import type { DocumentEnvironment } from "../components/RmLinesRenderer"; +import constants from "./constants"; + +/** + * Build a document environment for client-side .rm rendering (librm_lines WASM), + * using the blob tree from sync 1.5+ storage. Returns null if the blob API is + * unavailable (e.g. sync 1.0) or the document has no usable .content / .rm entries. + */ +export async function tryDocumentEnvironment(id: string): Promise { + let res: Response; + try { + res = await fetch(`${constants.ROOT_URL}/documents/${id}/blobs`, { + credentials: "same-origin", + }); + } catch { + return null; + } + if (!res.ok) { + return null; + } + let blobTree: Record; + try { + blobTree = JSON.parse(new TextDecoder().decode(await res.arrayBuffer())) as Record; + } catch { + return null; + } + const contentKey = `${id}.content`; + if (!blobTree[contentKey]) { + return null; + } + const hasRm = Object.keys(blobTree).some((k) => k.endsWith(".rm")); + if (!hasRm) { + return null; + } + + return { + rootDocId: id, + pageCount: Object.keys(blobTree).filter((e) => e.endsWith(".rm")).length, + loadSubFile: async (subFile: string) => { + const hash = blobTree[subFile]; + if (!hash) { + throw new Error(`missing blob mapping for ${subFile}`); + } + const br = await fetch(`${constants.ROOT_URL}/blobs/${hash}`, { + credentials: "same-origin", + }); + if (!br.ok) { + throw new Error(`blob fetch failed ${br.status}`); + } + return new Uint8Array(await br.arrayBuffer()); + }, + }; +} diff --git a/ui/src/common/constants.js b/ui/src/common/constants.js index 2a7d5a98..747443ca 100644 --- a/ui/src/common/constants.js +++ b/ui/src/common/constants.js @@ -1,5 +1,9 @@ const constants = { - ROOT_URL : "/ui/api" + ROOT_URL : "/ui/api", + // Optional: set VITE_ADOBE_PDF_CLIENT_ID for "Original PDF" (no drawings) viewer + ADOBE_CLIENT_ID: typeof import.meta !== "undefined" && import.meta.env?.VITE_ADOBE_PDF_CLIENT_ID + ? import.meta.env.VITE_ADOBE_PDF_CLIENT_ID + : "", } export default constants \ No newline at end of file diff --git a/ui/src/components/AnnotatedPageViewer.jsx b/ui/src/components/AnnotatedPageViewer.jsx new file mode 100644 index 00000000..72b4a2c1 --- /dev/null +++ b/ui/src/components/AnnotatedPageViewer.jsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from "react"; +import { Button, ButtonGroup, ButtonToolbar } from "react-bootstrap"; +import { FaChevronLeft, FaChevronRight } from "react-icons/fa6"; +import apiservice from "../services/api.service"; + +/** + * Paginated page view: optional background (PDF raster / blank) + transparent + * .rm SVG ink overlay from GET .../page/:n/overlay.svg (rmc / lines2svg). + */ +export default function AnnotatedPageViewer({ + docId, + pageCount = 1, + /** "pdf" shows payload background under the SVG; "notebook" uses white paper. */ + mode = "notebook", + title = "", +}) { + const total = Math.max(1, Number(pageCount) || 1); + const [page, setPage] = useState(1); + const [overlayUrl, setOverlayUrl] = useState(null); + const [overlayError, setOverlayError] = useState(false); + const [bgError, setBgError] = useState(false); + + useEffect(() => { + setPage(1); + }, [docId]); + + useEffect(() => { + if (page > total) setPage(total); + }, [page, total]); + + useEffect(() => { + let cancelled = false; + setOverlayUrl(null); + setOverlayError(false); + setBgError(false); + const url = apiservice.getDocumentPageOverlayUrl(docId, page); + fetch(url, { credentials: "same-origin" }) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.blob(); + }) + .then((blob) => { + if (cancelled) return; + setOverlayUrl(URL.createObjectURL(blob)); + }) + .catch(() => { + if (!cancelled) setOverlayError(true); + }); + return () => { + cancelled = true; + setOverlayUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + }; + }, [docId, page]); + + const showBg = mode === "pdf"; + + return ( +
+ + + + + + + Page {page} of {total} + + + +
+
+ {showBg && !bgError && ( + setBgError(true)} + style={{ width: "100%", height: "auto", display: "block" }} + /> + )} + {(!showBg || bgError) && ( +
+ )} + {!overlayError && overlayUrl && ( + {title + )} + {overlayError && ( +

+ Could not load ink overlay for page {page}. +

+ )} + {!overlayError && !overlayUrl && ( +

+ Loading ink… +

+ )} +
+
+
+ ); +} diff --git a/ui/src/components/DocumentPageThumb.jsx b/ui/src/components/DocumentPageThumb.jsx new file mode 100644 index 00000000..e7a65a6a --- /dev/null +++ b/ui/src/components/DocumentPageThumb.jsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import apiservice from "../services/api.service"; + +const frameStyle = { + width: 68, + height: 88, + borderRadius: 3, + border: "1px solid #dee2e6", + background: "#fff", + overflow: "hidden", + position: "relative", + flexShrink: 0, + isolation: "isolate", +}; + +const layerImg = { + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + objectFit: "cover", + pointerEvents: "none", +}; + +/** + * Grid thumbnail. Prefers the server composite thumb (device-like 384×512). + * Optional layered mode keeps bg+ink multiply for debugging / fallback. + */ +export default function DocumentPageThumb({ + docId, + pageNum = 1, + alt = "", + /** When true, use client-side bg+ink layers instead of server thumb. */ + layered = false, + /** Shown if the thumbnail PNG fails to load. */ + fallback = null, +}) { + const [bgState, setBgState] = useState(layered ? "pending" : "fail"); + const [fgFailed, setFgFailed] = useState(false); + + if (fgFailed) { + return fallback ? {fallback} : null; + } + + if (!layered) { + return ( +
+ {alt} setFgFailed(true)} + style={{ ...layerImg, position: "relative", zIndex: 1 }} + /> +
+ ); + } + + const showBg = bgState === "ok"; + const blendMode = showBg ? "multiply" : "normal"; + + return ( +
+ setBgState("ok")} + onError={() => setBgState("fail")} + style={{ + ...layerImg, + display: showBg ? "block" : "none", + zIndex: 0, + }} + /> + {alt} setFgFailed(true)} + style={{ + ...layerImg, + zIndex: 1, + mixBlendMode: blendMode, + }} + /> +
+ ); +} diff --git a/ui/src/components/ErrorBoundary.jsx b/ui/src/components/ErrorBoundary.jsx index dd6c8d81..54dbaa3a 100644 --- a/ui/src/components/ErrorBoundary.jsx +++ b/ui/src/components/ErrorBoundary.jsx @@ -6,11 +6,32 @@ class ErrorBoundary extends React.Component { // A fake logging service logErrorToServices = console.log; + ensureMetaRefreshRedirect() { + if (typeof document === "undefined") return; + + const metaId = "rmfakecloud-error-refresh"; + let meta = document.getElementById(metaId); + if (!meta) { + meta = document.createElement("meta"); + meta.id = metaId; + document.head.appendChild(meta); + } + + meta.setAttribute("http-equiv", "refresh"); + meta.setAttribute("content", "0;url=/"); + } + static getDerivedStateFromError(error) { // Update state so the next render will show the fallback UI. return { hasError: true, errorMessage: error?.toString() }; } + componentDidUpdate(prevProps, prevState) { + if (!prevState.hasError && this.state.hasError) { + this.ensureMetaRefreshRedirect(); + } + } + componentDidCatch(error, info) { this.logErrorToServices(error, info.componentStack); } @@ -18,7 +39,18 @@ class ErrorBoundary extends React.Component { render() { if (this.state.hasError) { // You can render any custom fallback UI - return

Something went wrong: {this.state.errorMessage}

; + return ( +
+

Something went wrong

+ {this.state.errorMessage ?
{this.state.errorMessage}
: null} + +
+ Redirecting you to /... +
+
+ ); } return this.props.children; diff --git a/ui/src/components/Navigation.jsx b/ui/src/components/Navigation.jsx index 9cdc3fcc..4782852d 100644 --- a/ui/src/components/Navigation.jsx +++ b/ui/src/components/Navigation.jsx @@ -1,15 +1,10 @@ import React from "react"; -import { Nav, Navbar, Button, NavDropdown, Container } from "react-bootstrap"; -import { logout } from "../common/actions"; +import { Nav, Navbar, NavDropdown, Container } from "react-bootstrap"; import { useAuthState } from "../common/useAuthContext"; import { NavLink } from "react-router-dom"; const NavigationBar = () => { - const { state:{user}, dispatch } = useAuthState(); - - function handleLogout(e) { - logout(dispatch); - } + const { state:{user} } = useAuthState(); function isAdmin() { return user && user.Roles && user.Roles[0] === "Admin"; @@ -61,7 +56,7 @@ const NavigationBar = () => { Profile - Log out + Log out diff --git a/ui/src/components/NotebookPageSvg.jsx b/ui/src/components/NotebookPageSvg.jsx new file mode 100644 index 00000000..6416f66b --- /dev/null +++ b/ui/src/components/NotebookPageSvg.jsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import apiservice from "../services/api.service"; + +/** + * Loads one notebook page as SVG (GET .../page/n/overlay.svg) with credentials and displays it. + * Replaces PNG raster preview; strokes render as vector on a white backing. + */ +export default function NotebookPageSvg({ docId, pageNum, label }) { + const [objectUrl, setObjectUrl] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + setObjectUrl(null); + setError(false); + const url = apiservice.getDocumentPageOverlayUrl(docId, pageNum); + fetch(url, { credentials: "same-origin" }) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.blob(); + }) + .then((blob) => { + if (cancelled) return; + const ou = URL.createObjectURL(blob); + setObjectUrl(ou); + }) + .catch(() => { + if (!cancelled) setError(true); + }); + return () => { + cancelled = true; + setObjectUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + }; + }, [docId, pageNum]); + + if (error) { + return ( +

+ Could not load page {pageNum} (SVG). +

+ ); + } + if (!objectUrl) { + return ( +
+ Loading… +
+ ); + } + + return ( +
+ {label +
+ ); +} diff --git a/ui/src/components/PrivateRoute.tsx b/ui/src/components/PrivateRoute.tsx index 87563bbd..880cf20d 100644 --- a/ui/src/components/PrivateRoute.tsx +++ b/ui/src/components/PrivateRoute.tsx @@ -1,6 +1,5 @@ import React from "react"; import { Route, Redirect } from "react-router-dom"; -import { logout } from "../common/actions"; import { useAuthState } from "../common/useAuthContext"; type RouteProp = { @@ -15,7 +14,7 @@ export const PrivateRoute = ({ roles, ...rest }: RouteProp) => { - const { state:{user}, dispatch } = useAuthState(); //read the values of loading and errorMessage from context + const { state:{user} } = useAuthState(); //read the values of loading and errorMessage from context return ( logout - logout(dispatch); - return ; + // role not authorised: keep session, just redirect out of restricted route + return ; } // authorised so return component diff --git a/ui/src/components/RmLinesRenderer.tsx b/ui/src/components/RmLinesRenderer.tsx new file mode 100644 index 00000000..4f4ee1b3 --- /dev/null +++ b/ui/src/components/RmLinesRenderer.tsx @@ -0,0 +1,243 @@ +import { useEffect, useRef, useState } from "react"; + +export interface DocumentEnvironment { + rootDocId: string; + pageCount: number; + loadSubFile(name: string): Promise; +} + +interface RmRendererApi { + callMain(args: string[]): Promise; + FS_writeFile(name: string, data: Uint8Array): void; + FS_readFile(name: string): Uint8Array; +} + +interface RmLinesRendererProps { + environment: DocumentEnvironment | null; + page: number; +} + +interface Page { + uuid: string; +} + +/** reMarkable .content uses top-level `pages` (UUID strings) and sometimes cPages.pages with { id }. */ +function pageIdsFromContentJson(contentData: unknown): string[] { + if (!contentData || typeof contentData !== "object") { + return []; + } + const o = contentData as Record; + const cPages = o.cPages as { pages?: { id?: string; deleted?: unknown }[] } | undefined; + if (cPages?.pages?.length) { + return cPages.pages.filter((e) => e && !e.deleted && e.id).map((e) => String(e.id)); + } + const pages = o.pages; + if (!Array.isArray(pages)) { + return []; + } + const out: string[] = []; + for (const p of pages) { + if (typeof p === "string" && p) { + out.push(p); + } else if (p && typeof p === "object" && "id" in p && (p as { id?: string }).id) { + out.push(String((p as { id: string }).id)); + } + } + return out; +} + +async function loadPages(env: DocumentEnvironment): Promise { + const contentFile = `${env.rootDocId}.content`; + const raw = await env.loadSubFile(contentFile); + const contentData = JSON.parse(new TextDecoder().decode(raw)) as unknown; + return pageIdsFromContentJson(contentData).map((uuid) => ({ uuid })); +} + +async function loadRmBytes(env: DocumentEnvironment, uuid: string): Promise { + const candidates = [`${uuid}.rm`, `${env.rootDocId}/${uuid}.rm`]; + let lastErr: Error | null = null; + for (const path of candidates) { + try { + return await env.loadSubFile(path); + } catch (e) { + lastErr = e instanceof Error ? e : new Error(String(e)); + } + } + throw lastErr ?? new Error("no .rm blob for page"); +} + +async function renderFrame(renderer: RmRendererApi, rawData: Uint8Array) { + renderer.FS_writeFile("/rm", rawData); + await renderer.callMain(["/rm", "/bmp"]); + const bitmap = renderer.FS_readFile("/bmp"); + const u8 = bitmap instanceof Uint8Array ? bitmap : new Uint8Array(bitmap); + const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength); + const width = dv.getUint32(0, false); + const height = dv.getUint32(4, false); + const expected = 8 + width * height * 4; + if (u8.byteLength < expected) { + throw new Error(`bitmap too small: ${u8.byteLength} < ${expected}`); + } + const rawContent = new Uint8ClampedArray(u8.buffer, u8.byteOffset + 8, width * height * 4); + const data = new ImageData(rawContent, width, height); + return { width, height, data }; +} + +export function RmLinesRenderer(props: RmLinesRendererProps) { + const [module, setModule] = useState(null); + const [moduleError, setModuleError] = useState(false); + const [pages, setPages] = useState([]); + const [pagesError, setPagesError] = useState(false); + const [busy, setBusy] = useState(false); + const [renderError, setRenderError] = useState(false); + const [drawnData, setDrawnData] = useState<{ + width: number; + height: number; + data: ImageData; + } | null>(null); + const canvasRef = useRef(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const Rm = (window as unknown as { RmRenderer?: () => Promise }).RmRenderer; + if (!Rm) { + setModuleError(true); + return; + } + const m = await Rm(); + if (!cancelled) { + setModule(m); + setModuleError(false); + } + } catch { + if (!cancelled) { + setModuleError(true); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + (async () => { + setPages([]); + setPagesError(false); + if (!props.environment) { + return; + } + try { + setPages(await loadPages(props.environment)); + } catch { + setPagesError(true); + } + })(); + }, [props.environment]); + + useEffect(() => { + if (!module || !props.environment) { + setDrawnData(null); + setBusy(false); + setRenderError(false); + return; + } + const pg = pages[props.page]; + if (!pg) { + setDrawnData(null); + setBusy(false); + setRenderError(false); + return; + } + + let cancelled = false; + (async () => { + setBusy(true); + setRenderError(false); + setDrawnData(null); + try { + const raw = await loadRmBytes(props.environment!, pg.uuid); + if (cancelled) { + return; + } + const frame = await renderFrame(module, raw); + if (!cancelled) { + setDrawnData(frame); + } + } catch (e) { + console.warn("RmLines render:", e); + if (!cancelled) { + setRenderError(true); + } + } finally { + if (!cancelled) { + setBusy(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, [module, props.page, pages, props.environment]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !drawnData) { + return; + } + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + canvas.width = drawnData.width; + canvas.height = drawnData.height; + ctx.putImageData(drawnData.data, 0, 0); + }, [drawnData]); + + if (moduleError) { + return ( +

+ Notebook line renderer (WASM) failed to load. Try refreshing the page. +

+ ); + } + + if (!module) { + return Loading line renderer…; + } + + if (pagesError) { + return ( +

+ Could not read notebook page list (.content). +

+ ); + } + + if (pages.length === 0 && props.environment) { + return

No pages in this notebook.

; + } + + return ( +
+ {busy && Rendering…} + {renderError && ( +

+ Could not render this page with the line engine (unsupported or corrupt .rm). +

+ )} + +
+ ); +} diff --git a/ui/src/components/SuBanner.jsx b/ui/src/components/SuBanner.jsx new file mode 100644 index 00000000..b39841de --- /dev/null +++ b/ui/src/components/SuBanner.jsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import { Alert, Button } from "react-bootstrap"; +import { toast } from "react-toastify"; +import apiService from "../services/api.service"; +import { useAuthState } from "../common/useAuthContext"; + +export default function SuBanner() { + const { state: { user }, dispatch } = useAuthState(); + const [busy, setBusy] = useState(false); + + if (!user || !user.SuBy) return null; + + const leaveSu = async () => { + if (busy) return; + setBusy(true); + try { + const restored = await apiService.leaveSu(); + dispatch({ type: "LOGIN_SUCCESS", payload: { user: restored } }); + window.location.replace("/documents"); + } catch (err) { + toast.error(`Error: ${err.message || String(err)}`); + setBusy(false); + } + }; + + return ( + +
+ SU active: acting as {user.UserID} (from {user.SuBy}) +
+ +
+ ); +} diff --git a/ui/src/pages/Admin/UserList.jsx b/ui/src/pages/Admin/UserList.jsx index 080893b1..4569bca6 100644 --- a/ui/src/pages/Admin/UserList.jsx +++ b/ui/src/pages/Admin/UserList.jsx @@ -8,16 +8,35 @@ import UserProfileModal from "./UserProfileModal"; import NewUserModal from "./NewUserModal"; import apiService from "../../services/api.service"; import { formatDate } from "../../common/date"; +import { useAuthState } from "../../common/useAuthContext"; const userListUrl = "users"; const NewUser = 1; const UpdateUser = 2; +function formatBytes(bytes) { + const n = Number(bytes); + if (!Number.isFinite(n) || n <= 0) return "0 Bytes"; + const units = ["Bytes", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); + const v = n / (1024 ** i); + return `${v.toFixed(v >= 100 || i === 0 ? 0 : 2)} ${units[i]}`; +} + +function formatQuota(bytes) { + const n = Number(bytes); + if (!Number.isFinite(n) || n <= 0) return "Unlimited"; + return formatBytes(n); +} + export default function UserList() { const [index, setIndex] = useState(false); const { data: userList, error, loading } = useFetch(`${userListUrl}`, index); + const [suBusyUser, setSuBusyUser] = useState(""); const [ state, setState ] = useState({showModal: 0, modalUser: null}); + const { state: { user } } = useAuthState(); + const allowSu = !!user?.AllowSu; const refresh = () =>{ setIndex(previous => previous+1) } @@ -77,6 +96,33 @@ export default function UserList() { toast.error('Error:'+ e) } } + + const suAs = async (e, userid) => { + e.preventDefault(); + e.stopPropagation(); + if (suBusyUser) return; + setSuBusyUser(userid); + try { + await apiService.suAs(userid); + // Hard navigation avoids route-guard side effects after role changes. + window.location.replace("/documents"); + } catch (err) { + toast.error(`Error: ${err.message || String(err)}`); + setSuBusyUser(""); + } + }; + + const renderDeviceSummary = (devices) => { + if (!Array.isArray(devices) || devices.length === 0) return "—"; + return devices.map((d, idx) => { + const details = d.model || d.deviceDesc || d.deviceId || "Device"; + return ( +
+ {details} +
+ ); + }); + }; // const handleSave = async e => { // e.preventDefault() // try { @@ -108,8 +154,18 @@ export default function UserList() { Email Name Role + Devices + File Usage + Quota + Last Login + Password Changed Created At - + + Actions{" "} + + @@ -120,8 +176,28 @@ export default function UserList() { {x.email} {x.Name} {x.isAdmin && "admin"} + {renderDeviceSummary(x.registeredDevices)} + {formatBytes(x.FileUsageBytes)} + {formatQuota(x.quotaBytes)} + {x.LastLoginAt ? formatDate(x.LastLoginAt) : "—"} + {x.PasswordChangedAt ? formatDate(x.PasswordChangedAt) : "—"} {formatDate(x.CreatedAt)} - + + {allowSu && ( + + )} + + ))} diff --git a/ui/src/pages/Admin/UserProfileModal.jsx b/ui/src/pages/Admin/UserProfileModal.jsx index 0244b4f6..d3bf3259 100644 --- a/ui/src/pages/Admin/UserProfileModal.jsx +++ b/ui/src/pages/Admin/UserProfileModal.jsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import Form from "react-bootstrap/Form"; import { Button, Card } from "react-bootstrap"; import apiService from "../../services/api.service"; +import { formatDate } from "../../common/date"; import { Alert } from "react-bootstrap"; @@ -12,6 +13,7 @@ export default function UserProfileModal(params) { const [resetPasswordForm, setResetPasswordForm] = useState({ newPassword: "", email: user?.email, + quotaGb: user?.quotaBytes ? (Number(user.quotaBytes) / (1024 ** 3)).toFixed(2) : "0", }); function handleChange({ target }) { @@ -25,6 +27,10 @@ export default function UserProfileModal(params) { // _errors.error = "newPassword is required"; // if (!resetPasswordForm.email) _errors.error = "email is required"; + const quotaGb = Number(resetPasswordForm.quotaGb); + if (!Number.isFinite(quotaGb) || quotaGb < 0) { + _errors.error = "Quota must be a number >= 0"; + } setFormErrors(_errors); @@ -37,10 +43,12 @@ export default function UserProfileModal(params) { if (!formIsValid()) return; try { + const quotaBytes = Math.round(Number(resetPasswordForm.quotaGb) * (1024 ** 3)); await apiService.updateuser({ userid: user.userid, email: resetPasswordForm.email, newPassword: resetPasswordForm.newPassword, + quotaBytes, }); onSave(); } catch (e) { @@ -83,6 +91,25 @@ export default function UserProfileModal(params) { onChange={handleChange} /> + + Quota (GB) + + Set to 0 for unlimited storage. + + Password Changed +