From 62f550c3736fdfa1f7c9587bcf24ed9340e00d1c Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sat, 11 Apr 2026 14:53:28 +0200 Subject: [PATCH] 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) --- .env.example | 28 + Dockerfile | 38 + backend/app/__init__.py | 74 ++ backend/app/api/__init__.py | 5 + backend/app/api/auth.py | 208 +++ backend/app/api/calendar.py | 397 ++++++ backend/app/api/contacts.py | 340 +++++ backend/app/api/email.py | 489 +++++++ backend/app/api/files.py | 571 ++++++++ backend/app/api/office.py | 170 +++ backend/app/api/passwords.py | 361 ++++++ backend/app/api/users.py | 111 ++ backend/app/config.py | 34 + backend/app/dav/__init__.py | 0 backend/app/extensions.py | 7 + backend/app/models/__init__.py | 15 + backend/app/models/calendar.py | 79 ++ backend/app/models/contact.py | 73 ++ backend/app/models/email_account.py | 41 + backend/app/models/file.py | 82 ++ backend/app/models/password_vault.py | 99 ++ backend/app/models/user.py | 47 + backend/app/services/__init__.py | 0 backend/app/services/crypto_service.py | 39 + backend/app/utils/__init__.py | 0 backend/requirements.txt | 31 + backend/wsgi.py | 19 + docker-compose.yml | 15 + frontend/.gitignore | 24 + frontend/index.html | 13 + frontend/package-lock.json | 1650 ++++++++++++++++++++++++ frontend/package.json | 24 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.vue | 3 + frontend/src/api/client.js | 83 ++ frontend/src/assets/hero.png | Bin 0 -> 44919 bytes frontend/src/assets/vite.svg | 1 + frontend/src/assets/vue.svg | 1 + frontend/src/main.js | 28 + frontend/src/router/index.js | 100 ++ frontend/src/stores/auth.js | 72 ++ frontend/src/stores/files.js | 84 ++ frontend/src/style.css | 16 + frontend/src/views/AdminView.vue | 75 ++ frontend/src/views/AppLayout.vue | 166 +++ frontend/src/views/CalendarView.vue | 376 ++++++ frontend/src/views/ContactsView.vue | 206 +++ frontend/src/views/EmailView.vue | 299 +++++ frontend/src/views/FilesView.vue | 409 ++++++ frontend/src/views/LoginView.vue | 146 +++ frontend/src/views/PasswordsView.vue | 453 +++++++ frontend/src/views/RegisterView.vue | 170 +++ frontend/src/views/SettingsView.vue | 104 ++ frontend/src/views/ShareView.vue | 123 ++ frontend/vite.config.js | 23 + 56 files changed, 8047 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/calendar.py create mode 100644 backend/app/api/contacts.py create mode 100644 backend/app/api/email.py create mode 100644 backend/app/api/files.py create mode 100644 backend/app/api/office.py create mode 100644 backend/app/api/passwords.py create mode 100644 backend/app/api/users.py create mode 100644 backend/app/config.py create mode 100644 backend/app/dav/__init__.py create mode 100644 backend/app/extensions.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/calendar.py create mode 100644 backend/app/models/contact.py create mode 100644 backend/app/models/email_account.py create mode 100644 backend/app/models/file.py create mode 100644 backend/app/models/password_vault.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/crypto_service.py create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/requirements.txt create mode 100644 backend/wsgi.py create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/auth.js create mode 100644 frontend/src/stores/files.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/views/AdminView.vue create mode 100644 frontend/src/views/AppLayout.vue create mode 100644 frontend/src/views/CalendarView.vue create mode 100644 frontend/src/views/ContactsView.vue create mode 100644 frontend/src/views/EmailView.vue create mode 100644 frontend/src/views/FilesView.vue create mode 100644 frontend/src/views/LoginView.vue create mode 100644 frontend/src/views/PasswordsView.vue create mode 100644 frontend/src/views/RegisterView.vue create mode 100644 frontend/src/views/SettingsView.vue create mode 100644 frontend/src/views/ShareView.vue create mode 100644 frontend/vite.config.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..06640df --- /dev/null +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c39022 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..6fcd67d --- /dev/null +++ b/backend/app/__init__.py @@ -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/') + 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 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..2cfe196 --- /dev/null +++ b/backend/app/api/__init__.py @@ -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 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..8782c00 --- /dev/null +++ b/backend/app/api/auth.py @@ -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 diff --git a/backend/app/api/calendar.py b/backend/app/api/calendar.py new file mode 100644 index 0000000..93300ac --- /dev/null +++ b/backend/app/api/calendar.py @@ -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/', 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/', 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//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//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/', 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/', 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//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//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//shares/', 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//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) diff --git a/backend/app/api/contacts.py b/backend/app/api/contacts.py new file mode 100644 index 0000000..1e0498c --- /dev/null +++ b/backend/app/api/contacts.py @@ -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/', 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/', 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//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//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/', 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/', 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/', 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//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//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//shares/', 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//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) diff --git a/backend/app/api/email.py b/backend/app/api/email.py new file mode 100644 index 0000000..e879dd6 --- /dev/null +++ b/backend/app/api/email.py @@ -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/', 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/', 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//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//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//folders//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//messages/', 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//messages//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//messages//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//messages/', 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 diff --git a/backend/app/api/files.py b/backend/app/api/files.py new file mode 100644 index 0000000..e871ad8 --- /dev/null +++ b/backend/app/api/files.py @@ -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//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/', 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/', 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//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//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//permissions/', 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//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//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//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//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//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/', 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 diff --git a/backend/app/api/office.py b/backend/app/api/office.py new file mode 100644 index 0000000..0e63b22 --- /dev/null +++ b/backend/app/api/office.py @@ -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//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('
') + continue + if 'Heading 1' in style: + html_parts.append(f'

{text}

') + elif 'Heading 2' in style: + html_parts.append(f'

{text}

') + elif 'Heading 3' in style: + html_parts.append(f'

{text}

') + else: + # Check for bold/italic runs + run_html = '' + for run in para.runs: + t = run.text + if run.bold: + t = f'{t}' + if run.italic: + t = f'{t}' + if run.underline: + t = f'{t}' + run_html += t + html_parts.append(f'

{run_html}

') + + # Tables + for table in doc.tables: + html_parts.append('') + for i, row in enumerate(table.rows): + html_parts.append('') + tag = 'th' if i == 0 else 'td' + for cell in row.cells: + html_parts.append(f'<{tag}>{cell.text}') + html_parts.append('') + html_parts.append('
') + + 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'

{text}

') + if shape.has_table: + table_html = '' + for row in shape.table.rows: + table_html += '' + for cell in row.cells: + table_html += f'' + table_html += '' + table_html += '
{cell.text}
' + content_parts.append(table_html) + + slides.append({ + 'index': i, + 'html': '\n'.join(content_parts) if content_parts else '

(Leere Folie)

