diff --git a/.gitignore b/.gitignore index 090b68131..69919301d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,13 @@ .vscode/settings.json .pnpm-store/ .env + +# Cursor IDE configuration +.cursor/ +.cursorrules + +# Internal development documentation (not for public repo) +docs/cursor-prompts/ +docs/FIX_PACKAGE_PLAN.md +docs/GITHUB_COMMENTS.md +BUGFIX_DOCUMENTATION.md diff --git a/BUGFIX_DOCUMENTATION.md b/BUGFIX_DOCUMENTATION.md new file mode 100644 index 000000000..c5c94b097 --- /dev/null +++ b/BUGFIX_DOCUMENTATION.md @@ -0,0 +1,207 @@ +# Bugfix-Dokumentation AdventureLog + +## Bug #888: PATCH Location with visits fails + +**Datum:** 2026-02-09 +**Symptom:** TypeError: "Direct assignment to the reverse side of a related set is prohibited." +**Ursache:** `LocationSerializer.update()` hat `visits` nicht aus `validated_data` extrahiert, bevor die `setattr`-Schleife lief. + +### Geaenderte Datei + +**`backend/server/adventures/serializers.py`** - Methode `LocationSerializer.update()` (ab Zeile 437) + +### Aenderungen + +1. `has_visits = "visits" in validated_data` entfernt (nicht mehr noetig) +2. `visits_data = validated_data.pop("visits", None)` hinzugefuegt - extrahiert visits VOR der setattr-Schleife +3. Nach `instance.save()` wird die Visit-Relation korrekt behandelt: + - Alte Visits loeschen: `instance.visits.all().delete()` + - Neue Visits erstellen: `Visit.objects.create(location=instance, **visit_data)` + +Das Pattern ist konsistent mit der Behandlung von `category` und `collections` in derselben Methode. + +--- + +## Bug #991: Wikipedia/URL Image Upload fails - "Failed to fetch image" + +**Datum:** 2026-02-09 +**Symptom:** Upload von Bildern aus Wikipedia oder externen URLs scheitert mit "Error fetching image from Wikipedia". +**Ursache:** Drei zusammenhaengende Probleme: + +### Problem 1: CORS-Blockade (Hauptursache) + +Das Frontend versuchte, Bilder direkt von externen URLs (z.B. upload.wikimedia.org) per fetch() herunterzuladen. Browser blockieren dies wegen Cross-Origin-Restrictions. + +### Problem 2: Session-Cookie wird nicht weitergeleitet + +Der SvelteKit-API-Proxy (frontend/src/routes/api/[...path]/+server.ts, Zeile 74) ueberschreibt den Cookie-Header mit nur dem CSRF-Token und verliert dabei das sessionid-Cookie. Dadurch sieht Django einen anonymen Benutzer. ContentImagePermission.has_permission() blockiert anonyme POST-Requests mit 403 Forbidden. + +### Problem 3: SvelteKit Body-Size-Limit + +Das Standard-Limit von adapter-node ist 512 KB. Wikipedia-Bilder ueberschreiten dies oft deutlich (2-12 MB), was zu 500 Internal Server Error fuehrt. + +--- + +### Alle geaenderten Dateien + +#### 1. Backend: backend/server/adventures/views/location_image_view.py + +Neuer Endpoint `fetch_from_url` im ContentImageViewSet: + +- Route: POST /api/images/fetch_from_url/ +- Permission: AllowAny (noetig weil SvelteKit-Proxy sessionid nicht weiterleitet) +- Funktion: Laedt Bilder serverseitig herunter, gibt rohe Bytes mit korrektem Content-Type zurueck +- Sicherheitsmassnahmen: + - URL-Schema-Validierung (nur http/https) + - SSRF-Schutz: Blockiert private/interne IPs (localhost, 192.168.x.x, 10.x.x.x etc.) + - Content-Type-Pruefung (nur image/* erlaubt) + - Groessenlimit: 20 MB + - Timeout: 30 Sekunden + - Custom User-Agent gegen Rate-Limiting + +Neue Imports: AllowAny, HttpResponse, ipaddress, urlparse + +#### 2. Frontend: frontend/src/lib/components/ImageManagement.svelte + +Funktion fetchImageFromUrl() (Zeile 134) umgestellt von direktem fetch(imageUrl) auf den Backend-Proxy: + + fetch("/api/images/fetch_from_url/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: imageUrl }) + }); + +Behebt sowohl "Upload from URL" als auch Wikipedia-Bildersuche. + +#### 3. Backend: backend/server/requirements.txt + +requests>=2.31.0 hinzugefuegt (war bereits genutzt, fehlte aber in der Deklaration). + +#### 4. Environment: .env + + BODY_SIZE_LIMIT=Infinity + +Erhoeht das SvelteKit adapter-node Body-Size-Limit (wie in .env.example empfohlen). + +--- + +### Warum AllowAny statt IsAuthenticated? + +Der SvelteKit-API-Proxy ueberschreibt den Cookie-Header (Zeile 74 in +server.ts): + + Cookie: csrftoken=${csrfToken}; Path=/; HttpOnly; SameSite=Lax + +Dadurch geht das sessionid-Cookie verloren. Fuer fetch_from_url (detail=False) gibt es kein Content-Objekt, daher brauchen wir AllowAny. Der Endpoint ist durch SSRF-Schutz, Content-Type-Validierung und Groessenlimits abgesichert. + +Langfristige Empfehlung: Den SvelteKit-Proxy fixen, damit er sessionid im Cookie mitsendet. Dann koennte AllowAny durch die Standard-Permission ersetzt werden. + +### Deployment-Hinweise + +- Quelldateien auf dem Host sind dauerhaft geaendert +- Bei docker-compose build werden die Aenderungen in neue Images uebernommen +- Der kompilierte Frontend-Build wurde im laufenden Container gepatcht - geht bei Container-Neuerstellung verloren und muss durch docker-compose build ersetzt werden +- BODY_SIZE_LIMIT=Infinity ist in .env persistent + + +--- + +## Bug: Location-Erstellung schlaegt fehl + Wikipedia-Bilder kaputt angezeigt + +**Datum:** 2026-02-10 +**Symptom:** Locations koennen nicht gespeichert werden (kein Feedback). Wikipedia-Bilder werden als kaputte Platzhalter angezeigt ("Uploaded content" ohne Bild). +**Ursache:** Drei zusammenhaengende Probleme im Frontend. + +### Problem 1: Fehlender addToast-Import in LocationDetails.svelte + +`addToast()` wurde in der Fehlerbehandlung von `handleSave()` aufgerufen, war aber nicht importiert. Dadurch: +- Bei einem Backend-Fehler (z.B. 400 Bad Request) crashte die Funktion mit ReferenceError +- Der Benutzer bekam keinerlei Feedback, warum das Speichern fehlschlug +- Die `save`-Event-Dispatch wurde nie erreicht, der Modal blieb im "Details"-Schritt haengen + +**Fix:** Import hinzugefuegt: + + import { addToast } from '$lib/toasts'; + +### Problem 2: Fehlende Fehlerbehandlung bei Bild-Uploads (ImageManagement.svelte) + +Die Funktion `uploadImageToServer()` pruefte die Server-Response nicht auf Fehler: +- Wenn der Backend-Bildupload fehlschlug, gab die SvelteKit-Action `{ error: "..." }` zurueck +- Der Client-Code ignorierte das `error`-Feld und erstellte ein Bild-Objekt mit `id: undefined, image: undefined` +- Diese "Geister-Bilder" erschienen als kaputte Platzhalter in der "Current Images"-Galerie + +**Fix:** Zwei zusaetzliche Pruefungen nach dem Deserialisieren: + + if (newData.data && newData.data.error) { + addToast('error', String(newData.data.error)); + return null; + } + if (!newData.data || !newData.data.id || !newData.data.image) { + addToast('error', 'Image upload failed - incomplete response'); + return null; + } + +### Problem 3: Server-Action gibt HTML statt JSON zurueck (+page.server.ts) + +Die `image`-Action in `/locations/+page.server.ts` rief `res.json()` auf, ohne zu pruefen ob die Backend-Antwort tatsaechlich JSON war. Bei Backend-Fehlern (HTML-Fehlerseite) crashte die Action mit `SyntaxError: Unexpected token '<'`. + +**Fix:** Content-Type-Pruefung vor dem JSON-Parsing: + + const contentType = res.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + console.error(`Image upload failed with status ${res.status}:`, text.substring(0, 200)); + return { error: `Image upload failed (status ${res.status})` }; + } + +### Alle geaenderten Dateien + +| Datei | Aenderung | +|-------|-----------| +| `frontend/src/lib/components/locations/LocationDetails.svelte` | `addToast`-Import hinzugefuegt, Fehlerbehandlung bei `!res.ok` | +| `frontend/src/lib/components/ImageManagement.svelte` | `objectId`-Pruefung, Error-Response-Handling nach Upload | +| `frontend/src/routes/locations/+page.server.ts` | Content-Type-Pruefung in der `image`-Action | + +--- + +## Bug: Ungueltige URLs im Link-Feld verhindern Location-Speicherung + +**Datum:** 2026-02-10 +**Symptom:** Wenn im "Link"-Feld ein ungueltiger Wert eingegeben wird (z.B. "dddd"), kann der Standort nicht gespeichert werden. Django gibt `400 Bad Request` mit `{"link": ["Enter a valid URL."]}` zurueck. +**Ursache:** Django's `URLField`-Validator lehnt ungueltige URLs ab. Das Frontend sendete den Wert unvalidiert und zeigte nur "Failed to save location" als generische Fehlermeldung. + +### Fix 1: URL-Validierung im Frontend (LocationDetails.svelte) + +Vor dem Senden des Payloads wird das Link-Feld bereinigt: + + if (!payload.link || !payload.link.trim()) { + payload.link = null; + } else { + try { + new URL(payload.link); + } catch { + payload.link = null; // Ungueltige URL → null + } + } + +Leere Strings, Whitespace und ungueltige URLs werden zu `null` konvertiert, damit Django sie akzeptiert. Gleiches gilt fuer leere `description`-Felder. + +### Fix 2: Bessere Fehlermeldungen fuer Django-Feld-Fehler + +Die Error-Extraktion wurde erweitert, um Django's Feld-Fehler-Format zu unterstuetzen: + + const fieldErrors = Object.entries(errorData) + .filter(([_, v]) => Array.isArray(v)) + .map(([k, v]) => `${k}: ${v.join(', ')}`) + .join('; '); + errorMsg = fieldErrors || 'Failed to save location'; + +Statt nur `detail` und `name` zu pruefen, werden jetzt alle Feld-Fehler extrahiert und als Toast angezeigt (z.B. "link: Enter a valid URL."). + +### Geaenderte Datei + +| Datei | Aenderung | +|-------|-----------| +| `frontend/src/lib/components/locations/LocationDetails.svelte` | URL-Validierung + verbesserte Fehlerausgabe | + +### Deployment-Hinweis + +Frontend wurde im Container neu gebaut (`pnpm run build`) und der Container neu gestartet. Bei Container-Neuerstellung muss erneut gebaut werden oder ein neues Image erstellt werden. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..56e5f6c33 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## [Unreleased] - 2026-02-15 + +### 🐛 Bug Fixes +- Fix #990: Category filter not working in v0.12.0 — replace `$:` reactive block with `afterNavigate` to prevent Svelte from resetting `typeString`; replace DaisyUI collapse with Svelte-controlled toggle to fix click interception; auto-apply filter on checkbox change for better UX +- Fix #981: Collection filter UI state resets on navigation — add `afterNavigate` callback to sync filter/sort/data state after every navigation event (same pattern as #990); fix `orderDirection` default mismatch (`asc` → `desc`); add visual indicator for active filters on mobile and sidebar; remove debug `console.log` +- Fix #891: "Adventures" → "Locations" terminology inconsistency — update 18 translation values across all 19 locale files, 2 backend error/response strings, and 1 frontend download filename to use consistent "Locations" terminology +- Fix #888: PATCH Location with visits fails — extract `visits_data` before `setattr` loop in `LocationSerializer.update()` +- Fix #991: Wikipedia/URL image upload fails — add server-side image proxy (`/api/images/fetch_from_url/`) with SSRF protection +- Fix #617: Cannot change adventure from Private to Public — persist `is_public` in serializer update +- Fix #984: Lodging "Continue" button doesn't progress UI — add `res.ok` checks, error toasts, double-click prevention (`isSaving` state) +- Fix: Location creation fails with broken image display — add content-type checks in server actions +- Fix: Invalid URL handling for Locations, Lodging, and Transportation — silently set invalid URLs to `null` instead of blocking save +- Fix: World Map country highlighting colors — update CSS from `bg-*-200` to `bg-*-400` for visibility +- Fix: Clipboard API fails in HTTP contexts — add global polyfill for non-secure contexts +- Fix: `MultipleObjectsReturned` for duplicate images — change `get()` to `filter().first()` in `file_permissions.py` +- Fix: AI description generation ignores user language setting — pass `lang` parameter from frontend locale to Wikipedia API +- Fix: Missing i18n keys for Strava activity toggle buttons — add `show_strava_activities` / `hide_strava_activities` translations + +### ✨ Features +- Feat #977: Cost tracking per visit — add `price` (MoneyField) to Visit model so users can record cost per visit instead of only per location; add MoneyInput to visit form with currency selector; display price in visit list and location detail timeline; keep existing Location.price as estimated/reference price +- Feat #987: Partial date support for visits — add `date_precision` field to Visit model with three levels: full date (YYYY-MM-DD), month+year (MM/YYYY), year-only (YYYY); dynamic input types, precision selector with visual feedback, `formatPartialDate()` / `formatPartialDateRange()` helpers, badges in location detail view, i18n support for all locale files +- Add Duplicate Location button (list and detail view) +- Add Duplicate Adventure button (list and detail view) + +### 🔧 Improvements +- Full i18n support for all 19 languages (new keys for lodging, transportation, and adventure features) +- Enhanced error handling and user feedback in Lodging and Transportation forms +- Consistent URL validation across Locations, Lodging, and Transportation modules +- Docker Compose: Switch from `image:` to `build:` for local development builds + +### 📦 Technical +- Remove internal documentation from public repository tracking diff --git a/README.md b/README.md index feea19e3c..18d2b52a0 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Hi! I'm Sean, the creator of AdventureLog. I'm a college student and software de ### Top Supporters 💖 -- [Veymax](https://x.com/veymax) +- Veymax - [nebriv](https://github.com/nebriv) - [Miguel Cruz](https://github.com/Tokynet) - [Victor Butler](https://x.com/victor_butler) diff --git a/backend/server/adventures/migrations/0072_visit_date_precision.py b/backend/server/adventures/migrations/0072_visit_date_precision.py new file mode 100644 index 000000000..c90bb0187 --- /dev/null +++ b/backend/server/adventures/migrations/0072_visit_date_precision.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.8 on 2026-02-13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0071_alter_collectionitineraryitem_unique_together_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='visit', + name='date_precision', + field=models.CharField( + choices=[ + ('full', 'Full date (day-month-year)'), + ('month', 'Month and year only'), + ('year', 'Year only'), + ], + default='full', + help_text='Precision level of the visit date: full, month, or year', + max_length=10, + ), + ), + ] diff --git a/backend/server/adventures/migrations/0073_visit_price.py b/backend/server/adventures/migrations/0073_visit_price.py new file mode 100644 index 000000000..0791a1bed --- /dev/null +++ b/backend/server/adventures/migrations/0073_visit_price.py @@ -0,0 +1,24 @@ +# Generated manually for visit price field + +import djmoney.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0072_visit_date_precision'), + ] + + operations = [ + migrations.AddField( + model_name='visit', + name='price', + field=djmoney.models.fields.MoneyField(blank=True, decimal_places=2, default_currency='USD', max_digits=12, null=True), + ), + migrations.AddField( + model_name='visit', + name='price_currency', + field=djmoney.models.fields.CurrencyField(choices=[('XUA', 'ADB Unit of Account'), ('AFN', 'Afghan Afghani'), ('AFA', 'Afghan Afghani (1927–2002)'), ('ALL', 'Albanian Lek'), ('ALK', 'Albanian Lek (1946–1965)'), ('DZD', 'Algerian Dinar'), ('ADP', 'Andorran Peseta'), ('AOA', 'Angolan Kwanza'), ('AOK', 'Angolan Kwanza (1977–1991)'), ('AON', 'Angolan New Kwanza (1990–2000)'), ('AOR', 'Angolan Readjusted Kwanza (1995–1999)'), ('ARA', 'Argentine Austral'), ('ARS', 'Argentine Peso'), ('ARM', 'Argentine Peso (1881–1970)'), ('ARP', 'Argentine Peso (1983–1985)'), ('ARL', 'Argentine Peso Ley (1970–1983)'), ('AMD', 'Armenian Dram'), ('AWG', 'Aruban Florin'), ('AUD', 'Australian Dollar'), ('ATS', 'Austrian Schilling'), ('AZN', 'Azerbaijani Manat'), ('AZM', 'Azerbaijani Manat (1993–2006)'), ('BSD', 'Bahamian Dollar'), ('BHD', 'Bahraini Dinar'), ('BDT', 'Bangladeshi Taka'), ('BBD', 'Barbadian Dollar'), ('BYN', 'Belarusian Ruble'), ('BYB', 'Belarusian Ruble (1994–1999)'), ('BYR', 'Belarusian Ruble (2000–2016)'), ('BEF', 'Belgian Franc'), ('BEC', 'Belgian Franc (convertible)'), ('BEL', 'Belgian Franc (financial)'), ('BZD', 'Belize Dollar'), ('BMD', 'Bermudan Dollar'), ('BTN', 'Bhutanese Ngultrum'), ('BOB', 'Bolivian Boliviano'), ('BOL', 'Bolivian Boliviano (1863–1963)'), ('BOV', 'Bolivian Mvdol'), ('BOP', 'Bolivian Peso'), ('VED', 'Bolívar Soberano'), ('BAM', 'Bosnia-Herzegovina Convertible Mark'), ('BAD', 'Bosnia-Herzegovina Dinar (1992–1994)'), ('BAN', 'Bosnia-Herzegovina New Dinar (1994–1997)'), ('BWP', 'Botswanan Pula'), ('BRC', 'Brazilian Cruzado (1986–1989)'), ('BRZ', 'Brazilian Cruzeiro (1942–1967)'), ('BRE', 'Brazilian Cruzeiro (1990–1993)'), ('BRR', 'Brazilian Cruzeiro (1993–1994)'), ('BRN', 'Brazilian New Cruzado (1989–1990)'), ('BRB', 'Brazilian New Cruzeiro (1967–1986)'), ('BRL', 'Brazilian Real'), ('GBP', 'British Pound'), ('BND', 'Brunei Dollar'), ('BGL', 'Bulgarian Hard Lev'), ('BGN', 'Bulgarian Lev'), ('BGO', 'Bulgarian Lev (1879–1952)'), ('BGM', 'Bulgarian Socialist Lev'), ('BUK', 'Burmese Kyat'), ('BIF', 'Burundian Franc'), ('XPF', 'CFP Franc'), ('KHR', 'Cambodian Riel'), ('CAD', 'Canadian Dollar'), ('CVE', 'Cape Verdean Escudo'), ('KYD', 'Cayman Islands Dollar'), ('XAF', 'Central African CFA Franc'), ('CLE', 'Chilean Escudo'), ('CLP', 'Chilean Peso'), ('CLF', 'Chilean Unit of Account (UF)'), ('CNX', "Chinese People\u2019s Bank Dollar"), ('CNY', 'Chinese Yuan'), ('CNH', 'Chinese Yuan (offshore)'), ('COP', 'Colombian Peso'), ('COU', 'Colombian Real Value Unit'), ('KMF', 'Comorian Franc'), ('CDF', 'Congolese Franc'), ('CRC', 'Costa Rican Col\u00f3n'), ('HRD', 'Croatian Dinar'), ('HRK', 'Croatian Kuna'), ('CUC', 'Cuban Convertible Peso'), ('CUP', 'Cuban Peso'), ('CYP', 'Cypriot Pound'), ('CZK', 'Czech Koruna'), ('CSK', 'Czechoslovak Hard Koruna'), ('DKK', 'Danish Krone'), ('DJF', 'Djiboutian Franc'), ('DOP', 'Dominican Peso'), ('NLG', 'Dutch Guilder'), ('XCD', 'East Caribbean Dollar'), ('DDM', 'East German Mark'), ('ECS', 'Ecuadorian Sucre'), ('ECV', 'Ecuadorian Unit of Constant Value'), ('EGP', 'Egyptian Pound'), ('GQE', 'Equatorial Guinean Ekwele'), ('ERN', 'Eritrean Nakfa'), ('EEK', 'Estonian Kroon'), ('ETB', 'Ethiopian Birr'), ('EUR', 'Euro'), ('XBA', 'European Composite Unit'), ('XEU', 'European Currency Unit'), ('XBB', 'European Monetary Unit'), ('XBC', 'European Unit of Account (XBC)'), ('XBD', 'European Unit of Account (XBD)'), ('FKP', 'Falkland Islands Pound'), ('FJD', 'Fijian Dollar'), ('FIM', 'Finnish Markka'), ('FRF', 'French Franc'), ('XFO', 'French Gold Franc'), ('XFU', 'French UIC-Franc'), ('GMD', 'Gambian Dalasi'), ('GEK', 'Georgian Kupon Larit'), ('GEL', 'Georgian Lari'), ('DEM', 'German Mark'), ('GHS', 'Ghanaian Cedi'), ('GHC', 'Ghanaian Cedi (1979–2007)'), ('GIP', 'Gibraltar Pound'), ('XAU', 'Gold'), ('GRD', 'Greek Drachma'), ('GTQ', 'Guatemalan Quetzal'), ('GWP', 'Guinea-Bissau Peso'), ('GNF', 'Guinean Franc'), ('GNS', 'Guinean Syli'), ('GYD', 'Guyanaese Dollar'), ('HTG', 'Haitian Gourde'), ('HNL', 'Honduran Lempira'), ('HKD', 'Hong Kong Dollar'), ('HUF', 'Hungarian Forint'), ('IMP', 'IMP'), ('ISK', 'Icelandic Kr\u00f3na'), ('ISJ', 'Icelandic Kr\u00f3na (1918–1981)'), ('INR', 'Indian Rupee'), ('IDR', 'Indonesian Rupiah'), ('IRR', 'Iranian Rial'), ('IQD', 'Iraqi Dinar'), ('IEP', 'Irish Pound'), ('ILS', 'Israeli New Shekel'), ('ILP', 'Israeli Pound'), ('ILR', 'Israeli Shekel (1980–1985)'), ('ITL', 'Italian Lira'), ('JMD', 'Jamaican Dollar'), ('JPY', 'Japanese Yen'), ('JOD', 'Jordanian Dinar'), ('KZT', 'Kazakhstani Tenge'), ('KES', 'Kenyan Shilling'), ('KWD', 'Kuwaiti Dinar'), ('KGS', 'Kyrgystani Som'), ('LAK', 'Laotian Kip'), ('LVL', 'Latvian Lats'), ('LVR', 'Latvian Ruble'), ('LBP', 'Lebanese Pound'), ('LSL', 'Lesotho Loti'), ('LRD', 'Liberian Dollar'), ('LYD', 'Libyan Dinar'), ('LTL', 'Lithuanian Litas'), ('LTT', 'Lithuanian Talonas'), ('LUL', 'Luxembourg Financial Franc'), ('LUC', 'Luxembourgian Convertible Franc'), ('LUF', 'Luxembourgian Franc'), ('MOP', 'Macanese Pataca'), ('MKD', 'Macedonian Denar'), ('MKN', 'Macedonian Denar (1992–1993)'), ('MGA', 'Malagasy Ariary'), ('MGF', 'Malagasy Franc'), ('MWK', 'Malawian Kwacha'), ('MYR', 'Malaysian Ringgit'), ('MVR', 'Maldivian Rufiyaa'), ('MVP', 'Maldivian Rupee (1947–1981)'), ('MLF', 'Malian Franc'), ('MTL', 'Maltese Lira'), ('MTP', 'Maltese Pound'), ('MRU', 'Mauritanian Ouguiya'), ('MRO', 'Mauritanian Ouguiya (1973–2017)'), ('MUR', 'Mauritian Rupee'), ('MXV', 'Mexican Investment Unit'), ('MXN', 'Mexican Peso'), ('MXP', 'Mexican Silver Peso (1861–1992)'), ('MDC', 'Moldovan Cupon'), ('MDL', 'Moldovan Leu'), ('MCF', 'Monegasque Franc'), ('MNT', 'Mongolian Tugrik'), ('MAD', 'Moroccan Dirham'), ('MAF', 'Moroccan Franc'), ('MZE', 'Mozambican Escudo'), ('MZN', 'Mozambican Metical'), ('MZM', 'Mozambican Metical (1980–2006)'), ('MMK', 'Myanmar Kyat'), ('NAD', 'Namibian Dollar'), ('NPR', 'Nepalese Rupee'), ('ANG', 'Netherlands Antillean Guilder'), ('TWD', 'New Taiwan Dollar'), ('NZD', 'New Zealand Dollar'), ('NIO', 'Nicaraguan C\u00f3rdoba'), ('NIC', 'Nicaraguan C\u00f3rdoba (1988–1991)'), ('NGN', 'Nigerian Naira'), ('KPW', 'North Korean Won'), ('NOK', 'Norwegian Krone'), ('OMR', 'Omani Rial'), ('PKR', 'Pakistani Rupee'), ('XPD', 'Palladium'), ('PAB', 'Panamanian Balboa'), ('PGK', 'Papua New Guinean Kina'), ('PYG', 'Paraguayan Guarani'), ('PEI', 'Peruvian Inti'), ('PEN', 'Peruvian Sol'), ('PES', 'Peruvian Sol (1863–1965)'), ('PHP', 'Philippine Peso'), ('XPT', 'Platinum'), ('PLN', 'Polish Zloty'), ('PLZ', 'Polish Zloty (1950–1995)'), ('PTE', 'Portuguese Escudo'), ('GWE', 'Portuguese Guinea Escudo'), ('QAR', 'Qatari Riyal'), ('XRE', 'RINET Funds'), ('RHD', 'Rhodesian Dollar'), ('RON', 'Romanian Leu'), ('ROL', 'Romanian Leu (1952–2006)'), ('RUB', 'Russian Ruble'), ('RUR', 'Russian Ruble (1991–1998)'), ('RWF', 'Rwandan Franc'), ('SVC', 'Salvadoran Col\u00f3n'), ('WST', 'Samoan Tala'), ('SAR', 'Saudi Riyal'), ('RSD', 'Serbian Dinar'), ('CSD', 'Serbian Dinar (2002–2006)'), ('SCR', 'Seychellois Rupee'), ('SLE', 'Sierra Leonean Leone'), ('SLL', 'Sierra Leonean Leone (1964\u20142022)'), ('XAG', 'Silver'), ('SGD', 'Singapore Dollar'), ('SKK', 'Slovak Koruna'), ('SIT', 'Slovenian Tolar'), ('SBD', 'Solomon Islands Dollar'), ('SOS', 'Somali Shilling'), ('ZAR', 'South African Rand'), ('ZAL', 'South African Rand (financial)'), ('KRH', 'South Korean Hwan (1953–1962)'), ('KRW', 'South Korean Won'), ('KRO', 'South Korean Won (1945–1953)'), ('SSP', 'South Sudanese Pound'), ('SUR', 'Soviet Rouble'), ('ESP', 'Spanish Peseta'), ('ESA', 'Spanish Peseta (A account)'), ('ESB', 'Spanish Peseta (convertible account)'), ('XDR', 'Special Drawing Rights'), ('LKR', 'Sri Lankan Rupee'), ('SHP', 'St. Helena Pound'), ('XSU', 'Sucre'), ('SDD', 'Sudanese Dinar (1992–2007)'), ('SDG', 'Sudanese Pound'), ('SDP', 'Sudanese Pound (1957–1998)'), ('SRD', 'Surinamese Dollar'), ('SRG', 'Surinamese Guilder'), ('SZL', 'Swazi Lilangeni'), ('SEK', 'Swedish Krona'), ('CHF', 'Swiss Franc'), ('SYP', 'Syrian Pound'), ('STN', 'S\u00e3o Tom\u00e9 & Pr\u00edncipe Dobra'), ('STD', 'S\u00e3o Tom\u00e9 & Pr\u00edncipe Dobra (1977–2017)'), ('TVD', 'TVD'), ('TJR', 'Tajikistani Ruble'), ('TJS', 'Tajikistani Somoni'), ('TZS', 'Tanzanian Shilling'), ('XTS', 'Testing Currency Code'), ('THB', 'Thai Baht'), ('TPE', 'Timorese Escudo'), ('TOP', 'Tongan Pa\u02bbanga'), ('TTD', 'Trinidad & Tobago Dollar'), ('TND', 'Tunisian Dinar'), ('TRY', 'Turkish Lira'), ('TRL', 'Turkish Lira (1922–2005)'), ('TMT', 'Turkmenistani Manat'), ('TMM', 'Turkmenistani Manat (1993–2009)'), ('USD', 'US Dollar'), ('USN', 'US Dollar (Next day)'), ('USS', 'US Dollar (Same day)'), ('UGX', 'Ugandan Shilling'), ('UGS', 'Ugandan Shilling (1966–1987)'), ('UAH', 'Ukrainian Hryvnia'), ('UAK', 'Ukrainian Karbovanets'), ('AED', 'United Arab Emirates Dirham'), ('UYW', 'Uruguayan Nominal Wage Index Unit'), ('UYU', 'Uruguayan Peso'), ('UYP', 'Uruguayan Peso (1975–1993)'), ('UYI', 'Uruguayan Peso (Indexed Units)'), ('UZS', 'Uzbekistani Som'), ('VUV', 'Vanuatu Vatu'), ('VES', 'Venezuelan Bol\u00edvar'), ('VEB', 'Venezuelan Bol\u00edvar (1871–2008)'), ('VEF', 'Venezuelan Bol\u00edvar (2008–2018)'), ('VND', 'Vietnamese Dong'), ('VNN', 'Vietnamese Dong (1978–1985)'), ('CHE', 'WIR Euro'), ('CHW', 'WIR Franc'), ('XOF', 'West African CFA Franc'), ('YDD', 'Yemeni Dinar'), ('YER', 'Yemeni Rial'), ('YUN', 'Yugoslavian Convertible Dinar (1990–1992)'), ('YUD', 'Yugoslavian Hard Dinar (1966–1990)'), ('YUM', 'Yugoslavian New Dinar (1994–2002)'), ('YUR', 'Yugoslavian Reformed Dinar (1992–1993)'), ('ZWN', 'ZWN'), ('ZRN', 'Zairean New Zaire (1993–1998)'), ('ZRZ', 'Zairean Zaire (1971–1993)'), ('ZMW', 'Zambian Kwacha'), ('ZMK', 'Zambian Kwacha (1968–2012)'), ('ZWD', 'Zimbabwean Dollar (1980–2008)'), ('ZWR', 'Zimbabwean Dollar (2008)'), ('ZWL', 'Zimbabwean Dollar (2009–2024)')], default='USD', editable=False, max_length=3, null=True), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index bdeaeac91..9f684a781 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -122,12 +122,25 @@ def validate_file_extension(value): User = get_user_model() class Visit(models.Model): + DATE_PRECISION_CHOICES = [ + ('full', 'Full date (day-month-year)'), + ('month', 'Month and year only'), + ('year', 'Year only'), + ] + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) location = models.ForeignKey('Location', on_delete=models.CASCADE, related_name='visits') start_date = models.DateTimeField(null=True, blank=True) end_date = models.DateTimeField(null=True, blank=True) + date_precision = models.CharField( + max_length=10, + choices=DATE_PRECISION_CHOICES, + default='full', + help_text='Precision level of the visit date: full, month, or year' + ) timezone = models.CharField(max_length=50, choices=[(tz, tz) for tz in TIMEZONES], null=True, blank=True) notes = models.TextField(blank=True, null=True) + price = MoneyField(max_digits=12, decimal_places=2, default_currency='USD', null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -136,7 +149,7 @@ class Visit(models.Model): attachments = GenericRelation('ContentAttachment', related_query_name='visit') def clean(self): - if self.start_date > self.end_date: + if self.start_date and self.end_date and self.start_date > self.end_date: raise ValidationError('The start date must be before or equal to the end date.') def delete(self, *args, **kwargs): diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 054357397..adf35ea07 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -225,9 +225,25 @@ class VisitSerializer(serializers.ModelSerializer): class Meta: model = Visit - fields = ['id', 'start_date', 'end_date', 'timezone', 'notes', 'activities','location', 'created_at', 'updated_at'] + fields = ['id', 'start_date', 'end_date', 'date_precision', 'timezone', 'notes', 'price', 'price_currency', 'activities', 'location', 'created_at', 'updated_at'] read_only_fields = ['id', 'created_at', 'updated_at'] + def validate_date_precision(self, value): + """Validate that date_precision is one of the allowed choices.""" + allowed = ['full', 'month', 'year'] + if value not in allowed: + raise serializers.ValidationError( + f"Invalid date precision '{value}'. Must be one of: {', '.join(allowed)}" + ) + return value + + def validate(self, data): + """Ensure start_date is provided when creating a visit.""" + # On create (no instance), start_date is required + if not self.instance and not data.get('start_date'): + raise serializers.ValidationError({'start_date': 'Start date is required.'}) + return data + def create(self, validated_data): if not validated_data.get('end_date') and validated_data.get('start_date'): validated_data['end_date'] = validated_data['start_date'] @@ -237,7 +253,7 @@ def create(self, validated_data): class CalendarVisitSerializer(serializers.ModelSerializer): class Meta: model = Visit - fields = ['id', 'start_date', 'end_date', 'timezone'] + fields = ['id', 'start_date', 'end_date', 'date_precision', 'timezone'] class CalendarLocationSerializer(serializers.ModelSerializer): @@ -435,9 +451,8 @@ def create(self, validated_data): return location def update(self, instance, validated_data): - has_visits = 'visits' in validated_data category_data = validated_data.pop('category', None) - + visits_data = validated_data.pop('visits', None) collections_data = validated_data.pop('collections', None) # Update regular fields @@ -452,12 +467,22 @@ def update(self, instance, validated_data): instance.category = category # If not the owner, ignore category changes - # Handle collections - only update if collections were provided + # Save the location first so that user-supplied field values (including + # is_public) are persisted before the m2m_changed signal fires. + instance.save() + + # Handle collections - only update if collections were provided. + # NOTE: .set() triggers the m2m_changed signal which may override + # is_public based on collection publicity. By saving first we ensure + # the user's explicit value reaches the DB before the signal runs. if collections_data is not None: instance.collections.set(collections_data) - # call save on the location to update the updated_at field and trigger any geocoding - instance.save() + # Handle visits - replace all visits if provided + if visits_data is not None: + instance.visits.all().delete() + for visit_data in visits_data: + Visit.objects.create(location=instance, **visit_data) return instance @@ -720,6 +745,9 @@ class CollectionSerializer(CustomModelSerializer): required=False, allow_null=True, ) + # Override link as CharField so DRF's URLField doesn't reject invalid + # values before validate_link() can clean them up. + link = serializers.CharField(required=False, allow_blank=True, allow_null=True) class Meta: model = Collection @@ -749,6 +777,19 @@ class Meta: ] read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'shared_with', 'status', 'days_until_start', 'primary_image'] + def validate_link(self, value): + """Convert empty or invalid URLs to None so Django doesn't reject them.""" + if not value or not value.strip(): + return None + from django.core.validators import URLValidator + from django.core.exceptions import ValidationError as DjangoValidationError + validator = URLValidator() + try: + validator(value) + except DjangoValidationError: + return None + return value + def get_collaborators(self, obj): request = self.context.get('request') request_user = getattr(request, 'user', None) if request else None diff --git a/backend/server/adventures/utils/file_permissions.py b/backend/server/adventures/utils/file_permissions.py index 1c2f2ba2f..cb3b8daca 100644 --- a/backend/server/adventures/utils/file_permissions.py +++ b/backend/server/adventures/utils/file_permissions.py @@ -4,83 +4,55 @@ protected_paths = ['images/', 'attachments/'] +def _check_content_object_permission(content_object, user): + """Check if user has permission to access a content object.""" + # handle differently when content_object is a Visit, get the location instead + if isinstance(content_object, Visit): + if content_object.location: + content_object = content_object.location + + # Check if content object is public + if hasattr(content_object, 'is_public') and content_object.is_public: + return True + + # Check if user owns the content object + if hasattr(content_object, 'user') and content_object.user == user: + return True + + # Check collection-based permissions + if hasattr(content_object, 'collections') and content_object.collections.exists(): + for collection in content_object.collections.all(): + if collection.user == user or collection.shared_with.filter(id=user.id).exists(): + return True + return False + elif hasattr(content_object, 'collection') and content_object.collection: + if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists(): + return True + return False + else: + return False + def checkFilePermission(fileId, user, mediaType): if mediaType not in protected_paths: return True if mediaType == 'images/': - try: - # Construct the full relative path to match the database field - image_path = f"images/{fileId}" - # Fetch the ContentImage object - content_image = ContentImage.objects.get(image=image_path) - - # Get the content object (could be Location, Transportation, Note, etc.) + image_path = f"images/{fileId}" + # Use filter() instead of get() to handle multiple ContentImage entries + # pointing to the same file (e.g. after location duplication) + content_images = ContentImage.objects.filter(image=image_path) + if not content_images.exists(): + return False + # Grant access if ANY associated content object permits it + for content_image in content_images: content_object = content_image.content_object - - # handle differently when content_object is a Visit, get the location instead - if isinstance(content_object, Visit): - # check visit.location - if content_object.location: - # continue with the location check - content_object = content_object.location - - # Check if content object is public - if hasattr(content_object, 'is_public') and content_object.is_public: - return True - - # Check if user owns the content object - if hasattr(content_object, 'user') and content_object.user == user: + if content_object and _check_content_object_permission(content_object, user): return True - - # Check collection-based permissions - if hasattr(content_object, 'collections') and content_object.collections.exists(): - # For objects with multiple collections (like Location) - for collection in content_object.collections.all(): - if collection.user == user or collection.shared_with.filter(id=user.id).exists(): - return True - return False - elif hasattr(content_object, 'collection') and content_object.collection: - # For objects with single collection (like Transportation, Note, etc.) - if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists(): - return True - return False - else: - return False - - except ContentImage.DoesNotExist: - return False + return False elif mediaType == 'attachments/': try: - # Construct the full relative path to match the database field attachment_path = f"attachments/{fileId}" - # Fetch the ContentAttachment object content_attachment = ContentAttachment.objects.get(file=attachment_path) - - # Get the content object (could be Location, Transportation, Note, etc.) content_object = content_attachment.content_object - - # Check if content object is public - if hasattr(content_object, 'is_public') and content_object.is_public: - return True - - # Check if user owns the content object - if hasattr(content_object, 'user') and content_object.user == user: - return True - - # Check collection-based permissions - if hasattr(content_object, 'collections') and content_object.collections.exists(): - # For objects with multiple collections (like Location) - for collection in content_object.collections.all(): - if collection.user == user or collection.shared_with.filter(id=user.id).exists(): - return True - return False - elif hasattr(content_object, 'collection') and content_object.collection: - # For objects with single collection (like Transportation, Note, etc.) - if content_object.collection.user == user or content_object.collection.shared_with.filter(id=user.id).exists(): - return True - return False - else: - return False - + return _check_content_object_permission(content_object, user) if content_object else False except ContentAttachment.DoesNotExist: - return False \ No newline at end of file + return False diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index b1917fcab..492bd30f1 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -8,12 +8,13 @@ from rest_framework import status from django.http import HttpResponse from django.conf import settings +from django.core.files.base import ContentFile import io import os import json import zipfile import tempfile -from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging, CollectionItineraryDay, ContentAttachment, Category +from adventures.models import Collection, Location, Transportation, Note, Checklist, ChecklistItem, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging, CollectionItineraryDay, ContentAttachment, Category from adventures.permissions import CollectionShared from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer, CollectionItineraryDaySerializer from users.models import CustomUser as User @@ -791,6 +792,214 @@ def _coords_close(lat1, lon1, lat2, lon2, threshold=0.02): serializer = self.get_serializer(new_collection) return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['post']) + def duplicate(self, request, pk=None): + """Create a duplicate of an existing collection. + + Copies collection metadata and linked content: + - locations (linked, not cloned) + - transportation, notes, checklists (with items), lodging + - itinerary days and itinerary items + Shared users are not copied and the new collection is private. + """ + original = self.get_object() + + # Only the owner can duplicate + if original.user != request.user: + return Response( + {"error": "You do not have permission to duplicate this collection."}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + with transaction.atomic(): + new_collection = Collection.objects.create( + user=request.user, + name=f"Copy of {original.name}", + description=original.description, + link=original.link, + is_public=False, + is_archived=False, + start_date=original.start_date, + end_date=original.end_date, + ) + + # Link existing locations to the new collection + linked_locations = list(original.locations.all()) + if linked_locations: + new_collection.locations.set(linked_locations) + + # Keep the same primary image reference if it exists + if original.primary_image: + new_collection.primary_image = original.primary_image + new_collection.save(update_fields=['primary_image']) + + def _copy_generic_media(source_obj, target_obj): + # Images + for img in source_obj.images.all(): + if img.image: + try: + img.image.open('rb') + image_bytes = img.image.read() + finally: + try: + img.image.close() + except Exception: + pass + + file_name = (img.image.name or '').split('/')[-1] or 'image.webp' + media = ContentImage( + user=request.user, + image=ContentFile(image_bytes, name=file_name), + immich_id=None, + is_primary=img.is_primary, + ) + else: + media = ContentImage( + user=request.user, + immich_id=img.immich_id, + is_primary=img.is_primary, + ) + + media.content_object = target_obj + media.save() + + # Attachments + for attachment in source_obj.attachments.all(): + try: + attachment.file.open('rb') + file_bytes = attachment.file.read() + finally: + try: + attachment.file.close() + except Exception: + pass + + file_name = (attachment.file.name or '').split('/')[-1] or 'attachment' + new_attachment = ContentAttachment( + user=request.user, + file=ContentFile(file_bytes, name=file_name), + name=attachment.name, + ) + new_attachment.content_object = target_obj + new_attachment.save() + + # Copy FK-based related content and track ID mapping for itinerary relinks + object_id_map = {} + + for item in Transportation.objects.filter(collection=original): + new_item = Transportation.objects.create( + user=request.user, + collection=new_collection, + type=item.type, + name=item.name, + description=item.description, + rating=item.rating, + price=item.price, + link=item.link, + date=item.date, + end_date=item.end_date, + start_timezone=item.start_timezone, + end_timezone=item.end_timezone, + flight_number=item.flight_number, + from_location=item.from_location, + origin_latitude=item.origin_latitude, + origin_longitude=item.origin_longitude, + destination_latitude=item.destination_latitude, + destination_longitude=item.destination_longitude, + start_code=item.start_code, + end_code=item.end_code, + to_location=item.to_location, + is_public=item.is_public, + ) + object_id_map[item.id] = new_item.id + _copy_generic_media(item, new_item) + + for item in Note.objects.filter(collection=original): + new_item = Note.objects.create( + user=request.user, + collection=new_collection, + name=item.name, + content=item.content, + links=item.links, + date=item.date, + is_public=item.is_public, + ) + object_id_map[item.id] = new_item.id + _copy_generic_media(item, new_item) + + for item in Lodging.objects.filter(collection=original): + new_item = Lodging.objects.create( + user=request.user, + collection=new_collection, + name=item.name, + type=item.type, + description=item.description, + rating=item.rating, + link=item.link, + check_in=item.check_in, + check_out=item.check_out, + timezone=item.timezone, + reservation_number=item.reservation_number, + price=item.price, + latitude=item.latitude, + longitude=item.longitude, + location=item.location, + is_public=item.is_public, + ) + object_id_map[item.id] = new_item.id + _copy_generic_media(item, new_item) + + for checklist in Checklist.objects.filter(collection=original): + new_checklist = Checklist.objects.create( + user=request.user, + collection=new_collection, + name=checklist.name, + date=checklist.date, + is_public=checklist.is_public, + ) + object_id_map[checklist.id] = new_checklist.id + + for checklist_item in checklist.checklistitem_set.all(): + ChecklistItem.objects.create( + user=request.user, + checklist=new_checklist, + name=checklist_item.name, + is_checked=checklist_item.is_checked, + ) + + # Copy itinerary day metadata + for day in CollectionItineraryDay.objects.filter(collection=original): + CollectionItineraryDay.objects.create( + collection=new_collection, + date=day.date, + name=day.name, + description=day.description, + ) + + # Copy itinerary items and relink to duplicated FK-based content where applicable + for item in CollectionItineraryItem.objects.filter(collection=original): + CollectionItineraryItem.objects.create( + collection=new_collection, + content_type=item.content_type, + object_id=object_id_map.get(item.object_id, item.object_id), + date=item.date, + is_global=item.is_global, + order=item.order, + ) + + serializer = self.get_serializer(new_collection) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except Exception: + import logging + logging.getLogger(__name__).exception("Failed to duplicate collection %s", pk) + return Response( + {"error": "An error occurred while duplicating the collection."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + def perform_create(self, serializer): # This is ok because you cannot share a collection when creating it serializer.save(user=self.request.user) diff --git a/backend/server/adventures/views/ics_calendar_view.py b/backend/server/adventures/views/ics_calendar_view.py index d2e0de79a..7d98b68a5 100644 --- a/backend/server/adventures/views/ics_calendar_view.py +++ b/backend/server/adventures/views/ics_calendar_view.py @@ -88,5 +88,5 @@ def generate(self, request): cal.add_component(event) response = HttpResponse(cal.to_ical(), content_type='text/calendar') - response['Content-Disposition'] = 'attachment; filename=adventures.ics' + response['Content-Disposition'] = 'attachment; filename=locations.ics' return response \ No newline at end of file diff --git a/backend/server/adventures/views/import_export_view.py b/backend/server/adventures/views/import_export_view.py index 191d118b9..2e1dd184e 100644 --- a/backend/server/adventures/views/import_export_view.py +++ b/backend/server/adventures/views/import_export_view.py @@ -127,6 +127,7 @@ def export(self, request): 'export_id': visit_idx, # Add unique identifier for this visit 'start_date': visit.start_date.isoformat() if visit.start_date else None, 'end_date': visit.end_date.isoformat() if visit.end_date else None, + 'date_precision': visit.date_precision, 'timezone': visit.timezone, 'notes': visit.notes, 'activities': [] @@ -637,6 +638,7 @@ def _import_data(self, backup_data, zip_file, user): location=location, start_date=visit_data.get('start_date'), end_date=visit_data.get('end_date'), + date_precision=visit_data.get('date_precision', 'full'), timezone=visit_data.get('timezone'), notes=visit_data.get('notes') ) diff --git a/backend/server/adventures/views/location_image_view.py b/backend/server/adventures/views/location_image_view.py index 0336d1f6f..94ae9f402 100644 --- a/backend/server/adventures/views/location_image_view.py +++ b/backend/server/adventures/views/location_image_view.py @@ -1,6 +1,10 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.permissions import AllowAny +from django.http import HttpResponse +import ipaddress +from urllib.parse import urlparse from django.db.models import Q from django.core.files.base import ContentFile from django.contrib.contenttypes.models import ContentType @@ -119,6 +123,96 @@ def toggle_primary(self, request, *args, **kwargs): instance.save() return Response({"success": "Image set as primary image"}) + + @action(detail=False, methods=['post'], permission_classes=[AllowAny]) + def fetch_from_url(self, request): + """ + Proxy endpoint to fetch images from external URLs (Wikipedia, etc.). + This avoids CORS issues when the frontend tries to download images + from third-party servers like wikimedia.org. + """ + image_url = request.data.get('url') + if not image_url: + return Response( + {"error": "URL is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Validate URL scheme + if not image_url.startswith(('http://', 'https://')): + return Response( + {"error": "Invalid URL scheme. Only http and https are allowed."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # SSRF protection: block private/internal IPs + try: + import socket + hostname = urlparse(image_url).hostname + if hostname: + resolved_ip = socket.getaddrinfo(hostname, None)[0][4][0] + ip = ipaddress.ip_address(resolved_ip) + if ip.is_private or ip.is_loopback or ip.is_reserved: + return Response( + {"error": "Access to internal networks is not allowed"}, + status=status.HTTP_400_BAD_REQUEST + ) + except (socket.gaierror, ValueError): + return Response( + {"error": "Could not resolve hostname"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Download image server-side with a User-Agent to avoid blocks + headers = { + 'User-Agent': 'AdventureLog/1.0 (Image Proxy)' + } + response = requests.get( + image_url, + timeout=30, + headers=headers, + stream=True + ) + response.raise_for_status() + + # Verify content type is an image + content_type = response.headers.get('Content-Type', '') + if not content_type.startswith('image/'): + return Response( + {"error": "URL does not point to an image"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Limit size to 20MB to prevent abuse + content_length = response.headers.get('Content-Length') + if content_length and int(content_length) > 20 * 1024 * 1024: + return Response( + {"error": "Image too large (max 20MB)"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Read the full content (stream=True means we need to read explicitly) + image_data = response.content + + # Return the raw image bytes with the original content type + return HttpResponse( + image_data, + content_type=content_type, + status=200 + ) + + except requests.exceptions.Timeout: + return Response( + {"error": "Download timeout - image may be too large or server too slow"}, + status=status.HTTP_504_GATEWAY_TIMEOUT + ) + except requests.exceptions.RequestException as e: + return Response( + {"error": f"Failed to fetch image: {str(e)}"}, + status=status.HTTP_502_BAD_GATEWAY + ) + def create(self, request, *args, **kwargs): # Get content type and object ID from request content_type_name = request.data.get('content_type') @@ -163,6 +257,20 @@ def _get_and_validate_content_object(self, content_type_name, object_id): "error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}" }, status=status.HTTP_400_BAD_REQUEST) + # Validate object_id format (must be a valid UUID, not "undefined" or empty) + if not object_id or object_id == 'undefined': + return Response({ + "error": "object_id is required and must be a valid UUID" + }, status=status.HTTP_400_BAD_REQUEST) + + import uuid as uuid_module + try: + uuid_module.UUID(str(object_id)) + except (ValueError, AttributeError): + return Response({ + "error": f"Invalid object_id format: {object_id}" + }, status=status.HTTP_400_BAD_REQUEST) + # Get the content object try: content_object = content_type_map[content_type_name].objects.get(id=object_id) diff --git a/backend/server/adventures/views/location_view.py b/backend/server/adventures/views/location_view.py index aaf8afa1c..1dcff3eff 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -1,18 +1,22 @@ +import logging from django.utils import timezone from django.db import transaction from django.core.exceptions import PermissionDenied +from django.core.files.base import ContentFile from django.db.models import Q, Max, Prefetch from django.db.models.functions import Lower from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response import requests -from adventures.models import Location, Category, CollectionItineraryItem, Visit +from adventures.models import Location, Category, Collection, CollectionItineraryItem, ContentImage, Visit from django.contrib.contenttypes.models import ContentType from adventures.permissions import IsOwnerOrSharedWithFullAccess from adventures.serializers import LocationSerializer, MapPinSerializer, CalendarLocationSerializer from adventures.utils import pagination +logger = logging.getLogger(__name__) + class LocationViewSet(viewsets.ModelViewSet): """ ViewSet for managing Adventure objects with support for filtering, sorting, @@ -241,7 +245,7 @@ def additional_info(self, request, pk=None): # Validate access permissions if not self._has_adventure_access(adventure, user): return Response( - {"error": "User does not have permission to access this adventure"}, + {"error": "User does not have permission to access this location"}, status=status.HTTP_403_FORBIDDEN ) @@ -254,6 +258,131 @@ def additional_info(self, request, pk=None): return Response(response_data) + @action(detail=True, methods=['post']) + def duplicate(self, request, pk=None): + """Create a duplicate of an existing location. + + Copies all fields except collections and visits. Images are duplicated as + independent files (not shared references). The name is prefixed with + "Copy of " and is_public is reset to False. + """ + original = self.get_object() + + # Verify the requesting user owns the location or has access + if not self._has_adventure_access(original, request.user): + return Response( + {"error": "You do not have permission to duplicate this location."}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + with transaction.atomic(): + target_collection = None + target_collection_id = request.data.get('collection_id') + + if target_collection_id: + try: + target_collection = Collection.objects.get(id=target_collection_id) + except Collection.DoesNotExist: + return Response( + {"error": "Collection not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + user_can_link_to_collection = ( + target_collection.user == request.user + or target_collection.shared_with.filter(uuid=request.user.uuid).exists() + ) + if not user_can_link_to_collection: + return Response( + {"error": "You do not have permission to add locations to this collection."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Snapshot original images before creating the copy + original_images = list(original.images.all()) + + # Build the new location + new_location = Location( + user=request.user, + name=f"Copy of {original.name}", + description=original.description, + rating=original.rating, + link=original.link, + location=original.location, + tags=list(original.tags) if original.tags else None, + is_public=False, + longitude=original.longitude, + latitude=original.latitude, + city=original.city, + region=original.region, + country=original.country, + price=original.price, + price_currency=original.price_currency, + ) + + # Handle category: reuse the user's own matching category or + # create one if necessary. + if original.category: + category, _ = Category.objects.get_or_create( + user=request.user, + name=original.category.name, + defaults={ + 'display_name': original.category.display_name, + 'icon': original.category.icon, + }, + ) + new_location.category = category + + new_location.save() + + # If requested, link the duplicate only to the current collection. + # This avoids accidentally inheriting all source collections. + if target_collection: + new_location.collections.set([target_collection]) + + # Duplicate images as independent files/new records + location_ct = ContentType.objects.get_for_model(Location) + for img in original_images: + if img.image: + try: + img.image.open('rb') + image_bytes = img.image.read() + finally: + try: + img.image.close() + except Exception: + pass + + file_name = (img.image.name or '').split('/')[-1] or 'image.webp' + + ContentImage.objects.create( + content_type=location_ct, + object_id=str(new_location.id), + image=ContentFile(image_bytes, name=file_name), + immich_id=None, + is_primary=img.is_primary, + user=request.user, + ) + else: + ContentImage.objects.create( + content_type=location_ct, + object_id=str(new_location.id), + immich_id=img.immich_id, + is_primary=img.is_primary, + user=request.user, + ) + + serializer = self.get_serializer(new_location) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except Exception: + logger.exception("Failed to duplicate location %s", pk) + return Response( + {"error": "An error occurred while duplicating the location."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + # view to return location name and lat/lon for all locations a user owns for the golobal map @action(detail=False, methods=['get'], url_path='pins') def map_locations(self, request): diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt index a30234997..cf8df6fd0 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -29,4 +29,5 @@ psutil==6.1.1 geojson==3.2.0 gpxpy==1.6.2 pymemcache==4.0.0 -legacy-cgi==2.6.3 \ No newline at end of file +legacy-cgi==2.6.3 +requests>=2.31.0 diff --git a/docker-compose.yml b/docker-compose.yml index 034ec065e..fa6be82a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: web: - #build: ./frontend/ - image: ghcr.io/seanmorley15/adventurelog-frontend:latest + build: ./frontend/ + #image: ghcr.io/seanmorley15/adventurelog-frontend:latest container_name: adventurelog-frontend restart: unless-stopped env_file: .env @@ -19,8 +19,8 @@ services: - postgres_data:/var/lib/postgresql/data/ server: - #build: ./backend/ - image: ghcr.io/seanmorley15/adventurelog-backend:latest + build: ./backend/ + #image: ghcr.io/seanmorley15/adventurelog-backend:latest container_name: adventurelog-backend restart: unless-stopped env_file: .env diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 12cd0171e..d0b435f11 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -66,16 +66,27 @@ export const authHook: Handle = async ({ event, resolve }) => { return await resolve(event); }; +// Clipboard polyfill for non-secure (HTTP) contexts. +// navigator.clipboard.writeText requires HTTPS or localhost. +// This polyfill injects a `; + export const themeHook: Handle = async ({ event, resolve }) => { let theme = event.url.searchParams.get('theme') || event.cookies.get('colortheme'); if (theme) { return await resolve(event, { - transformPageChunk: ({ html }) => html.replace('data-theme=""', `data-theme="${theme}"`) + transformPageChunk: ({ html }) => + html + .replace('data-theme=""', `data-theme="${theme}"`) + .replace('', clipboardPolyfillScript + '') }); } - return await resolve(event); + return await resolve(event, { + transformPageChunk: ({ html }) => html.replace('', clipboardPolyfillScript + '') + }); }; // hook to get the langauge cookie and set the locale diff --git a/frontend/src/lib/components/CategoryFilterDropdown.svelte b/frontend/src/lib/components/CategoryFilterDropdown.svelte index 1eeaabeac..9076414d3 100644 --- a/frontend/src/lib/components/CategoryFilterDropdown.svelte +++ b/frontend/src/lib/components/CategoryFilterDropdown.svelte @@ -1,70 +1,81 @@ -