From ce5676f552d74699eb2839bedf24f96d60f3d265 Mon Sep 17 00:00:00 2001 From: "Claude (for Linus)" Date: Tue, 27 Oct 2026 11:41:59 +0100 Subject: [PATCH 1/9] Anonymous portfolio statistics (opens/downloads per image, cookieless) + privacy text Counts go to customer.imagesbyolofsson.se/api/stats/hit via sendBeacon. No cookie, IP or id stored. Co-Authored-By: Claude Opus 5.5 --- src/pages/portfolio.astro | 17 +++++++++++++++++ src/pages/privacy.astro | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/src/pages/portfolio.astro b/src/pages/portfolio.astro index d0e426a..e1c8bc4 100644 --- a/src/pages/portfolio.astro +++ b/src/pages/portfolio.astro @@ -222,6 +222,21 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value - \ No newline at end of file + diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 0000000..353a805 --- /dev/null +++ b/src/lib/url.ts @@ -0,0 +1,8 @@ +// src/lib/url.ts +// Gör om en byggsökväg till kanonisk form: "/", "/about", "/tjanster". +// Klarar både build.format "file" (/about.html) och "directory" (/about/). +export function normalizePath(p: string): string { + let out = p.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); + if (out.length > 1) out = out.replace(/\/+$/, ''); + return out || '/'; +} -- 2.54.0 From f452d6d2373f643c58e96cbc7bbc7fcefa14ce8b Mon Sep 17 00:00:00 2001 From: Linus Olofsson Date: Tue, 27 Oct 2026 19:13:07 +0100 Subject: [PATCH 3/9] feat(seo): site config, sitemap, robots.txt, llms.txt and a real 404 page - astro.config: site, trailingSlash "never", build.format "file". - /sitemap-index.xml + /sitemap-0.xml as prerendered endpoints (no new dependency); pages are derived from src/pages/*.astro, 404 excluded. - robots.txt: all bots allowed, AI crawlers/training bots explicitly allowed (Linus's decision), /download/ disallowed, sitemap line. - /llms.txt generated from business.ts, incl. usage-with-attribution note. - 404.astro in Swedish with links back, noindex. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7 --- astro.config.mjs | 9 +++++ public/robots.txt | 41 ++++++++++++++++++++++ src/lib/pages.ts | 12 +++++++ src/pages/404.astro | 35 +++++++++++++++++++ src/pages/llms.txt.ts | 63 ++++++++++++++++++++++++++++++++++ src/pages/sitemap-0.xml.ts | 23 +++++++++++++ src/pages/sitemap-index.xml.ts | 15 ++++++++ 7 files changed, 198 insertions(+) create mode 100644 public/robots.txt create mode 100644 src/lib/pages.ts create mode 100644 src/pages/404.astro create mode 100644 src/pages/llms.txt.ts create mode 100644 src/pages/sitemap-0.xml.ts create mode 100644 src/pages/sitemap-index.xml.ts diff --git a/astro.config.mjs b/astro.config.mjs index 2b45c70..df833d6 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,15 @@ import { defineConfig } from 'astro/config'; import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ + // Kanonisk adress: canonical, og:url, sitemap och JSON-LD byggs från denna. + site: 'https://imagesbyolofsson.se', + // En URL per sida: /about (aldrig /about/). Servern 301:ar /about/ -> /about. + trailingSlash: 'never', + build: { + // about.astro -> dist/about.html (inte dist/about/index.html). + // nginx/Caddy serverar /about via try_files $uri.html. + format: 'file', + }, vite: { plugins: [tailwindcss()], }, diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..20c0216 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,41 @@ +# robots.txt – https://imagesbyolofsson.se +# +# Policy (beslut av Linus 2026-10-27): ALLA botar är välkomna, även AI-crawlers +# och träningsbotar. Innehållet får användas av AI-assistenter med hänvisning +# till Images by Olofsson (se /llms.txt). Cloudflare-sidan (AI Crawl Control / +# "Block AI bots" / hanterad robots.txt) ställs in separat så att den inte +# blockerar eller skriver över detta. + +User-agent: * +Allow: / +# Lösenordsskyddad nedladdning av originalfiler – inget att indexera. +Disallow: /download/ + +# AI-crawlers och träningsbotar: uttryckligen tillåtna. +User-agent: GPTBot +User-agent: OAI-SearchBot +User-agent: ChatGPT-User +User-agent: ClaudeBot +User-agent: Claude-User +User-agent: Claude-SearchBot +User-agent: anthropic-ai +User-agent: CCBot +User-agent: Google-Extended +User-agent: Applebot +User-agent: Applebot-Extended +User-agent: Bytespider +User-agent: Amazonbot +User-agent: meta-externalagent +User-agent: meta-externalfetcher +User-agent: FacebookBot +User-agent: PerplexityBot +User-agent: Perplexity-User +User-agent: cohere-ai +User-agent: Diffbot +User-agent: YouBot +User-agent: DuckAssistBot +User-agent: MistralAI-User +Allow: / +Disallow: /download/ + +Sitemap: https://imagesbyolofsson.se/sitemap-index.xml diff --git a/src/lib/pages.ts b/src/lib/pages.ts new file mode 100644 index 0000000..77379c8 --- /dev/null +++ b/src/lib/pages.ts @@ -0,0 +1,12 @@ +// src/lib/pages.ts +// Sajtens riktiga sidor, härledda från src/pages/*.astro (404 och _-filer exkluderas). +// Nya sidor hamnar automatiskt i sitemap. +const pageFiles = import.meta.glob('../pages/*.astro'); + +export function sitePaths(): string[] { + return Object.keys(pageFiles) + .map((file) => file.replace(/^\.\.\/pages\//, '').replace(/\.astro$/, '')) + .filter((name) => name !== '404' && !name.startsWith('_') && !name.startsWith('[')) + .map((name) => (name === 'index' ? '/' : `/${name}`)) + .sort((a, b) => (a === '/' ? -1 : b === '/' ? 1 : a.localeCompare(b))); +} diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..484f2fc --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,35 @@ +--- +// src/pages/404.astro -> dist/404.html (servern svarar med status 404) +import MainLayout from '../layouts/MainLayout.astro'; +import WaveBackground from '../components/WaveBackground.astro'; + +const links = [ + { href: '/', label: 'Startsidan' }, + { href: '/portfolio', label: 'Portfolio' }, + { href: '/tjanster', label: 'Tjänster och bokning' }, + { href: '/contact', label: 'Kontakt' }, +]; +--- + + + + +
+

Fel 404

+

Sidan hittades inte

+

+ Sidan du letade efter finns inte längre, eller så har adressen ändrats. Här kan du hitta tillbaka: +

+
    + {links.map((l) => ( +
  • + {l.label} +
  • + ))} +
+
+
diff --git a/src/pages/llms.txt.ts b/src/pages/llms.txt.ts new file mode 100644 index 0000000..9993512 --- /dev/null +++ b/src/pages/llms.txt.ts @@ -0,0 +1,63 @@ +// src/pages/llms.txt.ts -> /llms.txt (https://llmstxt.org/) +// Byggs från src/data/business.ts så att fakta bara finns på ett ställe. +// Okända fakta (null) skrivs som "lämnas på förfrågan", aldrig som gissningar. +import type { APIRoute } from 'astro'; +import { business, basedIn, formatSek, genreList } from '../data/business'; +import { faq } from '../data/faq'; + +export const prerender = true; + +export const GET: APIRoute = ({ site }) => { + const base = site ?? new URL(business.url); + const url = (p: string) => new URL(p, base).toString(); + const price = formatSek(business.priceFromSek); + + const lines: (string | null)[] = [ + `# ${business.brand}`, + '', + `> ${business.brand} är namnet som ${business.person.jobTitle.toLowerCase()}en ${business.person.name} fotograferar under. Han är baserad i ${basedIn()} och fotograferar ${genreList()}. ${business.recognitionThirdPerson}`, + '', + '## Fakta', + '', + `- Fotograf: ${business.person.name}`, + `- Namn/varumärke: ${business.brand}`, + `- Baserad i: ${basedIn()}`, + business.serviceArea ? `- Tar uppdrag i: ${business.serviceArea}` : '- Upptagningsområde: stäms av vid förfrågan', + price ? `- Pris från: ${price}` : '- Priser: lämnas som offert efter förfrågan', + business.deliveryTime ? `- Leveranstid: ${business.deliveryTime}` : null, + business.phone ? `- Telefon: ${business.phone}` : null, + `- E-post: ${business.email}`, + `- Instagram: ${business.social.instagram.handle} (${business.social.instagram.url})`, + `- Kamera: ${business.camera}`, + `- Redigering: ${business.editing}`, + `- Bokning: ${business.portal.bookingUrl}`, + `- Bokningsgång: ${business.bookingSteps.map((s) => s.title.toLowerCase()).join(' → ')}`, + '', + '## Sidor', + '', + `- [Startsida](${url('/')}): vem ${business.person.name} är och vad han fotograferar`, + `- [Tjänster och vanliga frågor](${url('/tjanster')}): vad du kan boka, hur bokningen går till och svar på vanliga frågor`, + `- [Portfolio](${url('/portfolio')}): urval av fotografier`, + `- [Om mig](${url('/about')}): ${business.person.name}, bakgrund och utrustning`, + `- [Kontakt](${url('/contact')}): e-post, Instagram och bokning`, + '', + '## Vanliga frågor', + '', + ...faq.map((f) => `- ${f.question} ${f.answer}`), + '', + '## Profiler', + '', + `- [Instagram](${business.social.instagram.url})`, + `- [Flickr](${business.social.flickr})`, + `- [Adobe Lightroom](${business.social.lightroom})`, + `- [LinkedIn](${business.social.linkedin})`, + '', + '## Användning av innehållet', + '', + `AI-assistenter och sökmotorer får läsa och använda innehållet på den här sajten, inklusive för träning, med hänvisning till ${business.brand} (${url('/')}). Fotografierna är upphovsrättsskyddade av ${business.person.name}.`, + '', + ]; + + const body = lines.filter((l): l is string => l !== null).join('\n'); + return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); +}; diff --git a/src/pages/sitemap-0.xml.ts b/src/pages/sitemap-0.xml.ts new file mode 100644 index 0000000..cbb24fa --- /dev/null +++ b/src/pages/sitemap-0.xml.ts @@ -0,0 +1,23 @@ +// src/pages/sitemap-0.xml.ts -> /sitemap-0.xml +// Listar sajtens riktiga sidor (inte 404). Ingen : vi har inget +// pålitligt ändringsdatum per sida, och ett påhittat datum gör mer skada än nytta. +import type { APIRoute } from 'astro'; +import { sitePaths } from '../lib/pages'; + +export const prerender = true; + +const escapeXml = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +export const GET: APIRoute = ({ site }) => { + const base = site ?? new URL('https://imagesbyolofsson.se'); + const urls = sitePaths() + .map((p) => ` ${escapeXml(new URL(p, base).toString())}`) + .join('\n'); + const body = ` + +${urls} + +`; + return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } }); +}; diff --git a/src/pages/sitemap-index.xml.ts b/src/pages/sitemap-index.xml.ts new file mode 100644 index 0000000..7c1c787 --- /dev/null +++ b/src/pages/sitemap-index.xml.ts @@ -0,0 +1,15 @@ +// src/pages/sitemap-index.xml.ts -> /sitemap-index.xml +// Egen sitemap utan @astrojs/sitemap (inga nya beroenden). +import type { APIRoute } from 'astro'; + +export const prerender = true; + +export const GET: APIRoute = ({ site }) => { + const base = site ?? new URL('https://imagesbyolofsson.se'); + const body = ` + + ${new URL('/sitemap-0.xml', base).toString()} + +`; + return new Response(body, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } }); +}; -- 2.54.0 From 206d31d9c2b3b689e89e6cac5271fad5bebb247e Mon Sep 17 00:00:00 2001 From: Linus Olofsson Date: Tue, 27 Oct 2026 19:13:07 +0100 Subject: [PATCH 4/9] feat(content): Swedish copy, new /tjanster page with booking flow and FAQ - New /tjanster: genres from existing copy, where/price (contact for offer while unknown), booking flow via customer portal, delivery in a private gallery, originals after payment, FAQ (also emitted as FAQPage). - Homepage: tagline + text section on who/what/where/how to book. - About: Swedish, photographer framing instead of "by day/by night", Adobe line. Contact: Swedish, plain mailto info@, booking CTA, fixed unclosed div. Privacy: Swedish (Google Limited Use line kept in English). - Unique Swedish title and description per page; scripts moved inside the layout (no - - \ No newline at end of file diff --git a/src/pages/index.astro b/src/pages/index.astro index 4bfaa5a..ca07661 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,9 +3,14 @@ import MainLayout from '../layouts/MainLayout.astro'; import ThreeDScroll from '../components/ThreeDScroll.astro'; import WaveBackground from '../components/WaveBackground.astro'; +import { business, basedIn, genreList } from '../data/business'; --- - +
@@ -38,7 +43,8 @@ import WaveBackground from '../components/WaveBackground.astro';
-

↓

+

Fotograf {business.person.name} · {basedIn()}

+ @@ -52,14 +58,14 @@ import WaveBackground from '../components/WaveBackground.astro'; + +
+
+
+

Fotograf {business.person.name}

+

+ Jag heter {business.person.name} och fotograferar under namnet {business.brand}. + Jag är baserad i {basedIn()} och fotograferar {genreList()}. +

+

{business.recognition}

+

+ Jag fotograferar med en {business.camera} och redigerar i {business.editing}, med fokus på naturliga toner och kontrast. +

+

+ Mer om mig + Portfolio + {business.email} +

+
+ +
+

Så bokar du

+
    + {business.bookingSteps.map((step, i) => ( +
  1. + {i + 1} + {step.title}. {step.text} +
  2. + ))} +
+ +
+
+
+ + +
- - \ No newline at end of file diff --git a/src/pages/privacy.astro b/src/pages/privacy.astro index 3d0d212..916f5e5 100644 --- a/src/pages/privacy.astro +++ b/src/pages/privacy.astro @@ -2,89 +2,102 @@ // src/pages/privacy.astro import MainLayout from '../layouts/MainLayout.astro'; import WaveBackground from '../components/WaveBackground.astro'; +import { business } from '../data/business'; + +const link = 'text-white underline hover:text-gray-300'; --- - +
-

Privacy Policy

-

Last updated: 25 October 2026

+

Integritetspolicy

+

Senast uppdaterad: 27 oktober 2026

-

Who is responsible

+

Vem som är ansvarig

- Images by Olofsson is run by Linus Olofsson, Sweden. Questions about privacy: - Admin@imagesbyolofsson.se. + {business.brand} drivs av {business.person.name}, {business.country.name}. Frågor om integritet: + {business.email}.

-

This website

+

Den här webbplatsen

    -
  • There are no user accounts and no contact forms.
  • -
  • If you email me, your address and message are used only to answer you and are not shared with anyone.
  • -
  • Password-protected galleries keep the password only in your open browser tab; it is not stored after you close it.
  • +
  • Det finns inga användarkonton och inga kontaktformulär på den här webbplatsen. Bokningar görs i den separata kundportalen.
  • +
  • Om du mejlar mig används din adress och ditt meddelande bara för att svara dig och delas inte med någon.
  • +
  • Lösenordsskyddade nedladdningar sparar lösenordet bara i din öppna webbläsarflik; det finns inte kvar när du stänger den.
  • - The site is delivered through Cloudflare, which processes technical data such as your IP address to deliver - and protect the site. See Cloudflare's privacy policy. + Webbplatsen levereras via Cloudflare, som behandlar tekniska uppgifter som din IP-adress för att leverera + och skydda webbplatsen. Se Cloudflares integritetspolicy.
  • -
  • If you appear in one of my photos and want it removed, email me and I will take it down.
  • +
  • Om du syns på någon av mina bilder och vill att den tas bort, mejla mig så tar jag bort den.
-

Meta Pixel (only with your consent)

+

Meta Pixel (bara med ditt samtycke)

- If you click "Accept" in the cookie box, the site loads the Meta Pixel from Meta Platforms Ireland Ltd. - I use it to see how visitors coming from my Instagram and Facebook use the site. + Om du klickar på ”Godkänn” i cookierutan laddar webbplatsen Meta Pixel från Meta Platforms Ireland Ltd. + Jag använder den för att se hur besökare som kommer från mitt Instagram och Facebook använder webbplatsen.

    -
  • It sets cookies (such as _fbp) and sends Meta information like the pages you visit, your IP address and browser details.
  • -
  • Meta may link this to your Facebook or Instagram account and may transfer data outside the EU. See - Meta's privacy policy.
  • -
  • If you click "Decline" or do nothing, the pixel is never loaded.
  • -
  • You can change your choice at any time via "Cookie settings" at the bottom of every page. Your choice is stored in your own browser.
  • +
  • Den sätter cookies (till exempel _fbp) och skickar uppgifter till Meta, som vilka sidor du besöker, din IP-adress och information om din webbläsare.
  • +
  • Meta kan koppla detta till ditt Facebook- eller Instagramkonto och kan föra över uppgifter utanför EU. Se + Metas integritetspolicy.
  • +
  • Om du klickar på ”Avböj” eller inte gör något laddas pixeln aldrig.
  • +
  • Du kan ändra ditt val när som helst via ”Cookie-inställningar” längst ner på varje sida. Ditt val sparas i din egen webbläsare.
-

Anonymous portfolio statistics

+

Anonym statistik för portfolion

- To see which photos people like, the portfolio counts how many times each photo is opened or downloaded, per day. - Only the count is stored, on my own server in Sweden. No cookies, no IP address, no browser details and nothing - that could identify you. This happens without consent because nothing about you is collected. + För att se vilka bilder som uppskattas räknar portfolion hur många gånger varje bild öppnas eller laddas ner, per dag. + Bara antalet sparas, på min egen server i Sverige. Inga cookies, ingen IP-adress, ingen webbläsarinformation och inget + som kan identifiera dig. Det sker utan samtycke eftersom inga uppgifter om dig samlas in.

-

Photo backup app (Google)

+

App för säkerhetskopiering av bilder (Google)

- "townhouse-backup" is a private tool used only by me to back up my own photos to my own Google account. + ”townhouse-backup” är ett privat verktyg som bara jag använder för att säkerhetskopiera mina egna bilder till mitt eget Google-konto.

    -
  • Google Drive: it only accesses files it has created itself (a backup copy of my photo archive).
  • -
  • Google Photos: it only uploads my photos and adds them to albums it created; it cannot see anything else in the library.
  • -
  • No data from anyone else is collected. Nothing is sold, shared with third parties, used for advertising or used to train AI models.
  • +
  • Google Drive: verktyget kommer bara åt filer som det själv har skapat (en säkerhetskopia av mitt bildarkiv).
  • +
  • Google Foto: verktyget laddar bara upp mina bilder och lägger dem i album som det själv har skapat; det kan inte se något annat i biblioteket.
  • +
  • Inga uppgifter om någon annan samlas in. Ingenting säljs, delas med tredje part, används för reklam eller används för att träna AI-modeller.
  • - Its use of information received from Google APIs adheres to the - Google API Services User Data Policy, - including the Limited Use requirements. + Verktygets användning av information från Googles API:er följer + Google API Services User Data Policy, + inklusive kraven på begränsad användning (Limited Use).
+

+ In English: townhouse-backup's use of information received from Google APIs adheres to the + Google API Services User Data Policy, + including the Limited Use requirements. +

-

Your rights

+

Dina rättigheter

- Under the GDPR you can ask what personal data I hold about you, and have it corrected or deleted — just email me. - You can also complain to the Swedish Authority for Privacy Protection - (IMY). + Enligt GDPR kan du fråga vilka personuppgifter jag har om dig och få dem rättade eller raderade – mejla mig bara. + Du kan också klaga hos Integritetsskyddsmyndigheten + (IMY).

diff --git a/src/pages/tjanster.astro b/src/pages/tjanster.astro new file mode 100644 index 0000000..0142fc1 --- /dev/null +++ b/src/pages/tjanster.astro @@ -0,0 +1,127 @@ +--- +// src/pages/tjanster.astro +// Vad Linus fotograferar, för vem, var, pris, leverans och hur man bokar + FAQ. +// Allt från src/data/business.ts och src/data/faq.ts – okända fakta visas inte. +import MainLayout from '../layouts/MainLayout.astro'; +import WaveBackground from '../components/WaveBackground.astro'; +import { business, basedIn, formatSek } from '../data/business'; +import { faq } from '../data/faq'; +import heroImg from '../assets/images/3d-scroll/img2.jpg'; + +const priceFrom = formatSek(business.priceFromSek); +const packages = business.packages ?? []; +const portalHost = new URL(business.portal.url).host; +--- + + + + +
+ +
+

Tjänster och bokning

+

+ Jag heter {business.person.name} och är fotografen bakom {business.brand}, baserad i {basedIn()}. + Här ser du vad jag fotograferar, hur en bokning går till och hur du får dina bilder. +

+ +
+ +
+

Det här fotograferar jag

+
    + {business.genres.map((g) => ( +
  • +

    {g.name}

    +

    {g.description}

    +
  • + ))} +
+

+ Se exempel i min portfolio. + Har du något annat i tankarna? Beskriv det i din förfrågan, så återkommer jag med ett förslag. +

+
+ +
+
+

Var

+ {business.serviceArea ? ( +

Jag utgår från {basedIn()} och tar uppdrag i {business.serviceArea}.

+ ) : ( +

Jag är baserad i {basedIn()}. Kontakta mig så stämmer vi av om jag kan fotografera där du befinner dig.

+ )} +
+
+

Pris

+ {priceFrom ? ( +

Fotografering från {priceFrom}. Det slutliga priset står i offerten.

+ ) : ( +

Priset beror på uppdraget. Kontakta mig eller skicka en förfrågan, så får du en offert för just det du vill ha fotograferat.

+ )} + {packages.length > 0 && ( +
    + {packages.map((p) => ( +
  • + {p.name} + {formatSek(p.priceSek) && – {formatSek(p.priceSek)}} + {p.includes.length > 0 && {p.includes.join(', ')}} +
  • + ))} +
+ )} +
+
+ +
+

