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
+28
View File
@@ -0,0 +1,28 @@
# Mini-Cloud Konfiguration
# Kopiere diese Datei nach .env und passe die Werte an
# Flask
SECRET_KEY=change-me-to-a-random-secret-key
FLASK_ENV=production
FLASK_DEBUG=0
# Datenbank
DATABASE_PATH=./data/minicloud.db
# Dateispeicher
UPLOAD_PATH=./data/files
# JWT
JWT_SECRET_KEY=change-me-to-another-random-secret-key
JWT_ACCESS_TOKEN_EXPIRES=900
JWT_REFRESH_TOKEN_EXPIRES=604800
# Server
HOST=0.0.0.0
PORT=5000
# Frontend URL (fuer CORS)
FRONTEND_URL=http://localhost:3010
# Max Upload-Groesse in MB
MAX_UPLOAD_SIZE_MB=500
+38
View File
@@ -0,0 +1,38 @@
# Stage 1: Build frontend
FROM node:22-slim AS frontend-build
WORKDIR /build
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Production
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt gunicorn
# Copy backend
COPY backend/ ./
# Copy frontend build
COPY --from=frontend-build /build/dist ./static
# Create data directory
RUN mkdir -p /app/data/files
# Environment
ENV FLASK_ENV=production
ENV DATABASE_PATH=/app/data/minicloud.db
ENV UPLOAD_PATH=/app/data/files
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "wsgi:application"]
+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,
)
+15
View File
@@ -0,0 +1,15 @@
services:
minicloud:
build: .
ports:
- "5000:5000"
volumes:
- ./data:/app/data
environment:
- SECRET_KEY=${SECRET_KEY:-change-me-to-a-random-secret-key}
- JWT_SECRET_KEY=${JWT_SECRET_KEY:-change-me-to-another-random-secret-key}
- DATABASE_PATH=/app/data/minicloud.db
- UPLOAD_PATH=/app/data/files
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:5000}
- MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-500}
restart: unless-stopped
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1650
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@primevue/themes": "^4.5.4",
"axios": "^1.15.0",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.5",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.4"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+83
View File
@@ -0,0 +1,83 @@
import axios from 'axios'
import { useAuthStore } from '../stores/auth'
import router from '../router'
const apiClient = axios.create({
baseURL: '/api',
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor: attach access token
apiClient.interceptors.request.use((config) => {
const auth = useAuthStore()
if (auth.accessToken) {
config.headers.Authorization = `Bearer ${auth.accessToken}`
}
return config
})
// Response interceptor: handle 401 with token refresh
let isRefreshing = false
let failedQueue = []
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error)
} else {
prom.resolve(token)
}
})
failedQueue = []
}
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/auth/login') {
const auth = useAuthStore()
auth.logout()
router.push('/login')
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`
return apiClient(originalRequest)
})
}
originalRequest._retry = true
isRefreshing = true
try {
const auth = useAuthStore()
const newToken = await auth.refreshToken()
processQueue(null, newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return apiClient(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
const auth = useAuthStore()
auth.logout()
router.push('/login')
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
export default apiClient
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+28
View File
@@ -0,0 +1,28 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import 'primeicons/primeicons.css'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
darkModeSelector: '.dark-mode',
}
}
})
app.use(ToastService)
app.use(ConfirmationService)
app.mount('#app')
+100
View File
@@ -0,0 +1,100 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('../views/LoginView.vue'),
meta: { guest: true },
},
{
path: '/register',
name: 'Register',
component: () => import('../views/RegisterView.vue'),
meta: { guest: true },
},
{
path: '/',
component: () => import('../views/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/files',
},
{
path: 'files/:folderId?',
name: 'Files',
component: () => import('../views/FilesView.vue'),
},
{
path: 'calendar',
name: 'Calendar',
component: () => import('../views/CalendarView.vue'),
},
{
path: 'contacts',
name: 'Contacts',
component: () => import('../views/ContactsView.vue'),
},
{
path: 'email',
name: 'Email',
component: () => import('../views/EmailView.vue'),
},
{
path: 'passwords',
name: 'Passwords',
component: () => import('../views/PasswordsView.vue'),
},
{
path: 'admin',
name: 'Admin',
component: () => import('../views/AdminView.vue'),
meta: { requiresAdmin: true },
},
{
path: 'settings',
name: 'Settings',
component: () => import('../views/SettingsView.vue'),
},
],
},
{
path: '/share/:token',
name: 'Share',
component: () => import('../views/ShareView.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(async (to, from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
// Try to refresh token
try {
await auth.refreshToken()
await auth.fetchMe()
} catch {
return next('/login')
}
}
if (to.meta.guest && auth.isAuthenticated) {
return next('/')
}
if (to.meta.requiresAdmin && !auth.isAdmin) {
return next('/')
}
next()
})
export default router
+72
View File
@@ -0,0 +1,72 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import apiClient from '../api/client'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const accessToken = ref(null)
const masterKeySalt = ref(null)
const isAuthenticated = computed(() => !!accessToken.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const hasEmailAccounts = computed(() => (user.value?.email_account_count || 0) > 0)
async function login(username, password) {
const response = await apiClient.post('/auth/login', { username, password })
user.value = response.data.user
accessToken.value = response.data.access_token
masterKeySalt.value = response.data.master_key_salt
return response.data
}
async function register(username, password, email) {
const payload = { username, password }
if (email) payload.email = email
const response = await apiClient.post('/auth/register', payload)
user.value = response.data.user
accessToken.value = response.data.access_token
return response.data
}
async function refreshToken() {
const response = await apiClient.post('/auth/refresh')
accessToken.value = response.data.access_token
return response.data.access_token
}
async function fetchMe() {
const response = await apiClient.get('/auth/me')
user.value = response.data
masterKeySalt.value = response.data.master_key_salt
return response.data
}
async function changePassword(currentPassword, newPassword) {
await apiClient.post('/auth/change-password', {
current_password: currentPassword,
new_password: newPassword,
})
}
function logout() {
apiClient.post('/auth/logout').catch(() => {})
user.value = null
accessToken.value = null
masterKeySalt.value = null
}
return {
user,
accessToken,
masterKeySalt,
isAuthenticated,
isAdmin,
hasEmailAccounts,
login,
register,
refreshToken,
fetchMe,
changePassword,
logout,
}
})
+84
View File
@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import apiClient from '../api/client'
export const useFilesStore = defineStore('files', () => {
const files = ref([])
const breadcrumb = ref([])
const currentParentId = ref(null)
const loading = ref(false)
async function loadFiles(parentId = null) {
loading.value = true
try {
currentParentId.value = parentId
const params = parentId ? { parent_id: parentId } : {}
const response = await apiClient.get('/files', { params })
files.value = response.data.files
breadcrumb.value = response.data.breadcrumb
} finally {
loading.value = false
}
}
async function createFolder(name, parentId = null) {
const response = await apiClient.post('/files/folder', {
name,
parent_id: parentId,
})
await loadFiles(parentId)
return response.data
}
async function uploadFile(file, parentId = null) {
const formData = new FormData()
formData.append('file', file)
if (parentId) formData.append('parent_id', parentId)
const response = await apiClient.post('/files/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
await loadFiles(parentId)
return response.data
}
async function deleteFile(fileId) {
await apiClient.delete(`/files/${fileId}`)
await loadFiles(currentParentId.value)
}
async function renameFile(fileId, newName) {
await apiClient.put(`/files/${fileId}`, { name: newName })
await loadFiles(currentParentId.value)
}
async function moveFile(fileId, newParentId) {
await apiClient.put(`/files/${fileId}`, { parent_id: newParentId })
await loadFiles(currentParentId.value)
}
async function createShareLink(fileId, options = {}) {
const response = await apiClient.post(`/files/${fileId}/share`, options)
return response.data
}
async function getShareLinks(fileId) {
const response = await apiClient.get(`/files/${fileId}/shares`)
return response.data
}
async function deleteShareLink(token) {
await apiClient.delete(`/share/${token}`)
}
function downloadUrl(fileId) {
return `/api/files/${fileId}/download`
}
return {
files, breadcrumb, currentParentId, loading,
loadFiles, createFolder, uploadFile, deleteFile,
renameFile, moveFile, createShareLink, getShareLinks,
deleteShareLink, downloadUrl,
}
})
+16
View File
@@ -0,0 +1,16 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
min-height: 100vh;
}
+75
View File
@@ -0,0 +1,75 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Administration</h2>
</div>
<div class="admin-section">
<h3>Benutzerverwaltung</h3>
<DataTable :value="users" :loading="loading" striped-rows>
<Column field="id" header="ID" style="width: 60px" />
<Column field="username" header="Benutzername" />
<Column field="email" header="E-Mail" />
<Column field="role" header="Rolle">
<template #body="{ data }">
<Tag :value="data.role" :severity="data.role === 'admin' ? 'danger' : 'info'" />
</template>
</Column>
<Column field="is_active" header="Aktiv">
<template #body="{ data }">
<Tag :value="data.is_active ? 'Ja' : 'Nein'" :severity="data.is_active ? 'success' : 'warn'" />
</template>
</Column>
<Column field="storage_quota_mb" header="Quota (MB)" />
<Column header="Aktionen" style="width: 100px">
<template #body="{ data }">
<Button
icon="pi pi-pencil"
text
rounded
size="small"
@click="editUser(data)"
/>
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import apiClient from '../api/client'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
const users = ref([])
const loading = ref(false)
async function loadUsers() {
loading.value = true
try {
const response = await apiClient.get('/users')
users.value = response.data
} catch (err) {
console.error('Fehler beim Laden der Benutzer:', err)
} finally {
loading.value = false
}
}
function editUser(user) {
// TODO: Edit dialog in Phase 8
console.log('Edit user:', user)
}
onMounted(loadUsers)
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header h2 { margin: 0 0 1.5rem; }
.admin-section h3 { margin: 0 0 1rem; font-size: 1.125rem; }
</style>
+166
View File
@@ -0,0 +1,166 @@
<template>
<div class="app-layout">
<aside class="sidebar">
<div class="sidebar-header">
<i class="pi pi-cloud"></i>
<span class="sidebar-title">Mini-Cloud</span>
</div>
<nav class="sidebar-nav">
<router-link to="/files" class="nav-item" active-class="active">
<i class="pi pi-folder"></i>
<span>Dateien</span>
</router-link>
<router-link to="/calendar" class="nav-item" active-class="active">
<i class="pi pi-calendar"></i>
<span>Kalender</span>
</router-link>
<router-link to="/contacts" class="nav-item" active-class="active">
<i class="pi pi-users"></i>
<span>Kontakte</span>
</router-link>
<router-link
v-if="auth.hasEmailAccounts"
to="/email"
class="nav-item"
active-class="active"
>
<i class="pi pi-envelope"></i>
<span>E-Mail</span>
</router-link>
<router-link to="/passwords" class="nav-item" active-class="active">
<i class="pi pi-key"></i>
<span>Passwoerter</span>
</router-link>
</nav>
<div class="sidebar-footer">
<router-link
v-if="auth.isAdmin"
to="/admin"
class="nav-item"
active-class="active"
>
<i class="pi pi-cog"></i>
<span>Admin</span>
</router-link>
<router-link to="/settings" class="nav-item" active-class="active">
<i class="pi pi-user"></i>
<span>Einstellungen</span>
</router-link>
<a class="nav-item" @click="handleLogout">
<i class="pi pi-sign-out"></i>
<span>Abmelden</span>
</a>
</div>
</aside>
<main class="main-content">
<router-view />
</main>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const auth = useAuthStore()
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.app-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: var(--p-surface-0);
border-right: 1px solid var(--p-surface-200);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 1rem;
border-bottom: 1px solid var(--p-surface-200);
}
.sidebar-header i {
font-size: 1.5rem;
color: var(--p-primary-color);
}
.sidebar-title {
font-size: 1.125rem;
font-weight: 600;
}
.sidebar-nav {
flex: 1;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.sidebar-footer {
padding: 0.5rem;
border-top: 1px solid var(--p-surface-200);
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 8px;
color: var(--p-text-color);
text-decoration: none;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.15s;
}
.nav-item:hover {
background: var(--p-surface-100);
}
.nav-item.active {
background: var(--p-primary-50);
color: var(--p-primary-color);
font-weight: 500;
}
.nav-item i {
font-size: 1.125rem;
width: 1.25rem;
text-align: center;
}
.main-content {
flex: 1;
background: var(--p-surface-50);
overflow-y: auto;
}
</style>
+376
View File
@@ -0,0 +1,376 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Kalender</h2>
<div class="header-actions">
<Button icon="pi pi-plus" label="Neuer Kalender" size="small" outlined @click="showNewCalendar = true" />
<Button icon="pi pi-plus" label="Neues Event" size="small" @click="openNewEvent" />
</div>
</div>
<div class="calendar-layout">
<aside class="calendar-sidebar">
<div v-for="cal in calendars" :key="cal.id" class="calendar-item">
<div class="calendar-color" :style="{ background: cal.color }"></div>
<span>{{ cal.name }}</span>
<span v-if="cal.owner_name" class="shared-label">({{ cal.owner_name }})</span>
<Button icon="pi pi-ellipsis-v" text size="small" @click="openCalendarMenu(cal, $event)" />
</div>
</aside>
<div class="calendar-main">
<div class="cal-nav">
<Button icon="pi pi-chevron-left" text @click="changeMonth(-1)" />
<h3>{{ currentMonthLabel }}</h3>
<Button icon="pi pi-chevron-right" text @click="changeMonth(1)" />
<Button label="Heute" text size="small" @click="goToday" />
</div>
<div class="cal-grid">
<div class="cal-header" v-for="day in weekDays" :key="day">{{ day }}</div>
<div
v-for="(cell, i) in calendarCells"
:key="i"
class="cal-cell"
:class="{ 'other-month': !cell.currentMonth, 'today': cell.isToday }"
@click="openNewEventOnDate(cell.date)"
>
<span class="cell-day">{{ cell.day }}</span>
<div v-for="evt in cell.events" :key="evt.id" class="cell-event"
:style="{ background: evt.color }" @click.stop="openEditEvent(evt)">
{{ evt.summary }}
</div>
</div>
</div>
</div>
</div>
<!-- New Calendar Dialog -->
<Dialog v-model:visible="showNewCalendar" header="Neuer Kalender" modal :style="{ width: '400px' }">
<div class="field">
<label>Name</label>
<InputText v-model="newCalName" fluid autofocus />
</div>
<div class="field">
<label>Farbe</label>
<InputText v-model="newCalColor" type="color" style="width: 60px; height: 36px" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewCalendar = false" />
<Button label="Erstellen" @click="createCalendar" />
</template>
</Dialog>
<!-- Event Dialog -->
<Dialog v-model:visible="showEventDialog" :header="editingEvent ? 'Event bearbeiten' : 'Neues Event'" modal :style="{ width: '500px' }">
<div class="field">
<label>Titel</label>
<InputText v-model="eventForm.summary" fluid autofocus />
</div>
<div class="field">
<label>Kalender</label>
<Select v-model="eventForm.calendar_id" :options="ownCalendars" optionLabel="name" optionValue="id" fluid />
</div>
<div class="field-row">
<div class="field">
<label>Start</label>
<InputText v-model="eventForm.dtstart" type="datetime-local" fluid />
</div>
<div class="field">
<label>Ende</label>
<InputText v-model="eventForm.dtend" type="datetime-local" fluid />
</div>
</div>
<div class="field">
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
</div>
<template #footer>
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="deleteEvent" />
<Button label="Abbrechen" text @click="showEventDialog = false" />
<Button :label="editingEvent ? 'Speichern' : 'Erstellen'" @click="saveEvent" />
</template>
</Dialog>
<!-- Calendar Context Menu -->
<Dialog v-model:visible="showCalMenu" header="Kalender-Optionen" modal :style="{ width: '400px' }">
<div v-if="selectedCal" class="cal-menu-content">
<p><strong>{{ selectedCal.name }}</strong></p>
<div class="field">
<label>Mit Benutzer teilen</label>
<div class="share-row">
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="shareCalendar" />
</div>
</div>
<div v-if="selectedCal.permission === 'owner'" class="field">
<Button label="iCal-Link generieren" icon="pi pi-link" outlined size="small" @click="generateIcalLink" />
<div v-if="icalUrl" class="ical-url">
<code>{{ fullIcalUrl }}</code>
<Button icon="pi pi-copy" text size="small" @click="copyIcal" />
</div>
</div>
<Button v-if="selectedCal.permission === 'owner'" label="Kalender loeschen"
severity="danger" text size="small" @click="deleteCalendar" />
</div>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Select from 'primevue/select'
const toast = useToast()
const calendars = ref([])
const allEvents = ref([])
const currentDate = ref(new Date())
const showNewCalendar = ref(false)
const newCalName = ref('')
const newCalColor = ref('#3788d8')
const showEventDialog = ref(false)
const editingEvent = ref(null)
const eventForm = ref({ summary: '', calendar_id: null, dtstart: '', dtend: '', all_day: false })
const showCalMenu = ref(false)
const selectedCal = ref(null)
const shareUsername = ref('')
const sharePermission = ref('read')
const icalUrl = ref('')
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const ownCalendars = computed(() => calendars.value.filter(c => c.permission === 'owner'))
const fullIcalUrl = computed(() => icalUrl.value ? `${window.location.origin}${icalUrl.value}` : '')
const currentMonthLabel = computed(() => {
return currentDate.value.toLocaleString('de-DE', { month: 'long', year: 'numeric' })
})
const calendarCells = computed(() => {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
const today = new Date()
let startDay = (firstDay.getDay() + 6) % 7
const cells = []
for (let i = startDay - 1; i >= 0; i--) {
const d = new Date(year, month, -i)
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
}
for (let d = 1; d <= lastDay.getDate(); d++) {
const date = new Date(year, month, d)
const isToday = date.toDateString() === today.toDateString()
const dayEvents = allEvents.value.filter(e => {
const start = new Date(e.dtstart)
return start.getFullYear() === year && start.getMonth() === month && start.getDate() === d
})
cells.push({ date, day: d, currentMonth: true, isToday, events: dayEvents })
}
while (cells.length < 42) {
const d = new Date(year, month + 1, cells.length - startDay - lastDay.getDate() + 1)
cells.push({ date: d, day: d.getDate(), currentMonth: false, isToday: false, events: [] })
}
return cells
})
function changeMonth(delta) {
const d = new Date(currentDate.value)
d.setMonth(d.getMonth() + delta)
currentDate.value = d
loadEvents()
}
function goToday() {
currentDate.value = new Date()
loadEvents()
}
async function loadCalendars() {
const res = await apiClient.get('/calendars')
calendars.value = res.data
if (!calendars.value.length) {
await apiClient.post('/calendars', { name: 'Mein Kalender', color: '#3788d8' })
const res2 = await apiClient.get('/calendars')
calendars.value = res2.data
}
}
async function loadEvents() {
allEvents.value = []
for (const cal of calendars.value) {
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth()
const start = new Date(year, month - 1, 1).toISOString()
const end = new Date(year, month + 2, 0).toISOString()
try {
const res = await apiClient.get(`/calendars/${cal.id}/events`, { params: { start, end } })
allEvents.value.push(...res.data.map(e => ({ ...e, color: cal.color, calendarName: cal.name })))
} catch { /* skip */ }
}
}
async function createCalendar() {
if (!newCalName.value.trim()) return
await apiClient.post('/calendars', { name: newCalName.value.trim(), color: newCalColor.value })
showNewCalendar.value = false
newCalName.value = ''
await loadCalendars()
}
function openNewEvent() {
editingEvent.value = null
const now = new Date()
const later = new Date(now.getTime() + 3600000)
eventForm.value = {
summary: '',
calendar_id: ownCalendars.value[0]?.id,
dtstart: toLocalISO(now),
dtend: toLocalISO(later),
all_day: false,
}
showEventDialog.value = true
}
function openNewEventOnDate(date) {
editingEvent.value = null
const start = new Date(date); start.setHours(9, 0)
const end = new Date(date); end.setHours(10, 0)
eventForm.value = {
summary: '',
calendar_id: ownCalendars.value[0]?.id,
dtstart: toLocalISO(start),
dtend: toLocalISO(end),
all_day: false,
}
showEventDialog.value = true
}
function openEditEvent(evt) {
editingEvent.value = evt
eventForm.value = {
summary: evt.summary,
calendar_id: evt.calendar_id,
dtstart: toLocalISO(new Date(evt.dtstart)),
dtend: toLocalISO(new Date(evt.dtend)),
all_day: evt.all_day,
}
showEventDialog.value = true
}
async function saveEvent() {
if (!eventForm.value.summary.trim()) return
const payload = { ...eventForm.value }
if (editingEvent.value) {
await apiClient.put(`/events/${editingEvent.value.id}`, payload)
} else {
await apiClient.post(`/calendars/${payload.calendar_id}/events`, payload)
}
showEventDialog.value = false
await loadEvents()
}
async function deleteEvent() {
if (!editingEvent.value) return
await apiClient.delete(`/events/${editingEvent.value.id}`)
showEventDialog.value = false
await loadEvents()
}
function openCalendarMenu(cal) {
selectedCal.value = cal
icalUrl.value = ''
showCalMenu.value = true
}
async function shareCalendar() {
if (!shareUsername.value.trim() || !selectedCal.value) return
try {
await apiClient.post(`/calendars/${selectedCal.value.id}/share`, {
username: shareUsername.value.trim(), permission: sharePermission.value,
})
toast.add({ severity: 'success', summary: 'Kalender geteilt', life: 3000 })
shareUsername.value = ''
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function generateIcalLink() {
if (!selectedCal.value) return
const res = await apiClient.post(`/calendars/${selectedCal.value.id}/ical-link`)
icalUrl.value = res.data.ical_url
}
function copyIcal() {
navigator.clipboard.writeText(fullIcalUrl.value)
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
}
async function deleteCalendar() {
if (!selectedCal.value) return
await apiClient.delete(`/calendars/${selectedCal.value.id}`)
showCalMenu.value = false
await loadCalendars()
await loadEvents()
}
function toLocalISO(date) {
const pad = n => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
onMounted(async () => {
await loadCalendars()
await loadEvents()
})
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; }
.calendar-layout { display: flex; gap: 1rem; }
.calendar-sidebar { width: 220px; flex-shrink: 0; }
.calendar-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
.calendar-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.calendar-main { flex: 1; }
.cal-nav { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
.cal-nav h3 { margin: 0; min-width: 180px; text-align: center; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); border: 1px solid var(--p-surface-200); }
.cal-header { padding: 0.5rem; text-align: center; font-weight: 600; font-size: 0.8rem; background: var(--p-surface-100); border-bottom: 1px solid var(--p-surface-200); }
.cal-cell { min-height: 80px; padding: 0.25rem; border: 1px solid var(--p-surface-100); cursor: pointer; font-size: 0.8rem; }
.cal-cell:hover { background: var(--p-surface-50); }
.cal-cell.other-month { opacity: 0.4; }
.cal-cell.today { background: var(--p-primary-50); }
.cell-day { font-weight: 500; font-size: 0.75rem; }
.cell-event { font-size: 0.7rem; padding: 1px 4px; border-radius: 3px; color: white; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.field-row { display: flex; gap: 1rem; }
.field-row .field { flex: 1; }
.share-row { display: flex; gap: 0.5rem; align-items: flex-start; }
.ical-url { margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
.ical-url code { font-size: 0.75rem; word-break: break-all; }
.cal-menu-content p { margin: 0 0 1rem; }
</style>
+206
View File
@@ -0,0 +1,206 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Kontakte</h2>
<div class="header-actions">
<Button icon="pi pi-book" label="Neues Adressbuch" size="small" outlined @click="showNewBook = true" />
<Button icon="pi pi-user-plus" label="Neuer Kontakt" size="small" @click="openNewContact" />
</div>
</div>
<div class="contacts-layout">
<aside class="books-sidebar">
<div v-for="book in addressBooks" :key="book.id"
class="book-item" :class="{ active: selectedBookId === book.id }"
@click="selectBook(book.id)">
<i class="pi pi-book"></i>
<span>{{ book.name }}</span>
<span class="count">{{ book.contact_count }}</span>
</div>
</aside>
<div class="contacts-main">
<div class="search-bar">
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="loadContacts" />
</div>
<DataTable :value="contacts" :loading="loading" striped-rows @row-click="openEditContact">
<template #empty><p class="empty">Keine Kontakte</p></template>
<Column field="display_name" header="Name" sortable />
<Column field="email" header="E-Mail" sortable />
<Column field="phone" header="Telefon" />
<Column header="" style="width: 80px">
<template #body="{ data }">
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="deleteContact(data)" />
</template>
</Column>
</DataTable>
</div>
</div>
<!-- New Address Book -->
<Dialog v-model:visible="showNewBook" header="Neues Adressbuch" modal :style="{ width: '400px' }">
<div class="field">
<label>Name</label>
<InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewBook = false" />
<Button label="Erstellen" @click="createBook" />
</template>
</Dialog>
<!-- Contact Form -->
<Dialog v-model:visible="showContactForm" :header="editingContact ? 'Kontakt bearbeiten' : 'Neuer Kontakt'" modal :style="{ width: '500px' }">
<div class="field">
<label>Name</label>
<InputText v-model="contactForm.display_name" fluid autofocus />
</div>
<div class="field">
<label>E-Mail</label>
<InputText v-model="contactForm.email" type="email" fluid />
</div>
<div class="field">
<label>Telefon</label>
<InputText v-model="contactForm.phone" fluid />
</div>
<div class="field">
<label>Organisation</label>
<InputText v-model="contactForm.organization" fluid />
</div>
<div class="field">
<label>Notizen</label>
<Textarea v-model="contactForm.notes" rows="3" fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showContactForm = false" />
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
const toast = useToast()
const addressBooks = ref([])
const contacts = ref([])
const selectedBookId = ref(null)
const loading = ref(false)
const searchQuery = ref('')
const showNewBook = ref(false)
const newBookName = ref('')
const showContactForm = ref(false)
const editingContact = ref(null)
const contactForm = ref({ display_name: '', email: '', phone: '', organization: '', notes: '' })
async function loadBooks() {
const res = await apiClient.get('/addressbooks')
addressBooks.value = res.data
if (addressBooks.value.length && !selectedBookId.value) {
selectedBookId.value = addressBooks.value[0].id
await loadContacts()
}
if (!addressBooks.value.length) {
await apiClient.post('/addressbooks', { name: 'Kontakte' })
await loadBooks()
}
}
async function loadContacts() {
if (!selectedBookId.value) return
loading.value = true
try {
const params = searchQuery.value ? { search: searchQuery.value } : {}
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`, { params })
contacts.value = res.data
} finally {
loading.value = false
}
}
function selectBook(id) {
selectedBookId.value = id
loadContacts()
}
async function createBook() {
if (!newBookName.value.trim()) return
await apiClient.post('/addressbooks', { name: newBookName.value.trim() })
showNewBook.value = false
newBookName.value = ''
await loadBooks()
}
function openNewContact() {
editingContact.value = null
contactForm.value = { display_name: '', email: '', phone: '', organization: '', notes: '' }
showContactForm.value = true
}
function openEditContact(event) {
const c = event.data
editingContact.value = c
contactForm.value = {
display_name: c.display_name || '',
email: c.email || '',
phone: c.phone || '',
organization: '',
notes: '',
}
showContactForm.value = true
}
async function saveContact() {
if (!contactForm.value.display_name.trim()) return
try {
if (editingContact.value) {
await apiClient.put(`/contacts/${editingContact.value.id}`, contactForm.value)
} else {
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, contactForm.value)
}
showContactForm.value = false
await loadContacts()
await loadBooks()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function deleteContact(contact) {
await apiClient.delete(`/contacts/${contact.id}`)
await loadContacts()
await loadBooks()
}
onMounted(loadBooks)
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; }
.contacts-layout { display: flex; gap: 1rem; }
.books-sidebar { width: 220px; flex-shrink: 0; }
.book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
.book-item:hover { background: var(--p-surface-100); }
.book-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
.book-item .count { margin-left: auto; color: var(--p-text-muted-color); font-size: 0.75rem; }
.contacts-main { flex: 1; }
.search-bar { margin-bottom: 1rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
</style>
+299
View File
@@ -0,0 +1,299 @@
<template>
<div class="view-container email-view">
<div class="email-layout">
<!-- Folder sidebar -->
<aside class="email-sidebar">
<div v-for="account in accounts" :key="account.id" class="account-group">
<div class="account-header">
<i class="pi pi-envelope"></i>
<span>{{ account.display_name }}</span>
</div>
<div v-for="folder in account.folders || []" :key="folder.name"
class="folder-item" :class="{ active: activeFolder === `${account.id}:${folder.name}` }"
@click="selectFolder(account, folder)">
<i :class="folderIcon(folder.name)"></i>
<span>{{ folder.name }}</span>
</div>
</div>
<Button icon="pi pi-cog" label="Konten verwalten" text size="small" class="manage-btn"
@click="$router.push('/settings')" />
</aside>
<!-- Message list -->
<div class="message-list-panel">
<div class="list-header">
<span class="folder-title">{{ currentFolderName }}</span>
<Button icon="pi pi-refresh" text size="small" @click="loadMessages" />
<Button icon="pi pi-pencil" label="Neue E-Mail" size="small" @click="openCompose" />
</div>
<div v-if="loadingMessages" class="loading-center">
<i class="pi pi-spin pi-spinner"></i>
</div>
<div v-else class="message-list">
<div v-for="msg in messages" :key="msg.uid"
class="message-item" :class="{ unread: !msg.seen, active: selectedMessage?.uid === msg.uid }"
@click="selectMessage(msg)">
<div class="msg-from">{{ msg.from }}</div>
<div class="msg-subject">{{ msg.subject || '(Kein Betreff)' }}</div>
<div class="msg-date">{{ formatDate(msg.date) }}</div>
</div>
<div v-if="!messages.length" class="empty">Keine Nachrichten</div>
</div>
</div>
<!-- Message view -->
<div class="message-view-panel">
<div v-if="selectedMessage && messageDetail">
<div class="msg-header">
<h3>{{ messageDetail.subject }}</h3>
<div class="msg-meta">
<div><strong>Von:</strong> {{ messageDetail.from }}</div>
<div><strong>An:</strong> {{ messageDetail.to }}</div>
<div v-if="messageDetail.cc"><strong>CC:</strong> {{ messageDetail.cc }}</div>
<div><strong>Datum:</strong> {{ messageDetail.date }}</div>
</div>
<div class="msg-actions">
<Button icon="pi pi-reply" label="Antworten" size="small" outlined @click="replyTo" />
<Button icon="pi pi-trash" size="small" severity="danger" text @click="deleteMessage" />
</div>
</div>
<div v-if="messageDetail.html_body" class="msg-body" v-html="messageDetail.html_body"></div>
<pre v-else-if="messageDetail.text_body" class="msg-body-text">{{ messageDetail.text_body }}</pre>
<div v-if="messageDetail.attachments?.length" class="msg-attachments">
<strong>Anhaenge:</strong>
<span v-for="a in messageDetail.attachments" :key="a.filename" class="attachment">
<i class="pi pi-paperclip"></i> {{ a.filename }}
</span>
</div>
</div>
<div v-else class="empty-message">
<i class="pi pi-envelope" style="font-size: 2rem; color: var(--p-surface-400)"></i>
<p>Nachricht auswaehlen</p>
</div>
</div>
</div>
<!-- Compose Dialog -->
<Dialog v-model:visible="showCompose" header="Neue E-Mail" modal :style="{ width: '700px' }">
<div v-if="accounts.length > 1" class="field">
<label>Von</label>
<Select v-model="composeForm.account_id" :options="accounts" optionLabel="email_address" optionValue="id" fluid />
</div>
<div class="field">
<label>An</label>
<InputText v-model="composeForm.to" fluid />
</div>
<div class="field">
<label>CC</label>
<InputText v-model="composeForm.cc" fluid />
</div>
<div class="field">
<label>Betreff</label>
<InputText v-model="composeForm.subject" fluid />
</div>
<div class="field">
<label>Nachricht</label>
<Textarea v-model="composeForm.body_text" rows="12" fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showCompose = false" />
<Button label="Senden" icon="pi pi-send" @click="sendEmail" :loading="sending" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
const toast = useToast()
const auth = useAuthStore()
const accounts = ref([])
const messages = ref([])
const selectedMessage = ref(null)
const messageDetail = ref(null)
const activeFolder = ref('')
const currentFolderName = ref('INBOX')
const currentAccount = ref(null)
const loadingMessages = ref(false)
const showCompose = ref(false)
const sending = ref(false)
const composeForm = ref({ account_id: null, to: '', cc: '', subject: '', body_text: '' })
function getEncKey() { return auth.masterKeySalt || '' }
function folderIcon(name) {
const n = name.toLowerCase()
if (n === 'inbox') return 'pi pi-inbox'
if (n.includes('sent') || n.includes('gesendet')) return 'pi pi-send'
if (n.includes('draft') || n.includes('entwu')) return 'pi pi-file-edit'
if (n.includes('trash') || n.includes('papier') || n.includes('gelöscht')) return 'pi pi-trash'
if (n.includes('spam') || n.includes('junk')) return 'pi pi-ban'
return 'pi pi-folder'
}
function formatDate(dateStr) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return dateStr }
}
async function loadAccounts() {
const res = await apiClient.get('/email/accounts')
accounts.value = res.data
for (const acc of accounts.value) {
try {
const fRes = await apiClient.get(`/email/accounts/${acc.id}/folders`, {
headers: { 'X-Encryption-Key': getEncKey() }
})
acc.folders = fRes.data
} catch { acc.folders = [{ name: 'INBOX', flags: [], delimiter: '/' }] }
}
if (accounts.value.length) {
selectFolder(accounts.value[0], { name: 'INBOX' })
}
}
function selectFolder(account, folder) {
currentAccount.value = account
activeFolder.value = `${account.id}:${folder.name}`
currentFolderName.value = `${account.display_name} - ${folder.name}`
loadMessages()
}
async function loadMessages() {
if (!currentAccount.value) return
loadingMessages.value = true
selectedMessage.value = null
messageDetail.value = null
try {
const folder = activeFolder.value.split(':')[1]
const res = await apiClient.get(
`/email/accounts/${currentAccount.value.id}/folders/${encodeURIComponent(folder)}/messages`,
{ headers: { 'X-Encryption-Key': getEncKey() } }
)
messages.value = res.data.messages
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
messages.value = []
} finally {
loadingMessages.value = false
}
}
async function selectMessage(msg) {
selectedMessage.value = msg
try {
const folder = activeFolder.value.split(':')[1]
const res = await apiClient.get(
`/email/accounts/${currentAccount.value.id}/messages/${msg.uid}`,
{ params: { folder }, headers: { 'X-Encryption-Key': getEncKey() } }
)
messageDetail.value = res.data
msg.seen = true
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function openCompose() {
composeForm.value = {
account_id: currentAccount.value?.id || accounts.value[0]?.id,
to: '', cc: '', subject: '', body_text: '',
}
showCompose.value = true
}
function replyTo() {
if (!messageDetail.value) return
composeForm.value = {
account_id: currentAccount.value?.id,
to: messageDetail.value.from,
cc: '',
subject: `Re: ${messageDetail.value.subject}`,
body_text: `\n\n--- Urspruengliche Nachricht ---\n${messageDetail.value.text_body || ''}`,
}
showCompose.value = true
}
async function sendEmail() {
sending.value = true
try {
await apiClient.post('/email/send', composeForm.value, {
headers: { 'X-Encryption-Key': getEncKey() }
})
showCompose.value = false
toast.add({ severity: 'success', summary: 'E-Mail gesendet', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Sendefehler', detail: err.response?.data?.error, life: 5000 })
} finally {
sending.value = false
}
}
async function deleteMessage() {
if (!selectedMessage.value || !currentAccount.value) return
const folder = activeFolder.value.split(':')[1]
try {
await apiClient.delete(
`/email/accounts/${currentAccount.value.id}/messages/${selectedMessage.value.uid}`,
{ params: { folder }, headers: { 'X-Encryption-Key': getEncKey() } }
)
selectedMessage.value = null
messageDetail.value = null
await loadMessages()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
onMounted(loadAccounts)
</script>
<style scoped>
.view-container { padding: 0; height: calc(100vh - 0px); }
.email-layout { display: flex; height: 100%; }
.email-sidebar { width: 220px; border-right: 1px solid var(--p-surface-200); overflow-y: auto; padding: 0.5rem; flex-shrink: 0; }
.account-group { margin-bottom: 0.5rem; }
.account-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; font-weight: 600; font-size: 0.85rem; }
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem 0.375rem 1.5rem; font-size: 0.825rem; cursor: pointer; border-radius: 4px; }
.folder-item:hover { background: var(--p-surface-100); }
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
.manage-btn { margin-top: 0.5rem; }
.message-list-panel { width: 350px; border-right: 1px solid var(--p-surface-200); display: flex; flex-direction: column; flex-shrink: 0; }
.list-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem; border-bottom: 1px solid var(--p-surface-200); }
.folder-title { font-weight: 600; font-size: 0.875rem; flex: 1; }
.message-list { flex: 1; overflow-y: auto; }
.message-item { padding: 0.625rem 0.75rem; border-bottom: 1px solid var(--p-surface-100); cursor: pointer; }
.message-item:hover { background: var(--p-surface-50); }
.message-item.active { background: var(--p-primary-50); }
.message-item.unread { font-weight: 600; }
.msg-from { font-size: 0.8rem; color: var(--p-text-muted-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-subject { font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-date { font-size: 0.7rem; color: var(--p-text-muted-color); }
.message-view-panel { flex: 1; overflow-y: auto; padding: 1rem; }
.msg-header h3 { margin: 0 0 0.75rem; }
.msg-meta { font-size: 0.85rem; margin-bottom: 0.75rem; line-height: 1.6; }
.msg-actions { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.msg-body { border: 1px solid var(--p-surface-200); border-radius: 6px; padding: 1rem; background: white; }
.msg-body-text { white-space: pre-wrap; font-size: 0.875rem; }
.msg-attachments { margin-top: 1rem; font-size: 0.85rem; }
.attachment { display: inline-flex; align-items: center; gap: 0.25rem; margin-right: 0.75rem; }
.empty-message { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 0.5rem; color: var(--p-text-muted-color); }
.empty { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
.loading-center { display: flex; justify-content: center; padding: 2rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
</style>
+409
View File
@@ -0,0 +1,409 @@
<template>
<div class="view-container">
<div class="view-header">
<div class="breadcrumb">
<a @click="navigateTo(null)" class="crumb">Dateien</a>
<template v-for="item in filesStore.breadcrumb" :key="item.id">
<i class="pi pi-angle-right crumb-sep"></i>
<a @click="navigateTo(item.id)" class="crumb">{{ item.name }}</a>
</template>
</div>
<div class="header-actions">
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
<Button icon="pi pi-upload" label="Hochladen" size="small" @click="triggerUpload" />
<input ref="fileInput" type="file" multiple hidden @change="handleUpload" />
</div>
</div>
<DataTable
:value="filesStore.files"
:loading="filesStore.loading"
@row-dblclick="handleDoubleClick"
striped-rows
removable-sort
class="files-table"
>
<template #empty>
<div class="empty-state">
<i class="pi pi-folder-open" style="font-size: 2rem; color: var(--p-surface-400)"></i>
<p>Dieser Ordner ist leer</p>
</div>
</template>
<Column field="name" header="Name" sortable style="min-width: 300px">
<template #body="{ data }">
<div class="file-name" @click="data.is_folder && navigateTo(data.id)">
<i :class="fileIcon(data)" class="file-icon"></i>
<span>{{ data.name }}</span>
<Tag v-if="data.shared" value="Geteilt" severity="info" class="shared-tag" />
</div>
</template>
</Column>
<Column field="size" header="Groesse" sortable style="width: 120px">
<template #body="{ data }">
{{ data.is_folder ? '' : formatSize(data.size) }}
</template>
</Column>
<Column field="updated_at" header="Geaendert" sortable style="width: 180px">
<template #body="{ data }">
{{ formatDate(data.updated_at) }}
</template>
</Column>
<Column header="" style="width: 140px">
<template #body="{ data }">
<div class="row-actions">
<Button
v-if="!data.is_folder"
icon="pi pi-download"
text rounded size="small"
@click="downloadFile(data)"
/>
<Button
icon="pi pi-share-alt"
text rounded size="small"
@click="openShare(data)"
/>
<Button
icon="pi pi-pencil"
text rounded size="small"
@click="openRename(data)"
/>
<Button
icon="pi pi-trash"
text rounded size="small"
severity="danger"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
</DataTable>
<!-- New Folder Dialog -->
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
<div class="field">
<label>Ordnername</label>
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewFolder = false" />
<Button label="Erstellen" @click="createFolder" />
</template>
</Dialog>
<!-- Rename Dialog -->
<Dialog v-model:visible="showRename" header="Umbenennen" modal :style="{ width: '400px' }">
<div class="field">
<label>Neuer Name</label>
<InputText v-model="renameName" fluid autofocus @keyup.enter="doRename" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showRename = false" />
<Button label="Umbenennen" @click="doRename" />
</template>
</Dialog>
<!-- Share Dialog -->
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '500px' }">
<div v-if="shareFile" class="share-content">
<h4>{{ shareFile.name }}</h4>
<div class="share-form">
<div class="field">
<label>Passwort (optional)</label>
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Ablaufdatum (optional)</label>
<InputText v-model="shareExpiry" type="date" fluid />
</div>
<Button label="Link erstellen" icon="pi pi-link" @click="createShare" :loading="shareLoading" />
</div>
<div v-if="shareLinks.length" class="existing-links">
<h4>Bestehende Links</h4>
<div v-for="link in shareLinks" :key="link.id" class="share-link-item">
<div class="link-info">
<code>{{ window.location.origin }}/share/{{ link.token }}</code>
<small>
{{ link.download_count }} Downloads
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
<template v-if="link.has_password"> | Passwortgeschuetzt</template>
</small>
</div>
<div class="link-actions">
<Button icon="pi pi-copy" text size="small" @click="copyLink(link.token)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(link.token)" />
</div>
</div>
</div>
</div>
</Dialog>
<!-- Delete Confirm -->
<Dialog v-model:visible="showDeleteConfirm" header="Loeschen bestaetigen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ deleteTarget?.name }}</strong> wirklich loeschen?</p>
<p v-if="deleteTarget?.is_folder" class="text-warn">Alle Dateien in diesem Ordner werden ebenfalls geloescht!</p>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDelete" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFilesStore } from '../stores/files'
import { useToast } from 'primevue/usetoast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Tag from 'primevue/tag'
const route = useRoute()
const router = useRouter()
const filesStore = useFilesStore()
const toast = useToast()
const fileInput = ref(null)
const showNewFolder = ref(false)
const newFolderName = ref('')
const showRename = ref(false)
const renameName = ref('')
const renameTarget = ref(null)
const showShare = ref(false)
const shareFile = ref(null)
const sharePassword = ref('')
const shareExpiry = ref('')
const shareLinks = ref([])
const shareLoading = ref(false)
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
function currentParentId() {
const id = route.params.folderId
return id ? parseInt(id) : null
}
function navigateTo(folderId) {
if (folderId) {
router.push(`/files/${folderId}`)
} else {
router.push('/files')
}
}
function handleDoubleClick(event) {
const data = event.data
if (data.is_folder) {
navigateTo(data.id)
} else {
downloadFile(data)
}
}
function fileIcon(data) {
if (data.is_folder) return 'pi pi-folder'
const mime = data.mime_type || ''
if (mime.startsWith('image/')) return 'pi pi-image'
if (mime.startsWith('video/')) return 'pi pi-video'
if (mime.startsWith('audio/')) return 'pi pi-volume-up'
if (mime.includes('pdf')) return 'pi pi-file-pdf'
if (mime.includes('word') || mime.includes('document')) return 'pi pi-file-word'
if (mime.includes('sheet') || mime.includes('excel')) return 'pi pi-file-excel'
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'pi pi-file'
return 'pi pi-file'
}
function formatSize(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) {
size /= 1024
i++
}
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function formatDate(iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function triggerUpload() {
fileInput.value?.click()
}
async function handleUpload(event) {
const uploadFiles = event.target.files
if (!uploadFiles.length) return
for (const file of uploadFiles) {
try {
await filesStore.uploadFile(file, currentParentId())
toast.add({ severity: 'success', summary: `${file.name} hochgeladen`, life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: `Fehler: ${file.name}`, detail: err.response?.data?.error, life: 5000 })
}
}
event.target.value = ''
}
async function createFolder() {
if (!newFolderName.value.trim()) return
try {
await filesStore.createFolder(newFolderName.value.trim(), currentParentId())
showNewFolder.value = false
newFolderName.value = ''
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function downloadFile(data) {
const url = filesStore.downloadUrl(data.id)
const a = document.createElement('a')
a.href = url
a.download = data.name
a.click()
}
function openRename(data) {
renameTarget.value = data
renameName.value = data.name
showRename.value = true
}
async function doRename() {
if (!renameName.value.trim() || !renameTarget.value) return
try {
await filesStore.renameFile(renameTarget.value.id, renameName.value.trim())
showRename.value = false
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function openShare(data) {
shareFile.value = data
sharePassword.value = ''
shareExpiry.value = ''
showShare.value = true
try {
shareLinks.value = await filesStore.getShareLinks(data.id)
} catch {
shareLinks.value = []
}
}
async function createShare() {
if (!shareFile.value) return
shareLoading.value = true
try {
const opts = {}
if (sharePassword.value) opts.password = sharePassword.value
if (shareExpiry.value) opts.expires_at = shareExpiry.value
await filesStore.createShareLink(shareFile.value.id, opts)
shareLinks.value = await filesStore.getShareLinks(shareFile.value.id)
sharePassword.value = ''
shareExpiry.value = ''
toast.add({ severity: 'success', summary: 'Link erstellt', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
} finally {
shareLoading.value = false
}
}
function copyLink(token) {
navigator.clipboard.writeText(`${window.location.origin}/share/${token}`)
toast.add({ severity: 'info', summary: 'Link kopiert', life: 2000 })
}
async function removeShare(token) {
try {
await filesStore.deleteShareLink(token)
shareLinks.value = shareLinks.value.filter(l => l.token !== token)
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function confirmDelete(data) {
deleteTarget.value = data
showDeleteConfirm.value = true
}
async function doDelete() {
if (!deleteTarget.value) return
try {
await filesStore.deleteFile(deleteTarget.value.id)
showDeleteConfirm.value = false
toast.add({ severity: 'success', summary: 'Geloescht', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
watch(() => route.params.folderId, () => {
filesStore.loadFiles(currentParentId())
})
onMounted(() => {
filesStore.loadFiles(currentParentId())
})
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.breadcrumb { display: flex; align-items: center; gap: 0.25rem; }
.crumb { cursor: pointer; color: var(--p-primary-color); font-weight: 500; }
.crumb:hover { text-decoration: underline; }
.crumb-sep { font-size: 0.75rem; color: var(--p-surface-400); }
.header-actions { display: flex; gap: 0.5rem; }
.file-name {
display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
}
.file-icon { font-size: 1.125rem; width: 1.25rem; text-align: center; }
.shared-tag { font-size: 0.7rem; }
.row-actions { display: flex; gap: 0; }
.empty-state {
display: flex; flex-direction: column; align-items: center;
gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color);
}
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.share-content h4 { margin: 0 0 1rem; }
.share-form { margin-bottom: 1.5rem; }
.existing-links { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; }
.share-link-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);
}
.link-info { display: flex; flex-direction: column; gap: 0.25rem; }
.link-info code { font-size: 0.8rem; word-break: break-all; }
.link-info small { color: var(--p-text-muted-color); }
.link-actions { display: flex; }
.text-warn { color: var(--p-orange-500); font-size: 0.875rem; }
</style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<i class="pi pi-cloud" style="font-size: 2.5rem; color: var(--p-primary-color)"></i>
<h1>Mini-Cloud</h1>
<p>Anmelden</p>
</div>
<form @submit.prevent="handleLogin" class="auth-form">
<div class="field">
<label for="username">Benutzername</label>
<InputText
id="username"
v-model="username"
placeholder="Benutzername"
:invalid="!!error"
autofocus
fluid
/>
</div>
<div class="field">
<label for="password">Passwort</label>
<Password
id="password"
v-model="password"
placeholder="Passwort"
:feedback="false"
:invalid="!!error"
toggle-mask
fluid
/>
</div>
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
<Button
type="submit"
label="Anmelden"
:loading="loading"
fluid
/>
</form>
<div class="auth-footer">
<router-link to="/register">Noch kein Konto? Registrieren</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
import Message from 'primevue/message'
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
error.value = ''
loading.value = true
try {
await auth.login(username.value, password.value)
router.push('/')
} catch (err) {
error.value = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--p-surface-50);
}
.auth-card {
background: var(--p-surface-0);
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.auth-header {
text-align: center;
margin-bottom: 2rem;
}
.auth-header h1 {
margin: 0.5rem 0 0.25rem;
font-size: 1.5rem;
}
.auth-header p {
color: var(--p-text-muted-color);
margin: 0;
}
.auth-form .field {
margin-bottom: 1rem;
}
.auth-form .field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
.auth-form button {
margin-top: 0.5rem;
}
.auth-footer {
text-align: center;
margin-top: 1.5rem;
font-size: 0.875rem;
}
.auth-footer a {
color: var(--p-primary-color);
text-decoration: none;
}
.auth-footer a:hover {
text-decoration: underline;
}
</style>
+453
View File
@@ -0,0 +1,453 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Passwoerter</h2>
<div class="header-actions">
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
<Button icon="pi pi-plus" label="Neuer Eintrag" size="small" @click="openNewEntry" />
<Button icon="pi pi-upload" label="KeePass Import" size="small" outlined @click="showImport = true" />
</div>
</div>
<div class="passwords-layout">
<aside class="folders-sidebar">
<div class="folder-item" :class="{ active: selectedFolderId === null }" @click="selectedFolderId = null; loadEntries()">
<i class="pi pi-key"></i>
<span>Alle</span>
</div>
<div v-for="folder in folders" :key="folder.id"
class="folder-item" :class="{ active: selectedFolderId === folder.id }"
@click="selectedFolderId = folder.id; loadEntries()">
<i :class="folder.icon || 'pi pi-folder'"></i>
<span>{{ folder.name }}</span>
<span v-if="folder.owner_name" class="shared-label">({{ folder.owner_name }})</span>
</div>
</aside>
<div class="entries-main">
<div class="search-bar">
<InputText v-model="searchQuery" placeholder="Passwoerter suchen..." fluid />
</div>
<div class="entries-list">
<div v-for="entry in filteredEntries" :key="entry.id"
class="entry-item" @click="openEntry(entry)">
<div class="entry-icon">
<i class="pi pi-key"></i>
</div>
<div class="entry-info">
<div class="entry-title">{{ decryptedEntries[entry.id]?.title || '(Verschluesselt)' }}</div>
<div class="entry-url">{{ decryptedEntries[entry.id]?.username || '' }}</div>
</div>
<div class="entry-actions">
<Button icon="pi pi-copy" text size="small" title="Passwort kopieren"
@click.stop="copyPassword(entry)" />
<Button v-if="decryptedEntries[entry.id]?.totp_secret" icon="pi pi-clock" text size="small"
title="TOTP Code" @click.stop="showTotp(entry)" />
</div>
</div>
<div v-if="!filteredEntries.length" class="empty">
<i class="pi pi-key" style="font-size: 2rem; color: var(--p-surface-400)"></i>
<p>Keine Eintraege</p>
</div>
</div>
</div>
</div>
<!-- Entry Dialog -->
<Dialog v-model:visible="showEntryDialog" :header="editingEntry ? 'Eintrag bearbeiten' : 'Neuer Eintrag'" modal :style="{ width: '500px' }">
<div class="field">
<label>Titel</label>
<InputText v-model="entryForm.title" fluid autofocus />
</div>
<div class="field">
<label>URL</label>
<InputText v-model="entryForm.url" fluid />
</div>
<div class="field">
<label>Benutzername</label>
<InputText v-model="entryForm.username" fluid />
</div>
<div class="field">
<label>Passwort</label>
<div class="password-field">
<Password v-model="entryForm.password" :feedback="false" toggle-mask fluid />
<Button icon="pi pi-sync" text size="small" title="Generieren" @click="generatePassword" />
</div>
</div>
<div class="field">
<label>TOTP Secret (optional)</label>
<InputText v-model="entryForm.totp_secret" fluid placeholder="otpauth:// oder Secret" />
</div>
<div class="field">
<label>Ordner</label>
<Select v-model="entryForm.folder_id" :options="folderOptions" optionLabel="name" optionValue="id" fluid placeholder="Kein Ordner" />
</div>
<div class="field">
<label>Kategorie</label>
<InputText v-model="entryForm.category" fluid />
</div>
<div class="field">
<label>Notizen</label>
<Textarea v-model="entryForm.notes" rows="3" fluid />
</div>
<template #footer>
<Button v-if="editingEntry" label="Loeschen" severity="danger" text @click="deleteEntry" />
<Button label="Abbrechen" text @click="showEntryDialog = false" />
<Button :label="editingEntry ? 'Speichern' : 'Erstellen'" @click="saveEntry" />
</template>
</Dialog>
<!-- New Folder -->
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
<div class="field">
<label>Name</label>
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewFolder = false" />
<Button label="Erstellen" @click="createFolder" />
</template>
</Dialog>
<!-- KeePass Import -->
<Dialog v-model:visible="showImport" header="KeePass Import" modal :style="{ width: '500px' }">
<p>Waehle eine .kdbx-Datei und gib das KeePass-Passwort ein.</p>
<div class="field">
<label>KDBX-Datei</label>
<input ref="kdbxInput" type="file" accept=".kdbx" />
</div>
<div class="field">
<label>KeePass-Passwort</label>
<Password v-model="importPassword" :feedback="false" toggle-mask fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showImport = false" />
<Button label="Importieren" @click="importKeePass" :loading="importing" />
</template>
</Dialog>
<!-- TOTP Dialog -->
<Dialog v-model:visible="showTotpDialog" header="TOTP Code" modal :style="{ width: '300px' }">
<div class="totp-display">
<div class="totp-code">{{ totpCode }}</div>
<Button icon="pi pi-copy" text @click="copyToClipboard(totpCode)" />
</div>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
const toast = useToast()
const auth = useAuthStore()
const folders = ref([])
const entries = ref([])
const decryptedEntries = ref({})
const selectedFolderId = ref(null)
const searchQuery = ref('')
const showEntryDialog = ref(false)
const editingEntry = ref(null)
const entryForm = ref({ title: '', url: '', username: '', password: '', totp_secret: '', folder_id: null, category: '', notes: '' })
const showNewFolder = ref(false)
const newFolderName = ref('')
const showImport = ref(false)
const importPassword = ref('')
const importing = ref(false)
const kdbxInput = ref(null)
const showTotpDialog = ref(false)
const totpCode = ref('')
const folderOptions = computed(() => [{ id: null, name: '(Kein Ordner)' }, ...folders.value])
const filteredEntries = computed(() => {
if (!searchQuery.value) return entries.value
const q = searchQuery.value.toLowerCase()
return entries.value.filter(e => {
const d = decryptedEntries.value[e.id]
if (!d) return false
return (d.title || '').toLowerCase().includes(q) ||
(d.username || '').toLowerCase().includes(q) ||
(d.url || '').toLowerCase().includes(q)
})
})
// --- Crypto helpers using Web Crypto API ---
async function getMasterKey() {
const salt = auth.masterKeySalt
if (!salt) return null
const enc = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(auth.user?.username + ':' + 'stored'), 'PBKDF2', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: Uint8Array.from(atob(salt), c => c.charCodeAt(0)), iterations: 600000, hash: 'SHA-256' },
keyMaterial, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
)
}
async function encryptText(text, key) {
if (!text) return null
const iv = crypto.getRandomValues(new Uint8Array(12))
const enc = new TextEncoder()
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(text))
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
combined.set(iv)
combined.set(new Uint8Array(ciphertext), iv.length)
return btoa(String.fromCharCode(...combined))
}
async function decryptText(b64, key) {
if (!b64) return ''
try {
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
const iv = raw.slice(0, 12)
const ciphertext = raw.slice(12)
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
return new TextDecoder().decode(plaintext)
} catch { return '(Entschluesselung fehlgeschlagen)' }
}
async function decryptEntries() {
const key = await getMasterKey()
if (!key) return
const result = {}
for (const entry of entries.value) {
result[entry.id] = {
title: await decryptText(entry.title_encrypted, key),
url: await decryptText(entry.url_encrypted, key),
username: await decryptText(entry.username_encrypted, key),
password: await decryptText(entry.password_encrypted, key),
notes: await decryptText(entry.notes_encrypted, key),
totp_secret: await decryptText(entry.totp_secret_encrypted, key),
}
}
decryptedEntries.value = result
}
// --- Data loading ---
async function loadFolders() {
const res = await apiClient.get('/passwords/folders')
folders.value = res.data
}
async function loadEntries() {
const params = {}
if (selectedFolderId.value !== null) params.folder_id = selectedFolderId.value
const res = await apiClient.get('/passwords/entries', { params })
entries.value = res.data
await decryptEntries()
}
async function createFolder() {
if (!newFolderName.value.trim()) return
await apiClient.post('/passwords/folders', { name: newFolderName.value.trim() })
showNewFolder.value = false
newFolderName.value = ''
await loadFolders()
}
function openNewEntry() {
editingEntry.value = null
entryForm.value = { title: '', url: '', username: '', password: '', totp_secret: '', folder_id: selectedFolderId.value, category: '', notes: '' }
showEntryDialog.value = true
}
async function openEntry(entry) {
editingEntry.value = entry
const d = decryptedEntries.value[entry.id] || {}
entryForm.value = {
title: d.title || '',
url: d.url || '',
username: d.username || '',
password: d.password || '',
totp_secret: d.totp_secret || '',
folder_id: entry.folder_id,
category: entry.category || '',
notes: d.notes || '',
}
showEntryDialog.value = true
}
async function saveEntry() {
const key = await getMasterKey()
if (!key) { toast.add({ severity: 'error', summary: 'Kein Master-Key', life: 3000 }); return }
const iv = crypto.getRandomValues(new Uint8Array(12))
const ivB64 = btoa(String.fromCharCode(...iv))
const payload = {
title_encrypted: await encryptText(entryForm.value.title, key),
url_encrypted: await encryptText(entryForm.value.url, key),
username_encrypted: await encryptText(entryForm.value.username, key),
password_encrypted: await encryptText(entryForm.value.password, key),
notes_encrypted: await encryptText(entryForm.value.notes, key),
totp_secret_encrypted: await encryptText(entryForm.value.totp_secret, key),
iv: ivB64,
folder_id: entryForm.value.folder_id,
category: entryForm.value.category,
}
if (editingEntry.value) {
await apiClient.put(`/passwords/entries/${editingEntry.value.id}`, payload)
} else {
await apiClient.post('/passwords/entries', payload)
}
showEntryDialog.value = false
await loadEntries()
}
async function deleteEntry() {
if (!editingEntry.value) return
await apiClient.delete(`/passwords/entries/${editingEntry.value.id}`)
showEntryDialog.value = false
await loadEntries()
}
async function copyPassword(entry) {
const d = decryptedEntries.value[entry.id]
if (d?.password) {
await navigator.clipboard.writeText(d.password)
toast.add({ severity: 'info', summary: 'Passwort kopiert', life: 2000 })
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text)
toast.add({ severity: 'info', summary: 'Kopiert', life: 2000 })
}
function generatePassword() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-='
const arr = new Uint8Array(20)
crypto.getRandomValues(arr)
entryForm.value.password = Array.from(arr, b => chars[b % chars.length]).join('')
}
async function showTotp(entry) {
const d = decryptedEntries.value[entry.id]
if (!d?.totp_secret) return
// Simple TOTP generation
try {
const secret = d.totp_secret.replace(/^otpauth:\/\/.*secret=/, '').replace(/&.*/, '')
totpCode.value = await generateTOTP(secret)
showTotpDialog.value = true
} catch {
toast.add({ severity: 'error', summary: 'TOTP-Fehler', life: 3000 })
}
}
async function generateTOTP(secret) {
// Base32 decode
const base32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const bits = secret.toUpperCase().replace(/=+$/, '').split('').map(c => {
const val = base32.indexOf(c)
return val >= 0 ? val.toString(2).padStart(5, '0') : ''
}).join('')
const bytes = new Uint8Array(bits.match(/.{8}/g).map(b => parseInt(b, 2)))
const time = Math.floor(Date.now() / 30000)
const timeBytes = new Uint8Array(8)
let t = time
for (let i = 7; i >= 0; i--) { timeBytes[i] = t & 0xff; t >>= 8 }
const key = await crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, timeBytes))
const offset = sig[sig.length - 1] & 0xf
const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 | sig[offset + 2] << 8 | sig[offset + 3]) % 1000000
return code.toString().padStart(6, '0')
}
async function importKeePass() {
if (!kdbxInput.value?.files?.length || !importPassword.value) return
importing.value = true
try {
const formData = new FormData()
formData.append('file', kdbxInput.value.files[0])
formData.append('password', importPassword.value)
const res = await apiClient.post('/passwords/import/keepass', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
const key = await getMasterKey()
if (!key) return
// Create folders
const folderMap = {}
for (const group of res.data.groups) {
const folder = await apiClient.post('/passwords/folders', { name: group.name })
folderMap[group.uuid] = folder.data.id
}
// Import entries
for (const entry of res.data.entries) {
const folderId = entry.group_uuid ? folderMap[entry.group_uuid] : null
const iv = crypto.getRandomValues(new Uint8Array(12))
await apiClient.post('/passwords/entries', {
title_encrypted: await encryptText(entry.title, key),
url_encrypted: await encryptText(entry.url, key),
username_encrypted: await encryptText(entry.username, key),
password_encrypted: await encryptText(entry.password, key),
notes_encrypted: await encryptText(entry.notes, key),
totp_secret_encrypted: await encryptText(entry.totp, key),
iv: btoa(String.fromCharCode(...iv)),
folder_id: folderId,
})
}
showImport.value = false
toast.add({ severity: 'success', summary: `${res.data.count} Eintraege importiert`, life: 5000 })
await loadFolders()
await loadEntries()
} catch (err) {
toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error, life: 5000 })
} finally {
importing.value = false
}
}
onMounted(async () => {
await loadFolders()
await loadEntries()
})
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
.view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; }
.passwords-layout { display: flex; gap: 1rem; }
.folders-sidebar { width: 220px; flex-shrink: 0; }
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
.folder-item:hover { background: var(--p-surface-100); }
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.entries-main { flex: 1; }
.search-bar { margin-bottom: 1rem; }
.entries-list { display: flex; flex-direction: column; gap: 2px; }
.entry-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--p-surface-0); border-radius: 6px; cursor: pointer; }
.entry-item:hover { background: var(--p-surface-100); }
.entry-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: var(--p-primary-50); border-radius: 8px; color: var(--p-primary-color); }
.entry-info { flex: 1; }
.entry-title { font-weight: 500; font-size: 0.9rem; }
.entry-url { font-size: 0.8rem; color: var(--p-text-muted-color); }
.entry-actions { display: flex; gap: 0; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.password-field { display: flex; gap: 0.5rem; align-items: flex-start; }
.empty { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color); }
.totp-display { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1rem; }
.totp-code { font-size: 2rem; font-weight: 700; letter-spacing: 0.25em; font-family: monospace; }
</style>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<i class="pi pi-cloud" style="font-size: 2.5rem; color: var(--p-primary-color)"></i>
<h1>Mini-Cloud</h1>
<p>Registrieren</p>
</div>
<form @submit.prevent="handleRegister" class="auth-form">
<div class="field">
<label for="username">Benutzername</label>
<InputText
id="username"
v-model="username"
placeholder="Benutzername (min. 3 Zeichen)"
autofocus
fluid
/>
</div>
<div class="field">
<label for="email">E-Mail (optional)</label>
<InputText
id="email"
v-model="email"
placeholder="email@beispiel.de"
type="email"
fluid
/>
</div>
<div class="field">
<label for="password">Passwort</label>
<Password
id="password"
v-model="password"
placeholder="Passwort (min. 8 Zeichen)"
toggle-mask
fluid
/>
</div>
<div class="field">
<label for="password2">Passwort wiederholen</label>
<Password
id="password2"
v-model="password2"
placeholder="Passwort wiederholen"
:feedback="false"
toggle-mask
fluid
/>
</div>
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
<Button
type="submit"
label="Registrieren"
:loading="loading"
fluid
/>
</form>
<div class="auth-footer">
<router-link to="/login">Bereits ein Konto? Anmelden</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
import Message from 'primevue/message'
const router = useRouter()
const auth = useAuthStore()
const username = ref('')
const email = ref('')
const password = ref('')
const password2 = ref('')
const error = ref('')
const loading = ref(false)
async function handleRegister() {
error.value = ''
if (password.value !== password2.value) {
error.value = 'Passwoerter stimmen nicht ueberein'
return
}
loading.value = true
try {
await auth.register(username.value, password.value, email.value || undefined)
router.push('/')
} catch (err) {
error.value = err.response?.data?.error || 'Registrierung fehlgeschlagen'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--p-surface-50);
}
.auth-card {
background: var(--p-surface-0);
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.auth-header {
text-align: center;
margin-bottom: 2rem;
}
.auth-header h1 {
margin: 0.5rem 0 0.25rem;
font-size: 1.5rem;
}
.auth-header p {
color: var(--p-text-muted-color);
margin: 0;
}
.auth-form .field {
margin-bottom: 1rem;
}
.auth-form .field label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
.auth-form button {
margin-top: 0.5rem;
}
.auth-footer {
text-align: center;
margin-top: 1.5rem;
font-size: 0.875rem;
}
.auth-footer a {
color: var(--p-primary-color);
text-decoration: none;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Einstellungen</h2>
</div>
<div class="settings-section">
<h3>Profil</h3>
<div class="settings-info">
<div class="info-row">
<span class="label">Benutzername:</span>
<span>{{ auth.user?.username }}</span>
</div>
<div class="info-row">
<span class="label">E-Mail:</span>
<span>{{ auth.user?.email || 'Nicht angegeben' }}</span>
</div>
<div class="info-row">
<span class="label">Rolle:</span>
<Tag :value="auth.user?.role" :severity="auth.user?.role === 'admin' ? 'danger' : 'info'" />
</div>
</div>
</div>
<div class="settings-section">
<h3>Passwort aendern</h3>
<form @submit.prevent="handleChangePassword" class="password-form">
<div class="field">
<label>Aktuelles Passwort</label>
<Password v-model="currentPassword" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Neues Passwort</label>
<Password v-model="newPassword" toggle-mask fluid />
</div>
<div class="field">
<label>Neues Passwort wiederholen</label>
<Password v-model="newPassword2" :feedback="false" toggle-mask fluid />
</div>
<Message v-if="pwError" severity="error" :closable="false">{{ pwError }}</Message>
<Message v-if="pwSuccess" severity="success" :closable="false">{{ pwSuccess }}</Message>
<Button type="submit" label="Passwort aendern" :loading="pwLoading" />
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '../stores/auth'
import Password from 'primevue/password'
import Button from 'primevue/button'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
const auth = useAuthStore()
const currentPassword = ref('')
const newPassword = ref('')
const newPassword2 = ref('')
const pwError = ref('')
const pwSuccess = ref('')
const pwLoading = ref(false)
async function handleChangePassword() {
pwError.value = ''
pwSuccess.value = ''
if (newPassword.value !== newPassword2.value) {
pwError.value = 'Neue Passwoerter stimmen nicht ueberein'
return
}
pwLoading.value = true
try {
await auth.changePassword(currentPassword.value, newPassword.value)
pwSuccess.value = 'Passwort erfolgreich geaendert'
currentPassword.value = ''
newPassword.value = ''
newPassword2.value = ''
} catch (err) {
pwError.value = err.response?.data?.error || 'Fehler beim Aendern des Passworts'
} finally {
pwLoading.value = false
}
}
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header h2 { margin: 0 0 1.5rem; }
.settings-section {
background: var(--p-surface-0);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.settings-section h3 { margin: 0 0 1rem; font-size: 1.125rem; }
.settings-info { display: flex; flex-direction: column; gap: 0.5rem; }
.info-row { display: flex; align-items: center; gap: 0.5rem; }
.info-row .label { font-weight: 500; min-width: 120px; }
.password-form { max-width: 400px; }
.password-form .field { margin-bottom: 1rem; }
.password-form .field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
</style>
+123
View File
@@ -0,0 +1,123 @@
<template>
<div class="share-container">
<div class="share-card">
<i class="pi pi-cloud" style="font-size: 2rem; color: var(--p-primary-color)"></i>
<div v-if="loading" class="share-loading">
<i class="pi pi-spin pi-spinner" style="font-size: 1.5rem"></i>
<p>Laden...</p>
</div>
<div v-else-if="error" class="share-error">
<i class="pi pi-times-circle" style="font-size: 2rem; color: var(--p-red-500)"></i>
<p>{{ error }}</p>
</div>
<div v-else-if="fileInfo" class="share-info">
<h2>{{ fileInfo.name }}</h2>
<p class="file-size" v-if="fileInfo.size">{{ formatSize(fileInfo.size) }}</p>
<div v-if="fileInfo.has_password && !authenticated" class="password-form">
<p>Diese Datei ist passwortgeschuetzt.</p>
<div class="field">
<Password v-model="password" placeholder="Passwort eingeben" :feedback="false" toggle-mask fluid />
</div>
<Message v-if="authError" severity="error" :closable="false">{{ authError }}</Message>
<Button label="Entsperren" @click="verifyPassword" :loading="verifying" fluid />
</div>
<div v-else class="download-section">
<Button
label="Herunterladen"
icon="pi pi-download"
size="large"
@click="downloadFile"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import Button from 'primevue/button'
import Password from 'primevue/password'
import Message from 'primevue/message'
const route = useRoute()
const token = route.params.token
const loading = ref(true)
const error = ref('')
const fileInfo = ref(null)
const password = ref('')
const authenticated = ref(false)
const authError = ref('')
const verifying = ref(false)
function formatSize(bytes) {
if (!bytes) return ''
const units = ['B', 'KB', 'MB', 'GB']
let i = 0
let size = bytes
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
async function loadInfo() {
try {
const res = await axios.get(`/api/share/${token}/info`)
fileInfo.value = res.data
if (!res.data.has_password) authenticated.value = true
} catch (err) {
error.value = err.response?.data?.error || 'Link nicht gefunden oder abgelaufen'
} finally {
loading.value = false
}
}
async function verifyPassword() {
authError.value = ''
verifying.value = true
try {
await axios.post(`/api/share/${token}/verify`, { password: password.value })
authenticated.value = true
} catch (err) {
authError.value = err.response?.data?.error || 'Falsches Passwort'
} finally {
verifying.value = false
}
}
function downloadFile() {
let url = `/api/share/${token}/download`
if (fileInfo.value?.has_password && password.value) {
url += `?password=${encodeURIComponent(password.value)}`
}
window.location.href = url
}
onMounted(loadInfo)
</script>
<style scoped>
.share-container {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--p-surface-50);
}
.share-card {
background: var(--p-surface-0); border-radius: 12px; padding: 3rem;
text-align: center; max-width: 450px; width: 100%;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.share-card h2 { margin: 1rem 0 0.25rem; font-size: 1.25rem; }
.file-size { color: var(--p-text-muted-color); margin-bottom: 1.5rem; }
.password-form { text-align: left; margin-top: 1.5rem; }
.password-form p { margin-bottom: 1rem; color: var(--p-text-muted-color); }
.field { margin-bottom: 1rem; }
.download-section { margin-top: 1.5rem; }
.share-loading, .share-error { margin-top: 1.5rem; }
</style>
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3100,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
'/dav': {
target: 'http://localhost:5000',
changeOrigin: true,
},
'/ical': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
})