feat: Share-Links mit Berechtigungen (Lesen / Lesen+Schreiben)
Share-Links haben jetzt ein permission-Feld (read/write): - read (Standard): Nur Download erlaubt, kein Upload, kein Aendern - write: Download + Upload in Ordner erlaubt Backend-Absicherung: - POST /share/<token>/upload prueft permission == 'write', gibt 403 bei read-only Links zurueck - GET /share/<token>/info gibt permission + upload_allowed zurueck - ShareLink-Model hat neues permission-Feld (default: 'read') Frontend Share-Dialog: - Dropdown "Berechtigung" beim Erstellen von Links (Nur Lesen / Lesen+Hochladen) - Bestehende Links zeigen Berechtigungslevel an Frontend ShareView: - Upload-Zone nur sichtbar wenn upload_allowed == true - Bei read-only Links: kein Drag & Drop, kein Upload-Button Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -419,6 +419,10 @@ def create_share_link(file_id):
|
|||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
expires_at = data.get('expires_at')
|
expires_at = data.get('expires_at')
|
||||||
max_downloads = data.get('max_downloads')
|
max_downloads = data.get('max_downloads')
|
||||||
|
permission = data.get('permission', 'read')
|
||||||
|
|
||||||
|
if permission not in ('read', 'write'):
|
||||||
|
return jsonify({'error': 'Berechtigung muss "read" oder "write" sein'}), 400
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
password_hash = None
|
password_hash = None
|
||||||
@@ -435,6 +439,7 @@ def create_share_link(file_id):
|
|||||||
link = ShareLink(
|
link = ShareLink(
|
||||||
file_id=file_id,
|
file_id=file_id,
|
||||||
token=token,
|
token=token,
|
||||||
|
permission=permission,
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
expires_at=exp_dt,
|
expires_at=exp_dt,
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
@@ -446,6 +451,7 @@ def create_share_link(file_id):
|
|||||||
return jsonify({
|
return jsonify({
|
||||||
'token': token,
|
'token': token,
|
||||||
'url': f'/share/{token}',
|
'url': f'/share/{token}',
|
||||||
|
'permission': permission,
|
||||||
'expires_at': exp_dt.isoformat() if exp_dt else None,
|
'expires_at': exp_dt.isoformat() if exp_dt else None,
|
||||||
'has_password': bool(password),
|
'has_password': bool(password),
|
||||||
}), 201
|
}), 201
|
||||||
@@ -463,6 +469,7 @@ def list_share_links(file_id):
|
|||||||
return jsonify([{
|
return jsonify([{
|
||||||
'id': l.id,
|
'id': l.id,
|
||||||
'token': l.token,
|
'token': l.token,
|
||||||
|
'permission': l.permission,
|
||||||
'has_password': bool(l.password_hash),
|
'has_password': bool(l.password_hash),
|
||||||
'expires_at': l.expires_at.isoformat() if l.expires_at else None,
|
'expires_at': l.expires_at.isoformat() if l.expires_at else None,
|
||||||
'download_count': l.download_count,
|
'download_count': l.download_count,
|
||||||
@@ -490,7 +497,8 @@ def share_info(token):
|
|||||||
'size': f.size,
|
'size': f.size,
|
||||||
'mime_type': f.mime_type,
|
'mime_type': f.mime_type,
|
||||||
'has_password': bool(link.password_hash),
|
'has_password': bool(link.password_hash),
|
||||||
'upload_allowed': f.is_folder,
|
'permission': link.permission,
|
||||||
|
'upload_allowed': f.is_folder and link.permission == 'write',
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -560,7 +568,7 @@ def share_download(token):
|
|||||||
|
|
||||||
@api_bp.route('/share/<token>/upload', methods=['POST'])
|
@api_bp.route('/share/<token>/upload', methods=['POST'])
|
||||||
def share_upload(token):
|
def share_upload(token):
|
||||||
"""Upload a file via a share link (only if the shared item is a folder)."""
|
"""Upload a file via a share link (only if the shared item is a folder with write permission)."""
|
||||||
link = ShareLink.query.filter_by(token=token).first()
|
link = ShareLink.query.filter_by(token=token).first()
|
||||||
if not link:
|
if not link:
|
||||||
return jsonify({'error': 'Link nicht gefunden'}), 404
|
return jsonify({'error': 'Link nicht gefunden'}), 404
|
||||||
@@ -568,6 +576,10 @@ def share_upload(token):
|
|||||||
if link.is_expired():
|
if link.is_expired():
|
||||||
return jsonify({'error': 'Link abgelaufen'}), 410
|
return jsonify({'error': 'Link abgelaufen'}), 410
|
||||||
|
|
||||||
|
# Check write permission
|
||||||
|
if link.permission != 'write':
|
||||||
|
return jsonify({'error': 'Dieser Link erlaubt nur Lesen'}), 403
|
||||||
|
|
||||||
# Check password if set
|
# Check password if set
|
||||||
if link.password_hash:
|
if link.password_hash:
|
||||||
password = request.form.get('password', '') or request.headers.get('X-Share-Password', '')
|
password = request.form.get('password', '') or request.headers.get('X-Share-Password', '')
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class ShareLink(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False, index=True)
|
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False, index=True)
|
||||||
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
||||||
|
permission = db.Column(db.String(20), default='read', nullable=False) # 'read' or 'write'
|
||||||
password_hash = db.Column(db.String(255), nullable=True)
|
password_hash = db.Column(db.String(255), nullable=True)
|
||||||
expires_at = db.Column(db.DateTime, nullable=True)
|
expires_at = db.Column(db.DateTime, nullable=True)
|
||||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
|||||||
@@ -162,6 +162,10 @@
|
|||||||
<div class="share-section">
|
<div class="share-section">
|
||||||
<h5>Freigabe-Link erstellen</h5>
|
<h5>Freigabe-Link erstellen</h5>
|
||||||
<div class="share-form">
|
<div class="share-form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Berechtigung</label>
|
||||||
|
<Select v-model="shareLinkPermission" :options="linkPermOptions" optionLabel="label" optionValue="value" fluid />
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Passwort (optional)</label>
|
<label>Passwort (optional)</label>
|
||||||
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
|
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
|
||||||
@@ -180,7 +184,8 @@
|
|||||||
<div class="link-info">
|
<div class="link-info">
|
||||||
<code>{{ currentOrigin }}/share/{{ link.token }}</code>
|
<code>{{ currentOrigin }}/share/{{ link.token }}</code>
|
||||||
<small>
|
<small>
|
||||||
{{ link.download_count }} Downloads
|
{{ link.permission === 'write' ? 'Lesen+Schreiben' : 'Nur Lesen' }}
|
||||||
|
| {{ link.download_count }} Downloads
|
||||||
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
|
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
|
||||||
<template v-if="link.has_password"> | Passwortgeschuetzt</template>
|
<template v-if="link.has_password"> | Passwortgeschuetzt</template>
|
||||||
</small>
|
</small>
|
||||||
@@ -250,6 +255,8 @@ const selectedShareUser = ref(null)
|
|||||||
const shareUserPermission = ref('read')
|
const shareUserPermission = ref('read')
|
||||||
const userSearchResults = ref([])
|
const userSearchResults = ref([])
|
||||||
const userPermOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Schreiben', value: 'write' }, { label: 'Admin', value: 'admin' }]
|
const userPermOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Schreiben', value: 'write' }, { label: 'Admin', value: 'admin' }]
|
||||||
|
const linkPermOptions = [{ label: 'Nur Lesen (Download)', value: 'read' }, { label: 'Lesen + Hochladen (nur Ordner)', value: 'write' }]
|
||||||
|
const shareLinkPermission = ref('read')
|
||||||
const currentOrigin = window.location.origin
|
const currentOrigin = window.location.origin
|
||||||
const shareLoading = ref(false)
|
const shareLoading = ref(false)
|
||||||
const showDeleteConfirm = ref(false)
|
const showDeleteConfirm = ref(false)
|
||||||
@@ -580,13 +587,14 @@ async function createShare() {
|
|||||||
if (!shareFile.value) return
|
if (!shareFile.value) return
|
||||||
shareLoading.value = true
|
shareLoading.value = true
|
||||||
try {
|
try {
|
||||||
const opts = {}
|
const opts = { permission: shareLinkPermission.value }
|
||||||
if (sharePassword.value) opts.password = sharePassword.value
|
if (sharePassword.value) opts.password = sharePassword.value
|
||||||
if (shareExpiry.value) opts.expires_at = shareExpiry.value
|
if (shareExpiry.value) opts.expires_at = shareExpiry.value
|
||||||
await filesStore.createShareLink(shareFile.value.id, opts)
|
await filesStore.createShareLink(shareFile.value.id, opts)
|
||||||
shareLinks.value = await filesStore.getShareLinks(shareFile.value.id)
|
shareLinks.value = await filesStore.getShareLinks(shareFile.value.id)
|
||||||
sharePassword.value = ''
|
sharePassword.value = ''
|
||||||
shareExpiry.value = ''
|
shareExpiry.value = ''
|
||||||
|
shareLinkPermission.value = 'read'
|
||||||
toast.add({ severity: 'success', summary: 'Link erstellt', life: 3000 })
|
toast.add({ severity: 'success', summary: 'Link erstellt', life: 3000 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||||
|
|||||||
Reference in New Issue
Block a user