Skip to content
Closed
1 change: 1 addition & 0 deletions cozy.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ jobs:
# - "notes-save": saving notes to the VFS
# - "rag-index": send data to the RAG server for being indexed
# - "rag-query": send a query to the RAG server
# - "recents-source": following a moved or deleted instance in the recents
# - "push": sending push notifications
# - "sms": sending SMS notifications
# - "sendmail": sending mails
Expand Down
127 changes: 100 additions & 27 deletions docs/adr/007-adr-server-side-recents.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,19 @@ for recently modified files.
Creating/uploading a file, creating a folder and saving a file's contents
count as edits, and so does restoring a file from the trash: the restorer acts
like the item was recreated, and this is a genuine personal edit. Rename, move,
trash, copy and permission changes do not count.
trash, copy and permission changes do not count. The automatic writes of the
flagship app do not count either: the photo backup (files tagged with
`metadata.backupDeviceIds` and its folders), the konnector folders, and the
konnector traces and support logs under `/Settings/Logs` and
`/Settings/AALogs`. Uploads from the share sheet of the mobile app count as
file creations.

### Stored data and sorting

A row needs these fields:
A row is keyed by its resource key, the stable source-instance ID + file ID,
used as its `_id`. It needs these fields:

```text
resource_key string stable source-instance ID + file ID
source_locator string where to fetch the original file
last_edited_at string my latest successful edit, if any (RFC 3339)
last_viewed_at string my latest open, if any (RFC 3339)
Expand All @@ -74,11 +79,12 @@ The same file appears once. Files from different owners remain separate even
if their local IDs match.
Do not use names, paths or checksums to identify them. The stable instance ID
is the CouchDB-generated `_id` of the instance document
(`instance.Instance.DocID`): a UUID assigned once at creation, never modified,
independent of the domain, so it survives domain changes. The optional manager
`Instance.UUID` is not used for this, as operators may leave it empty.
(`instance.Instance.DocID`): a UUID assigned once at creation, never modified.
The domain of an instance never changes either: a change of domain is a move,
which creates a new instance with a new ID and keeps the file IDs. The optional
manager `Instance.UUID` is not used for this, as operators may leave it empty.

Sort by `last_activity_at` descending, then `resource_key` descending. Use
Sort by `last_activity_at` descending, then `_id` descending. Use
the time of the successful edit or open, not the time the row was written.
Another person's change to `updated_at` must not affect this order.

Expand All @@ -94,30 +100,60 @@ Map every supported write path to its identity source:
| Write path | Identity source |
|---|---|
| Browser/mobile with a webapp or mobile session | The instance owner. |
| Sharing write by a recipient (`share-interact` token) | The sharing member, resolved by the actor middleware. |
| `cozy-desktop` / `cozy-flagship` OAuth clients | The instance owner. |
| Sharing write by a recipient (`share-interact` token) | The sharing member, resolved by the actor middleware, once they have accepted the sharing. |
| Shared drive proxy (same stack) | The recipient, when their original token is a person. |
| Flagship OAuth client (certified, or with the `*` scope when its context skips the certification) | The instance owner, except for its automatic writes (see above). |
| OAuth clients that declare the `desktop` kind (`cozy-desktop`) | The instance owner. |
| Other app tokens and konnectors | Not a person. No edit recorded. |
| OnlyOffice callback | The user or member who opened the editing session, recorded server-side when the session starts. |
| Background saves and workers | Not a person. No edit recorded. |

Verified against the current code: `cozyMetadata` records only app slugs, never
a person, so it cannot identify the editor. The actor middleware
(`ResolveRequestActor`) covers the owner session and `share-interact` members,
the two cases above. There is no inbound WebDAV server path; writes from remote
members arrive through the sharing drive routes. Do not trust a client-supplied
editor ID. Verify callback identities on the server.

Opening a document counts as personal activity, detected from reads. The same
HTTP request serves an editor open and a download, so the stack tells them
apart by who reads what: a content read by the app that edits this file type
counts as an open — a note read by the notes app, a drawing read by the
excalidraw app. Downloads, previews and reads of non-editable files by any app
do not count. Reads with OAuth, CLI or konnector tokens never count, and
neither do metadata reads such as folder listings. OnlyOffice sessions are
visible server-side and need no heuristic. Audit each editor app's background
reads before rollout: an editor that starts prefetching would create false
opens. Mobile apps read with OAuth tokens, so their opens do not count until a
trusted-client rule covers them.
(`ResolveRequestActor`) tells, from the original token of the request, whether
it stands for a person, and carries the instance of that person when it lives
on this stack: the owner for their own tokens, a `share-interact` member who
has accepted the sharing. Before the acceptance, the member's Cozy URL comes
from the sharer's contacts and is not trusted. The shared drive proxy swaps the
recipient's token for the drive token on the owner's side, and keeps the actor
resolved from the original token; a proxied route without authentication gets
an actor that is not a person. A member whose instance is on another stack is
skipped silently. There is no inbound WebDAV server path; writes from remote
members arrive through the sharing drive routes. Do not trust a
client-supplied editor ID. Verify callback identities on the server.

Known limit: the `desktop` kind is declared by the OAuth client itself, like
the other client metadata. Any OAuth client that registers with this kind
counts as its user.

Known limit: the shared drive proxy checks the original token only when it
serves the owner's instance directly on this stack. When it cannot load that
instance from the URL of the owner (an alias, an old domain, a port, a
transient error), it forwards the request over HTTP, and the owner's side only
sees the drive token: any request of the recipient then counts as the
recipient, a konnector or service token included.

Opening a document counts as personal activity, recorded by the routes that
open it, never detected from reads: a content read is the same HTTP request as
a download, a preview or a sync, whatever the app. Drive opens a drawing in its
excalidraw editor and a PDF file in its viewer with `GET /editor/:file-id/open`,
the notes editor loads a note with `GET /notes/:id`, and OnlyOffice sessions
are opened server-side. Each of these routes records the open for the person
behind the request, a sharing member included, read-only or not. A read-only
open uses a `share-preview` code: its request actor is anonymous, but
`ParseJWT` finds the member to whom the code was given, credited once they have
accepted the sharing. The mobile app shows Drive and Notes in webviews with
session-bound app tokens, so its opens count like the browser's. Downloads,
metadata reads, and reads with OAuth, CLI or konnector tokens never count, and
neither does the `cozy-viewer` preview of Drive, which only reads the content
of the file. A new editor must open its files through such a route for its
opens to be recorded.

Known limit: an open route of a sharing gives the client a `share-interact` or
`share-preview` code, then used directly on the owner's instance. Whoever holds
the code of a member counts as that member. Through the shared drive proxy, an
open made with a token that is not a person records nothing, but the code it
gets back records the opens made with it as the member's.

Keep one row per user/item pair. If Alice saves and then Bob saves, both users'
activity must survive: each user's row is written on their own instance. The
Expand All @@ -135,9 +171,19 @@ for a recents list.

All involved instances live on the same stack, so there is no
server-to-server delivery: no jobs, locks, retries or ordering machinery. The
row write upserts by `resource_key` and keeps the maximum date per component,
row write upserts by resource key and keeps the maximum date per component,
so writing a row twice is a no-op.

Two activities of the same kind on a row less than 60 seconds apart are one:
the row is not rewritten, so that the hot write paths (typing in a note, a
sync client uploading files) do not write it on every request. Each process
also remembers, for 60 seconds, the activities it has stored, and skips the
read of the row for an activity it knows to be already there. A row deleted by
other means is therefore not recreated by that process for up to 60 seconds,
which no legitimate activity needs: the documents and instances whose rows are
deleted are gone for good, and an instance reset by an import stays blocked
much longer.

Dates are stamped by the server that captures the activity, never by the
client: a browser clock is not trusted. One stack means one clock, so no
cross-stack skew needs clamping.
Expand All @@ -151,7 +197,9 @@ JSON:API: one resource per item with the fields the interface needs (name,
type, size, last modification metadata), the source instance for items owned
by another instance, a `links.next` cursor and a `meta.incomplete` flag set
when the page may miss items. Only visible paths are returned, never the
owner's private parent folders.
owner's private parent folders. The resource key holds the internal ID of the
source instance, possibly another person's: resources and cursors carry a hash
of it, never the key itself.

For each candidate, check that it still belongs in recents and that the caller
can read it. Fetch current metadata and permissions directly from the owner's
Expand All @@ -160,6 +208,31 @@ depend on the owners of candidate items, not every accessible share. The
owner's instance applies the same effective-access rules it applies for the
sharing itself.

The source of a row is found from the instance ID of its key, not from its
`source_locator`, which is refreshed when it differs. The instance of a moved
source remains, with frozen data, until it is deleted, but its rows are
migrated as soon as they are read, under the key of its successor on the same
stack, given by the target of the move or by the instance that declares the old
domain, and the dates of both rows are merged with their maximum. A migrated
row gets a new public ID. The user's own rows are never migrated. The rows of a
source that is no longer on the stack and has no successor on it, moved to
another stack included, are deleted. A source that cannot be checked hides its
rows and marks the page as incomplete. Only the target of the move and the
instance that declares the old domain designate a successor: an instance that
only has that domain as an alias, or as its own domain, is not one, as it may
belong to another person.

These rows are also followed without waiting for a read. When a move is
finalized, and when an instance is destroyed, a `recents-source` job is pushed
to each member of its sharings that lives on the stack and has the recents
enabled: it migrates their rows on its items to the successor, at the end of
its chain of moves, or deletes them when there is none on the stack. The job
gives the successor by its ID, as its domain may be given to another instance
before the job runs. The rows of a source share the prefix of its ID, and are
read by batches from that range of the primary index. A former member removed
from the sharings is not found, and a member whose recents are disabled is
skipped: the reads remain the safety net.

Skip trashed, deleted or unauthorized items and read more candidates to fill
the page, within a fixed work limit. If an owner is unavailable or the limit
is reached, report that the result may be incomplete. If an access check
Expand Down
66 changes: 66 additions & 0 deletions model/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,72 @@ func FindByOldDomain(oldDomain string) (*Instance, error) {
return docs[0], nil
}

// GetByIDs loads the instances with the given CouchDB IDs in one request,
// indexed by ID. The IDs of deleted or unknown instances are absent from the
// map.
func GetByIDs(ids []string) (map[string]*Instance, error) {
insts := make(map[string]*Instance, len(ids))
if len(ids) == 0 {
return insts, nil
}
var docs []*Instance
req := &couchdb.AllDocsRequest{Keys: ids}
err := couchdb.GetAllDocs(prefixer.GlobalPrefixer, consts.Instances, req, &docs)
if err != nil {
return nil, err
}
for _, inst := range docs {
if inst == nil {
continue
}
if err := inst.MakeVFS(); err != nil {
return nil, err
}
insts[inst.DocID] = inst
}
return insts, nil
}

// GetByDomains loads the instances with the given domains or domain aliases
// in one request, indexed by the domain or alias asked. The domains of
// unknown instances are absent from the map.
func GetByDomains(domains []string) (map[string]*Instance, error) {
insts := make(map[string]*Instance, len(domains))
if len(domains) == 0 {
return insts, nil
}
keys := make([]interface{}, len(domains))
for i, domain := range domains {
keys[i] = domain
}
var res couchdb.ViewResponse
err := couchdb.ExecView(prefixer.GlobalPrefixer, couchdb.DomainAndAliasesView, &couchdb.ViewRequest{
Keys: keys,
IncludeDocs: true,
}, &res)
if couchdb.IsNoDatabaseError(err) {
return insts, nil
}
if err != nil {
return nil, err
}
for _, row := range res.Rows {
domain, _ := row.Key.(string)
if domain == "" || insts[domain] != nil {
continue
}
inst := &Instance{}
if err := json.Unmarshal(row.Doc, inst); err != nil {
return nil, err
}
if err := inst.MakeVFS(); err != nil {
return nil, err
}
insts[domain] = inst
}
return insts, nil
}

// PickKey choose which of the Instance keys to use depending on token audience
func (i *Instance) PickKey(audience string) ([]byte, error) {
switch audience {
Expand Down
1 change: 1 addition & 0 deletions model/instance/lifecycle/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ func Create(opts *Options) (*instance.Instance, error) {
g.Go(func() error { return couchdb.CreateDB(i, consts.BitwardenCiphers) })
g.Go(func() error { return couchdb.CreateDB(i, consts.SessionsLogins) })
g.Go(func() error { return couchdb.CreateDB(i, consts.Notifications) })
g.Go(func() error { return couchdb.CreateDB(i, consts.FilesRecents) })
g.Go(func() error {
var errg error
if errg = couchdb.CreateNamedDocWithDB(i, settings); errg != nil {
Expand Down
6 changes: 6 additions & 0 deletions model/instance/lifecycle/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/cozy/cozy-stack/model/instance"
job "github.com/cozy/cozy-stack/model/job"
"github.com/cozy/cozy-stack/model/rag"
"github.com/cozy/cozy-stack/model/recents"
"github.com/cozy/cozy-stack/pkg/config/config"
"github.com/cozy/cozy-stack/pkg/consts"
"github.com/cozy/cozy-stack/pkg/couchdb"
Expand Down Expand Up @@ -92,10 +93,15 @@ func Destroy(domain string) error {

removeTriggers(inst)

// The recents rows of the members of the sharings on the items of the
// instance are followed once its databases are deleted.
followRecents := recents.PrepareDestroy(inst)

if err = couchdb.DeleteAllDBs(inst); err != nil {
inst.Logger().Errorf("Could not delete all CouchDB databases: %s", err.Error())
return err
}
followRecents()

if err = inst.VFS().Delete(); err != nil {
inst.Logger().Errorf("Could not delete VFS: %s", err.Error())
Expand Down
5 changes: 5 additions & 0 deletions model/instance/lifecycle/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package lifecycle

// ResetRecentsDB exposes resetRecentsDB to the tests of the lifecycle_test
// package.
var ResetRecentsDB = resetRecentsDB
11 changes: 10 additions & 1 deletion model/instance/lifecycle/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,18 @@ func createDefaultFilesTree(inst *instance.Instance) error {
}
}

// The Photos folder is referenced as io.cozy.apps/photos, the magic
// folder that cozy-client and the flagship app look for.
if createPhotosFolder {
name := inst.Translate("Tree Photos")
createDir(vfs.NewDirDocWithPath(name, consts.RootDirID, "/", nil))
photosFolder, err := vfs.NewDirDocWithPath(name, consts.RootDirID, "/", nil)
if err == nil {
photosFolder.AddReferencedBy(couchdb.DocReference{
ID: "io.cozy.apps/photos",
Type: consts.Apps,
})
}
createDir(photosFolder, err)
}

return errf
Expand Down
57 changes: 57 additions & 0 deletions model/instance/lifecycle/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,18 @@ func TestLifecycle(t *testing.T) {
}
})

t.Run("InstanceHasReferencedPhotosDir", func(t *testing.T) {
i, err := lifecycle.GetInstance("test.cozycloud.cc")
require.NoError(t, err)

dir, err := i.VFS().DirByPath("/" + i.Translate("Tree Photos"))
require.NoError(t, err)
assert.Contains(t, dir.ReferencedBy, couchdb.DocReference{
ID: "io.cozy.apps/photos",
Type: consts.Apps,
})
})

t.Run("InstanceHasIndexes", func(t *testing.T) {
var results []*vfs.DirDoc
prefix := getDB(t, "test.cozycloud.cc")
Expand All @@ -231,6 +243,51 @@ func TestLifecycle(t *testing.T) {
assert.Len(t, results, 1)
})

t.Run("InstanceHasRecents", func(t *testing.T) {
prefix := getDB(t, "test.cozycloud.cc")
doctypes, err := couchdb.AllDoctypes(prefix)
assert.NoError(t, err)
assert.Contains(t, doctypes, consts.FilesRecents)

var results []couchdb.JSONDoc
req := &couchdb.FindRequest{
UseIndex: "by-last-activity-and-id",
Selector: mango.And(
mango.Gt("last_activity_at", nil),
mango.Gt("_id", nil),
),
Sort: mango.SortBy{
{Field: "last_activity_at", Direction: mango.Desc},
{Field: "_id", Direction: mango.Desc},
},
}
err = couchdb.FindDocs(prefix, consts.FilesRecents, req, &results)
assert.NoError(t, err)
})

t.Run("ResetRecreatedRecents", func(t *testing.T) {
i, err := lifecycle.GetInstance("test.cozycloud.cc")
require.NoError(t, err)

// An activity recorded while the databases are deleted recreates the
// recents database before the reset does.
row := &couchdb.JSONDoc{
Type: consts.FilesRecents,
M: map[string]interface{}{"_id": "source:file", "last_activity_at": "2026-01-01T00:00:00Z"},
}
require.NoError(t, couchdb.CreateNamedDocWithDB(i, row))

require.NoError(t, lifecycle.ResetRecentsDB(i))
var docs []couchdb.JSONDoc
require.NoError(t, couchdb.GetAllDocs(i, consts.FilesRecents, &couchdb.AllDocsRequest{}, &docs))
assert.Empty(t, docs)

require.NoError(t, couchdb.DeleteDB(i, consts.FilesRecents))
require.NoError(t, lifecycle.ResetRecentsDB(i))
_, err = couchdb.DBStatus(i, consts.FilesRecents)
assert.NoError(t, err)
})

t.Run("RegisterPassphrase", func(t *testing.T) {
i, err := lifecycle.GetInstance("test.cozycloud.cc")
if !assert.NoError(t, err, "cant fetch i") {
Expand Down
Loading
Loading