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(``,
+ 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(``, 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(``, 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(``, 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(``, 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(``, 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(``, 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(``, 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 << "