diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..668c66bf --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ui/extra/simplerenderer/librm_lines"] + path = ui/extra/simplerenderer/librm_lines + url = https://github.com/RedTTGMoss/librm_lines 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/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/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/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/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..d89e21ba 100644 --- a/internal/storage/fs/blobstore.go +++ b/internal/storage/fs/blobstore.go @@ -760,3 +760,24 @@ func generationFromFileSize(size int64) int64 { //time + 1 space + 64 hash + 1 newline return size / 86 } + +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 +} 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/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..767ffee4 100644 --- a/internal/ui/backend10.go +++ b/internal/ui/backend10.go @@ -1,6 +1,7 @@ package ui import ( + "errors" "io" "time" @@ -130,3 +131,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..9bf1a66c 100644 --- a/internal/ui/backend15.go +++ b/internal/ui/backend15.go @@ -49,3 +49,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..1ab06bd9 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -4,14 +4,21 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" + "path" + "sort" + "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 +30,10 @@ import ( const ( userIDContextKey = "userID" browserIDContextKey = "browserID" + suByContextKey = "suBy" isSync15Key = "sync15" docIDParam = "docid" + blobIDParam = "blobid" intIDParam = "intid" uiLogger = "[ui] " ui10 = " [10] " @@ -129,32 +138,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}, - }, + user.LastLoginAt = time.Now() + if err := app.userStorer.UpdateUser(user); err != nil { + log.Warn(uiLogger, "persist last login: ", err) } - if user.IsAdmin { - claims.Roles = []string{AdminRole} - } else { - claims.Roles = []string{"User"} - } - - 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 +221,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 +251,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,12 +270,11 @@ 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) { @@ -301,7 +290,6 @@ func (app *ReactAppWrapper) getDocumentMetadata(c *gin.Context) { } -// move rename func (app *ReactAppWrapper) updateDocument(c *gin.Context) { upd := viewmodel.UpdateDoc{} if err := c.ShouldBindJSON(&upd); err != nil { @@ -320,6 +308,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 +405,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 +446,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 +490,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 +505,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 +558,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 +579,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 +600,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 +629,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 +666,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 +681,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 +730,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 +946,399 @@ 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) 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..8bbbc6e1 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,16 @@ 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/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 +110,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..898afdc7 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -28,6 +28,8 @@ type backend interface { 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) @@ -51,6 +53,8 @@ type blobHandler interface { CreateBlobFolder(uid, name, parent string) (doc *storage.Document, err error) Export(uid, docid string) (io.ReadCloser, error) ExportRmDoc(uid, docid string) (io.ReadCloser, error) + GetRawBlob(uid, hash string) (stream io.ReadCloser, err error) + GetBlobDocumentTree(uid, docid string) (m map[string]string, err error) } type notificationHub interface { 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/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/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/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/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 +