Self-hosted file storage in Rust.
A web app, a REST API and folder sync over a content-addressed blob store.
Your files, on hardware you own, under the AGPL.
Early, and not yet usable end to end.
Done: content-addressed blob store with dedup, on local disk or S3, the node tree with quotas and blob refcounts, and upload, download, listing, trash and restore over REST.
Done too: password accounts with Argon2id, session tokens, and login from the web app, the desktop shell and the CLI, OIDC alongside passwords, plus the marketing and documentation site in English and French. The file browser uploads, previews, renames and deletes, shows who is signed in and lets them sign out, and renaming doubles as moving: type a path instead of a name and the node lands there.
And: folder sync, with a three-way reconciler that keeps both copies when a file changed on either side, either once or watching the folder as it changes. Share links for people with no account, sharing a folder with another account on the instance, search by name, thumbnails, resumable uploads, and WebDAV with locking, which is what lets macOS Finder and Windows Explorer write to it.
Not written: an interface for the sync client beyond the command line.
The desktop app asks which instance to sign in to and remembers it, so one build works against any server.
api/ Rust: the server, the domain, the migrations
web/ Angular app, shared by the browser and the desktop shell
site/ Angular marketing and documentation site, prerendered
deploy/ Dockerfile, compose file, Helm chart
Every release carries an installer: RoxyCloud_<version>_x64-setup.exe for Windows and
RoxyCloud_<version>_amd64.AppImage for Linux, on the
releases page. The app asks for the address
of the instance on first launch, so it works against any server, not only a particular one.
The Windows installer is not signed with an Authenticode certificate yet, so SmartScreen shows "Windows protected your PC" on first run. "More info" then "Run anyway" gets past it. Each installer is published with its SHA-256 beside it, so the download can be checked before it is run:
sha256sum -c RoxyCloud_0.26.0_x64-setup.exe.sha256The AppImage needs the executable bit and a system with FUSE. It is built on Ubuntu 22.04, so it wants glibc 2.35 or later, which covers Debian 12 and everything more recent:
chmod +x RoxyCloud_*_amd64.AppImage
./RoxyCloud_*_amd64.AppImagemacOS has no build yet: a .dmg anyone can open needs an Apple developer account and notarisation.
The account menu has "Check for updates". It reads the manifest attached to the newest release, compares versions, and offers the new one with its release notes. Installing downloads the installer, checks it against the signature published beside it, and restarts the app on the new version, which stops a sync running in that window. Nothing is downloaded or installed until the update is accepted.
A release only reaches installed copies if it carries the .sig files and latest.json, which the
Desktop workflow produces from TAURI_SIGNING_PRIVATE_KEY. Losing that key means no installed copy
accepts any later version, so it belongs in the repository secrets and in a backup, not only on one
machine.
To build it yourself, from a checkout:
pnpm install
cd app && pnpm exec tauri buildThe bundle lands in target/release/bundle/, since every crate in the workspace shares one output
directory.
Postgres 15 or later, and a Rust toolchain matching rust-toolchain.toml.
DATABASE_URL=postgres://localhost/roxycloud JWT_SECRET=dev-secret cargo run -p roxycloud-apiMigrations run on boot. Configuration is environment only:
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
required | Postgres connection string |
JWT_SECRET |
required | HS256 secret used to sign session tokens |
PORT |
3001 |
Listen port |
OIDC_ISSUER |
Provider to discover; all four OIDC_* are needed together |
|
OIDC_CLIENT_ID |
||
OIDC_CLIENT_SECRET |
||
OIDC_REDIRECT_URL |
Where the provider sends the browser back | |
OIDC_CREATE_ACCOUNTS |
false |
Whether a verified address with no account becomes one |
BLOB_BACKEND |
local |
local or s3 |
UPLOAD_ROOT |
beside BLOB_ROOT |
Scratch space for resumable uploads in flight; refused at startup if unset when the backend is s3 |
BLOB_ROOT |
./data |
Local blob store root, when the backend is local |
S3_BUCKET |
Required when the backend is s3 |
|
S3_ENDPOINT |
MinIO or Garage URL; leave unset for AWS | |
S3_REGION |
Region, when the endpoint needs one | |
S3_PREFIX |
Key prefix, for sharing a bucket | |
S3_ACCESS_KEY_ID |
Omit to use the credentials the environment provides | |
S3_SECRET_ACCESS_KEY |
||
WEB_ROOT |
unset | Directory holding the built web app, served alongside the API |
CORS_ALLOWED_ORIGINS |
empty | Comma-separated origins for the SPA, not needed when WEB_ROOT serves it |
DEFAULT_QUOTA_BYTES |
10 GiB | Quota granted on first write |
SESSION_TTL_SECONDS |
12 h | Session token lifetime |
BLOB_SWEEP_INTERVAL_SECONDS |
1 h | How often orphaned blobs are collected, 0 disables it |
BLOB_GRACE_PERIOD_SECONDS |
24 h | How long an unreferenced blob is kept before collection |
BOOTSTRAP_ADMIN_EMAIL |
unset | Creates the first administrator on an empty database |
BOOTSTRAP_ADMIN_PASSWORD |
unset | Required alongside the email, minimum 12 characters |
The web app compiles two values in, ROXYCLOUD_API_URL and ROXYCLOUD_SOURCE_URL. They default to
a local API and to this repository, and both are overridden at build time. An empty API URL means
the same origin as the page, which is what the image builds with, since the API serving the app is
also the API it talks to:
pnpm --filter @roxycloud/web build --define ROXYCLOUD_API_URL="'https://api.example.com'" --define ROXYCLOUD_SOURCE_URL="'https://git.example.com/roxycloud'"If you deploy a modified RoxyCloud, point the source URL at your fork: the AGPL requires you to offer your users the source of the version they are actually using.
deploy/docker-compose.yml brings up the API, the web app and a Postgres for them, on
http://localhost:3001:
POSTGRES_PASSWORD=... JWT_SECRET=... docker compose -f deploy/docker-compose.yml up -d --buildThe image carries the built web app and serves it from the same origin as the API, so there is no
second deployment and no CORS to configure. Hosting the bundle elsewhere still works: build it with
ROXYCLOUD_API_URL pointing at the API, serve it however you like, and name its origin in
CORS_ALLOWED_ORIGINS.
On Kubernetes, deploy/helm/roxycloud deploys the API against a database you already run, with a
volume for the blobs and an optional ingress. It does not bundle Postgres. It serves the web app,
since the image carries it. deploy/helm/roxycloud/README.md has the values and the reasoning.
helm install roxycloud oci://ghcr.io/ferrlabs/charts/roxycloud --set database.url='postgres://roxycloud:password@postgres/roxycloud' --set jwt.secret="$(openssl rand -hex 32)"The release workflow publishes the chart to oci://ghcr.io/ferrlabs/charts/roxycloud and the image
to ghcr.io/ferrlabs/roxycloud-api, for amd64 and arm64, both under the release version, so the
chart's default image needs no override.
GET /health
POST /v1/auth/login exchange email and password for a session token
GET /v1/auth/me the authenticated account
GET /v1/auth/methods what this installation offers to sign in with
PUT /v1/auth/methods turn password login off or on (admin)
POST /v1/auth/oidc/start begin an authorization code flow
POST /v1/auth/oidc/callback finish one, answering a session token
GET /v1/folders list the root
GET /v1/folders/{*path} list a directory
PUT /v1/files/{*path} upload, creating parent directories
GET /v1/files/{*path} download
DELETE /v1/files/{*path} move to trash
POST /v1/move rename a node, or move it under another directory
GET /v1/uploads the sessions this account is holding open
POST /v1/uploads open a resumable upload
GET /v1/uploads/{id} where it got to, for resuming
PATCH /v1/uploads/{id} append at Upload-Offset
POST /v1/uploads/{id}/finish hash what arrived and place it
DELETE /v1/uploads/{id} abandon it, taking the staged bytes
GET /v1/search?q= find a node by part of its name
GET /v1/thumbnails/{*path} a thumbnail of an image, made on demand
GET /v1/app-passwords the credentials this account has minted
POST /v1/app-passwords mint one, shown once
DELETE /v1/app-passwords/{id} revoke one, taking effect immediately
PUT /v1/auth/password change your own, giving the current one
GET /v1/users every account, with what each is using (admin)
POST /v1/users create one (admin)
POST /v1/users/{id}/disable end its sessions and refuse it at login (admin)
POST /v1/users/{id}/enable let it back in (admin)
PUT /v1/users/{id}/role admin, member or reader (admin)
PUT /v1/users/{id}/quota how many bytes it may hold (admin)
PUT /v1/users/{id}/password reset it without knowing the old one (admin)
POST /v1/users/{id}/unlock let a locked out account try again (admin)
GET /v1/grants what this account shares with other accounts
POST /v1/grants share a folder or a file with one, read or write
GET /v1/grants/received what other accounts share with this one
DELETE /v1/grants/{id} take a share back, or leave one shared with you
GET /v1/shares the links this account has published
POST /v1/shares publish one, its token shown once
DELETE /v1/shares/{id} revoke one, taking effect immediately
GET /v1/public/{token} what is behind a link, and its listing
GET /v1/public/{token}/entries/{*path} the same, for something under it
GET /v1/public/{token}/content download what the link names
GET /v1/public/{token}/content/{*path} download something under it
OPTIONS /dav what the WebDAV surface supports
PROPFIND /dav/{*path} list a collection, Depth 0 or 1
PROPPATCH /dav/{*path} answered, and refused: no dead properties are stored
MKCOL /dav/{*path} create a collection
GET /dav/{*path} download
PUT /dav/{*path} upload, without inventing the collections above it
DELETE /dav/{*path} move to trash
COPY /dav/{*path} copy, sharing the bytes rather than writing them again
MOVE /dav/{*path} move, in one transaction
LOCK /dav/{*path} take or refresh an exclusive write lock
UNLOCK /dav/{*path} release one
GET /v1/trash what the account has deleted
POST /v1/trash/{id}/restore bring it back, with the directories it needs
DELETE /v1/trash/{id} delete it for good, and release its bytes
Every /v1 route except login and /v1/public/* takes Authorization: Bearer <session token>.
Deleting is reversible. DELETE /v1/files/{*path} marks the node and everything under it, credits
the quota and leaves the bytes alone, so GET /v1/trash lists what was deleted and a restore puts it
back where it was, recreating any directory above it that was deleted in the meantime. A name taken
since the delete answers 409 rather than inventing a new one: move the occupant, then restore. What
was deleted separately stays separate, so restoring a file out of a folder someone deleted later
leaves the rest of that folder in the trash, listed on its own. Only a purge releases the blobs,
which is what makes it the one irreversible call, and purging a folder takes everything trashed
under it, including what was deleted before it.
Releasing a blob does not delete it. A background sweep collects blobs nothing points at once they
have been unreferenced for BLOB_GRACE_PERIOD_SECONDS, which is what keeps a delete followed by a
re-upload of the same content from racing the collector: the re-upload finds the blob and adopts it.
The bytes come back to the disk on that schedule, not on the purge.
A WebDAV client stores its credential in plain text more often than not, so it never gets the
account password. POST /v1/app-passwords mints a high-entropy secret, shows it once, and keeps only
a fingerprint of it. The secret authenticates over Basic auth on the WebDAV surface and nowhere else:
account management answers 401 to it, so a stolen credential cannot mint itself a successor. Revoking
takes effect on the next request, and last_used_at says which credentials nothing is using.
The account menu mints and revokes them without a terminal. The secret appears once, in the dialog that made it, next to the WebDAV address of the instance and the address to sign in with, so the three go into a client together.
/dav speaks WebDAV against the same tree, authenticated by Basic auth with an app password and
nothing else: a session token is answered 401 there. It advertises class 2, which is what macOS
Finder and Windows Explorer require before they will write to a mounted drive.
Locks are exclusive write locks, taken on a file or on a collection with Depth: infinity. A write
without the token answers 423, whoever is asking, and that includes deleting a folder around a file
someone else holds. A lock lasts ten minutes by default and an hour at most, so a client that
disappears stops holding a file when its lock lapses rather than when someone notices. A COPY shares the blob rather than storing
the bytes twice, and quota is charged for the copy because the tree grew.
Each account carries a role: admin, member or reader. A reader may list and download; upload
and delete answer 403. The check sits in the API rather than in the interface, so it holds for curl
and for roxy sync as much as for the web app.
An administrator reaches the accounts from their own menu: who exists, what each is using against its quota, and whether it is disabled, with a role to change, a quota to set, an account to add, one to disable or enable, a password to reset and a locked one to unlock. A created or reset password is generated there and shown once, because the server keeps only its hash. The interface does not offer an administrator the two things the API refuses them, disabling their own account and taking their own admin role away, and it is the API that enforces it either way.
A share link hands a file or a folder to somebody who has no account. POST /v1/shares takes a
path, mints a 256-bit token, shows it once and stores only a fingerprint of it, so a stolen database
row is not a working link. The token is the whole credential, and it names one node: paths under
/v1/public/{token} are walked downward from that node by following children, so no path a visitor
can write reaches anything the link does not cover.
A link can carry an expiry and a password. The password is chosen by a person rather than generated,
so it goes through argon2 rather than a fingerprint, and it travels in an X-Share-Password header
rather than in the URL that already carries the token. Six characters is enough for it, where an
account password needs twelve: a share password is only ever guessed online against the limiter
below, while an account password has to survive an offline attack on a stolen database.
Revoking is immediate, and so is everything else that should take a link down: the file going to the
trash, the account that published it being disabled, the expiry passing. All of them answer 404,
including a wrong password on a link that does not exist, because a link that says "wrong password"
tells whoever guessed a token that they guessed it.
The header carries the account: the display name, opening a menu with the address it belongs to, its role, a password change and a way to sign out. Signing out forgets the token rather than asking the server, since a session token is only stored in the browser, and changing a password does not end sessions elsewhere.
In the web app the share action sits next to rename and delete, and a Sharing button lists the
links the account has published, with the expiry, when each was last opened, and a revoke, then what
it shares with other accounts. The token appears
once, in the dialog that mints it, because the server keeps only a fingerprint and cannot show it
again. A link opens at /#/s/{token}, which is a page with none of the app's chrome on it: whoever
follows it has no account, is never shown a sign-in form, and no request that page makes carries a
session.
Publishing is a write. A reader may download every file in the account and still gets 403 from
POST /v1/shares, because handing bytes to anyone holding a URL is not reading them. Revoking is
not, so a member demoted to reader keeps the ability to take down what they published. An anonymous
listing carries names, sizes and modification times and no identifiers: not the node ids, not the
account behind the link, and every public response says Cache-Control: no-store so that a proxy
cannot go on serving a link somebody revoked.
An account can also share a folder or a file with another account on the same instance, which a
link is the wrong tool for. POST /v1/grants takes a path, an address and read or write. What
was shared appears for the other account under Shared with me/ at the top of their tree, named
after the folder, and it is reachable there through the same routes as their own files: listing,
download, upload, search and thumbnails. Nothing above the shared node resolves, because the path is
walked down from it, the same containment a link has.
The bytes stay the owner's. A write by the other account lands in the owner's tree, counts against the owner's quota, and a delete goes to the owner's trash, so giving somebody write access is trusting them with that much of your quota. A move from a shared folder into the recipient's own files, or the other way, answers 403, because it would carry bytes from one account's quota into another's; copying is how to take a file. The recipient cannot move or delete the shared folder itself, and cannot pass anything in it on, by a grant or by a link.
An account's role caps what a grant gives it: a reader with write access still only reads. A grant
names an address rather than an account, and it is answered the same way whether an account exists
for that address or not, so the form cannot be used to find out who has one. An account created
later for the address receives what was shared with it. A second grant to the same address inside a
folder it already reaches is refused, so what a path allows comes from one grant, and two shares
with the same name are told apart as Photos and Photos (2).
DELETE /v1/grants/{id} takes a share back when the owner sends it and leaves it when the
recipient does. Either way it is gone on the next request, from sessions already open too, and so
is a shared folder its owner sends to the trash, until it is restored. The owner's GET /v1/grants
keeps listing that share with in_trash, so it can be revoked before a restore hands it back.
Shared with me is reserved
at the top of every tree, and upgrading renames a folder that already had that name.
In the web app the share dialog offers the same thing next to a link: an address, and whether they
may only view or also edit, with who already has it listed underneath. The recipient sees who shared
each folder and how, a folder they may only read offers download and nothing else, and a Leave button
on Shared with me withdraws a share from their side.
Over WebDAV the same folders appear under /dav/Shared with me/, with the same rules. A read-only
share answers current-user-privilege-set without write, so a client that asks greys out what it
would only be refused, and it reports a quota of zero rather than the owner's usage, which is not
the recipient's to see. A write share reports the owner's quota, since that is where the bytes
land.
GET /v1/thumbnails/{*path}?edge=256 answers a WebP thumbnail, made when it is asked for rather
than when the file arrives, and cached against the source digest so the same photo uploaded by two
people costs one thumbnail. The offered edges are 128, 256 and 512: an open integer would let one
request make the server decode and re-encode at any dimension it liked.
Decoding runs in this process, which is a decision rather than a default. The failure mode that
makes image handling notorious is a memory-safety bug in a C decoder reached by a crafted file, and
there is no C decoder here: the image crate is built with its pure Rust codecs and nothing else.
What is left is resource exhaustion, so a file is refused on its size before any decoder sees it, on
its declared pixel count before anything is allocated, and the decoder is given an allocation
ceiling in case the header lied. The name is checked first, so bytes that do not claim to be an
image are never read at all, and SVG is not on the list: it is a document with a script surface
rather than a raster to shrink.
A thumbnail is served as image/webp rather than as the opaque stream every other route uses, with
nosniff and an attachment disposition kept. The reasoning that puts application/octet-stream on
the rest is that the bytes came from a person and the server will not vouch for them; these came out
of its own encoder and WebP carries no script surface. Sending the real type is also what lets a
client point an <img> at it, which a browser refuses when nosniff is set and the type is not an
image.
Decodes are bounded in number as well as in size: a permit per core, taken before the source is read, because every other bound here is per request and would otherwise multiply by the requests in flight.
A large file over a bad link should not start again from zero. POST /v1/uploads opens a session
for a path and a size, PATCH appends at Upload-Offset, and a client that lost the connection asks
GET /v1/uploads/{id} where it got to rather than guessing: it knows what it sent, not what arrived.
A chunk at the wrong offset is refused with the real one in the Upload-Offset header, so resyncing
costs no extra round trip. POST /v1/uploads/{id}/finish hashes what arrived and places it.
The digest is taken by rehashing the staged file at the end rather than carrying a hasher between
requests, because a hasher state persisted across two processes is a second thing that can disagree
with the bytes. An account may hold eight sessions open at once, counted and inserted under the same lock so that
requests arriving together do not all read a count below the ceiling. GET /v1/uploads lists what
is open, so reaching the ceiling is something a client can act on rather than wait out. The quota check when a session opens is not a
reservation, so without a ceiling one account could stage close to its whole quota once per session
and hold all of it for a day.
Quota is checked when the session opens as well as charged when it finishes, so a client does not spend an hour sending a file there was never room for.
A session is claimed for the length of one write by a named holder, and a second write while that claim stands is refused with a 409. Two writers do not share a file cursor, so without it the second truncating under the first would leave a hole of zeros between their write heads, and a length that happened to land on the promised size would be stored under an ETag over those zeros.
The claim is renewed every half of its life for as long as the body drains, and a renewal that does not land ends the write there with a 409. A body slower than the claim would otherwise outlive it, and its remaining bytes would land inside the region the next holder goes on to record, which is the same corruption by a longer route. Everything a write does after that point is scoped to the holder: what it records, what it releases, and the teardown that an over-send triggers, so a writer that lost the session cannot take the session away from whoever has it. What a request records is also what it wrote, counted as it goes, rather than the length the file ends up at, which would count somebody else's write head as arrived.
The claim expires by itself, so a request that died holding it does not strand the session for the day it has left. A lock would serialise them too, but it would hold a database transaction open for as long as the client takes to send its body, which is what this endpoint is built to be slow at.
A chunk is written at the offset the session records rather than appended to the end, and the file
is cut back to that offset first. A request that died mid-body left bytes past that offset, because
received is only recorded once a whole chunk has drained, and appending after them would duplicate
a region and lose the tail while still reaching the promised size. That is the case the feature
exists for, so it is the case the write has to be correct under.
The bytes of a session in flight live on local disk whichever backend owns the blobs. An object
store has no append, and its multipart parts have a five mebibyte floor that would decide the
client's chunk size and make an offset below a part boundary unresumable. The cost is scratch space
under UPLOAD_ROOT for uploads in flight, bounded by the twenty-four hour session lifetime, and the
sweep reconciles that directory against the sessions that still exist.
GET /v1/search?q= matches part of a name against the account's live tree and what other accounts
share with it, case-insensitively, prefix matches first. A match in a shared folder comes back under
Shared with me/, and its path stops at the shared folder rather than naming the owner's folders
above it. A result carries the path it was found at, because a name on its own tells you
that you have a file called notes.md without telling you which of the four it is. What was typed is
a literal: % and _ are escaped rather than passed to the pattern matcher, so searching for %
finds files with a percent sign in the name instead of returning everything. The trash is not
searched, and limit and offset page through the results, capped at 200 at a time.
This is names only. Searching inside documents needs text extraction per format, and that is a different feature rather than a bigger version of this one.
A provider signs people in alongside passwords rather than instead of them, because a self-hoster
with no identity provider still needs a way in. POST /v1/auth/oidc/start answers an authorization
URL, and the PKCE verifier behind it never leaves the server, so a code intercepted on the way back
is not enough to finish the flow. A state is spendable once: one that could be spent twice is a code
that could be replayed.
The flow is tied to the browser that started it by an HttpOnly cookie carrying the state, because
a live code and state pair alone would otherwise be enough to sign somebody else's browser in as the
attacker. The issuer in the token is compared, not merely required: a multi-tenant provider signs
every tenant with the same keys.
An address the provider has not vouched for never reaches an account, whether that account exists or
not. Asserting somebody else's address at a provider that never checked it is the classic way one of
these integrations is broken, so the decision lives in one function and is tested from both
directions: it neither takes over an existing account nor creates a new one. A missing
email_verified claim is not a verified address; a provider that says nothing has vouched for
nothing.
PUT /v1/auth/methods turns password login off once the provider is known to work. It is a setting
rather than an environment variable so that an administrator can do it from the running system, and
it is refused when no provider is configured, because turning off the only way in is not a change
anybody meant to make.
Guessing is limited wherever somebody who is not logged in gets to try an answer, which means
POST /v1/auth/login and the password on a share link. Ten attempts cost nothing, the tenth buys a
minute of silence, and each one after that doubles it up to an hour, so a day of guessing buys a
couple of dozen tries. A block runs out and the next attempt is answered on its merits, so this is a
ladder rather than a lockout. The refusal is a 429 with a Retry-After, and it comes before the
password is hashed rather than after, because a guess that costs the server an argon2 is a guess
worth making. Counting and deciding are serialised per subject, so a burst of simultaneous guesses
does not all read the same count and all get through.
Only a guess counts. Opening a password-protected link without sending a password is answered 401 without touching the counter, since that 401 is how a client learns to ask.
The count belongs to what is being guessed, not to where the guess came from: an address for login, the token's fingerprint for a link. Changing address does not shed it, and an address nobody has is counted like one somebody does, so a 429 never answers whether an account exists. Getting it right clears the count. Two things follow: someone who knows an address can keep that account locked out by failing against it (#91), and the counts live in Postgres, so restarting the server does not clear them.
POST /v1/users/{id}/unlock is the way back in for somebody an attacker has shut out of their own
account. Waiting for whoever is guessing to lose interest is not a recovery plan.
An administrator creates the rest of the accounts, sets their roles and quotas, and can reset a password without knowing it. Disabling one takes effect on the account's next request rather than when its token expires, because every request loads the account behind the session rather than taking the token's word for it. An administrator cannot disable or demote themselves, since an installation nobody can administer is not a state worth being able to reach through the API.
On an empty database, set BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD for the first boot
to create the administrator, then log in:
cargo run -p roxycloud-cli -- login you@example.com --password '...'roxy sync reconciles a local folder with the server once and prints what it did. It compares
content, not timestamps: a file is only transferred when its bytes differ from the other side.
ROXYCLOUD_TOKEN=... cargo run -p roxycloud-cli -- sync ~/RoxyCloudState lives in .roxycloud-sync.json inside the folder, which is what makes a second run cheap and
what tells a deletion apart from a file that was never there. Delete it to start from a full
comparison again.
When a file changed on both sides, both copies are kept: the server's version keeps the name, and
the local one is renamed name (conflict <timestamp>).ext and uploaded under that name. Nothing is
overwritten and nothing waits for an answer.
--watch keeps it running instead, syncing as the folder changes:
ROXYCLOUD_TOKEN=... cargo run -p roxycloud-cli -- sync ~/RoxyCloud --watchA save is not a sync. Changes are collected until the folder has been quiet for a moment, and a folder that never goes quiet still syncs at a ceiling rather than waiting forever. Editors that write a temp file, rename it, and touch the directory therefore produce one sync, not four. Ctrl+C stops it.
One thing it deliberately does not do: an empty local directory is not created on the server, since there is no endpoint for that yet.
Removing a folder locally removes it on the server, contents first and the folder itself last. It holds back when the server's copy has gained anything the last sync did not see, a file added from another machine or an edit to one that is already there, because the delete would take that with it. The folder stays, the new work comes down, and the next removal is the user's to make with both sides in front of them.
Folders other accounts share with you sync like your own, under Shared with me/. The client asks
which of them it may only read, and holds back anything that would write there rather than sending
it to be refused on every pass: an edit to a file in a read-only share, a file dropped loose in
Shared with me/ itself, and removing a shared folder or Shared with me locally, which holds back
everything inside it too rather than emptying the owner's folder. Those are listed
as held in what the sync prints, the local copy is left as it is, and the server's is untouched.
When a file in a read-only share changed on both sides, your version is set aside as the usual
conflict copy and kept locally, held, and the owner's version comes down under the name, so the file
goes on following the owner's edits. The same happens to a conflict on a file shared on its own,
since its copy would otherwise land loose in Shared with me/. Inside a write share, edits and
deletions go through, landing in the owner's files. Against a server too old to have shares,
nothing is held back.
pnpm install && pnpm run build
cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspaceThe tests that need Postgres skip themselves when DATABASE_URL is unset, so the line above runs
anywhere. Point it at a database and they run:
DATABASE_URL=postgres://roxy:roxy@localhost:5432/roxycloud cargo test --workspacepnpm run build builds both browser surfaces. web/dist is embedded in the desktop build, so
build the web app before touching app/. On Linux the Tauri crate needs libwebkit2gtk-4.1-dev,
libappindicator3-dev, librsvg2-dev and patchelf.
The site is a separate Angular app under site/, prerendered to static files in site/dist, with
pnpm run dev:site for the dev server. It carries the install and API pages, so a change to a
config key or an endpoint updates site/src/app/content/ in the same pull request.
See CONTRIBUTING.md.