feat: Upload in freigegebene Ordner + Benachrichtigung
Share-Links fuer Ordner erlauben jetzt auch Uploads: Backend: - POST /share/<token>/upload - Datei in freigegebenen Ordner hochladen - Passwort-Schutz wird bei Upload ebenfalls geprueft - share_info gibt jetzt upload_allowed zurueck (true bei Ordner-Shares) - Email-Benachrichtigung an den Ersteller wenn jemand eine Datei hochlaedt (Dateiname, Groesse, IP-Adresse) Frontend (ShareView): - Ordner-Shares zeigen jetzt eine Upload-Zone (Drag & Drop + Button) - Fortschrittsbalken beim Upload mit Datei-Zaehler - Erfolgs-/Fehlermeldung nach Upload - Passwortgeschuetzte Ordner-Shares: erst entsperren, dann uploaden Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e811210977
commit
6a17748552
|
|
@ -490,6 +490,7 @@ 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,
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -557,6 +558,67 @@ def share_download(token):
|
||||||
download_name=f.name)
|
download_name=f.name)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/share/<token>/upload', methods=['POST'])
|
||||||
|
def share_upload(token):
|
||||||
|
"""Upload a file via a share link (only if the shared item is a folder)."""
|
||||||
|
link = ShareLink.query.filter_by(token=token).first()
|
||||||
|
if not link:
|
||||||
|
return jsonify({'error': 'Link nicht gefunden'}), 404
|
||||||
|
|
||||||
|
if link.is_expired():
|
||||||
|
return jsonify({'error': 'Link abgelaufen'}), 410
|
||||||
|
|
||||||
|
# Check password if set
|
||||||
|
if link.password_hash:
|
||||||
|
password = request.form.get('password', '') or request.headers.get('X-Share-Password', '')
|
||||||
|
if not bcrypt.check_password_hash(link.password_hash, password):
|
||||||
|
return jsonify({'error': 'Passwort erforderlich'}), 401
|
||||||
|
|
||||||
|
f = db.session.get(File, link.file_id)
|
||||||
|
if not f.is_folder:
|
||||||
|
return jsonify({'error': 'Upload nur in freigegebene Ordner moeglich'}), 400
|
||||||
|
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'error': 'Keine Datei gesendet'}), 400
|
||||||
|
|
||||||
|
uploaded = request.files['file']
|
||||||
|
if not uploaded.filename:
|
||||||
|
return jsonify({'error': 'Leerer Dateiname'}), 400
|
||||||
|
|
||||||
|
filename = uploaded.filename
|
||||||
|
mime = uploaded.content_type or mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
||||||
|
|
||||||
|
storage_name = str(uuid.uuid4())
|
||||||
|
user_dir = _user_upload_dir(f.owner_id)
|
||||||
|
storage_path = user_dir / storage_name
|
||||||
|
uploaded.save(str(storage_path))
|
||||||
|
|
||||||
|
size = os.path.getsize(str(storage_path))
|
||||||
|
checksum = _compute_checksum(str(storage_path))
|
||||||
|
|
||||||
|
file_obj = File(
|
||||||
|
owner_id=f.owner_id,
|
||||||
|
parent_id=f.id,
|
||||||
|
name=filename,
|
||||||
|
is_folder=False,
|
||||||
|
mime_type=mime,
|
||||||
|
size=size,
|
||||||
|
storage_path=storage_name,
|
||||||
|
checksum=checksum,
|
||||||
|
)
|
||||||
|
db.session.add(file_obj)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Notify share link creator about the upload
|
||||||
|
try:
|
||||||
|
from app.services.system_mail import notify_share_link_upload
|
||||||
|
notify_share_link_upload(link, f.name, filename, size, request.remote_addr)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify(file_obj.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/share/<token>', methods=['DELETE'])
|
@api_bp.route('/share/<token>', methods=['DELETE'])
|
||||||
@token_required
|
@token_required
|
||||||
def delete_share_link(token):
|
def delete_share_link(token):
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,35 @@ def notify_share_link_accessed(share_link, file_name, accessor_ip):
|
||||||
send_system_email(creator.email, subject, body)
|
send_system_email(creator.email, subject, body)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_share_link_upload(share_link, folder_name, filename, filesize, uploader_ip):
|
||||||
|
"""Notify the share link creator that someone uploaded a file via their shared folder."""
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
creator = User.query.get(share_link.created_by)
|
||||||
|
if not creator or not creator.email:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _fmt_size(b):
|
||||||
|
for u in ['B', 'KB', 'MB', 'GB']:
|
||||||
|
if b < 1024:
|
||||||
|
return f'{b:.1f} {u}'
|
||||||
|
b /= 1024
|
||||||
|
return f'{b:.1f} TB'
|
||||||
|
|
||||||
|
subject = f'Mini-Cloud: Neue Datei in geteiltem Ordner "{folder_name}"'
|
||||||
|
body = (
|
||||||
|
f'Hallo {creator.username},\n\n'
|
||||||
|
f'Jemand hat eine Datei in deinen geteilten Ordner hochgeladen:\n\n'
|
||||||
|
f' Ordner: {folder_name}\n'
|
||||||
|
f' Datei: {filename}\n'
|
||||||
|
f' Groesse: {_fmt_size(filesize)}\n'
|
||||||
|
f' IP-Adresse: {uploader_ip}\n\n'
|
||||||
|
f'Melde dich in deiner Mini-Cloud an, um die Datei zu sehen.\n\n'
|
||||||
|
f'Deine Mini-Cloud'
|
||||||
|
)
|
||||||
|
send_system_email(creator.email, subject, body)
|
||||||
|
|
||||||
|
|
||||||
def notify_file_shared_with_user(file_name, owner_username, target_user):
|
def notify_file_shared_with_user(file_name, owner_username, target_user):
|
||||||
"""Notify a user that a file/folder was shared with them."""
|
"""Notify a user that a file/folder was shared with them."""
|
||||||
if not target_user.email:
|
if not target_user.email:
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@
|
||||||
|
|
||||||
<div v-else-if="fileInfo" class="share-info">
|
<div v-else-if="fileInfo" class="share-info">
|
||||||
<h2>{{ fileInfo.name }}</h2>
|
<h2>{{ fileInfo.name }}</h2>
|
||||||
<p class="file-size" v-if="fileInfo.size">{{ formatSize(fileInfo.size) }}</p>
|
<p class="file-size" v-if="fileInfo.size && !fileInfo.is_folder">{{ formatSize(fileInfo.size) }}</p>
|
||||||
|
<Tag v-if="fileInfo.is_folder" value="Ordner" severity="info" />
|
||||||
|
|
||||||
<div v-if="fileInfo.has_password && !authenticated" class="password-form">
|
<div v-if="fileInfo.has_password && !authenticated" class="password-form">
|
||||||
<p>Diese Datei ist passwortgeschuetzt.</p>
|
<p>Diese Freigabe ist passwortgeschuetzt.</p>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<Password v-model="password" placeholder="Passwort eingeben" :feedback="false" toggle-mask fluid />
|
<Password v-model="password" placeholder="Passwort eingeben" :feedback="false" toggle-mask fluid />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -26,13 +27,37 @@
|
||||||
<Button label="Entsperren" @click="verifyPassword" :loading="verifying" fluid />
|
<Button label="Entsperren" @click="verifyPassword" :loading="verifying" fluid />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="download-section">
|
<div v-else class="actions-section">
|
||||||
<Button
|
<!-- Download (files only) -->
|
||||||
label="Herunterladen"
|
<div v-if="!fileInfo.is_folder" class="action-block">
|
||||||
icon="pi pi-download"
|
<Button
|
||||||
size="large"
|
label="Herunterladen"
|
||||||
@click="downloadFile"
|
icon="pi pi-download"
|
||||||
/>
|
size="large"
|
||||||
|
@click="downloadFile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload (folders only) -->
|
||||||
|
<div v-if="fileInfo.upload_allowed" class="action-block">
|
||||||
|
<div class="upload-area"
|
||||||
|
@dragover.prevent="isDragging = true"
|
||||||
|
@dragleave.prevent="isDragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
:class="{ dragging: isDragging }">
|
||||||
|
<i class="pi pi-cloud-upload"></i>
|
||||||
|
<p>Dateien hierher ziehen oder</p>
|
||||||
|
<Button label="Dateien auswaehlen" icon="pi pi-upload" size="small" outlined @click="$refs.uploadInput.click()" />
|
||||||
|
<input ref="uploadInput" type="file" multiple hidden @change="handleUpload" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="upload-progress">
|
||||||
|
<ProgressBar :value="uploadPercent" />
|
||||||
|
<p>{{ uploadStatus }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Message v-if="uploadSuccess" severity="success" :closable="false">{{ uploadSuccess }}</Message>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -46,6 +71,8 @@ import axios from 'axios'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Password from 'primevue/password'
|
import Password from 'primevue/password'
|
||||||
import Message from 'primevue/message'
|
import Message from 'primevue/message'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
import ProgressBar from 'primevue/progressbar'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const token = route.params.token
|
const token = route.params.token
|
||||||
|
|
@ -58,6 +85,12 @@ const authenticated = ref(false)
|
||||||
const authError = ref('')
|
const authError = ref('')
|
||||||
const verifying = ref(false)
|
const verifying = ref(false)
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadPercent = ref(0)
|
||||||
|
const uploadStatus = ref('')
|
||||||
|
const uploadSuccess = ref('')
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
if (!bytes) return ''
|
if (!bytes) return ''
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
|
@ -100,6 +133,54 @@ function downloadFile() {
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onDrop(e) {
|
||||||
|
isDragging.value = false
|
||||||
|
const files = Array.from(e.dataTransfer.files)
|
||||||
|
if (files.length) await uploadFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload(event) {
|
||||||
|
const files = Array.from(event.target.files)
|
||||||
|
if (files.length) await uploadFiles(files)
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(files) {
|
||||||
|
uploading.value = true
|
||||||
|
uploadSuccess.value = ''
|
||||||
|
uploadPercent.value = 0
|
||||||
|
let uploaded = 0
|
||||||
|
let errors = 0
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
uploadStatus.value = `${uploaded + 1} / ${files.length}: ${file.name}`
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
if (fileInfo.value?.has_password && password.value) {
|
||||||
|
formData.append('password', password.value)
|
||||||
|
}
|
||||||
|
await axios.post(`/api/share/${token}/upload`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
...(password.value ? { 'X-Share-Password': password.value } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
uploaded++
|
||||||
|
} catch {
|
||||||
|
errors++
|
||||||
|
}
|
||||||
|
uploadPercent.value = Math.round(((uploaded + errors) / files.length) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploading.value = false
|
||||||
|
if (errors) {
|
||||||
|
uploadSuccess.value = `${uploaded} hochgeladen, ${errors} fehlgeschlagen`
|
||||||
|
} else {
|
||||||
|
uploadSuccess.value = `${uploaded} Datei${uploaded !== 1 ? 'en' : ''} erfolgreich hochgeladen`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadInfo)
|
onMounted(loadInfo)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -110,14 +191,29 @@ onMounted(loadInfo)
|
||||||
}
|
}
|
||||||
.share-card {
|
.share-card {
|
||||||
background: var(--p-surface-0); border-radius: 12px; padding: 3rem;
|
background: var(--p-surface-0); border-radius: 12px; padding: 3rem;
|
||||||
text-align: center; max-width: 450px; width: 100%;
|
text-align: center; max-width: 500px; width: 100%;
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
.share-card h2 { margin: 1rem 0 0.25rem; font-size: 1.25rem; }
|
.share-card h2 { margin: 1rem 0 0.25rem; font-size: 1.25rem; }
|
||||||
.file-size { color: var(--p-text-muted-color); margin-bottom: 1.5rem; }
|
.file-size { color: var(--p-text-muted-color); margin-bottom: 1rem; }
|
||||||
.password-form { text-align: left; margin-top: 1.5rem; }
|
.password-form { text-align: left; margin-top: 1.5rem; }
|
||||||
.password-form p { margin-bottom: 1rem; color: var(--p-text-muted-color); }
|
.password-form p { margin-bottom: 1rem; color: var(--p-text-muted-color); }
|
||||||
.field { margin-bottom: 1rem; }
|
.field { margin-bottom: 1rem; }
|
||||||
.download-section { margin-top: 1.5rem; }
|
.actions-section { margin-top: 1.5rem; }
|
||||||
|
.action-block { margin-bottom: 1.5rem; }
|
||||||
.share-loading, .share-error { margin-top: 1.5rem; }
|
.share-loading, .share-error { margin-top: 1.5rem; }
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed var(--p-surface-300); border-radius: 10px;
|
||||||
|
padding: 2rem; text-align: center; cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.upload-area:hover, .upload-area.dragging {
|
||||||
|
border-color: var(--p-primary-color); background: var(--p-primary-50);
|
||||||
|
}
|
||||||
|
.upload-area i { font-size: 2rem; color: var(--p-surface-400); }
|
||||||
|
.upload-area.dragging i { color: var(--p-primary-color); }
|
||||||
|
.upload-area p { margin: 0.5rem 0; color: var(--p-text-muted-color); font-size: 0.9rem; }
|
||||||
|
.upload-progress { margin-top: 1rem; }
|
||||||
|
.upload-progress p { font-size: 0.85rem; color: var(--p-text-muted-color); margin-top: 0.5rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue