Compare commits
1 commit
fix/guest-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1fa539495 |
10 changed files with 449 additions and 147 deletions
|
|
@ -15,7 +15,6 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import se.bilhalsning.dto.ErrorResponse;
|
import se.bilhalsning.dto.ErrorResponse;
|
||||||
import se.bilhalsning.security.GuestOrderRateLimitFilter;
|
|
||||||
import se.bilhalsning.security.JwtAuthenticationFilter;
|
import se.bilhalsning.security.JwtAuthenticationFilter;
|
||||||
import se.bilhalsning.security.JwtService;
|
import se.bilhalsning.security.JwtService;
|
||||||
|
|
||||||
|
|
@ -42,8 +41,7 @@ public class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||||
JwtAuthenticationFilter jwtAuthenticationFilter,
|
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
|
||||||
GuestOrderRateLimitFilter guestOrderRateLimitFilter) throws Exception {
|
|
||||||
http
|
http
|
||||||
.csrf(csrf -> csrf.disable())
|
.csrf(csrf -> csrf.disable())
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
|
@ -66,7 +64,6 @@ public class SecurityConfig {
|
||||||
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
|
writeError(response, HttpStatus.UNAUTHORIZED, UNAUTHENTICATED_MESSAGE))
|
||||||
.accessDeniedHandler((request, response, ex) ->
|
.accessDeniedHandler((request, response, ex) ->
|
||||||
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
|
writeError(response, HttpStatus.FORBIDDEN, FORBIDDEN_MESSAGE)))
|
||||||
.addFilterBefore(guestOrderRateLimitFilter, UsernamePasswordAuthenticationFilter.class)
|
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
|
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
package se.bilhalsning.security;
|
|
||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
|
||||||
import jakarta.servlet.ServletException;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.ArrayDeque;
|
|
||||||
import java.util.Deque;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory per-IP rate limiter for public guest-order endpoints.
|
|
||||||
*
|
|
||||||
* <p>Uses a sliding-window log algorithm: for each client IP, stores request
|
|
||||||
* timestamps in a deque. On each request, timestamps older than the window
|
|
||||||
* are pruned. If the remaining count exceeds the limit, the request is
|
|
||||||
* rejected with HTTP 429.
|
|
||||||
*
|
|
||||||
* <p>This is a Phase 0 interim measure. It is not suitable for multi-instance
|
|
||||||
* deployments (state is per-JVM) and resets on restart. For production, use
|
|
||||||
* Bucket4j with a Redis backing or a reverse proxy (nginx {@code limit_req}).
|
|
||||||
*
|
|
||||||
* <p>Configurable via application properties:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code app.rate-limit.guest-create} — POST /api/guest-orders (default 5/min)</li>
|
|
||||||
* <li>{@code app.rate-limit.guest-default} — all other /api/guest-orders/** (default 20/min)</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class GuestOrderRateLimitFilter extends OncePerRequestFilter {
|
|
||||||
|
|
||||||
@Value("${app.rate-limit.guest-create:5}")
|
|
||||||
private int createLimit;
|
|
||||||
|
|
||||||
@Value("${app.rate-limit.guest-default:20}")
|
|
||||||
private int defaultLimit;
|
|
||||||
|
|
||||||
private static final long WINDOW_SECONDS = 60;
|
|
||||||
|
|
||||||
private final ConcurrentHashMap<String, Deque<Instant>> store = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void doFilterInternal(
|
|
||||||
HttpServletRequest request,
|
|
||||||
HttpServletResponse response,
|
|
||||||
FilterChain filterChain) throws ServletException, IOException {
|
|
||||||
|
|
||||||
String path = request.getRequestURI();
|
|
||||||
if (!path.startsWith("/api/guest-orders")) {
|
|
||||||
filterChain.doFilter(request, response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String ip = extractClientIp(request);
|
|
||||||
String key = ip + ":" + (isCreatePath(path) ? "create" : "default");
|
|
||||||
int limit = isCreatePath(path) ? createLimit : defaultLimit;
|
|
||||||
|
|
||||||
if (isRateLimited(key, limit)) {
|
|
||||||
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
|
|
||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
|
||||||
response.setCharacterEncoding("UTF-8");
|
|
||||||
response.getWriter().write(
|
|
||||||
"{\"message\":\"För många förfrågningar. Försök igen om en minut.\"}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
filterChain.doFilter(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check and record a request. Returns true if the request should be
|
|
||||||
* rejected (rate limit exceeded), false otherwise.
|
|
||||||
*/
|
|
||||||
private boolean isRateLimited(String key, int limit) {
|
|
||||||
Instant now = Instant.now();
|
|
||||||
Instant cutoff = now.minusSeconds(WINDOW_SECONDS);
|
|
||||||
|
|
||||||
Deque<Instant> timestamps = store.computeIfAbsent(key, k -> new ArrayDeque<>());
|
|
||||||
|
|
||||||
synchronized (timestamps) {
|
|
||||||
while (!timestamps.isEmpty() && timestamps.peekFirst().isBefore(cutoff)) {
|
|
||||||
timestamps.pollFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timestamps.size() >= limit) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamps.addLast(now);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isCreatePath(String path) {
|
|
||||||
return path.equals("/api/guest-orders");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String extractClientIp(HttpServletRequest request) {
|
|
||||||
String forwarded = request.getHeader("X-Forwarded-For");
|
|
||||||
if (forwarded != null && !forwarded.isBlank()) {
|
|
||||||
return forwarded.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
String realIp = request.getHeader("X-Real-IP");
|
|
||||||
if (realIp != null && !realIp.isBlank()) {
|
|
||||||
return realIp.trim();
|
|
||||||
}
|
|
||||||
return request.getRemoteAddr();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the rate limit state. For testing only.
|
|
||||||
*/
|
|
||||||
public void reset() {
|
|
||||||
store.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -23,11 +23,7 @@ import se.bilhalsning.service.UserService;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@TestPropertySource(properties = {
|
@TestPropertySource(properties = "app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!")
|
||||||
"app.jwt.secret=this-is-a-test-secret-that-is-at-least-32-bytes-long!!",
|
|
||||||
"app.rate-limit.guest-create=1000",
|
|
||||||
"app.rate-limit.guest-default=1000"
|
|
||||||
})
|
|
||||||
class GuestOrderControllerTest {
|
class GuestOrderControllerTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|
@ -39,14 +35,6 @@ class GuestOrderControllerTest {
|
||||||
@MockitoBean
|
@MockitoBean
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private se.bilhalsning.security.GuestOrderRateLimitFilter rateLimitFilter;
|
|
||||||
|
|
||||||
@org.junit.jupiter.api.BeforeEach
|
|
||||||
void resetRateLimit() {
|
|
||||||
rateLimitFilter.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- POST /api/guest-orders (create) ---
|
// --- POST /api/guest-orders (create) ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name _;
|
server_name bilhej.se www.bilhej.se;
|
||||||
|
|
||||||
ssl_certificate /etc/nginx/certs/cert.crt;
|
ssl_certificate /etc/nginx/certs/cert.crt;
|
||||||
ssl_certificate_key /etc/nginx/certs/cert.key;
|
ssl_certificate_key /etc/nginx/certs/cert.key;
|
||||||
|
|
@ -11,20 +11,59 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# ── Security headers ──────────────────────────────────────────
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
# Strict-Transport-Security only when HTTPS is active
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
|
||||||
|
# ── Gzip ──────────────────────────────────────────────────────
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml
|
||||||
|
application/xml text/javascript image/svg+xml text/markdown;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
|
||||||
|
# ── API reverse proxy ─────────────────────────────────────────
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Static assets with far-future cache ───────────────────────
|
||||||
|
location ~* \.(?:ico|svg|css|js|woff2?|ttf|eot|png|jpg|jpeg|gif|webp|avif)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header Vary "Accept-Encoding";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Robots + sitemap (never cached, fresh every time) ─────────
|
||||||
|
location = /robots.txt {
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
location = /sitemap.xml {
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── SPA fallback — serve index.html for all other routes ──────
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
gzip on;
|
# ── Deny access to dotfiles ───────────────────────────────────
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
location ~ /\. {
|
||||||
gzip_vary on;
|
deny all;
|
||||||
gzip_min_length 256;
|
return 404;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,143 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=4" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Skicka ett brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, så postar vi det." />
|
|
||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>Bilhej — Skicka brev till fordonsägare via registreringsnummer</title>
|
||||||
|
<meta name="title" content="Bilhej — Skicka brev till fordonsägare via registreringsnummer" />
|
||||||
|
<meta name="description" content="Skicka ett fysiskt brev till en fordonsägare. Ange registreringsnummer, skriv ditt meddelande, betala 49 kr via Swish. Vi postar brevet åt dig med spårning." />
|
||||||
|
<meta name="keywords" content="brev till bilägare, kontakta fordonsägare, registreringsnummer, skicka brev, anonymt meddelande, parkeringsskada, köp bilen, tuta, körbeteende, svensk post" />
|
||||||
|
<meta name="author" content="Bilhej" />
|
||||||
|
<meta name="robots" content="index, follow, max-image-preview:large" />
|
||||||
|
<meta name="language" content="Swedish" />
|
||||||
<meta name="theme-color" content="#1d4ed8" />
|
<meta name="theme-color" content="#1d4ed8" />
|
||||||
|
<link rel="canonical" href="https://bilhej.se/" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://bilhej.se/" />
|
||||||
|
<meta property="og:title" content="Bilhej — Skicka brev till fordonsägare" />
|
||||||
|
<meta property="og:description" content="Skicka ett fysiskt brev till en bilägare via registreringsnummer. Skriv, betala 49 kr, vi postar." />
|
||||||
|
<meta property="og:site_name" content="Bilhej" />
|
||||||
|
<meta property="og:locale" content="sv_SE" />
|
||||||
|
<meta property="og:image" content="https://bilhej.se/og-image.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:url" content="https://bilhej.se/" />
|
||||||
|
<meta name="twitter:title" content="Bilhej — Skicka brev till fordonsägare" />
|
||||||
|
<meta name="twitter:description" content="Skicka ett fysiskt brev till en bilägare via registreringsnummer. Skriv, betala 49 kr, vi postar." />
|
||||||
|
<meta name="twitter:image" content="https://bilhej.se/og-image.png" />
|
||||||
|
|
||||||
|
<!-- Performance: preconnect to font + analytics origins -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://analytics.bilhej.se" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<title>Bilhej — Skicka brev till fordonsägare</title>
|
|
||||||
|
<!-- Structured Data: Organization -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Bilhej",
|
||||||
|
"url": "https://bilhej.se",
|
||||||
|
"logo": "https://bilhej.se/favicon.svg",
|
||||||
|
"description": "Tjänst för att skicka fysiska brev till fordonsägare via registreringsnummer.",
|
||||||
|
"email": "kontakt@bilhej.se",
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"email": "support@bilhej.se",
|
||||||
|
"contactType": "customer support",
|
||||||
|
"availableLanguage": ["Swedish"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Structured Data: WebSite with SearchAction -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "Bilhej",
|
||||||
|
"url": "https://bilhej.se",
|
||||||
|
"inLanguage": "sv-SE",
|
||||||
|
"potentialAction": {
|
||||||
|
"@type": "ReadAction",
|
||||||
|
"target": "https://bilhej.se"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Structured Data: Service / Product -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Service",
|
||||||
|
"name": "Bilhej — Brev till fordonsägare",
|
||||||
|
"description": "Skicka ett fysiskt brev till en fordonsägare genom att ange registreringsnummer. Swish-betalning, spårning och anonym avsändare.",
|
||||||
|
"url": "https://bilhej.se",
|
||||||
|
"provider": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Bilhej",
|
||||||
|
"url": "https://bilhej.se"
|
||||||
|
},
|
||||||
|
"areaServed": {
|
||||||
|
"@type": "Country",
|
||||||
|
"name": "Sverige"
|
||||||
|
},
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "49",
|
||||||
|
"priceCurrency": "SEK",
|
||||||
|
"description": "Per brev: utskrift, kuvertering och postning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- FAQ Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": [
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Hur skickar jag ett brev till en bilägare?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Ange bilens registreringsnummer på bilhej.se, skriv ditt meddelande med en mall eller fritext, betala 49 kr via Swish, så postar vi brevet åt dig."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Behöver jag veta vem som äger bilen?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Nej, du behöver bara registreringsnumret. Vi kopplar brevet till rätt mottagare. Du ser aldrig mottagarens namn eller adress."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Kan jag vara anonym?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Ja, du kan nå bilägaren anonymt. Lägg bara till dina kontaktuppgifter i brevet om du vill att de ska kunna svara."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Vad kostar det?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "49 kr per brev. Detta täcker utskrift, kuvertering och porto. Du betalar via Swish."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
27
frontend/public/robots.txt
Normal file
27
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Allow all crawlers
|
||||||
|
User-agent: *
|
||||||
|
Crawl-delay: 10
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /admin/
|
||||||
|
Disallow: /orders/
|
||||||
|
Disallow: /bestallning/*/redigera
|
||||||
|
Disallow: /betalning/*
|
||||||
|
Disallow: /gast-bestallning*
|
||||||
|
Disallow: /gast-betalning/*
|
||||||
|
Disallow: /gast-order/*
|
||||||
|
Disallow: /andra-losenord
|
||||||
|
Disallow: /andra-epost
|
||||||
|
Disallow: /bekrafta-epost
|
||||||
|
Disallow: /aterstall-losenord
|
||||||
|
Disallow: /glomt-losenord
|
||||||
|
|
||||||
|
# Allow important pages
|
||||||
|
Allow: /
|
||||||
|
Allow: /om-oss
|
||||||
|
Allow: /om
|
||||||
|
Allow: /kontakt
|
||||||
|
Allow: /integritetspolicy
|
||||||
|
Allow: /villkor
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
Sitemap: https://bilhej.se/sitemap.xml
|
||||||
36
frontend/public/sitemap.xml
Normal file
36
frontend/public/sitemap.xml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||||
|
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||||
|
<url>
|
||||||
|
<loc>https://bilhej.se/</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://bilhej.se/om-oss</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://bilhej.se/om</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://bilhej.se/kontakt</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://bilhej.se/integritetspolicy</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://bilhej.se/villkor</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
|
|
@ -1,7 +1,29 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
import AppFooter from '@/components/AppFooter.vue'
|
||||||
|
import { useSeo } from '@/composables/useSeo'
|
||||||
|
import { routeSeo } from '@/data/routeSeo'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const seo = useSeo()
|
||||||
|
|
||||||
|
// Update title and meta tags on every route change
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name) => {
|
||||||
|
const meta = routeSeo[name as string]
|
||||||
|
if (meta) {
|
||||||
|
seo.set(meta)
|
||||||
|
} else {
|
||||||
|
// Fallback for unnamed or dynamic routes (payment, guest, etc.)
|
||||||
|
seo.reset()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
95
frontend/src/composables/useSeo.ts
Normal file
95
frontend/src/composables/useSeo.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/**
|
||||||
|
* useSeo — dynamically updates document <title> and meta tags on route change.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* const seo = useSeo()
|
||||||
|
* // On page mount / watch:
|
||||||
|
* watch(route, () => {
|
||||||
|
* seo.set({ title: 'Min sida', description: '...' })
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* The composable sets <title>, meta[name=description], meta[name=keywords],
|
||||||
|
* meta[property=og:title], meta[property=og:description], meta[name=twitter:title],
|
||||||
|
* and meta[name=twitter:description].
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SeoMeta {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
keywords?: string
|
||||||
|
ogTitle?: string
|
||||||
|
ogDescription?: string
|
||||||
|
canonical?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSeo() {
|
||||||
|
function set(meta: SeoMeta) {
|
||||||
|
const title = meta.title ?? 'Bilhej'
|
||||||
|
const description =
|
||||||
|
meta.description ??
|
||||||
|
'Skicka brev till fordonsägare via registreringsnummer.'
|
||||||
|
|
||||||
|
// Document title
|
||||||
|
document.title = title
|
||||||
|
|
||||||
|
// Common meta tags
|
||||||
|
updateMeta('description', description)
|
||||||
|
if (meta.keywords) updateMeta('keywords', meta.keywords)
|
||||||
|
|
||||||
|
// Open Graph
|
||||||
|
updateMeta('og:title', meta.ogTitle ?? title, 'property')
|
||||||
|
updateMeta('og:description', meta.ogDescription ?? description, 'property')
|
||||||
|
|
||||||
|
// Twitter
|
||||||
|
updateMeta('twitter:title', meta.ogTitle ?? title, 'name')
|
||||||
|
updateMeta('twitter:description', meta.ogDescription ?? description, 'name')
|
||||||
|
|
||||||
|
// Canonical URL
|
||||||
|
updateCanonical(meta.canonical)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
document.title = 'Bilhej — Skicka brev till fordonsägare'
|
||||||
|
updateMeta(
|
||||||
|
'description',
|
||||||
|
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
|
||||||
|
)
|
||||||
|
updateMeta('og:title', 'Bilhej — Skicka brev till fordonsägare', 'property')
|
||||||
|
updateMeta(
|
||||||
|
'og:description',
|
||||||
|
'Skicka ett fysiskt brev till en fordonsägare via registreringsnummer.',
|
||||||
|
'property',
|
||||||
|
)
|
||||||
|
updateCanonical('https://bilhej.se/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { set, reset }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMeta(
|
||||||
|
nameOrProperty: string,
|
||||||
|
content: string,
|
||||||
|
attr: 'name' | 'property' = 'name',
|
||||||
|
) {
|
||||||
|
let el = document.querySelector(
|
||||||
|
`meta[${attr}="${nameOrProperty}"]`,
|
||||||
|
) as HTMLMetaElement | null
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement('meta')
|
||||||
|
el.setAttribute(attr, nameOrProperty)
|
||||||
|
document.head.appendChild(el)
|
||||||
|
}
|
||||||
|
el.setAttribute('content', content)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCanonical(href: string | undefined) {
|
||||||
|
let link = document.querySelector(
|
||||||
|
'link[rel="canonical"]',
|
||||||
|
) as HTMLLinkElement | null
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link')
|
||||||
|
link.setAttribute('rel', 'canonical')
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
link.setAttribute('href', href ?? 'https://bilhej.se/')
|
||||||
|
}
|
||||||
90
frontend/src/data/routeSeo.ts
Normal file
90
frontend/src/data/routeSeo.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* Route-level SEO metadata for Bilhej.
|
||||||
|
* Each entry maps route.name → { title, description, keywords?, canonical? }
|
||||||
|
* These are applied by App.vue on every route change via useSeo().
|
||||||
|
*/
|
||||||
|
export interface RouteSeoMeta {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
keywords?: string
|
||||||
|
/** The full canonical URL for this page */
|
||||||
|
canonical?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routeSeo: Record<string, RouteSeoMeta> = {
|
||||||
|
home: {
|
||||||
|
title: 'Bilhej — Skicka brev till fordonsägare via registreringsnummer',
|
||||||
|
description:
|
||||||
|
'Skicka ett fysiskt brev till en bilägare med bara registreringsnumret. Välj mall, skriv, betala 49 kr via Swish. Vi postar med spårning.',
|
||||||
|
keywords:
|
||||||
|
'brev till bilägare, kontakta fordonsägare, parkeringsskada, köp bil, skicka brev, registreringsnummer brev',
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
title: 'Om Bilhej — Skicka fysiska brev till fordonsägare online',
|
||||||
|
description:
|
||||||
|
'Bilhej gör det enkelt att nå en bilägare med ett fysiskt brev. Skriv meddelandet, vi sköter utskick och post. Anonymt eller med dina uppgifter.',
|
||||||
|
keywords: 'om bilhej, hur fungerar bilhej, skicka brev till bilägare',
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
title: 'Kontakt — Bilhej support, klagomål och allmänna frågor',
|
||||||
|
description:
|
||||||
|
'Kontakta Bilhej via e-post. Support för beställningar och betalning, allmän kontakt och klagomål.',
|
||||||
|
keywords: 'bilhej kontakt, support bilhej, klagomål',
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
title: 'Integritetspolicy — Bilhej | Personuppgifter och GDPR',
|
||||||
|
description:
|
||||||
|
'Läs om hur Bilhej hanterar personuppgifter: vad vi samlar in, varför, hur länge och dina rättigheter enligt GDPR.',
|
||||||
|
keywords: 'integritetspolicy bilhej, gdpr, personuppgifter brev',
|
||||||
|
},
|
||||||
|
terms: {
|
||||||
|
title: 'Användarvillkor — Bilhej | Villkor för brevtjänsten',
|
||||||
|
description:
|
||||||
|
'Villkor för att använda Bilhej. Regler för brev till fordonsägare, betalning, ansvar och reklamation.',
|
||||||
|
keywords: 'användarvillkor bilhej, köpvillkor, brevtjänst villkor',
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
title: 'Registrera konto — Bilhej | Skapa konto för beställningar',
|
||||||
|
description:
|
||||||
|
'Skapa ett konto på Bilhej för att följa dina beställningar, se historik och hantera dina brev.',
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Logga in — Bilhej | Dina brev till fordonsägare',
|
||||||
|
description:
|
||||||
|
'Logga in på Bilhej för att se dina beställningar, status och spårning.',
|
||||||
|
},
|
||||||
|
'forgot-password': {
|
||||||
|
title: 'Glömt lösenord — Bilhej | Återställ lösenord',
|
||||||
|
description:
|
||||||
|
'Återställ ditt lösenord på Bilhej. Få en återställningslänk via e-post.',
|
||||||
|
},
|
||||||
|
'guest-checkout': {
|
||||||
|
title: 'Gästbeställning — Bilhej | Skicka brev utan konto',
|
||||||
|
description:
|
||||||
|
'Skicka ett brev till en fordonsägare utan att skapa konto. Ange registreringsnummer, skriv meddelande, betala via Swish.',
|
||||||
|
keywords: 'gästbeställning bilhej, utan konto, snabb beställning',
|
||||||
|
},
|
||||||
|
compose: {
|
||||||
|
title: 'Skriv brev — Bilhej | Komponera meddelande till fordonsägare',
|
||||||
|
description:
|
||||||
|
'Skriv ditt brev till bilägaren. Välj mall för köp, komplimang, parkeringsskada eller tips, eller skriv fritt.',
|
||||||
|
},
|
||||||
|
orders: {
|
||||||
|
title: 'Mina beställningar — Bilhej | Följ dina brev',
|
||||||
|
description:
|
||||||
|
'Se dina tidigare och pågående beställningar. Följ status och spårning för brev du skickat.',
|
||||||
|
},
|
||||||
|
'change-password': {
|
||||||
|
title: 'Byt lösenord — Bilhej | Kontoinställningar',
|
||||||
|
description: 'Byt lösenord på ditt Bilhej-konto.',
|
||||||
|
},
|
||||||
|
'change-email': {
|
||||||
|
title: 'Byt e-postadress — Bilhej | Kontoinställningar',
|
||||||
|
description: 'Byt e-postadress på ditt Bilhej-konto.',
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
title: 'Admin — Bilhej | Administrationspanel',
|
||||||
|
description:
|
||||||
|
'Administration av Bilhej — hantera beställningar och användare.',
|
||||||
|
},
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue