Før ny agent. Jobber med innlogging

This commit is contained in:
Erol 2026-03-02 09:56:37 +01:00
parent ebd4e40a41
commit 67d1c2ff8c
19 changed files with 586 additions and 17725 deletions

View file

@ -1,8 +1,7 @@
"""
TEE OFF ADMIN GENERATOR v1.2 (PBKDF2)
TEE OFF ADMIN GENERATOR v1.3 (PBKDF2 ONLY)
---------------------------------------------------------------------------
FUNKSJON: Genererer SQL for å sette inn en admin med PBKDF2-hash.
BRUK: docker exec -it teeoff_api python create_admin.py
FUNKSJON: Genererer SQL-kommando for PBKDF2-basert administrator.
---------------------------------------------------------------------------
"""
import pyotp
@ -11,33 +10,34 @@ import getpass
def generate_admin():
print("\n" + "="*50)
print(" TEE OFF ADMIN GENERATOR v1.2 (PBKDF2)")
print(" TEE OFF ADMIN GENERATOR v1.3")
print("="*50)
username = input("Brukernavn: ").strip()
username = input("Brukernavn (f.eks Envide Webutvikling): ").strip()
email = input("E-post: ").strip()
password = getpass.getpass("Passord (Ingen lengdebegrensning): ")
password = getpass.getpass("Passord: ")
# Generer 2FA hemmelighet
otp_secret = pyotp.random_base32()
# Lag hash med PBKDF2
print("⏳ Genererer sikker hash...")
print("⏳ Genererer PBKDF2-hash...")
# Dette vil produsere en streng som starter med $pbkdf2-sha256$...
password_hash = pbkdf2_sha256.hash(password)
print("\n" + "✅ GENERERING VELLYKKET!")
print("\n✅ GENERERING VELLYKKET!")
print("-" * 50)
print("KJØR DENNE KOMMANDOEN FOR Å OPPRETTE BRUKEREN:")
print("1. KJØR DISSE TO KOMMANDOENE I REKKEFØLGE:")
print("-" * 50)
# Kommando 1: Tøm tabellen for å fjerne gamle, inkompatible hasher
print(f'docker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c "TRUNCATE admins;"')
# Kommando 2: Sett inn den nye brukeren
sql = f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{username}', '{email}', '{password_hash}', '{otp_secret}');"
print(f"\ndocker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c \"{sql}\"")
print(f'\ndocker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c "{sql}"')
print("\n" + "-" * 50)
print("2FA KONFIGURASJON (Viktig!):")
print(f"Brukernavn: {email}")
print(f"Nøkkel (Secret): {otp_secret}")
print("2. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:")
print(f"Nøkkel: {otp_secret}")
print("-" * 50 + "\n")
if __name__ == "__main__":

View file

@ -1,10 +1,11 @@
"""
TEE OFF BACKEND API v3.6.5 - THE FINAL MASTER VERSION
TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION
---------------------------------------------------------------------------
REGEL 1: Bruk str (ikke string) for type-hinting.
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
LOV: Aldri trunker eller slett logikk for "effektivitet".
---------------------------------------------------------------------------
"""
@ -26,66 +27,86 @@ load_dotenv()
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
ALGORITHM = "HS256"
# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def format_row(row):
"""
Vasker data fra databasen:
1. Konverterer datoer til ISO-format.
2. Parser stringified JSON til ekte Python-objekter.
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
"""
if row is None:
return None
d = dict(row)
# 1. Datoer
# 1. Håndter dato- og tidsformater for JSON-serialisering
for key in ['status_updated_at', 'created_at']:
if isinstance(d.get(key), (date, datetime)):
d[key] = d[key].isoformat()
# 2. JSON-felter (Lister)
# 2. Definer alle felter som inneholder JSON-data
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes'
]
json_dict_fields = [
'amenities', 'vtg', 'nsg_data', 'golfamore_data'
]
# Vask list-felter
for field in json_list_fields:
if field in d:
val = d[field]
if val is None: d[field] = []
if val is None:
d[field] = []
elif isinstance(val, str):
try: d[field] = json.loads(val)
except: d[field] = []
elif not isinstance(val, list): d[field] = []
try:
d[field] = json.loads(val)
except:
d[field] = []
elif not isinstance(val, list):
d[field] = []
# 3. JSON-felter (Objekter)
json_dict_fields = ['amenities', 'vtg', 'nsg_data', 'golfamore_data']
# Vask objekt-felter
for field in json_dict_fields:
if field in d:
val = d[field]
if val is None: d[field] = {}
if val is None:
d[field] = {}
elif isinstance(val, str):
try: d[field] = json.loads(val)
except: d[field] = {}
elif not isinstance(val, dict): d[field] = {}
try:
d[field] = json.loads(val)
except:
d[field] = {}
elif not isinstance(val, dict):
d[field] = {}
return d
@asynccontextmanager
async def lifespan(app: FastAPI):
# Opprett database-pool
# Opprett database-pool ved start
try:
print(f"📡 Forsøker å koble til database på: {DB_URL}")
app.state.pool = await asyncpg.create_pool(
DB_URL, min_size=5, max_size=20, command_timeout=60
DB_URL,
min_size=5,
max_size=20,
command_timeout=60
)
print("✅ Database pool opprettet")
print("✅ Database tilkoblet og pool opprettet")
except Exception as e:
print(f"❌ Databasefeil: {e}")
print(f"❌ Databasefeil under oppstart: {e}")
raise e
yield
# Lukk pool ved avslutning
await app.state.pool.close()
app = FastAPI(title="TeeOff API v3.6.5", lifespan=lifespan)
app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan)
# CORS - Tillater både lokal utvikling og produksjonsdomene
app.add_middleware(
@ -105,34 +126,54 @@ app.add_middleware(
@app.post("/api/auth/login")
async def login(data: dict):
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
print(f"🔐 Login-forsøk for: {data.get('username')}")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow(
"SELECT * FROM admins WHERE username = $1 OR email = $1",
data.get('username')
)
if not admin or not pwd_context.verify(data.get('password'), admin['password_hash']):
if not admin:
print(" - ❌ Bruker ikke funnet i databasen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
# DEBUG: Printer starten på hashen for å verifisere formatet ($pbkdf2-sha256$...)
h = admin['password_hash']
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
try:
if not pwd_context.verify(data.get('password'), h):
print(" - ❌ Passordet samsvarer ikke med hashen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
except Exception as e:
print(f" - 🔥 FEIL VED VERIFISERING: {e}")
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
temp_token = jwt.encode(
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
SECRET_KEY, algorithm=ALGORITHM
)
print(" - ✅ Steg 1 fullført. Temp-token generert.")
return {"step": "2fa", "temp_token": temp_token}
@app.post("/api/auth/verify-2fa")
async def verify_2fa(data: dict, response: Response):
"""Steg 2: Sjekk TOTP og sett session cookie."""
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
try:
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
if not payload.get("partial"):
raise JWTError()
username = payload.get("sub")
except:
raise HTTPException(status_code=401, detail="Sesjonen har utløpt")
except JWTError:
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
totp = pyotp.TOTP(admin['otp_secret'])
if not totp.verify(data.get('code')):
print(f" - ❌ Feil 2FA-kode oppgitt for {username}")
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
final_token = jwt.encode(
@ -140,9 +181,13 @@ async def verify_2fa(data: dict, response: Response):
SECRET_KEY, algorithm=ALGORITHM
)
# Sett som HTTP-only cookie
response.set_cookie(
key="admin_session", value=final_token,
httponly=True, samesite="lax", secure=False # False for utvikling
key="admin_session",
value=final_token,
httponly=True,
samesite="lax",
secure=False # Sett til True i produksjon (HTTPS)
)
return {"status": "success"}
@ -150,7 +195,7 @@ async def verify_2fa(data: dict, response: Response):
@app.get("/api/facilities")
async def get_facilities():
"""Henter alle anlegg med aggregert banestatus for kortene."""
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
@ -167,7 +212,7 @@ async def get_facilities():
@app.get("/api/facilities/{slug}")
async def get_facility(slug: str):
"""Henter ett anlegg med alle baner og hull (brukes i FacilityDetailView)."""
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT f.*, (
@ -186,10 +231,20 @@ async def get_facility(slug: str):
""", slug)
if not row:
raise HTTPException(status_code=404, detail="Banen finnes ikke")
raise HTTPException(status_code=404, detail="Golfanlegget ble ikke funnet")
return format_row(row)
@app.get("/api/health")
async def health_check():
return {"status": "healthy"}
"""Enkel sjekk for å se at API og DB lever."""
try:
async with app.state.pool.acquire() as conn:
await conn.execute("SELECT 1")
return {"status": "healthy", "database": "connected"}
except Exception as e:
return {"status": "unhealthy", "error": str(e)}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View file

@ -1,100 +0,0 @@
import asyncio
import asyncpg
import httpx
from bs4 import BeautifulSoup
import re
import json
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
def clean_name(text):
if not text: return ""
# Vasker navnet for matching (fjerner alt unntatt bokstaver)
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" & ", "").strip()
return re.sub(r'[^a-z]', '', s)
async def get_golfamore_links(client):
"""Henter ALLE norske klubblenker fra Golfamore sin sitemap"""
print("🕵️ Henter komplett liste fra Golfamore...")
try:
# Golfamore har egne sitemaps for hvert land
resp = await client.get("https://www.golfamore.com/sitemaps/courses-no.xml")
if resp.status_code == 200:
links = re.findall(r'<loc>(https://www.golfamore.com/no/golfklubb/.*?/)</loc>', resp.text)
return list(set(links))
except Exception as e:
print(f"❌ Kunne ikke hente sitemap: {e}")
return []
async def scrape_golfamore():
print("\n******************************************")
print("🚀 STARTER GOLFAMORE-SYNKRONISERING v1.0")
print("******************************************\n")
conn = await asyncpg.connect(DB_URL)
facilities = await conn.fetch("SELECT id, name FROM facilities")
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
ga_links = await get_golfamore_links(client)
# Map vaskede navn fra URL-en til selve URL-en
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in ga_links}
matches_found = 0
for fac in facilities:
fac_id = fac['id']
fac_name = fac['name']
fac_clean = clean_name(fac_name)
match_url = link_map.get(fac_clean)
# Prøv delvis match hvis ikke eksakt (f.eks "Arendal" i "Arendal og Omegn")
if not match_url:
for slug, url in link_map.items():
if len(fac_clean) > 4 and (fac_clean in slug or slug in fac_clean):
match_url = url
break
if match_url:
try:
# Gå til klubbsiden for å finne vilkårene
f_resp = await client.get(match_url)
soup = BeautifulSoup(f_resp.text, 'html.parser')
# Finn teksten om når kortet gjelder.
# Golfamore bruker ofte spesifikke klasser for "rules" eller "conditions"
rules_section = soup.find('div', {'class': 'course-rules'}) or \
soup.find('div', {'class': 'course-info__rules'}) or \
soup.find(text=re.compile(r'Golfamore gjelder', re.I))
validity = "Gjelder alle dager" # Standard
if rules_section:
# Rydd opp i teksten
validity = rules_section.get_text(separator=' ').replace('\n', ' ')
validity = re.sub(r'\s+', ' ', validity).strip()
ga_data = {
"validity": validity,
"source_url": match_url
}
# Oppdater databasen
await conn.execute("""
UPDATE facilities
SET golfamore = true, golfamore_data = $1
WHERE id = $2
""", json.dumps(ga_data), fac_id)
print(f"✅ MATCH: {fac_name} ({validity[:50]}...)")
matches_found += 1
except:
# Hvis vi ikke klarer å lese detaljene, markerer vi den i hvert fall som aktiv
await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
else:
# Hvis den ikke finnes på Golfamore, sett til false
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
await conn.close()
print(f"\n🎉 Ferdig! {matches_found} baner er nå bekreftet hos Golfamore.")
if __name__ == "__main__":
asyncio.run(scrape_golfamore())

View file

@ -1,110 +0,0 @@
import asyncio
import asyncpg
import httpx
from bs4 import BeautifulSoup
import re
import json
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
def clean(text):
if not text: return ""
# Fjerner alt som ikke er bokstaver for å matche navn på tvers av systemer
return re.sub(r'[^a-z0-9]', '', text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", ""))
async def scrape_golfamore():
print("\n******************************************")
print("🚀 GOLFAMORE ULTIMATE SYNC v1.2")
print("******************************************\n")
conn = await asyncpg.connect(DB_URL)
facilities = await conn.fetch("SELECT id, name FROM facilities")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
}
async with httpx.AsyncClient(timeout=30.0, headers=headers, follow_redirects=True) as client:
print("🕵️ Henter baneliste fra Golfamore (Norge)...")
url = "https://www.golfamore.com/no/golfbaner/?country=NO"
resp = await client.get(url)
# 1. Finn alle klubb-lenker i kildekoden (omgår lazyload)
# Vi leter etter mønsteret "/no/golfklubb/navn-pa-klubb/"
all_slugs = re.findall(r'/no/golfklubb/([^/\"\s]+)/', resp.text)
found_links = list(set([f"https://www.golfamore.com/no/golfklubb/{s}/" for s in all_slugs]))
print(f"📍 Fant {len(found_links)} potensielle norske klubber i kildekoden.")
if not found_links:
print("❌ Klarte ikke å finne noen baner. Sjekk om Golfamore har endret URL-struktur.")
await conn.close()
return
# Lag et map for rask matching {vasket_navn: url}
link_map = {clean(l.split('/')[-2].replace('-', ' ')): l for l in found_links}
matches_found = 0
for fac in facilities:
fac_id = fac['id']
fac_name = fac['name']
fac_clean = clean(fac_name)
match_url = link_map.get(fac_clean)
# Prøv delvis match (f.eks "Arendal" i "Arendal og Omegn")
if not match_url:
for key, url in link_map.items():
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
match_url = url
break
if match_url:
try:
print(f"✅ Match: {fac_name}")
f_resp = await client.get(match_url)
soup = BeautifulSoup(f_resp.text, 'html.parser')
# Finn teksten om når kortet gjelder
# Vi leter etter div-er som inneholder "Gjelder"
validity = "Gjelder én gang pr. sesong."
# Golfamore bruker ofte en liste (ul/li) eller en spesifikk div for regler
rules_container = soup.find('div', class_=re.compile(r'rules|conditions|terms', re.I))
if not rules_container:
# Fallback: Let etter tekstblokken manuelt
for div in soup.find_all('div'):
if div.text and "Gjelder" in div.text and len(div.text) < 200:
validity = div.text.strip()
break
else:
validity = rules_container.get_text(separator=' ').strip()
# Vask teksten for linjeskift
validity = re.sub(r'\s+', ' ', validity).replace('"', '').strip()
ga_data = {
"url": match_url,
"validity": validity
}
await conn.execute("""
UPDATE facilities
SET golfamore = true, golfamore_data = $1
WHERE id = $2
""", json.dumps(ga_data), fac_id)
matches_found += 1
await asyncio.sleep(0.2) # Vær snill
except Exception as e:
print(f"⚠️ Feil ved parsing av {fac_name}: {e}")
await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
else:
# Hvis ikke funnet, sett til false
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
await conn.close()
print(f"\n🎉 Ferdig! {matches_found} baner er nå synkronisert med Golfamore.")
if __name__ == "__main__":
asyncio.run(scrape_golfamore())

View file

@ -1,11 +1,9 @@
📁 teeoff/
📄 fil-tre.txt
📄 struktur2_dump.txt
📄 seed.sql
📄 teeoff_backup_2.sql
📄 eksport_script.py
📄 update_golfbox.sql
📄 teeoff_backup_1.sql
📄 teeoff_backup.sql
📄 docker-compose.yml
📄 schema.sql
📄 init.sql
@ -661,6 +659,7 @@
📄 main_selje-golfklubb.jpg
📁 src/
📄 struktur_dump.txt
📄 middleware.ts
📁 components/
📄 Header.tsx
📁 config/
@ -677,6 +676,10 @@
📄 CourseDisplay.tsx
📄 page.tsx
📄 FacilityDetailView.tsx
📁 admin/
📄 page.tsx
📁 login/
📄 page.tsx
📁 kode_eksport_1/
📄 frontend_src_components_Header_tsx.txt
📄 frontend_next-env_d_ts.txt
@ -684,9 +687,12 @@
📄 frontend_src_app_page_tsx.txt
📄 eksport_script_py.txt
📄 frontend_src_app_golfbaner_[slug]_page_tsx.txt
📄 frontend_src_middleware_ts.txt
📄 frontend_src_app_golfbaner_[slug]_CourseDisplay_tsx.txt
📄 frontend_next_config_ts.txt
📄 frontend_src_app_admin_login_page_tsx.txt
📄 frontend_src_app_golfbaner_[slug]_FacilityDetailView_tsx.txt
📄 frontend_src_app_admin_page_tsx.txt
📄 frontend_src_app_HeroSlider_tsx.txt
📄 frontend_src_app_FacilitySearch_tsx.txt
📄 frontend_src_config_constants_ts.txt
@ -694,14 +700,13 @@
📄 scrape_nsg_3.py
📄 import_gallery.py
📄 .env
📄 scrape_golfamore1.2.py
📄 sync_greenfee.py
📄 scrape_status.py
📄 scrape_golfamore1.3.py
📄 requirements.txt
📄 import_wp.py
📄 create_admin.py
📄 main.py
📄 Dockerfile
📄 scrape_golfamore.py
📁 public/
📁 media/

View file

@ -0,0 +1,44 @@
"""
TEE OFF ADMIN GENERATOR v1.3 (PBKDF2 ONLY)
---------------------------------------------------------------------------
FUNKSJON: Genererer SQL-kommando for PBKDF2-basert administrator.
---------------------------------------------------------------------------
"""
import pyotp
from passlib.hash import pbkdf2_sha256
import getpass
def generate_admin():
print("\n" + "="*50)
print(" TEE OFF ADMIN GENERATOR v1.3")
print("="*50)
username = input("Brukernavn (f.eks Envide Webutvikling): ").strip()
email = input("E-post: ").strip()
password = getpass.getpass("Passord: ")
otp_secret = pyotp.random_base32()
print("⏳ Genererer PBKDF2-hash...")
# Dette vil produsere en streng som starter med $pbkdf2-sha256$...
password_hash = pbkdf2_sha256.hash(password)
print("\n✅ GENERERING VELLYKKET!")
print("-" * 50)
print("1. KJØR DISSE TO KOMMANDOENE I REKKEFØLGE:")
print("-" * 50)
# Kommando 1: Tøm tabellen for å fjerne gamle, inkompatible hasher
print(f'docker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c "TRUNCATE admins;"')
# Kommando 2: Sett inn den nye brukeren
sql = f"INSERT INTO admins (username, email, password_hash, otp_secret) VALUES ('{username}', '{email}', '{password_hash}', '{otp_secret}');"
print(f'\ndocker exec -it teeoff_db psql -U teeoff_admin -d teeoff -c "{sql}"')
print("\n" + "-" * 50)
print("2. KONFIGURER 2FA I GOOGLE AUTHENTICATOR:")
print(f"Nøkkel: {otp_secret}")
print("-" * 50 + "\n")
if __name__ == "__main__":
generate_admin()

View file

@ -1,18 +1,42 @@
from fastapi import FastAPI, HTTPException
"""
TEE OFF BACKEND API v3.6.8 - THE RESTORED MASTER VERSION
---------------------------------------------------------------------------
REGEL 1: Bruk str (ikke string) for type-hinting.
REGEL 2: Inkluder alle subqueries for banestatus og hull-data.
REGEL 3: Robust JSON-parsing (format_row) for å hindre Frontend-krasj.
REGEL 4: JWT-sesjoner lagres i HTTP-only cookies.
LOV: Aldri trunker eller slett logikk for "effektivitet".
---------------------------------------------------------------------------
"""
from fastapi import FastAPI, HTTPException, Response, Cookie, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import asyncpg
import json
from datetime import date, datetime
import pyotp
import os
from datetime import datetime, date, timedelta
from jose import jwt, JWTError
from passlib.context import CryptContext
from dotenv import load_dotenv
load_dotenv()
# --- KONFIGURASJON ---
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff")
SECRET_KEY = os.getenv("JWT_SECRET", "super_secret_change_this_in_production")
ALGORITHM = "HS256"
# VIKTIG: Vi bruker PBKDF2-SHA256 for å unngå Bcrypt-begrensninger
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def format_row(row):
"""
Vasker data fra databasen:
1. Konverterer datoer til ISO-format.
2. Tvinger tekst-JSON (stringified JSON) over til ekte Python objekter/lister.
3. Sikrer at lister og objekter aldri er None for å hindre Frontend-krasj.
"""
if row is None:
return None
@ -25,7 +49,6 @@ def format_row(row):
d[key] = d[key].isoformat()
# 2. Definer alle felter som inneholder JSON-data
# Disse må parses manuelt hvis de kommer som strenger fra Postgres
json_list_fields = [
'course_statuses', 'courses', 'gallery', 'greenfee',
'faqs', 'shotzoom', 'social_links', 'holes'
@ -68,6 +91,7 @@ def format_row(row):
async def lifespan(app: FastAPI):
# Opprett database-pool ved start
try:
print(f"📡 Forsøker å koble til database på: {DB_URL}")
app.state.pool = await asyncpg.create_pool(
DB_URL,
min_size=5,
@ -82,19 +106,96 @@ async def lifespan(app: FastAPI):
# Lukk pool ved avslutning
await app.state.pool.close()
app = FastAPI(title="TeeOff API v3.5", lifespan=lifespan)
app = FastAPI(title="TeeOff API v3.6.8", lifespan=lifespan)
# CORS-oppsett slik at Next.js kan snakke med API-et
# CORS - Tillater både lokal utvikling og produksjonsdomene
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[
"https://nye.teeoff.no",
"http://nye.teeoff.no",
"http://localhost:3000"
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- AUTH ENDPOINTS ---
@app.post("/api/auth/login")
async def login(data: dict):
"""Steg 1: Sjekk passord og returner temp_token for 2FA."""
print(f"🔐 Login-forsøk for: {data.get('username')}")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow(
"SELECT * FROM admins WHERE username = $1 OR email = $1",
data.get('username')
)
if not admin:
print(" - ❌ Bruker ikke funnet i databasen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
# DEBUG: Printer starten på hashen for å verifisere formatet ($pbkdf2-sha256$...)
h = admin['password_hash']
print(f" - Verifiserer hash i DB (starter med: {h[:20]}...)")
try:
if not pwd_context.verify(data.get('password'), h):
print(" - ❌ Passordet samsvarer ikke med hashen")
raise HTTPException(status_code=401, detail="Ugyldig brukernavn eller passord")
except Exception as e:
print(f" - 🔥 FEIL VED VERIFISERING: {e}")
raise HTTPException(status_code=500, detail="Internt problem med passord-format")
temp_token = jwt.encode(
{"sub": admin['username'], "partial": True, "exp": datetime.utcnow() + timedelta(minutes=5)},
SECRET_KEY, algorithm=ALGORITHM
)
print(" - ✅ Steg 1 fullført. Temp-token generert.")
return {"step": "2fa", "temp_token": temp_token}
@app.post("/api/auth/verify-2fa")
async def verify_2fa(data: dict, response: Response):
"""Steg 2: Verifiser TOTP-kode og sett session cookie."""
try:
payload = jwt.decode(data.get('temp_token'), SECRET_KEY, algorithms=[ALGORITHM])
if not payload.get("partial"):
raise JWTError()
username = payload.get("sub")
except JWTError:
raise HTTPException(status_code=401, detail="Sesjonen har utløpt eller er ugyldig")
async with app.state.pool.acquire() as conn:
admin = await conn.fetchrow("SELECT otp_secret FROM admins WHERE username = $1", username)
totp = pyotp.TOTP(admin['otp_secret'])
if not totp.verify(data.get('code')):
print(f" - ❌ Feil 2FA-kode oppgitt for {username}")
raise HTTPException(status_code=401, detail="Feil 2FA-kode")
final_token = jwt.encode(
{"sub": username, "exp": datetime.utcnow() + timedelta(hours=12)},
SECRET_KEY, algorithm=ALGORITHM
)
# Sett som HTTP-only cookie
response.set_cookie(
key="admin_session",
value=final_token,
httponly=True,
samesite="lax",
secure=False # Sett til True i produksjon (HTTPS)
)
return {"status": "success"}
# --- DATA ENDPOINTS ---
@app.get("/api/facilities")
async def get_facilities():
"""Henter alle golfanlegg med aggregert banestatus"""
"""Henter alle golfanlegg med aggregert banestatus for forsiden."""
async with app.state.pool.acquire() as conn:
rows = await conn.fetch("""
SELECT f.*, (
@ -111,7 +212,7 @@ async def get_facilities():
@app.get("/api/facilities/{slug}")
async def get_facility(slug: str):
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull"""
"""Henter detaljer for ett spesifikt golfanlegg inkludert alle baner og hull."""
async with app.state.pool.acquire() as conn:
row = await conn.fetchrow("""
SELECT f.*, (
@ -136,7 +237,7 @@ async def get_facility(slug: str):
@app.get("/api/health")
async def health_check():
"""Enkel sjekk for å se at API og DB lever"""
"""Enkel sjekk for å se at API og DB lever."""
try:
async with app.state.pool.acquire() as conn:
await conn.execute("SELECT 1")

View file

@ -1,110 +0,0 @@
import asyncio
import asyncpg
import httpx
from bs4 import BeautifulSoup
import re
import json
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
def clean(text):
if not text: return ""
# Fjerner alt som ikke er bokstaver for å matche navn på tvers av systemer
return re.sub(r'[^a-z0-9]', '', text.lower().replace("golfklubb", "").replace("gk", "").replace(" og ", "").replace("&", ""))
async def scrape_golfamore():
print("\n******************************************")
print("🚀 GOLFAMORE ULTIMATE SYNC v1.2")
print("******************************************\n")
conn = await asyncpg.connect(DB_URL)
facilities = await conn.fetch("SELECT id, name FROM facilities")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36',
}
async with httpx.AsyncClient(timeout=30.0, headers=headers, follow_redirects=True) as client:
print("🕵️ Henter baneliste fra Golfamore (Norge)...")
url = "https://www.golfamore.com/no/golfbaner/?country=NO"
resp = await client.get(url)
# 1. Finn alle klubb-lenker i kildekoden (omgår lazyload)
# Vi leter etter mønsteret "/no/golfklubb/navn-pa-klubb/"
all_slugs = re.findall(r'/no/golfklubb/([^/\"\s]+)/', resp.text)
found_links = list(set([f"https://www.golfamore.com/no/golfklubb/{s}/" for s in all_slugs]))
print(f"📍 Fant {len(found_links)} potensielle norske klubber i kildekoden.")
if not found_links:
print("❌ Klarte ikke å finne noen baner. Sjekk om Golfamore har endret URL-struktur.")
await conn.close()
return
# Lag et map for rask matching {vasket_navn: url}
link_map = {clean(l.split('/')[-2].replace('-', ' ')): l for l in found_links}
matches_found = 0
for fac in facilities:
fac_id = fac['id']
fac_name = fac['name']
fac_clean = clean(fac_name)
match_url = link_map.get(fac_clean)
# Prøv delvis match (f.eks "Arendal" i "Arendal og Omegn")
if not match_url:
for key, url in link_map.items():
if len(fac_clean) > 4 and (fac_clean in key or key in fac_clean):
match_url = url
break
if match_url:
try:
print(f"✅ Match: {fac_name}")
f_resp = await client.get(match_url)
soup = BeautifulSoup(f_resp.text, 'html.parser')
# Finn teksten om når kortet gjelder
# Vi leter etter div-er som inneholder "Gjelder"
validity = "Gjelder én gang pr. sesong."
# Golfamore bruker ofte en liste (ul/li) eller en spesifikk div for regler
rules_container = soup.find('div', class_=re.compile(r'rules|conditions|terms', re.I))
if not rules_container:
# Fallback: Let etter tekstblokken manuelt
for div in soup.find_all('div'):
if div.text and "Gjelder" in div.text and len(div.text) < 200:
validity = div.text.strip()
break
else:
validity = rules_container.get_text(separator=' ').strip()
# Vask teksten for linjeskift
validity = re.sub(r'\s+', ' ', validity).replace('"', '').strip()
ga_data = {
"url": match_url,
"validity": validity
}
await conn.execute("""
UPDATE facilities
SET golfamore = true, golfamore_data = $1
WHERE id = $2
""", json.dumps(ga_data), fac_id)
matches_found += 1
await asyncio.sleep(0.2) # Vær snill
except Exception as e:
print(f"⚠️ Feil ved parsing av {fac_name}: {e}")
await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
else:
# Hvis ikke funnet, sett til false
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
await conn.close()
print(f"\n🎉 Ferdig! {matches_found} baner er nå synkronisert med Golfamore.")
if __name__ == "__main__":
asyncio.run(scrape_golfamore())

View file

@ -1,100 +0,0 @@
import asyncio
import asyncpg
import httpx
from bs4 import BeautifulSoup
import re
import json
DB_URL = "postgresql://teeoff_admin:teeoff_secret_password@db:5432/teeoff"
def clean_name(text):
if not text: return ""
# Vasker navnet for matching (fjerner alt unntatt bokstaver)
s = text.lower().replace("golfklubb", "").replace("gk", "").replace(" & ", "").strip()
return re.sub(r'[^a-z]', '', s)
async def get_golfamore_links(client):
"""Henter ALLE norske klubblenker fra Golfamore sin sitemap"""
print("🕵️ Henter komplett liste fra Golfamore...")
try:
# Golfamore har egne sitemaps for hvert land
resp = await client.get("https://www.golfamore.com/sitemaps/courses-no.xml")
if resp.status_code == 200:
links = re.findall(r'<loc>(https://www.golfamore.com/no/golfklubb/.*?/)</loc>', resp.text)
return list(set(links))
except Exception as e:
print(f"❌ Kunne ikke hente sitemap: {e}")
return []
async def scrape_golfamore():
print("\n******************************************")
print("🚀 STARTER GOLFAMORE-SYNKRONISERING v1.0")
print("******************************************\n")
conn = await asyncpg.connect(DB_URL)
facilities = await conn.fetch("SELECT id, name FROM facilities")
async with httpx.AsyncClient(timeout=20.0, headers={'User-Agent': 'Mozilla/5.0'}) as client:
ga_links = await get_golfamore_links(client)
# Map vaskede navn fra URL-en til selve URL-en
link_map = {clean_name(l.split('/')[-2].replace('-', ' ')): l for l in ga_links}
matches_found = 0
for fac in facilities:
fac_id = fac['id']
fac_name = fac['name']
fac_clean = clean_name(fac_name)
match_url = link_map.get(fac_clean)
# Prøv delvis match hvis ikke eksakt (f.eks "Arendal" i "Arendal og Omegn")
if not match_url:
for slug, url in link_map.items():
if len(fac_clean) > 4 and (fac_clean in slug or slug in fac_clean):
match_url = url
break
if match_url:
try:
# Gå til klubbsiden for å finne vilkårene
f_resp = await client.get(match_url)
soup = BeautifulSoup(f_resp.text, 'html.parser')
# Finn teksten om når kortet gjelder.
# Golfamore bruker ofte spesifikke klasser for "rules" eller "conditions"
rules_section = soup.find('div', {'class': 'course-rules'}) or \
soup.find('div', {'class': 'course-info__rules'}) or \
soup.find(text=re.compile(r'Golfamore gjelder', re.I))
validity = "Gjelder alle dager" # Standard
if rules_section:
# Rydd opp i teksten
validity = rules_section.get_text(separator=' ').replace('\n', ' ')
validity = re.sub(r'\s+', ' ', validity).strip()
ga_data = {
"validity": validity,
"source_url": match_url
}
# Oppdater databasen
await conn.execute("""
UPDATE facilities
SET golfamore = true, golfamore_data = $1
WHERE id = $2
""", json.dumps(ga_data), fac_id)
print(f"✅ MATCH: {fac_name} ({validity[:50]}...)")
matches_found += 1
except:
# Hvis vi ikke klarer å lese detaljene, markerer vi den i hvert fall som aktiv
await conn.execute("UPDATE facilities SET golfamore = true WHERE id = $1", fac_id)
else:
# Hvis den ikke finnes på Golfamore, sett til false
await conn.execute("UPDATE facilities SET golfamore = false, golfamore_data = '{}' WHERE id = $1", fac_id)
await conn.close()
print(f"\n🎉 Ferdig! {matches_found} baner er nå bekreftet hos Golfamore.")
if __name__ == "__main__":
asyncio.run(scrape_golfamore())

View file

@ -0,0 +1,102 @@
"use client";
/**
* TEE OFF ADMIN LOGIN v1.2
* ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/login/page.tsx
* FUNKSJON: Offentlig tilgjengelig innlogging for administratorer.
* ---------------------------------------------------------------------------
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { API_URL } from "@/config/constants";
export default function AdminLogin() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({ username: '', password: '', code: '' });
const [tempToken, setTempToken] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: formData.username, password: formData.password })
});
const data = await res.json();
if (res.ok) {
setTempToken(data.temp_token);
setStep(2);
} else {
setError(data.detail || 'Ugyldig pålogging');
}
} catch (err) {
setError('Systemfeil: Kunne ikke koble til API-et');
} finally {
setIsLoading(false);
}
};
const handleVerify2FA = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const res = await fetch(`${API_URL}/auth/verify-2fa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ temp_token: tempToken, code: formData.code })
});
if (res.ok) {
// VIKTIG: Etter suksess sender vi brukeren til selve dashbordet
router.push('/admin');
router.refresh();
} else {
setError('Ugyldig 2FA-kode');
}
} catch (err) {
setError('Tilkoblingsfeil ved 2FA-verifisering');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#f1f7ed] p-6 font-sans">
<div className="max-w-md w-full bg-white rounded-[3rem] shadow-2xl p-12 border border-white">
<div className="flex justify-center mb-10">
<img src="/TeeOff-logo-Retina-1.png" className="h-10 w-auto" alt="TeeOff" />
</div>
<h2 className="text-2xl font-black text-center uppercase tracking-tighter mb-8 text-[#11280f]">
{step === 1 ? "Admin Portalen" : "Tofaktor Sjekk"}
</h2>
<form onSubmit={step === 1 ? handleLogin : handleVerify2FA} className="space-y-4">
{step === 1 ? (
<>
<input type="text" placeholder="Brukernavn eller E-post" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData({...formData, username: e.target.value})} required />
<input type="password" placeholder="Passord" className="w-full p-5 bg-gray-50 rounded-2xl border-none ring-1 ring-gray-100 outline-none focus:ring-2 focus:ring-[#8bc34a] transition-all text-sm font-bold text-[#11280f]" onChange={e => setFormData({...formData, password: e.target.value})} required />
</>
) : (
<div className="space-y-4">
<p className="text-[10px] text-gray-400 font-black uppercase text-center tracking-widest">Tast inn 6 siffer fra appen din</p>
<input type="text" placeholder="000 000" className="w-full p-6 text-center text-4xl tracking-[0.3em] font-black bg-gray-50 rounded-3xl border-none ring-2 ring-[#ff5722]/20 outline-none focus:ring-[#ff5722] transition-all text-[#ff5722]" onChange={e => setFormData({...formData, code: e.target.value})} autoFocus required />
</div>
)}
{error && <div className="bg-red-50 p-4 rounded-xl text-red-600 text-[10px] font-black uppercase tracking-widest text-center border border-red-100">⚠️ {error}</div>}
<button type="submit" disabled={isLoading} className={`w-full p-6 rounded-2xl font-black uppercase text-xs tracking-widest text-white transition-all shadow-xl ${step === 1 ? 'bg-[#11280f]' : 'bg-[#ff5722]'}`}>
{isLoading ? "Venter..." : (step === 1 ? "Fortsett" : "Logg inn")}
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,91 @@
"use client";
/**
* TEE OFF ADMIN DASHBOARD v1.1
* ---------------------------------------------------------------------------
* PLASSERING: frontend/src/app/admin/page.tsx
* FUNKSJON: Monitorering av banestatus og administrasjon.
* ---------------------------------------------------------------------------
*/
import { useState, useEffect } from 'react';
import { API_URL } from "@/config/constants";
export default function AdminDashboard() {
const [facilities, setFacilities] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/facilities`)
.then(res => res.json())
.then(data => {
setFacilities(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) return <div className="p-20 text-center font-black animate-pulse">LASTER DASHBORD...</div>;
return (
<div className="flex flex-col lg:flex-row min-h-screen bg-[#f1f7ed] font-sans">
{/* SIDEBAR (22%) */}
<aside className="lg:w-[22%] bg-[#11280f] text-white p-10 flex flex-col">
<h1 className="text-2xl font-black uppercase tracking-tighter mb-10">TeeOff Admin</h1>
<nav className="space-y-6 text-[10px] font-black uppercase tracking-[0.2em] text-[#7ca982] flex-grow">
<div className="text-white border-l-4 border-[#8bc34a] pl-4 py-1">Scraping Monitor</div>
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Medlemskap</div>
<div className="hover:text-white cursor-pointer pl-4 py-1 transition-colors">Bildegalleri</div>
</nav>
<div className="mt-auto pt-10 border-t border-white/10">
<button onClick={() => window.location.href='/'} className="text-[10px] font-black uppercase tracking-widest text-red-400 hover:text-red-300">Logg ut</button>
</div>
</aside>
{/* HOVEDINNHOLD (78%) */}
<main className="lg:w-[78%] p-6 md:p-12">
<div className="bg-white rounded-[3rem] shadow-2xl p-10 md:p-16 border border-white">
<header className="flex justify-between items-center mb-12">
<div>
<h2 className="text-4xl font-black tracking-tighter text-[#11280f] mb-2">Scraping Monitor</h2>
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest">Sjekker status på {facilities.length} anlegg</p>
</div>
<button className="bg-[#8bc34a] text-white px-8 py-4 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-xl hover:scale-105 transition-all">Kjør alle skrapere</button>
</header>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-[10px] font-black uppercase tracking-widest text-gray-300 border-b border-gray-50">
<th className="pb-6">Anlegg</th>
<th className="pb-6">Konfigurasjon</th>
<th className="pb-6">Siste Sjekk</th>
<th className="pb-6 text-right">Status</th>
</tr>
</thead>
<tbody className="text-sm font-bold text-[#11280f]">
{facilities.map((f: any) => (
<tr key={f.id} className="border-b border-gray-50 group hover:bg-gray-50/50 transition-colors">
<td className="py-8">
<div className="font-black text-lg">{f.name}</div>
<div className="text-[10px] text-[#7ca982] uppercase tracking-widest">{f.city}</div>
</td>
<td className="py-8">
<div className="text-[11px] text-blue-600 truncate max-w-[200px] mb-1">{f.scrape_status_url}</div>
<div className="text-[10px] font-mono text-gray-300">{f.scrape_status_selector}</div>
</td>
<td className="py-8 text-gray-400 font-mono text-xs">
{f.status_updated_at ? new Date(f.status_updated_at).toLocaleDateString('nb-NO') : 'Aldri'}
</td>
<td className="py-8 text-right">
<button className="bg-gray-100 px-5 py-2.5 rounded-xl text-[9px] font-black uppercase tracking-widest hover:bg-[#11280f] hover:text-white transition-all">Rediger</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
</div>
);
}

View file

@ -20,7 +20,7 @@ export default function Header() {
<Link href="/golfbaner" className="hover:text-[#8bc34a]">Finn Bane</Link>
<Link href="/medlemskap" className="hover:text-[#8bc34a]">Medlemskap</Link>
<Link href="/om-oss" className="hover:text-[#8bc34a]">Om oss</Link>
<Link href="/logg-inn" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-[#e64a19] transition-all">Admin</Link>
<Link href="/admin/login" className="px-5 py-2 bg-[#ff5722] text-white rounded-xl hover:bg-black transition-all font-black uppercase text-[10px] tracking-widest">Admin</Link>
</nav>
{/* HAMBURGER (Mobil) */}

View file

@ -1,5 +1,24 @@
// Globale innstillinger for TeeOff.no
export const API_URL = process.env.API_URL || "http://api:8000/api";
/**
* TEE OFF CONFIG CONSTANTS v1.3
* ---------------------------------------------------------------------------
* REGEL 1: ALDRI trunker eller fjern data fra denne filen.
* REGEL 2: Håndterer både intern Docker-kommunikasjon og ekstern browser-kommunikasjon.
* REGEL 3: Inneholder alle regionale mappinger for Norge.
* ---------------------------------------------------------------------------
*/
const isBrowser = typeof window !== 'undefined';
// Intern URL for server-to-server (Docker-internt)
const INTERNAL_API = process.env.API_URL || "http://api:8000/api";
// Relativ sti for browseren.
// Ved å bruke '/api' sørger vi for at nettleseren bruker samme protokoll (https)
// og domene (nye.teeoff.no) som resten av siden.
const EXTERNAL_API = "/api";
export const API_URL = isBrowser ? EXTERNAL_API : INTERNAL_API;
export const FALLBACK_IMAGE = "/Toppbilde-standard.jpg";
export const TEEOFF_LOGO = "/TeeOff-logo-Retina-1.png";
@ -20,4 +39,4 @@ export const REGIONS: Record<string, string[]> = {
"vestlandet": ["møre og romsdal", "sogn og fjordane", "hordaland", "rogaland", "vestland"],
"sørlandet": ["vest-agder", "aust-agder", "agder"],
"østlandet": ["telemark", "vestfold", "østfold", "buskerud", "hedmark", "oppland", "oslo", "akershus", "innlandet", "viken"]
};
};

View file

@ -0,0 +1,36 @@
/**
* TEE OFF SECURITY MIDDLEWARE v1.0
* ---------------------------------------------------------------------------
* REGEL: Beskytter alle ruter under /admin (unntatt /admin/login).
* FUNKSJON: Sjekker for admin_session cookie og omdirigerer hvis den mangler.
* ---------------------------------------------------------------------------
*/
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/request';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const session = request.cookies.get('admin_session');
// 1. Tillat alltid tilgang til innloggingssiden
if (pathname.startsWith('/admin/login')) {
return NextResponse.next();
}
// 2. Beskytt alle andre ruter under /admin
if (pathname.startsWith('/admin')) {
if (!session) {
// Ingen sesjon funnet -> Send til innlogging
const loginUrl = new URL('/admin/login', request.url);
return NextResponse.redirect(loginUrl);
}
}
return NextResponse.next();
}
// Definer hvilke ruter middleware skal kjøre på
export const config = {
matcher: ['/admin/:path*'],
};

View file

@ -110,6 +110,44 @@ SET default_tablespace = '';
SET default_table_access_method = heap;
--
-- Name: admins; Type: TABLE; Schema: public; Owner: teeoff_admin
--
CREATE TABLE public.admins (
id integer NOT NULL,
username character varying(50) NOT NULL,
password_hash text NOT NULL,
otp_secret character varying(32),
created_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
email character varying(255)
);
ALTER TABLE public.admins OWNER TO teeoff_admin;
--
-- Name: admins_id_seq; Type: SEQUENCE; Schema: public; Owner: teeoff_admin
--
CREATE SEQUENCE public.admins_id_seq
AS integer
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE public.admins_id_seq OWNER TO teeoff_admin;
--
-- Name: admins_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: teeoff_admin
--
ALTER SEQUENCE public.admins_id_seq OWNED BY public.admins.id;
--
-- Name: courses; Type: TABLE; Schema: public; Owner: teeoff_admin
--
@ -401,6 +439,13 @@ ALTER TABLE public.tees_id_seq OWNER TO teeoff_admin;
ALTER SEQUENCE public.tees_id_seq OWNED BY public.tees.id;
--
-- Name: admins id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.admins ALTER COLUMN id SET DEFAULT nextval('public.admins_id_seq'::regclass);
--
-- Name: courses id; Type: DEFAULT; Schema: public; Owner: teeoff_admin
--
@ -443,6 +488,30 @@ ALTER TABLE ONLY public.holes ALTER COLUMN id SET DEFAULT nextval('public.holes_
ALTER TABLE ONLY public.tees ALTER COLUMN id SET DEFAULT nextval('public.tees_id_seq'::regclass);
--
-- Name: admins admins_email_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.admins
ADD CONSTRAINT admins_email_key UNIQUE (email);
--
-- Name: admins admins_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.admins
ADD CONSTRAINT admins_pkey PRIMARY KEY (id);
--
-- Name: admins admins_username_key; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--
ALTER TABLE ONLY public.admins
ADD CONSTRAINT admins_username_key UNIQUE (username);
--
-- Name: courses courses_pkey; Type: CONSTRAINT; Schema: public; Owner: teeoff_admin
--

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff