OpenDrive is a multi-tenant internal drive built with Elixir, Phoenix LiveView, SQLite metadata, and pluggable blob storage behind OpenDrive.Storage.
The application is organized around workspaces (tenants). Each authenticated user operates inside one active workspace and all drive reads and writes are scoped by tenant_id.
Current product surface:
- Email/password authentication plus magic-link login support
- Workspace creation during registration
- Multi-tenant membership model with
owner,admin, andmemberroles - Tenant switcher for users who belong to multiple workspaces
- Folder tree navigation
- File upload through the Phoenix backend, with optional direct-to-storage primitives available in the codebase
- Authenticated single-file download and multi-file ZIP download
- Soft delete, trash listing, restore, and permanent empty-trash cleanup
- Audit log entries for key tenant, membership, and drive actions
- Elixir
~> 1.15 - Phoenix
~> 1.8.5 - Phoenix LiveView
~> 1.1 - Ecto + SQLite via
ecto_sqlite3 - Tailwind CSS + esbuild
- S3-compatible storage adapter plus a fake local adapter for development and tests
Main modules:
OpenDrive.Accounts: registration, authentication, session tokens, password/email updates, scope bootstrapOpenDrive.Tenancy: workspace creation, membership listing, member management, scope resolutionOpenDrive.Drive: folders, files, uploads, renames, downloads, trash, restore, ZIP assemblyOpenDrive.Audit: tenant-scoped audit event persistenceOpenDrive.Storage: blob storage facade with adapter-based implementation
Relevant web entrypoints:
lib/open_drive_web/router.exlib/open_drive_web/live/drive_live/index.exlib/open_drive_web/live/members_live/index.exlib/open_drive_web/live/trash_live/index.exlib/open_drive_web/controllers/direct_upload_controller.exlib/open_drive_web/controllers/file_download_controller.ex
The current schema is composed of:
usersusers_tokenstenantsmembershipsfoldersfile_objectsfilesaudit_events
Important storage and integrity rules:
- Tenant slug uniqueness is scoped by
owner_user_id, not globally - Active folders must have unique names within the same tenant + parent folder
- Active files must have unique names within the same tenant + folder
- Soft-deleted folders/files do not block reuse of the same name
- Elixir
~> 1.15 - Erlang/OTP compatible with Phoenix 1.8
- Node.js is not required globally; Tailwind and esbuild are installed through Mix tasks
mix setup
mix phx.serverThen open http://localhost:4000.
mix setup runs:
- dependency install
- database creation and migrations
priv/repo/seeds.exs- Tailwind/esbuild installation
- asset build
At the moment, priv/repo/seeds.exs is only a placeholder, so the first user/workspace is created through the registration flow.
mix test
mix precommit
mix ecto.reset
mix assets.build- Anonymous users land on
/ - Registration creates both the user and the first workspace in a single transaction
- Authenticated users are redirected to
/app - Users with more than one membership can switch the active workspace via
/app/switch-tenant - Member management is limited to owners and admins
- Adding a member requires that the invited email already exists as a registered OpenDrive user
Uploads support two paths:
- Direct upload preparation through
POST /app/uploads - Backend proxy upload through
POST /app/uploads/proxy
Direct uploads are signed with a Phoenix token and finalized through POST /app/uploads/complete.
Current operational limits from the code:
- Maximum upload size:
2 GB - Backend proxy threshold constant:
2 GB - ZIP download limit:
100 files - ZIP download total size limit:
500 MB
The default runtime behavior now keeps uploads on the app origin up to the full 2 GB limit. This avoids browser-side S3 CORS failures when the bucket is not configured for cross-origin PUT requests from the app domain.
Downloads support:
- Single file redirect through a presigned download URL
- ZIP generation for selected files
Three adapters implement the same OpenDrive.Storage behaviour, so the rest of the app does not care which one is in use. Pick one with OPEN_DRIVE_STORAGE_ADAPTER:
| Adapter | Value | Default in | Notes |
|---|---|---|---|
| Fake | fake |
development and test | writes under the system temp dir; nothing leaves the machine |
| Backblaze B2 | b2 |
production (when unset) | native-friendly defaults; only the key and bucket are required |
| S3-compatible | s3 |
— | AWS S3 or any S3-compatible service with AWS-style credentials |
export OPEN_DRIVE_STORAGE_ADAPTER=b2
export B2_BUCKET=your-bucket
export B2_APPLICATION_KEY_ID=...
export B2_APPLICATION_KEY=...
export B2_REGION=us-east-005 # optional, defaults to us-east-005
export B2_ENDPOINT=s3.us-east-005.backblazeb2.com # optional, derived from B2_REGIONCreate the application key in the Backblaze panel with access to the bucket and these capabilities: listBuckets, listFiles, readFiles, writeFiles, deleteFiles, shareFiles. The bucket can stay private — media is served through presigned URLs.
TLS note: Backblaze's S3 endpoint sits behind Cloudflare, which replies with a TLS 1.3 HelloRetryRequest that OTP's TLS client rejects (hello_retry_middlebox_assert). config/runtime.exs pins TLS 1.2 for *.backblazeb2.com hosts, so no action is needed when you use the b2 adapter.
export OPEN_DRIVE_STORAGE_ADAPTER=s3
export AWS_S3_BUCKET=your-bucket
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=us-east-1Optional custom endpoint variables (MinIO, Ceph, R2, a VPC endpoint, …):
export AWS_S3_HOST=localhost
export AWS_S3_PORT=9000
export AWS_S3_SCHEME=http://You can also place these variables in .env.local at the project root. config/runtime.exs loads that file automatically outside the test environment without overriding shell-exported variables.
There are two different pieces involved when OpenDrive uses S3:
- IAM permissions for the server-side AWS credentials
- Bucket CORS rules for browser-based direct uploads
These solve different problems and both may be required.
If the browser uploads directly with a presigned PUT URL, the bucket must allow cross-origin requests from the app origin — this applies to AWS S3 and to Backblaze B2 alike. Without it, the browser blocks the request and the UI falls back to /app/uploads/proxy (the upload still works, it just goes through the app server).
AWS S3, one-line command for local development:
aws s3api put-bucket-cors --bucket YOUR_BUCKET --cors-configuration '{"CORSRules":[{"AllowedOrigins":["http://127.0.0.1:4000","http://localhost:4000"],"AllowedMethods":["GET","HEAD","PUT"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":3000}]}'To verify:
aws s3api get-bucket-cors --bucket YOUR_BUCKETOpenDrive talks to B2 through the S3-compatible API (PUT/GET/HEAD on presigned URLs). Native B2 operations such as b2_upload_file do not cover that path. If the bucket has no native CORS rules yet, PutBucketCors works (or the Backblaze panel with S3 Compatible API selected). If native rules already exist, B2 rejects PutBucketCors — update them with b2_update_bucket and include s3_put, s3_get, and s3_head.
aws s3api put-bucket-cors \
--bucket YOUR_BUCKET \
--endpoint-url https://s3.us-east-005.backblazeb2.com \
--cors-configuration '{"CORSRules":[{"AllowedOrigins":["http://localhost:4000","https://drive.gestaobem.com"],"AllowedMethods":["GET","HEAD","PUT"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag","Content-Length"],"MaxAgeSeconds":3600}]}'The equivalent native corsRules JSON uses s3_put, s3_get, and s3_head:
[
{
"corsRuleName": "opendrive-web",
"allowedOrigins": ["http://localhost:4000", "https://drive.gestaobem.com"],
"allowedHeaders": ["*"],
"allowedOperations": ["s3_put", "s3_get", "s3_head"],
"exposeHeaders": ["ETag", "Content-Length"],
"maxAgeSeconds": 3600
}
]Notes:
- Add any extra local or deployed origins you actually use, such as staging or production domains
s3_putis required for direct browser uploads;s3_get/s3_headcover media previews and downloadsAllowedHeaders=["*"]avoids preflight failures with the signedcontent-typeheader- Fallback through
/app/uploads/proxyonly covers files up to 25 MB. Larger files need this CORS rule.
With the b2 adapter there is no IAM to write: the application key created in the Backblaze panel already scopes the access (bucket + capabilities listed above). Skip to the Database configuration section.
With the s3 adapter on AWS, the credentials used by OpenDrive.Storage.S3 need permission to manage the objects stored by the app. A minimal example looks like this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "OpenDriveBucketList",
"Effect": "Allow",
"Action": [
"s3:ListBucket"
],
"Resource": "arn:aws:s3:::YOUR_BUCKET"
},
{
"Sid": "OpenDriveObjectAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:AbortMultipartUpload"
],
"Resource": "arn:aws:s3:::YOUR_BUCKET/*"
}
]
}If you want the same IAM principal to inspect or manage bucket CORS from the CLI, add:
"s3:GetBucketCORS",
"s3:PutBucketCORS"on the bucket resource arn:aws:s3:::YOUR_BUCKET.
Default database files:
- Development:
open_drive_dev.db - Test:
open_drive_test.db - Production fallback when
DATABASE_PATHis set: custom path - Production fallback without
DATABASE_PATH:/tmp/open_drive.db
To override the runtime database path:
export DATABASE_PATH=/absolute/path/to/open_drive.dbThe repository now includes a production Dockerfile and a starter Kamal config in config/deploy.yml.
Current production assumptions:
- Phoenix runs as a release on port
4000 - SQLite lives in
/data/open_drive.dbinside the container - Kamal mounts the host path
/var/lib/open_driveinto/data - Blob storage stays on S3 via
OPEN_DRIVE_STORAGE_ADAPTER=s3 - Health checks use
GET /up
Before the first deploy:
mix phx.gen.secretPut the generated value plus your AWS credentials in .kamal/secrets:
SECRET_KEY_BASE=...
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...Adjust these placeholders in config/deploy.yml:
proxy.hostservers.web.hostsenv.clear.PHX_HOSTenv.clear.AWS_S3_BUCKETssh.userif your server user is notubuntu
If you want to build remotely through a Docker host over SSH, you can still run:
DOCKER_HOST=ssh://your-server-alias kamal setup
DOCKER_HOST=ssh://your-server-alias kamal deployTypical first-run flow:
kamal setup
kamal deployProject checks are grouped in:
mix precommitThat alias runs:
- compile with warnings as errors
mix deps.unlock --unused- format check
- Credo strict mode
- tests
If you want the same gate before each push:
git config core.hooksPath .githooks- Keep tenant-aware behavior scoped through the current workspace context
- Prefer changing the smallest layer that solves the problem
- Preserve blob handling behind
OpenDrive.Storage - For root-level UI metadata and global assets, start with
lib/open_drive_web/components/layouts/root.html.heex - For front-end work, keep the current Phoenix, Tailwind, and LiveView structure unless there is a concrete reason to refactor