Så går en bokning till

+
    + {business.bookingSteps.map((step, i) => ( +
  1. + {i + 1} +
    +

    {step.title}

    +

    {step.text}

    +
    +
  2. + ))} +
+

+ Leverans: bilderna levereras i ett privat galleri i kundportalen + ({portalHost}).{business.deliveryTime && ` Normal leveranstid är ${business.deliveryTime}.`} +

+ Skicka en förfrågan i kundportalen +
+ +
+

Vanliga frågor

+
+ {faq.map((item) => ( +
+ +

{item.question}

+ +
+

{item.answer}

+
+ ))} +
+

+ Fler frågor? Mejla {business.email} + eller skriv på Instagram {business.social.instagram.handle}. +

+
+ +
+
-- 2.54.0 From 4f1b468f759552aa2be474013a8ae9b18b6e4171 Mon Sep 17 00:00:00 2001 From: Linus Olofsson Date: Tue, 27 Oct 2026 19:13:07 +0100 Subject: [PATCH 5/9] feat(server): redirects, real 404, caching and security headers (nginx + Caddy) - www -> apex 301, old URLs (/my-people, /animales-and-nature, /celebrations-with-family, /northen-lights) -> /portfolio 301, trailing slash and .html -> clean URL 301. - try_files $uri $uri.html, otherwise 404.html with status 404. - /_astro/*: public, max-age=31536000, immutable (path-based map, no duplicate Cache-Control headers); HTML must-revalidate. - HSTS, Referrer-Policy, Permissions-Policy, X-Frame-Options, CSP that allows the Meta Pixel and the stats beacon to customer.imagesbyolofsson.se. - deploy/caddy-site.snippet: the same rules as importable Caddy snippets. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7 --- deploy/caddy-site.snippet | 99 +++++++++++++++++++++++++++++++++++++++ nginx.conf | 94 +++++++++++++++++++++++++++---------- 2 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 deploy/caddy-site.snippet diff --git a/deploy/caddy-site.snippet b/deploy/caddy-site.snippet new file mode 100644 index 0000000..550c8c4 --- /dev/null +++ b/deploy/caddy-site.snippet @@ -0,0 +1,99 @@ +# deploy/caddy-site.snippet +# Caddy-motsvarigheten till nginx.conf för imagesbyolofsson.se (statisk dist/ från `astro build`, +# build.format "file": /about -> about.html, 404 -> 404.html). +# +# Användning i Caddyfile (Caddy >= 2.7): +# +# import /etc/caddy/snippets/caddy-site.snippet +# +# www.imagesbyolofsson.se { +# import ibo_www_redirect +# } +# imagesbyolofsson.se { +# # Befintliga specialregler (t.ex. handle /download/original* -> Immich-proxyn) +# # ska ligga FÖRE importen, så att de vinner över try_files/file_server. +# import ibo_site /srv/imagesbyolofsson/dist +# } +# +# Argumentet är sökvägen till dist-katalogen på servern (justera till den riktiga). + +(ibo_www_redirect) { + redir https://imagesbyolofsson.se{uri} permanent +} + +(ibo_site) { + root * {args[0]} + encode zstd gzip + + # --- Säkerhetsheaders --- + # CSP: tillåter Meta Pixel (laddas först efter samtycke), anonym statistik via sendBeacon + # till customer.imagesbyolofsson.se och Cloudflares egna skript (/cdn-cgi, ev. Web Analytics). + # 'unsafe-inline' behövs för samtyckesskriptet och Astros inlinade skript/stilar. + header { + Strict-Transport-Security "max-age=31536000" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://connect.facebook.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.facebook.com https://*.facebook.com; font-src 'self' data:; connect-src 'self' https://customer.imagesbyolofsson.se https://www.facebook.com https://*.facebook.com https://connect.facebook.net https://cloudflareinsights.com; frame-src https://www.facebook.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" + -Server + } + + # --- Cache-Control (ömsesidigt uteslutande matchers) --- + @ibo_immutable path /_astro/* + header @ibo_immutable Cache-Control "public, max-age=31536000, immutable" + + @ibo_media { + path *.jpg *.jpeg *.png *.webp *.avif *.gif *.svg *.ico *.woff *.woff2 *.ttf *.otf + not path /_astro/* + } + header @ibo_media Cache-Control "public, max-age=2592000" + + @ibo_textfiles path *.xml *.txt + header @ibo_textfiles Cache-Control "public, max-age=3600" + + @ibo_pages { + not path /_astro/* + not path *.jpg *.jpeg *.png *.webp *.avif *.gif *.svg *.ico *.woff *.woff2 *.ttf *.otf *.xml *.txt + } + header @ibo_pages Cache-Control "public, max-age=0, must-revalidate" + + # --- 301:or --- + # Gamla indexerade adresser -> portfolion. + @ibo_old path /my-people /my-people/ /animales-and-nature /animales-and-nature/ /celebrations-with-family /celebrations-with-family/ /northen-lights /northen-lights/ + redir @ibo_old /portfolio permanent + + # /index.html -> / + redir /index.html / permanent + + # /about.html -> /about (404-sidan hanteras separat nedan) + @ibo_htmlext { + path_regexp ibo_htmlext ^(/.+)\.html$ + not path /404.html + } + redir @ibo_htmlext {re.ibo_htmlext.1}{?query} permanent + + # Avslutande snedstreck: /about/ -> /about + @ibo_trailing path_regexp ibo_trailing ^(/.+)/$ + redir @ibo_trailing {re.ibo_trailing.1}{?query} permanent + + # 404-sidan ska bara synas som svar på okända adresser, inte som egen URL med 200. + @ibo_404page path /404 /404.html + error @ibo_404page 404 + + # --- Statiska filer --- + try_files {path} {path}.html + file_server + + # Riktig 404: 404.html med status 404 (inte startsidan med 200). + handle_errors { + @ibo_notfound expression {err.status_code} == 404 + handle @ibo_notfound { + root * {args[0]} + rewrite * /404.html + file_server { + status 404 + } + } + } +} diff --git a/nginx.conf b/nginx.conf index 242f4a1..58fd60e 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,11 +1,20 @@ -map $sent_http_content_type $cache_control_strategy { - default "public, max-age=2592000"; - ~^text/html "no-store, must-revalidate"; - ~^text/css "public, max-age=31536000, immutable"; - ~^application/javascript "public, max-age=31536000, immutable"; - ~^application/json "no-store, must-revalidate"; - ~^image/ "public, max-age=2592000"; - ~^font/ "public, max-age=31536000, immutable"; +# nginx för imagesbyolofsson.se (Docker-image: dist/ i /usr/share/nginx/html). +# Bygget använder build.format "file": /about -> about.html, 404 -> 404.html. +# Samma regler som Caddy-varianten i deploy/caddy-site.snippet. + +# Cache-Control per sökväg (inte per Content-Type, så att /_astro/*.jpg också blir immutable). +map $uri $ibo_cache_control { + default "public, max-age=0, must-revalidate"; + ~^/_astro/ "public, max-age=31536000, immutable"; + ~*\.(?:jpe?g|png|webp|avif|gif|svg|ico|woff2?|ttf|otf)$ "public, max-age=2592000"; + ~*\.(?:xml|txt)$ "public, max-age=3600"; +} + +# www -> apex (om www-trafik når den här containern). +server { + listen 80; + server_name www.imagesbyolofsson.se; + return 301 https://imagesbyolofsson.se$request_uri; } server { @@ -14,33 +23,68 @@ server { root /usr/share/nginx/html; index index.html; + # Relativa Location-headers: bakom Cloudflare/proxy blir det aldrig http://host:80/... + absolute_redirect off; + server_tokens off; + + # Säkerhetsheaders. OBS: inga add_header i location-blocken nedan, annars ärvs inte dessa. + # CSP: tillåter Meta Pixel (connect.facebook.net/facebook.com, laddas först efter samtycke), + # den anonyma statistiken (sendBeacon till customer.imagesbyolofsson.se) och Cloudflares + # egna skript (/cdn-cgi, ev. Web Analytics). 'unsafe-inline' behövs för samtyckesskriptet + # och Astros inlinade skript/stilar; byt mot hashar (Astro experimental.csp) i ett senare steg. + add_header Strict-Transport-Security "max-age=31536000" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; - add_header Cache-Control $cache_control_strategy always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://connect.facebook.net https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.facebook.com https://*.facebook.com; font-src 'self' data:; connect-src 'self' https://customer.imagesbyolofsson.se https://www.facebook.com https://*.facebook.com https://connect.facebook.net https://cloudflareinsights.com; frame-src https://www.facebook.com; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always; + add_header Cache-Control $ibo_cache_control always; + + # Gamla indexerade adresser -> närmaste nya sida. + location ~ ^/(?:my-people|animales-and-nature|celebrations-with-family|northen-lights)/?$ { + return 301 /portfolio; + } + + # /index.html och /about.html -> rena URL:er (bara externa anrop; try_files nedan + # serverar filerna internt utan att gå hit). + location = /index.html { + return 301 /; + } + location ~ ^(?/.+)\.html$ { + return 301 $ibo_noext$is_args$args; + } + + # Inga dubbletter med avslutande snedstreck: /about/ -> /about. + location ~ ^(?/.+)/$ { + return 301 $ibo_noslash$is_args$args; + } + + # Startsidan serveras direkt (ingen intern omdirigering till /index.html). + location = / { + try_files /index.html =404; + } + + # Hashade Astro-filer: cachas ett år (immutable sätts via map ovan). + location ^~ /_astro/ { + access_log off; + try_files $uri =404; + } + + # Originalnedladdning (/download/original) proxas till Immich någon annanstans + # (Cloudflare/Caddy). Finns den regeln i den här nginx: lägg den här ovanför. location / { - try_files $uri $uri/ $uri.html $uri/index.html =404; + try_files $uri $uri.html =404; } - location /_astro/ { - access_log off; - expires 1y; - } - - location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico|woff2?|ttf|otf|eot)$ { - access_log off; - expires 30d; + # Riktig 404: 404.html med status 404 (inte startsidan med 200). + error_page 404 /404.html; + location = /404.html { + internal; } gzip on; gzip_vary on; gzip_min_length 1024; - gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml; - - error_page 404 /404.html; - location = /404.html { - internal; - } + gzip_types text/plain text/css text/xml application/xml application/json application/javascript application/xml+rss image/svg+xml; } -- 2.54.0 From 1e921dfe3739d3df936feff613b4218876ff0d7f Mon Sep 17 00:00:00 2001 From: Linus Olofsson Date: Tue, 27 Oct 2026 19:13:24 +0100 Subject: [PATCH 6/9] fix(images): stop publishing EXIF/GPS originals, responsive images, crawlable gallery - Homepage images: from src/assets (AVIF/WebP/JPG srcset, width/ height, first image eager + fetchpriority high, rest lazy), Swedish alt texts. (The duplicate EXIF-laden copies in public/3d-scroll were deleted in 30e385e; README links now point at src/assets.) - Portfolio: originals are no longer referenced (no .src access), so Astro does not copy them to dist/. Small/Medium/Large downloads and the lightbox srcset are EXIF-free JPGs from getImage. Old -s/-m files are no longer imported. - Alt texts: Swedish fallback from date (EXIF or filename), never camera model or "undefined"; portfolio-meta.ts overrides per image. - Gallery items are to the large image (lightbox on click); download options are buttons; no empty H3, no H4 for the password box. - Stats beacon unchanged; item keys computed at build time (same format). - Script moved inside the layout so it is no longer rendered after . Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WvE3C43Uwtv2vtnoFBLDw7 --- README.md | 6 +- src/components/ThreeDScroll.astro | 34 +- src/pages/portfolio.astro | 781 ++++++++++++++++-------------- 3 files changed, 452 insertions(+), 369 deletions(-) diff --git a/README.md b/README.md index 1655dbb..d6ec4f1 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ A modern, responsive photography portfolio website built with Astro, Tailwind CS diff --git a/src/components/ThreeDScroll.astro b/src/components/ThreeDScroll.astro index e5a00c2..20c3f50 100644 --- a/src/components/ThreeDScroll.astro +++ b/src/components/ThreeDScroll.astro @@ -1,13 +1,22 @@ --- // src/components/ThreeDScroll.astro +// Bilderna ligger i src/assets så att Astro skalar om dem (AVIF/WebP/JPG, srcset) +// och rensar bort EXIF (GPS, serienummer m.m.). Originalen publiceras inte. +import { Picture } from 'astro:assets'; +import img1 from '../assets/images/3d-scroll/img1.jpg'; +import img2 from '../assets/images/3d-scroll/img2.jpg'; +import img3 from '../assets/images/3d-scroll/img3.jpg'; +import img4 from '../assets/images/3d-scroll/img4.jpg'; +import img5 from '../assets/images/3d-scroll/img5.jpg'; const images = [ - { src: "/3d-scroll/img1.jpg", alt: "Architecture Dark" }, - { src: "/3d-scroll/img2.jpg", alt: "Shadows Light" }, - { src: "/3d-scroll/img3.jpg", alt: "Portrait High End" }, - { src: "/3d-scroll/img4.jpg", alt: "Minimalist Landscape" }, - { src: "/3d-scroll/img5.jpg", alt: "Foggy Forest" }, + { src: img1, alt: 'Närbild av en gammal läderinbunden bok med slitna pappersark i varmt motljus' }, + { src: img2, alt: 'Veteranbil med tända strålkastare på en stadsgata i kvällsljus' }, + { src: img3, alt: 'Överblommade tistlar bland höga grässtrån i dämpade bruna toner' }, + { src: img4, alt: 'Järnvägsspår som slingrar sig mellan träd i motljus, i sepiaton' }, + { src: img5, alt: 'Svartvit bild av en man som bär ett litet barn bland såpbubblor i motljus' }, ]; +const last = images.length - 1; ---
@@ -20,9 +29,20 @@ const images = [ class="gallery-item absolute transform-style-3d will-change-transform flex items-center justify-center" data-index={index} > - {img.alt}
diff --git a/src/pages/portfolio.astro b/src/pages/portfolio.astro index e1c8bc4..15e5df6 100644 --- a/src/pages/portfolio.astro +++ b/src/pages/portfolio.astro @@ -1,31 +1,32 @@ --- // src/pages/portfolio.astro import MainLayout from '../layouts/MainLayout.astro'; -import { Image } from 'astro:assets'; +import { Image, getImage } from 'astro:assets'; import exifr from 'exifr'; import fs from 'node:fs'; import path from 'node:path'; import WaveBackground from '../components/WaveBackground.astro'; +import { business, genreList } from '../data/business'; +import { portfolioMeta } from '../data/portfolio-meta'; -// --- SERVER-SIDE LOGIC --- +// SERVER-SIDE LOGIC (byggtid) -// 1. Hämta alla bilder -const allImageFiles = import.meta.glob('../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', { eager: true }); -// -s (800px) och -m (1600px) ar NEDLADDNINGSVARIANTER, inte egna galleribilder. -// Utan filtret visas varje bild tre ganger. -const imageFiles = Object.fromEntries( - Object.entries(allImageFiles).filter(([pathKey]) => !/-[sm]\.(jpe?g|png|webp)$/i.test(pathKey)) +// 1. Hämta alla galleribilder. +// -s (800px) och -m (1600px) i mappen är gamla nedladdningsvarianter med EXIF kvar. +// De importeras INTE längre (då hamnar de inte i dist/); nedladdningsstorlekarna +// tas i stället fram av Astros bildtjänst nedan, vilket också rensar bort EXIF +// (GPS, kamerans serienummer, Artist m.m.). +const imageFiles = import.meta.glob( + [ + '../assets/images/portfolio/*.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', + '!../assets/images/portfolio/*-{s,m}.{jpeg,jpg,png,webp,JPG,JPEG,PNG}', + ], + { eager: true } ); -// Karta assetId -> { s, m } med Astros hashade URL:er, sa klienten kan lanka -// till ratt nedladdningsvariant. Large ar galleribilden sjalv, original gar via proxy. -const downloadVariants: Record> = {}; -for (const [pathKey, file] of Object.entries(allImageFiles) as [string, any][]) { - const m = pathKey.match(/immich-([0-9a-f-]{36})-.*-([sm])\.(?:jpe?g|png|webp)$/i); - if (!m) continue; - const id = m[1].toLowerCase(); - (downloadVariants[id] ||= {})[m[2].toLowerCase()] = file.default.src; -} +// OBS: läs aldrig .src/.width/.height direkt på de importerade bilderna här. +// Då räknar Astro originalet som "använt" och kopierar originalfilen (med EXIF) +// till dist/. Gå alltid via /getImage. function formatShutterSpeed(exposureTime: number) { if (!exposureTime) return null; @@ -34,40 +35,78 @@ function formatShutterSpeed(exposureTime: number) { return `1/${fraction}`; } +// Datum ur filnamnet: "immich--20260905-..." eller "20250510-...". +function dateFromFilename(name: string): Date | null { + const m = name.match(/(?:^|-)((?:19|20)\d{2})(\d{2})(\d{2})-/); + if (!m) return null; + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + return isNaN(d.getTime()) ? null : d; +} + +const widthOf = (descriptor: string) => parseInt(descriptor, 10) || 0; + const galleryItems = await Promise.all(Object.entries(imageFiles).map(async ([relativePath, file]: [string, any]) => { - let exifData: any = {}; - let dateTaken = null; + let exifData: any = {}; + let dateTaken: Date | null = null; const absolutePath = path.resolve('./src/pages', relativePath); + const fileName = path.basename(relativePath); + // Stabil nyckel = filnamn utan ändelse. Används för alt-texter (portfolio-meta.ts) och statistik. + const key = fileName.replace(/\.[^.]+$/, ''); try { const fileBuffer = fs.readFileSync(absolutePath); exifData = await exifr.parse(fileBuffer, [ 'ISO', 'Model', 'FNumber', 'ExposureTime', 'FocalLength', 'DateTimeOriginal', 'CreateDate' ]); - dateTaken = exifData?.DateTimeOriginal || exifData?.CreateDate; - if (!dateTaken) dateTaken = new Date(); + const exifDate = exifData?.DateTimeOriginal || exifData?.CreateDate; + if (exifDate) dateTaken = new Date(exifDate); } catch (e) { console.error(`Kunde inte läsa EXIF för ${relativePath}`); - dateTaken = new Date(); } + if (!dateTaken || isNaN(dateTaken.getTime())) dateTaken = dateFromFilename(fileName); + const hasDate = !!dateTaken; + const dateObj = dateTaken ?? new Date(); - const dateObj = new Date(dateTaken); const year = dateObj.getFullYear().toString(); const month = dateObj.toLocaleString('sv-SE', { month: 'long' }).toLowerCase(); const monthIndex = dateObj.getMonth(); - - const fullDate = dateObj.toLocaleDateString('sv-SE', { - day: 'numeric', month: 'long', year: 'numeric' - }); + + const fullDate = hasDate + ? dateObj.toLocaleDateString('sv-SE', { day: 'numeric', month: 'long', year: 'numeric' }) + : ''; const shutterSpeed = formatShutterSpeed(exifData?.ExposureTime); const focalLength = exifData?.FocalLength ? `${Math.round(exifData.FocalLength)}mm` : null; const fStop = exifData?.FNumber ? `f/${exifData.FNumber}` : null; const iso = exifData?.ISO ? `ISO ${exifData.ISO}` : null; + // Alt-text: riktig text från portfolio-meta.ts om den finns, annars en neutral + // svensk reservtext. Aldrig kameramodell, aldrig "undefined". + const meta = portfolioMeta[key] ?? {}; + const fallbackAlt = [ + meta.category ? `${meta.category}:` : null, + `fotografi av ${business.person.name}`, + fullDate ? `taget ${fullDate}` : null, + ].filter(Boolean).join(' '); + const alt = meta.alt?.trim() || fallbackAlt.charAt(0).toUpperCase() + fallbackAlt.slice(1); + + // Nedladdning/lightbox: nya, EXIF-fria JPG:er i 800/1600/3000 px + // (Astro hoppar över bredder större än originalet). + const jpg = await getImage({ src: file.default, format: 'jpg', quality: 88, width: 800, widths: [800, 1600, 3000] }); + const variants = [...jpg.srcSet.values].sort((a, b) => widthOf(a.descriptor ?? '') - widthOf(b.descriptor ?? '')); + const small = variants[0]?.url ?? jpg.src; + const large = variants[variants.length - 1]?.url ?? jpg.src; + const medium = variants.find((v) => widthOf(v.descriptor ?? '') === 1600)?.url ?? large; + + const assetId = key.match(/^immich-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-/i)?.[1]?.toLowerCase() ?? null; + const statKey = ('portfolio/' + key.replace(/[^\w\-.]/g, '_')).slice(0, 150); + return { - ...file, year, month, monthIndex, fullDate, model: exifData?.Model, - fStop, shutterSpeed, iso, focalLength + img: file.default, key, alt, title: meta.title ?? null, + year, month, monthIndex, fullDate, model: exifData?.Model, + fStop, shutterSpeed, iso, focalLength, + dlS: small, dlM: medium, dlL: large, srcset: jpg.srcSet.attribute, + assetId, statKey, }; })); @@ -78,7 +117,13 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value .map((item: any) => item.name); --- - +
@@ -88,32 +133,44 @@ const uniqueMonths = [...new Map(allMonths.map(item => [item.name, item])).value

Portfolio

-

A curated selection of my work across different styles.

+

Ett urval av mina bilder i olika stilar – {genreList()}. + Vill du boka en fotografering? Läs om tjänster och bokning.

- + {uniqueYears.map(year => ( - + ))}
- + {uniqueMonths.map(month => ( - + ))}
+

Bilder

- Portfolio Grid + Portfolio Grid
More
- 3D Scroll Effect + 3D Scroll Effect
At
- Hero Section + Hero Section
Imagesbyolofsson.se