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:
Stefan Hacker
2026-04-11 14:53:28 +02:00
parent d4f7e90d0c
commit 62f550c373
56 changed files with 8047 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import os
from pathlib import Path
from flask import Flask, redirect, send_from_directory
from flask_cors import CORS
from app.config import Config
from app.extensions import db, bcrypt, migrate
def create_app(config_class=Config):
# Check if static frontend build exists (Docker production mode)
static_dir = Path(__file__).resolve().parent.parent / 'static'
if static_dir.exists():
app = Flask(__name__, static_folder=str(static_dir), static_url_path='')
else:
app = Flask(__name__)
app.config.from_object(config_class)
# Ensure data directories exist
Path(app.config['UPLOAD_PATH']).mkdir(parents=True, exist_ok=True)
db_dir = Path(app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')).parent
db_dir.mkdir(parents=True, exist_ok=True)
# Initialize extensions
db.init_app(app)
bcrypt.init_app(app)
migrate.init_app(app, db)
# CORS
CORS(app, resources={r'/api/*': {'origins': app.config['FRONTEND_URL']}},
supports_credentials=True)
# Register blueprints
from app.api import api_bp
app.register_blueprint(api_bp)
# Well-known URLs for CalDAV/CardDAV auto-discovery (iOS, DAVx5, etc.)
@app.route('/.well-known/caldav')
def wellknown_caldav():
return redirect('/dav/', code=301)
@app.route('/.well-known/carddav')
def wellknown_carddav():
return redirect('/dav/', code=301)
# iCal export (public, no auth)
@app.route('/ical/<token>')
def ical_export_route(token):
from app.api.calendar import ical_export as _ical_export
return _ical_export(token)
# Serve frontend SPA for all non-API routes (production/Docker)
if static_dir.exists():
@app.route('/')
def serve_index():
return send_from_directory(str(static_dir), 'index.html')
@app.errorhandler(404)
def serve_spa(e):
return send_from_directory(str(static_dir), 'index.html')
# Create tables
with app.app_context():
from app import models # noqa: F401
db.create_all()
# Enable WAL mode for SQLite
with db.engine.connect() as conn:
conn.execute(db.text('PRAGMA journal_mode=WAL'))
conn.commit()
return app
+5
View File
@@ -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
+208
View File
@@ -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
+397
View File
@@ -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)
+340
View File
@@ -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)
+489
View File
@@ -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
+571
View File
@@ -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
+170
View File
@@ -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
+361
View File
@@ -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
+111
View File
@@ -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
+34
View File
@@ -0,0 +1,34 @@
import os
from datetime import timedelta
from pathlib import Path
# Project root: backend/app/config.py -> backend/app -> backend -> project_root
basedir = Path(__file__).resolve().parent.parent.parent
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-me')
# Database - always resolve relative to project root
_db_default = str(basedir / 'data' / 'minicloud.db')
_db_env = os.environ.get('DATABASE_PATH', '')
_db_path = str(basedir / _db_env) if _db_env and not os.path.isabs(_db_env) else (_db_env or _db_default)
SQLALCHEMY_DATABASE_URI = f'sqlite:///{_db_path}'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# File uploads - always resolve relative to project root
_upload_env = os.environ.get('UPLOAD_PATH', '')
UPLOAD_PATH = str(basedir / _upload_env) if _upload_env and not os.path.isabs(_upload_env) else (_upload_env or str(basedir / 'data' / 'files'))
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_UPLOAD_SIZE_MB', 500)) * 1024 * 1024
# JWT
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'dev-jwt-secret-change-me')
JWT_ACCESS_TOKEN_EXPIRES = timedelta(
seconds=int(os.environ.get('JWT_ACCESS_TOKEN_EXPIRES', 900))
)
JWT_REFRESH_TOKEN_EXPIRES = timedelta(
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 604800))
)
# CORS
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000')
View File
+7
View File
@@ -0,0 +1,7 @@
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_migrate import Migrate
db = SQLAlchemy()
bcrypt = Bcrypt()
migrate = Migrate()
+15
View File
@@ -0,0 +1,15 @@
from app.models.user import User
from app.models.file import File, FilePermission, ShareLink
from app.models.calendar import Calendar, CalendarEvent, CalendarShare
from app.models.contact import AddressBook, Contact, AddressBookShare
from app.models.email_account import EmailAccount
from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare
__all__ = [
'User',
'File', 'FilePermission', 'ShareLink',
'Calendar', 'CalendarEvent', 'CalendarShare',
'AddressBook', 'Contact', 'AddressBookShare',
'EmailAccount',
'PasswordFolder', 'PasswordEntry', 'PasswordShare',
]
+79
View File
@@ -0,0 +1,79 @@
from datetime import datetime, timezone
from app.extensions import db
class Calendar(db.Model):
__tablename__ = 'calendars'
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
color = db.Column(db.String(7), default='#3788d8')
description = db.Column(db.Text, nullable=True)
ical_token = db.Column(db.String(64), unique=True, nullable=True, index=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
events = db.relationship('CalendarEvent', backref='calendar', lazy='dynamic',
cascade='all, delete-orphan')
shares = db.relationship('CalendarShare', backref='calendar', lazy='dynamic',
cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'owner_id': self.owner_id,
'name': self.name,
'color': self.color,
'description': self.description,
'ical_token': self.ical_token,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class CalendarEvent(db.Model):
__tablename__ = 'calendar_events'
id = db.Column(db.Integer, primary_key=True)
calendar_id = db.Column(db.Integer, db.ForeignKey('calendars.id'), nullable=False, index=True)
uid = db.Column(db.String(255), unique=True, nullable=False)
ical_data = db.Column(db.Text, nullable=False) # Full VCALENDAR component
summary = db.Column(db.String(500), nullable=True)
dtstart = db.Column(db.DateTime, nullable=True, index=True)
dtend = db.Column(db.DateTime, nullable=True)
all_day = db.Column(db.Boolean, default=False)
recurrence_rule = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self):
return {
'id': self.id,
'calendar_id': self.calendar_id,
'uid': self.uid,
'summary': self.summary,
'dtstart': self.dtstart.isoformat() if self.dtstart else None,
'dtend': self.dtend.isoformat() if self.dtend else None,
'all_day': self.all_day,
'recurrence_rule': self.recurrence_rule,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class CalendarShare(db.Model):
__tablename__ = 'calendar_shares'
id = db.Column(db.Integer, primary_key=True)
calendar_id = db.Column(db.Integer, db.ForeignKey('calendars.id'), nullable=False, index=True)
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
permission = db.Column(db.String(20), nullable=False, default='read') # 'read' or 'readwrite'
shared_with = db.relationship('User', backref='shared_calendars')
__table_args__ = (
db.UniqueConstraint('calendar_id', 'shared_with_id', name='uq_calendar_share'),
)
+73
View File
@@ -0,0 +1,73 @@
from datetime import datetime, timezone
from app.extensions import db
class AddressBook(db.Model):
__tablename__ = 'address_books'
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
contacts = db.relationship('Contact', backref='address_book', lazy='dynamic',
cascade='all, delete-orphan')
shares = db.relationship('AddressBookShare', backref='address_book', lazy='dynamic',
cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'owner_id': self.owner_id,
'name': self.name,
'description': self.description,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class Contact(db.Model):
__tablename__ = 'contacts'
id = db.Column(db.Integer, primary_key=True)
address_book_id = db.Column(db.Integer, db.ForeignKey('address_books.id'),
nullable=False, index=True)
uid = db.Column(db.String(255), unique=True, nullable=False)
vcard_data = db.Column(db.Text, nullable=False) # Full VCARD
display_name = db.Column(db.String(255), nullable=True, index=True)
email = db.Column(db.String(255), nullable=True)
phone = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self):
return {
'id': self.id,
'address_book_id': self.address_book_id,
'uid': self.uid,
'display_name': self.display_name,
'email': self.email,
'phone': self.phone,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class AddressBookShare(db.Model):
__tablename__ = 'address_book_shares'
id = db.Column(db.Integer, primary_key=True)
address_book_id = db.Column(db.Integer, db.ForeignKey('address_books.id'),
nullable=False, index=True)
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
permission = db.Column(db.String(20), nullable=False, default='read')
shared_with = db.relationship('User', backref='shared_address_books')
__table_args__ = (
db.UniqueConstraint('address_book_id', 'shared_with_id', name='uq_addressbook_share'),
)
+41
View File
@@ -0,0 +1,41 @@
from datetime import datetime, timezone
from app.extensions import db
class EmailAccount(db.Model):
__tablename__ = 'email_accounts'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
display_name = db.Column(db.String(100), nullable=False) # e.g. "Arbeit", "Privat"
email_address = db.Column(db.String(255), nullable=False)
imap_host = db.Column(db.String(255), nullable=False)
imap_port = db.Column(db.Integer, default=993)
imap_ssl = db.Column(db.Boolean, default=True)
smtp_host = db.Column(db.String(255), nullable=False)
smtp_port = db.Column(db.Integer, default=587)
smtp_ssl = db.Column(db.Boolean, default=True) # STARTTLS
username = db.Column(db.String(255), nullable=False)
password_encrypted = db.Column(db.LargeBinary, nullable=False)
is_default = db.Column(db.Boolean, default=False)
sort_order = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self):
return {
'id': self.id,
'display_name': self.display_name,
'email_address': self.email_address,
'imap_host': self.imap_host,
'imap_port': self.imap_port,
'imap_ssl': self.imap_ssl,
'smtp_host': self.smtp_host,
'smtp_port': self.smtp_port,
'smtp_ssl': self.smtp_ssl,
'username': self.username,
'is_default': self.is_default,
'sort_order': self.sort_order,
}
+82
View File
@@ -0,0 +1,82 @@
from datetime import datetime, timezone
from app.extensions import db
class File(db.Model):
__tablename__ = 'files'
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
parent_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=True, index=True)
name = db.Column(db.String(255), nullable=False)
is_folder = db.Column(db.Boolean, default=False, nullable=False)
mime_type = db.Column(db.String(255), nullable=True)
size = db.Column(db.BigInteger, default=0)
storage_path = db.Column(db.String(500), nullable=True) # UUID-based path on disk
checksum = db.Column(db.String(64), nullable=True) # SHA-256 for sync
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
# Relationships
children = db.relationship('File', backref=db.backref('parent', remote_side='File.id'),
lazy='dynamic')
permissions = db.relationship('FilePermission', backref='file', lazy='dynamic',
cascade='all, delete-orphan')
share_links = db.relationship('ShareLink', backref='file', lazy='dynamic',
cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'owner_id': self.owner_id,
'parent_id': self.parent_id,
'name': self.name,
'is_folder': self.is_folder,
'mime_type': self.mime_type,
'size': self.size,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class FilePermission(db.Model):
__tablename__ = 'file_permissions'
id = db.Column(db.Integer, primary_key=True)
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False, index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
permission = db.Column(db.String(20), nullable=False) # 'read', 'write', 'admin'
user = db.relationship('User', backref='file_permissions')
__table_args__ = (
db.UniqueConstraint('file_id', 'user_id', name='uq_file_user_permission'),
)
class ShareLink(db.Model):
__tablename__ = 'share_links'
id = db.Column(db.Integer, primary_key=True)
file_id = db.Column(db.Integer, db.ForeignKey('files.id'), nullable=False, index=True)
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=True)
expires_at = db.Column(db.DateTime, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
download_count = db.Column(db.Integer, default=0)
max_downloads = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
creator = db.relationship('User', backref='share_links')
def is_expired(self):
if self.expires_at is None:
return False
return datetime.now(timezone.utc) > self.expires_at
def is_download_limit_reached(self):
if self.max_downloads is None:
return False
return self.download_count >= self.max_downloads
+99
View File
@@ -0,0 +1,99 @@
from datetime import datetime, timezone
from app.extensions import db
class PasswordFolder(db.Model):
__tablename__ = 'password_folders'
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
parent_id = db.Column(db.Integer, db.ForeignKey('password_folders.id'), nullable=True,
index=True)
name = db.Column(db.String(255), nullable=False)
icon = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
children = db.relationship('PasswordFolder',
backref=db.backref('parent', remote_side='PasswordFolder.id'),
lazy='dynamic')
entries = db.relationship('PasswordEntry', backref='folder', lazy='dynamic',
cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'owner_id': self.owner_id,
'parent_id': self.parent_id,
'name': self.name,
'icon': self.icon,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class PasswordEntry(db.Model):
__tablename__ = 'password_entries'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
folder_id = db.Column(db.Integer, db.ForeignKey('password_folders.id'), nullable=True,
index=True)
# All sensitive fields are encrypted client-side (AES-256-GCM)
title_encrypted = db.Column(db.LargeBinary, nullable=False)
url_encrypted = db.Column(db.LargeBinary, nullable=True)
username_encrypted = db.Column(db.LargeBinary, nullable=True)
password_encrypted = db.Column(db.LargeBinary, nullable=True)
notes_encrypted = db.Column(db.LargeBinary, nullable=True)
totp_secret_encrypted = db.Column(db.LargeBinary, nullable=True)
passkey_data_encrypted = db.Column(db.LargeBinary, nullable=True)
# IV for each entry (needed for AES-GCM decryption)
iv = db.Column(db.LargeBinary, nullable=False)
category = db.Column(db.String(100), nullable=True) # Plaintext for filtering
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
user = db.relationship('User', backref='password_entries')
def to_dict(self):
import base64
return {
'id': self.id,
'folder_id': self.folder_id,
'title_encrypted': base64.b64encode(self.title_encrypted).decode() if self.title_encrypted else None,
'url_encrypted': base64.b64encode(self.url_encrypted).decode() if self.url_encrypted else None,
'username_encrypted': base64.b64encode(self.username_encrypted).decode() if self.username_encrypted else None,
'password_encrypted': base64.b64encode(self.password_encrypted).decode() if self.password_encrypted else None,
'notes_encrypted': base64.b64encode(self.notes_encrypted).decode() if self.notes_encrypted else None,
'totp_secret_encrypted': base64.b64encode(self.totp_secret_encrypted).decode() if self.totp_secret_encrypted else None,
'passkey_data_encrypted': base64.b64encode(self.passkey_data_encrypted).decode() if self.passkey_data_encrypted else None,
'iv': base64.b64encode(self.iv).decode() if self.iv else None,
'category': self.category,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class PasswordShare(db.Model):
__tablename__ = 'password_shares'
id = db.Column(db.Integer, primary_key=True)
shareable_type = db.Column(db.String(20), nullable=False) # 'entry' or 'folder'
shareable_id = db.Column(db.Integer, nullable=False)
shared_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
permission = db.Column(db.String(20), nullable=False, default='read') # 'read', 'write', 'manage'
# Re-encrypted data for the recipient (encrypted with recipient's public key)
encrypted_key = db.Column(db.LargeBinary, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
shared_by = db.relationship('User', foreign_keys=[shared_by_id], backref='password_shares_given')
shared_with = db.relationship('User', foreign_keys=[shared_with_id], backref='password_shares_received')
__table_args__ = (
db.UniqueConstraint('shareable_type', 'shareable_id', 'shared_with_id',
name='uq_password_share'),
db.Index('ix_password_shareable', 'shareable_type', 'shareable_id'),
)
+47
View File
@@ -0,0 +1,47 @@
from datetime import datetime, timezone
from app.extensions import db, bcrypt
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(255), unique=True, nullable=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), default='user', nullable=False) # 'admin' or 'user'
master_key_salt = db.Column(db.LargeBinary, nullable=True) # For password manager
is_active = db.Column(db.Boolean, default=True, nullable=False)
storage_quota_mb = db.Column(db.Integer, default=5120) # 5 GB default
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
# Relationships
files = db.relationship('File', backref='owner', lazy='dynamic',
foreign_keys='File.owner_id')
calendars = db.relationship('Calendar', backref='owner', lazy='dynamic')
address_books = db.relationship('AddressBook', backref='owner', lazy='dynamic')
email_accounts = db.relationship('EmailAccount', backref='user', lazy='dynamic',
order_by='EmailAccount.sort_order')
password_folders = db.relationship('PasswordFolder', backref='owner', lazy='dynamic')
def set_password(self, password):
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
return bcrypt.check_password_hash(self.password_hash, password)
def to_dict(self, include_email=False):
data = {
'id': self.id,
'username': self.username,
'role': self.role,
'is_active': self.is_active,
'storage_quota_mb': self.storage_quota_mb,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
if include_email:
data['email'] = self.email
return data
View File
+39
View File
@@ -0,0 +1,39 @@
"""Encryption/decryption for email passwords and other server-side secrets.
Uses AES-256-GCM with a key derived from the user's encryption key.
The encryption key is passed from the frontend via X-Encryption-Key header
and is itself derived from the user's login password using PBKDF2.
"""
import base64
import hashlib
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def _derive_key(user_key_b64: str) -> bytes:
"""Derive a 256-bit AES key from the user's base64-encoded key."""
try:
raw = base64.b64decode(user_key_b64)
except Exception:
raw = user_key_b64.encode('utf-8')
return hashlib.sha256(raw).digest()
def encrypt_field(plaintext: str, user_key_b64: str) -> bytes:
"""Encrypt a string field. Returns nonce + ciphertext as bytes."""
key = _derive_key(user_key_b64)
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
return nonce + ciphertext
def decrypt_field(encrypted: bytes, user_key_b64: str) -> str:
"""Decrypt a field. Input is nonce (12 bytes) + ciphertext."""
key = _derive_key(user_key_b64)
aesgcm = AESGCM(key)
nonce = encrypted[:12]
ciphertext = encrypted[12:]
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return plaintext.decode('utf-8')
View File
+31
View File
@@ -0,0 +1,31 @@
# Core
Flask==3.1.1
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.1.0
Flask-Bcrypt==1.0.1
Flask-CORS==5.0.1
PyJWT==2.9.0
gunicorn==23.0.0
python-dotenv==1.1.0
# CalDAV/CardDAV
Radicale==3.3.3
vobject==0.9.8
icalendar==6.1.2
# Email
imapclient==3.0.1
# Office Viewer
python-docx==1.1.2
openpyxl==3.1.5
python-pptx==1.0.2
# Crypto (Passwort-Manager + Email-Verschluesselung)
cryptography==44.0.3
# KeePass Import
pykeepass==4.1.0
# Utilities
Pillow==11.1.0
+19
View File
@@ -0,0 +1,19 @@
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env from project root
env_path = Path(__file__).resolve().parent.parent / '.env'
load_dotenv(env_path)
from app import create_app # noqa: E402
application = create_app()
if __name__ == '__main__':
application.run(
host='0.0.0.0',
port=int(os.environ.get('PORT', 5000)),
debug=True,
)