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:
Stefan Hacker
2026-04-11 21:19:17 +02:00
parent a2ded7f97c
commit 216445d41b
6 changed files with 260 additions and 44 deletions
+139
View File
@@ -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
+6 -1
View File
@@ -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
+18
View File
@@ -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
+31
View File
@@ -75,6 +75,34 @@
</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>Docker-Setup:</strong>
<ol>
<li>In <code>docker-compose.yml</code> den <code>onlyoffice</code>-Service auskommentieren</li>
<li><code>docker-compose up -d</code></li>
<li>URL auf <code>http://onlyoffice</code> (intern) oder die oeffentliche URL setzen</li>
<li>JWT Secret muss in beiden Services identisch sein</li>
</ol>
</div>
</div>
<!-- Backup & Restore -->
<div class="admin-section">
<h3>Backup & Restore</h3>
@@ -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 */ }
}
+2 -2
View File
@@ -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)
}
+64 -41
View File
@@ -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>