feat: Mini-Cloud Plattform - komplette Implementierung Phase 0-8
Selbstgehostete Web-Cloud mit Dateiverwaltung, Kalender, Kontakte, Email-Webclient, Office-Viewer und Passwort-Manager. Backend (Flask/Python): - JWT-Auth mit Access/Refresh Tokens, Benutzerverwaltung - Dateien: Upload/Download, Ordner, Berechtigungen, Share-Links - Kalender: CRUD, Teilen, iCal-Export, CalDAV well-known URLs - Kontakte: Adressbuecher, vCard-Export, Teilen - Email: IMAP/SMTP-Proxy, Multi-Account - Office-Viewer: DOCX/XLSX/PPTX/PDF Vorschau - Passwort-Manager: AES-256-GCM clientseitig, KeePass-Import - Sync-API fuer Desktop/Mobile-Clients - SQLite mit WAL-Modus Frontend (Vue 3 + PrimeVue): - Datei-Explorer mit Breadcrumbs und Share-Dialogen - Monatskalender mit Event-Verwaltung - Kontaktliste mit Adressbuch-Sidebar - Email-Client mit 3-Spalten-Layout - Passwort-Manager mit TOTP und Passwort-Generator - Admin-Panel, Settings, oeffentliche Share-Seite Docker: Multi-Stage Build, Bind Mounts (keine Volumes) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request, jsonify
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.extensions import db
|
||||
from app.models.calendar import Calendar, CalendarEvent, CalendarShare
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _get_calendar_or_err(cal_id, user, need_write=False):
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal:
|
||||
return None, (jsonify({'error': 'Kalender nicht gefunden'}), 404)
|
||||
if cal.owner_id == user.id:
|
||||
return cal, None
|
||||
share = CalendarShare.query.filter_by(
|
||||
calendar_id=cal_id, shared_with_id=user.id
|
||||
).first()
|
||||
if not share:
|
||||
return None, (jsonify({'error': 'Zugriff verweigert'}), 403)
|
||||
if need_write and share.permission != 'readwrite':
|
||||
return None, (jsonify({'error': 'Schreibzugriff verweigert'}), 403)
|
||||
return cal, None
|
||||
|
||||
|
||||
# --- Calendars ---
|
||||
|
||||
@api_bp.route('/calendars', methods=['GET'])
|
||||
@token_required
|
||||
def list_calendars():
|
||||
user = request.current_user
|
||||
own = Calendar.query.filter_by(owner_id=user.id).all()
|
||||
shared_ids = [s.calendar_id for s in
|
||||
CalendarShare.query.filter_by(shared_with_id=user.id).all()]
|
||||
shared = Calendar.query.filter(Calendar.id.in_(shared_ids)).all() if shared_ids else []
|
||||
|
||||
result = []
|
||||
for c in own:
|
||||
d = c.to_dict()
|
||||
d['permission'] = 'owner'
|
||||
result.append(d)
|
||||
for c in shared:
|
||||
d = c.to_dict()
|
||||
share = CalendarShare.query.filter_by(
|
||||
calendar_id=c.id, shared_with_id=user.id
|
||||
).first()
|
||||
d['permission'] = share.permission if share else 'read'
|
||||
d['owner_name'] = c.owner.username
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars', methods=['POST'])
|
||||
@token_required
|
||||
def create_calendar():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name erforderlich'}), 400
|
||||
|
||||
cal = Calendar(
|
||||
owner_id=user.id,
|
||||
name=name,
|
||||
color=data.get('color', '#3788d8'),
|
||||
description=data.get('description', ''),
|
||||
)
|
||||
db.session.add(cal)
|
||||
db.session.commit()
|
||||
return jsonify(cal.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_calendar(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if 'name' in data:
|
||||
cal.name = data['name'].strip()
|
||||
if 'color' in data:
|
||||
cal.color = data['color']
|
||||
if 'description' in data:
|
||||
cal.description = data['description']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(cal.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_calendar(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
|
||||
|
||||
db.session.delete(cal)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Kalender geloescht'}), 200
|
||||
|
||||
|
||||
# --- Events ---
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/events', methods=['GET'])
|
||||
@token_required
|
||||
def list_events(cal_id):
|
||||
user = request.current_user
|
||||
cal, err = _get_calendar_or_err(cal_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
|
||||
query = CalendarEvent.query.filter_by(calendar_id=cal_id)
|
||||
if start:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start)
|
||||
query = query.filter(CalendarEvent.dtend >= start_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
if end:
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end)
|
||||
query = query.filter(CalendarEvent.dtstart <= end_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
events = query.order_by(CalendarEvent.dtstart).all()
|
||||
return jsonify([e.to_dict() for e in events]), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/events', methods=['POST'])
|
||||
@token_required
|
||||
def create_event(cal_id):
|
||||
user = request.current_user
|
||||
cal, err = _get_calendar_or_err(cal_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
summary = data.get('summary', '').strip()
|
||||
if not summary:
|
||||
return jsonify({'error': 'Zusammenfassung erforderlich'}), 400
|
||||
|
||||
dtstart = data.get('dtstart')
|
||||
dtend = data.get('dtend')
|
||||
all_day = data.get('all_day', False)
|
||||
|
||||
if not dtstart:
|
||||
return jsonify({'error': 'Startdatum erforderlich'}), 400
|
||||
|
||||
try:
|
||||
dtstart_dt = datetime.fromisoformat(dtstart)
|
||||
dtend_dt = datetime.fromisoformat(dtend) if dtend else dtstart_dt
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Ungueltiges Datumsformat'}), 400
|
||||
|
||||
event_uid = str(uuid.uuid4())
|
||||
|
||||
# Build simple iCal data
|
||||
ical_data = _build_ical(event_uid, summary, dtstart_dt, dtend_dt, all_day,
|
||||
data.get('description', ''), data.get('location', ''),
|
||||
data.get('recurrence_rule', ''))
|
||||
|
||||
event = CalendarEvent(
|
||||
calendar_id=cal_id,
|
||||
uid=event_uid,
|
||||
ical_data=ical_data,
|
||||
summary=summary,
|
||||
dtstart=dtstart_dt,
|
||||
dtend=dtend_dt,
|
||||
all_day=all_day,
|
||||
recurrence_rule=data.get('recurrence_rule'),
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
return jsonify(event.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/events/<int:event_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_event(event_id):
|
||||
user = request.current_user
|
||||
event = db.session.get(CalendarEvent, event_id)
|
||||
if not event:
|
||||
return jsonify({'error': 'Event nicht gefunden'}), 404
|
||||
|
||||
cal, err = _get_calendar_or_err(event.calendar_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
if 'summary' in data:
|
||||
event.summary = data['summary'].strip()
|
||||
if 'dtstart' in data:
|
||||
event.dtstart = datetime.fromisoformat(data['dtstart'])
|
||||
if 'dtend' in data:
|
||||
event.dtend = datetime.fromisoformat(data['dtend'])
|
||||
if 'all_day' in data:
|
||||
event.all_day = data['all_day']
|
||||
if 'recurrence_rule' in data:
|
||||
event.recurrence_rule = data['recurrence_rule']
|
||||
if 'calendar_id' in data:
|
||||
new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True)
|
||||
if cerr:
|
||||
return cerr
|
||||
event.calendar_id = data['calendar_id']
|
||||
|
||||
event.ical_data = _build_ical(
|
||||
event.uid, event.summary, event.dtstart, event.dtend,
|
||||
event.all_day, data.get('description', ''), data.get('location', ''),
|
||||
event.recurrence_rule or ''
|
||||
)
|
||||
event.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(event.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/events/<int:event_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_event(event_id):
|
||||
user = request.current_user
|
||||
event = db.session.get(CalendarEvent, event_id)
|
||||
if not event:
|
||||
return jsonify({'error': 'Event nicht gefunden'}), 404
|
||||
|
||||
cal, err = _get_calendar_or_err(event.calendar_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
db.session.delete(event)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Event geloescht'}), 200
|
||||
|
||||
|
||||
# --- Calendar sharing ---
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/share', methods=['POST'])
|
||||
@token_required
|
||||
def share_calendar(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nur der Eigentuemer kann teilen'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
username = data.get('username', '').strip()
|
||||
permission = data.get('permission', 'read')
|
||||
|
||||
if permission not in ('read', 'readwrite'):
|
||||
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
|
||||
|
||||
target = User.query.filter_by(username=username).first()
|
||||
if not target:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
if target.id == user.id:
|
||||
return jsonify({'error': 'Kann nicht mit sich selbst teilen'}), 400
|
||||
|
||||
existing = CalendarShare.query.filter_by(
|
||||
calendar_id=cal_id, shared_with_id=target.id
|
||||
).first()
|
||||
if existing:
|
||||
existing.permission = permission
|
||||
else:
|
||||
share = CalendarShare(
|
||||
calendar_id=cal_id, shared_with_id=target.id, permission=permission
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': f'Kalender mit {username} geteilt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/shares', methods=['GET'])
|
||||
@token_required
|
||||
def list_calendar_shares(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
shares = CalendarShare.query.filter_by(calendar_id=cal_id).all()
|
||||
return jsonify([{
|
||||
'id': s.id,
|
||||
'user_id': s.shared_with_id,
|
||||
'username': s.shared_with.username,
|
||||
'permission': s.permission,
|
||||
} for s in shares]), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/shares/<int:share_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def remove_calendar_share(cal_id, share_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
share = db.session.get(CalendarShare, share_id)
|
||||
if not share or share.calendar_id != cal_id:
|
||||
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Freigabe entfernt'}), 200
|
||||
|
||||
|
||||
# --- iCal Export ---
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/ical-link', methods=['POST'])
|
||||
@token_required
|
||||
def generate_ical_link(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
cal.ical_token = secrets.token_urlsafe(32)
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'ical_url': f'/ical/{cal.ical_token}',
|
||||
'token': cal.ical_token,
|
||||
}), 200
|
||||
|
||||
|
||||
def ical_export(token):
|
||||
cal = Calendar.query.filter_by(ical_token=token).first()
|
||||
if not cal:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
events = CalendarEvent.query.filter_by(calendar_id=cal.id).all()
|
||||
|
||||
lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Mini-Cloud//DE',
|
||||
f'X-WR-CALNAME:{cal.name}',
|
||||
]
|
||||
for e in events:
|
||||
if e.ical_data:
|
||||
# Extract VEVENT from stored ical_data
|
||||
lines.append(e.ical_data)
|
||||
else:
|
||||
lines.append(_build_vevent(e.uid, e.summary, e.dtstart, e.dtend, e.all_day))
|
||||
lines.append('END:VCALENDAR')
|
||||
|
||||
from flask import Response
|
||||
return Response(
|
||||
'\r\n'.join(lines),
|
||||
mimetype='text/calendar',
|
||||
headers={'Content-Disposition': f'attachment; filename="{cal.name}.ics"'},
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _format_dt(dt, all_day=False):
|
||||
if all_day:
|
||||
return dt.strftime('%Y%m%d')
|
||||
return dt.strftime('%Y%m%dT%H%M%SZ')
|
||||
|
||||
|
||||
def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''):
|
||||
lines = [
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
]
|
||||
if all_day:
|
||||
lines.append(f'DTSTART;VALUE=DATE:{_format_dt(dtstart, True)}')
|
||||
lines.append(f'DTEND;VALUE=DATE:{_format_dt(dtend, True)}')
|
||||
else:
|
||||
lines.append(f'DTSTART:{_format_dt(dtstart)}')
|
||||
lines.append(f'DTEND:{_format_dt(dtend)}')
|
||||
lines.append(f'SUMMARY:{summary}')
|
||||
if description:
|
||||
lines.append(f'DESCRIPTION:{description}')
|
||||
if location:
|
||||
lines.append(f'LOCATION:{location}')
|
||||
if rrule:
|
||||
lines.append(f'RRULE:{rrule}')
|
||||
lines.append(f'DTSTAMP:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}')
|
||||
lines.append('END:VEVENT')
|
||||
return '\r\n'.join(lines)
|
||||
|
||||
|
||||
def _build_ical(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''):
|
||||
return _build_vevent(uid, summary, dtstart, dtend, all_day, description, location, rrule)
|
||||
Reference in New Issue
Block a user