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,5 @@
|
||||
from flask import Blueprint
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
from app.api import auth, users, files, calendar, contacts, email, office, passwords # noqa: E402, F401
|
||||
@@ -0,0 +1,208 @@
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
|
||||
import jwt
|
||||
from flask import request, jsonify, current_app, make_response
|
||||
|
||||
from app.api import api_bp
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def create_access_token(user_id):
|
||||
exp = datetime.now(timezone.utc) + current_app.config['JWT_ACCESS_TOKEN_EXPIRES']
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'type': 'access',
|
||||
'exp': exp,
|
||||
'iat': datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, current_app.config['JWT_SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
|
||||
def create_refresh_token(user_id):
|
||||
exp = datetime.now(timezone.utc) + current_app.config['JWT_REFRESH_TOKEN_EXPIRES']
|
||||
payload = {
|
||||
'user_id': user_id,
|
||||
'type': 'refresh',
|
||||
'exp': exp,
|
||||
'iat': datetime.now(timezone.utc),
|
||||
}
|
||||
return jwt.encode(payload, current_app.config['JWT_SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = None
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
token = auth_header[7:]
|
||||
|
||||
if not token:
|
||||
return jsonify({'error': 'Token fehlt'}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['JWT_SECRET_KEY'],
|
||||
algorithms=['HS256'])
|
||||
if payload.get('type') != 'access':
|
||||
return jsonify({'error': 'Falscher Token-Typ'}), 401
|
||||
user = db.session.get(User, payload['user_id'])
|
||||
if not user or not user.is_active:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden oder deaktiviert'}), 401
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token abgelaufen'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Ungueltiger Token'}), 401
|
||||
|
||||
request.current_user = user
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
@token_required
|
||||
def decorated(*args, **kwargs):
|
||||
if request.current_user.role != 'admin':
|
||||
return jsonify({'error': 'Admin-Berechtigung erforderlich'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
@api_bp.route('/auth/register', methods=['POST'])
|
||||
def register():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten gesendet'}), 400
|
||||
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
email = data.get('email', '').strip() or None
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Benutzername und Passwort erforderlich'}), 400
|
||||
|
||||
if len(username) < 3:
|
||||
return jsonify({'error': 'Benutzername muss mindestens 3 Zeichen lang sein'}), 400
|
||||
|
||||
if len(password) < 8:
|
||||
return jsonify({'error': 'Passwort muss mindestens 8 Zeichen lang sein'}), 400
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
return jsonify({'error': 'Benutzername bereits vergeben'}), 409
|
||||
|
||||
if email and User.query.filter_by(email=email).first():
|
||||
return jsonify({'error': 'Email-Adresse bereits vergeben'}), 409
|
||||
|
||||
# First user becomes admin
|
||||
is_first_user = User.query.count() == 0
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
role='admin' if is_first_user else 'user',
|
||||
master_key_salt=os.urandom(32),
|
||||
)
|
||||
user.set_password(password)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
response = make_response(jsonify({
|
||||
'user': user.to_dict(include_email=True),
|
||||
'access_token': access_token,
|
||||
}))
|
||||
|
||||
response.set_cookie(
|
||||
'refresh_token', refresh_token,
|
||||
httponly=True, secure=request.is_secure,
|
||||
samesite='Lax',
|
||||
max_age=int(current_app.config['JWT_REFRESH_TOKEN_EXPIRES'].total_seconds()),
|
||||
)
|
||||
|
||||
return response, 201
|
||||
|
||||
|
||||
@api_bp.route('/auth/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten gesendet'}), 400
|
||||
|
||||
username = data.get('username', '').strip()
|
||||
password = data.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({'error': 'Benutzername und Passwort erforderlich'}), 400
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user or not user.check_password(password):
|
||||
return jsonify({'error': 'Ungueltige Anmeldedaten'}), 401
|
||||
|
||||
if not user.is_active:
|
||||
return jsonify({'error': 'Konto deaktiviert'}), 403
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
import base64
|
||||
response = make_response(jsonify({
|
||||
'user': user.to_dict(include_email=True),
|
||||
'access_token': access_token,
|
||||
'master_key_salt': base64.b64encode(user.master_key_salt).decode() if user.master_key_salt else None,
|
||||
}))
|
||||
|
||||
response.set_cookie(
|
||||
'refresh_token', refresh_token,
|
||||
httponly=True, secure=request.is_secure,
|
||||
samesite='Lax',
|
||||
max_age=int(current_app.config['JWT_REFRESH_TOKEN_EXPIRES'].total_seconds()),
|
||||
)
|
||||
|
||||
return response, 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
refresh_token = request.cookies.get('refresh_token')
|
||||
if not refresh_token:
|
||||
return jsonify({'error': 'Refresh-Token fehlt'}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(refresh_token, current_app.config['JWT_SECRET_KEY'],
|
||||
algorithms=['HS256'])
|
||||
if payload.get('type') != 'refresh':
|
||||
return jsonify({'error': 'Falscher Token-Typ'}), 401
|
||||
user = db.session.get(User, payload['user_id'])
|
||||
if not user or not user.is_active:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 401
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Refresh-Token abgelaufen'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Ungueltiger Token'}), 401
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
return jsonify({'access_token': access_token}), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/logout', methods=['POST'])
|
||||
def logout():
|
||||
response = make_response(jsonify({'message': 'Abgemeldet'}))
|
||||
response.delete_cookie('refresh_token')
|
||||
return response, 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/me', methods=['GET'])
|
||||
@token_required
|
||||
def me():
|
||||
import base64
|
||||
user = request.current_user
|
||||
data = user.to_dict(include_email=True)
|
||||
data['master_key_salt'] = base64.b64encode(user.master_key_salt).decode() if user.master_key_salt else None
|
||||
data['email_account_count'] = user.email_accounts.count()
|
||||
return jsonify(data), 200
|
||||
@@ -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)
|
||||
@@ -0,0 +1,340 @@
|
||||
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.contact import AddressBook, Contact, AddressBookShare
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _get_addressbook_or_err(book_id, user, need_write=False):
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book:
|
||||
return None, (jsonify({'error': 'Adressbuch nicht gefunden'}), 404)
|
||||
if book.owner_id == user.id:
|
||||
return book, None
|
||||
share = AddressBookShare.query.filter_by(
|
||||
address_book_id=book_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 book, None
|
||||
|
||||
|
||||
# --- Address Books ---
|
||||
|
||||
@api_bp.route('/addressbooks', methods=['GET'])
|
||||
@token_required
|
||||
def list_addressbooks():
|
||||
user = request.current_user
|
||||
own = AddressBook.query.filter_by(owner_id=user.id).all()
|
||||
shared_ids = [s.address_book_id for s in
|
||||
AddressBookShare.query.filter_by(shared_with_id=user.id).all()]
|
||||
shared = AddressBook.query.filter(AddressBook.id.in_(shared_ids)).all() if shared_ids else []
|
||||
|
||||
result = []
|
||||
for b in own:
|
||||
d = b.to_dict()
|
||||
d['permission'] = 'owner'
|
||||
d['contact_count'] = b.contacts.count()
|
||||
result.append(d)
|
||||
for b in shared:
|
||||
d = b.to_dict()
|
||||
share = AddressBookShare.query.filter_by(
|
||||
address_book_id=b.id, shared_with_id=user.id
|
||||
).first()
|
||||
d['permission'] = share.permission if share else 'read'
|
||||
d['owner_name'] = b.owner.username
|
||||
d['contact_count'] = b.contacts.count()
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks', methods=['POST'])
|
||||
@token_required
|
||||
def create_addressbook():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name erforderlich'}), 400
|
||||
|
||||
book = AddressBook(owner_id=user.id, name=name, description=data.get('description', ''))
|
||||
db.session.add(book)
|
||||
db.session.commit()
|
||||
return jsonify(book.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_addressbook(book_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if 'name' in data:
|
||||
book.name = data['name'].strip()
|
||||
if 'description' in data:
|
||||
book.description = data['description']
|
||||
db.session.commit()
|
||||
return jsonify(book.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_addressbook(book_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(book)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Adressbuch geloescht'}), 200
|
||||
|
||||
|
||||
# --- Contacts ---
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['GET'])
|
||||
@token_required
|
||||
def list_contacts(book_id):
|
||||
user = request.current_user
|
||||
book, err = _get_addressbook_or_err(book_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
search = request.args.get('search', '').strip()
|
||||
query = Contact.query.filter_by(address_book_id=book_id)
|
||||
if search:
|
||||
query = query.filter(Contact.display_name.ilike(f'%{search}%'))
|
||||
contacts = query.order_by(Contact.display_name).all()
|
||||
return jsonify([c.to_dict() for c in contacts]), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['POST'])
|
||||
@token_required
|
||||
def create_contact(book_id):
|
||||
user = request.current_user
|
||||
book, err = _get_addressbook_or_err(book_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
display_name = data.get('display_name', '').strip()
|
||||
if not display_name:
|
||||
return jsonify({'error': 'Name erforderlich'}), 400
|
||||
|
||||
contact_uid = str(uuid.uuid4())
|
||||
email = data.get('email', '')
|
||||
phone = data.get('phone', '')
|
||||
org = data.get('organization', '')
|
||||
notes = data.get('notes', '')
|
||||
|
||||
vcard = _build_vcard(contact_uid, display_name, email, phone, org, notes)
|
||||
|
||||
contact = Contact(
|
||||
address_book_id=book_id,
|
||||
uid=contact_uid,
|
||||
vcard_data=vcard,
|
||||
display_name=display_name,
|
||||
email=email or None,
|
||||
phone=phone or None,
|
||||
)
|
||||
db.session.add(contact)
|
||||
db.session.commit()
|
||||
return jsonify(contact.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<int:contact_id>', methods=['GET'])
|
||||
@token_required
|
||||
def get_contact(contact_id):
|
||||
user = request.current_user
|
||||
contact = db.session.get(Contact, contact_id)
|
||||
if not contact:
|
||||
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
|
||||
|
||||
book, err = _get_addressbook_or_err(contact.address_book_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
result = contact.to_dict()
|
||||
result['vcard_data'] = contact.vcard_data
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<int:contact_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_contact(contact_id):
|
||||
user = request.current_user
|
||||
contact = db.session.get(Contact, contact_id)
|
||||
if not contact:
|
||||
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
|
||||
|
||||
book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
if 'display_name' in data:
|
||||
contact.display_name = data['display_name'].strip()
|
||||
if 'email' in data:
|
||||
contact.email = data['email'] or None
|
||||
if 'phone' in data:
|
||||
contact.phone = data['phone'] or None
|
||||
|
||||
contact.vcard_data = _build_vcard(
|
||||
contact.uid,
|
||||
contact.display_name,
|
||||
data.get('email', contact.email or ''),
|
||||
data.get('phone', contact.phone or ''),
|
||||
data.get('organization', ''),
|
||||
data.get('notes', ''),
|
||||
)
|
||||
contact.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(contact.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<int:contact_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_contact(contact_id):
|
||||
user = request.current_user
|
||||
contact = db.session.get(Contact, contact_id)
|
||||
if not contact:
|
||||
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
|
||||
|
||||
book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True)
|
||||
if err:
|
||||
return err
|
||||
|
||||
db.session.delete(contact)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Kontakt geloescht'}), 200
|
||||
|
||||
|
||||
# --- Sharing ---
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/share', methods=['POST'])
|
||||
@token_required
|
||||
def share_addressbook(book_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.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 = AddressBookShare.query.filter_by(
|
||||
address_book_id=book_id, shared_with_id=target.id
|
||||
).first()
|
||||
if existing:
|
||||
existing.permission = permission
|
||||
else:
|
||||
share = AddressBookShare(
|
||||
address_book_id=book_id, shared_with_id=target.id, permission=permission
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': f'Adressbuch mit {username} geteilt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/shares', methods=['GET'])
|
||||
@token_required
|
||||
def list_addressbook_shares(book_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
shares = AddressBookShare.query.filter_by(address_book_id=book_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('/addressbooks/<int:book_id>/shares/<int:share_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def remove_addressbook_share(book_id, share_id):
|
||||
user = request.current_user
|
||||
book = db.session.get(AddressBook, book_id)
|
||||
if not book or book.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
share = db.session.get(AddressBookShare, share_id)
|
||||
if not share or share.address_book_id != book_id:
|
||||
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Freigabe entfernt'}), 200
|
||||
|
||||
|
||||
# --- Import/Export ---
|
||||
|
||||
@api_bp.route('/addressbooks/<int:book_id>/export', methods=['GET'])
|
||||
@token_required
|
||||
def export_contacts(book_id):
|
||||
user = request.current_user
|
||||
book, err = _get_addressbook_or_err(book_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
contacts = Contact.query.filter_by(address_book_id=book_id).all()
|
||||
vcards = '\r\n'.join(c.vcard_data for c in contacts)
|
||||
|
||||
from flask import Response
|
||||
return Response(
|
||||
vcards,
|
||||
mimetype='text/vcard',
|
||||
headers={'Content-Disposition': f'attachment; filename="{book.name}.vcf"'},
|
||||
)
|
||||
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _build_vcard(uid, display_name, email='', phone='', org='', notes=''):
|
||||
parts = display_name.split(' ', 1)
|
||||
first = parts[0]
|
||||
last = parts[1] if len(parts) > 1 else ''
|
||||
|
||||
lines = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
f'UID:{uid}',
|
||||
f'FN:{display_name}',
|
||||
f'N:{last};{first};;;',
|
||||
]
|
||||
if email:
|
||||
lines.append(f'EMAIL:{email}')
|
||||
if phone:
|
||||
lines.append(f'TEL:{phone}')
|
||||
if org:
|
||||
lines.append(f'ORG:{org}')
|
||||
if notes:
|
||||
lines.append(f'NOTE:{notes}')
|
||||
lines.append(f'REV:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}')
|
||||
lines.append('END:VCARD')
|
||||
return '\r\n'.join(lines)
|
||||
@@ -0,0 +1,489 @@
|
||||
import email as email_lib
|
||||
import email.header
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.base import MIMEBase
|
||||
from email import encoders
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request, jsonify, current_app
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.extensions import db
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.services.crypto_service import encrypt_field, decrypt_field
|
||||
|
||||
|
||||
def _get_account_or_err(account_id, user):
|
||||
account = db.session.get(EmailAccount, account_id)
|
||||
if not account or account.user_id != user.id:
|
||||
return None, (jsonify({'error': 'Konto nicht gefunden'}), 404)
|
||||
return account, None
|
||||
|
||||
|
||||
def _get_imap_connection(account, user_password_key):
|
||||
import imapclient
|
||||
password = decrypt_field(account.password_encrypted, user_password_key)
|
||||
host = account.imap_host
|
||||
port = account.imap_port
|
||||
|
||||
if account.imap_ssl:
|
||||
conn = imapclient.IMAPClient(host, port=port, ssl=True)
|
||||
else:
|
||||
conn = imapclient.IMAPClient(host, port=port, ssl=False)
|
||||
conn.starttls()
|
||||
|
||||
conn.login(account.username, password)
|
||||
return conn
|
||||
|
||||
|
||||
def _decode_header(header_value):
|
||||
if not header_value:
|
||||
return ''
|
||||
decoded_parts = email.header.decode_header(header_value)
|
||||
result = []
|
||||
for part, charset in decoded_parts:
|
||||
if isinstance(part, bytes):
|
||||
result.append(part.decode(charset or 'utf-8', errors='replace'))
|
||||
else:
|
||||
result.append(part)
|
||||
return ' '.join(result)
|
||||
|
||||
|
||||
# --- Accounts ---
|
||||
|
||||
@api_bp.route('/email/accounts', methods=['GET'])
|
||||
@token_required
|
||||
def list_email_accounts():
|
||||
user = request.current_user
|
||||
accounts = EmailAccount.query.filter_by(user_id=user.id)\
|
||||
.order_by(EmailAccount.sort_order).all()
|
||||
return jsonify([a.to_dict() for a in accounts]), 200
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts', methods=['POST'])
|
||||
@token_required
|
||||
def create_email_account():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
|
||||
required = ['display_name', 'email_address', 'imap_host', 'smtp_host', 'username', 'password']
|
||||
for field in required:
|
||||
if not data.get(field):
|
||||
return jsonify({'error': f'{field} erforderlich'}), 400
|
||||
|
||||
# Get encryption key from header
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich (X-Encryption-Key Header)'}), 400
|
||||
|
||||
encrypted_pw = encrypt_field(data['password'], enc_key)
|
||||
|
||||
account = EmailAccount(
|
||||
user_id=user.id,
|
||||
display_name=data['display_name'],
|
||||
email_address=data['email_address'],
|
||||
imap_host=data['imap_host'],
|
||||
imap_port=data.get('imap_port', 993),
|
||||
imap_ssl=data.get('imap_ssl', True),
|
||||
smtp_host=data['smtp_host'],
|
||||
smtp_port=data.get('smtp_port', 587),
|
||||
smtp_ssl=data.get('smtp_ssl', True),
|
||||
username=data['username'],
|
||||
password_encrypted=encrypted_pw,
|
||||
is_default=data.get('is_default', False),
|
||||
sort_order=data.get('sort_order', 0),
|
||||
)
|
||||
db.session.add(account)
|
||||
|
||||
# Update email account count
|
||||
db.session.commit()
|
||||
|
||||
return jsonify(account.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_email_account(account_id):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
for field in ['display_name', 'email_address', 'imap_host', 'imap_port',
|
||||
'imap_ssl', 'smtp_host', 'smtp_port', 'smtp_ssl',
|
||||
'username', 'is_default', 'sort_order']:
|
||||
if field in data:
|
||||
setattr(account, field, data[field])
|
||||
|
||||
if 'password' in data and data['password']:
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if enc_key:
|
||||
account.password_encrypted = encrypt_field(data['password'], enc_key)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(account.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_email_account(account_id):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
db.session.delete(account)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'E-Mail-Konto geloescht'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/test', methods=['POST'])
|
||||
@token_required
|
||||
def test_email_account(account_id):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich'}), 400
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.logout()
|
||||
return jsonify({'message': 'Verbindung erfolgreich'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Verbindungsfehler: {str(e)}'}), 400
|
||||
|
||||
|
||||
# --- Folders ---
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/folders', methods=['GET'])
|
||||
@token_required
|
||||
def list_email_folders(account_id):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich'}), 400
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
folders_raw = conn.list_folders()
|
||||
folders = []
|
||||
for flags, delimiter, name in folders_raw:
|
||||
flag_strs = [f.decode() if isinstance(f, bytes) else f for f in flags]
|
||||
folders.append({
|
||||
'name': name,
|
||||
'delimiter': delimiter.decode() if isinstance(delimiter, bytes) else delimiter,
|
||||
'flags': flag_strs,
|
||||
})
|
||||
conn.logout()
|
||||
return jsonify(folders), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# --- Messages ---
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/folders/<path:folder>/messages', methods=['GET'])
|
||||
@token_required
|
||||
def list_messages(account_id, folder):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich'}), 400
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
|
||||
# Get all UIDs, sorted newest first
|
||||
uids = conn.search(['ALL'])
|
||||
uids.reverse()
|
||||
|
||||
total = len(uids)
|
||||
start = (page - 1) * limit
|
||||
page_uids = uids[start:start + limit]
|
||||
|
||||
messages = []
|
||||
if page_uids:
|
||||
fetch_data = conn.fetch(page_uids, ['ENVELOPE', 'FLAGS', 'RFC822.SIZE'])
|
||||
for uid in page_uids:
|
||||
if uid not in fetch_data:
|
||||
continue
|
||||
msg_data = fetch_data[uid]
|
||||
envelope = msg_data.get(b'ENVELOPE')
|
||||
flags = msg_data.get(b'FLAGS', ())
|
||||
size = msg_data.get(b'RFC822.SIZE', 0)
|
||||
|
||||
flag_strs = [f.decode() if isinstance(f, bytes) else str(f) for f in flags]
|
||||
|
||||
from_addr = ''
|
||||
if envelope and envelope.from_:
|
||||
f = envelope.from_[0]
|
||||
name = f.name.decode(errors='replace') if f.name else ''
|
||||
mailbox = f.mailbox.decode(errors='replace') if f.mailbox else ''
|
||||
host = f.host.decode(errors='replace') if f.host else ''
|
||||
from_addr = f'{name} <{mailbox}@{host}>' if name else f'{mailbox}@{host}'
|
||||
|
||||
subject = ''
|
||||
if envelope and envelope.subject:
|
||||
subject = _decode_header(envelope.subject.decode(errors='replace'))
|
||||
|
||||
date_str = ''
|
||||
if envelope and envelope.date:
|
||||
date_str = envelope.date.isoformat() if hasattr(envelope.date, 'isoformat') else str(envelope.date)
|
||||
|
||||
messages.append({
|
||||
'uid': uid,
|
||||
'subject': subject,
|
||||
'from': from_addr,
|
||||
'date': date_str,
|
||||
'flags': flag_strs,
|
||||
'size': size,
|
||||
'seen': '\\Seen' in flag_strs,
|
||||
})
|
||||
|
||||
conn.logout()
|
||||
return jsonify({
|
||||
'messages': messages,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/messages/<int:uid>', methods=['GET'])
|
||||
@token_required
|
||||
def get_message(account_id, uid):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
folder = request.args.get('folder', 'INBOX')
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
|
||||
fetch_data = conn.fetch([uid], ['RFC822', 'FLAGS'])
|
||||
if uid not in fetch_data:
|
||||
conn.logout()
|
||||
return jsonify({'error': 'Nachricht nicht gefunden'}), 404
|
||||
|
||||
raw = fetch_data[uid][b'RFC822']
|
||||
flags = fetch_data[uid].get(b'FLAGS', ())
|
||||
|
||||
# Mark as seen
|
||||
conn.set_flags([uid], ['\\Seen'])
|
||||
conn.logout()
|
||||
|
||||
msg = email_lib.message_from_bytes(raw)
|
||||
|
||||
# Extract body
|
||||
html_body = ''
|
||||
text_body = ''
|
||||
attachments = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
disposition = str(part.get('Content-Disposition', ''))
|
||||
|
||||
if 'attachment' in disposition:
|
||||
filename = part.get_filename() or 'attachment'
|
||||
attachments.append({
|
||||
'filename': _decode_header(filename),
|
||||
'content_type': content_type,
|
||||
'size': len(part.get_payload(decode=True) or b''),
|
||||
})
|
||||
elif content_type == 'text/html':
|
||||
html_body = part.get_payload(decode=True).decode(errors='replace')
|
||||
elif content_type == 'text/plain':
|
||||
text_body = part.get_payload(decode=True).decode(errors='replace')
|
||||
else:
|
||||
content_type = msg.get_content_type()
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
if content_type == 'text/html':
|
||||
html_body = payload.decode(errors='replace')
|
||||
else:
|
||||
text_body = payload.decode(errors='replace')
|
||||
|
||||
return jsonify({
|
||||
'uid': uid,
|
||||
'subject': _decode_header(msg.get('Subject', '')),
|
||||
'from': _decode_header(msg.get('From', '')),
|
||||
'to': _decode_header(msg.get('To', '')),
|
||||
'cc': _decode_header(msg.get('Cc', '')),
|
||||
'date': msg.get('Date', ''),
|
||||
'html_body': html_body,
|
||||
'text_body': text_body,
|
||||
'attachments': attachments,
|
||||
'flags': [f.decode() if isinstance(f, bytes) else str(f) for f in flags],
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# --- Send ---
|
||||
|
||||
@api_bp.route('/email/send', methods=['POST'])
|
||||
@token_required
|
||||
def send_email():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
|
||||
account_id = data.get('account_id')
|
||||
if not account_id:
|
||||
return jsonify({'error': 'Konto-ID erforderlich'}), 400
|
||||
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
if not enc_key:
|
||||
return jsonify({'error': 'Verschluesselungs-Key erforderlich'}), 400
|
||||
|
||||
to_addr = data.get('to', '')
|
||||
cc_addr = data.get('cc', '')
|
||||
subject = data.get('subject', '')
|
||||
body_html = data.get('body_html', '')
|
||||
body_text = data.get('body_text', '')
|
||||
|
||||
if not to_addr:
|
||||
return jsonify({'error': 'Empfaenger erforderlich'}), 400
|
||||
|
||||
password = decrypt_field(account.password_encrypted, enc_key)
|
||||
|
||||
# Build message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['From'] = f'{account.display_name} <{account.email_address}>'
|
||||
msg['To'] = to_addr
|
||||
if cc_addr:
|
||||
msg['Cc'] = cc_addr
|
||||
msg['Subject'] = subject
|
||||
msg['Date'] = email_lib.utils.formatdate(localtime=True)
|
||||
|
||||
if body_text:
|
||||
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
|
||||
if body_html:
|
||||
msg.attach(MIMEText(body_html, 'html', 'utf-8'))
|
||||
|
||||
try:
|
||||
if account.smtp_ssl and account.smtp_port == 465:
|
||||
server = smtplib.SMTP_SSL(account.smtp_host, account.smtp_port)
|
||||
else:
|
||||
server = smtplib.SMTP(account.smtp_host, account.smtp_port)
|
||||
server.starttls()
|
||||
|
||||
server.login(account.username, password)
|
||||
|
||||
recipients = [to_addr]
|
||||
if cc_addr:
|
||||
recipients.extend(cc_addr.split(','))
|
||||
|
||||
server.sendmail(account.email_address, recipients, msg.as_string())
|
||||
server.quit()
|
||||
|
||||
return jsonify({'message': 'E-Mail gesendet'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Sendefehler: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- Flag / Move / Delete ---
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/messages/<int:uid>/flag', methods=['POST'])
|
||||
@token_required
|
||||
def flag_message(account_id, uid):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
data = request.get_json()
|
||||
folder = data.get('folder', 'INBOX')
|
||||
flag = data.get('flag', '\\Seen')
|
||||
add = data.get('add', True)
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
if add:
|
||||
conn.add_flags([uid], [flag])
|
||||
else:
|
||||
conn.remove_flags([uid], [flag])
|
||||
conn.logout()
|
||||
return jsonify({'message': 'Flag gesetzt'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/messages/<int:uid>/move', methods=['POST'])
|
||||
@token_required
|
||||
def move_message(account_id, uid):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
data = request.get_json()
|
||||
folder = data.get('folder', 'INBOX')
|
||||
target = data.get('target')
|
||||
|
||||
if not target:
|
||||
return jsonify({'error': 'Ziel-Ordner erforderlich'}), 400
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
conn.move([uid], target)
|
||||
conn.logout()
|
||||
return jsonify({'message': 'Nachricht verschoben'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/email/accounts/<int:account_id>/messages/<int:uid>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_message(account_id, uid):
|
||||
user = request.current_user
|
||||
account, err = _get_account_or_err(account_id, user)
|
||||
if err:
|
||||
return err
|
||||
|
||||
enc_key = request.headers.get('X-Encryption-Key', '')
|
||||
folder = request.args.get('folder', 'INBOX')
|
||||
|
||||
try:
|
||||
conn = _get_imap_connection(account, enc_key)
|
||||
conn.select_folder(folder)
|
||||
conn.delete_messages([uid])
|
||||
conn.expunge()
|
||||
conn.logout()
|
||||
return jsonify({'message': 'Nachricht geloescht'}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -0,0 +1,571 @@
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import secrets
|
||||
import mimetypes
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from flask import request, jsonify, send_file, current_app
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import token_required
|
||||
from app.extensions import db, bcrypt
|
||||
from app.models.file import File, FilePermission, ShareLink
|
||||
|
||||
|
||||
def _user_upload_dir(user_id):
|
||||
base = Path(current_app.config['UPLOAD_PATH'])
|
||||
user_dir = base / str(user_id)
|
||||
user_dir.mkdir(parents=True, exist_ok=True)
|
||||
return user_dir
|
||||
|
||||
|
||||
def _check_file_access(file_obj, user, permission='read'):
|
||||
"""Check if user has access to file. Owner always has full access."""
|
||||
if file_obj.owner_id == user.id:
|
||||
return True
|
||||
perm = FilePermission.query.filter_by(
|
||||
file_id=file_obj.id, user_id=user.id
|
||||
).first()
|
||||
if not perm:
|
||||
return False
|
||||
perm_levels = {'read': 0, 'write': 1, 'admin': 2}
|
||||
return perm_levels.get(perm.permission, -1) >= perm_levels.get(permission, 0)
|
||||
|
||||
|
||||
def _get_file_or_403(file_id, user, permission='read'):
|
||||
f = db.session.get(File, file_id)
|
||||
if not f:
|
||||
return None, (jsonify({'error': 'Datei nicht gefunden'}), 404)
|
||||
if not _check_file_access(f, user, permission):
|
||||
return None, (jsonify({'error': 'Zugriff verweigert'}), 403)
|
||||
return f, None
|
||||
|
||||
|
||||
def _compute_checksum(filepath):
|
||||
h = hashlib.sha256()
|
||||
with open(filepath, 'rb') as fh:
|
||||
for chunk in iter(lambda: fh.read(8192), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# --- Folder / File listing ---
|
||||
|
||||
@api_bp.route('/files', methods=['GET'])
|
||||
@token_required
|
||||
def list_files():
|
||||
user = request.current_user
|
||||
parent_id = request.args.get('parent_id', None, type=int)
|
||||
|
||||
# Own files in this folder
|
||||
query = File.query.filter_by(owner_id=user.id, parent_id=parent_id)
|
||||
files = query.order_by(File.is_folder.desc(), File.name).all()
|
||||
|
||||
# Shared files at root level
|
||||
shared = []
|
||||
if parent_id is None:
|
||||
shared_perms = FilePermission.query.filter_by(user_id=user.id).all()
|
||||
shared_file_ids = [p.file_id for p in shared_perms]
|
||||
if shared_file_ids:
|
||||
shared = File.query.filter(
|
||||
File.id.in_(shared_file_ids),
|
||||
File.parent_id.is_(None)
|
||||
).order_by(File.is_folder.desc(), File.name).all()
|
||||
|
||||
result = [f.to_dict() for f in files]
|
||||
for f in shared:
|
||||
d = f.to_dict()
|
||||
d['shared'] = True
|
||||
result.append(d)
|
||||
|
||||
# Build breadcrumb
|
||||
breadcrumb = []
|
||||
if parent_id:
|
||||
current = db.session.get(File, parent_id)
|
||||
while current:
|
||||
breadcrumb.insert(0, {'id': current.id, 'name': current.name})
|
||||
current = current.parent
|
||||
|
||||
return jsonify({'files': result, 'breadcrumb': breadcrumb}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/folder', methods=['POST'])
|
||||
@token_required
|
||||
def create_folder():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
parent_id = data.get('parent_id', None)
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'Ordnername erforderlich'}), 400
|
||||
|
||||
if parent_id:
|
||||
parent, err = _get_file_or_403(parent_id, user, 'write')
|
||||
if err:
|
||||
return err
|
||||
if not parent.is_folder:
|
||||
return jsonify({'error': 'Uebergeordnetes Element ist kein Ordner'}), 400
|
||||
|
||||
existing = File.query.filter_by(
|
||||
owner_id=user.id, parent_id=parent_id, name=name, is_folder=True
|
||||
).first()
|
||||
if existing:
|
||||
return jsonify({'error': 'Ordner existiert bereits'}), 409
|
||||
|
||||
folder = File(
|
||||
owner_id=user.id,
|
||||
parent_id=parent_id,
|
||||
name=name,
|
||||
is_folder=True,
|
||||
)
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
return jsonify(folder.to_dict()), 201
|
||||
|
||||
|
||||
# --- Upload ---
|
||||
|
||||
@api_bp.route('/files/upload', methods=['POST'])
|
||||
@token_required
|
||||
def upload_file():
|
||||
user = request.current_user
|
||||
parent_id = request.form.get('parent_id', None, type=int)
|
||||
|
||||
if parent_id:
|
||||
parent, err = _get_file_or_403(parent_id, user, 'write')
|
||||
if err:
|
||||
return err
|
||||
|
||||
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'
|
||||
|
||||
# Save to disk with UUID name
|
||||
storage_name = str(uuid.uuid4())
|
||||
user_dir = _user_upload_dir(user.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))
|
||||
|
||||
# Check if file with same name exists -> overwrite
|
||||
existing = File.query.filter_by(
|
||||
owner_id=user.id, parent_id=parent_id, name=filename, is_folder=False
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Remove old file from disk
|
||||
old_path = Path(current_app.config['UPLOAD_PATH']) / str(user.id) / existing.storage_path
|
||||
if old_path.exists():
|
||||
old_path.unlink()
|
||||
existing.storage_path = storage_name
|
||||
existing.size = size
|
||||
existing.mime_type = mime
|
||||
existing.checksum = checksum
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(existing.to_dict()), 200
|
||||
|
||||
file_obj = File(
|
||||
owner_id=user.id,
|
||||
parent_id=parent_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()
|
||||
return jsonify(file_obj.to_dict()), 201
|
||||
|
||||
|
||||
# --- Download ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/download', methods=['GET'])
|
||||
@token_required
|
||||
def download_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
if f.is_folder:
|
||||
return jsonify({'error': 'Ordner koennen nicht heruntergeladen werden'}), 400
|
||||
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||
if not filepath.exists():
|
||||
return jsonify({'error': 'Datei auf Datentraeger nicht gefunden'}), 404
|
||||
|
||||
return send_file(str(filepath), mimetype=f.mime_type, as_attachment=True,
|
||||
download_name=f.name)
|
||||
|
||||
|
||||
# --- Rename / Move ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'write')
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json()
|
||||
if 'name' in data:
|
||||
name = data['name'].strip()
|
||||
if name:
|
||||
f.name = name
|
||||
|
||||
if 'parent_id' in data:
|
||||
new_parent = data['parent_id']
|
||||
if new_parent is not None:
|
||||
parent, perr = _get_file_or_403(new_parent, user, 'write')
|
||||
if perr:
|
||||
return perr
|
||||
if not parent.is_folder:
|
||||
return jsonify({'error': 'Ziel ist kein Ordner'}), 400
|
||||
# Prevent moving folder into itself
|
||||
if f.is_folder:
|
||||
check = parent
|
||||
while check:
|
||||
if check.id == f.id:
|
||||
return jsonify({'error': 'Ordner kann nicht in sich selbst verschoben werden'}), 400
|
||||
check = check.parent
|
||||
f.parent_id = new_parent
|
||||
|
||||
f.updated_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
return jsonify(f.to_dict()), 200
|
||||
|
||||
|
||||
# --- Delete ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'admin')
|
||||
if err:
|
||||
# Owner can always delete
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
_delete_recursive(f, user.id)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Geloescht'}), 200
|
||||
|
||||
|
||||
def _delete_recursive(file_obj, user_id):
|
||||
if file_obj.is_folder:
|
||||
children = File.query.filter_by(parent_id=file_obj.id).all()
|
||||
for child in children:
|
||||
_delete_recursive(child, user_id)
|
||||
else:
|
||||
if file_obj.storage_path:
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(file_obj.owner_id) / file_obj.storage_path
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
db.session.delete(file_obj)
|
||||
|
||||
|
||||
# --- Permissions ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/permissions', methods=['GET'])
|
||||
@token_required
|
||||
def get_permissions(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'admin')
|
||||
if err:
|
||||
if not (f := db.session.get(File, file_id)) or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
perms = FilePermission.query.filter_by(file_id=file_id).all()
|
||||
from app.models.user import User
|
||||
result = []
|
||||
for p in perms:
|
||||
u = db.session.get(User, p.user_id)
|
||||
result.append({
|
||||
'id': p.id,
|
||||
'user_id': p.user_id,
|
||||
'username': u.username if u else None,
|
||||
'permission': p.permission,
|
||||
})
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/permissions', methods=['POST'])
|
||||
@token_required
|
||||
def set_permission(file_id):
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen setzen'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
target_user_id = data.get('user_id')
|
||||
permission = data.get('permission', 'read')
|
||||
|
||||
if permission not in ('read', 'write', 'admin'):
|
||||
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
|
||||
|
||||
from app.models.user import User
|
||||
target = db.session.get(User, target_user_id)
|
||||
if not target:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
|
||||
existing = FilePermission.query.filter_by(
|
||||
file_id=file_id, user_id=target_user_id
|
||||
).first()
|
||||
if existing:
|
||||
existing.permission = permission
|
||||
else:
|
||||
perm = FilePermission(file_id=file_id, user_id=target_user_id, permission=permission)
|
||||
db.session.add(perm)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Berechtigung gesetzt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/permissions/<int:perm_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def remove_permission(file_id, perm_id):
|
||||
user = request.current_user
|
||||
f = db.session.get(File, file_id)
|
||||
if not f or f.owner_id != user.id:
|
||||
return jsonify({'error': 'Nur der Eigentuemer kann Berechtigungen entfernen'}), 403
|
||||
|
||||
perm = db.session.get(FilePermission, perm_id)
|
||||
if not perm or perm.file_id != file_id:
|
||||
return jsonify({'error': 'Berechtigung nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(perm)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Berechtigung entfernt'}), 200
|
||||
|
||||
|
||||
# --- Share Links ---
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/share', methods=['POST'])
|
||||
@token_required
|
||||
def create_share_link(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password')
|
||||
expires_at = data.get('expires_at')
|
||||
max_downloads = data.get('max_downloads')
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
password_hash = None
|
||||
if password:
|
||||
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
|
||||
exp_dt = None
|
||||
if expires_at:
|
||||
try:
|
||||
exp_dt = datetime.fromisoformat(expires_at).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Ungueltiges Datumsformat'}), 400
|
||||
|
||||
link = ShareLink(
|
||||
file_id=file_id,
|
||||
token=token,
|
||||
password_hash=password_hash,
|
||||
expires_at=exp_dt,
|
||||
created_by=user.id,
|
||||
max_downloads=max_downloads,
|
||||
)
|
||||
db.session.add(link)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'token': token,
|
||||
'url': f'/share/{token}',
|
||||
'expires_at': exp_dt.isoformat() if exp_dt else None,
|
||||
'has_password': bool(password),
|
||||
}), 201
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/shares', methods=['GET'])
|
||||
@token_required
|
||||
def list_share_links(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
|
||||
links = ShareLink.query.filter_by(file_id=file_id).all()
|
||||
return jsonify([{
|
||||
'id': l.id,
|
||||
'token': l.token,
|
||||
'has_password': bool(l.password_hash),
|
||||
'expires_at': l.expires_at.isoformat() if l.expires_at else None,
|
||||
'download_count': l.download_count,
|
||||
'max_downloads': l.max_downloads,
|
||||
'created_at': l.created_at.isoformat(),
|
||||
} for l in links]), 200
|
||||
|
||||
|
||||
@api_bp.route('/share/<token>/info', methods=['GET'])
|
||||
def share_info(token):
|
||||
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
|
||||
|
||||
if link.is_download_limit_reached():
|
||||
return jsonify({'error': 'Download-Limit erreicht'}), 410
|
||||
|
||||
f = db.session.get(File, link.file_id)
|
||||
return jsonify({
|
||||
'name': f.name,
|
||||
'is_folder': f.is_folder,
|
||||
'size': f.size,
|
||||
'mime_type': f.mime_type,
|
||||
'has_password': bool(link.password_hash),
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/share/<token>/verify', methods=['POST'])
|
||||
def share_verify(token):
|
||||
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
|
||||
|
||||
data = request.get_json() or {}
|
||||
password = data.get('password', '')
|
||||
|
||||
if link.password_hash:
|
||||
if not bcrypt.check_password_hash(link.password_hash, password):
|
||||
return jsonify({'error': 'Falsches Passwort'}), 401
|
||||
|
||||
# Generate temporary download token
|
||||
download_token = secrets.token_urlsafe(16)
|
||||
# Store in link temporarily (simple approach)
|
||||
link._download_token = download_token
|
||||
return jsonify({'download_token': download_token}), 200
|
||||
|
||||
|
||||
@api_bp.route('/share/<token>/download', methods=['GET'])
|
||||
def share_download(token):
|
||||
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
|
||||
|
||||
if link.is_download_limit_reached():
|
||||
return jsonify({'error': 'Download-Limit erreicht'}), 410
|
||||
|
||||
# Check password if set
|
||||
if link.password_hash:
|
||||
# For password-protected links, require the password as query param or header
|
||||
password = request.args.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 f.is_folder:
|
||||
return jsonify({'error': 'Ordner-Download noch nicht implementiert'}), 501
|
||||
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||
if not filepath.exists():
|
||||
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
||||
|
||||
link.download_count += 1
|
||||
db.session.commit()
|
||||
|
||||
return send_file(str(filepath), mimetype=f.mime_type, as_attachment=True,
|
||||
download_name=f.name)
|
||||
|
||||
|
||||
@api_bp.route('/share/<token>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_share_link(token):
|
||||
user = request.current_user
|
||||
link = ShareLink.query.filter_by(token=token).first()
|
||||
if not link:
|
||||
return jsonify({'error': 'Link nicht gefunden'}), 404
|
||||
|
||||
if link.created_by != user.id:
|
||||
return jsonify({'error': 'Nur der Ersteller kann den Link loeschen'}), 403
|
||||
|
||||
db.session.delete(link)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Link geloescht'}), 200
|
||||
|
||||
|
||||
# --- Sync API ---
|
||||
|
||||
@api_bp.route('/sync/tree', methods=['GET'])
|
||||
@token_required
|
||||
def sync_tree():
|
||||
"""Returns complete file tree with checksums for sync clients."""
|
||||
user = request.current_user
|
||||
|
||||
def _build_tree(parent_id):
|
||||
files = File.query.filter_by(owner_id=user.id, parent_id=parent_id)\
|
||||
.order_by(File.is_folder.desc(), File.name).all()
|
||||
result = []
|
||||
for f in files:
|
||||
entry = {
|
||||
'id': f.id,
|
||||
'name': f.name,
|
||||
'is_folder': f.is_folder,
|
||||
'size': f.size,
|
||||
'checksum': f.checksum,
|
||||
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
|
||||
}
|
||||
if f.is_folder:
|
||||
entry['children'] = _build_tree(f.id)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
return jsonify({'tree': _build_tree(None)}), 200
|
||||
|
||||
|
||||
@api_bp.route('/sync/changes', methods=['GET'])
|
||||
@token_required
|
||||
def sync_changes():
|
||||
"""Returns files changed since a given timestamp."""
|
||||
user = request.current_user
|
||||
since = request.args.get('since')
|
||||
|
||||
if not since:
|
||||
return jsonify({'error': 'Parameter "since" erforderlich'}), 400
|
||||
|
||||
try:
|
||||
since_dt = datetime.fromisoformat(since).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Ungueltiges Datumsformat'}), 400
|
||||
|
||||
changed = File.query.filter(
|
||||
File.owner_id == user.id,
|
||||
File.updated_at > since_dt
|
||||
).all()
|
||||
|
||||
return jsonify({
|
||||
'changes': [f.to_dict() for f in changed],
|
||||
'server_time': datetime.now(timezone.utc).isoformat(),
|
||||
}), 200
|
||||
@@ -0,0 +1,170 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from flask import request, jsonify, current_app, send_file
|
||||
|
||||
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
|
||||
|
||||
|
||||
@api_bp.route('/files/<int:file_id>/preview', methods=['GET'])
|
||||
@token_required
|
||||
def preview_file(file_id):
|
||||
user = request.current_user
|
||||
f, err = _get_file_or_403(file_id, user, 'read')
|
||||
if err:
|
||||
return err
|
||||
|
||||
if f.is_folder:
|
||||
return jsonify({'error': 'Ordner haben keine Vorschau'}), 400
|
||||
|
||||
mime = f.mime_type or ''
|
||||
filepath = Path(current_app.config['UPLOAD_PATH']) / str(f.owner_id) / f.storage_path
|
||||
|
||||
if not filepath.exists():
|
||||
return jsonify({'error': 'Datei nicht gefunden'}), 404
|
||||
|
||||
# PDF -> just return URL for PDF.js to load
|
||||
if 'pdf' in mime:
|
||||
return jsonify({
|
||||
'type': 'pdf',
|
||||
'url': f'/api/files/{file_id}/download',
|
||||
'name': f.name,
|
||||
}), 200
|
||||
|
||||
# DOCX
|
||||
if mime in ('application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword') or f.name.endswith('.docx'):
|
||||
try:
|
||||
html = _convert_docx(filepath)
|
||||
return jsonify({'type': 'html', 'content': html, 'name': f.name}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'DOCX-Vorschau fehlgeschlagen: {str(e)}'}), 500
|
||||
|
||||
# XLSX
|
||||
if mime in ('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel') or f.name.endswith('.xlsx'):
|
||||
try:
|
||||
data = _convert_xlsx(filepath)
|
||||
return jsonify({'type': 'spreadsheet', 'sheets': data, 'name': f.name}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'XLSX-Vorschau fehlgeschlagen: {str(e)}'}), 500
|
||||
|
||||
# PPTX
|
||||
if mime in ('application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.ms-powerpoint') or f.name.endswith('.pptx'):
|
||||
try:
|
||||
slides = _convert_pptx(filepath)
|
||||
return jsonify({'type': 'slides', 'slides': slides, 'name': f.name}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'PPTX-Vorschau fehlgeschlagen: {str(e)}'}), 500
|
||||
|
||||
# Images
|
||||
if mime.startswith('image/'):
|
||||
return jsonify({
|
||||
'type': 'image',
|
||||
'url': f'/api/files/{file_id}/download',
|
||||
'name': f.name,
|
||||
}), 200
|
||||
|
||||
# Text files
|
||||
if mime.startswith('text/') or f.name.endswith(('.txt', '.md', '.json', '.xml', '.csv',
|
||||
'.py', '.js', '.html', '.css', '.yml', '.yaml')):
|
||||
try:
|
||||
content = filepath.read_text(encoding='utf-8', errors='replace')[:100000]
|
||||
return jsonify({'type': 'text', 'content': content, 'name': f.name}), 200
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'type': 'unsupported', 'name': f.name, 'mime_type': mime}), 200
|
||||
|
||||
|
||||
def _convert_docx(filepath):
|
||||
from docx import Document
|
||||
doc = Document(str(filepath))
|
||||
html_parts = []
|
||||
for para in doc.paragraphs:
|
||||
style = para.style.name if para.style else ''
|
||||
text = para.text
|
||||
if not text.strip():
|
||||
html_parts.append('<br/>')
|
||||
continue
|
||||
if 'Heading 1' in style:
|
||||
html_parts.append(f'<h1>{text}</h1>')
|
||||
elif 'Heading 2' in style:
|
||||
html_parts.append(f'<h2>{text}</h2>')
|
||||
elif 'Heading 3' in style:
|
||||
html_parts.append(f'<h3>{text}</h3>')
|
||||
else:
|
||||
# Check for bold/italic runs
|
||||
run_html = ''
|
||||
for run in para.runs:
|
||||
t = run.text
|
||||
if run.bold:
|
||||
t = f'<strong>{t}</strong>'
|
||||
if run.italic:
|
||||
t = f'<em>{t}</em>'
|
||||
if run.underline:
|
||||
t = f'<u>{t}</u>'
|
||||
run_html += t
|
||||
html_parts.append(f'<p>{run_html}</p>')
|
||||
|
||||
# Tables
|
||||
for table in doc.tables:
|
||||
html_parts.append('<table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%">')
|
||||
for i, row in enumerate(table.rows):
|
||||
html_parts.append('<tr>')
|
||||
tag = 'th' if i == 0 else 'td'
|
||||
for cell in row.cells:
|
||||
html_parts.append(f'<{tag}>{cell.text}</{tag}>')
|
||||
html_parts.append('</tr>')
|
||||
html_parts.append('</table>')
|
||||
|
||||
return '\n'.join(html_parts)
|
||||
|
||||
|
||||
def _convert_xlsx(filepath):
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(str(filepath), read_only=True, data_only=True)
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
rows = []
|
||||
for row in ws.iter_rows(max_row=500, values_only=True):
|
||||
rows.append([str(cell) if cell is not None else '' for cell in row])
|
||||
sheets.append({
|
||||
'name': ws.title,
|
||||
'rows': rows,
|
||||
})
|
||||
wb.close()
|
||||
return sheets
|
||||
|
||||
|
||||
def _convert_pptx(filepath):
|
||||
from pptx import Presentation
|
||||
prs = Presentation(str(filepath))
|
||||
slides = []
|
||||
for i, slide in enumerate(prs.slides):
|
||||
content_parts = []
|
||||
for shape in slide.shapes:
|
||||
if shape.has_text_frame:
|
||||
for para in shape.text_frame.paragraphs:
|
||||
text = para.text.strip()
|
||||
if text:
|
||||
content_parts.append(f'<p>{text}</p>')
|
||||
if shape.has_table:
|
||||
table_html = '<table border="1" cellpadding="4" style="border-collapse: collapse">'
|
||||
for row in shape.table.rows:
|
||||
table_html += '<tr>'
|
||||
for cell in row.cells:
|
||||
table_html += f'<td>{cell.text}</td>'
|
||||
table_html += '</tr>'
|
||||
table_html += '</table>'
|
||||
content_parts.append(table_html)
|
||||
|
||||
slides.append({
|
||||
'index': i,
|
||||
'html': '\n'.join(content_parts) if content_parts else '<p>(Leere Folie)</p>',
|
||||
})
|
||||
return slides
|
||||
@@ -0,0 +1,361 @@
|
||||
import base64
|
||||
|
||||
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.password_vault import PasswordFolder, PasswordEntry, PasswordShare
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# --- Folders ---
|
||||
|
||||
@api_bp.route('/passwords/folders', methods=['GET'])
|
||||
@token_required
|
||||
def list_password_folders():
|
||||
user = request.current_user
|
||||
own = PasswordFolder.query.filter_by(owner_id=user.id).all()
|
||||
|
||||
# Get shared folders
|
||||
shared_folder_shares = PasswordShare.query.filter_by(
|
||||
shared_with_id=user.id, shareable_type='folder'
|
||||
).all()
|
||||
shared_ids = [s.shareable_id for s in shared_folder_shares]
|
||||
shared = PasswordFolder.query.filter(PasswordFolder.id.in_(shared_ids)).all() if shared_ids else []
|
||||
|
||||
result = []
|
||||
for f in own:
|
||||
d = f.to_dict()
|
||||
d['permission'] = 'owner'
|
||||
result.append(d)
|
||||
for f in shared:
|
||||
d = f.to_dict()
|
||||
share = next((s for s in shared_folder_shares if s.shareable_id == f.id), None)
|
||||
d['permission'] = share.permission if share else 'read'
|
||||
d['owner_name'] = f.owner.username
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/folders', methods=['POST'])
|
||||
@token_required
|
||||
def create_password_folder():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name erforderlich'}), 400
|
||||
|
||||
folder = PasswordFolder(
|
||||
owner_id=user.id,
|
||||
parent_id=data.get('parent_id'),
|
||||
name=name,
|
||||
icon=data.get('icon'),
|
||||
)
|
||||
db.session.add(folder)
|
||||
db.session.commit()
|
||||
return jsonify(folder.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/passwords/folders/<int:folder_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_password_folder(folder_id):
|
||||
user = request.current_user
|
||||
folder = db.session.get(PasswordFolder, folder_id)
|
||||
if not folder or folder.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if 'name' in data:
|
||||
folder.name = data['name'].strip()
|
||||
if 'icon' in data:
|
||||
folder.icon = data['icon']
|
||||
if 'parent_id' in data:
|
||||
folder.parent_id = data['parent_id']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(folder.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/folders/<int:folder_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_password_folder(folder_id):
|
||||
user = request.current_user
|
||||
folder = db.session.get(PasswordFolder, folder_id)
|
||||
if not folder or folder.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(folder)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Ordner geloescht'}), 200
|
||||
|
||||
|
||||
# --- Entries ---
|
||||
|
||||
@api_bp.route('/passwords/entries', methods=['GET'])
|
||||
@token_required
|
||||
def list_password_entries():
|
||||
user = request.current_user
|
||||
folder_id = request.args.get('folder_id', None, type=int)
|
||||
category = request.args.get('category', None)
|
||||
|
||||
query = PasswordEntry.query.filter_by(user_id=user.id)
|
||||
if folder_id is not None:
|
||||
query = query.filter_by(folder_id=folder_id)
|
||||
if category:
|
||||
query = query.filter_by(category=category)
|
||||
|
||||
entries = query.order_by(PasswordEntry.created_at.desc()).all()
|
||||
|
||||
# Also get shared entries
|
||||
shared_entry_shares = PasswordShare.query.filter_by(
|
||||
shared_with_id=user.id, shareable_type='entry'
|
||||
).all()
|
||||
shared_ids = [s.shareable_id for s in shared_entry_shares]
|
||||
shared = PasswordEntry.query.filter(PasswordEntry.id.in_(shared_ids)).all() if shared_ids else []
|
||||
|
||||
result = [e.to_dict() for e in entries]
|
||||
for e in shared:
|
||||
d = e.to_dict()
|
||||
d['shared'] = True
|
||||
share = next((s for s in shared_entry_shares if s.shareable_id == e.id), None)
|
||||
d['permission'] = share.permission if share else 'read'
|
||||
result.append(d)
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/entries', methods=['POST'])
|
||||
@token_required
|
||||
def create_password_entry():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
|
||||
if 'title_encrypted' not in data or 'iv' not in data:
|
||||
return jsonify({'error': 'Verschluesselte Daten + IV erforderlich'}), 400
|
||||
|
||||
entry = PasswordEntry(
|
||||
user_id=user.id,
|
||||
folder_id=data.get('folder_id'),
|
||||
title_encrypted=base64.b64decode(data['title_encrypted']),
|
||||
url_encrypted=base64.b64decode(data['url_encrypted']) if data.get('url_encrypted') else None,
|
||||
username_encrypted=base64.b64decode(data['username_encrypted']) if data.get('username_encrypted') else None,
|
||||
password_encrypted=base64.b64decode(data['password_encrypted']) if data.get('password_encrypted') else None,
|
||||
notes_encrypted=base64.b64decode(data['notes_encrypted']) if data.get('notes_encrypted') else None,
|
||||
totp_secret_encrypted=base64.b64decode(data['totp_secret_encrypted']) if data.get('totp_secret_encrypted') else None,
|
||||
passkey_data_encrypted=base64.b64decode(data['passkey_data_encrypted']) if data.get('passkey_data_encrypted') else None,
|
||||
iv=base64.b64decode(data['iv']),
|
||||
category=data.get('category'),
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
return jsonify(entry.to_dict()), 201
|
||||
|
||||
|
||||
@api_bp.route('/passwords/entries/<int:entry_id>', methods=['PUT'])
|
||||
@token_required
|
||||
def update_password_entry(entry_id):
|
||||
user = request.current_user
|
||||
entry = db.session.get(PasswordEntry, entry_id)
|
||||
if not entry:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
# Check access
|
||||
if entry.user_id != user.id:
|
||||
share = PasswordShare.query.filter_by(
|
||||
shareable_type='entry', shareable_id=entry_id, shared_with_id=user.id
|
||||
).first()
|
||||
if not share or share.permission not in ('write', 'manage'):
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
for field in ['title_encrypted', 'url_encrypted', 'username_encrypted',
|
||||
'password_encrypted', 'notes_encrypted', 'totp_secret_encrypted',
|
||||
'passkey_data_encrypted', 'iv']:
|
||||
if field in data and data[field]:
|
||||
setattr(entry, field, base64.b64decode(data[field]))
|
||||
|
||||
if 'category' in data:
|
||||
entry.category = data['category']
|
||||
if 'folder_id' in data:
|
||||
entry.folder_id = data['folder_id']
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(entry.to_dict()), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/entries/<int:entry_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def delete_password_entry(entry_id):
|
||||
user = request.current_user
|
||||
entry = db.session.get(PasswordEntry, entry_id)
|
||||
if not entry:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
if entry.user_id != user.id:
|
||||
return jsonify({'error': 'Zugriff verweigert'}), 403
|
||||
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Eintrag geloescht'}), 200
|
||||
|
||||
|
||||
# --- Sharing ---
|
||||
|
||||
@api_bp.route('/passwords/share', methods=['POST'])
|
||||
@token_required
|
||||
def share_password():
|
||||
user = request.current_user
|
||||
data = request.get_json()
|
||||
|
||||
shareable_type = data.get('type') # 'entry' or 'folder'
|
||||
shareable_id = data.get('id')
|
||||
username = data.get('username', '').strip()
|
||||
permission = data.get('permission', 'read')
|
||||
|
||||
if shareable_type not in ('entry', 'folder'):
|
||||
return jsonify({'error': 'Typ muss "entry" oder "folder" sein'}), 400
|
||||
|
||||
if permission not in ('read', 'write', 'manage'):
|
||||
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
|
||||
|
||||
# Verify ownership
|
||||
if shareable_type == 'entry':
|
||||
obj = db.session.get(PasswordEntry, shareable_id)
|
||||
if not obj or obj.user_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
else:
|
||||
obj = db.session.get(PasswordFolder, shareable_id)
|
||||
if not obj or obj.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
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 = PasswordShare.query.filter_by(
|
||||
shareable_type=shareable_type, shareable_id=shareable_id,
|
||||
shared_with_id=target.id
|
||||
).first()
|
||||
if existing:
|
||||
existing.permission = permission
|
||||
else:
|
||||
share = PasswordShare(
|
||||
shareable_type=shareable_type,
|
||||
shareable_id=shareable_id,
|
||||
shared_by_id=user.id,
|
||||
shared_with_id=target.id,
|
||||
permission=permission,
|
||||
encrypted_key=base64.b64decode(data['encrypted_key']) if data.get('encrypted_key') else None,
|
||||
)
|
||||
db.session.add(share)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'message': f'Mit {username} geteilt'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/shares', methods=['GET'])
|
||||
@token_required
|
||||
def list_password_shares():
|
||||
user = request.current_user
|
||||
shareable_type = request.args.get('type')
|
||||
shareable_id = request.args.get('id', type=int)
|
||||
|
||||
query = PasswordShare.query.filter_by(shared_by_id=user.id)
|
||||
if shareable_type:
|
||||
query = query.filter_by(shareable_type=shareable_type)
|
||||
if shareable_id:
|
||||
query = query.filter_by(shareable_id=shareable_id)
|
||||
|
||||
shares = query.all()
|
||||
return jsonify([{
|
||||
'id': s.id,
|
||||
'type': s.shareable_type,
|
||||
'shareable_id': s.shareable_id,
|
||||
'shared_with': s.shared_with.username,
|
||||
'permission': s.permission,
|
||||
} for s in shares]), 200
|
||||
|
||||
|
||||
@api_bp.route('/passwords/shares/<int:share_id>', methods=['DELETE'])
|
||||
@token_required
|
||||
def remove_password_share(share_id):
|
||||
user = request.current_user
|
||||
share = db.session.get(PasswordShare, share_id)
|
||||
if not share or share.shared_by_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
db.session.delete(share)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Freigabe entfernt'}), 200
|
||||
|
||||
|
||||
# --- KeePass Import ---
|
||||
|
||||
@api_bp.route('/passwords/import/keepass', methods=['POST'])
|
||||
@token_required
|
||||
def import_keepass():
|
||||
user = request.current_user
|
||||
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'Keine Datei gesendet'}), 400
|
||||
|
||||
kdbx_file = request.files['file']
|
||||
kdbx_password = request.form.get('password', '')
|
||||
|
||||
if not kdbx_password:
|
||||
return jsonify({'error': 'KeePass-Passwort erforderlich'}), 400
|
||||
|
||||
try:
|
||||
from pykeepass import PyKeePass
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Save to temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.kdbx') as tmp:
|
||||
kdbx_file.save(tmp.name)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
kp = PyKeePass(tmp_path, password=kdbx_password)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
# Return entries as plaintext - frontend will encrypt them
|
||||
entries = []
|
||||
groups = []
|
||||
|
||||
for group in kp.groups:
|
||||
if group.name and group.name not in ('Root', 'Recycle Bin'):
|
||||
groups.append({
|
||||
'name': group.name,
|
||||
'path': '/'.join(g.name for g in group.path if g.name),
|
||||
'uuid': str(group.uuid),
|
||||
'parent_uuid': str(group.parentgroup.uuid) if group.parentgroup else None,
|
||||
})
|
||||
|
||||
for entry in kp.entries:
|
||||
if entry.title:
|
||||
group_path = '/'.join(g.name for g in entry.group.path if g.name) if entry.group else ''
|
||||
entries.append({
|
||||
'title': entry.title or '',
|
||||
'url': entry.url or '',
|
||||
'username': entry.username or '',
|
||||
'password': entry.password or '',
|
||||
'notes': entry.notes or '',
|
||||
'totp': entry.otp or '',
|
||||
'group': group_path,
|
||||
'group_uuid': str(entry.group.uuid) if entry.group else None,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'entries': entries,
|
||||
'groups': groups,
|
||||
'count': len(entries),
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Import fehlgeschlagen: {str(e)}'}), 400
|
||||
@@ -0,0 +1,111 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
from app.api import api_bp
|
||||
from app.api.auth import admin_required, token_required
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@api_bp.route('/users', methods=['GET'])
|
||||
@admin_required
|
||||
def list_users():
|
||||
users = User.query.order_by(User.created_at.desc()).all()
|
||||
return jsonify([u.to_dict(include_email=True) for u in users]), 200
|
||||
|
||||
|
||||
@api_bp.route('/users/<int:user_id>', methods=['GET'])
|
||||
@admin_required
|
||||
def get_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
return jsonify(user.to_dict(include_email=True)), 200
|
||||
|
||||
|
||||
@api_bp.route('/users/<int:user_id>', methods=['PUT'])
|
||||
@admin_required
|
||||
def update_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten gesendet'}), 400
|
||||
|
||||
if 'email' in data:
|
||||
email = data['email'].strip() or None
|
||||
if email and email != user.email:
|
||||
if User.query.filter_by(email=email).first():
|
||||
return jsonify({'error': 'Email bereits vergeben'}), 409
|
||||
user.email = email
|
||||
|
||||
if 'role' in data and data['role'] in ('admin', 'user'):
|
||||
# Prevent removing last admin
|
||||
if user.role == 'admin' and data['role'] == 'user':
|
||||
admin_count = User.query.filter_by(role='admin', is_active=True).count()
|
||||
if admin_count <= 1:
|
||||
return jsonify({'error': 'Letzter Admin kann nicht herabgestuft werden'}), 400
|
||||
user.role = data['role']
|
||||
|
||||
if 'is_active' in data:
|
||||
if user.id == request.current_user.id and not data['is_active']:
|
||||
return jsonify({'error': 'Eigenes Konto kann nicht deaktiviert werden'}), 400
|
||||
user.is_active = data['is_active']
|
||||
|
||||
if 'storage_quota_mb' in data:
|
||||
user.storage_quota_mb = max(0, int(data['storage_quota_mb']))
|
||||
|
||||
if 'password' in data and data['password']:
|
||||
if len(data['password']) < 8:
|
||||
return jsonify({'error': 'Passwort muss mindestens 8 Zeichen lang sein'}), 400
|
||||
user.set_password(data['password'])
|
||||
|
||||
db.session.commit()
|
||||
return jsonify(user.to_dict(include_email=True)), 200
|
||||
|
||||
|
||||
@api_bp.route('/users/<int:user_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
user = db.session.get(User, user_id)
|
||||
if not user:
|
||||
return jsonify({'error': 'Benutzer nicht gefunden'}), 404
|
||||
|
||||
if user.id == request.current_user.id:
|
||||
return jsonify({'error': 'Eigenes Konto kann nicht geloescht werden'}), 400
|
||||
|
||||
if user.role == 'admin':
|
||||
admin_count = User.query.filter_by(role='admin', is_active=True).count()
|
||||
if admin_count <= 1:
|
||||
return jsonify({'error': 'Letzter Admin kann nicht geloescht werden'}), 400
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Benutzer geloescht'}), 200
|
||||
|
||||
|
||||
@api_bp.route('/auth/change-password', methods=['POST'])
|
||||
@token_required
|
||||
def change_password():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Keine Daten gesendet'}), 400
|
||||
|
||||
current_password = data.get('current_password', '')
|
||||
new_password = data.get('new_password', '')
|
||||
|
||||
if not current_password or not new_password:
|
||||
return jsonify({'error': 'Aktuelles und neues Passwort erforderlich'}), 400
|
||||
|
||||
if len(new_password) < 8:
|
||||
return jsonify({'error': 'Neues Passwort muss mindestens 8 Zeichen lang sein'}), 400
|
||||
|
||||
user = request.current_user
|
||||
if not user.check_password(current_password):
|
||||
return jsonify({'error': 'Aktuelles Passwort falsch'}), 401
|
||||
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'message': 'Passwort geaendert'}), 200
|
||||
Reference in New Issue
Block a user