feat: OnlyOffice Document Server Integration + Preview ohne neuen Tab
OnlyOffice Integration: - DOCX, XLSX, PPTX nativ im Browser bearbeiten (wie Google Docs) - Automatische Erkennung: Wenn OnlyOffice konfiguriert ist, wird der vollwertige Editor geladen, sonst die einfache Vorschau als Fallback - Backend: WOPI-aehnliche Endpunkte - GET /files/<id>/onlyoffice-config - Editor-Konfiguration - POST /files/onlyoffice-callback - Speicher-Callback von OnlyOffice - GET /files/onlyoffice-status - Verfuegbarkeits-Check - JWT-Signierung fuer sichere Kommunikation mit OnlyOffice - Dokument-Key basiert auf file_id + checksum (Cache-Invalidierung) Admin-Einstellungen: - OnlyOffice URL + JWT Secret konfigurierbar - Setup-Anleitung direkt in der UI (docker-compose auskommentieren) docker-compose.yml: - OnlyOffice Document Server als optionaler Service (auskommentiert) - Einfach auskommentieren fuer volle Office-Bearbeitung Preview: - Oeffnet sich jetzt innerhalb der App (kein neuer Tab) - Zurueck-Button in der Toolbar Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user