feat: add VitePress docs site, music file picker modal, Dockerfile optimization, i18n keys for 14 locales

- Add docs/ with VitePress site (19 pages): guide, config, architecture, FAQ
- Add GitHub Actions workflow for auto-deploy to GitHub Pages
- Replace music 'Add Tracks' upload picker with in-app audio file browser modal
- Add music picker CSS styles with dark theme support
- Add missing i18n keys (search_audio, no_audio_files, etc.) to all 14 locales
- Optimize Dockerfile: shared base stage, COPY --chmod, consolidated RUN, HEALTHCHECK
- Improve README: docs links, updated stats (222+ tests, 14 languages), feature status
This commit is contained in:
Diocrafts
2026-04-11 20:58:34 +02:00
parent 1bb65e6448
commit f08cffc0e8
42 changed files with 4313 additions and 95 deletions
+54
View File
@@ -0,0 +1,54 @@
name: Deploy Docs
on:
push:
branches: [main]
paths:
- 'docs/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
working-directory: docs
run: npm install
- name: Build docs
working-directory: docs
run: npm run docs:build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+20 -19
View File
@@ -1,8 +1,11 @@
# Stage 1: Cache dependencies
FROM rust:1.94.0-alpine3.23 AS cacher
WORKDIR /app
# ─── Stage 1: Shared build base (avoids duplicate apk install) ────────────────
FROM rust:1.94.1-alpine3.23 AS base
RUN apk --no-cache upgrade && \
apk add --no-cache musl-dev pkgconfig postgresql-dev gcc perl make
# ─── Stage 2: Cache dependencies ─────────────────────────────────────────────
FROM base AS cacher
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
# build.rs + static/ are needed so the build script can run and set OUT_DIR
COPY build.rs ./
@@ -13,11 +16,10 @@ RUN mkdir -p src/bin && \
echo 'fn main() {}' > src/bin/generate-openapi.rs && \
cargo build --release && \
rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-*
# Stage 2: Build the application
FROM rust:1.94.0-alpine3.23 AS builder
# ─── Stage 3: Build the application ──────────────────────────────────────────
FROM base AS builder
WORKDIR /app
RUN apk --no-cache upgrade && \
apk add --no-cache musl-dev pkgconfig postgresql-dev gcc perl make
# Copy cached dependencies (only target dir and cargo registry)
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo/registry /usr/local/cargo/registry
@@ -30,7 +32,7 @@ COPY migrations migrations
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
# Stage 3: Create minimal final image
# ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
FROM alpine:3.23.3
# OCI image metadata
@@ -44,19 +46,13 @@ LABEL org.opencontainers.image.title="OxiCloud" \
# Install only necessary runtime dependencies and update packages
# su-exec is needed by the entrypoint to drop privileges after fixing volume permissions
RUN apk --no-cache upgrade && \
apk add --no-cache libgcc ca-certificates libpq tzdata su-exec
# Create non-root user
RUN addgroup -g 1001 -S oxicloud && \
apk add --no-cache libgcc ca-certificates libpq tzdata su-exec && \
addgroup -g 1001 -S oxicloud && \
adduser -u 1001 -S oxicloud -G oxicloud
# Copy only the compiled binary
COPY --from=builder /app/target/release/oxicloud /usr/local/bin/
RUN chmod +x /usr/local/bin/oxicloud
# Copy entrypoint script
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Copy the compiled binary and entrypoint (--chmod avoids extra RUN chmod layers)
COPY --from=builder --chmod=755 /app/target/release/oxicloud /usr/local/bin/
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
# Copy processed static files (bundled/minified by build.rs in release)
COPY --from=builder --chown=oxicloud:oxicloud /app/static-dist /app/static
@@ -69,6 +65,11 @@ WORKDIR /app
# Expose application port
EXPOSE 8086
# Basic health check — verifies the HTTP server responds on the main port.
# Docker / Compose / Swarm will mark the container unhealthy after 3 failures.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8086/api/version || exit 1
# Entrypoint fixes volume permissions then drops to oxicloud user.
# The container starts as root so it can chown mounted volumes,
# then su-exec drops privileges before running the application.
+21 -23
View File
@@ -15,9 +15,11 @@
[![GitHub Issues](https://img.shields.io/github/issues/diocrafts/OxiCloud?style=for-the-badge)](https://github.com/diocrafts/OxiCloud/issues)
[![Last Commit](https://img.shields.io/github/last-commit/diocrafts/OxiCloud?style=for-the-badge)](https://github.com/diocrafts/OxiCloud/commits/main)
</div>
[**Documentation**](https://diocrafts.github.io/OxiCloud/) | [**Quick Start**](#-quick-start)
<br>
**English** | [Español](docs/es/)
<br/>
</div>
NextCloud was too slow on my home server. So I built OxiCloud — a complete cloud platform written in Rust that runs on minimal hardware and stays out of your way.
@@ -86,8 +88,8 @@ NextCloud was too slow on my home server. So I built OxiCloud — a complete clo
- **Dual DB pool** — dedicated maintenance pool so background tasks never starve user queries
- **LTO-optimised release** — fat LTO, 1 codegen-unit, `opt-level = 3`, stripped
- **Write-behind caching** (moka) — sub-millisecond hot reads
- **112 automated tests** — `cargo test` on every push (CI)
- **9 languages** — EN, ES, DE, FR, IT, PT, NL, ZH, FA
- **222+ automated tests** — `cargo test` on every push (CI)
- **14 languages** — EN, ES, DE, FR, IT, PT, NL, ZH, JA, KO, AR, HI, FA, RU
---
@@ -105,6 +107,8 @@ NextCloud was too slow on my home server. So I built OxiCloud — a complete clo
| **Trash / recycle bin** | ✅ Working | Soft-delete with restore |
| **Full-text search** | ✅ Working | Recursive subtree search |
| **Shared links** | ✅ Working | Optional password protection |
| **Music library & playlists** | ✅ Working | Streaming player, drag-and-drop reorder |
| **Photo gallery** | ✅ Working | Day/month/year views, multi-select |
| **Desktop sync client** | ❌ Planned | Not yet available |
| **Android / iOS app** | ❌ Planned | Not yet available |
| **End-to-end encryption** | ❌ Planned | Roadmap item |
@@ -208,7 +212,7 @@ All config via environment variables (see [`example.env`](example.env)):
| `OXICLOUD_ENABLE_AUTH` | `true` | Toggle authentication |
| `OXICLOUD_ENABLE_TRASH` | `true` | Toggle trash / recycle bin |
Full reference: [`example.env`](example.env) · [Deployment guide](doc/deployment.md) · [OIDC examples](doc/oidc-config-examples.md)
Full reference: [`example.env`](example.env) · [Deployment guide](https://diocrafts.github.io/OxiCloud/config/deployment) · [OIDC setup](https://diocrafts.github.io/OxiCloud/config/oidc)
---
@@ -241,30 +245,24 @@ CSS & JS linter is `biome` (can be installed via `cargo install biome-cli` or on
|--------|-------|
| Rust source files | 170 |
| Lines of code | ~50 000 |
| Automated tests | 112 |
| Documentation pages | 35 |
| Automated tests | 222+ |
| Documentation pages | 35+ |
---
## Documentation
Extensive docs live in [`doc/`](doc/):
📖 **Full documentation:** [**diocrafts.github.io/OxiCloud**](https://diocrafts.github.io/OxiCloud/)
| Topic | Link |
|-------|------|
| Deployment & Docker | [deployment.md](doc/deployment.md) |
| WebDAV integration | [webdav-integration-guide.md](doc/webdav-integration-guide.md) |
| CalDAV / CardDAV | [caldav-technical-spec.md](doc/caldav-technical-spec.md) · [carddav-technical-spec.md](doc/carddav-technical-spec.md) |
| OIDC / SSO setup | [oidc-integration.md](doc/oidc-integration.md) · [oidc-config-examples.md](doc/oidc-config-examples.md) |
| WOPI (Office editing) | [wopi-integration.md](doc/wopi-integration.md) |
| Chunked uploads | [chunked-uploads.md](doc/chunked-uploads.md) |
| Deduplication | [deduplication.md](doc/deduplication.md) |
| Search | [search.md](doc/search.md) |
| Caching architecture | [caching-architecture.md](doc/caching-architecture.md) |
| Storage quotas | [storage-quotas.md](doc/storage-quotas.md) |
| Trash / recycle bin | [trash-feature-summary.md](doc/trash-feature-summary.md) |
| Internationalisation | [i18n.md](doc/i18n.md) |
| Internal architecture | [internal-architecture.md](doc/internal-architecture.md) |
| Topic | Online | Source |
|-------|--------|--------|
| Quick Start | [Guide](https://diocrafts.github.io/OxiCloud/guide/installation) | [doc/deployment.md](doc/deployment.md) |
| WebDAV | [Guide](https://diocrafts.github.io/OxiCloud/guide/webdav) | [doc/webdav-integration-guide.md](doc/webdav-integration-guide.md) |
| CalDAV / CardDAV | [Guide](https://diocrafts.github.io/OxiCloud/guide/caldav-carddav) | [doc/caldav-technical-spec.md](doc/caldav-technical-spec.md) |
| OIDC / SSO | [Guide](https://diocrafts.github.io/OxiCloud/config/oidc) | [doc/oidc-integration.md](doc/oidc-integration.md) |
| WOPI (Office) | [Guide](https://diocrafts.github.io/OxiCloud/config/wopi) | [doc/wopi-integration.md](doc/wopi-integration.md) |
| Architecture | [Guide](https://diocrafts.github.io/OxiCloud/architecture/) | [doc/internal-architecture.md](doc/internal-architecture.md) |
| All env variables | [Reference](https://diocrafts.github.io/OxiCloud/config/env) | [`example.env`](example.env) |
---
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.vitepress/dist/
.vitepress/cache/
+165
View File
@@ -0,0 +1,165 @@
import { defineConfig } from "vitepress";
export default defineConfig({
title: "OxiCloud",
description: "Self-hosted cloud storage, calendar & contacts — blazingly fast",
base: "/OxiCloud/",
sitemap: {
hostname: "https://diocrafts.github.io/OxiCloud",
lastmodDateOnly: false,
},
markdown: {
image: {
lazyLoading: true,
},
},
lastUpdated: true,
ignoreDeadLinks: [
/^https?:\/\/localhost/,
],
locales: {
root: {
label: "English",
lang: "en",
},
es: {
label: "Español",
lang: "es",
link: "/es/",
title: "OxiCloud",
description: "Almacenamiento en la nube autoalojado, calendario y contactos — increíblemente rápido",
themeConfig: {
nav: [
{ text: "Inicio", link: "/es/" },
{ text: "Guía", link: "/es/guide/" },
{ text: "Configuración", link: "/es/config/" },
{ text: "FAQ", link: "/es/faq" },
],
editLink: {
pattern: "https://github.com/DioCrafts/OxiCloud/tree/main/docs/:path",
text: "Editar esta página en GitHub",
},
sidebar: {
"/es/": [
{
text: "Introducción",
items: [
{ text: "¿Qué es OxiCloud?", link: "/es/guide/" },
{ text: "Inicio Rápido", link: "/es/guide/installation" },
],
},
{
text: "Configuración",
items: [
{ text: "Despliegue & Docker", link: "/es/config/deployment" },
{ text: "Variables de Entorno", link: "/es/config/env" },
{ text: "OIDC / SSO", link: "/es/config/oidc" },
{ text: "WOPI (Office)", link: "/es/config/wopi" },
],
},
{
text: "Características",
items: [
{ text: "WebDAV", link: "/es/guide/webdav" },
{ text: "CalDAV & CardDAV", link: "/es/guide/caldav-carddav" },
{ text: "Subida Chunked", link: "/es/guide/chunked-uploads" },
{ text: "Deduplicación", link: "/es/guide/deduplication" },
{ text: "Búsqueda", link: "/es/guide/search" },
{ text: "Papelera", link: "/es/guide/trash" },
],
},
{
text: "Arquitectura",
items: [
{ text: "Arquitectura Interna", link: "/es/architecture/" },
{ text: "Caché", link: "/es/architecture/caching" },
{ text: "Cuotas de Almacenamiento", link: "/es/architecture/storage-quotas" },
],
},
{ text: "FAQ", link: "/es/faq" },
],
},
},
},
},
head: [
["link", { rel: "icon", href: "/OxiCloud/logo.svg" }],
],
themeConfig: {
logo: "/logo.svg",
search: {
provider: "local",
},
editLink: {
pattern: "https://github.com/DioCrafts/OxiCloud/tree/main/docs/:path",
text: "Edit this page on GitHub",
},
nav: [
{ text: "Home", link: "/" },
{ text: "Guide", link: "/guide/" },
{ text: "Configuration", link: "/config/" },
{ text: "FAQ", link: "/faq" },
],
sidebar: {
"/": [
{
text: "Introduction",
items: [
{ text: "What is OxiCloud?", link: "/guide/" },
{ text: "Quick Start", link: "/guide/installation" },
],
},
{
text: "Configuration",
items: [
{ text: "Deployment & Docker", link: "/config/deployment" },
{ text: "Environment Variables", link: "/config/env" },
{ text: "OIDC / SSO", link: "/config/oidc" },
{ text: "WOPI (Office Editing)", link: "/config/wopi" },
],
},
{
text: "Features",
items: [
{ text: "WebDAV", link: "/guide/webdav" },
{ text: "CalDAV & CardDAV", link: "/guide/caldav-carddav" },
{ text: "Chunked Uploads", link: "/guide/chunked-uploads" },
{ text: "Deduplication", link: "/guide/deduplication" },
{ text: "Search", link: "/guide/search" },
{ text: "Trash & Recycle Bin", link: "/guide/trash" },
],
},
{
text: "Architecture",
items: [
{ text: "Internal Architecture", link: "/architecture/" },
{ text: "Caching", link: "/architecture/caching" },
{ text: "Storage Quotas", link: "/architecture/storage-quotas" },
],
},
{ text: "FAQ", link: "/faq" },
],
},
socialLinks: [
{ icon: "github", link: "https://github.com/DioCrafts/OxiCloud" },
],
footer: {
message: "Released under the MIT License.",
copyright: "Copyright © 2025-present DioCrafts",
},
},
});
+37
View File
@@ -0,0 +1,37 @@
# Caching Architecture
OxiCloud uses **moka** (a lock-free, concurrent cache) for write-behind caching that delivers sub-millisecond hot reads.
## Cache Layers
| Cache | TTL | Max Entries | Purpose |
|---|---|---|---|
| File metadata | 60 s | 10 000 | Avoid re-querying PostgreSQL for file info |
| Directory listings | 120 s | 10 000 | Frequently accessed folder contents |
| Thumbnail cache | configurable | 1 000 | Generated WebP/AVIF thumbnails |
| Image transcode | configurable | 500 | On-the-fly image transcoding results |
| Blob hash | 30 s TTI | 5 000 | SHA-256 hashes for dedup lookups |
| Audio metadata | — | 2 000 | ID3 tags and duration |
## How It Works
1. **Read path:** check cache → if hit, return immediately (sub-ms); if miss, query PostgreSQL, populate cache, return
2. **Write path:** update PostgreSQL → invalidate relevant cache entries
3. **TTL expiry:** entries are evicted after their time-to-live, ensuring eventual consistency
## Why moka?
- **Lock-free** — no mutex contention under concurrent access
- **Bounded memory** — max entries prevent unbounded growth
- **TTL + TTI** — supports both time-to-live and time-to-idle eviction
- **Async-ready** — works natively with Tokio
## Configuration
Cache parameters are currently hardcoded in `src/common/config.rs`. Key defaults:
```rust
file_cache_ttl_ms: 60_000, // 1 minute
directory_cache_ttl_ms: 120_000, // 2 minutes
max_cache_entries: 10_000,
```
+63
View File
@@ -0,0 +1,63 @@
# Internal Architecture
OxiCloud follows a **hexagonal (ports & adapters) architecture** with four layers:
```
┌───────────────────────────────────────────────────────────────┐
│ Interfaces │ REST API, WebDAV, CalDAV, CardDAV, WOPI │
├───────────────────────────────────────────────────────────────┤
│ Application │ Use cases, DTOs, port definitions │
├───────────────────────────────────────────────────────────────┤
│ Domain │ Entities, business rules, repository traits │
├───────────────────────────────────────────────────────────────┤
│ Infrastructure│ PostgreSQL, filesystem, caching, auth │
└───────────────────────────────────────────────────────────────┘
```
All cross-layer dependencies point **inward** via trait-based ports. The DI container (`AppServiceFactory`) wires concrete implementations at startup.
## Storage Model: 100% Blob Storage
- **File metadata** (name, folder, size, user, timestamps, trash status) → PostgreSQL (`storage.files`)
- **File content** → content-addressed blobs via DedupService at `.blobs/{prefix}/{hash}.blob`
- **Folder structure** → purely virtual, rows in `storage.folders` (no filesystem directories per user)
- **Trash** → soft-delete flags on files/folders, exposed via `storage.trash_items` VIEW
## Dependency Injection
`AppServiceFactory` in `src/common/di.rs` builds all services in a defined order:
1. **Core services** — paths, content cache, thumbnails, chunked upload, transcode, dedup, compression
2. **Repositories** — `FolderDbRepository`, `FileBlobReadRepository`, `FileBlobWriteRepository`, `TrashDbRepository`
3. **Trash service** (if enabled)
4. **Application services** — folder, file upload/retrieval/management, search, i18n
5. **Share service** (if enabled)
6. **DB services** — favorites, recent, storage usage, auth
7. **CalDAV/CardDAV services**
8. **ZIP service** (last, depends on file & folder services)
9. **Assemble `AppState`**
## Project Structure
```
src/
├── common/ # Config, DI container, errors
├── domain/ # Entities, repository traits
├── application/ # Use cases, DTOs, port traits
├── infrastructure/ # PostgreSQL repos, filesystem, caching
└── interfaces/ # HTTP handlers, WebDAV, CalDAV, CardDAV
```
## Key Metrics
| Metric | Value |
|--------|-------|
| Rust source files | ~170 |
| Lines of code | ~50 000 |
| Automated tests | 222+ |
| Docker image | ~40 MB |
## Further Reading
- [Caching Architecture →](/architecture/caching)
- [Storage Quotas →](/architecture/storage-quotas)
+36
View File
@@ -0,0 +1,36 @@
# Storage Quotas
OxiCloud supports per-user storage quotas to limit disk usage.
## Enabling Quotas
```bash
OXICLOUD_ENABLE_USER_STORAGE_QUOTAS=true
```
## How It Works
1. Each user has a `storage_quota` field (in bytes, `0` = unlimited)
2. On every file upload, the current usage is checked against the quota
3. If the upload would exceed the quota, it's rejected with a `413 Payload Too Large` error
4. Admins can view and set quotas via the admin panel or API
## API
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/admin/users/{id}/quota` | Get user's quota and current usage |
| PUT | `/api/admin/users/{id}/quota` | Set user's quota |
## Admin Panel
The admin panel (`/admin.html`) shows each user's current usage vs. quota with a visual progress bar.
## Deduplication Interaction
Storage usage is calculated based on **logical file size** (what the user uploaded), not physical blob size. This means:
- If two users upload the same 100 MB file, each user's quota is charged 100 MB
- But on disk, only one 100 MB blob exists
This ensures fair quota accounting while maintaining dedup benefits.
+96
View File
@@ -0,0 +1,96 @@
# Deployment & Docker
## Docker Image
OxiCloud uses a multi-stage Alpine build producing a **~40 MB** image:
1. **Base** — shared build dependencies (`musl-dev`, `pkgconfig`, `openssl-dev`, `libpq-dev`)
2. **Cacher** — pre-builds the dependency layer for fast rebuilds
3. **Builder** — compiles OxiCloud (`rust:1.94.0-alpine3.23`)
4. **Runtime** — minimal Alpine (`alpine:3.23.3`) with `libgcc`, `ca-certificates`, `libpq`, `tzdata`, `su-exec`
The final image runs as non-root user `oxicloud` (UID/GID 1001). Exposed port: **8086**.
## Docker Compose
```yaml
services:
postgres:
image: postgres:17.4-alpine
environment:
POSTGRES_DB: oxicloud
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres # change in production!
volumes:
- pg_data:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/schema.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
oxicloud:
image: ghcr.io/diocrafts/oxicloud:latest
ports:
- "8086:8086"
env_file:
- .env
volumes:
- storage_data:/app/storage
depends_on:
postgres:
condition: service_healthy
volumes:
pg_data:
storage_data:
```
## Kubernetes (Helm)
### Prerequisites
- Kubernetes cluster
- Default StorageClass
- Ingress Controller
- Helm 3+
### Install
```bash
helm upgrade --install oxicloud charts/oxicloud \
-f charts/oxicloud/values.yaml
```
### Verify
```bash
kubectl get pods -n oxicloud
kubectl logs statefulset/oxicloud -n oxicloud
```
### WOPI Verification
If Collabora/OnlyOffice is enabled:
```bash
kubectl logs statefulset/oxicloud -n oxicloud | grep "WOPI discovery loaded"
```
## Feature Dependency Matrix
| Feature | Requires DB | Requires Auth | Feature Flag |
|---|---|---|---|
| File storage | Yes | No | Always on |
| Authentication | Yes | — | `OXICLOUD_ENABLE_AUTH` |
| OIDC / SSO | Yes | Yes | `OXICLOUD_OIDC_ENABLED` |
| File sharing | Yes | Yes | `OXICLOUD_ENABLE_FILE_SHARING` |
| Trash | Yes | No | `OXICLOUD_ENABLE_TRASH` |
| Search | Yes | No | `OXICLOUD_ENABLE_SEARCH` |
| Favorites | Yes | Yes | Always on |
| Storage quotas | Yes | Yes | `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` |
| WebDAV | Yes | Optional | Always on |
| CalDAV / CardDAV | Yes | Yes | Always on |
| Deduplication | No | No | Always on |
| Thumbnails | No | No | Always on |
| Chunked uploads | No | No | Always on |
+83
View File
@@ -0,0 +1,83 @@
# Environment Variables
All variables use the `OXICLOUD_` prefix.
## Server
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_STORAGE_PATH` | `./storage` | Root storage directory |
| `OXICLOUD_STATIC_PATH` | `./static` | Static files directory |
| `OXICLOUD_SERVER_PORT` | `8086` | Server port |
| `OXICLOUD_SERVER_HOST` | `127.0.0.1` | Server bind address |
| `OXICLOUD_BASE_URL` | (auto) | Public base URL for share links |
## Database
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_DB_CONNECTION_STRING` | `postgres://postgres:postgres@localhost:5432/oxicloud` | PostgreSQL connection string |
| `OXICLOUD_DB_MAX_CONNECTIONS` | `20` | Max pool connections |
| `OXICLOUD_DB_MIN_CONNECTIONS` | `5` | Min pool connections |
## Authentication
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_JWT_SECRET` | (random) | JWT signing secret |
| `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` | `3600` | Access token lifetime (seconds) |
| `OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS` | `2592000` | Refresh token lifetime (seconds) |
## Feature Flags
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_ENABLE_AUTH` | `true` | Enable authentication |
| `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` | `false` | Per-user storage quotas |
| `OXICLOUD_ENABLE_FILE_SHARING` | `true` | File/folder sharing |
| `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin |
| `OXICLOUD_ENABLE_SEARCH` | `true` | Search |
## OIDC / SSO
See the [OIDC configuration guide](/config/oidc) for details.
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_OIDC_ENABLED` | `false` | Enable OIDC |
| `OXICLOUD_OIDC_ISSUER_URL` | — | OIDC issuer URL |
| `OXICLOUD_OIDC_CLIENT_ID` | — | Client ID |
| `OXICLOUD_OIDC_CLIENT_SECRET` | — | Client secret |
| `OXICLOUD_OIDC_REDIRECT_URI` | `http://localhost:8086/api/auth/oidc/callback` | Callback URL |
| `OXICLOUD_OIDC_SCOPES` | `openid profile email` | Requested scopes |
| `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Frontend URL |
| `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first SSO login |
| `OXICLOUD_OIDC_ADMIN_GROUPS` | — | Groups that grant admin role |
| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password form when OIDC enabled |
| `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Display name for the provider |
## WOPI (Office Editing)
See the [WOPI configuration guide](/config/wopi) for details.
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_WOPI_ENABLED` | `false` | Enable WOPI |
| `OXICLOUD_WOPI_DISCOVERY_URL` | — | Collabora/OnlyOffice discovery URL |
| `OXICLOUD_WOPI_SECRET` | (JWT secret) | WOPI token signing key |
| `OXICLOUD_WOPI_TOKEN_TTL_SECS` | `86400` | Token lifetime |
| `OXICLOUD_WOPI_LOCK_TTL_SECS` | `1800` | Lock expiration |
## Internal Defaults (not configurable via env)
| Parameter | Default |
|---|---|
| File cache TTL | 60 s |
| Directory cache TTL | 120 s |
| Max cache entries | 10 000 |
| Large file threshold | 100 MB |
| Streaming chunk size | 1 MB |
| Max parallel chunks | 8 |
| Trash retention | 30 days |
| Argon2id memory cost | 64 MB |
| Argon2id time cost | 3 iterations |
+21
View File
@@ -0,0 +1,21 @@
# Configuration
OxiCloud is configured entirely via **environment variables** (no config files needed).
## Sections
- [Deployment & Docker](/config/deployment) — Docker Compose, Kubernetes Helm chart, image details
- [Environment Variables](/config/env) — complete reference of all `OXICLOUD_*` variables
- [OIDC / SSO](/config/oidc) — single sign-on with Keycloak, Authentik, Authelia, Google, Azure AD
- [WOPI (Office Editing)](/config/wopi) — Collabora Online / OnlyOffice integration
## Minimal `.env`
```bash
OXICLOUD_DB_CONNECTION_STRING=postgres://postgres:postgres@postgres:5432/oxicloud
OXICLOUD_STORAGE_PATH=/app/storage
OXICLOUD_SERVER_HOST=0.0.0.0
OXICLOUD_SERVER_PORT=8086
```
That's enough to get started. All other settings have sensible defaults.
+95
View File
@@ -0,0 +1,95 @@
# OIDC / SSO
OxiCloud supports OpenID Connect for single sign-on with providers like **Keycloak**, **Authentik**, **Authelia**, **Google**, and **Azure AD**.
## How It Works
1. User clicks "Sign in with SSO" on the login page
2. Browser redirects to the identity provider (IdP)
3. User authenticates with their existing credentials
4. IdP redirects back to OxiCloud with an auth code
5. OxiCloud exchanges the code for user info and issues its own JWT tokens
## Configuration
```bash
OXICLOUD_OIDC_ENABLED=true
OXICLOUD_OIDC_ISSUER_URL="https://authentik.example.com/application/o/oxicloud/"
OXICLOUD_OIDC_CLIENT_ID="your-client-id"
OXICLOUD_OIDC_CLIENT_SECRET="your-client-secret"
OXICLOUD_OIDC_REDIRECT_URI="https://oxicloud.example.com/api/auth/oidc/callback"
OXICLOUD_OIDC_SCOPES="openid profile email"
OXICLOUD_OIDC_FRONTEND_URL="https://oxicloud.example.com"
OXICLOUD_OIDC_AUTO_PROVISION=true
OXICLOUD_OIDC_ADMIN_GROUPS="oxicloud-admins"
OXICLOUD_OIDC_PROVIDER_NAME="Authentik"
```
### Variable Reference
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_OIDC_ENABLED` | `false` | Master switch |
| `OXICLOUD_OIDC_ISSUER_URL` | — | Provider's OIDC issuer URL |
| `OXICLOUD_OIDC_CLIENT_ID` | — | OAuth client ID |
| `OXICLOUD_OIDC_CLIENT_SECRET` | — | OAuth client secret |
| `OXICLOUD_OIDC_REDIRECT_URI` | `http://localhost:8086/api/auth/oidc/callback` | Callback URL registered with the IdP |
| `OXICLOUD_OIDC_SCOPES` | `openid profile email` | Requested scopes |
| `OXICLOUD_OIDC_FRONTEND_URL` | `http://localhost:8086` | Where to redirect the browser after auth |
| `OXICLOUD_OIDC_AUTO_PROVISION` | `true` | Auto-create users on first login |
| `OXICLOUD_OIDC_ADMIN_GROUPS` | — | OIDC groups that grant admin role |
| `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN` | `false` | Hide password login when OIDC is active |
| `OXICLOUD_OIDC_PROVIDER_NAME` | `SSO` | Label shown on the login button |
::: warning
If `OXICLOUD_OIDC_ENABLED=true` but `issuer_url`, `client_id`, or `client_secret` are empty, OIDC is automatically disabled with an error log.
:::
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/auth/oidc/providers` | Returns OIDC provider info |
| GET | `/api/auth/oidc/authorize` | Authorization URL for redirect to IdP |
| GET | `/api/auth/oidc/callback` | Callback from IdP with auth code |
| POST | `/api/auth/oidc/exchange` | Exchange auth code for JWT tokens |
## Provider Examples
### Keycloak
```yaml
# docker-compose.yml
services:
oxicloud:
environment:
OXICLOUD_OIDC_ENABLED: "true"
OXICLOUD_OIDC_ISSUER_URL: "https://keycloak.example.com/realms/your-realm"
OXICLOUD_OIDC_CLIENT_ID: "oxicloud"
OXICLOUD_OIDC_CLIENT_SECRET: "your-client-secret"
OXICLOUD_OIDC_REDIRECT_URI: "https://oxicloud.example.com/api/auth/oidc/callback"
OXICLOUD_OIDC_FRONTEND_URL: "https://oxicloud.example.com"
OXICLOUD_OIDC_PROVIDER_NAME: "Keycloak"
```
### Authentik
```bash
OXICLOUD_OIDC_ISSUER_URL="https://authentik.example.com/application/o/oxicloud/"
OXICLOUD_OIDC_PROVIDER_NAME="Authentik"
```
### Google
```bash
OXICLOUD_OIDC_ISSUER_URL="https://accounts.google.com"
OXICLOUD_OIDC_PROVIDER_NAME="Google"
```
## Notes
- Always use **HTTPS** for OIDC connections
- One OIDC provider per instance (single-provider model)
- OIDC users share the same permissions model as local users
- After OIDC auth, the backend issues its own JWT tokens (no IdP token dependency)
- Use the admin settings UI (`/admin.html`) to configure and test OIDC at runtime
+78
View File
@@ -0,0 +1,78 @@
# WOPI (Office Document Editing)
OxiCloud integrates with **Collabora Online** and **OnlyOffice** via the WOPI protocol, letting users edit documents, spreadsheets, and presentations directly in the browser.
## How It Works
1. User opens a document (`.docx`, `.xlsx`, `.pptx`, `.odt`, etc.)
2. OxiCloud generates a WOPI access token and redirects to the editor
3. The editor fetches the file from OxiCloud via WOPI endpoints
4. Edits are saved back via `PutFile`
## Configuration
```bash
OXICLOUD_WOPI_ENABLED=true
OXICLOUD_WOPI_DISCOVERY_URL="http://collabora:9980/hosting/discovery"
```
| Variable | Default | Description |
|---|---|---|
| `OXICLOUD_WOPI_ENABLED` | `false` | Enable WOPI integration |
| `OXICLOUD_WOPI_DISCOVERY_URL` | — | Editor's WOPI discovery URL |
| `OXICLOUD_WOPI_SECRET` | (JWT secret) | Token signing key |
| `OXICLOUD_WOPI_TOKEN_TTL_SECS` | `86400` | Access token lifetime |
| `OXICLOUD_WOPI_LOCK_TTL_SECS` | `1800` | Lock expiration |
## Docker Compose with Collabora
```yaml
services:
collabora:
image: collabora/code:latest
environment:
- domain=oxicloud\\.example\\.com
- extra_params=--o:ssl.enable=false
ports:
- "9980:9980"
cap_add:
- MKNOD
oxicloud:
environment:
OXICLOUD_WOPI_ENABLED: "true"
OXICLOUD_WOPI_DISCOVERY_URL: "http://collabora:9980/hosting/discovery"
```
## Docker Compose with OnlyOffice
```yaml
services:
onlyoffice:
image: onlyoffice/documentserver:latest
environment:
- JWT_ENABLED=false
ports:
- "8443:443"
oxicloud:
environment:
OXICLOUD_WOPI_ENABLED: "true"
OXICLOUD_WOPI_DISCOVERY_URL: "http://onlyoffice/hosting/discovery"
```
## WOPI Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/wopi/files/{id}` | CheckFileInfo — file metadata |
| GET | `/wopi/files/{id}/contents` | GetFile — download file content |
| POST | `/wopi/files/{id}/contents` | PutFile — save edited content |
| POST | `/wopi/files/{id}` | Lock / Unlock / RefreshLock |
## Supported Formats
Any format supported by your Collabora or OnlyOffice installation, typically:
- Documents: `.docx`, `.odt`, `.doc`, `.rtf`
- Spreadsheets: `.xlsx`, `.ods`, `.xls`, `.csv`
- Presentations: `.pptx`, `.odp`, `.ppt`
+79
View File
@@ -0,0 +1,79 @@
# FAQ
## General
### What is OxiCloud?
OxiCloud is a self-hosted cloud platform written in Rust. It provides file storage, calendar sync, contacts sync, and office editing from a single binary.
### How does it compare to NextCloud?
OxiCloud uses ~20× less RAM, produces a ~25× smaller Docker image, and starts in under 1 second. The trade-off is fewer plugins — OxiCloud builds everything into the core. See the [comparison table](/guide/#oxicloud-vs-nextcloud).
### What hardware do I need?
Minimum: 1 vCPU, 512 MB RAM, and a few GB of disk for PostgreSQL + file storage. OxiCloud runs comfortably on a Raspberry Pi 4.
---
## Installation
### Which database is supported?
PostgreSQL 13 or later. SQLite is not supported — PostgreSQL's ltree, array operations, and concurrent access are essential.
### Can I use MySQL / MariaDB?
Not currently. PostgreSQL is the only supported database.
### How do I update?
Pull the latest image and restart:
```bash
docker compose pull
docker compose up -d
```
---
## Features
### Can I sync files from my desktop?
WebDAV is built-in. You can mount OxiCloud in Windows Explorer, macOS Finder, or Linux file managers. A dedicated desktop sync client is on the roadmap.
### Does CalDAV/CardDAV work with my phone?
On Android, use [DAVx⁵](https://www.davx5.com/). On iOS, CalDAV and CardDAV work via the built-in Accounts settings.
### Can I edit Office documents?
Yes, via WOPI integration with Collabora Online or OnlyOffice. See [WOPI configuration](/config/wopi).
### Is there end-to-end encryption?
Not yet — it's on the roadmap. Files are stored unencrypted on the server (standard for self-hosted solutions that support server-side processing like thumbnails and search).
---
## Troubleshooting
### I can't connect via WebDAV
1. Check that the URL is `http(s)://host:8086/webdav/` (note the trailing slash)
2. Ensure authentication is correct (HTTP Basic)
3. Check server logs: `docker compose logs oxicloud`
### Uploads fail for large files
OxiCloud automatically switches to chunked upload for files over 200 MB. If you're behind a reverse proxy, ensure it allows large request bodies:
```nginx
client_max_body_size 0; # unlimited
proxy_read_timeout 600s;
```
### OIDC login doesn't redirect back
Verify that `OXICLOUD_OIDC_REDIRECT_URI` matches exactly what's configured in your identity provider, and that `OXICLOUD_OIDC_FRONTEND_URL` points to your actual frontend URL.
+70
View File
@@ -0,0 +1,70 @@
# CalDAV & CardDAV
OxiCloud provides built-in CalDAV (calendar) and CardDAV (contacts) servers — no extra apps or plugins needed.
## CalDAV (Calendars)
### Endpoint
```
https://your-server:8086/caldav/
```
### Protocol Compliance
- RFC 4791 (Calendar Access)
- RFC 5545 (iCalendar format)
- DAV capabilities: `1, 2, calendar-access`
### Client Setup
| Client | URL |
|--------|-----|
| Thunderbird | `https://your-server:8086/caldav/` |
| GNOME Calendar | `https://your-server:8086/caldav/` |
| Apple Calendar (macOS/iOS) | `https://your-server:8086/caldav/` |
| DAVx⁵ (Android) | `https://your-server:8086/` (auto-discovery) |
### Thunderbird Setup
1. Open Thunderbird → **Calendar** tab
2. Right-click → **New Calendar** → **On the Network**
3. Format: **CalDAV**
4. URL: `https://your-server:8086/caldav/`
5. Enter your OxiCloud credentials
---
## CardDAV (Contacts)
### Endpoint
```
https://your-server:8086/carddav/
```
### Protocol Compliance
- RFC 6352 (CardDAV)
- RFC 6350 (vCard 4.0)
### Client Setup
| Client | URL |
|--------|-----|
| Thunderbird | `https://your-server:8086/carddav/` |
| GNOME Contacts | `https://your-server:8086/carddav/` |
| Apple Contacts (macOS/iOS) | `https://your-server:8086/carddav/` |
| DAVx⁵ (Android) | `https://your-server:8086/` (auto-discovery) |
### DAVx⁵ (Android) Setup
1. Install [DAVx⁵](https://www.davx5.com/) from F-Droid or Play Store
2. Add account → **Login with URL and user name**
3. Base URL: `https://your-server:8086/`
4. Enter your OxiCloud credentials
5. DAVx⁵ auto-discovers both CalDAV and CardDAV endpoints
::: info
DAVx⁵ file sync works. CalDAV/CardDAV support on DAVx⁵ is still being refined.
:::
+63
View File
@@ -0,0 +1,63 @@
# Chunked Uploads
OxiCloud supports TUS-like chunked uploads for large files. Uploads are parallel, resumable, and have MD5 integrity checks.
## How It Works
1. Client sends `POST /api/files/upload/init` with file metadata → receives an `upload_id`
2. Client splits the file into chunks and uploads them in parallel via `POST /api/files/upload/chunk`
3. Each chunk includes its index, MD5 hash, and the `upload_id`
4. When all chunks are uploaded, client calls `POST /api/files/upload/complete`
5. Server reassembles the file, verifies integrity, and runs deduplication
## API Endpoints
### Initialize Upload
```http
POST /api/files/upload/init
Content-Type: application/json
{
"file_name": "large-video.mp4",
"folder_id": "folder-uuid",
"total_size": 524288000,
"chunk_size": 8388608,
"total_chunks": 63
}
```
### Upload Chunk
```http
POST /api/files/upload/chunk
Content-Type: multipart/form-data
upload_id: "uuid"
chunk_index: 0
chunk_hash: "md5-hex"
file: <binary>
```
### Complete Upload
```http
POST /api/files/upload/complete
Content-Type: application/json
{
"upload_id": "uuid"
}
```
## Configuration
| Parameter | Default | Description |
|---|---|---|
| Max parallel chunks | 8 | Concurrent chunk uploads |
| Min size for chunking | 200 MB | Below this, single-shot upload is used |
| Chunk size | 8 MB | Default chunk size |
## Frontend Behaviour
The OxiCloud web UI automatically selects chunked upload for large files. A progress bar shows overall completion and current chunk status.
+46
View File
@@ -0,0 +1,46 @@
# File Deduplication
OxiCloud uses **SHA-256 content-addressable storage** to avoid storing duplicate files. If two users upload the same file, only one copy is stored on disk.
## How It Works
1. When a file is uploaded, its SHA-256 hash is computed
2. The hash is checked against the blob store (`.blobs/{prefix}/{hash}.blob`)
3. If a blob with that hash already exists, the file metadata points to the existing blob (no extra disk usage)
4. If not, the content is saved as a new blob
5. A reference counter tracks how many files point to each blob
## Automatic Cleanup
When a file is permanently deleted:
1. The blob's reference count is decremented
2. If the reference count reaches zero, the blob is removed from disk
This means disk space is only freed when the **last** reference to a blob is removed.
## Storage Layout
```
storage/
├── .blobs/
│ ├── a1/
│ │ └── a1b2c3d4...sha256.blob
│ ├── f8/
│ │ └── f8e7d6c5...sha256.blob
│ └── ...
```
The first two hex characters of the hash are used as a directory prefix to avoid having millions of files in a single directory.
## Benefits
- **Disk savings** — identical files across users consume storage only once
- **Instant uploads** — if the blob already exists, the upload completes immediately
- **Integrity** — SHA-256 ensures bit-for-bit correctness
## Limitations
- Deduplication is based on exact content match (byte-identical files)
- Near-duplicate files (e.g., a JPEG re-saved at slightly different quality) are stored separately
- Encryption at rest would require per-user keys, which breaks deduplication (planned as opt-in)
+81
View File
@@ -0,0 +1,81 @@
# What is OxiCloud?
OxiCloud is a self-hosted cloud platform written in Rust. It provides file storage, calendar sync (CalDAV), contacts sync (CardDAV), and office document editing (WOPI) — all from a single binary.
NextCloud was too slow on a home server. So OxiCloud was built to run on minimal hardware and stay out of the way.
## OxiCloud vs NextCloud
| Metric | OxiCloud | NextCloud |
|--------|----------|-----------|
| **Language** | Rust (compiled, zero-cost abstractions) | PHP (interpreted) |
| **Docker image** | ~40 MB (Alpine, static binary) | ~1 GB+ (Apache + PHP + modules) |
| **Idle RAM** | ~30–50 MB | ~250–512 MB |
| **Cold start** | < 1 s | 5–15 s |
| **CPU at idle** | ~0 % | 1–5 % (cron, background jobs) |
| **Min. hardware** | 1 vCPU / 512 MB RAM | 2 vCPU / 2 GB RAM |
| **File dedup** | SHA-256 content-addressable | None |
| **Dependencies** | Single binary + PostgreSQL | PHP, Apache/Nginx, Redis, Cron, … |
| **WebDAV** | Built-in (RFC 4918) | Built-in |
| **CalDAV / CardDAV** | Built-in | Via apps |
| **WOPI** | Built-in | Via apps |
| **OIDC / SSO** | Built-in | Via apps |
> NextCloud is a mature, feature-rich ecosystem. OxiCloud targets users who prioritise raw performance, simplicity, and low resource usage over plugin breadth.
## Key Features
### Storage & Files
- Drag-and-drop upload, multi-file, grid & list views
- Chunked uploads (TUS-like, parallel, resumable, MD5 integrity)
- SHA-256 content-addressable file deduplication with ref-counting
- Adaptive compression (zstd / gzip per MIME type)
- Trash bin with soft-delete and auto-purge
- Favourites, recent files, full-text search
- Inline preview for images, PDF, text, audio & video
- On-the-fly thumbnails & transcoding (WebP / AVIF)
### Protocols
- **WebDAV** — RFC 4918, streaming PROPFIND, locking
- **CalDAV** — calendar sync (Thunderbird, GNOME Calendar, iOS, DAVx⁵)
- **CardDAV** — contacts sync with vCard support
- **WOPI** — Collabora Online / OnlyOffice
- **REST API** — complete JSON API
### Security
- JWT + Argon2id password hashing
- OIDC / SSO (Keycloak, Authentik, Authelia, Google, Azure AD)
- Role-based access, per-folder permissions, storage quotas
- Shared links with optional password protection
### Infrastructure
- Single binary, ~40 MB Docker image
- Dual DB pool (user queries never starved by background tasks)
- Write-behind caching (moka) for sub-millisecond reads
- LTO-optimised release builds
- 222+ automated tests
## Feature Status
| Feature | Status |
|---------|--------|
| File storage & upload | ✅ Working |
| WebDAV | ✅ Working |
| CalDAV | ✅ Working |
| CardDAV | ✅ Working |
| WOPI / Office editing | ✅ Working |
| OIDC / SSO | ✅ Working |
| Trash / recycle bin | ✅ Working |
| Full-text search | ✅ Working |
| Shared links | ✅ Working |
| Music library & playlists | ✅ Working |
| Photo gallery | ✅ Working |
| Desktop sync client | ❌ Planned |
| Android / iOS app | ❌ Planned |
| E2E encryption | ❌ Planned |
## Next Steps
- [Quick Start →](/guide/installation)
- [Deployment & Docker →](/config/deployment)
- [Architecture →](/architecture/)
+94
View File
@@ -0,0 +1,94 @@
# Quick Start
## Docker (recommended)
```bash
git clone https://github.com/DioCrafts/oxicloud.git
cd oxicloud
cp example.env .env
docker compose up -d
```
Open **http://localhost:8086**. That's it.
### Docker Compose
```yaml
services:
postgres:
image: postgres:17.4-alpine
environment:
POSTGRES_DB: oxicloud
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- pg_data:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/schema.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
oxicloud:
image: oxicloud:latest
ports:
- "8086:8086"
env_file:
- .env
volumes:
- storage_data:/app/storage
depends_on:
postgres:
condition: service_healthy
volumes:
pg_data:
storage_data:
```
## From Source
Requires **Rust 1.93+** and **PostgreSQL 13+**.
```bash
git clone https://github.com/DioCrafts/oxicloud.git
cd oxicloud
echo "DATABASE_URL=postgres://user:pass@localhost/oxicloud" > .env
cargo build --release
cargo run --release
```
## Kubernetes (Helm)
```bash
helm upgrade --install oxicloud charts/oxicloud \
-f charts/oxicloud/values.yaml
```
Verify:
```bash
kubectl get pods -n oxicloud
kubectl logs statefulset/oxicloud -n oxicloud
```
## Client Setup
| Client | Protocol | URL |
|--------|----------|-----|
| Windows Explorer | WebDAV | `http://host:8086/webdav/` |
| macOS Finder | WebDAV | `http://host:8086/webdav/` |
| Nautilus / Dolphin | WebDAV | `dav://host:8086/webdav/` |
| Thunderbird (calendar) | CalDAV | `http://host:8086/caldav/` |
| Thunderbird (contacts) | CardDAV | `http://host:8086/carddav/` |
| DAVx⁵ (Android) | CalDAV + CardDAV | `http://host:8086/` |
| GNOME Calendar | CalDAV | `http://host:8086/caldav/` |
| GNOME Contacts | CardDAV | `http://host:8086/carddav/` |
| Collabora / OnlyOffice | WOPI | See [WOPI configuration](/config/wopi) |
## What's Next?
- [Environment Variables →](/config/env)
- [OIDC / SSO Setup →](/config/oidc)
- [WebDAV Guide →](/guide/webdav)
+43
View File
@@ -0,0 +1,43 @@
# Search
OxiCloud provides full-text search across your files with multiple filter options.
## Endpoint
```http
GET /api/search?q=report&type_filter=pdf,docx&recursive=true
```
## Query Parameters
| Parameter | Description |
|---|---|
| `q` | Search query (matches file name) |
| `type_filter` | Comma-separated file extensions to filter by |
| `folder_id` | Restrict search to a specific folder |
| `recursive` | `true` to search subfolders |
| `date_from` / `date_to` | Filter by modification date |
| `size_min` / `size_max` | Filter by file size (bytes) |
| `limit` | Maximum results to return |
| `offset` | Pagination offset |
## How It Works
OxiCloud stores file metadata in PostgreSQL with an `ltree` path column, enabling efficient recursive subtree queries:
```sql
SELECT * FROM storage.files
WHERE path <@ 'root.folder_id'
AND LOWER(name) LIKE '%query%'
AND LOWER(extension) = ANY('{pdf,docx}')
ORDER BY updated_at DESC
LIMIT 50;
```
## Frontend
The web UI includes a search bar in the toolbar. Results appear instantly with file name, path, size, and type. Clicking a result navigates to the file's location.
## Feature Flag
Search can be disabled via `OXICLOUD_ENABLE_SEARCH=false`.
+27
View File
@@ -0,0 +1,27 @@
# Trash & Recycle Bin
OxiCloud provides a trash system that soft-deletes files and folders, allowing users to restore or permanently remove them.
## How It Works
1. When a file or folder is deleted, it's **soft-deleted** — a flag (`is_trashed`) is set and a `trashed_at` timestamp is recorded
2. Trashed items are hidden from normal file listings but remain on disk and in the database
3. Users can browse the trash, restore items, or permanently delete them
4. Items older than the retention period (default: **30 days**) are automatically purged
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/trash` | List trashed items |
| POST | `/api/trash/restore/{id}` | Restore a trashed item |
| DELETE | `/api/trash/{id}` | Permanently delete |
| DELETE | `/api/trash/empty` | Empty the entire trash |
## Deduplication Interaction
Permanent deletion decrements the blob reference count. If no other file points to the same blob, the blob is removed from disk.
## Feature Flag
Trash can be disabled via `OXICLOUD_ENABLE_TRASH=false`. When disabled, deletions are permanent.
+80
View File
@@ -0,0 +1,80 @@
# WebDAV
OxiCloud exposes a fully RFC 4918 compliant WebDAV interface at `/webdav/`. It works with all major file managers and sync clients.
## Base URL
```
https://your-server:8086/webdav/
```
## Authentication
HTTP Basic Authentication:
```
Authorization: Basic base64(username:password)
```
::: tip
Always use HTTPS in production — Basic auth sends credentials in every request.
:::
## Supported Methods
| Method | Description |
|--------|-------------|
| `PROPFIND` | List directory contents / get file properties |
| `GET` | Download a file |
| `PUT` | Upload a file |
| `MKCOL` | Create a folder |
| `MOVE` | Move or rename a file/folder |
| `COPY` | Copy a file/folder |
| `DELETE` | Delete a file/folder |
| `LOCK` / `UNLOCK` | File locking |
## Client Setup
### Windows Explorer
1. Open **This PC** → **Map network drive**
2. Enter: `https://your-server:8086/webdav/`
3. Check **Connect using different credentials**
4. Enter your OxiCloud username and password
### macOS Finder
1. **Go** → **Connect to Server** (⌘K)
2. Enter: `https://your-server:8086/webdav/`
3. Enter credentials when prompted
### Linux (Nautilus / Files)
1. Open Files → **Other Locations**
2. In the address bar, type: `davs://your-server:8086/webdav/`
3. Enter credentials
### Linux (Dolphin / KDE)
1. In the address bar, type: `webdavs://your-server:8086/webdav/`
### Command Line (curl)
```bash
# List root directory
curl -u user:pass -X PROPFIND https://your-server:8086/webdav/ \
-H "Depth: 1"
# Download a file
curl -u user:pass https://your-server:8086/webdav/document.pdf -o document.pdf
# Upload a file
curl -u user:pass -T localfile.txt https://your-server:8086/webdav/remotefile.txt
# Create a folder
curl -u user:pass -X MKCOL https://your-server:8086/webdav/new-folder/
```
## Streaming PROPFIND
OxiCloud streams PROPFIND responses, so listing directories with thousands of files doesn't consume excessive memory.
+41
View File
@@ -0,0 +1,41 @@
---
layout: home
hero:
name: "OxiCloud"
text: "Self-hosted cloud storage"
tagline: "Files, calendar & contacts — blazingly fast, written in Rust"
image:
src: /logo.svg
alt: OxiCloud Logo
actions:
- theme: brand
text: Get Started
link: /guide/installation
- theme: alt
text: View on GitHub
link: https://github.com/DioCrafts/OxiCloud
- theme: alt
text: Why OxiCloud?
link: /guide/
features:
- icon: 🚀
title: Blazingly Fast
details: Single Rust binary, ~40 MB Docker image, <1s cold start, 30–50 MB idle RAM.
- icon: 📁
title: Full File Management
details: Chunked uploads, SHA-256 deduplication, trash, favourites, full-text search, thumbnails.
- icon: 🔗
title: WebDAV / CalDAV / CardDAV
details: RFC-compliant protocols for files, calendars, and contacts. Works with all major clients.
- icon: 📝
title: Office Editing (WOPI)
details: Edit documents in Collabora Online or OnlyOffice directly in the browser.
- icon: 🔐
title: Security First
details: JWT + Argon2id, OIDC/SSO (Keycloak, Authentik, Azure AD), role-based access, shared links.
- icon: 🌍
title: 14 Languages
details: EN, ES, DE, FR, IT, PT, NL, ZH, JA, KO, AR, HI, FA, RU — and growing.
---
+2510
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"scripts": {
"docs:dev": "vitepress dev .",
"docs:build": "vitepress build .",
"docs:preview": "vitepress preview ."
},
"devDependencies": {
"vitepress": "^1.6.3"
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
<!-- Fondo circular -->
<circle cx="250" cy="250" r="200" fill="#f8f9fa" />
<!-- Forma de nube principal - inspirada en Nextcloud pero con estilo propio -->
<path d="M330 280c27.6 0 50-22.4 50-50s-22.4-50-50-50c-5.3 0-10.3 0.8-15.1 2.3C307.4 154.5 282.6 135 253 135c-29.6 0-54.4 19.5-62.9 46.3C185.3 180.8 180.3 180 175 180c-27.6 0-50 22.4-50 50s22.4 50 50 50h155z" fill="#ff5e3a" />
<!-- Texto del logotipo - solo el nombre -->
<text x="250" y="350" font-family="Arial, sans-serif" font-size="55" font-weight="bold" text-anchor="middle" fill="#2b3a4a">OxiCloud</text>
</svg>

After

Width:  |  Height:  |  Size: 657 B

+183
View File
@@ -1288,3 +1288,186 @@
[data-theme="dark"] .music-empty-state-desc {
color: var(--color-text-muted, #888);
}
/* ═══════════════ Audio file picker modal ═══════════════ */
.music-picker-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
transition: opacity 0.2s ease;
}
.music-picker-overlay.active {
opacity: 1;
}
.music-picker-modal {
display: flex;
flex-direction: column;
width: min(560px, 94vw);
max-height: min(620px, 85vh);
background: var(--bg-primary, #fff);
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
transform: translateY(12px);
transition: transform 0.2s ease;
}
.music-picker-overlay.active .music-picker-modal {
transform: translateY(0);
}
/* ── header ── */
.music-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color, #e2e8f0);
}
.music-picker-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text, #2d3748);
}
.music-picker-header h3 i { margin-right: 8px; color: var(--color-primary, #667eea); }
.music-picker-close {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: var(--color-text-muted, #718096);
padding: 0 4px;
line-height: 1;
}
.music-picker-close:hover { color: var(--color-text, #2d3748); }
/* ── search bar ── */
.music-picker-search {
position: relative;
padding: 12px 20px;
border-bottom: 1px solid var(--border-color, #e2e8f0);
}
.music-picker-search i {
position: absolute;
left: 32px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted, #a0aec0);
font-size: 13px;
}
.music-picker-search input {
width: 100%;
padding: 8px 12px 8px 32px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 6px;
font-size: 13px;
background: var(--bg-secondary, #f7fafc);
color: var(--color-text, #2d3748);
outline: none;
}
.music-picker-search input:focus {
border-color: var(--color-primary, #667eea);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
/* ── file list ── */
.music-picker-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
min-height: 200px;
}
.music-picker-loading,
.music-picker-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 20px;
color: var(--color-text-muted, #718096);
font-size: 13px;
}
.music-picker-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 20px;
cursor: pointer;
transition: background 0.1s;
font-size: 13px;
color: var(--color-text, #2d3748);
}
.music-picker-item:hover { background: var(--bg-hover, #edf2f7); }
.music-picker-item.selected { background: rgba(102, 126, 234, 0.08); }
.music-picker-item input[type="checkbox"] {
accent-color: var(--color-primary, #667eea);
width: 16px;
height: 16px;
flex-shrink: 0;
}
.music-picker-item i.fa-file-audio {
color: var(--color-primary, #667eea);
font-size: 14px;
flex-shrink: 0;
}
.music-picker-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.music-picker-size {
flex-shrink: 0;
font-size: 12px;
color: var(--color-text-muted, #a0aec0);
}
/* ── footer ── */
.music-picker-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-top: 1px solid var(--border-color, #e2e8f0);
}
.music-picker-selected-count {
font-size: 12px;
color: var(--color-text-muted, #718096);
font-weight: 500;
}
.music-picker-actions {
display: flex;
gap: 8px;
}
/* ── dark theme ── */
[data-theme="dark"] .music-picker-modal {
background: var(--bg-primary, #1a202c);
}
[data-theme="dark"] .music-picker-header h3 {
color: var(--color-text, #e0e0e0);
}
[data-theme="dark"] .music-picker-search input {
background: var(--bg-secondary, #2d3748);
border-color: var(--border-color, #4a5568);
color: var(--color-text, #e0e0e0);
}
[data-theme="dark"] .music-picker-item {
color: var(--color-text, #e0e0e0);
}
[data-theme="dark"] .music-picker-item:hover {
background: var(--bg-hover, #2d3748);
}
[data-theme="dark"] .music-picker-item.selected {
background: rgba(102, 126, 234, 0.12);
}
+106 -39
View File
@@ -738,53 +738,117 @@ const musicView = {
return typeof i18n !== 'undefined' && i18n.t ? i18n.t(key) : fallback || key;
};
// Create a file picker input for audio files
const input = document.createElement('input');
input.type = 'file';
input.accept = 'audio/*';
input.multiple = true;
input.style.display = 'none';
document.body.appendChild(input);
// ── Build modal overlay ──
const overlay = document.createElement('div');
overlay.className = 'music-picker-overlay';
overlay.innerHTML = `
<div class="music-picker-modal">
<div class="music-picker-header">
<h3><i class="fas fa-music"></i> ${t('music.add_tracks', 'Add Tracks')}</h3>
<button class="music-picker-close" title="${t('common.close', 'Close')}">&times;</button>
</div>
<div class="music-picker-search">
<i class="fas fa-search"></i>
<input type="text" id="music-picker-query"
placeholder="${t('music.search_audio', 'Search audio files…')}" autocomplete="off">
</div>
<div class="music-picker-list" id="music-picker-list">
<div class="music-picker-loading"><i class="fas fa-spinner fa-spin"></i> ${t('music.loading', 'Loading…')}</div>
</div>
<div class="music-picker-footer">
<span class="music-picker-selected-count" id="music-picker-count">0 ${t('music.selected', 'selected')}</span>
<div class="music-picker-actions">
<button class="btn btn-secondary music-picker-cancel">${t('common.cancel', 'Cancel')}</button>
<button class="btn btn-primary music-picker-add" id="music-picker-add-btn" disabled>
<i class="fas fa-plus"></i> ${t('music.add', 'Add')}
</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('active'));
input.addEventListener('change', async () => {
const files = Array.from(input.files);
input.remove();
if (files.length === 0) return;
const listEl = document.getElementById('music-picker-list');
const queryInput = document.getElementById('music-picker-query');
const addBtn = document.getElementById('music-picker-add-btn');
const countEl = document.getElementById('music-picker-count');
const selectedIds = new Set();
// Upload each file first, then add to playlist
const fileIds = [];
for (const file of files) {
try {
const formData = new FormData();
formData.append('file', file);
const folderId = window.app?.currentPath || window.app?.userHomeFolderId || '';
formData.append('folder_id', folderId);
// ── Close helpers ──
const close = () => {
overlay.classList.remove('active');
setTimeout(() => overlay.remove(), 200);
};
overlay.querySelector('.music-picker-close').addEventListener('click', close);
overlay.querySelector('.music-picker-cancel').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const uploadResp = await fetch('/api/files/upload', {
method: 'POST',
credentials: 'include',
headers: typeof getCsrfHeaders === 'function' ? getCsrfHeaders() : {},
body: formData
});
// ── Fetch & render audio files ──
const AUDIO_EXTENSIONS = 'mp3,ogg,flac,wav,aac,m4a,wma,opus,webm';
if (!uploadResp.ok) throw new Error(`Upload failed: ${file.name}`);
const uploaded = await uploadResp.json();
if (uploaded.id) fileIds.push(uploaded.id);
} catch (err) {
console.error('Upload error:', err);
}
const fetchAudioFiles = async (query = '') => {
listEl.innerHTML = `<div class="music-picker-loading"><i class="fas fa-spinner fa-spin"></i> ${t('music.loading', 'Loading…')}</div>`;
try {
const params = new URLSearchParams({ type_filter: AUDIO_EXTENSIONS, limit: '200', recursive: 'true' });
if (query.trim()) params.set('query', query.trim());
const resp = await fetch(`/api/search?${params}`, { credentials: 'include' });
if (!resp.ok) throw new Error('Search failed');
const data = await resp.json();
renderFiles(data.files || []);
} catch (err) {
console.error('Audio search error:', err);
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-exclamation-triangle"></i> ${t('music.search_error', 'Could not load audio files')}</div>`;
}
};
if (fileIds.length === 0) return;
const renderFiles = (files) => {
if (files.length === 0) {
listEl.innerHTML = `<div class="music-picker-empty"><i class="fas fa-folder-open"></i> ${t('music.no_audio_files', 'No audio files found')}</div>`;
return;
}
listEl.innerHTML = '';
for (const file of files) {
const row = document.createElement('label');
row.className = 'music-picker-item' + (selectedIds.has(file.id) ? ' selected' : '');
const sizeStr = file.size != null && window.formatFileSize ? window.formatFileSize(file.size) : '';
row.innerHTML = `
<input type="checkbox" value="${file.id}" ${selectedIds.has(file.id) ? 'checked' : ''}>
<i class="fas fa-file-audio"></i>
<span class="music-picker-name" title="${this._escapeHtml(file.name)}">${this._escapeHtml(file.name)}</span>
<span class="music-picker-size">${sizeStr}</span>
`;
const cb = row.querySelector('input');
cb.addEventListener('change', () => {
if (cb.checked) { selectedIds.add(file.id); row.classList.add('selected'); }
else { selectedIds.delete(file.id); row.classList.remove('selected'); }
countEl.textContent = `${selectedIds.size} ${t('music.selected', 'selected')}`;
addBtn.disabled = selectedIds.size === 0;
});
listEl.appendChild(row);
}
};
// ── Debounced search ──
let searchTimer = null;
queryInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => fetchAudioFiles(queryInput.value), 300);
});
// ── Add button ──
addBtn.addEventListener('click', async () => {
if (selectedIds.size === 0) return;
addBtn.disabled = true;
addBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('music.adding', 'Adding…')}`;
try {
const resp = await fetch(`/api/playlists/${this.currentPlaylist.id}/tracks`, {
method: 'POST',
credentials: 'include',
headers: this._headers(true),
body: JSON.stringify({ file_ids: fileIds })
body: JSON.stringify({ file_ids: [...selectedIds] })
});
if (!resp.ok) throw new Error('Failed to add tracks');
if (window.notifications) {
@@ -792,15 +856,14 @@ const musicView = {
icon: 'fa-check-circle',
iconClass: 'upload',
title: t('music.add_tracks', 'Add Tracks'),
text: `${fileIds.length} ${t('music.added_to_playlist', 'added to playlist')}`
text: `${selectedIds.size} ${t('music.added_to_playlist', 'added to playlist')}`
});
}
close();
await this._loadPlaylistTracks(this.currentPlaylist.id);
// Update track count
const playlist = this.playlists.find((p) => p.id === this.currentPlaylist.id);
if (playlist) {
playlist.track_count = (playlist.track_count || 0) + fileIds.length;
playlist.track_count = (playlist.track_count || 0) + selectedIds.size;
this.currentPlaylist.track_count = playlist.track_count;
this._renderPlaylistList();
const metaEl = document.getElementById('music-playlist-meta');
@@ -816,10 +879,14 @@ const musicView = {
text: t('music.add_error', 'Could not add tracks to playlist')
});
}
addBtn.disabled = false;
addBtn.innerHTML = `<i class="fas fa-plus"></i> ${t('music.add', 'Add')}`;
}
});
input.click();
// ── Initial load (all audio files) ──
queryInput.focus();
fetchAudioFiles();
},
async _removeTrackFromPlaylist(trackId, fileId) {
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "تعذر إضافة المقاطع",
"no_playlists_yet": "لا توجد قوائم بعد. أنشئ واحدة أولاً!",
"selected_files": "محدد:",
"error": "خطأ"
"error": "خطأ",
"search_audio": "البحث عن ملفات صوتية…",
"no_audio_files": "لم يتم العثور على ملفات صوتية",
"selected": "محدد",
"loading": "جارٍ التحميل…",
"search_error": "تعذر تحميل الملفات الصوتية",
"adding": "جارٍ الإضافة…"
},
"actions": {
"search": "البحث في الملفات...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "Tracks konnten nicht hinzugefügt werden",
"no_playlists_yet": "Noch keine Playlists. Erstellen Sie zuerst eine!",
"selected_files": "Ausgewählt:",
"error": "Fehler"
"error": "Fehler",
"search_audio": "Audiodateien suchen…",
"no_audio_files": "Keine Audiodateien gefunden",
"selected": "ausgewählt",
"loading": "Wird geladen…",
"search_error": "Audiodateien konnten nicht geladen werden",
"adding": "Wird hinzugefügt…"
},
"actions": {
"search": "Dateien suchen...",
+7 -1
View File
@@ -79,7 +79,13 @@
"make_public": "Make public",
"make_private": "Make private",
"set_cover": "Set cover",
"cover_updated": "Cover updated"
"cover_updated": "Cover updated",
"search_audio": "Search audio files…",
"no_audio_files": "No audio files found",
"selected": "selected",
"loading": "Loading…",
"search_error": "Could not load audio files",
"adding": "Adding…"
},
"actions": {
"search": "Search files...",
+7 -1
View File
@@ -79,7 +79,13 @@
"make_public": "Hacer pública",
"make_private": "Hacer privada",
"set_cover": "Establecer portada",
"cover_updated": "Portada actualizada"
"cover_updated": "Portada actualizada",
"search_audio": "Buscar archivos de audio…",
"no_audio_files": "No se encontraron archivos de audio",
"selected": "seleccionados",
"loading": "Cargando…",
"search_error": "No se pudieron cargar los archivos de audio",
"adding": "Añadiendo…"
},
"share": {
"dialogTitle": "Compartir Enlace",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "امکان افزودن آهنگ‌ها به فهرست پخش نیست",
"no_playlists_yet": "فهرست پخشی وجود ندارد. اول یکی بسازید!",
"selected_files": "انتخاب شده:",
"error": "خطا"
"error": "خطا",
"search_audio": "جستجوی فایل‌های صوتی…",
"no_audio_files": "فایل صوتی یافت نشد",
"selected": "انتخاب شده",
"loading": "در حال بارگذاری…",
"search_error": "بارگذاری فایل‌های صوتی ممکن نشد",
"adding": "در حال افزودن…"
},
"actions": {
"search": "جست‌و‌جوی پرونده‌ها..",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "Impossible d'ajouter les pistes",
"no_playlists_yet": "Pas encore de playlists. Créez-en une d'abord !",
"selected_files": "Sélectionnés :",
"error": "Erreur"
"error": "Erreur",
"search_audio": "Rechercher des fichiers audio…",
"no_audio_files": "Aucun fichier audio trouvé",
"selected": "sélectionnés",
"loading": "Chargement…",
"search_error": "Impossible de charger les fichiers audio",
"adding": "Ajout en cours…"
},
"actions": {
"search": "Rechercher des fichiers...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "प्लेलिस्ट में ट्रैक नहीं जोड़े जा सके",
"no_playlists_yet": "अभी तक कोई प्लेलिस्ट नहीं। पहले एक बनाएं!",
"selected_files": "चयनित:",
"error": "त्रुटि"
"error": "त्रुटि",
"search_audio": "ऑडियो फ़ाइलें खोजें…",
"no_audio_files": "कोई ऑडियो फ़ाइल नहीं मिली",
"selected": "चयनित",
"loading": "लोड हो रहा है…",
"search_error": "ऑडियो फ़ाइलें लोड नहीं हो सकीं",
"adding": "जोड़ा जा रहा है…"
},
"actions": {
"search": "फ़ाइलें खोजें...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "Impossibile aggiungere le tracce",
"no_playlists_yet": "Nessuna playlist ancora. Creane una prima!",
"selected_files": "Selezionati:",
"error": "Errore"
"error": "Errore",
"search_audio": "Cerca file audio…",
"no_audio_files": "Nessun file audio trovato",
"selected": "selezionati",
"loading": "Caricamento…",
"search_error": "Impossibile caricare i file audio",
"adding": "Aggiunta in corso…"
},
"actions": {
"search": "Cerca file...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "曲を追加できませんでした",
"no_playlists_yet": "プレイリストがありません。最初に作成してください!",
"selected_files": "選択中:",
"error": "エラー"
"error": "エラー",
"search_audio": "オーディオファイルを検索…",
"no_audio_files": "オーディオファイルが見つかりません",
"selected": "件選択中",
"loading": "読み込み中…",
"search_error": "オーディオファイルを読み込めませんでした",
"adding": "追加中…"
},
"actions": {
"search": "ファイルを検索...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "트랙을 플레이리스트에 추가할 수 없습니다",
"no_playlists_yet": "플레이리스트가 없습니다. 먼저 하나를 만드세요!",
"selected_files": "선택됨:",
"error": "오류"
"error": "오류",
"search_audio": "오디오 파일 검색…",
"no_audio_files": "오디오 파일을 찾을 수 없습니다",
"selected": "선택됨",
"loading": "로딩 중…",
"search_error": "오디오 파일을 불러올 수 없습니다",
"adding": "추가 중…"
},
"actions": {
"search": "파일 검색...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "Kon tracks niet toevoegen aan playlist",
"no_playlists_yet": "Nog geen playlists. Maak er eerst een!",
"selected_files": "Geselecteerd:",
"error": "Fout"
"error": "Fout",
"search_audio": "Audiobestanden zoeken…",
"no_audio_files": "Geen audiobestanden gevonden",
"selected": "geselecteerd",
"loading": "Laden…",
"search_error": "Kan audiobestanden niet laden",
"adding": "Toevoegen…"
},
"actions": {
"search": "Zoek bestanden...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "Não foi possível adicionar as faixas",
"no_playlists_yet": "Nenhuma playlist ainda. Crie uma primeiro!",
"selected_files": "Selecionados:",
"error": "Erro"
"error": "Erro",
"search_audio": "Pesquisar ficheiros de áudio…",
"no_audio_files": "Nenhum ficheiro de áudio encontrado",
"selected": "selecionados",
"loading": "A carregar…",
"search_error": "Não foi possível carregar os ficheiros de áudio",
"adding": "A adicionar…"
},
"actions": {
"search": "Pesquisar arquivos...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "Не удалось добавить треки в плейлист",
"no_playlists_yet": "Плейлистов пока нет. Создайте сначала!",
"selected_files": "Выбрано:",
"error": "Ошибка"
"error": "Ошибка",
"search_audio": "Поиск аудиофайлов…",
"no_audio_files": "Аудиофайлы не найдены",
"selected": "выбрано",
"loading": "Загрузка…",
"search_error": "Не удалось загрузить аудиофайлы",
"adding": "Добавление…"
},
"actions": {
"search": "Поиск файлов...",
+7 -1
View File
@@ -62,7 +62,13 @@
"add_error": "无法将曲目添加到播放列表",
"no_playlists_yet": "暂无播放列表。请先创建一个!",
"selected_files": "已选择:",
"error": "错误"
"error": "错误",
"search_audio": "搜索音频文件…",
"no_audio_files": "未找到音频文件",
"selected": "已选择",
"loading": "加载中…",
"search_error": "无法加载音频文件",
"adding": "添加中…"
},
"actions": {
"search": "搜索文件...",