Skip to content

RoxyCloud

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.

Latest release CI Conventional Commits License

Architecture | Contributing | Security | FerrLabs

Status

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.

Layout

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

Installing the desktop app

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.sha256

The 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.AppImage

macOS has no build yet: a .dmg anyone can open needs an Apple developer account and notarisation.

Updating the desktop app

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 build

The bundle lands in target/release/bundle/, since every crate in the workspace shares one output directory.

Running the API

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-api

Migrations 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.

Self-hosting

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 --build

The 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.

Endpoints

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 '...'

Syncing a folder

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 ~/RoxyCloud

State 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 --watch

A 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.

Development

pnpm install && pnpm run build
cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace

The 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 --workspace

pnpm 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.

About

Self-hosted file storage with WebDAV, built in Rust over a content-addressed blob store.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages