Compare commits
3 Commits
a2ded7f97c
...
3185b2d41c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3185b2d41c | |||
| fca2bc2d47 | |||
| 216445d41b |
+7
-1
@@ -24,7 +24,13 @@ HOST=0.0.0.0
|
||||
PORT=5000
|
||||
|
||||
# Frontend URL (fuer CORS)
|
||||
FRONTEND_URL=http://localhost:3010
|
||||
FRONTEND_URL=https://cloud.example.com
|
||||
|
||||
# Max Upload-Groesse in MB
|
||||
MAX_UPLOAD_SIZE_MB=500
|
||||
|
||||
# OnlyOffice Document Server (optional)
|
||||
# Oeffentliche HTTPS-URL unter der OnlyOffice im Browser erreichbar ist
|
||||
ONLYOFFICE_URL=
|
||||
# Muss mit JWT_SECRET im OnlyOffice-Container uebereinstimmen
|
||||
ONLYOFFICE_JWT_SECRET=
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
# Mini-Cloud
|
||||
|
||||
Selbstgehostete Web-Cloud-Plattform mit Dateiverwaltung, Kalender, Kontakte, E-Mail-Webclient, Office-Viewer und Passwort-Manager.
|
||||
Selbstgehostete Web-Cloud-Plattform mit Dateiverwaltung, Kalender, Kontakte, E-Mail-Webclient, Office-Editor und Passwort-Manager.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dateiverwaltung** - Upload/Download, Ordner, Berechtigungen, Share-Links (Passwort + Ablaufdatum)
|
||||
- **Dateiverwaltung** - Upload/Download (Drag & Drop + Ordner-Upload), Berechtigungen, Share-Links (Lesen/Schreiben/Nur-Upload, Passwort, Ablaufdatum), Papierkorb, Ordner als ZIP downloaden
|
||||
- **Kalender** - CalDAV-kompatibel (iOS, DAVx5, Thunderbird, Outlook), iCal-Export, Teilen mit Benutzern
|
||||
- **Kontakte** - CardDAV-kompatibel, vCard-Export, Adressbuecher teilen
|
||||
- **E-Mail-Webclient** - IMAP/SMTP-Proxy (kein eigener Mailserver), Multi-Account, Absender-Logik
|
||||
- **Office-Viewer** - DOCX, XLSX, PPTX, PDF direkt im Browser anzeigen
|
||||
- **Passwort-Manager** - AES-256-GCM clientseitig verschluesselt, TOTP, KeePass-Import, Ordner teilen
|
||||
- **Benutzerverwaltung** - Rollen (Admin/User), Speicher-Quotas
|
||||
- **Office-Editor** - DOCX, XLSX, PPTX bearbeiten mit OnlyOffice; PDF, Bilder, Text als Vorschau
|
||||
- **Passwort-Manager** - AES-256-GCM clientseitig verschluesselt, TOTP, Passkeys, KeePass/Firefox/CSV-Import, Ordner teilen
|
||||
- **Benutzerverwaltung** - Rollen (Admin/User), Speicher-Quotas, Einladungslinks, oeffentliche Registrierung an/aus
|
||||
- **Backup** - Lokales ZIP-Backup, SFTP-Backup mit Scheduler + Versionierung, Einzeldatei-Restore
|
||||
- **Benachrichtigungen** - System-Email bei Freigaben, Downloads, neuen Benutzern
|
||||
- **Sync-API** - Delta-Sync fuer Desktop/Mobile-Clients
|
||||
|
||||
## Tech-Stack
|
||||
@@ -21,6 +23,7 @@ Selbstgehostete Web-Cloud-Plattform mit Dateiverwaltung, Kalender, Kontakte, E-M
|
||||
| Frontend | Vue 3 / Vite / PrimeVue |
|
||||
| Datenbank | SQLite (WAL-Modus) |
|
||||
| Auth | JWT (Web-UI) + HTTP Basic Auth (CalDAV/CardDAV) |
|
||||
| Office-Editor | OnlyOffice Document Server (optional) |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -29,6 +32,7 @@ Selbstgehostete Web-Cloud-Plattform mit Dateiverwaltung, Kalender, Kontakte, E-M
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- npm
|
||||
- Docker (fuer Produktion)
|
||||
|
||||
### Entwicklungsumgebung
|
||||
|
||||
@@ -80,22 +84,112 @@ Beim ersten Registrieren wird der Benutzer automatisch zum Admin.
|
||||
```bash
|
||||
# .env anlegen und Secrets eintragen
|
||||
cp .env.example .env
|
||||
# SECRET_KEY und JWT_SECRET_KEY generieren und eintragen:
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
|
||||
# Starten
|
||||
docker-compose up --build -d
|
||||
# -> laeuft auf http://localhost:5000
|
||||
```
|
||||
|
||||
Die Datenbank und hochgeladene Dateien liegen unter `./data/` (Bind Mount, keine Docker Volumes).
|
||||
|
||||
### Nginx Reverse-Proxy (Beispiel)
|
||||
|
||||
Die Datei `nginx.example.conf` enthaelt eine vollstaendige Beispielkonfiguration:
|
||||
|
||||
```nginx
|
||||
# cloud.example.com -> Mini-Cloud (Port 5000)
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cloud.example.com;
|
||||
ssl_certificate /etc/letsencrypt/live/cloud.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/cloud.example.com/privkey.pem;
|
||||
client_max_body_size 0;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Let's Encrypt Zertifikat erstellen:
|
||||
```bash
|
||||
certbot --nginx -d cloud.example.com
|
||||
```
|
||||
|
||||
### OnlyOffice Document Server (optional)
|
||||
|
||||
Fuer die Bearbeitung von Word, Excel und PowerPoint Dateien direkt im Browser.
|
||||
|
||||
**1. docker-compose.yml - OnlyOffice-Service aktivieren:**
|
||||
|
||||
```yaml
|
||||
# In docker-compose.yml auskommentieren:
|
||||
onlyoffice:
|
||||
image: onlyoffice/documentserver:latest
|
||||
environment:
|
||||
- JWT_ENABLED=true
|
||||
- JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
|
||||
volumes:
|
||||
- ./data/onlyoffice/logs:/var/log/onlyoffice
|
||||
- ./data/onlyoffice/data:/var/www/onlyoffice/Data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
**2. .env - OnlyOffice konfigurieren:**
|
||||
|
||||
```bash
|
||||
ONLYOFFICE_URL=https://office.example.com
|
||||
ONLYOFFICE_JWT_SECRET=ein-sicheres-secret-hier
|
||||
```
|
||||
|
||||
**3. Nginx - Eigene Subdomain fuer OnlyOffice:**
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name office.example.com;
|
||||
ssl_certificate /etc/letsencrypt/live/office.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/office.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
certbot --nginx -d office.example.com
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**Ohne OnlyOffice** werden Office-Dateien in einer einfachen Vorschau angezeigt (nur Lesen). **Mit OnlyOffice** erhaelt man einen vollwertigen Editor (wie Google Docs).
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Dateien
|
||||
|
||||
- Dateien per Drag-and-Drop oder Upload-Button hochladen
|
||||
- Ordner erstellen, Dateien verschieben/umbenennen/loeschen
|
||||
- Share-Links erstellen: optional mit Passwort und Ablaufdatum
|
||||
- Share-Links funktionieren ohne Login fuer externe Empfaenger
|
||||
- Ganze Ordner mit Unterordnern hochladen (Drag & Drop oder Ordner-Button)
|
||||
- Ordner erstellen, Dateien verschieben/umbenennen
|
||||
- Ordner als ZIP herunterladen
|
||||
- Geloeschte Dateien landen im Papierkorb (wiederherstellen oder endgueltig loeschen)
|
||||
- Share-Links erstellen mit drei Berechtigungsstufen:
|
||||
- **Nur Lesen** - Download, Ordnerinhalt sehen
|
||||
- **Lesen + Schreiben** - Download, Upload, Loeschen
|
||||
- **Nur Upload** - Hochladen ohne Einblick (Briefkasten-Modus)
|
||||
- Optional: Passwort und Ablaufdatum fuer Share-Links
|
||||
- Dateien/Ordner mit anderen Benutzern teilen (Lesen/Schreiben/Admin)
|
||||
- Gruenes Icon zeigt an welche Dateien bereits Freigaben haben
|
||||
|
||||
### Kalender
|
||||
|
||||
@@ -118,24 +212,46 @@ Die Datenbank und hochgeladene Dateien liegen unter `./data/` (Bind Mount, keine
|
||||
### E-Mail
|
||||
|
||||
- E-Mail-Konten unter Einstellungen hinzufuegen (IMAP/SMTP-Zugangsdaten)
|
||||
- Admin kann E-Mail-Konten fuer andere Benutzer anlegen
|
||||
- Kein eigener Mailserver noetig - verbindet sich mit externen Mailservern
|
||||
- Bei keinem konfigurierten Konto wird der E-Mail-Bereich ausgeblendet
|
||||
- Mehrere Konten: Ordner nach Konten gruppiert, Standard-Absender = aktives Konto
|
||||
- Absender-Dropdown nur sichtbar bei mehr als einem Konto
|
||||
|
||||
### Office-Viewer
|
||||
### Office-Dateien
|
||||
|
||||
- DOCX, XLSX, PPTX und PDF direkt im Browser anzeigen (nur Lesen)
|
||||
- Bilder und Textdateien werden ebenfalls inline dargestellt
|
||||
- Doppelklick oder Auge-Icon oeffnet die Vorschau
|
||||
- **Mit OnlyOffice**: Vollwertiger Editor fuer DOCX, XLSX, PPTX (wie Google Docs)
|
||||
- **Ohne OnlyOffice**: Einfache Vorschau (HTML/Tabelle/Folien), Text-Dateien bearbeitbar
|
||||
- PDF wird inline angezeigt, Bilder mit Vorschau
|
||||
|
||||
### Passwort-Manager
|
||||
|
||||
- Alle Daten clientseitig mit AES-256-GCM verschluesselt (Zero Knowledge)
|
||||
- TOTP-Codes direkt generieren
|
||||
- Passwort-Generator integriert
|
||||
- KeePass-Import (.kdbx) mit Ordnerstruktur
|
||||
- Import aus: KeePass (.kdbx), Firefox (CSV), Chrome/Bitwarden/1Password (CSV)
|
||||
- Ordner/Gruppen wie in KeePass
|
||||
- Einzelne Passwoerter oder ganze Ordner mit anderen Benutzern teilen
|
||||
|
||||
### Backup & Restore
|
||||
|
||||
- **Lokales Backup**: ZIP-Download mit Datenbank + allen Dateien
|
||||
- **SFTP-Backup**: Automatisch auf SFTP-Server sichern
|
||||
- Mehrere Backup-Ziele moeglich
|
||||
- Konfigurierbares Intervall (15 Min. bis woechentlich)
|
||||
- Versionierung mit automatischem Aufraumen
|
||||
- Versionen durchsuchen und Einzeldateien wiederherstellen
|
||||
- **Restore**: ZIP hochladen (Chunked Upload fuer grosse Backups) oder direkt von SFTP
|
||||
|
||||
### Administration
|
||||
|
||||
- Benutzer anlegen, bearbeiten, deaktivieren, loeschen
|
||||
- Benutzersuche
|
||||
- E-Mail-Konten pro Benutzer verwalten
|
||||
- Oeffentliche Registrierung an/aus
|
||||
- Einladungslinks (funktionieren auch bei deaktivierter Registrierung)
|
||||
- System-Email (SMTP) fuer Benachrichtigungen und Einladungen
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
@@ -143,7 +259,7 @@ backend/
|
||||
app/
|
||||
api/ # REST-Endpunkte
|
||||
models/ # SQLAlchemy-Models
|
||||
services/ # Business-Logik (Crypto, etc.)
|
||||
services/ # Business-Logik (Crypto, SFTP, Email, Scheduler)
|
||||
dav/ # CalDAV/CardDAV (Radicale-Vorbereitung)
|
||||
wsgi.py # Einstiegspunkt
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.api.files import _get_file_or_403
|
||||
from app.extensions import db
|
||||
from app.models.settings import AppSettings
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/preview', methods=['GET'])
|
||||
@@ -314,3 +315,141 @@ def _save_sheets_to_xlsx(filepath, sheets_data):
|
||||
ws.cell(row=ri, column=ci, value=val if val != '' else None)
|
||||
|
||||
wb.save(str(filepath))
|
||||
|
||||
|
||||
# ========== OnlyOffice Integration ==========
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/onlyoffice-config', methods=['GET'])
|
||||
@token_required
|
||||
def onlyoffice_config(file_id):
|
||||
"""Generate OnlyOffice editor config for a file."""
|
||||
import secrets as _secrets
|
||||
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
|
||||
oo_url = AppSettings.get('onlyoffice_url', os.environ.get('ONLYOFFICE_URL', ''))
|
||||
if not oo_url:
|
||||
return jsonify({'error': 'OnlyOffice nicht konfiguriert', 'available': False}), 200
|
||||
|
||||
# Determine document type
|
||||
ext = f.name.rsplit('.', 1)[-1].lower() if '.' in f.name else ''
|
||||
doc_type_map = {
|
||||
'docx': 'word', 'doc': 'word', 'odt': 'word', 'rtf': 'word', 'txt': 'word',
|
||||
'xlsx': 'cell', 'xls': 'cell', 'ods': 'cell', 'csv': 'cell',
|
||||
'pptx': 'slide', 'ppt': 'slide', 'odp': 'slide',
|
||||
}
|
||||
doc_type = doc_type_map.get(ext)
|
||||
if not doc_type:
|
||||
return jsonify({'error': 'Dateityp nicht von OnlyOffice unterstuetzt', 'available': False}), 200
|
||||
|
||||
# Check write permission
|
||||
can_write = _get_file_or_403(file_id, user, 'write')[1] is None
|
||||
|
||||
# Generate a callback key for this editing session
|
||||
callback_key = _secrets.token_urlsafe(16)
|
||||
AppSettings.set(f'oo_callback_{callback_key}', str(file_id))
|
||||
|
||||
# Build the config
|
||||
# The URLs must be reachable by OnlyOffice server (not the browser)
|
||||
base_url = request.host_url.rstrip('/')
|
||||
token = request.args.get('token', '') or request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
|
||||
config = {
|
||||
'available': True,
|
||||
'onlyoffice_url': oo_url.rstrip('/'),
|
||||
'config': {
|
||||
'document': {
|
||||
'fileType': ext,
|
||||
'key': f'{file_id}_{f.checksum or "0"}_{callback_key[:8]}',
|
||||
'title': f.name,
|
||||
'url': f'{base_url}/api/files/{file_id}/download?token={token}',
|
||||
},
|
||||
'documentType': doc_type,
|
||||
'editorConfig': {
|
||||
'callbackUrl': f'{base_url}/api/files/onlyoffice-callback?key={callback_key}',
|
||||
'mode': 'edit' if can_write else 'view',
|
||||
'lang': 'de',
|
||||
'user': {
|
||||
'id': str(user.id),
|
||||
'name': user.username,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Sign with JWT if secret is set
|
||||
jwt_secret = AppSettings.get('onlyoffice_jwt_secret', '')
|
||||
if jwt_secret:
|
||||
import jwt as pyjwt
|
||||
config['config']['token'] = pyjwt.encode(config['config'], jwt_secret, algorithm='HS256')
|
||||
|
||||
return jsonify(config), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/onlyoffice-callback', methods=['POST'])
|
||||
def onlyoffice_callback():
|
||||
"""Callback from OnlyOffice when document is saved."""
|
||||
import urllib.request
|
||||
|
||||
callback_key = request.args.get('key', '')
|
||||
file_id_str = AppSettings.get(f'oo_callback_{callback_key}', '')
|
||||
|
||||
if not file_id_str:
|
||||
return jsonify({'error': 1}), 200 # OnlyOffice expects {"error": 0} for success
|
||||
|
||||
data = request.get_json()
|
||||
status = data.get('status', 0)
|
||||
|
||||
# Status 2 = document ready for saving, 6 = force save
|
||||
if status in (2, 6):
|
||||
download_url = data.get('url', '')
|
||||
if download_url:
|
||||
try:
|
||||
from app.models.file import File
|
||||
file_id = int(file_id_str)
|
||||
f = db.session.get(File, file_id)
|
||||
if f:
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||
|
||||
# Download the saved document from OnlyOffice
|
||||
urllib.request.urlretrieve(download_url, str(filepath))
|
||||
|
||||
# Update metadata
|
||||
f.size = os.path.getsize(str(filepath))
|
||||
h = hashlib.sha256()
|
||||
with open(str(filepath), 'rb') as fh:
|
||||
for chunk in iter(lambda: fh.read(8192), b''):
|
||||
h.update(chunk)
|
||||
f.checksum = h.hexdigest()
|
||||
f.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
print(f'[OnlyOffice Callback] Error: {e}')
|
||||
return jsonify({'error': 1}), 200
|
||||
|
||||
# Status 4 = closed without changes
|
||||
if status in (2, 4, 6):
|
||||
# Cleanup callback key
|
||||
try:
|
||||
setting = db.session.get(AppSettings, f'oo_callback_{callback_key}')
|
||||
if setting:
|
||||
db.session.delete(setting)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'error': 0}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/onlyoffice-status', methods=['GET'])
|
||||
@token_required
|
||||
def onlyoffice_status():
|
||||
"""Check if OnlyOffice is available."""
|
||||
oo_url = AppSettings.get('onlyoffice_url', os.environ.get('ONLYOFFICE_URL', ''))
|
||||
return jsonify({
|
||||
'available': bool(oo_url),
|
||||
'url': oo_url,
|
||||
}), 200
|
||||
|
||||
@@ -153,6 +153,9 @@ def get_settings():
|
||||
'system_smtp_username': AppSettings.get('system_smtp_username', ''),
|
||||
'system_smtp_password_set': bool(AppSettings.get('system_smtp_password', '')),
|
||||
'system_email_from': AppSettings.get('system_email_from', ''),
|
||||
'onlyoffice_url': AppSettings.get('onlyoffice_url', os.environ.get('ONLYOFFICE_URL', '')),
|
||||
'onlyoffice_jwt_secret': AppSettings.get('onlyoffice_jwt_secret', ''),
|
||||
'onlyoffice_jwt_secret_set': bool(AppSettings.get('onlyoffice_jwt_secret', '')),
|
||||
}), 200
|
||||
|
||||
|
||||
@@ -163,11 +166,13 @@ def update_settings():
|
||||
if 'public_registration' in data:
|
||||
AppSettings.set('public_registration', str(data['public_registration']).lower())
|
||||
for key in ['system_smtp_host', 'system_smtp_port', 'system_smtp_ssl',
|
||||
'system_smtp_username', 'system_email_from']:
|
||||
'system_smtp_username', 'system_email_from', 'onlyoffice_url']:
|
||||
if key in data:
|
||||
AppSettings.set(key, str(data[key]))
|
||||
if 'system_smtp_password' in data and data['system_smtp_password']:
|
||||
AppSettings.set('system_smtp_password', data['system_smtp_password'])
|
||||
if 'onlyoffice_jwt_secret' in data and data['onlyoffice_jwt_secret']:
|
||||
AppSettings.set('onlyoffice_jwt_secret', data['onlyoffice_jwt_secret'])
|
||||
return jsonify({'message': 'Einstellungen gespeichert'}), 200
|
||||
|
||||
|
||||
|
||||
+17
-7
@@ -5,11 +5,21 @@ services:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-to-a-random-secret-key}
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-change-me-to-another-random-secret-key}
|
||||
- DATABASE_PATH=/app/data/minicloud.db
|
||||
- UPLOAD_PATH=/app/data/files
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:5000}
|
||||
- MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-500}
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: OnlyOffice Document Server fuer Office-Bearbeitung
|
||||
# Auskommentieren um DOCX/XLSX/PPTX im Browser bearbeiten zu koennen
|
||||
# Braucht eine eigene Subdomain mit HTTPS (z.B. office.example.com)
|
||||
# Siehe nginx.example.conf und README.md fuer Setup-Anleitung
|
||||
#
|
||||
# onlyoffice:
|
||||
# image: onlyoffice/documentserver:latest
|
||||
# environment:
|
||||
# - JWT_ENABLED=true
|
||||
# - JWT_SECRET=${ONLYOFFICE_JWT_SECRET}
|
||||
# volumes:
|
||||
# - ./data/onlyoffice/logs:/var/log/onlyoffice
|
||||
# - ./data/onlyoffice/data:/var/www/onlyoffice/Data
|
||||
# restart: unless-stopped
|
||||
|
||||
@@ -75,6 +75,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OnlyOffice -->
|
||||
<div class="admin-section">
|
||||
<h3>OnlyOffice Document Server</h3>
|
||||
<p class="hint">Fuer die Bearbeitung von Word, Excel und PowerPoint Dateien direkt im Browser.
|
||||
Ohne OnlyOffice werden Dateien in einer einfachen Vorschau angezeigt.</p>
|
||||
<div class="smtp-form">
|
||||
<div class="field">
|
||||
<label>OnlyOffice URL</label>
|
||||
<InputText v-model="smtpForm.onlyoffice_url" placeholder="http://onlyoffice:80 oder https://office.example.com" fluid />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>JWT Secret {{ onlyofficeJwtSet ? '(gesetzt)' : '' }}</label>
|
||||
<Password v-model="smtpForm.onlyoffice_jwt_secret" :feedback="false" toggle-mask fluid
|
||||
placeholder="Muss mit JWT_SECRET in docker-compose uebereinstimmen" />
|
||||
</div>
|
||||
<Button label="Speichern" icon="pi pi-save" size="small" @click="saveSmtp" />
|
||||
</div>
|
||||
<div class="restore-instructions" style="margin-top: 1rem">
|
||||
<strong>Setup:</strong>
|
||||
<ol>
|
||||
<li>In <code>docker-compose.yml</code> den <code>onlyoffice</code>-Service auskommentieren</li>
|
||||
<li>Nginx-Eintrag fuer OnlyOffice anlegen (z.B. <code>office.deine-domain.de</code>) - siehe <code>nginx.example.conf</code></li>
|
||||
<li>Let's Encrypt Zertifikat fuer die OnlyOffice-Domain erstellen</li>
|
||||
<li><code>docker-compose up -d</code></li>
|
||||
<li>Hier die <strong>oeffentliche HTTPS-URL</strong> eintragen (z.B. <code>https://office.deine-domain.de</code>)<br/>
|
||||
<em>Nicht</em> die interne Docker-URL - der Browser muss OnlyOffice erreichen koennen!</li>
|
||||
<li>JWT Secret muss mit <code>ONLYOFFICE_JWT_SECRET</code> in <code>docker-compose.yml</code> uebereinstimmen</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup & Restore -->
|
||||
<div class="admin-section">
|
||||
<h3>Backup & Restore</h3>
|
||||
@@ -509,6 +540,7 @@ const smtpForm = ref({
|
||||
system_smtp_username: '', system_smtp_password: '', system_email_from: '',
|
||||
})
|
||||
const smtpPasswordSet = ref(false)
|
||||
const onlyofficeJwtSet = ref(false)
|
||||
const smtpTesting = ref(false)
|
||||
|
||||
// Backup & Restore
|
||||
@@ -616,6 +648,8 @@ async function loadSettings() {
|
||||
smtpForm.value.system_smtp_username = res.data.system_smtp_username || ''
|
||||
smtpForm.value.system_email_from = res.data.system_email_from || ''
|
||||
smtpPasswordSet.value = res.data.system_smtp_password_set
|
||||
smtpForm.value.onlyoffice_url = res.data.onlyoffice_url || ''
|
||||
onlyofficeJwtSet.value = res.data.onlyoffice_jwt_secret_set
|
||||
} catch { /* first load, defaults */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -298,9 +298,9 @@ function handleDoubleClick(event) {
|
||||
}
|
||||
|
||||
function openPreview(data) {
|
||||
const previewable = /\.(pdf|docx?|xlsx?|pptx?|txt|md|json|xml|csv|py|js|html|css|yml|yaml|png|jpe?g|gif|svg|webp|bmp)$/i
|
||||
const previewable = /\.(pdf|docx?|xlsx?|pptx?|txt|md|json|xml|csv|py|js|html|css|yml|yaml|png|jpe?g|gif|svg|webp|bmp|odt|ods|odp|rtf)$/i
|
||||
if (previewable.test(data.name)) {
|
||||
window.open(`/preview/${data.id}`, '_blank')
|
||||
router.push(`/preview/${data.id}`)
|
||||
} else {
|
||||
downloadFile(data)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<h3>{{ fileName }}</h3>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<Button v-if="canEdit && !editing" icon="pi pi-pencil" label="Bearbeiten" size="small" outlined @click="startEdit" />
|
||||
<Button v-if="editing" icon="pi pi-save" label="Speichern" size="small" @click="saveEdit" :loading="saving" />
|
||||
<Button v-if="editing" label="Abbrechen" size="small" text @click="cancelEdit" />
|
||||
<Button v-if="canEdit && !editing && !onlyOfficeMode" icon="pi pi-pencil" label="Bearbeiten" size="small" outlined @click="startEdit" />
|
||||
<Button v-if="editing && !onlyOfficeMode" icon="pi pi-save" label="Speichern" size="small" @click="saveEdit" :loading="saving" />
|
||||
<Button v-if="editing && !onlyOfficeMode" label="Abbrechen" size="small" text @click="cancelEdit" />
|
||||
<Button icon="pi pi-download" label="Herunterladen" size="small" outlined @click="downloadFile" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,6 +23,11 @@
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- OnlyOffice Editor -->
|
||||
<div v-else-if="onlyOfficeMode" class="preview-content">
|
||||
<div id="onlyoffice-editor" class="oo-frame"></div>
|
||||
</div>
|
||||
|
||||
<!-- PDF -->
|
||||
<div v-else-if="previewType === 'pdf'" class="preview-content">
|
||||
<iframe :src="previewUrl" class="pdf-frame"></iframe>
|
||||
@@ -33,7 +38,7 @@
|
||||
<img :src="previewUrl" :alt="fileName" />
|
||||
</div>
|
||||
|
||||
<!-- HTML (DOCX) -->
|
||||
<!-- HTML (DOCX fallback) -->
|
||||
<div v-else-if="previewType === 'html'" class="preview-content">
|
||||
<div v-if="!editing" class="document-frame" v-html="htmlContent"></div>
|
||||
<div v-else class="editor-frame">
|
||||
@@ -42,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spreadsheet (XLSX) -->
|
||||
<!-- Spreadsheet (XLSX fallback) -->
|
||||
<div v-else-if="previewType === 'spreadsheet'" class="preview-content">
|
||||
<div class="sheet-tabs" v-if="sheets.length > 1">
|
||||
<button v-for="(sheet, i) in sheets" :key="i"
|
||||
@@ -61,7 +66,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slides (PPTX) -->
|
||||
<!-- Slides (PPTX fallback) -->
|
||||
<div v-else-if="previewType === 'slides'" class="preview-content">
|
||||
<div class="slides-nav">
|
||||
<Button icon="pi pi-chevron-left" text :disabled="activeSlide <= 0" @click="activeSlide--" />
|
||||
@@ -87,15 +92,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import apiClient from '../api/client'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -116,23 +120,42 @@ const editorDirty = ref(false)
|
||||
const saving = ref(false)
|
||||
const editorRef = ref(null)
|
||||
const canEdit = ref(false)
|
||||
const onlyOfficeMode = ref(false)
|
||||
|
||||
function getTokenUrl(path) {
|
||||
return `${path}${path.includes('?') ? '&' : '?'}token=${encodeURIComponent(auth.accessToken || '')}`
|
||||
}
|
||||
|
||||
// Check if file is an Office format that OnlyOffice can handle
|
||||
function isOfficeFile(name) {
|
||||
return /\.(docx?|xlsx?|pptx?|odt|ods|odp|rtf|csv)$/i.test(name)
|
||||
}
|
||||
|
||||
async function loadPreview() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiClient.get(`/files/${fileId}/preview`)
|
||||
const data = res.data
|
||||
fileName.value = data.name || ''
|
||||
// For Office files, try OnlyOffice first
|
||||
const previewRes = await apiClient.get(`/files/${fileId}/preview`)
|
||||
fileName.value = previewRes.data.name || ''
|
||||
|
||||
if (isOfficeFile(fileName.value)) {
|
||||
try {
|
||||
const ooRes = await apiClient.get(`/files/${fileId}/onlyoffice-config`)
|
||||
if (ooRes.data.available) {
|
||||
onlyOfficeMode.value = true
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
initOnlyOffice(ooRes.data)
|
||||
return
|
||||
}
|
||||
} catch { /* OnlyOffice not available, fall through to simple preview */ }
|
||||
}
|
||||
|
||||
// Simple preview fallback
|
||||
const data = previewRes.data
|
||||
previewType.value = data.type
|
||||
|
||||
if (data.type === 'pdf') {
|
||||
previewUrl.value = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
canEdit.value = false
|
||||
} else if (data.type === 'image') {
|
||||
if (data.type === 'pdf' || data.type === 'image') {
|
||||
previewUrl.value = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
canEdit.value = false
|
||||
} else if (data.type === 'html') {
|
||||
@@ -157,6 +180,25 @@ async function loadPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
function initOnlyOffice(ooData) {
|
||||
const ooUrl = ooData.onlyoffice_url
|
||||
|
||||
// Load OnlyOffice API script
|
||||
const script = document.createElement('script')
|
||||
script.src = `${ooUrl}/web-apps/apps/api/documents/api.js`
|
||||
script.onload = () => {
|
||||
if (window.DocsAPI) {
|
||||
new window.DocsAPI.DocEditor('onlyoffice-editor', ooData.config)
|
||||
}
|
||||
}
|
||||
script.onerror = () => {
|
||||
onlyOfficeMode.value = false
|
||||
error.value = 'OnlyOffice konnte nicht geladen werden. Fallback auf einfache Vorschau.'
|
||||
loadPreview()
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
function downloadFile() {
|
||||
window.location.href = getTokenUrl(`/api/files/${fileId}/download`)
|
||||
}
|
||||
@@ -168,7 +210,6 @@ function startEdit() {
|
||||
|
||||
function cancelEdit() {
|
||||
editing.value = false
|
||||
// Reload to discard changes
|
||||
loadPreview()
|
||||
}
|
||||
|
||||
@@ -181,20 +222,15 @@ async function saveEdit() {
|
||||
saving.value = true
|
||||
try {
|
||||
let payload = {}
|
||||
|
||||
if (previewType.value === 'html') {
|
||||
// Get HTML from contenteditable
|
||||
const content = editorRef.value ? editorRef.value.innerHTML : htmlContent.value
|
||||
payload = { type: 'html', content }
|
||||
payload = { type: 'html', content: editorRef.value ? editorRef.value.innerHTML : htmlContent.value }
|
||||
} else if (previewType.value === 'spreadsheet') {
|
||||
payload = { type: 'spreadsheet', sheets: sheets.value }
|
||||
} else if (previewType.value === 'text') {
|
||||
payload = { type: 'text', content: textContent.value }
|
||||
}
|
||||
|
||||
await apiClient.post(`/files/${fileId}/save`, payload)
|
||||
editing.value = false
|
||||
editorDirty.value = false
|
||||
toast.add({ severity: 'success', summary: 'Gespeichert', life: 3000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Speichern fehlgeschlagen', detail: err.response?.data?.error || String(err), life: 5000 })
|
||||
@@ -221,6 +257,7 @@ onMounted(loadPreview)
|
||||
flex: 1; gap: 0.75rem; color: var(--p-text-muted-color);
|
||||
}
|
||||
.preview-content { flex: 1; overflow: auto; }
|
||||
.oo-frame { width: 100%; height: 100%; }
|
||||
.pdf-frame { width: 100%; height: 100%; border: none; }
|
||||
.image-preview { display: flex; align-items: center; justify-content: center; padding: 1rem; background: var(--p-surface-100); }
|
||||
.image-preview img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
@@ -236,29 +273,15 @@ onMounted(loadPreview)
|
||||
border: 2px solid var(--p-primary-color);
|
||||
}
|
||||
.sheet-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--p-surface-200); padding: 0 1rem; background: var(--p-surface-50); }
|
||||
.sheet-tabs button {
|
||||
padding: 0.5rem 1rem; border: none; background: none; cursor: pointer;
|
||||
font-size: 0.85rem; border-bottom: 2px solid transparent;
|
||||
}
|
||||
.sheet-tabs button { padding: 0.5rem 1rem; border: none; background: none; cursor: pointer; font-size: 0.85rem; border-bottom: 2px solid transparent; }
|
||||
.sheet-tabs button.active { border-bottom-color: var(--p-primary-color); color: var(--p-primary-color); font-weight: 600; }
|
||||
.spreadsheet-wrapper { overflow: auto; padding: 0.5rem; }
|
||||
.spreadsheet { border-collapse: collapse; width: 100%; font-size: 0.85rem; }
|
||||
.spreadsheet td {
|
||||
border: 1px solid var(--p-surface-200); padding: 4px 8px;
|
||||
min-width: 80px; white-space: nowrap;
|
||||
}
|
||||
.spreadsheet td { border: 1px solid var(--p-surface-200); padding: 4px 8px; min-width: 80px; white-space: nowrap; }
|
||||
.spreadsheet tr:first-child td { font-weight: 600; background: var(--p-surface-100); }
|
||||
.spreadsheet td[contenteditable="true"] { cursor: text; }
|
||||
.spreadsheet td[contenteditable="true"]:focus { outline: 2px solid var(--p-primary-color); background: var(--p-primary-50); }
|
||||
.slides-nav { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--p-surface-200); }
|
||||
.slide-frame {
|
||||
max-width: 900px; margin: 2rem auto; padding: 3rem; background: white;
|
||||
min-height: 500px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.slide-frame { max-width: 900px; margin: 2rem auto; padding: 3rem; background: white; min-height: 500px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); border-radius: 4px; font-size: 16px; }
|
||||
.text-frame { padding: 1.5rem; margin: 0; font-size: 0.9rem; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.text-editor {
|
||||
width: 100%; height: 100%; min-height: 500px; padding: 1.5rem;
|
||||
font-family: monospace; font-size: 0.9rem; border: none; outline: none; resize: none;
|
||||
}
|
||||
.text-editor { width: 100%; height: 100%; min-height: 500px; padding: 1.5rem; font-family: monospace; font-size: 0.9rem; border: none; outline: none; resize: none; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Beispiel nginx-Konfiguration fuer Mini-Cloud mit OnlyOffice
|
||||
# Anpassen: cloud.example.com und office.example.com durch eigene Domains ersetzen
|
||||
|
||||
# Mini-Cloud
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cloud.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/cloud.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/cloud.example.com/privkey.pem;
|
||||
|
||||
client_max_body_size 0; # Kein Upload-Limit (wird von Flask gesteuert)
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket (falls spaeter benoetigt)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# CalDAV/CardDAV braucht spezielle Methoden
|
||||
location /dav/ {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass_request_headers on;
|
||||
}
|
||||
}
|
||||
|
||||
# OnlyOffice Document Server (optional)
|
||||
# Nur noetig wenn OnlyOffice in docker-compose aktiviert ist
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name office.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/office.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/office.example.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP -> HTTPS Redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name cloud.example.com office.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
Reference in New Issue
Block a user