diff --git a/backend/app/api/office.py b/backend/app/api/office.py index bb45906..6a5e2f3 100644 --- a/backend/app/api/office.py +++ b/backend/app/api/office.py @@ -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//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//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 diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 79dff23..81d1f9c 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index f757910..f762a1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,4 +12,22 @@ services: - UPLOAD_PATH=/app/data/files - FRONTEND_URL=${FRONTEND_URL:-http://localhost:5000} - MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-500} + - ONLYOFFICE_URL=${ONLYOFFICE_URL:-} restart: unless-stopped + + # Optional: OnlyOffice Document Server fuer Office-Bearbeitung + # Auskommentieren um DOCX/XLSX/PPTX bearbeiten zu koennen + # Nach dem Start die ONLYOFFICE_URL in den Admin-Einstellungen setzen + # oder als Umgebungsvariable: ONLYOFFICE_URL=http://onlyoffice + # + # onlyoffice: + # image: onlyoffice/documentserver:latest + # ports: + # - "8080:80" + # environment: + # - JWT_ENABLED=true + # - JWT_SECRET=${ONLYOFFICE_JWT_SECRET:-minicloud-onlyoffice-secret} + # volumes: + # - ./data/onlyoffice/logs:/var/log/onlyoffice + # - ./data/onlyoffice/data:/var/www/onlyoffice/Data + # restart: unless-stopped diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 6d4160c..3c2a4a4 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -75,6 +75,34 @@ + +
+

OnlyOffice Document Server

+

Fuer die Bearbeitung von Word, Excel und PowerPoint Dateien direkt im Browser. + Ohne OnlyOffice werden Dateien in einer einfachen Vorschau angezeigt.

+
+
+ + +
+
+ + +
+
+
+ Docker-Setup: +
    +
  1. In docker-compose.yml den onlyoffice-Service auskommentieren
  2. +
  3. docker-compose up -d
  4. +
  5. URL auf http://onlyoffice (intern) oder die oeffentliche URL setzen
  6. +
  7. JWT Secret muss in beiden Services identisch sein
  8. +
+
+
+

Backup & Restore

@@ -509,6 +537,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 +645,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 */ } } diff --git a/frontend/src/views/FilesView.vue b/frontend/src/views/FilesView.vue index 4ff5b78..1585fa1 100644 --- a/frontend/src/views/FilesView.vue +++ b/frontend/src/views/FilesView.vue @@ -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) } diff --git a/frontend/src/views/PreviewView.vue b/frontend/src/views/PreviewView.vue index d302a5e..38bde14 100644 --- a/frontend/src/views/PreviewView.vue +++ b/frontend/src/views/PreviewView.vue @@ -6,9 +6,9 @@

{{ fileName }}

-
@@ -23,6 +23,11 @@

{{ error }}

+ +
+
+
+
@@ -33,7 +38,7 @@
- +
@@ -42,7 +47,7 @@
- +
- +