', + }) + return slides diff --git a/backend/app/api/passwords.py b/backend/app/api/passwords.py new file mode 100644 index 0000000..a78d48e --- /dev/null +++ b/backend/app/api/passwords.py @@ -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/', 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/', 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/', 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/', 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/', 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 diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..2c3b2f0 --- /dev/null +++ b/backend/app/api/users.py @@ -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/', 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/', 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/', 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 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..47b518a --- /dev/null +++ b/backend/app/config.py @@ -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') diff --git a/backend/app/dav/__init__.py b/backend/app/dav/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/extensions.py b/backend/app/extensions.py new file mode 100644 index 0000000..27fcd78 --- /dev/null +++ b/backend/app/extensions.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..01d4dcf --- /dev/null +++ b/backend/app/models/__init__.py @@ -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', +] diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py new file mode 100644 index 0000000..e0a0d8c --- /dev/null +++ b/backend/app/models/calendar.py @@ -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'), + ) diff --git a/backend/app/models/contact.py b/backend/app/models/contact.py new file mode 100644 index 0000000..fcab880 --- /dev/null +++ b/backend/app/models/contact.py @@ -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'), + ) diff --git a/backend/app/models/email_account.py b/backend/app/models/email_account.py new file mode 100644 index 0000000..87ff41d --- /dev/null +++ b/backend/app/models/email_account.py @@ -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, + } diff --git a/backend/app/models/file.py b/backend/app/models/file.py new file mode 100644 index 0000000..a545818 --- /dev/null +++ b/backend/app/models/file.py @@ -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 diff --git a/backend/app/models/password_vault.py b/backend/app/models/password_vault.py new file mode 100644 index 0000000..121165a --- /dev/null +++ b/backend/app/models/password_vault.py @@ -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'), + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..c1e1256 --- /dev/null +++ b/backend/app/models/user.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py new file mode 100644 index 0000000..e9137fa --- /dev/null +++ b/backend/app/services/crypto_service.py @@ -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') diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8f192a3 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/wsgi.py b/backend/wsgi.py new file mode 100644 index 0000000..574f197 --- /dev/null +++ b/backend/wsgi.py @@ -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, + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f757910 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2b56de4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..33a07d3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1650 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@primeuix/styled": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.7.4.tgz", + "integrity": "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==", + "license": "MIT", + "dependencies": { + "@primeuix/utils": "^0.6.1" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primeuix/styles": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@primeuix/styles/-/styles-2.0.3.tgz", + "integrity": "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4" + } + }, + "node_modules/@primeuix/themes": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@primeuix/themes/-/themes-2.0.3.tgz", + "integrity": "sha512-3fS1883mtCWhgUgNf/feiaaDSOND4EBIOu9tZnzJlJ8QtYyL6eFLcA6V3ymCWqLVXQ1+lTVEZv1gl47FIdXReg==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4" + } + }, + "node_modules/@primeuix/utils": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.6.4.tgz", + "integrity": "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==", + "license": "MIT", + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primevue/core": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.5.5.tgz", + "integrity": "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4", + "@primeuix/utils": "^0.6.2" + }, + "engines": { + "node": ">=12.11.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@primevue/icons": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.5.5.tgz", + "integrity": "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA==", + "license": "MIT", + "dependencies": { + "@primeuix/utils": "^0.6.2", + "@primevue/core": "4.5.5" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primevue/themes": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/@primevue/themes/-/themes-4.5.4.tgz", + "integrity": "sha512-rUFZxMHLanTZdvZq4zgZPk+KRBZ3s7fE3bBK32OrZBkHQhEJmkJ7Ftd4w4QFlXyz1B7c+k5invZiOOCjwHXg9Q==", + "deprecated": "Deprecated. This package is no longer maintained. Please migrate to @primeuix/themes: https://www.npmjs.com/package/@primeuix/themes", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4", + "@primeuix/themes": "^2.0.2" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/primeicons": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==", + "license": "MIT" + }, + "node_modules/primevue": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/primevue/-/primevue-4.5.5.tgz", + "integrity": "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4", + "@primeuix/styles": "^2.0.3", + "@primeuix/utils": "^0.6.2", + "@primevue/core": "4.5.5", + "@primevue/icons": "4.5.5" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..387a8ee --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..dcecb29 --- /dev/null +++ b/frontend/src/api/client.js @@ -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 diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 zcma%i^5TDbT`tlgo2c`(n!ND-Q6MGAYIbZ-QCh5-QC^YozK_ne*b_MKK#O- zIWy zd$aJVZ?rl%;eiC7d#Sl-cWLv9rA0(UOX(@I3k&yyL+3GaQ4xpb1EGC|i|{byaTI># zBO=0pyZu5XO!hzGNPch4cx%6XJAJpDa<+98BOcYNo1=XER1sv!UW z^>ZDMp%FSmVnt)n^EIR+Nth`vRO^_=UF3EWv75ym{S;#2F8MPot@-y$>ioj!)a1bE zijXPQY;U`qNwl9|wl{W>{FhMSb<>m4{;8Udp4psl)NwFRo(W-T)Y6-qDf=L#U?g<@ zV+T|3+RuE~!E&nodKrkfPcOpJ)&1|p`Tbtd12@MSE8DjWkD|9M>GZsHLf>TTbLx)B z#5K5l%gS7s(yWk?Lj{Nvm`Z-s8xb-Xr`5-xRr%w8v>!oSz{dN*MmxbscQl#Z40qSd z!PQXs-utLEF&$@S#__Lo*pOhG{l(%jyCh-0ME8owiT>U~r&q@MaDRePL(aZAAff9= zBd@*7RZxmiqK^nZH7`bTjIEQw#Y=V6(h{$>7ZIf=7S0;$8~4NXLd4T;Ai~C8&3k-; zYEtJWq6x$#5rrCJ%zspgO z((R)&>BIkkr^qQSEZljO*B+ZDvTeBKJ9N%8Ej=U+62GI)dc|ZMEM66~W12v&QFAIS zoDs`J`wjsl?WdE(NTnjCO!^yB>{yU-2UPT`&FOyVQVmxy#un2Po>GiPPfzd0M^d_i z+Kr}dPhIfsDLd~jOiJ(sHTN;2u)@MaX&0AdXR;BAwr_;1sR;)MM+&{XTzNnKWH@0a zoy9ApaUt=>jjHICu3W42)5;nzHS!M3?aOvZfv-sIc%wc9#l0uHFc}aS4JSrIDOQ?4ri_bS?pjH{U{6qr+6m z--%u=5oc&PxE==-I$~$5gw}yiu_y_o?|ag2+rAgSg%G)}EU}r%*A|v|pjbE`lxJpU zy0{?;(US(i-TiKq6s_(KTYy|YVi&!plMT)EJ4wMU{C7Y;!Xow1nJ+X@ks@r0v25R; z*o$8AP*G*f3$UlYR~18PxKyPj9vU#v)4#GgEx4*?KOhlh>0%3M$-LN7&b*0fXgm$k zH78>bObkx^3_K+RY;G+Usy6L}p9iT!hlnJCmR=;=JL1TdtB#vL!RTJ1TABQx8Ux0w zl^{Jkf(hU>-jr59iK_v-PkV!WwG!LvW<@{3{IbbSiWBrX@S8^`8JFRrc+(AqsUIvm zCTstACtCZ~qy-5^Gr@_z#X!N1*1vH=7@8oL4AEOxWl^YW&LW|1$1J?gG061vk1epe zRI_*s(lrX?-2#tCt_`)p?{zZC+)onl60CU~%4!vPA}h0+fB9ucNkTQ3u29((9Wq=> z^JUm|{_2-=?dMKu&9)#x{lgPOCM`U1^tXDbmZ%I$0fw7|Y-@3Tyj1LGfk$lvzYC85 z=R()QEER%Dz=mTMZ=7E?K74&?)4b~-uj34rKwb~7vU(48%+1xYc^VYn| zncI4NL8xEnmi>eM9EK&~si%*s|BX@zKIUU?cAWA5pdc`xEZIF1Ce=Wcg3#AP?N~p# zD7mfb{oR=ZPE^jgwD3G< z#8h1K&u&zKD4q*Pxt0ta#d}bm;QqZ!hFift22a~7c529SkmFQyN-*H zzQck2cL5iH2@d@Lhq4$~_!wMWL6(&mNq=7HhT}YYI$pVVZeQr>)4>qObE$PPNZ2!0 z&7?y_upwfiefj8-`B$ju)}QKTz*Zs<$Lb?XHBo(jyU(405&`EL({mgxA$Ov49U|rN z2@(l@n`1vzG(v=!u4AZ*0s}~H4{VgcNOJ1rB?Kg!=)mGHKWeC|MHb>aiQ4Qd+gq7|??WH7;?J+kYL8z# z@juTBhW#n3rN))N7T1~)qr~Es;2rln6_U>_Ejxj(E5%Cpoc^vfw64mua!ADSZ8i|+ zB}g?u(dtvesTegnG!9K33T)4eq>)>ZFp?L>R8Qp#(J=bxz2mscD;ZNoJB@ZUqPpI>o7VgScniW4c()#;@;-9PfR`b(r+#4c; z;1-)`!?b}4A3v^zVtGa(a;O%bzu(ZG;(l4+W^vU|a&n*xV0kU$uFQ!5!aWy)^q4^r zn!-6hfj79_B#>GGNvQiKMD?xyW>F&GS>3y?Ric*xp4cz3FH3Gd1z|e+Vuug7*Ya48 zL~K*l5zo1XRuWm%S~GzE4LQyuRsH1&L`Gz-%>!ZTYn9K_Ttz+Pa@9hKob^)gmLVN` zKJz}C50X$$>G1Q_p;%C}B?<9h`60%vwalt2*Ymd44dGF(oOa2mJQuPQmE~Yurn0UC z6(+5$posAd@e$nvJQFL^C~E0E4IH`B68)j#L_u|Ex5mNE8a8{>gAGcIFVS|K?g77# zE@R|9nR>Rw3(5}{d~HnPpooZ*XZC$5FYt20 z3Ydvy9t)XHw8qFCd;mt8r$e?RQ%MiUF@}!oDGG#E6xxV z=z>11f!msSqbAZYnSvt}&J+QXZCU5b`0!gi_R}Z@Qq2d2Mwc z%9aWfp&x2UGbLDvtjGb*p>4O(#}UE+QhYmf0&Vc_Ay<~3V0zym%`Lk}-3MOz<%)%#Pl z<=OjGrvuBq318+CJ-{30QA1-O@<-O!-zFNM^&wp}iWGG$B&eIYtF)Rs4;5FK=>Aa9 zyTJdUgpK$di~MI|ZC=Vkd^V6T5h^z))sl~Dq7~stg?&l_LW6N1>0nX=aS46Ks+vj7 zr#P2~h=M-LLX2!W_k&dv^Tm2}o9vK&uKMDMmPkEcj7~C78vw2XJx^s8uo(Lw>9ET2 zzXG^MDxZzwh4y=Hs@h^Y2$ntYP+GSm>#cM9ZiUR^>tiFtIol3wi8=y~L2f@Bun;{B zr@yZMir9Ur@yw@7ni+Jd*Oc9hFx zK$M%P9+XKj>`spPB?k6^h1pok(_k*E$fr(SnXlXEnE{ODRWuWqB2u+8*2z?-wl+WC zntSCtFwpr0nF!avN+7`^Pt@XDvec7%ipuHYXg%5TXDAXv;U-33A(vzDB8V%0%j-R@ zk!2mox%%pJ<_M$o0lf*YButy@IP%9Zz=UDDlr|NuSNW*bYB{&18Xj|$eVP~(lx>y3 zgjJh3l1)5_uw6CTgk`ABQVoCHT$nbFS*edKLAbhRxLyzMI-{#6H!q_O@+mM7#~@Kw zWFDq#m<+NGVr`grM*Mh=Dq@8Tzl-$WKFWsWruYa^v`B30wDORai8q&__SDBzc?K#o z^UN`hN&IN;bep+mS1Z}i#zurS+Vl`B&+6`B#XK@l^8+&2+e@&zII(kdzid}Lm^AE5 zqjZ+3N*0O?1%{glymHcUP?g3vB#mH9MA)__>pUakjX+4jPuRS$9mmbImM8^= zOGMzKSY0_htZs;&-)|di4DJjSjVQ}hf2vq`u?G4@2@M(y#8xp{#1&$)ZW$rlUwG%{ z-S3I$D5~^(7stnQ#qh(0D6TnSA5R2*0u@x*22u1y%V5wYfW$b@)H*9X9{5!1Gw0`$ z4^fR@T%cw74(zCoPNP98@iS+WaFoE>g!a7#s-iwfRHKJSou%<97*I%619(655MjTr z6;k$p>T1-|cb9V=`;0i>gjBf%t=3jn_oC874-1o3(J|G-g$c?a=wn!m?U?CAd4WKW zm>=k4ApUHFtra|}Wl_G|#Y@n(Qv*q-frfU@rg{K1dLr%5(jA(Als7lSt8bue+zbab zVF0VKb`8x4k`2s^D1=P<^mk&LXhA!1jsr46^sGC@bsZfT)hZq4gnT+I+aHp`_XRE{ zDgx9ExOOSGF^DuVB_iQ8s$S{7agA7rKLtYG0nVl0q1kdJPQ3g#tw9qL?gP!_e~V$R z7B*H7J0{kp*t0|SM#+|$l6`>>9*GXki2@B!1?#&`s}t$D9D05bdTLaq__DzJ3hhhx z4>Z*xjuhGkL>lPDr8KhXi~8N*3~eqgebLTG`3g)&9`ESMo4O`ywJ{RymGvLXG}!Y?yAZ!5^Y19ukC`n~3GM7)2v! zx|C7WvVV`|+~>K~FRJPdp3VTPY##;_7#_^stFuo>5ewhPn5=@ApsXs_<27I&gPv>g~?s5SHzci&*$xeFVsI6?MsNJwojSpg9-+xbDwNanO9CUPbs06^E~@ zW3}{)@boKx;MgISD4?gb;X2~Nzv6Vu z_d;=oiM*wq!ou(NN8Zrg1ZYYlE==ylKlarfHe9u21xL{BI8t!pRC1^0=DGRrV0_Q@ zC#L85xcROt(T$6-@Y|KI-@7cgFD>WF?-)WG5jRleK;pn&=Rb9nZ+_@Mx-Fk~VSb{E zq@Ay=ub)@s&Mz*$+FSlG0WrrMKZI+3YuZ5k`RZGGO+r;}6mJy$DM;>AadvNZ=5yf|1r(je z0NIXNIS||Cv*MHEs{?>y+_cZmakNb+;cq-QqDcP%tMf{NmoE%a zN}Y33Vukiwxzm0dhmNsZQ>TsfYfZ-XZJv?ZTQ(=j1nt6FMd#;_K1oqQ{yq$GC6%)U zZU3B>;dh0p{DE?0kaj|iKj8?vvgC|-pv7<_WZBV7+B?`x+~3_las0^52<3d}UOOFD z7O7yf($skvy4y{NCq)B!Z=x|~NnJN+V(IV6LPL~?ORfvDDj*}q67_9}bTd~ci zlKmqOV)pG2tgWwY4Xr65@I8rddMwBV71bVAeGxT?v8-f6l9tsu9MFYr4r+BQr%mT; zO=G1)NW}SP4_kI0273Ew)qtwOwo=X-`1?bJ^>I^-9FXhSX17W>;{G^F+<9U(<%-*JPc!x>jH zSpfzK?Tx3%`#8Qlql2)Lf)TAiKHBQ5IOieg6~2NY7g@9IFI!7$DETtUG^srTsi2YS zc$`cq59-bK0{Yv})|#O4%XrxCkS29A6q~iTWNRlF;SlDMr$~v5hgerQQg_UB>M>2% zI6J+NtM*`(N7ghI_emz^lYyF_O8LW&&6oX-gU1h39L7r@8tpHA@>FGx*W=fR6E@q@ zg{!zJeVuJaQCuA=1@IE7|3##J$1oumJ5vky^UJEjKU#$)KuHS7B;vs(wJ%$?>4zlr z<=b*ca@HsJ!Osy3xBOqrn__D7pqhw2^7;n0$R~Z;twx??hrssk#C1cMtRHfFzhTG1 zE{;!Tmiq;ZD9#2W4(M?+!*~v>l$%5;__SINKTNAEIBf46X8185dhp4TD9_K#gp?em zl9d>E%I2x(q#pB8rt!89i!Mi7sMMmaZ?N?eM2!JHoQ{QdAoSm@`@TtaEkw{)WuZe^ zzrVO3sL=ewi4YYv1t!gfQ_Xo()Is9PQtqh!#?v&Mscaiz6wb$F>GjZE1xw7d5)*24 zu~!(MAawsNH*G-kU-c=3l(?|JJl0^q#LV(WKmSHC=#5YKstmI(V=6c4>73kKDwk3F zD!sjK#(*WYb8j>uP??1gq4SEU63;>Pk_#yOYu7(GAy4!ABPQY-WoeY1I=l2&k9RM( z;&F-Ki}KoHAb;HXNP-^_3u`-L$+~dmP7LmypyE23q+IsyIAyGbu{1T^)Y7+m(;oN@;N26N#9X<& zwqI@>wi=7v)<%`#h|WWx1pPuT%3Hx zTmHj4u@(m6TMc`y;_9#P8As?uJeu-!|Lgzd>}uWMUo5{kA<)1ndxs@UZR32fT6pJHGaO!4QH(eAa5+t zS1N59EQ1r6i z<(E$QmAL~w+VkGpLI9*Hnm0tLT@_hjW9JWQXev%DVG3YZJ@}x78{*jc{asC?1L_)h zF^DC#%H`1`O_VrpaQ}@~&1zbs5~&ja^i#ZVXwP!}j8mnEV@;<{Ahw)4%S3LKNFJ3i zaiK4p7j50(Gg`7o7JU5p$cw9Ok3@$*lZ@g;nFZi|2gmE)4`U4Rnm2m{vKk-zbX%kA zCoK32`kIhZtyUTzRW&2mT0PG|s|zU{4QPllcC91scP>F97ZXap<9Bv#F$2P|qk;b&2$rxv~0fH76P8hs?SUZLs6n%pW)x z{94NZ^zuBrMOvmx1jBKr7I^C(e7yj;&kgD*7xRHBhV0n=;gNznW(J%ArEdQ3v2RnW zr(kstOqa&TJ`*F&kJM}we0``YRAQ>!`T?;}wzZgRk(fa^)#2*9%Z+psyrobKU%nac znGGN&)Npn`s=}e$R4yL6IsRDDSF=Ps)Z;1?NH}K#C*jVV4dx0@(DMhJqOL*I6)&L4 z9cLFcW!bbaiw~-ib4#2tjht6tOE}{zD6zU{xlC2$ zI>jGRD=rdrA25&Qq4jqQAhS4A^TEeuR}+ZLmIn&KRN3!3YkB-ej*-b9-c-AE)S%N> zf?x6evrm$2MOQ(b0-<^gvSC_6oBe@p+i`Ajxy1G91_dbm9z>* z`v6e3>~L1a-C*c2`$0^HXjr4(?IN{jFy+;}uvyb!LNh16HAJ)d@63e8GRMmWrMZ&F zv_aLU&4#ktx$@=QM^zZSdGAFn^&JpWIEc06k(WFQd*!&PpmY;wf3>)TvXQM+vqd#z zyU8VT;5@(~T!27u_1N3Z<{-f&SNd-M>^C*BK>cKP5&U7*KXmq@FP2FiN4aT+-1iF~ zfRiPbO{*ky%`uehvD+s~XnH7V{jvXcN8((ts-<3M-#N&I$MX3xlZ!UGg+fiN+}`r5 zkj3AjM%Sj6BRHE5?Q@(GmaEXx+0)r!TPtcgyrsy<^`_Wc*hwyr-;OCdQ4#vF=h5Xj!r_#p6O*Q* z)GM*S@GP^XHnavtL<^TD>&W%F)LS4nt}T73^w2{aE8S?2vByR~WOdM+N!yff<@?z8 zI#ww-Zu3B+Dw2VJIAV7nOX9!ujfO>l`;d|vXtw#0QXN#ak`$I0n8kN5(2;87J-CD? zHmL*sL>eCfe*GTXwvDI2D~K%nI37JKu}-!Po8ExO7L8{#pw*RuB`6KEDkQxqNdG4R zbz*yTL(6Iv2z+#WI#BgSE1!LJckdfI7H#~xxtSQ;JHtJbofI^}g8L7|Kn}2;V?6dd zK9bChE}t-w#v@|YYe!RB4PsH{@hW+RWHlR3f&YL23-N7 zB={^p7mTZ^ud}HaFV%4UvxHK!)luf%KBVaoi+}5rSQwa@bCw;vYHCGARWld==<7kL z=59v02kEeG3Rm_z)Zc3=MXmaA)I9-9T+O+St{6L3)`@2_41VCAA&8E3bj5sZx5x4s zmtI{uQpw=7HHzdjnUy|za5p(fC=*%NXWhuB(Dh_u6(6Y_e%!8tO&OI$^_@sEYZMc) z<_`+vf$U0(c!m5aMnvIZvM^uI5SEj)Z(;;xrCT_CmpZM4!RQ9UsISG;<-MiaiPA(v1+;q7waq z#DaO&yeXX-esRlYcP9QBezojM(;1VYYslzFHa5kqnhTql9tB)(1PR83ymJM)zr}u2 zA!bL-PF~HWs6_&|a2T`59w8gMCgzI0ZUSUfQfl;Ojkd&KMV<)NhcnfxuOH2mUXuwQ zAM*!OvW!{`MXjm7TIXfL-k+n%0dP~x1% zi$3~@96_CUQxT;Gzf^B~3kR0u=7eg2I4Fgw5M>k5m~x;XrP_^xUNLYFvz1}cRTX7r z0lHVaPz&tCq!B@(_+nwtq0RK$#IV+@P;sE{>RX8Bn-rrhrkj}46K*PBvhLdC@?i7h zJjx#Hk>f+3F<_Y0nGofcP^IE@)+(L~Q4*1fl-B_6231_D^dqI(^dhIc= z=LA*Dx+nYb(z7F472oY=W@o*6`ujtJZ|o#z!EAVr%)^Fux|HNxTtvhvDsp6UwTFwJ zM*F1zvWTTAmTD7v5DPy;dkkH$be+d!3z!mh9?~B zP;G9Vwc=}F40A(Sds~L)9PeFHO$%36su`>ADF4lttX|1!{}kJEkmfex*_yNVfSVdD*&UI|G|lX40rxwlAPgKpuk`23wH2sCfRuKK%fnp1R#=<@<9%+; zML4y^o|%u9_V0m5cLefgy9n<{uobfvYeu+aZKo0Ktc|gWw&pasMBNnfI2UHbKn{9O z)8)imqR}+@&r{T;xui0wrvTi{YW)CT-RWebe0G8{202Acf|Llgnqf=$=%XtXfK4Qv z=zT1j1nI9*CySKsm0?}}<#3SfXM2MsnAkgZs>SG?0o-+s-LK%L80d)#K;3u!6;8=5 zX@g4Fm=G<8m!gGW=R{0399feKC9Xe6!If(%Vf-@0mQ7tBX0NzqmY|9qPu^277yohID3?W6U;XA5NfW2T%outqW~PhQ+n&nro#DcM$Z$THW`N zvNBz|DwU7qm-tFK?Q`5dA&PTB@?7}m0eDq==POEw^{A`Fa?qK z&48UqJjKg|to+>?O{Xf0(K=JOzIa?8#vDp}6Rf^uG9;_RQ>Sv54OQdMjViE9g742S zMhS8Ye+*}NihDGfGuOzbNvx`CgC7KR%vHu{O-ehz$6LT4Mk3SiWVM?^5C{rNs<(ci zqw`nSS8I-1*=qA%mSmm%)UgQ`dsW)FynP!Cpz`|ATE_}k?|*Q37_<7=60FiHwB(_h zw5+MMx={v+RgSy*%jLa^{Rki@+7`oxIZt}@^zY`)n@lMhgAPv!!2u;Sa^;2L@?^x z%A-Mrjx%teimuzTAPSO;F~lr&gy>_G4IY{^P*NEOF|%r&ntw4|Ix}Z6Za4>|Vq}%A z6pcxIPQ@tDsnqjX?bEekhr8)RQoOi)#Gg%k8s-M;;psx6&rT16qf|d(x zQm|i=dq2&*4+`a7Tfs#LSH|);MEHt+!b{0d7;B0PK<1QGH_ynoq!E*2hGkz#6O9hV z?$@wob1i#9kmr+^>ORB=Br!O}1{@=Or zo%h~IPq;QRxJrZG=B=N=LCa3_ths#xboN?(E~BHD0#-A0HRWBd% zQcIeW%y@>zZ8l81ks#C7e+hpvP3-w#+7K8!Z#+falSF*kz#{e>Br}RGNxX7AU1lVi zBM!bs|1pEQkrg!e8V!3s{|$r6OO-b5{0em=IHTj>B%>xTM{2fQAz|zH#Py4>+?xni_0O!81gn!QL~C|A^iO>kV^4a_%tZvJM}($5)k4nG z1`n!DqAq7NrQbVbxd2VW=*}I~?A_RaioH~%?eBYLjJ5@FW1Pu+UAm(%H!%U>%pk7} zejlDzFG%i?NWK}?hzUWsKEW}sW!hRv85emvYXb>bj9PjkEJUSs#y-}~vu{`L=EN&3c~hF@`6?yd zt*{wD)SEe5tJzqXKE$Yy+1IchWywJgfw_Q4!wv!!5v&6E{)Mf7)=|Ty$5R8b@U^UT zH*#GGHSYPR@bGZ$75&;Bj!Dh8Z%`1MNltRwF(-lxD(>)-*7(HhmG5nQ+i+Z`;k`|g z%h9)2??XolklwMj)H3$J>HaS9heUSwj9nb|SnvxxR~23MWzjJ&wWNu0GHR|_`D@uU zJcWrzlRcU6ndDlgFI8Lbxu<+@@QxstO@yNH$yd+_nh{q=e4eP<==cK*H3z8Y(t_9COqt4~v_Qlm%pPjo%wZFKfn|@@9(-C_ zTK~A)tQ3f~*E*=hg0)-;lGt;ScvIjOMibwZ4x zJ_UAlwx$oR%6XV>upP2|637WYo24&Q}Y_fL*yf-Q)J=sU0Ln?t+}=J zO{6MCeh7$_?fo>?^zii23s=e9C&jWN+3Wk&N8il?$Rn1TVg8b_3$+-c4t1EpM3jNP1tx-~ZtZSw|kM3YHhY<3yn%Vn1xhDJu% z4Dv4H$I&nplNH^mY?|6wy=hopGrWsK{z&zWzg~2L(?_BXd*1qJV>321H#9~{E*{+K z!e9TFLZas6aujoB{o2~V*B17dvd{&Iqsk3=Epw1yoDK19=8B`6=j}^sM*D%B$mSlQ zX#nr4DX~ji#!=Nj_)ias_^{Y(lA?qcE`a>{=4^TOc?#56oiVbq2ANi8i&=TNn?&pk zt`VtbWh*T;WGoa9?%8a=={cj52ay?-Yi9r)62hP4b&xzbC(HecT>GQPlc<;0Z%*7x zZodr#pCg`OB3`dw!hrntXAoJmo=QMs$@kx$r(LhAPd=epl?(E@ zTyv?TwckxHOeIZy3=>WJv}?OuzDp~badvrF4_ zZAYU~d}%i=v{4M&=+*K|6X*V2+1Qvjc2Ko9YD}ENS~}lpu>xTCv^#n6e-9qt zhV_&E$RMR>%`RQ@$54%E!G$j!61RAW5b~GSPP)}#v)oupgLY4;dEuZK@1+Gg;XV}I$rIL*jyWr z%#b+Fa2-|41c5tm(GN?a8dVl1zFisqiPky)WPO?`%oSsK(Hf&IDaL(r`%S z-2Wn#BoRnHfqGV*!s*;zG-l;5+rkmw$u*-sA!lNdlNI=^8=bE^h^& zEODXG-PWduHouXLwjF4F!(35IXa!Q$a@o0)hwQe^4f(f-JAX*4-Cow;VDb*TZdS@H zqUd9T*+%su%e6L7M5t%M=UJ7V9HyWKQT0MWs3COo66`!uFnY3gmQjYiy2x8XhO@)> z$~WPw(}UW1aF~-s=CIaPH+8kG4exyi}ai$+h{shB*3W0rRF7=mD$#s zvR#Q@SDXD3D^=`Ph`BRQ^{vl_$cFGe&)d~zCy%|q@PdImLSty)@pAQ1>&enPc=}Hc zxK|095i`i|VQrKL0815&JK&dK9DdZJTv=}cxe}!(rRTVQA zz>Br`kSb^ePLUvOWki3xxKlM4deNqbyEV}je3vb|B;s5&FGql9?_#CDoYdH0y-F&x zmmEfNh6h@>F{QJ{ho4NR2lD=9hGNH2oIC_rb$IML zpQS^1(_7Yop5+Vhy%+YHF|E`%=bc9rjv2?=;WM~G<|FyL6?u#%TieI6z;E_?35N=+ z0Ixo25mhW*iKUS!M5jj`B4Aoh4{hmH(BZwuOSArZaffRMr0bkL=(zyx)q{3nGIFCt zP?|CQYOzYk5rJl?01bIJjV$ahRJVSWd3!3Z>FXU+^up2{FBnzM>P|-;XGsVkL5`RF z^7=C zeC2+{=kIBc)0DD5`G_YoUabnci0OMA>;XphacRZ#+lS*D8?ARGW7fDCOLMwkx#)by zx#YDL*_I7FjrWyjTBGud;0GL)qpsT(*rB1J-_=`Uw&ydA;1-mYlcj^y@4#eC#Oae{ zJMzbmnKyLiYBU&+6!x)+AHU8|r(4I|5gXO|yvLXkB8XQ!H zX2baRkI_{jpLFvC2dRbFcD)-@6RwWk6)$7O2aHGPQ4w5Ljz{X^ANl66!{l)US^OWr z7AZob!By7dm7H-cRkSe7adHaySI*vu#vJk0AzD%0Oj~;1NL0@B4>hMui3vafOxJH( z4|j*!N321k^8ELv`Q|voWIy=68f3oF19ight;SN>tLXSx=j7MN<#sD^G zXN=O6OXa?}ym}R~{&5qmA3br7O-gH%p>*6pf0>seX8#r;TT_si#b~RwReA-by-m5@KaM)U^CF;34yDGKb(cEIZa6%3o05E4cb7* z+;9{Ba~%6OZ?QP*qY4Lw{;`lW{Fw2)eDG(3ZA~DV=!e=H;w!?-D#OdFS1(gG zyzFg7o63quNB{kdv#R(Yms~Bi4g9(oQwOYZYF`fcDwZ;-e&+u6T3W7QyfyOLH~hV{ zcv{U@RWmFQUhZo-NV~bPb^B)Ma;IYLenRx_^`LpLomh?w_P?t)9#vU4oFt$%US2J7 zG3u77_b6!)XWOBm!OJr?p02gOc^iVO`vx^92i{QobuWO~{!bcylk#?ZolipoAuKZr5iYfc{YDSBTuZQWm0!K#TmjNYXzrs)cQG&h zs{O^UW3-$Pb6!s4t@cgj;iXW3B7S7t=z3bJhFpwR45Ez8fI41>sx74>ekw!_IkXfy zaL5ml)#=(w-DYW8AfCLQ1e{;|xE}b|M;gTf5I`}KA*Be@mJHPc`IVnmN zKzM}j2YhkQ(rua?wS`rnM9N_)A*)+I#aruc65|6j1X`K72zoM*5Z~k)`YpJg5u#T# z1UnK~t?@aOUqv`d{*9m0_V4EBFisI{SFXLr&WLI~tQ zdF3Fs&^^1nyLsQF`roY8z^SLRWCE{Et)_#r$;h|s@RR6~(s*+?KO^%8-RISZ$H2>s zU{yd|BIT`kpIB5PjcsOqU)MkLBt+l-ru8wdyMpf~uKXlS!ZkG8fCc|ZBT$+q#M{LXUTT@!$(pFyi+Z!=WrIl!ht(fbk6;GJYVD*)Qw*}LClLT+2yS_;POgF zq9xDxnSU7MfAAHf5i3~pi3m+?P6Eyb=Wi3&phKKk`PYcAC-FI3!sn7~p9jc`Cj$Q8 zuHDipWtBYU8|yeb(Ipdt&#=;h?}Loqf`0}UBZ!p$r;RqQfsXP)&wO+4Vflp$K6?&Q z;twAQ9bh;;J&DQ?%~cJxeA4^Usg3;(?o`E|Mm8(tG|Ayr6JOM1hW!Z zqxD=krm74NT!{cb)MHL-r<17RXDy8XM(g;r)EeD?j?WYa&0OkUiQjcxzi13nL8K!H zeDiiC=kH~xEt7u3fCSK42D#NOh42IayWdgWtoKjlQnwdQM6un!^>Q};JNS3NxvanR zz__R3*d{xY)ysy%#g0*R>YHm?_pI#R?Qj044R??sFMD2~Kf4zvu{NBA_$usENKfTS z4Gaw@rs*oK9f_aLy@FV(2ZI);S8rim-Z8N3*Dz@+q80$8+CUpR`}czcAl9#Nm*w` z3|4wuio*VcAN5^%L%@{ESF$qq8bp%5q0YxJqK_}=U17JDLBB@&VnLzg8n{M7<51&(7bIU0jO&t zore{7s{$>&?z~!j{}cowSNOHUwt9R85(Umm&g{Vt?c}9`e7nV{JA^-{`()zWc}mP< z`6vz@TnCDyM`=+5RT8M76SsxK1reI)_I0bypU)^%KHehFfB%DUBrq5-5*yhuSmA{K zg;^?iEVP{?k%jiZ^P{_rUv90*a`V}0T|DlP7nH#NEk?)g@D!tQ88(Hzh=ZT!Ipr*U z`$%5ehv&a@uTgn1q`VV-gj@&HX?$b+@rmi(FbA5?fQfs@S1S0_0zft0jJDHE{%Koh zJ}Yt3x&j;YrLThxA1C?y%Im9L>9sWfg@~pxH)IpP6d7j^Rp84-`?w#;l8_>mLOU$b zsHSafe6DIKD~U7^dD|Fa5hAcEABzc6^Ktz%I<)h8d7rUL$;n|Or^b9< zreSTSTbv4S4e zb+4F~=Rivm>wW8;?bgzr-caIP$LEvo{?<~D?wb*f zZzmBM!r>(u$Kar};P##{zdSDu1fuBpt zTQBv*X8N3?HakuultkMtd4Q8C_V4LnBc ze2rw!s6?G6Uf98Phn-$ud5-UQXr(!yslCjt!C&F2N z42*250>QOtI?~TE?4s8%=3ts;Mezd=8L2BMI?lDT` zd+-%YaKTWgiUykY6;X$SH8WzJweL&qkIL~-{r2?12=un^tCjyE$j^eWlG=R)b31$4 zkO%>Vx<_(5UEW5hTP8D@Bgr(i{ZlwprU{UL2MxN=FqS}t>rLg&(9wFi5&|a?mrz&# zoRbHGs<#$=Op@a|-xV_Vm;kCqZ$2nWvjFWH`@0g7A6!LRVAWKP@LcmdKUJmGD^juJxC{MLX2GZvG;>X!!?68TZ^|$=XepiPnI_ zw7cM~+XO<*d*G+10HH=PNat07nZYlXwM@rPmO7qLXF!Qson(VS$82|Sra<}4PZMZ7c8b7fmPo~Zh5UZ z8?C7AAgO@JmB^Lw$JuK7FPee+iUh%!WLW-D7|TxUKs2)mc23L(zxnOpF{>7~e|-~t zbXysjma)vW3S8&i124Twu-3@uWC36HbFS0tID++G@BkdO@4}9WIp8^;aod!0VE$I4 z5;fO>p#q#OGeyM@^ah^>oA=vc>$sD!WAYKOo00&|IytaQ`xdy*D`N*(3eq_ZuzOw$ zIBQjakA4H}(SHCUoigxU#Jzd`lQpGIf8|7aJx@rPiiDYsd|b{%#vtYR4|TP4qD1Ui#tqq>Y+bmSmg z+z30qxeji#D!^@KHArVQG7@eAhbcu6u%r+A~fUC79DP7T;iz6qqP>aA;GauX-0lUmB1ZVAH z_OsO>oKgUmQ;vh}^my3zVKK~m?Sv9DSJi{!$pfW;*{indelQza2iBidfaQ!sAexo| zPK*$(r)0pcX@wB7vWcC5TJYAZW`DlNGS@ng&Z~hyBLySeI*x!{=iCE7!y4GTv>AMt zmVuXk1^f9L2wK_(A#2#*o0AMKbJJ1-)?5j{o7qg$W{F&hT>Bxi_OzG<&uGuwKfjIf z$8B($p21eRx!}LF0QN3t8K+Sl1g>acoYKfv&v!w}2zD;Lm^6TFX*IadD*~B*3&<8Iz)iOh_N{4x&{fS4xV()0>{SrXIL-de)42zC zT=V_D`JV&mh9hz%a_#%5IRC#BbG?4r5j;ncCegYJHs2kk*xSgs93s}2gYC39u$_8}eepBkHv2-_F}GWG%{AYX9!um( z774GGer*__v8MIZZRi0t{)o=TgM;mtgF{f1@A>Sz*Fx&rV%=tyvBa#2@k$NsUcfkLVHNCNR0SThtHEXFUGQ5}559VhEa7VgnO+;XOl8R) z%Wx(0a#?bB4$McCF=BOQNu+&*GB>nFO;-tl$tt@+bD%d&8R!Sg)$+h*Oc|`77zD05 z=fG#tCGgZOV8n^t5G*xc(g?vTo4GIKKD&%d**)j7>{Y)Q0*q_GcafZ(glY&jsRQqM z)!@Cj7`$|=A!5S=kQ&?p|CQIkb#@k5Pf7rLmK{rG+yvJdSHROK^H{-|CMw+`awT%@ zBWQ2>Wx)0DUyZXwKRL#4{2rn<7lEzz2@uW50;g%|u<6SquzBoJ5PTL4Zu7EX_mb-@ zfvaYuSP3C3Tfl2!IUHQq%CcF;D@!W5l`_f#vPDg>Tfd4+@?2)!WB*nO$4%~YO1av6 z|HX`-3`$wndx0f!=eQ=RDFbDU<8}*PQf5q6@yebw(48^63up|Kz{1zkz~Y^H*g5$u ztp3awJmzJAXjTqe?pLw{ui~l#b}z)Ge=+P?S`TjX3&C;5ZT98Z7uKs|%l{TQAW*QA zQ3{?5%D|nyrS`97ZxzETkSr(!kA;`ObzTN+85<27zl>zr@nNvlJPndr*BOalJbldW zu6yaFmM`e$BoKNp?wt8yTI}ZU_T=vV6@1xJ-`n6Sm`~adn_P~fyN+s9%uO*1JRQwsS zy2CV;K){ZzwL=TRdSV_|>*_e|G@89Q9&<}rdS3$v);7U@(+ZF+$p?GQR9N%L0dSh0 z4i*|mVaMbcu$dAM`_~jgqII+MPTY@kTN}S4J(fV|O~%z{ny00>v^pL$ZwolGwgY^% z8$dj*7|f>zGtxW@J2ayi+2+IMua3g{&%;@gbp!&J-GZ>yb&OL=S!PosuYp}vM#mDC8kv z={xzL#a84DIWH+YwACWibOs&j&=}|mlLzjGDJs6O;`J-A>x(9^(`HL|ta0Y3WG?Dr4Y$zkNVR1QH)TfuKp4eVoC>%nyj zmd!RpuyGR{SXU3nEf_IRJqs2SPO_651J;w0!C`tTh-RmOn?Wkei0?p>umO%+)p+L} zRT#9^|D-}UE`h*b)D(8Sm*HPyeqc>Wc+`d_aQ?g*Hmg^{mJjd3?!|Xt-w>+`8rkakE=YB&z+1l(r1Pu5XUQGz-?bWl8CI%Y<5uLF1N{Uq z^+f2X9JJI?J;Y_Ls7=fnbQG-LYhugy3t&GbnH^+2OSN-BGQWhqL9isEhGn1C?29rY zHDsi^t_^}$H$a4W3xus}VSjFffK_tvSyT?eYpPkwUkSbjmF%Qd!#?(Nht`*a``k>h zo0I`A)3aF?n+|3Z!eFP?aR^va0It(2!SS~famu?$wP99*>Tv!5>mAH8~(xn2clZT5LzmBLKbNSHi8lK4_j##EKS?8yVYQS@cx z8UtI@8(BJk58QM!VB7c@Muu6O*MO&P8OuPM*&BjouZD8i%ib`7#?`Qwy-oHQGcsMt zvRn3630P6XveibAu~hwlNjvx%RKf10g>Z093&d_G9T$tvD*Eta`X zRSAG)ujj(Hj|xFF?+kd(y9{o#&w+Se9(XLg12QAbLTe#JAO|n@wg@s|>HNkPh}iHQ z_%APmgY3kFnKi=E9c>V{z6rb+-G{I>55U{75JJ|<*$FIV+3g*$7=Ik>7`g5oe+F#7 zP2)5YYwZ}=FDQi_U)%+UcOHOX=zS2pQ4YIjH^I?O3fQ+)9(ygaV=3L-1VYc?{^iCm z4sE+B+h=k+9B1z>`!F1|RS$si>-lUMUceHwIWJ|MP(pmNnGffMmQ*Fhmh6v5VEQX{Fbt; zl##Fh@(M<}b=>MXbWH;U88t$vaT`cMaayu1HPo zl;i_Y(DA`h$D1ypD{me?wBar+dp{B;4R8k?)o{=q6wi{NYA{i|3zowhz;0v{h{v{q zNcSQLXU4tDCu%@Zl}3 zj3XLguW==W7`HI;t>@}peU=t;yc1^H0=v|NatLE2(x0wA(h~} z^ghQIK`ZMZa2fk`c|H4mEd;V|-RlcWEtq zTQozcNi9Tfd;k#}+Zftm?{Yb(vmW3269lfR1liJ32wqbLksBT`(yd`{mPR47L&PmDOIx~kY4K6{@vN{ld!#?}nA7SgTa`sj%0+ZM8 zv5R;X=BUPij>Ic;2MIby!)824qAEbuy95) zXulzaZ(g;5X#)dU*6POX(M(qjWzT0NtWqmvxB*+$tHI{I1_(541vlL+u+%&TYrYJE z9TVfhW7ZXLoR$vTzfS!B*?SM5s+P4~ch_HMF9RwFm=o$+>e6KnC?YvXFs-%se{Q|^8|^-)>fZYAxqsSwuQ0o+Yfi=-a{^;_ zzx}*lf87HKx_3})+mEaxy~wugWzd#r^on$%pY&u5`8Gqypkuj5N0DaSPa;Y#S^Fi+ z3W(HviA*zY)h9un-fI%^cPKeNgb=yTo&?n%xj+5di@w0EAg7f*2vfNMpS>60E7^iX zy+@2*Q}l;%+GZT5k4+-O^gSZ!c!AXz@~jB$P5an|NHuwl)7BqQ;xNrHpL;F!P%m-EKEeG>UE;$`*4-3ZLLnd!@JcCukz}DunxbU;%kiV zJrSwhQWdXz1N(o7VFJ42I}Z|69|kj9zjMMadd@9AlAVdHW7I5Bq5#jQ;5vzFvr_8vpA`z&0FY+u$3CaeLZSfvC zM+n^P`;nmEjU;aI(UCzC(>|PW7-7yh!;G8c8ep;3Q)Z(`IsA4qT(8UgPrua?q|{&@ zEPJzui@nAkxJm!;019nB(8w`BLfOZH&m5t0G1e^l=Sxpa;jH5*&e}|o;0_V3zDJek zr*9XIaKF@PjD+_Uk~JU0N8$=R_B7-8)+z)@cfeb=0rC59BSEVVfg2{^vT%&Z^&u?h z_rQq%J~ZcCgx1_3QKS1hD116WILSaY)RFX8mpVcL8iCy&Xia+-`atxth&? zLFD=dCxl1fw7eUM>YS~A1#bc+FR6NjD7C?PcO6`I)xr9w5+v)~NB+?lNIpp7YSNEF z>v0qxpC)Y>L8{?<6rC7D43RIFZIo@^hg>4md`nJDhnX8rHtgYC^JI+v)1VqB2>j`{ zUV^sW7YJ5t4T{majRGznLiV2{(cEK$EEJG__#LuLhfwS|fl?CM94q?S;w{dc7-6sH zSq{?$A0#2}qvLN-e1Z!T+(v{-7yPBJ!%wOe-qM%p%V{JPMZ|U%_c%FB}&1 z!&2}S)ovOkTUl~2w+}6sHYPqZl15c8HghRS0=wfoPaIxf27kF5aFQtPED3q+@nP@_ zZz(OW^6I})uUGY``0cAb=PFy;>Lq^;G6Eq)roOCC{q$!$Y@gwdT{C=1SVO39xwE?K zJ3mITTtC$3?}P#WHI{;9E8Gje??;F#2a#ra2Y!1m!$GtHZW8BN*e^)tCQfXtK@sUf z?vXdhGJlJ_W1NQcp}=+sXNgYpkB%YFx}P*=l3)_jb_wjZZ$N84(g zeir%D@2#{(KqSv{pdjf`H;p<2$h90~IA7^Lg?y_K78c;dw8V7`7kqv}h5HzaY)4S- zJwc<-2x`5)&?xl*70#nLZP88k|1KQ2*O9n(z-`ZE1S+&3P^lRyMo*EhF$K?6LvUKq zha-Y7a9H3W^yjs+g$~lQQdoFEj6{~Zn*z58f*Vc6W^f~}2lg$>#esDxY&~)QVFMU9k!Jcgg~lo1wBajQWi$392o&(IXdQEtOh%osZ$TfdLBHDu@>j@S|AHz%Z3cU8Tv8Avl74E}BvL2_bA0tU?5Z-GCVK4lS z<-D5AzXP3l%~0hlCrXW`8p|qYSGf4kZW?j9y&JioxkkXnizMdx!E*CyBp-N)Gp?^A zZeD!D+uD#<|FCte|I@6qUQdD(_TMK_y#oF9ao9P-8(U{Mv)!Y(y7kXa*!mqOpeOPD z|2XjN_)I?*ca@qE#~dSDDnGjfM*I(PRIrBtXb2}3_9I?-nDpQ|eB~~|RxA%T+ltww zwVP-o{KRg+Pr4aJR^2GJ??WNcYNmM)k?R1m&H9mVJ&e4gBLrikD03yva2`YcF><&D z1Cv$WlTLs7qm|ra{pQ8TCwel>-Xg)^InqqHT(nW-+r1-vA0)A*3*|C_QujfWoR~l% z;eIiVN;MwSM6W~0F@6oZ&6V&LZ%3$n7d#|rgcGko-2NMgP<;*mpN8PIWD2%I-;$IK z`ENsgPA$u?6PpqCO+aUId3P~PV7XD2YXssmBA5Vk!FW*;+e2&f5vbZgcI0hVvHSDz z{s+IT;&nD&{iD>0v5)`KakftHnAnaI=uJ7&6J*Gz(snIYIY(~DJZ z5^L*s&P20b*h1%Uiv{*@uXE{FGXhztfCHPovvZ(5w~=7yCai^@!DZnPyw?vPQLmrv zC%|nd%B{e3qkiosO3$TlAyBp*sRwVP*zpxIEnlL{X#zE#pOJ4lOcXneT#F$R*Vm}< zqUScqv-e` z%ALkh>NJ2_mm#Fm4pGVv;3{4RFWEY>1aA>0{T^=1`*2v`4hic`m~LP;)3<2AAMZoPkykwxZa>TM)b#(Oq?z=XSGs)cDY6?wDOrDRLaV}M6a{uYD03ab zS*Ly?*g;ggllZ!gBGcd%0wiw1aVJ>^>1*(oYC?c)8&XZlQYiMqf898o7xt3{c>puA zA$oJ$**(9wbUB@qa8E2+*V)qoFmqqM66ueBR8kPIYW)P=W&4l8cYdx zP6+qIZOIT~l*W*5!rddQ8IGbAu-$nUo}$fg+1?E2?M;Z&xQDaWZ;@m14#f_`k~>HM<>tuO$W6mK!B&9|Blk=|5v9<=Z`&Q_LHdg;)2rysBoSjitRy-$0W`= zzQ;xXG31%NMyUK91WP=mFQW|}VvUGUe1I&=yGYW1i@?nja9lXRtcMX1tl|9YP@H`l zDtx6xsu}Dq3R1IU*`vaoEV3+F)Hpm@I6#gsm1-slZ5*5YQsB#F;R10Qouy`S?@5ID zrXr*oJ;p_sPZ4#2<35A0KMM0YDX;z(Yg68P18=3~Mw{)mIIuPg67zhqWrjT@=7g|# z>aLkS*iCgid+r5^*^zAWN_=J*#AXN5InL~L>A&5fWGBlZk0kdO%*d4s#c^3WYI7=K zA=pd8Is~VMJqTVuf<*2nfd{(~CVvY-vbR{ydVtJzSZ+LvK5*wvIt@fM zrS)12zn|peby!~gP23IO-lx??)*q4s74Ka3lx~6f>iTc_sk3~ja*zIyntKx4W;hYS zx>I{6H%EZ+(|0x`s6?@R0W2)QCbmdyxv&5ibL9k<>sR9B_&CAkZkr;{m(9eL+v%TM z@@gym9zGlTk;>f$>hKe|iPs}V;|)&iu7KOFD>$*`0wU#}A>ZN!F8B_k+IIkD!X z#@jN?pYuWh|J8CoA0kyA!)@ixBe)##5p8k5px*Bbs@#Xr;5+&^aeV-n-3{;*Yi3_e zIJa}o(RWBv8-nO2%L-zkIN?dw->U@4S=c(d< zbE)(CY+mI)-cxAbgEF^%BH1xC_>Un`^AY?cI^npj9$pen@Yr(&?oxHgws?%x{iE>v zVU$M5XE2$6m&IOn=3Rp3ybJ7$-a9Ls=rsT;^9sr4L@+DEG6-h)KxTFlqg!r87nl30 z$d~&qR4_Y*H5i#WTnbk*l=!o$;dwE-zjznR9Pr%J20t48(v0pRVgGBy z?3#k@qDMF;^csf*?!rKzlj?P-&M9Fc%84SEHo~nO;cN>RfBlvN8_DuqcQT=k$6lgS zZgPtwRT(~_T)r6Wq>)^7*0-ELMzgcSuwS?l#}+)Hzvm@RYP2I%qn6SpOp09e`%qBrIz;yW8DdnPBShv7+;%syow6boA0k=r2?~z&Ax35b zp=-Y2m|!eT)pMu zrPS9JqwhcR;<3E?53LWc_iXf0ZK^M_8cqw5y9w=udC(JRf%?2MYQu3jxS$15+SlMM zc^g{%wbbULAwJKKg#~ua@?=80W2P&1&T@z3oKULYh<59YZ^yTP=fWm>C8=+4E3&x0 z!Q36WzyIX`xk+Sh+fP0ICRhkQh2z3r_-=WJ48s9rnLLA=< z*Xeon?_J-%8WavQt2w2#+-t~gdjlNB>qsb%LvBtIOqSe)@?2{BWZ@k)JV2hs3wV*Z z%FRuNq<|k}_(R!b6_-*aKQ9HlXZuj~BC&PHZa#PHne9u|>I><45%k=Tfrb>{$-hBI z9Lv7pM3n;;4o=kOl|xsc9)|_)v$RNuMQ;!+(T7~iK6aOAZWpXj`CIUn?3nZxZFSR-cP2$@68=YsvI;D0{w>EiMRz{M;1C z^QU0zOnVa9lThSO!y(~j78)=Tyic~ukKUKWNLg!nDgu=*AzZ7mChJ&NTIac!3Oo_u z)xSs03vKn#Tov|SdATR-cAbIdl2m9c%76sF7c_*5p(AvWxh-{pBE%?UAp)8Qa(z6t( zFK}5lGP4ueq%W6KzL)xo`n*c$^IwB5|0UQ6_rQPkDAF`PpxkK)soLG}mZIa^N`mAB zoOp57Ut0;<)*}!l_d3W=>MDHpbi!5a0>ZT~Am<&-YN3?2! zc_hH!LI-klH{Fzp3Xg7_wS9}jYb%&w%JE0B39JK)>ZqMZ!brFi z@tUuYsPPth!sj4HA}S*gitT)MM5r!M6;6k&z)2{~r}jNJjE=ct*KBueo@vEGV%%hw zvcM_q;q#`?i(zvR9F(wyIOO!W%7q5B1kS-s_#Tc4y`cIEUh9UCa$pFjtRBEes;MpC zaEKRI{nam}m3uDYw)=8{pF}&Nw6CJfVG2<)18`qDf+Ki_%EeK8r*& zi>Ni7&2Dn3S5kbD*e6)Ph*f%SB#Wc&nc+{PaR|{Yjrt4oNnAr%I6#3vmCcMw&k2Vp zpFdRQXG29W8`|^F!FJJeSS+~@t@$-jqETI${}hpNGE{^zpeRUUyCfd=d&-b*dKcdE zHO(a_Z#a+iP4PsQSN~J>_SI+Goz?R%>a2==Z?mHm5o)(letZD+zT-&L?1RdJ6zt@4 zf&#TYZNVC-2^2zZUK}iz-XVAQ0`WSJVX(NK03Zf(LLnrm^|w|$_O$Ax?tj!%Y(Ic(-7oN1(+|f5BQ$EhgrQI?bOr07 zKED_W0?G9FZGTs8a!Yn@JPQ$Uiv?unMl-SHVpOX9IYg_WbSxH1H1caMEQF@eSrXP* zSgg7Ub-{cVCQzE6O3w>mBzOxJ3m+5J=F`ZYgS~T;sbL1N_bQSos|cq;RKN)`!hWz9 ztw6NyRm7XL3LyHa7E{OLx%q(k*zPb&vJys+#nL*a3bLdBHC~Lg0*qJQ0Cyci7qj2?qYTdl;;&< zztCkI7V3iif;Vtl@_sU8S3fVV`kP(jX@oid}rpkl^=$ z;krz?%9bNu_hv=vk_D(i($6Bi@7MZ`FV&`>O+>%bGZKWnzczOfk14TX^Wk6 z9NC`6asts%m>&z#dG6F+!yrD_2jYBwP!ddr)Vx5JJs>{k+oRs%3O4V+Wz=wcbnKkz z0mV5vP@Q)chlFpynuOI<@NQy|2ye;i@1~TPLnL6^+XD9`lVsOlkv+MEgY!F}KChgJ zw1_Nw9*JirON!=bRDFICTO1%sqqExl( zL1#qaB zpwd_Qy-l|o@r7!-x0u}?T3=BwJ-X7Gl~ zE+Nl!5M_2F(57>?@!1lM20?1RHzfJJAuZ@f?K23{0>KcQ=SkG+OFsu=>nt0hRewgV zoUn3X16lqU)*sXab69RTN3GmEg#v$8kB-0vUR?E$Qgj3^n;S2^+H+t*6AmqHf#}R& z$nvF-rHRD81vyZfpH8E1I;8nxAU->otW*inY(5EO0yU~2Xf7;(I-SSmx603tV|jku z`y}TDu+d#fD3MJLSS@}5GvSBO5I#ennMR~rMvc1wYQmW$tiI4(mJZd0Tzo4W@(aRP z)m)kdr9~&9x;Pe!ivw{&{4CsLOIyPYE*9Ua$mQeoRbv&2@yNfDd-ec4Q#~ z(YfxdjVlVpvQUBS+!!|D^=*#gB%4=I7tEQIm>m%$ClJI70sIk*fpBZk!9|yQSRj6O zDE0{!u~ZTz!8Ee+1vK&okSG#i&Iy2uP&zx#k*BIqCX3U`%!{P+a-g%Y90n`OS-J{m zmn7!;lkGYOvn4lRvGg9ah+GdYJI_*Jl!Y>&ESyXYof_c6R3g?;77mahN-$V`8ZyE@ zP+1ZM)umC;SWHyBA{oY;GGVki2FJznZ+fT~T^#5c<89FW2dRb8S5BC0Pq}wwQz5K( z6(RM&3)Fi~pe1Aq^+7|p6gGu(Uejz7=}M=sM6uIIQ0_*Z=M?IEh7qv0mBsWW1l?Kt zG+EKc#E^r5AhEYd)p?0P@t4%5v!NgqNzN&l2KxvoFNlZE@>48pU>6^^aKMd`ujm|4 z0)TXu_sT6IP^EsMFh3sqmy|(8Fat^g1Pp@N`EmjYJW>6lmu)k>L=@&F6sS?-(pqo^ za&r>N;uo=5PZ|C&i1P)q6)IdKQ(KS)**P)va}o;?=q;>d@l)+ZMNE9PmgKMr0JVi_ zEM@D+lKZe;{usK#)ht%ag%0!=*FtaU8K^Euh78#)xdnl27WdHFLZ}g~sxKyzT|ktv zG!Y65=x-46!GX0T=8Hn0yxg1JmDWl8Y-d5xRj&^NUuN+H=y$qgwWDvVyYjh4gCCN+ zjn`$tWm^*>Rqmn6VF;IfKjKRC2Q)>Dp&{TS>ioZ=<$+j37ZJ7+A!?Kp3P20wFFyVl5a0-Q@*rgBO+gS=cheu5H&$KVArcSN`83 z>m;&QApZWog`7afu!R8{3ksmWw2}q(rRS13F3g4e{8*w{YIt-GH<`szuh!yxYIq!x zCPIZoQ(|r)S+N`(THFH1HE*H2s1jNvw%ob%;j63u^vasu`!sft!D$d z%92PDSYH~@1DJp+2~%5NK$N?b+USyW?4IKcjYTA~i&LPoFqYmE!QeuAZusPGJ|An(yUL=us0oMYf+B4_PU0;%V1x53)o)ECowrNd`+>QC*l0MS&C|f=U>z zswF|qhV1-sXp`6)uc?9QifcHr>Mf3~d<0E8CdVJcLJ6FWGFV+mjg!bgAOLd0L<}NX zFyB}Pjpg(jk%r;gd?JVt9NkzAll4W=6-mXxwYgATMg+Yq5(j@shyMCdm~Tye5U6#& zrn%yQ8c&>l+qF4s+$37_RZW=kLnNpUB2lRqQL@hwEB6L@h65qrc#y z-zd&|d_twm2b{5*Mve0ql-m!Z;LrftB0l1j(QBBktA(_%7bN&SVY{IV#!FkEyQByw z)^_8R;d`X(z9Ru{hW7F_Cahxf+;QmpGdQrS0DA?)Aw}e>ydVxTf&l~#evn@n3Q7I| zBGz0ky=zipo?noTNIowFz$^d$VzusS5VzD%V{s-_g;QC|2^TsrTvC7iONm_5ptrmTh9YHbWy}5*r=h+e8*V?mhw~4;Fj#t?&W(YxU#2G!xsSYp%n1aXak3e+VOy^DtOeNewv*`)}@g+hrxJL5=?$dhT+Ee=SglC!iRb$c_RBOuYHd`t*CSwi7K$@&dNFR z90`i=5ib6SNVNx%k}r`c-_JxgOLqXp#|BaBI)LWzF*Jnrk+^FJ`I=GKzDHwIPuk5l1Fyy42fzcWckC%_MgSkbuBo$;xSy;_u}yC z258ec2bPz^YQt5?3x~7DtG_ZIN{hp&hT`a^D#$PPV|1#%A_6MQsBwRv4ZE#%B(gbB zrJt3T2E%mYX&l>93H8;1&{!FbeJdhi@?$QHf6T<8^~um#8w&fqIn8Y)uX(qc`8B3i z4Sbq)HD&B*(b0Dq*$3a?ockDZ4BsI^;T__n-y>S`4I)WYW2Ac!A@vNo2ZvDOGJw{Q zk7y)XZ9VxB&5_e+4E%~3x6i0N{uyOfUs31#85LF^Q13B~O1lX-h}L6|fCEdT;s$)X zjklq*q=?#JB?^wx?78kn$u+ab096`1t}qKBG+_sVX2cU z!g0JMtGx2}De^+m=0vVNN`i?nSXB!Bg9W~@+)~EuKNljq~=w5AAJD-#mUd2v-<`A1|Gs4q?m(pZ{?L#xVhaAg@(7bd`RT@#D9 zaJ^g zn+tGkTQO{QmB4s?9(Ak`=zkvz&D8<#GQ69D``?TU@&xXmQ*Tv$P)RlHKNF_>urW&W z2?C^^!hJ(O&X|8jOV}r5X!Q}LK1YJ=0Fo8@5hM4SYBy5U-l5iMoQQP-*Au>=BkmKf zM1IEQ@Xx6A{DiZ1lPIy7Mxpr>YFtN=r8SH?pHVu08cusIlid%3>e5J9ZM*{KZI5VR zFM#9r>nODyp*l{KS`2wQhYJU2uSg~^h=Kf~U=r3099W&(X1F1P7gyz#e{7Lk93f(` zvbf;z_vO%8LDaam0@{mDLt|+Q4A-7vL4QLU^);4c!+Fy)cbEvfK}{iydIFF1|Z6u-<3j?FU{w z_8(O5cf8%2*$3UWKF}kpf8?jrFyC|rMjK9n+x5sv^dedR zQzWdpFj$|0!y8XQ=lhf3wwXI2R>?%v?5BK$sdv!p39#N?2162N(@nW>5xopI(KhNl z!PvJl5cYd>o3B>A;N5EG?^uW4P0mesX^ODjQ`F@kb{;l6t6;vN0@mbayhUHZW7{jF zDSSb-%QQ}NHwWB1jKsbD2ormXB*g*5%l0Equ^UzPV`%W6MxFlN|-Sx;`}$6GM};UbCbC8TMM zvsGNal8+!eKMZ2?U7))rj%w1R#>%)LUa#hrUsZ7z>oPa_p{hrFX)c_1U4tG`sp^tw z99&%t`;E5{B-#t}bq&329QF{IuFr<;o-@#29|I@xY9^w=N>^Fz)pAQdG}i=?pyt4ET^6ji zR4{Qh`za4cx0K<;&N?FDWE|WON1q@1-by<2>h1PtTX|ym-#A${I`uCXv+o&Oi>2MP z-%|t+$xCn)y?|poO6fZ;fz9Si@DRHX@7*M#Y9nY4`2}Y!2av8jiZ}%>OQ0Ju(yx&y z*N1GaQMS_Ra?l5~M}K4?f%b&YXbR`{6PQBviND~i#YYsGOyHu|M-*E0quiknO+gdz zmT953Qb2=l1~gVA!gljj8t{{8;6IP-gCoc}{04SgFXPz8dX|Nvu`)K%Nv?($SLKyo zXE7AX7tvpxS75mIG#s~e;_wfpFkD+i4Z9saJKy5yh8D76#V}f13EgE}icA%Ze>j8v zt21D=qlC@)ANV02$9Ggwr)-AR_97hGkcI;r5@GTaS^OUpm{3}7D}d?dEVxQufF+5s zt>_t;Z_b0owp(gPexdg#`AHifnd@1ICGe&H1Gq?m<}UFX%I=WLZC!rlflyo-=jmFUA{|Rjo6S$fD8SU|( z(Gu|)&0)Xbf;W-t@vkU3LXSs(#s&AUIDPN~&O3fWD+zXx%1s)m^I`ZyHV%JZi4&V| zLw7|stVvL7oIau0b`b7jH|h1Pwg^SuT~>MJH&Rp=Cy4k?Z(M`3~z)2K$)UrHRN6AX)t&M}xk7;n&T?^w4r=Ynygv2!q zUecFgur3kiTe7f!eH8o^T41&{okTYd2i7N$Ko`POrU3!+?Qj++TH3~mb2n<1&eJ6MLWfDnID2O?X?8blYllXmSQmDF1`|t6uNjm~gZq!)Dj1 zI~MePSZ*#LN^!V@ zoMA+2u_X^4(nOgXGf5b0;iuS4RGI^4i5eKJkH-lyqSPHZ@Y&k{lT8`07cIewJykfV zc7su^?apEx-jqcIb()c}&CYVTN;JV$tOfQv>TrDLdANwS&}TP5XDt`MO@WjA+2)Sw zZY7>*{`+caSeL8G#<=Ilcb>-a-6brx>L$?wf7vb~$2{2Ys)ZwcudZU3ad;gKv^$y* zq1=lIsUcL^lEn|6LZ1EzQkBM#sxXWMxjw{6_aaa411>mC5upy@R_a%DBut|%mfNu9 zD=zwcMfC|1R`bs&F#JRU`vrA=M8GDasQ3PWQ-*J8u)YAJP093~o`S)O3fOMBf+IiH z;H2!k$qfBBLHRn9ybu7d{Pv6f%G{una{ZHjqVM3a?K;fY*TQaV3yy8R058c~FxhYh z2iK*+jI8~!?S&+u`Sd&!hCjwrhpnK;M7T+vN3c>m9nZ#bu_8KthU|ScTqLXEuUwC# zJ9FV7bAdW^Cj8_ZVX`@$Xtj*aD`V+e9JzAD>MM5@{&LsgE!z&;9W_K*<#3UzLzwD4 zmLF^UV+I$R=(dzh>*#qk$O{$x8+Bsr^S@LicN~q>ZmzQ1k$2BxOAZXzXTx2h6;9%f z@Q`eQuk1BAN>tJJl@I$p6*RaJ#cr!W@ZKlz6@QK}i9wXwki`%Dj7*}|Or=RA$n>$A zrZ9#a-4S+k!H%fUxSq_#TR-DU6p?GdN1XHeMB+-sYWf*@2S4Jh`4`kUf5171Pq-EL zugEfd!4{oZkhmMJ%Z0DZ6BeQ}`=KgdN2ErC*CTo5cU7FW4T+qTdtcxw`Vcl-8sRS1 z1(!XYj4+PxK8FMAl8GwoVYR)O1Tq&EM5vAuWw0d?^;Nh8N3m+SOPz!9rbH&9CnV0m zVmk?`LL;1{N@2IB2v$4u>3yf*y_e`$>=aIjmcxlUxWB>`mLuyS(+FqD^K|Syf|Rep zQ??l{;!W_A>x8p-13hnqx6Cyd(BERPE&&I=Pk5W=aXECTcanFjnZMN+w+1)(X_r@- z{gi|gyGm(ryNnQ(M|6#EP;G~oTr)ydZX;6jK927pXR$pW`s?H9JGp{rjb}u)*AS&N zh!nL^T=e{idjAhZt;2{E?M4QPY|7pdB*_mU-(Vb9LZ)#e@eA6MCU7nOE1FM!!X^K| zpvr-)ztt4-4}PNh1;s}`q4?-9%8yN=$>(R}m=2QbDIf=Q7H;D0u-ks6&286hUR;$| ze&?YAA_uKiNj)|{U4fhEb)wg59Q+{*MjLWS46ETof@dR^LjqUd0B}Az=+uX@i4AF|2pzljs)0iRjjg z&h?PKM4wv=f29_Ls9q<5y$%-=bPu^Y7LRolyNCe!E_(lCgztL@XNfxcyHa4aC$H;5 z)-#how5ZtZ?j0A&a&i)lNIBS#VC4sN%{$2z+(CqP7Y$N%aFed5L8^_# z!~+ytV7-&RAE^uQl)i#6h1Up?=|PU(6zY9GW$ zXbzepVx7jVl)sR;{){V;KeO!x&stBT(s~L-#*@f7Fo8-U)-DU<%HUFN)A$18uRa$-lTx$Tbn9(VB$SZ%Gw@ttJRcjhtLwAh&e7ikhr(E^xn z&W7>UIJipHAW-QtJY;L&qi}%;H49d|v*9CON4CBKmOIjkL@%@m;m>+}nsCrRzk-mtnW-9Erv|Bxt`!f^IMT zWFNBZ1e+bD_k1-jo$IbgqX5~PY$DBJPhD5B&zpdezA3)nyQp3)xS{W(T2}8Ue!A0Lt^y~uy6Bp| zAYpxp812`H*!L3Any(O|b{C#<%|x*`i1=?IT>S>z_SO)s()U1O9HMp&o-&u|x?Uz{ z(uEYQ5tjJRS^bKm)5uW%fJB*oB+3pTokTW$-w-bQeMEiW09*3f8a0g$I=3l=6Vkt+ z!fqOQhF_3pFom4`pV1oj7Ze(g;(E-#(rd$Q8RpM8caCgi z6A5btcfTw|s*~`^H<10mKpnM=I&dw#h+N%>YLAQO(uG5AyoM~0#xe}ta1&R=8uSU8%PLlQHO71L>r*eMr2lxP{k)m zJw)`X^B(b9eTY#VMxy2b;&flaTka}}NEb4U`U^V?#`TBaPyg;j_Vw+tb*abN)10Nw zcDT@W3{~lXi{vHt|A(qRK$O-~q#F&;HGhjlonE@0w-KaD!m4(gxr0c}E_f@}(?Hlj z-x=pD&e4EbN!PfUg%aXaxXoCm&>sH@S^GwjC`Z><<{P!9DU2iEU<{p!A8|YFXS794 z;a2+3XpR1gOM$=OywhJ$ZTAJGmYlGTB2#A!7d$6Xe0chPliw#^T$NXN<=-lPa!qnR z@(n#fO3g&8NhGkRVY54rMDRQUl^ftBUWz3BTVy%QsFqOYt-;Y-?nrjT`T0vU#VNINuu6vG}8m?wzUdxY~rBVKK#Z}$BjM3viU zJj0p${*12luehG{Gdk$J%RxV*C4i{a{xfP%d_?Ynzal|-5NFLlOkQ;R z%-af(S9s;$6_1rDGG9l4w8IIbY$XY4H4$hVLNy!Mv1pA>oRBz89k`x^wiw}B z&FmaknG)EEXORfrN4owK1S+(^Pw^t+^@&=Qn~9_@z(ejl32+zL+zxokUm)vRPn67A z+XiM~{S`aO`aVXHEp>MNaikC-rBTf@oj{h!AYyf&QhiRs{0uRA50Gm7xFA^PLREA5 z-QVo3X0Da=YWb>G*83?};iP&yBDFecKx=}xLIWbTJBik>Bh$Eti2fBa=^7**c#Zh| z-N-Q;M4a9W_{d*@A6@H{tE^d6FTCET7y30vhTm5(*7$7jK5_H zLhJtQ7@N(A?q zKKCAy44=SeNA|t5L7iUxJ)^&wUAJx&4{8dBkfyL+ZhINIB4lLc>pJ3iyJn(Vvm2@&Q>?(-p>%sxXEOm2tF%eMU#jXBH0V zNce*53IB?gkpGEhzptpWpGJ}C&u!($K5ygo5?tazv$qCEb|%7nM*^Ir3K2?{G;Cip3FUQ0xBg0Xh}5}CcAlt8 zyOmzMf|P@gNeEsbl%B`x+@WLFkYWB92}Grdy04LAI*hpeFOhv{0I_O)$TAv7n(;g2 zS`3j8KSP?~TN2erM6OQ|O=25O!t5k=mc+cGwKVv?*YjKb8-A^#TAzFWP=e9b!Wga2 znsk#}h^0X$PWuMjaQW;WN5Mk5F`c5NRgeH1NEk|Mv+p z4)+k1J}1F_LD#nf*~YJsV)y|5>gN%uOV{|oJ%p&X(sjH|M0*=~hewcaJc_2UDO_}) z!YS2BCaxJuACR~26G~0Kp!MVw?xg*UdpTTa;1_fz{(^I!Q)u@6OHYZ-&%C%Qukgx$ zXYp66F?WkDq{5BE&{(`mN%@zjcjl$S?SjBgeMtJh!jQ>!JxqyfeF0TF!*VszWtwaGSl zie%$kNH*$X0}^+Q@-2H2yZ;^vtOt;5)r&&AVH#B4Aj_u!3=o)e%fz(6yiC|mc ztyoI~&UM7jEIPx_<;ncnv4abYzh9qg7SGG0AAshzhCi?uW$-iz0%_(TL4EQR8GVqHLoH> zy`HG_D(oe55w3QH#Fd0X>l)GL6Qmt@h#=(#66F>mu)B!gPn2eG4e6$L$O1n=010&N zv8P0(kC0+?AE!xBGmLsrU^Rp?r%@Cf`G8`ZPbjgS###Gexec$q6)@c#54&A?u-lWB1G@KUHCLglh5E+9s;6G=psN&D|2LH`C4xa(qkpM>*1(hfdE zmI+-ygXajR!7Ib;ISKAF`v2c^*%FA-d`QImgs$~{oHBcfaE&(Pm_McW--DC%S-Q?Q zk!*0A1|crwatEmfeROSyQ1AW)o$H7}0vkR}wi@BUtqk z(n%n=i7{WLYD8*Zq0Zh#V)=rJNwUFRqOvNlhktyks%fOw(7$H76RgeuJ~e-;v1NM20C@U$Ym8)@&!yK93;P z^YB%yftOq*0u<_zr1cD0hn^QkX|>g)**C@4r#~^fd9hpO+0DKUAI2vCOeQG`5hUQv6&Is4Mj5r-G4ecDlROlM$-$A4X4LJ58b1a|&g4 zUvSQeNbC47$g>zm_K~;9HYZDL{t}soU*nAJ01`>4i>>;QbnrT|4nJVR606mTOrkh0 zmKmbj1YeaZL};}jN%s-`t}6)LcL{!q=iseS2`{BmBFgg1QTk0~;Rff63q89+tAk#6 zRmVI$(U|tqq9*pS-Gzi_HWw3LST&{gSQPu-52*Be<(FX6mK&|zQI%?V|4bo?VW!y~ zoH_msr!0vkEgm39tq$QTtwi>XNYd{jF{SHZ&`HF3i>}diqW%tqX&zq6+j@LSsFKKj2C9-!YFs5jZN^CwjL>}zM5s5AZS;hQ zwTrASQR|_bD71cwY|DEnuzXEoL&wb?lQ`ZbI(vtV!!J?dIEs=JA5i7+7ZTPlR6ioe zWR$3Fg2ZYNnoy^fP^N=u!E@YD&qAz5v_FfNNzYlFWU(J1|&c_j8ZhHnt4QU@PdI;M67@jAB=soTol@2_%>Y&`ufI_)H)O)Qly zT>T3D-#1yDG>qsrL7$!_)B9|H!IjXTaXfC!DEVuDtZSq*d~&3Kaa}aL1-kTj{f5W~F-f%m9kLmWbfSh*+ng`BMWL&TWxm96-M3 z1Sz;DcyNhA*}z3qhb#)|)P}61o)lJ*|2&cF7V1LxN!{+FPW=(h!9UP@htNfQ#{H{b zP!sf?l-nCLN57_HY$4BQ3Z;RwL@JYL4S9nyuN5Ng4I%L&j~P<0Q>3h)A=P0JNw&{$ z&yEzeWhbs$wjtGd5Q(-u^qmGMRG*NW13%xS(E7G@50T_F?QcX5h3NMjheV-EJDJ@O zV*jN3N}>*9$aEc(Vqd27IO0yWka}JxLVZDD`iP_^QXHNO$uj{nnO-~DPRE^;bV0t$ z0@CPx&bgNQ&7(EqHGQ6euE{D&{7K25e~C8DKHYHMj@l!oZ=}yA z61}jEn)9UE&(5JNa9R{_)mbL!byBl?s8S!IHS8k{X+IOeenExf5sFV9q1yI)eeNIk zPALDu3KaZ;QR+P}ty>u`!!or+WQ!`lRU|t+LayrsDoK$gIrJiv-Y@o^qfq`0DaEfT zf({K4B`L3(&~>z3+(%8wTQr{EqmcM5>I42N>4Ca)2e=>i1@|w1Phsv$v}$%~`)$+( zzmgm-tGzP6S!AmW^gNGpBI+z6xJ*)@?2V9aKTe;wfa}(zQtf&X`{xD;$&-mFZ=LC( zM>mSxSBNB^6Nx?{GA6+oVAY2_)jZvVjA)M7L{0b{ zo%13JJ!eoIxQ3eGHRvMW(Yd`LmHG<0n73%YctB)(2z~qq6bCGzJ?bs)+CC+s9ieOb zO3pjqbDVB2Q>gOi-1Pw|*pKLp{24C_e#AiHk0>~~H(Y6BR`RL}6#SZ?*O*V_IL(+! z{TD^OwuHQ+aGGiYcx~M}m$G)cLJv2q_pelG1#eqDCutZ92naJfON{F!YJPp#pQ0z4) z?M*4RBgpX>CuKPyQ)8TSWd)mTI}ELDAGG$pq;l!|l2T2uc}T=MMEeYhZ$b)fljk{2 z1U`p+w|S&GJx8%8h2Zo#1@wEas}XnY`{?&sB-;!jkq9%_;|1=KYUN^8rs@Tev=M3c zBhcE=b}q|A)MKP(pP|xslL&cC+SeMx*3lTbiX!hBQTMgyRwd-`y0VM5m_2mF(Ye!g zYKt+GQvHOs*gaCPTj;*Lht}{nbi|eE?=e;U zlX);v8Cg}J;8%?ln?ZHD-MEQKj#X=!&jPp|sfNh3J^Ced;U-BJ6nYye?B~`hBay=< z>WCog&%Z-c#1UGekI)%?EWV+gM6#`ndLU0VgA7u!Tv<<7jiSVFiHLAmh_cdeQwm=RXC6t& zU+lU{g!mX*B0Kh2V8YFJofSgN;DVIhfE3HJRgXXKa#u8YVdm8(7T1lf+$NV0h@ zeXQxK5jw_W$={ZGt;@04lYzG@^fb~aaFqHB|$*U?*@LPfU z8|@#8{f*iRzZL0w&2$+;ZP2=ezPhLlDZJ<|yp#f0Y2X}Mqu)S(?ErO=Cdnx_h8>|P zY#;UKj?jDk3z5hNv_%uiM7%_G$R_Q(i@I~KNa1nQ{WIhenPxhTN&zj42#`AllI)+z z2rv616niXFC{CgIsryK_A0%~aK&s;q%Kg?!Wlqq(FC-^gva|lLEFgnHlX3+tKr&klag0epy0QNmhin3jUnrG zP2p>#4Es@eb^-Zb6VMS!Hk{i=y?Td8caunS9gnqUw8tFDAVG5kg})b%(G>E%cnx%1 zqR=?{E$Sn`qtJLCO&4BE(|tXW5G%imvok30m?okk0uNZC*Onwtnqc(=_v{T)mFJM0 z+oL#7SsA!NA^JFy9iAb@W=KA}+;dHeX6cS&@}0C+Po>kM zk*-5a)F#RTh@gFVpn``YUZRA~fzP`&`jBo&`)H4QPsF-UukF!|hR=Tjts(Ew5xs*F zQvXGs({xVDXb9diHHMg!ys82PzXz218!f5=R!mHUMZS|1)|+tu(k_L;q*|liqMFoJ z=f%%xzp@K`ycr!ae?dpoPiT!erqK2idT)Fo;yp$cZCB*Ggs#{lv|f0Raw4GKtNWq= zn}T1VKKMInmn!y{MODB$DNdabCAU{`=*~T^Om3w*>Iqn{1ZOUjBh&%-DroMbbAeAju|Cc|}@2=j?_B&3ll=5#}W+X7NZ zS*O!}_v}YWl`hJDxsJ1>u(`PP0!`uU6JSJ{zY&cT=9l@-)Ad+GXY9T#u~HZI22B@t z>3V&U9BSv4w}*dyk?{O*ad_1#?5#qLNotpy2n2T;D-;ZSaz*%zqB$ z>RA-}Orb)(Bn2AIqu#%IB$G&-chz6|5&D?FqAlt(+B9Z#UOPlR&)A3WNP6JG6)y1X zpf%D&q_jaH{vyhFd^B)@NNrYz9B!O^AYpr!>zJ6zTtBH7<;teuT(rvbn39PoE;ywT z`Q>{}BhPhCUQaqRK*wB_^}*5{264x>k5np8J{hE^H`{576srLl6z*rL#*ldGvGmMl z5n&elEQ+^66{%w;b{#3qMC(3DLGVhcm%nY6ylo~OubR%kniPEfxw&YX0t{kH|f?J3_qa~ckG~#bWq=z!4)f%;rhV!qXi++bf3bD&c zxiy~OAVtd_uOp-|hltRIQRFcvrYLMMQ{*>`yAF?0;l(C41KPi=yQA zDd|a7&7e@4`{`It&yhl;cuVrIqteQi?au90Q!-l1#jYeLQlkz={K>V3@Aw}*-<$3>H*D0jhjY!V)mQ9z8#&Rlvy9e08tH5=MRPMMGpbAI{ zr`irtm~Rvnnqb?DZ0BiGuk%Q8d4dv8Qj%`-k{;mpDs}@a@S3LI4dB6wo3xMgysD;U z{Pwnu9?1?*kx0t6A#@#OzD(u=bc_k;FTFwg#T^v-&p>~TZYUSc=#Dp|>+&bGXx@{u zKQQa#54E)#lac~Zpg_TY50$|inpVv_Q>*3!p4|EweOLd22b!PIL+Y(2=m1R@KBDL9 zPo(bNqATtYr2(r%I`2vKy^*{nw=k7@Eh5u(Sb9qHJV+tBE+9`e2lhZwV$+D2b3G@C zEC*yHHplfJz63<(N!CQ*J}*$_wSilwdJy~PCZyA6CtCI+mB_V#4Y7%!a~zFC-UgHh z&Y>Y>19|S_XpZD@;C0lU+d+M}33U-BI@iylTnQY_kX$8qB2)*g(EHz^#*h77 znZzE+iU@2V%>^o672)O?y(~wQ>oO|~D(1N?kcu@Bnev$I91-9!GTcUpC|^hm)s0h~ za;y@M6>+ZO@mMZ~@%U?!^#Bs>dL&)IT?$OX9QxMKq+?7<5lhx0vwbQA&)x!e zNilP~SatA%OqgZ67*Oav30=e%YJykL5VcL@x`X!Ek7x`(94_@&TB{T&Q1DMcZMgYF zZP17Ldi4=1{Xd{9>Sxr29H2VHgx1K9XrV`S@GDdWZAoFLI%o+c{?kOp8$wP+9F{v7 zP@tml-gQ!PpX_rQZ>g77D4rf;MVo3jOkw$|7`5=~3d!_4o2+mOAxAYO4*#WIt3;xM zQUqf+tyqf&$)ED%R+=M|=71EmxW6^UaY*`Ib6t$c^&Lln#~doWwk3Cao3=?OMa_c* zoNvu>8xz%9;6JovXbovznZ@|&&jYrmd6tjK*4 zU78(Khs~l{y^Fin{kR|ZnjNyt`R< zdlO_k%%Iqloxq;px>c795^$^6bt}De4ctEU5Y52{NK^HrR=rL)f=Lv5O`-V$6ZNpZ zRK0#e`HL%1py2-uecGQ-=%Nqm+AhC`F8Tu+LibR4b{n-suEoC7Vh&U7zb-jUcHLs@ zJ~nRQu7C^*w|Taoi%#MZ;QXAz^)1}A?3Hjo{&WZOT;^nufX%eIbD+eVkFzM&g;yOr%5vLPp8FKi>_(Azx=-A;_;ntCWu;plNXpk|O~!8XJ!X-3rk_-;frz5*2iR#sV6pg_Sd6xG4&>h@@piI+S{aeOT4fozW5)2 z#GS%!&lNFUNhT%AD*)uUOd`j5nh3C8icdEzdt@Y)yj>wou+hI)706cPg&9aTuY8Nu>nS5DAFCd;*dG(w# zr`e5YYgNh+fC2>yekEuOTT`_}Zg%Imj#Ajaj0(SHBF28{HRWOx6WnzQ?^A7grGiBn zL5=uhIpQt!qFmYBrNDFMt39F0fE4>-Sr(i<2zVHPC%rf=Q0coRBwHS^Ecshb4aiCd zr+H1Tr*!;bWVso{RqHNo&t~1V>g{2j`cR{>s8vW+fdU1;PSmQ`PxM@QqfU1k94_}> zm$s+dR=r4fG$74xOnO^W9S3D~fZL}Y%TnLmubSpGfP8OKwXPE~rpjw#C0aj}@SY7< zcx07Hl}BH%pX?U@ST?@SRvGEI2C*&Fp6)||`+^J{q}V(k&UH6x`v6HY%ga|Zzzs+eRs|9MaKTx`lZlikqEY5R%}gn7?6;ktN*;b3zPA!(+?J|S$5`SJ5H+=g{nY-g5Mn~Jhr|m z@tjwcc&%s>tRLj%yUz`$+6@igv3<0Y=`dxEx44hEZ(GE$MQh!MT<2L_`nJ)W?rhje zw0^vkV*ji=%WbqST{WU*)0rz4?cZoE<`ptkpg@5F1qyzP_zyN4`RKUL%sc=9002ov JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..e92e4ab --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..7727c41 --- /dev/null +++ b/frontend/src/router/index.js @@ -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 diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..8c5a795 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -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, + } +}) diff --git a/frontend/src/stores/files.js b/frontend/src/stores/files.js new file mode 100644 index 0000000..932abf8 --- /dev/null +++ b/frontend/src/stores/files.js @@ -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, + } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..29324a7 --- /dev/null +++ b/frontend/src/style.css @@ -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; +} diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue new file mode 100644 index 0000000..0711647 --- /dev/null +++ b/frontend/src/views/AdminView.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue new file mode 100644 index 0000000..7b171a9 --- /dev/null +++ b/frontend/src/views/AppLayout.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue new file mode 100644 index 0000000..e4d375f --- /dev/null +++ b/frontend/src/views/CalendarView.vue @@ -0,0 +1,376 @@ + + + + + diff --git a/frontend/src/views/ContactsView.vue b/frontend/src/views/ContactsView.vue new file mode 100644 index 0000000..d05834d --- /dev/null +++ b/frontend/src/views/ContactsView.vue @@ -0,0 +1,206 @@ +