diff --git a/backend/app/api/calendar.py b/backend/app/api/calendar.py index 419be8c..a14a1e4 100644 --- a/backend/app/api/calendar.py +++ b/backend/app/api/calendar.py @@ -2,11 +2,11 @@ import secrets import uuid from datetime import datetime, timezone -from flask import request, jsonify +from flask import request, jsonify, Response from app.api import api_bp from app.api.auth import token_required -from app.extensions import db +from app.extensions import db, bcrypt from app.models.calendar import Calendar, CalendarEvent, CalendarShare from app.models.user import User @@ -166,21 +166,24 @@ def create_event(cal_id): return jsonify({'error': 'Ungueltiges Datumsformat'}), 400 event_uid = str(uuid.uuid4()) + description = (data.get('description') or '').strip() + location = (data.get('location') or '').strip() + rrule = (data.get('recurrence_rule') or '').strip() - # 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', '')) + description, location, rrule) event = CalendarEvent( calendar_id=cal_id, uid=event_uid, ical_data=ical_data, summary=summary, + description=description or None, + location=location or None, dtstart=dtstart_dt, dtend=dtend_dt, all_day=all_day, - recurrence_rule=data.get('recurrence_rule'), + recurrence_rule=rrule or None, ) db.session.add(event) db.session.commit() @@ -202,14 +205,18 @@ def update_event(event_id): data = request.get_json() if 'summary' in data: event.summary = data['summary'].strip() + if 'description' in data: + event.description = (data['description'] or '').strip() or None + if 'location' in data: + event.location = (data['location'] or '').strip() or None if 'dtstart' in data: event.dtstart = datetime.fromisoformat(data['dtstart']) if 'dtend' in data: - event.dtend = datetime.fromisoformat(data['dtend']) + event.dtend = datetime.fromisoformat(data['dtend']) if data['dtend'] else None if 'all_day' in data: event.all_day = data['all_day'] if 'recurrence_rule' in data: - event.recurrence_rule = data['recurrence_rule'] + event.recurrence_rule = (data['recurrence_rule'] or '').strip() or None if 'calendar_id' in data: new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True) if cerr: @@ -218,7 +225,7 @@ def update_event(event_id): event.ical_data = _build_ical( event.uid, event.summary, event.dtstart, event.dtend, - event.all_day, data.get('description', ''), data.get('location', ''), + event.all_day, event.description or '', event.location or '', event.recurrence_rule or '' ) event.updated_at = datetime.now(timezone.utc) @@ -334,19 +341,58 @@ def generate_ical_link(cal_id): if not cal or cal.owner_id != user.id: return jsonify({'error': 'Nicht gefunden'}), 404 - cal.ical_token = secrets.token_urlsafe(32) + data = request.get_json(silent=True) or {} + password = (data.get('password') or '').strip() + + if not cal.ical_token: + cal.ical_token = secrets.token_urlsafe(32) + if password: + cal.ical_password_hash = bcrypt.generate_password_hash(password).decode('utf-8') + elif data.get('clear_password'): + cal.ical_password_hash = None + db.session.commit() return jsonify({ 'ical_url': f'/ical/{cal.ical_token}', 'token': cal.ical_token, + 'has_password': bool(cal.ical_password_hash), }), 200 +@api_bp.route('/calendars//ical-link', methods=['DELETE']) +@token_required +def revoke_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 = None + cal.ical_password_hash = None + db.session.commit() + return jsonify({'message': 'Link zurueckgezogen'}), 200 + + +def _basic_auth_challenge(): + return Response( + 'Kalender erfordert Passwort', 401, + {'WWW-Authenticate': 'Basic realm="Mini-Cloud Kalender"'} + ) + + def ical_export(token): cal = Calendar.query.filter_by(ical_token=token).first() if not cal: return jsonify({'error': 'Nicht gefunden'}), 404 + # Password protection via HTTP Basic (compatible with DAVx5, Apple Cal, + # Thunderbird, curl, etc.). Username is ignored. + if cal.ical_password_hash: + auth = request.authorization + if not auth or not auth.password: + return _basic_auth_challenge() + if not bcrypt.check_password_hash(cal.ical_password_hash, auth.password): + return _basic_auth_challenge() + events = CalendarEvent.query.filter_by(calendar_id=cal.id).all() lines = [ @@ -357,13 +403,11 @@ def ical_export(token): ] 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', @@ -380,6 +424,8 @@ def _format_dt(dt, all_day=False): def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''): + if not dtend: + dtend = dtstart lines = [ 'BEGIN:VEVENT', f'UID:{uid}', diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py index e0a0d8c..06632aa 100644 --- a/backend/app/models/calendar.py +++ b/backend/app/models/calendar.py @@ -12,6 +12,7 @@ class Calendar(db.Model): 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) + ical_password_hash = db.Column(db.String(255), 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)) @@ -29,6 +30,7 @@ class Calendar(db.Model): 'color': self.color, 'description': self.description, 'ical_token': self.ical_token, + 'ical_has_password': bool(self.ical_password_hash), 'created_at': self.created_at.isoformat() if self.created_at else None, } @@ -41,6 +43,8 @@ class CalendarEvent(db.Model): 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) + description = db.Column(db.Text, nullable=True) + location = 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) @@ -55,6 +59,8 @@ class CalendarEvent(db.Model): 'calendar_id': self.calendar_id, 'uid': self.uid, 'summary': self.summary, + 'description': self.description, + 'location': self.location, 'dtstart': self.dtstart.isoformat() if self.dtstart else None, 'dtend': self.dtend.isoformat() if self.dtend else None, 'all_day': self.all_day, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 33a07d3..e030091 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,18 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/rrule": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@fullcalendar/vue3": "^6.1.15", "@primevue/themes": "^4.5.4", "axios": "^1.15.0", "pinia": "^3.0.4", "primeicons": "^7.0.0", "primevue": "^4.5.5", + "rrule": "^2.8.1", "vue": "^3.5.32", "vue-router": "^4.6.4" }, @@ -101,6 +108,65 @@ "tslib": "^2.4.0" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", + "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", + "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", + "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/rrule": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/rrule/-/rrule-6.1.20.tgz", + "integrity": "sha512-5Awk7bmaA97hSZRpIBehenXkYreVIvx8nnaMFZ/LDGRuK1mgbR4vSUrDTvVU+oEqqKnj/rqMBByWqN5NeehQxw==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "rrule": "^2.6.0" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", + "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.20" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/vue3": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.20.tgz", + "integrity": "sha512-8qg6pS27II9QBwFkkJC+7SfflMpWqOe7i3ii5ODq9KpLAjwQAd/zjfq8RvKR1Yryoh5UmMCmvRbMB7i4RGtqog==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "vue": "^3.0.11" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1393,6 +1459,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/primeicons": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz", @@ -1471,6 +1547,15 @@ "dev": true, "license": "MIT" }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1522,9 +1607,7 @@ "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 + "license": "0BSD" }, "node_modules/vite": { "version": "8.0.8", diff --git a/frontend/package.json b/frontend/package.json index 387a8ee..652655e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,11 +9,18 @@ "preview": "vite preview" }, "dependencies": { + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/rrule": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@fullcalendar/vue3": "^6.1.15", "@primevue/themes": "^4.5.4", "axios": "^1.15.0", "pinia": "^3.0.4", "primeicons": "^7.0.0", "primevue": "^4.5.5", + "rrule": "^2.8.1", "vue": "^3.5.32", "vue-router": "^4.6.4" }, diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 9daf48e..8d244b4 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -4,44 +4,24 @@

Kalender

-
-
- -
-
{{ day }}
-
- {{ cell.day }} -
- {{ evt.summary }} -
-
-
+
@@ -62,7 +42,8 @@ - +
@@ -74,16 +55,73 @@
- +
- +
+
+ + +
+
+ +