diff --git a/package.json b/package.json index 092a25d..03e363a 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "dev:server": "node --env-file-if-exists=.env --watch server/index.mjs", - "start:server": "node --env-file-if-exists=.env server/index.mjs" + "dev:server": "node --env-file-if-exists=.env --watch server/index.ts", + "start:server": "node --env-file-if-exists=.env server/index.ts" }, "dependencies": { "graphql": "^17.0.2", diff --git a/public/images/explore-nature.jpg b/public/images/explore-nature.jpg new file mode 100644 index 0000000..299fcfe Binary files /dev/null and b/public/images/explore-nature.jpg differ diff --git a/server/authLimits.ts b/server/authLimits.ts new file mode 100644 index 0000000..0ed84d8 --- /dev/null +++ b/server/authLimits.ts @@ -0,0 +1,13 @@ +import {GraphQLError} from 'graphql' +// In-memory guard for this single-process prototype; do not trust forwarded IP headers. +const attempts=new Map() +let globalWindow={start:0,count:0} +export function checkAuthAttempt(username:string,now=Date.now()){ + if(now-globalWindow.start>=60000)globalWindow={start:now,count:0} + if(++globalWindow.count>120)throw new GraphQLError('For mange innloggingsforsøk. Vent ett minutt og prøv igjen.') + for(const [key,value] of attempts)if(now-value.start>=900000)attempts.delete(key) + const key=username.trim().toLowerCase().slice(0,100) + const value=attempts.get(key)||{start:now,count:0} + attempts.set(key,value) + if(++value.count>20)throw new GraphQLError('For mange forsøk for dette brukernavnet. Vent 15 minutter og prøv igjen.') +} diff --git a/server/customRoutes.ts b/server/customRoutes.ts new file mode 100644 index 0000000..e2bd021 --- /dev/null +++ b/server/customRoutes.ts @@ -0,0 +1,167 @@ +import { GraphQLError } from 'graphql' +import { pool } from './db.ts' + +export type BuilderPoint={lon:number;lat:number} +type NetworkNode={id:string;xy:number[];ll:number[];neighbors:Map} +type LineRow={projected:{coordinates:number[][]};geographic:{coordinates:number[][]}} +type RingRow=LineRow & {id:string;length:number} +type Network={nodes:Map;sourceRouteId:string;sourceName:string;geojson:string;cycles?:Promise} +const networks = new Map>() +export function builderNetwork(routeId:string|null = null) { + if (routeId && !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig kilderute.') + const key = routeId || 'Bymarka' + if (!networks.has(key)) { + if (networks.size >= 4) networks.delete(networks.keys().next().value!) + networks.set(key,loadNetwork(routeId).catch(error => {networks.delete(key);throw error})) + } + return networks.get(key)! +} +async function loadNetwork(routeId:string|null):Promise { + const { rows: sources } = await pool.query(`SELECT id,name FROM norwegian_explorer_app.route_catalog + WHERE (($1::text IS NOT NULL AND id=$1) OR ($1::text IS NULL AND name='Bymarka')) + AND NOT EXISTS(SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=route_catalog.id) + UNION ALL SELECT id,name FROM norwegian_explorer_app.route_loops WHERE id=$1`,[routeId]) + if (!sources.length) throw new GraphQLError('Kilderuten finnes ikke lenger. Velg en annen rute.') + const source = sources[0] + const { rows } = await pool.query(`WITH network AS ( + SELECT ST_Node(ST_UnaryUnion(ST_Collect(geom))) AS geom + FROM (SELECT geom FROM norwegian_explorer_app.route_catalog WHERE id=$1 + UNION ALL SELECT geom FROM norwegian_explorer_app.route_loops WHERE id=$1) source + ), lines AS ( + SELECT (ST_Dump(ST_Segmentize(geom,30))).geom AS geom FROM network + ) SELECT ST_AsGeoJSON(geom)::json AS projected, + ST_AsGeoJSON(ST_Transform(geom,4326))::json AS geographic FROM lines`,[source.id]) + if (!rows.length) throw new GraphQLError('Denne ruten har ikke et tilgjengelig stinett.') + const pointCount=rows.reduce((total,row)=>total+row.projected.coordinates.length,0) + if(pointCount>250000)throw new GraphQLError('Stinettet er for stort for rutebyggeren. Velg en mindre rutevariant.') + const nodes = new Map() + const key = (point:number[]) => point.map(n => n.toFixed(6)).join(',') + for (const row of rows) { + const xy = row.projected.coordinates + const ll = row.geographic.coordinates + for (let i=0;i row.geographic.coordinates) }) } +} + +function shortestPath(nodes:Map, start:string, end:string) { + const distances = new Map([[start,0]]), previous = new Map(), visited = new Set() + const queue:[number,string][] = [[0,start]] + function push(entry:[number,string]) { + queue.push(entry) + let i=queue.length-1 + while(i>0){const parent=Math.floor((i-1)/2);if(queue[parent][0]<=entry[0])break;queue[i]=queue[parent];i=parent} + queue[i]=entry + } + function pop() { + const result=queue[0],last=queue.pop()! + if(queue.length){let i=0;queue[0]=last;while(true){let child=i*2+1;if(child>=queue.length)break;if(child+1=last[0])break;queue[i]=queue[child];i=child}queue[i]=last} + return result + } + while (queue.length) { + const [distance,id] = pop() + if (visited.has(id)) continue + visited.add(id) + if (id === end) { + const path = [id] + while (path[path.length-1] !== start) path.push(previous.get(path[path.length-1])) + path.reverse() + return { path, distance } + } + for (const [neighbor,cost] of nodes.get(id)!.neighbors) { + const next = distance+cost + if (next < (distances.get(neighbor) ?? Infinity)) { distances.set(neighbor,next); previous.set(neighbor,id); push([next,neighbor]) } + } + } + throw new GraphQLError('Punktene ligger på adskilte deler av stinettet. Flytt et punkt til en sti som er koblet til de andre.') +} + +export async function planCustomRoute(points:BuilderPoint[], routeId:string|null = null) { + if (!Array.isArray(points) || points.length<2 || points.length>12 || points.some(p => !p || !Number.isFinite(p.lon) || !Number.isFinite(p.lat) || p.lon<-180 || p.lon>180 || p.lat<-90 || p.lat>90)) throw new GraphQLError('Velg 2–12 gyldige punkter på den valgte ruten.') + const network = await builderNetwork(routeId) + const { rows } = await pool.query(`SELECT ST_X(p) AS x,ST_Y(p) AS y FROM ( + SELECT ST_Transform(ST_SetSRID(ST_MakePoint((v->>'lon')::float8,(v->>'lat')::float8),4326),25833) AS p + FROM jsonb_array_elements($1::jsonb) WITH ORDINALITY AS input(v,n) ORDER BY n + ) transformed`, [JSON.stringify(points)]) + const snapped = rows.map(point => { + let best:NetworkNode|undefined, distance = Infinity + for (const node of network.nodes.values()) { + const d = Math.hypot(node.xy[0]-point.x,node.xy[1]-point.y) + if (d120) throw new GraphQLError('Et punkt er mer enn 120 meter fra de registrerte stiene. Flytt det nærmere en sti.') + return best + }) + let lengthMeters=0 + const coordinates=[] + for (let i=1;i ({lon:node.ll[0],lat:node.ll[1]})) } +} + +export async function suggestRoundTrips(start:BuilderPoint, desiredLengthMeters:number, routeId:string|null = null) { + if (!start || !Number.isFinite(start.lon) || !Number.isFinite(start.lat) || Math.abs(start.lon)>180 || Math.abs(start.lat)>90 || !Number.isFinite(desiredLengthMeters) || desiredLengthMeters<500 || desiredLengthMeters>50000) throw new GraphQLError('Velg et gyldig startpunkt og en lengde mellom 0,5 og 50 km.') + const network=await builderNetwork(routeId) + const {rows:[position]}=await pool.query('SELECT ST_X(p) AS x,ST_Y(p) AS y FROM (SELECT ST_Transform(ST_SetSRID(ST_MakePoint($1,$2),4326),25833) p) point',[start.lon,start.lat]) + let startNode:NetworkNode|undefined,distance=Infinity + for(const node of network.nodes.values()){const d=Math.hypot(node.xy[0]-position.x,node.xy[1]-position.y);if(d120)throw new GraphQLError('Startpunktet må være innen 120 meter fra det valgte stinettet.') + if(!network.cycles) network.cycles=pool.query(`WITH source AS ( + SELECT geom FROM norwegian_explorer_app.route_catalog WHERE id=$1 + UNION ALL SELECT geom FROM norwegian_explorer_app.route_loops WHERE id=$1 + ), network AS (SELECT ST_Node(ST_UnaryUnion(ST_Collect(geom))) geom FROM source), + polygons AS (SELECT (ST_Dump(ST_CollectionExtract(ST_Polygonize(ARRAY[geom]),3))).geom geom FROM network), + rings AS (SELECT ST_ExteriorRing(geom) geom FROM polygons) + SELECT md5(ST_AsEWKB(ST_Normalize(geom))::text) id,ST_AsGeoJSON(geom)::json projected, + ST_AsGeoJSON(ST_Transform(geom,4326))::json geographic,ST_Length(geom) length + FROM rings WHERE ST_Length(geom) BETWEEN 200 AND 50000`,[network.sourceRouteId]).then(result=>result.rows).catch(error=>{network.cycles=undefined;throw error}) + const rings=await network.cycles + const candidates=[] + for(const ring of rings){ + let anchorIndex=0,near=Infinity + const xy=ring.projected.coordinates.slice(0,-1) + const ll=ring.geographic.coordinates.slice(0,-1) + let directLoop=null + for(let i=0;i=0&&t<=1&&Math.hypot(a[0]+t*dx-startNode.xy[0],a[1]+t*dy-startNode.xy[1])<0.0001){directLoop=[startNode.ll,...ll.slice(i+1),...ll.slice(0,i+1),startNode.ll];break} + } + for(let i=0;in.toFixed(6)).join(',')) + if(anchor)candidates.push({ring,anchor,anchorIndex,directLoop,score:Math.abs(ring.length+2*(directLoop?0:near)-desiredLengthMeters)}) + } + const proposals=[] + for(const {ring,anchor,anchorIndex,directLoop} of candidates.sort((a,b)=>a.score-b.score).slice(0,40)){ + let connector + try{connector=shortestPath(network.nodes,startNode.id,anchor.id)}catch{continue} + const lengthMeters=ring.length+2*connector.distance + if(Math.abs(lengthMeters-desiredLengthMeters)>Math.max(300,desiredLengthMeters*0.4))continue + const access=connector.path.map(id=>network.nodes.get(id)!.ll) + const ringCoords=ring.geographic.coordinates.slice(0,-1) + const loop=directLoop || [...ringCoords.slice(anchorIndex),...ringCoords.slice(0,anchorIndex),ringCoords[anchorIndex]] + const coordinates=[] + for(const point of access)coordinates.push(point) + for(const point of loop.slice(1))coordinates.push(point) + for(const point of access.slice(0,-1).reverse())coordinates.push(point) + const [lon,lat]=startNode.ll + proposals.push({id:ring.id,sourceRouteId:network.sourceRouteId,sourceName:network.sourceName, + geojson:JSON.stringify({type:'LineString',coordinates}),lengthMeters,connectorMeters:2*connector.distance, + differenceMeters:lengthMeters-desiredLengthMeters,waypoints:[{lon,lat},{lon,lat}]}) + } + return proposals.sort((a,b)=>Math.abs(a.differenceMeters)-Math.abs(b.differenceMeters)||a.connectorMeters-b.connectorMeters).slice(0,3) +} diff --git a/server/db.ts b/server/db.ts index 1982813..69c5681 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,4 +1,4 @@ -// Felles tilkobling til PostgreSQL-databasen, brukt av hele backenden (index.mjs og modulene i server/). +// Felles tilkobling til PostgreSQL-databasen, brukt av hele backenden (index.ts og modulene i server/). // Med DATABASE_URL i .env kobler vi til den (se .env.example). Uten den brukes PG-variablene, // eller en lokal database via socket i /tmp. import { userInfo } from 'node:os' diff --git a/server/elevation.ts b/server/elevation.ts new file mode 100644 index 0000000..8712fd3 --- /dev/null +++ b/server/elevation.ts @@ -0,0 +1,76 @@ +import {createHash} from 'node:crypto' +import {GraphQLError} from 'graphql' + +type SamplePoint={distanceMeters:number;lon:number;lat:number} +type Sample={points:SamplePoint[];lengthMeters:number;sampleIntervalMeters:number} +type HeightResponse={koordsys:number;punkter:{x:number;y:number;z:number|null}[]} +const cache=new Map}>() +const endpoint='https://ws.geonorge.no/hoydedata/v1/punkt' +function distance(a:number[],b:number[]){const rad=Math.PI/180;const dlat=(b[1]-a[1])*rad,dlon=(b[0]-a[0])*rad;const h=Math.sin(dlat/2)**2+Math.cos(a[1]*rad)*Math.cos(b[1]*rad)*Math.sin(dlon/2)**2;return 6371000*2*Math.atan2(Math.sqrt(h),Math.sqrt(Math.max(0,1-h)))} + +export function sampleRoute(geojson:string):Sample { + if(typeof geojson!=='string'||geojson.length>5000000)throw new GraphQLError('Kartlinjen er for stor for høydeprofil.') + let geometry:{type?:string;coordinates?:number[][]}|null + try{geometry=JSON.parse(geojson)}catch{throw new GraphQLError('Ugyldig kartlinje.')} + if(!geometry || geometry.type!=='LineString'||!Array.isArray(geometry.coordinates)||geometry.coordinates.length<2||geometry.coordinates.length>100000)throw new GraphQLError('Høydeprofil krever én sammenhengende rute.') + const coordinates=geometry.coordinates! + if(coordinates.some(p=>!Array.isArray(p)||!Number.isFinite(p[0])||!Number.isFinite(p[1])||Math.abs(p[0])>180||Math.abs(p[1])>90))throw new GraphQLError('Ruten har ugyldige koordinater.') + const cumulative=[0] + for(let i=1;i({...point,elevation:Number.isFinite(heights[i])?heights[i]:null})) + const valid=points.filter((p):p is (typeof points)[number]&{elevation:number}=>p.elevation!=null),complete=valid.length===points.length + let ascent=0,descent=0 + if(complete)for(let i=1;ip.elevation)):null, + maxElevation:valid.length?Math.max(...valid.map(p=>p.elevation)):null, + complete,missingCount:points.length-valid.length,source:'Kartverkets høydedata-API'} +} + +export async function elevationProfile(geojson:string,fetcher:typeof fetch=fetch) { + const sample=sampleRoute(geojson) + const key=createHash('sha256').update(JSON.stringify(sample.points)).digest('hex') + const existing=cache.get(key) + if(existing&&existing.expires>Date.now())return existing.value + const heights=new Array(sample.points.length).fill(null) + const signal=AbortSignal.timeout(20000) + const batches:number[]=[] + for(let i=0;i[p.lon,p.lat]))) + const response=await fetcher(url,{signal}) + if(!response.ok)throw new Error('Høydetjenesten svarte ikke.') + const result=await response.json() as HeightResponse + if(result.koordsys!==4326||!Array.isArray(result.punkter))throw new Error('Uventet svar fra høydetjenesten.') + for(let i=0;iNumber.isFinite(p.x)&&Number.isFinite(p.y)&&Math.abs(p.x-point.lon)<0.000001&&Math.abs(p.y-point.lat)<0.000001) + if(match&&Number.isFinite(match.z))heights[offset+i]=match.z + } + } + } + try{await Promise.all([worker(),worker()])}catch{throw new GraphQLError('Høydetjenesten er utilgjengelig akkurat nå. Ruten kan fortsatt brukes og lagres; prøv høydeprofilen igjen senere.')} + if(heights.every(value=>value==null))throw new GraphQLError('Ingen høydedata er tilgjengelige for denne ruten.') + const result=summarizeElevation(sample,heights) + if(cache.size>=50)cache.delete(cache.keys().next().value!) + cache.set(key,{expires:Date.now()+3600000,value:result}) + return result +} diff --git a/server/index.mjs b/server/index.mjs deleted file mode 100644 index f775470..0000000 --- a/server/index.mjs +++ /dev/null @@ -1,432 +0,0 @@ -import { createServer } from 'node:http' -import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto' -import { promisify } from 'node:util' -import { GraphQLError } from 'graphql' -import { createSchema, createYoga } from 'graphql-yoga' -import { pool } from './db.ts' -import { mountainResolvers, mountainTypeDefs } from './server.ts' - -const schemaName = process.env.DATA_SCHEMA || 'turogfriluftsruter_1b2541ab00554564ae88dabc8bb90c02' -if (!/^[a-z_][a-z0-9_]*$/.test(schemaName)) throw new Error('Invalid DATA_SCHEMA') -const scrypt = promisify(scryptCallback) -const appSchemaSetup = pool.query(` - CREATE SCHEMA IF NOT EXISTS norwegian_explorer_app; - CREATE TABLE IF NOT EXISTS norwegian_explorer_app.users ( - id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - username text NOT NULL UNIQUE, - password_hash bytea NOT NULL, - password_salt bytea NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() - ); - CREATE TABLE IF NOT EXISTS norwegian_explorer_app.sessions ( - token_hash bytea PRIMARY KEY, - user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, - expires_at timestamptz NOT NULL - ); - ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS email text; - ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS phone text; - ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS avatar text; - -- Kommunenummeret brukeren har valgt som sitt nærområde (vises på forsiden) - ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS home_municipality text; - CREATE TABLE IF NOT EXISTS norwegian_explorer_app.favorites ( - user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, - route_name text NOT NULL, - route_number text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, route_name, route_number) - ); - ALTER TABLE norwegian_explorer_app.favorites ADD COLUMN IF NOT EXISTS route_id text; - CREATE UNIQUE INDEX IF NOT EXISTS favorites_user_route_id_idx - ON norwegian_explorer_app.favorites (user_id, route_id) WHERE route_id IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_idx ON norwegian_explorer_app.users (lower(username)); -`) -// Oppsettet over krever at databasebrukeren eier tabellene. Lokalt kobler vi til som «gruppe», som kan lese -// og skrive, men ikke endre tabellene. Feiler oppsettet, men tabellene finnes allerede, fortsetter vi likevel. -const appSchemaReady = appSchemaSetup.catch(async error => { - console.error('Kunne ikke klargjøre appens tabeller:', error.message) - const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.users') AS users") - if (!rows[0].users) throw error - console.error('Tabellene finnes allerede, så backend fortsetter med dem som de er.') -}) -// Kolonnen for «Mitt sted» kan mangle hvis den ikke er lagt til av eieren av tabellen ennå -// (se server/sql/app/home_municipality.sql). Da virker alt annet, men stedsvalget er slått av. -const hasHomeMunicipality = appSchemaReady.then(async () => { - const { rowCount } = await pool.query(`SELECT 1 FROM information_schema.columns - WHERE table_schema='norwegian_explorer_app' AND table_name='users' AND column_name='home_municipality'`) - if (!rowCount) console.error('Kolonnen users.home_municipality mangler. Kjør server/sql/app/home_municipality.sql for å slå på stedsvalg.') - return rowCount > 0 -}) -/** Kolonneuttrykket for brukerens sted, eller NULL hvis kolonnen ikke finnes i databasen */ -async function homeColumn(prefix = '') { - return (await hasHomeMunicipality) ? `${prefix}home_municipality` : 'NULL AS home_municipality' -} -const routeCatalogReady = appSchemaReady.then(async () => { - const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.route_catalog') AS catalog") - if (!rows[0].catalog) throw new Error('Route catalog is missing. Run scripts/create-route-catalog.sql first.') -}) -// Mangler tabellene helt, skal bare innlogging og ruter feile, ikke hele serveren -appSchemaReady.catch(error => console.error('Appens tabeller finnes ikke:', error.message)) -routeCatalogReady.catch(error => console.error(error.message)) -hasHomeMunicipality.catch(() => undefined) // feilen er allerede logget over - -function validateCredentials(username, password) { - const normalized = username.trim() - if (!/^[\p{L}\p{N}_.-]{3,30}$/u.test(normalized)) throw new GraphQLError('Brukernavnet må være 3–30 tegn og kan inneholde bokstaver, tall, punktum, bindestrek og understrek.') - if (password.length < 10 || password.length > 200) throw new GraphQLError('Passordet må være minst 10 tegn.') - return normalized -} -async function passwordHash(password, salt) { return Buffer.from(await scrypt(password, salt, 64)) } -function tokenHash(token) { return createHash('sha256').update(token).digest() } -async function createSession(client, userId) { - const token = randomBytes(32).toString('base64url') - await client.query("INSERT INTO norwegian_explorer_app.sessions(token_hash,user_id,expires_at) VALUES ($1,$2,now()+(30 * interval '1 day'))", [tokenHash(token), userId]) - return token -} -async function userFromToken(token) { - if (!token || token.length > 200) return null - await appSchemaReady - const { rows } = await pool.query(`SELECT u.id,u.username,u.email,u.phone,u.avatar,${await homeColumn('u.')} FROM norwegian_explorer_app.sessions s - JOIN norwegian_explorer_app.users u ON u.id=s.user_id - WHERE s.token_hash=$1 AND s.expires_at>now()`, [tokenHash(token)]) - return rows[0] || null -} -async function profileFor(user) { - await routeCatalogReady - const { rows } = await pool.query(`SELECT coalesce(c.id,f.route_id) AS id, - coalesce(c.name,f.route_name) AS name,coalesce(c.number,f.route_number) AS number, - f.created_at::text AS "createdAt" - FROM norwegian_explorer_app.favorites f - LEFT JOIN norwegian_explorer_app.route_catalog c ON c.id=f.route_id - WHERE f.user_id=$1 ORDER BY f.created_at DESC`, [user.id]) - return { - username: user.username, email: user.email, phone: user.phone, avatar: user.avatar, - homeMunicipality: await municipalityByCode(user.home_municipality), favorites: rows - } -} -// Slår opp navnet på en kommune ut fra kommunenummeret. Gir null hvis kommunen ikke har registrerte ruter. -async function municipalityByCode(code) { - if (!code) return null - await routeCatalogReady - const { rows } = await pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name - FROM norwegian_explorer_app.route_areas WHERE municipality_number=$1 GROUP BY municipality_number`, [code]) - return rows[0] || null -} -async function requireUser(token) { - const user = await userFromToken(token) - if (!user) throw new GraphQLError('Du må logge inn.') - return user -} -const schema = createSchema({ - // Fjellområdene og turpunktene ligger i server/server.ts og legges til her - typeDefs: [` - type Route { - id: ID! - name: String! - number: String! - lengthMeters: Float! - segmentCount: Int! - connected: Boolean! - maintainer: String - difficulty: String - description: String - routeType: String - nameQuality: String! - duplicateCount: Int! - variantCount: Int! - geojson: String! - } - type RouteSummary { id: ID!, name: String!, number: String!, lengthMeters: Float!, difficulty: String, routeType: String, duplicateCount: Int!, variantCount: Int! } - type RouteGroup { key: String!, name: String!, variantCount: Int!, minLengthMeters: Float!, maxLengthMeters: Float!, variants: [RouteSummary!]! } - type RoutePage { items: [RouteGroup!]!, total: Int! } - type PopularRoute { favoriteCount: Int!, route: RouteSummary! } - type AreaOption { code: String!, name: String!, routeCount: Int! } - type AreaOptions { counties: [AreaOption!]!, municipalities: [AreaOption!]! } - type OsmPage { geojson: String!, truncated: Boolean! } - type Favorite { id: ID!, name: String!, number: String!, createdAt: String! } - type Municipality { code: String!, name: String! } - type UserProfile { username: String!, email: String, phone: String, avatar: String, homeMunicipality: Municipality, favorites: [Favorite!]! } - type AuthPayload { token: String!, user: UserProfile! } - type Query { - osmNearby(west: Float!, south: Float!, east: Float!, north: Float!): OsmPage! - routes(search: String = "", minLength: Float, maxLength: Float, difficulty: String, routeType: String, county: String, municipality: String, west: Float, south: Float, east: Float, north: Float, offset: Int = 0, limit: Int = 12): RoutePage! - areas(county: String): AreaOptions! - route(name: String!, number: String!): Route - routeVariants(name: String!): [RouteSummary!]! - # De mest populære turene i hele landet, målt i antall brukere som har lagret dem som favoritt - popularRoutes(limit: Int = 6): [PopularRoute!]! - # Kommuner med navn som inneholder søketeksten, brukt i søkefeltet på forsiden - searchMunicipalities(query: String!, limit: Int = 4): [AreaOption!]! - exampleRoute: Route - me(token: String!): UserProfile - } - type Mutation { - register(username: String!, password: String!): AuthPayload! - login(username: String!, password: String!): AuthPayload! - logout(token: String!): Boolean! - toggleFavorite(token: String!, routeId: ID!): UserProfile! - updateProfile(token: String!, email: String, phone: String, avatar: String): UserProfile! - # Setter kommunen brukeren vil se turforslag fra på forsiden. code = null fjerner valget. - setHomeMunicipality(token: String!, code: String): UserProfile! - } - `, mountainTypeDefs], - resolvers: [{ - Query: { - osmNearby: async (_, { west, south, east, north }) => { - if (![west, south, east, north].every(Number.isFinite) || west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north || east - west > 0.5 || north - south > 0.5) throw new GraphQLError('Velg et mindre kartområde for OSM-data.') - const { rows } = await pool.query(` - SELECT osm_type,osm_id::text,name,category, - coalesce(tags->>'amenity',tags->>'tourism',tags->>'natural',tags->>'highway') AS kind, - ST_AsGeoJSON(CASE WHEN category='place' THEN ST_PointOnSurface(geom) - ELSE ST_SimplifyPreserveTopology(geom,0.00001) END)::json AS geometry - FROM osm_import.objects - WHERE category IN ('trail','place') - AND geom && ST_MakeEnvelope($1,$2,$3,$4,4326) - AND ST_Intersects(geom,ST_MakeEnvelope($1,$2,$3,$4,4326)) - AND coalesce(tags->>'access','') NOT IN ('no','private') - AND coalesce(tags->>'foot','') NOT IN ('no','private') - AND (category='place' OR GeometryType(geom) IN ('LINESTRING','MULTILINESTRING')) - ORDER BY category,osm_type,osm_id LIMIT 1001`, [west, south, east, north]) - return { truncated: rows.length > 1000, geojson: JSON.stringify({ type: 'FeatureCollection', features: rows.slice(0, 1000).map(r => ({ type: 'Feature', geometry: r.geometry, properties: { id: r.osm_type + '/' + r.osm_id, name: r.name, category: r.category, kind: r.kind } })) }) } - }, - areas: async (_, { county }) => { - await routeCatalogReady - const [countyResult, municipalityResult] = await Promise.all([ - pool.query(`SELECT county_number AS code,min(county_name) AS name,count(DISTINCT route_id)::int AS "routeCount" - FROM norwegian_explorer_app.route_areas GROUP BY county_number ORDER BY name`), - pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name,count(DISTINCT route_id)::int AS "routeCount" - FROM norwegian_explorer_app.route_areas WHERE ($1::text IS NULL OR county_number=$1) - GROUP BY municipality_number ORDER BY name`, [county || null]), - ]) - return { counties: countyResult.rows, municipalities: municipalityResult.rows } - }, - routes: async (_, { search, minLength, maxLength, difficulty, routeType, county, municipality, west, south, east, north, offset, limit }) => { - await routeCatalogReady - const bounds = [west, south, east, north] - const hasBounds = bounds.every(Number.isFinite) - if (search == null || search.length > 120 || !Number.isInteger(offset) || offset < 0 || offset > 100000 || !Number.isInteger(limit) || limit < 1 || limit > 30 || - (minLength != null && (!Number.isFinite(minLength) || minLength < 0)) || (maxLength != null && (!Number.isFinite(maxLength) || maxLength < 0)) || - (minLength != null && maxLength != null && minLength > maxLength) || - (bounds.some(value => value != null) && !hasBounds) || (hasBounds && (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north))) { - throw new GraphQLError('Ugyldig søk eller sidestørrelse.') - } - const { rows } = await pool.query(` - WITH base AS ( - SELECT c.id,c.name,c.number,c.length_meters,c.difficulty,c.route_type,c.geom, - d.duplicate_count AS "duplicateCount" - FROM norwegian_explorer_app.route_catalog c - JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id AND d.canonical_id=c.id - WHERE c.name_quality <> 'suspect' - AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) - UNION ALL - SELECT l.id,l.name,l.number,l.length_meters,NULL,CASE WHEN l.number LIKE 'STREKNING_%' THEN 'Avledet strekning' ELSE 'Avledet rundtur' END,l.geom,1 - FROM norwegian_explorer_app.route_loops l - ), canonical AS ( - SELECT base.*,count(*) OVER (PARTITION BY lower(name))::int AS "variantCount" - FROM base - ), matches AS ( - SELECT id,name,number,length_meters AS "lengthMeters",difficulty,route_type AS "routeType", - "duplicateCount","variantCount" - FROM canonical - WHERE true - AND strpos(lower(name),lower($1)) > 0 - AND ($2::float8 IS NULL OR length_meters >= $2 * 1000) - AND ($3::float8 IS NULL OR length_meters <= $3 * 1000) - AND ($8::text IS NULL OR $8 = ANY(string_to_array(coalesce(difficulty,'Ikke gradert'), ', '))) - AND ($9::text IS NULL OR $9 = ANY(string_to_array(coalesce(route_type,'Ikke oppgitt'), ', '))) - AND ($10::text IS NULL OR EXISTS (SELECT 1 FROM norwegian_explorer_app.route_areas a WHERE a.route_id=canonical.id AND a.county_number=$10)) - AND ($11::text IS NULL OR EXISTS (SELECT 1 FROM norwegian_explorer_app.route_areas a WHERE a.route_id=canonical.id AND a.municipality_number=$11)) - AND ($4::float8 IS NULL OR ( - geom && ST_Transform(ST_MakeEnvelope($4,$5,$6,$7,4326),25833) - AND ST_Intersects(geom,ST_Transform(ST_MakeEnvelope($4,$5,$6,$7,4326),25833)))) - ), grouped AS ( - SELECT lower(name) AS key,min(name) AS name,count(*)::int AS "variantCount", - min("lengthMeters") AS "minLengthMeters",max("lengthMeters") AS "maxLengthMeters", - json_agg(json_build_object( - 'id',id,'name',name,'number',number,'lengthMeters',"lengthMeters", - 'difficulty',difficulty,'routeType',"routeType",'duplicateCount',"duplicateCount", - 'variantCount',"variantCount") ORDER BY "lengthMeters",number) AS variants - FROM matches GROUP BY lower(name) - ), page AS (SELECT * FROM grouped ORDER BY name LIMIT $12 OFFSET $13) - SELECT (SELECT count(*)::int FROM grouped) AS total, - coalesce((SELECT json_agg(page ORDER BY name) FROM page),'[]'::json) AS items - `, [search.trim(), minLength ?? null, maxLength ?? null, hasBounds ? west : null, hasBounds ? south : null, hasBounds ? east : null, hasBounds ? north : null, difficulty || null, routeType || null, county || null, municipality || null, limit, offset]) - return rows[0] - }, - popularRoutes: async (_, { limit }) => { - if (!Number.isInteger(limit) || limit < 1 || limit > 30) throw new GraphQLError('Ugyldig antall.') - await routeCatalogReady - // Én rad per rutenavn (den mest lagrede varianten). Har få turer favoritter ennå, fylles lista opp - // med graderte turer som har beskrivelse, deretter turer som er registrert flere ganger i Kartverkets data. - const { rows } = await pool.query(` - WITH favorite_counts AS ( - SELECT route_id, count(*)::int AS n FROM norwegian_explorer_app.favorites - WHERE route_id IS NOT NULL GROUP BY route_id - ), candidates AS ( - SELECT DISTINCT ON (lower(c.name)) - c.id, c.name, c.number, c.length_meters, c.difficulty, c.route_type, c.description, - d.duplicate_count, coalesce(f.n, 0) AS favorite_count - FROM norwegian_explorer_app.route_catalog c - JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id AND d.canonical_id=c.id - LEFT JOIN favorite_counts f ON f.route_id=c.id - WHERE c.name_quality <> 'suspect' AND c.length_meters >= 1000 - AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) - ORDER BY lower(c.name), coalesce(f.n, 0) DESC, d.duplicate_count DESC, c.length_meters - ) - SELECT favorite_count AS "favoriteCount", json_build_object( - 'id', id, 'name', name, 'number', number, 'lengthMeters', length_meters, - 'difficulty', difficulty, 'routeType', route_type, 'duplicateCount', duplicate_count, 'variantCount', 1 - ) AS route - FROM candidates - ORDER BY favorite_count DESC, (difficulty IS NOT NULL AND description IS NOT NULL) DESC, - duplicate_count DESC, name - LIMIT $1`, [limit]) - return rows - }, - searchMunicipalities: async (_, { query, limit }) => { - const search = query.trim() - if (!search || search.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 20) throw new GraphQLError('Ugyldig søk.') - await routeCatalogReady - // Navn som starter med søketeksten kommer først, deretter kommunene med flest turer - const { rows } = await pool.query(` - SELECT municipality_number AS code, min(municipality_name) AS name, count(DISTINCT route_id)::int AS "routeCount" - FROM norwegian_explorer_app.route_areas - WHERE strpos(lower(municipality_name), lower($1)) > 0 - GROUP BY municipality_number - ORDER BY bool_or(lower(municipality_name) LIKE lower($1) || '%') DESC, "routeCount" DESC, name - LIMIT $2`, [search, limit]) - return rows - }, - exampleRoute: () => loadRoute('Romsdalseggen', 'F_20140827_2'), - route: (_, { name, number }) => { - if (!name.trim() || name.length > 500 || !number.trim() || number.length > 500) throw new GraphQLError('Ugyldig rute.') - return loadRoute(name, number) - }, - routeVariants: async (_, { name }) => { - if (!name.trim() || name.length > 500) throw new GraphQLError('Ugyldig rutenavn.') - await routeCatalogReady - const { rows } = await pool.query(` - WITH variants AS ( - SELECT c.id,c.name,c.number,c.length_meters,c.difficulty,c.route_type, - d.duplicate_count AS "duplicateCount" - FROM norwegian_explorer_app.route_catalog c - JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id AND d.canonical_id=c.id - WHERE lower(c.name)=lower($1) AND c.name_quality<>'suspect' - AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) - UNION ALL - SELECT l.id,l.name,l.number,l.length_meters,NULL,CASE WHEN l.number LIKE 'STREKNING_%' THEN 'Avledet strekning' ELSE 'Avledet rundtur' END,1 - FROM norwegian_explorer_app.route_loops l WHERE lower(l.name)=lower($1) - ) - SELECT id,name,number,length_meters AS "lengthMeters",difficulty, - route_type AS "routeType","duplicateCount",count(*) OVER ()::int AS "variantCount" - FROM variants ORDER BY CASE WHEN number='SAMLET_RUNDE' THEN 0 ELSE 1 END,length_meters,number`, [name.trim()]) - return rows - }, - me: async (_, { token }) => { - const user = await userFromToken(token) - return user ? profileFor(user) : null - }, - }, Mutation: { - register: async (_, { username, password }) => { - await appSchemaReady - const cleanUsername = validateCredentials(username, password) - const salt = randomBytes(16) - const hash = await passwordHash(password, salt) - const client = await pool.connect() - try { - await client.query('BEGIN') - const { rows } = await client.query(`INSERT INTO norwegian_explorer_app.users(username,password_hash,password_salt) - VALUES ($1,$2,$3) RETURNING id,username`, [cleanUsername, hash, salt]) - const token = await createSession(client, rows[0].id) - await client.query('COMMIT') - return { token, user: { username: rows[0].username, homeMunicipality: null, favorites: [] } } - } catch (error) { - await client.query('ROLLBACK') - if (error?.code === '23505') throw new GraphQLError('Brukernavnet er allerede i bruk.') - throw error - } finally { client.release() } - }, - login: async (_, { username, password }) => { - await appSchemaReady - const { rows } = await pool.query(`SELECT id,username,email,phone,avatar,${await homeColumn()},password_hash,password_salt FROM norwegian_explorer_app.users WHERE lower(username)=lower($1)`, [username.trim()]) - const user = rows[0] - const candidate = user ? await passwordHash(password, user.password_salt) : Buffer.alloc(64) - if (!user || candidate.length !== user.password_hash.length || !timingSafeEqual(candidate, user.password_hash)) throw new GraphQLError('Feil brukernavn eller passord.') - const token = await createSession(pool, user.id) - return { token, user: await profileFor(user) } - }, - logout: async (_, { token }) => { - await appSchemaReady - await pool.query('DELETE FROM norwegian_explorer_app.sessions WHERE token_hash=$1', [tokenHash(token)]) - return true - }, - toggleFavorite: async (_, { token, routeId }) => { - if (!/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') - const user = await requireUser(token) - await routeCatalogReady - const routeResult = await pool.query(`SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 - UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, [routeId]) - if (!routeResult.rows[0]) throw new GraphQLError('Ruten finnes ikke.') - const removed = await pool.query('DELETE FROM norwegian_explorer_app.favorites WHERE user_id=$1 AND route_id=$2 RETURNING 1', [user.id, routeId]) - if (removed.rowCount === 0) await pool.query('INSERT INTO norwegian_explorer_app.favorites(user_id,route_id,route_name,route_number) VALUES ($1,$2,$3,$4)', [user.id, routeId, routeResult.rows[0].name, routeResult.rows[0].number]) - return profileFor(user) - }, - updateProfile: async (_, { token, email, phone, avatar }) => { - const user = await requireUser(token) - const cleanEmail = email?.trim() || null - const cleanPhone = phone?.trim() || null - const cleanAvatar = avatar?.trim() || null - if (cleanEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) throw new GraphQLError('Skriv inn en gyldig e-postadresse.') - if (cleanPhone && !/^\+?[0-9 ()-]{6,20}$/.test(cleanPhone)) throw new GraphQLError('Skriv inn et gyldig telefonnummer.') - if (cleanAvatar && (!cleanAvatar.startsWith('data:image/') || cleanAvatar.length > 600000)) throw new GraphQLError('Profilbildet må være et bilde på under 450 KB.') - const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users - SET email=$2,phone=$3,avatar=$4 WHERE id=$1 RETURNING id,username,email,phone,avatar,${await homeColumn()}`, - [user.id, cleanEmail, cleanPhone, cleanAvatar]) - return profileFor(rows[0]) - }, - setHomeMunicipality: async (_, { token, code }) => { - const user = await requireUser(token) - const cleanCode = code?.trim() || null - // Godta bare kommuner som faktisk finnes i rutedataene - if (cleanCode && !(await municipalityByCode(cleanCode))) throw new GraphQLError('Ukjent kommune.') - if (!(await hasHomeMunicipality)) throw new GraphQLError('Stedsvalg er ikke slått på i databasen ennå.') - const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users SET home_municipality=$2 - WHERE id=$1 RETURNING id,username,email,phone,avatar,home_municipality`, [user.id, cleanCode]) - return profileFor(rows[0]) - }, - } - }, mountainResolvers], -}) -async function loadRoute(name, number) { - await routeCatalogReady - const { rows } = await pool.query(` - SELECT id,name,number,segment_count AS "segmentCount",length_meters AS "lengthMeters", - connected,maintainer,difficulty,route_type AS "routeType",description, - name_quality AS "nameQuality",d.duplicate_count AS "duplicateCount", - (SELECT count(*)::int FROM norwegian_explorer_app.route_catalog c2 - JOIN norwegian_explorer_app.route_duplicate_map d2 ON d2.route_id=c2.id AND d2.canonical_id=c2.id - WHERE lower(c2.name)=lower(c.name)) AS "variantCount", - ST_AsGeoJSON(ST_Transform(geom,4326)) AS geojson - FROM norwegian_explorer_app.route_catalog c - JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id - WHERE name=$1 AND number=$2 - AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) - UNION ALL - SELECT l.id,l.name,l.number,l.source_variant_count AS "segmentCount",l.length_meters AS "lengthMeters", - true AS connected,'Avledet fra registrerte rutevarianter' AS maintainer,NULL AS difficulty, - CASE WHEN l.number LIKE 'STREKNING_%' THEN 'Avledet strekning' ELSE 'Avledet rundtur' END AS "routeType", - 'Dette rutevalget er beregnet fra registrerte stier. Ingen forbindelser over datagap er lagt til. Rutevalget er ikke et offisielt turforslag.' AS description, - 'derived' AS "nameQuality",1 AS "duplicateCount", - (SELECT count(*)::int + 1 FROM norwegian_explorer_app.route_catalog c2 - JOIN norwegian_explorer_app.route_duplicate_map d2 ON d2.route_id=c2.id AND d2.canonical_id=c2.id - WHERE lower(c2.name)=lower(l.name)) AS "variantCount", - ST_AsGeoJSON(ST_Transform(l.geom,4326)) AS geojson - FROM norwegian_explorer_app.route_loops l WHERE l.name=$1 AND l.number=$2`, [name.trim(), number.trim()]) - return rows[0] || null -} -const yoga = createYoga({ schema, cors: false, graphiql: process.env.NODE_ENV !== 'production' }) -const server = createServer(yoga) -const port = Number(process.env.PORT || 4000) -server.listen(port, '127.0.0.1', () => console.log(`GraphQL: http://127.0.0.1:${port}/graphql`)) -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, () => server.close(async () => { await pool.end(); process.exit(0) })) -} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..655cd44 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,623 @@ +import { createServer } from 'node:http' +import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto' +import { promisify } from 'node:util' +import { GraphQLError } from 'graphql' +import { createSchema, createYoga } from 'graphql-yoga' +import type {Pool,PoolClient} from 'pg' +import type {BuilderPoint} from './customRoutes.ts' +import { pool } from './db.ts' +import { mountainResolvers, mountainTypeDefs } from './server.ts' +import { builderNetwork, planCustomRoute, suggestRoundTrips } from './customRoutes.ts' +import { elevationProfile } from './elevation.ts' +import { routeStops, nearbyFacilityRouteIds } from './routeStops.ts' +import {checkAuthAttempt} from './authLimits.ts' +import {requestSafety} from './requestSafety.ts' + +const schemaName = process.env.DATA_SCHEMA || 'turogfriluftsruter_1b2541ab00554564ae88dabc8bb90c02' +if (!/^[a-z_][a-z0-9_]*$/.test(schemaName)) throw new Error('Invalid DATA_SCHEMA') +type AppUser={id:string;username:string;email:string|null;phone:string|null;avatar:string|null;home_municipality:string|null} +type AuthUser=AppUser & {password_hash:Buffer;password_salt:Buffer} +type ApiArgs={query:string;code:string|null;token:string;username:string;password:string;name:string;number:string;geojson:string;search:string;county:string|null;municipality:string|null;facility:string|null;difficulty:string|null;routeType:string|null;west:number;south:number;east:number;north:number;offset:number;limit:number;minLength:number|null;maxLength:number|null;routeId:string|null;id:string|null;points:BuilderPoint[];start:BuilderPoint;desiredLengthMeters:number;description:string;stopIds:string[];roundTrip:{start:BuilderPoint;desiredLengthMeters:number;proposalId:string}|null;currentPassword:string;newPassword:string;month:string;target:number|null;completedOn:string|null;diaryNote:string|null;rating:number|null;status:'PLANNED'|'COMPLETED'|null;email:string|null;phone:string|null;avatar:string|null} +const scrypt = promisify(scryptCallback) as (password:string,salt:Buffer,length:number)=>Promise +const appSchemaSetup = pool.query(` + CREATE SCHEMA IF NOT EXISTS norwegian_explorer_app; + CREATE TABLE IF NOT EXISTS norwegian_explorer_app.users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + username text NOT NULL UNIQUE, + password_hash bytea NOT NULL, + password_salt bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS norwegian_explorer_app.sessions ( + token_hash bytea PRIMARY KEY, + user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, + expires_at timestamptz NOT NULL + ); + ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS email text; + ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS phone text; + ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS avatar text; + ALTER TABLE norwegian_explorer_app.users ADD COLUMN IF NOT EXISTS home_municipality text; + CREATE TABLE IF NOT EXISTS norwegian_explorer_app.favorites ( + user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, + route_name text NOT NULL, + route_number text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, route_name, route_number) + ); + ALTER TABLE norwegian_explorer_app.favorites ADD COLUMN IF NOT EXISTS route_id text; + CREATE UNIQUE INDEX IF NOT EXISTS favorites_user_route_id_idx + ON norwegian_explorer_app.favorites (user_id, route_id) WHERE route_id IS NOT NULL; + CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_idx ON norwegian_explorer_app.users (lower(username)); + CREATE TABLE IF NOT EXISTS norwegian_explorer_app.user_routes ( + user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, + route_id text NOT NULL, + route_name text NOT NULL, + route_number text NOT NULL, + status text NOT NULL CHECK (status IN ('PLANNED','COMPLETED')), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id,route_id) + ); + ALTER TABLE norwegian_explorer_app.user_routes ADD COLUMN IF NOT EXISTS completed_on date; + ALTER TABLE norwegian_explorer_app.user_routes ADD COLUMN IF NOT EXISTS diary_note text; + ALTER TABLE norwegian_explorer_app.user_routes ADD COLUMN IF NOT EXISTS rating integer CHECK (rating BETWEEN 1 AND 5); + CREATE TABLE IF NOT EXISTS norwegian_explorer_app.user_goals ( + user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, + month text NOT NULL CHECK (month ~ '^[0-9]{4}-(0[1-9]|1[0-2])$'), + target integer NOT NULL CHECK (target BETWEEN 1 AND 1000), + PRIMARY KEY (user_id,month) + ); + CREATE TABLE IF NOT EXISTS norwegian_explorer_app.custom_routes ( + id text PRIMARY KEY, + user_id bigint NOT NULL REFERENCES norwegian_explorer_app.users(id) ON DELETE CASCADE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + waypoints jsonb NOT NULL, + geojson text NOT NULL, + length_meters double precision NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() + ); + ALTER TABLE norwegian_explorer_app.custom_routes ADD COLUMN IF NOT EXISTS source_route_id text; + ALTER TABLE norwegian_explorer_app.custom_routes ADD COLUMN IF NOT EXISTS source_name text NOT NULL DEFAULT 'Bymarka'; + ALTER TABLE norwegian_explorer_app.custom_routes ADD COLUMN IF NOT EXISTS smart_plan jsonb; + ALTER TABLE norwegian_explorer_app.custom_routes ADD COLUMN IF NOT EXISTS stops jsonb NOT NULL DEFAULT '[]'::jsonb; +`) +const appSchemaReady = appSchemaSetup.catch(async error => { + console.error('Kunne ikke klargjøre appens tabeller:', error.message) + const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.users') AS users") + if (!rows[0].users) throw error + const required=['sessions','favorites','user_routes','user_goals','custom_routes'] + const result=await pool.query<{ready:boolean}>("SELECT bool_and(to_regclass('norwegian_explorer_app.' || name) IS NOT NULL) AS ready FROM unnest($1::text[]) name",[required]) + if(!result.rows[0].ready)throw error + console.error('Tabellene finnes allerede, så backend fortsetter med dem som de er.') +}) +// Kolonnen for «Mitt sted» kan mangle hvis den ikke er lagt til av eieren av tabellen ennå +// (se server/sql/app/home_municipality.sql). Da virker alt annet, men stedsvalget er slått av. +const hasHomeMunicipality = appSchemaReady.then(async () => { + const { rowCount } = await pool.query(`SELECT 1 FROM information_schema.columns + WHERE table_schema='norwegian_explorer_app' AND table_name='users' AND column_name='home_municipality'`) + if (!rowCount) console.error('Kolonnen users.home_municipality mangler. Kjør server/sql/app/home_municipality.sql for å slå på stedsvalg.') + return (rowCount||0)>0 +}) +/** Kolonneuttrykket for brukerens sted, eller NULL hvis kolonnen ikke finnes i databasen */ +async function homeColumn(prefix:string = '') { + return (await hasHomeMunicipality) ? `${prefix}home_municipality` : 'NULL AS home_municipality' +} +const routeCatalogReady = appSchemaReady.then(async () => { + const { rows } = await pool.query("SELECT to_regclass('norwegian_explorer_app.route_catalog') AS catalog") + if (!rows[0].catalog) throw new Error('Route catalog is missing. Run scripts/create-route-catalog.sql first.') +}) +// Får ikke databasebrukeren lage tabellene over, skal bare innlogging og ruter feile, ikke hele serveren +appSchemaReady.catch(error => console.error('Kunne ikke klargjøre appens tabeller:', error.message)) +routeCatalogReady.catch(error => console.error(error.message)) +hasHomeMunicipality.catch(()=>undefined) + +function validateCredentials(username:string, password:string) { + const normalized = username.trim() + if (!/^[\p{L}\p{N}_.-]{3,30}$/u.test(normalized)) throw new GraphQLError('Brukernavnet må være 3–30 tegn og kan inneholde bokstaver, tall, punktum, bindestrek og understrek.') + if (password.length < 10 || password.length > 200) throw new GraphQLError('Passordet må være minst 10 tegn.') + return normalized +} +async function passwordHash(password:string, salt:Buffer) { return Buffer.from(await scrypt(password, salt, 64)) } +function tokenHash(token:string) { return createHash('sha256').update(token).digest() } +async function createSession(client:Pool|PoolClient, userId:string) { + const token = randomBytes(32).toString('base64url') + await client.query("INSERT INTO norwegian_explorer_app.sessions(token_hash,user_id,expires_at) VALUES ($1,$2,now()+(30 * interval '1 day'))", [tokenHash(token), userId]) + return token +} +async function userFromToken(token:string) { + if (!token || token.length > 200) return null + await appSchemaReady + const { rows } = await pool.query(`SELECT u.id,u.username,u.email,u.phone,u.avatar,${await homeColumn('u.')} FROM norwegian_explorer_app.sessions s + JOIN norwegian_explorer_app.users u ON u.id=s.user_id + WHERE s.token_hash=$1 AND s.expires_at>now()`, [tokenHash(token)]) + return rows[0] || null +} +async function profileFor(user:AppUser) { + await routeCatalogReady + const { rows } = await pool.query(`SELECT coalesce(c.id,f.route_id) AS id, + coalesce(c.name,f.route_name) AS name,coalesce(c.number,f.route_number) AS number, + f.created_at::text AS "createdAt" + FROM norwegian_explorer_app.favorites f + LEFT JOIN norwegian_explorer_app.route_catalog c ON c.id=f.route_id + WHERE f.user_id=$1 ORDER BY f.created_at DESC`, [user.id]) + const { rows: trips } = await pool.query(` + WITH routes AS ( + SELECT id,length_meters FROM norwegian_explorer_app.route_catalog + UNION ALL SELECT id,length_meters FROM norwegian_explorer_app.route_loops + ) + SELECT t.route_id AS id,t.route_name AS name,t.route_number AS number,t.status, + t.updated_at::text AS "updatedAt",r.length_meters AS "lengthMeters", + t.completed_on::text AS "completedOn",t.diary_note AS "diaryNote",t.rating + FROM norwegian_explorer_app.user_routes t LEFT JOIN routes r ON r.id=t.route_id + WHERE t.user_id=$1 ORDER BY t.updated_at DESC`, [user.id]) + const { rows: counties } = await pool.query(`SELECT count(DISTINCT a.county_number)::int AS count + FROM norwegian_explorer_app.user_routes t JOIN norwegian_explorer_app.route_areas a ON a.route_id=t.route_id + WHERE t.user_id=$1 AND t.status='COMPLETED'`, [user.id]) + const completed = trips.filter(trip => trip.status === 'COMPLETED') + const { rows: goals } = await pool.query('SELECT month,target FROM norwegian_explorer_app.user_goals WHERE user_id=$1 ORDER BY month DESC', [user.id]) + return { username: user.username, email: user.email, phone: user.phone, avatar: user.avatar, homeMunicipality:await municipalityByCode(user.home_municipality), favorites: rows, + trips, goals, stats: { completedCount: completed.length, plannedCount: trips.length-completed.length, + totalLengthMeters: completed.reduce((sum,trip) => sum+(trip.lengthMeters || 0),0), countyCount: counties[0].count } } +} +async function municipalityByCode(code:string|null) { + if (!code) return null + await routeCatalogReady + const { rows } = await pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name + FROM norwegian_explorer_app.route_areas WHERE municipality_number=$1 GROUP BY municipality_number`, [code]) + return rows[0] || null +} +async function requireUser(token:string) { + const user = await userFromToken(token) + if (!user) throw new GraphQLError('Du må logge inn.') + return user +} +const schema = createSchema({ + // Fjellområdene og turpunktene ligger i server/server.ts og legges til her + typeDefs: [` + type Route { + id: ID! + name: String! + number: String! + lengthMeters: Float! + segmentCount: Int! + connected: Boolean! + maintainer: String + difficulty: String + description: String + routeType: String + nameQuality: String! + duplicateCount: Int! + variantCount: Int! + geojson: String! + } + type RouteSummary { id: ID!, name: String!, number: String!, lengthMeters: Float!, difficulty: String, routeType: String, duplicateCount: Int!, variantCount: Int! } + type RouteGroup { key: String!, name: String!, variantCount: Int!, minLengthMeters: Float!, maxLengthMeters: Float!, variants: [RouteSummary!]! } + type RoutePage { items: [RouteGroup!]!, total: Int! } + type PopularRoute { favoriteCount: Int!, route: RouteSummary! } + type Municipality { code: String!, name: String! } + type AreaOption { code: String!, name: String!, routeCount: Int! } + type AreaOptions { counties: [AreaOption!]!, municipalities: [AreaOption!]! } + type OsmPage { geojson: String!, truncated: Boolean! } + type Favorite { id: ID!, name: String!, number: String!, createdAt: String! } + enum TripStatus { PLANNED COMPLETED } + type UserTrip { id: ID!, name: String!, number: String!, status: TripStatus!, updatedAt: String!, lengthMeters: Float, completedOn: String, diaryNote: String, rating: Int } + type TripStats { completedCount: Int!, plannedCount: Int!, totalLengthMeters: Float!, countyCount: Int! } + type MonthlyGoal { month: String!, target: Int! } + input BuilderPointInput { lon: Float!, lat: Float! } + type BuilderPoint { lon: Float!, lat: Float! } + type BuilderPreview { geojson: String!, lengthMeters: Float!, waypoints: [BuilderPoint!]! } + input RoundTripInput { start: BuilderPointInput!, desiredLengthMeters: Float!, proposalId: ID! } + type RoundTripProposal { id: ID!, geojson: String!, lengthMeters: Float!, connectorMeters: Float!, differenceMeters: Float!, waypoints: [BuilderPoint!]! } + type ElevationPoint { distanceMeters: Float!, elevation: Float } + type ElevationProfile { points: [ElevationPoint!]!, lengthMeters: Float!, sampleIntervalMeters: Float!, ascentMeters: Float, descentMeters: Float, minElevation: Float, maxElevation: Float, complete: Boolean!, missingCount: Int!, source: String! } + type RouteStop { id: ID!, name: String, kind: String!, lon: Float!, lat: Float!, distanceFromRouteMeters: Float!, distanceFromStartMeters: Float!, operator: String, openingHours: String, capacity: String, fee: String, website: String } + type RouteStopPage { stops: [RouteStop!]!, truncated: Boolean! } + type CustomRoute { id: ID!, name: String!, description: String!, geojson: String!, lengthMeters: Float!, waypoints: [BuilderPoint!]!, updatedAt: String!, sourceRouteId: ID, sourceName: String!, smartPlan: String, stops: [RouteStop!]! } + type UserProfile { username: String!, email: String, phone: String, avatar: String, homeMunicipality: Municipality, favorites: [Favorite!]!, trips: [UserTrip!]!, goals: [MonthlyGoal!]!, stats: TripStats! } + type AuthPayload { token: String!, user: UserProfile! } + type Query { + osmNearby(west: Float!, south: Float!, east: Float!, north: Float!): OsmPage! + routes(search: String = "", minLength: Float, maxLength: Float, difficulty: String, routeType: String, county: String, municipality: String, facility: String, west: Float, south: Float, east: Float, north: Float, offset: Int = 0, limit: Int = 12): RoutePage! + areas(county: String): AreaOptions! + route(name: String!, number: String!): Route + routeVariants(name: String!): [RouteSummary!]! + popularRoutes(limit: Int = 6): [PopularRoute!]! + searchMunicipalities(query: String!, limit: Int = 4): [AreaOption!]! + exampleRoute: Route + me(token: String!): UserProfile + builderNetwork(token: String!, routeId: ID): String! + previewCustomRoute(token: String!, points: [BuilderPointInput!]!, routeId: ID): BuilderPreview! + myCustomRoutes(token: String!): [CustomRoute!]! + suggestRoundTrips(token: String!, start: BuilderPointInput!, desiredLengthMeters: Float!, routeId: ID): [RoundTripProposal!]! + elevationProfile(token: String!, geojson: String!): ElevationProfile! + routeStops(token: String!, geojson: String!): RouteStopPage! + } + type Mutation { + register(username: String!, password: String!): AuthPayload! + login(username: String!, password: String!): AuthPayload! + logout(token: String!): Boolean! + toggleFavorite(token: String!, routeId: ID!): UserProfile! + setHomeMunicipality(token: String!, code: String): UserProfile! + updateProfile(token: String!, email: String, phone: String, avatar: String): UserProfile! + setTripStatus(token: String!, routeId: ID!, status: TripStatus): UserProfile! + saveTripDiary(token: String!, routeId: ID!, completedOn: String, diaryNote: String, rating: Int): UserProfile! + setMonthlyGoal(token: String!, month: String!, target: Int): UserProfile! + changePassword(token: String!, currentPassword: String!, newPassword: String!): Boolean! + saveCustomRoute(token: String!, id: ID, name: String!, description: String!, points: [BuilderPointInput!]!, routeId: ID, roundTrip: RoundTripInput, stopIds: [ID!] = []): CustomRoute! + deleteCustomRoute(token: String!, id: ID!): Boolean! + } + `, mountainTypeDefs], + resolvers: [{ Query: { + routeStops: async (_:unknown, {token,geojson}:ApiArgs) => {await requireUser(token);return routeStops(geojson)}, + elevationProfile: async (_:unknown, {token,geojson}:ApiArgs) => {await requireUser(token);return elevationProfile(geojson)}, + suggestRoundTrips: async (_:unknown, {token,start,desiredLengthMeters,routeId}:ApiArgs) => {await requireUser(token);return suggestRoundTrips(start,desiredLengthMeters,routeId)}, + builderNetwork: async (_:unknown, { token, routeId }:ApiArgs) => { await requireUser(token); return (await builderNetwork(routeId)).geojson }, + previewCustomRoute: async (_:unknown, { token, points, routeId }:ApiArgs) => { await requireUser(token); return planCustomRoute(points,routeId) }, + myCustomRoutes: async (_:unknown, { token }:ApiArgs) => { + const user=await requireUser(token) + const { rows }=await pool.query('SELECT id,name,description,geojson,length_meters AS "lengthMeters",waypoints,updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops FROM norwegian_explorer_app.custom_routes WHERE user_id=$1 ORDER BY updated_at DESC',[user.id]) + return rows + }, + osmNearby: async (_:unknown, { west, south, east, north }:ApiArgs) => { + if (![west,south,east,north].every(Number.isFinite) || west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north || east-west > 0.5 || north-south > 0.5) throw new GraphQLError('Velg et mindre kartområde for OSM-data.') + const { rows } = await pool.query(` + SELECT osm_type,osm_id::text,name,category, + coalesce(tags->>'amenity',tags->>'tourism',tags->>'natural',tags->>'highway') AS kind, + ST_AsGeoJSON(CASE WHEN category='place' THEN ST_PointOnSurface(geom) + ELSE ST_SimplifyPreserveTopology(geom,0.00001) END)::json AS geometry + FROM osm_import.objects + WHERE category IN ('trail','place') + AND geom && ST_MakeEnvelope($1,$2,$3,$4,4326) + AND ST_Intersects(geom,ST_MakeEnvelope($1,$2,$3,$4,4326)) + AND coalesce(tags->>'access','') NOT IN ('no','private') + AND coalesce(tags->>'foot','') NOT IN ('no','private') + AND (category='place' OR GeometryType(geom) IN ('LINESTRING','MULTILINESTRING')) + ORDER BY category,osm_type,osm_id LIMIT 1001`, [west,south,east,north]) + return { truncated: rows.length>1000, geojson: JSON.stringify({ type:'FeatureCollection', features: rows.slice(0,1000).map(r => ({ type:'Feature', geometry:r.geometry, properties:{ id:r.osm_type+'/'+r.osm_id, name:r.name, category:r.category, kind:r.kind } })) }) } + }, + areas: async (_:unknown, { county }:ApiArgs) => { + await routeCatalogReady + const [countyResult, municipalityResult] = await Promise.all([ + pool.query(`SELECT county_number AS code,min(county_name) AS name,count(DISTINCT route_id)::int AS "routeCount" + FROM norwegian_explorer_app.route_areas GROUP BY county_number ORDER BY name`), + pool.query(`SELECT municipality_number AS code,min(municipality_name) AS name,count(DISTINCT route_id)::int AS "routeCount" + FROM norwegian_explorer_app.route_areas WHERE ($1::text IS NULL OR county_number=$1) + GROUP BY municipality_number ORDER BY name`, [county || null]), + ]) + return { counties: countyResult.rows, municipalities: municipalityResult.rows } + }, + routes: async (_:unknown, { search, minLength, maxLength, difficulty, routeType, county, municipality, facility, west, south, east, north, offset, limit }:ApiArgs) => { + await routeCatalogReady + const bounds = [west,south,east,north] + const hasBounds = bounds.every(Number.isFinite) + if (search == null || search.length > 120 || !Number.isInteger(offset) || offset < 0 || offset > 100000 || !Number.isInteger(limit) || limit < 1 || limit > 30 || + (minLength != null && (!Number.isFinite(minLength) || minLength < 0)) || (maxLength != null && (!Number.isFinite(maxLength) || maxLength < 0)) || + (minLength != null && maxLength != null && minLength > maxLength) || + (bounds.some(value => value != null) && !hasBounds) || (hasBounds && (west < -180 || east > 180 || south < -90 || north > 90 || west >= east || south >= north))) { + throw new GraphQLError('Ugyldig søk eller sidestørrelse.') + } + const facilityIds=facility?await nearbyFacilityRouteIds(facility):null + const { rows } = await pool.query(` + WITH base AS ( + SELECT c.id,c.name,c.number,c.length_meters,c.difficulty,c.route_type,c.geom, + d.duplicate_count AS "duplicateCount" + FROM norwegian_explorer_app.route_catalog c + JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id AND d.canonical_id=c.id + WHERE c.name_quality <> 'suspect' + AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) + UNION ALL + SELECT l.id,l.name,l.number,l.length_meters,NULL,CASE WHEN l.number LIKE 'STREKNING_%' THEN 'Avledet strekning' ELSE 'Avledet rundtur' END,l.geom,1 + FROM norwegian_explorer_app.route_loops l + ), canonical AS ( + SELECT base.*,count(*) OVER (PARTITION BY lower(name))::int AS "variantCount" + FROM base + ), matches AS ( + SELECT id,name,number,length_meters AS "lengthMeters",difficulty,route_type AS "routeType", + "duplicateCount","variantCount" + FROM canonical + WHERE true + AND strpos(lower(name),lower($1)) > 0 + AND ($14::text[] IS NULL OR id=ANY($14)) + AND ($2::float8 IS NULL OR length_meters >= $2 * 1000) + AND ($3::float8 IS NULL OR length_meters <= $3 * 1000) + AND ($8::text IS NULL OR $8 = ANY(string_to_array(coalesce(difficulty,'Ikke gradert'), ', '))) + AND ($9::text IS NULL OR $9 = ANY(string_to_array(coalesce(route_type,'Ikke oppgitt'), ', '))) + AND ($10::text IS NULL OR EXISTS (SELECT 1 FROM norwegian_explorer_app.route_areas a WHERE a.route_id=canonical.id AND a.county_number=$10)) + AND ($11::text IS NULL OR EXISTS (SELECT 1 FROM norwegian_explorer_app.route_areas a WHERE a.route_id=canonical.id AND a.municipality_number=$11)) + AND ($4::float8 IS NULL OR ( + geom && ST_Transform(ST_MakeEnvelope($4,$5,$6,$7,4326),25833) + AND ST_Intersects(geom,ST_Transform(ST_MakeEnvelope($4,$5,$6,$7,4326),25833)))) + ), grouped AS ( + SELECT lower(name) AS key,min(name) AS name,count(*)::int AS "variantCount", + min("lengthMeters") AS "minLengthMeters",max("lengthMeters") AS "maxLengthMeters", + json_agg(json_build_object( + 'id',id,'name',name,'number',number,'lengthMeters',"lengthMeters", + 'difficulty',difficulty,'routeType',"routeType",'duplicateCount',"duplicateCount", + 'variantCount',"variantCount") ORDER BY "lengthMeters",number) AS variants + FROM matches GROUP BY lower(name) + ), page AS (SELECT * FROM grouped ORDER BY name LIMIT $12 OFFSET $13) + SELECT (SELECT count(*)::int FROM grouped) AS total, + coalesce((SELECT json_agg(page ORDER BY name) FROM page),'[]'::json) AS items + `, [search.trim(), minLength ?? null, maxLength ?? null, hasBounds ? west : null, hasBounds ? south : null, hasBounds ? east : null, hasBounds ? north : null, difficulty || null, routeType || null, county || null, municipality || null, limit, offset,facilityIds]) + return rows[0] + }, + popularRoutes: async (_:unknown, { limit }:ApiArgs) => { + if (!Number.isInteger(limit) || limit < 1 || limit > 30) throw new GraphQLError('Ugyldig antall.') + await routeCatalogReady + // Én rad per rutenavn (den mest lagrede varianten). Har få turer favoritter ennå, fylles lista opp + // med graderte turer som har beskrivelse, deretter turer som er registrert flere ganger i Kartverkets data. + const { rows } = await pool.query(` + WITH favorite_counts AS ( + SELECT route_id, count(*)::int AS n FROM norwegian_explorer_app.favorites + WHERE route_id IS NOT NULL GROUP BY route_id + ), candidates AS ( + SELECT DISTINCT ON (lower(c.name)) + c.id, c.name, c.number, c.length_meters, c.difficulty, c.route_type, c.description, + d.duplicate_count, coalesce(f.n, 0) AS favorite_count + FROM norwegian_explorer_app.route_catalog c + JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id AND d.canonical_id=c.id + LEFT JOIN favorite_counts f ON f.route_id=c.id + WHERE c.name_quality <> 'suspect' AND c.length_meters >= 1000 + AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) + ORDER BY lower(c.name), coalesce(f.n, 0) DESC, d.duplicate_count DESC, c.length_meters + ) + SELECT favorite_count AS "favoriteCount", json_build_object( + 'id', id, 'name', name, 'number', number, 'lengthMeters', length_meters, + 'difficulty', difficulty, 'routeType', route_type, 'duplicateCount', duplicate_count, 'variantCount', 1 + ) AS route + FROM candidates + ORDER BY favorite_count DESC, (difficulty IS NOT NULL AND description IS NOT NULL) DESC, + duplicate_count DESC, name + LIMIT $1`, [limit]) + return rows + }, + searchMunicipalities: async (_:unknown, { query, limit }:ApiArgs) => { + const search = query.trim() + if (!search || search.length > 120 || !Number.isInteger(limit) || limit < 1 || limit > 20) throw new GraphQLError('Ugyldig søk.') + await routeCatalogReady + // Navn som starter med søketeksten kommer først, deretter kommunene med flest turer + const { rows } = await pool.query(` + SELECT municipality_number AS code, min(municipality_name) AS name, count(DISTINCT route_id)::int AS "routeCount" + FROM norwegian_explorer_app.route_areas + WHERE strpos(lower(municipality_name), lower($1)) > 0 + GROUP BY municipality_number + ORDER BY bool_or(lower(municipality_name) LIKE lower($1) || '%') DESC, "routeCount" DESC, name + LIMIT $2`, [search, limit]) + return rows + }, + exampleRoute: () => loadRoute('Romsdalseggen', 'F_20140827_2'), + route: (_, { name, number }) => { + if (!name.trim() || name.length > 500 || !number.trim() || number.length > 500) throw new GraphQLError('Ugyldig rute.') + return loadRoute(name, number) + }, + routeVariants: async (_:unknown, { name }:ApiArgs) => { + if (!name.trim() || name.length > 500) throw new GraphQLError('Ugyldig rutenavn.') + await routeCatalogReady + const { rows } = await pool.query(` + WITH variants AS ( + SELECT c.id,c.name,c.number,c.length_meters,c.difficulty,c.route_type, + d.duplicate_count AS "duplicateCount" + FROM norwegian_explorer_app.route_catalog c + JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id AND d.canonical_id=c.id + WHERE lower(c.name)=lower($1) AND c.name_quality<>'suspect' + AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) + UNION ALL + SELECT l.id,l.name,l.number,l.length_meters,NULL,CASE WHEN l.number LIKE 'STREKNING_%' THEN 'Avledet strekning' ELSE 'Avledet rundtur' END,1 + FROM norwegian_explorer_app.route_loops l WHERE lower(l.name)=lower($1) + ) + SELECT id,name,number,length_meters AS "lengthMeters",difficulty, + route_type AS "routeType","duplicateCount",count(*) OVER ()::int AS "variantCount" + FROM variants ORDER BY CASE WHEN number='SAMLET_RUNDE' THEN 0 ELSE 1 END,length_meters,number`, [name.trim()]) + return rows + }, + me: async (_:unknown, { token }:ApiArgs) => { + const user = await userFromToken(token) + return user ? profileFor(user) : null + }, + }, Mutation: { + register: async (_:unknown, { username, password }:ApiArgs) => { + await appSchemaReady + checkAuthAttempt(username) + const cleanUsername = validateCredentials(username, password) + const salt = randomBytes(16) + const hash = await passwordHash(password, salt) + const client = await pool.connect() + try { + await client.query('BEGIN') + const { rows } = await client.query(`INSERT INTO norwegian_explorer_app.users(username,password_hash,password_salt) + VALUES ($1,$2,$3) RETURNING id,username`, [cleanUsername, hash, salt]) + const token = await createSession(client, rows[0].id) + await client.query('COMMIT') + return { token, user: { username: rows[0].username, homeMunicipality:null, favorites: [], trips: [], goals: [], + stats: { completedCount: 0, plannedCount: 0, totalLengthMeters: 0, countyCount: 0 } } } + } catch (error) { + await client.query('ROLLBACK') + if (error && typeof error==='object' && 'code' in error && error.code === '23505') throw new GraphQLError('Brukernavnet er allerede i bruk.') + throw error + } finally { client.release() } + }, + login: async (_:unknown, { username, password }:ApiArgs) => { + checkAuthAttempt(username) + if(username.length>100 || password.length>200)throw new GraphQLError('Feil brukernavn eller passord.') + await appSchemaReady + const client=await pool.connect() + let user:AuthUser,token:string + try{ + await client.query('BEGIN') + const {rows}=await client.query(`SELECT id,username,email,phone,avatar,${await homeColumn()},password_hash,password_salt FROM norwegian_explorer_app.users WHERE lower(username)=lower($1) FOR UPDATE`,[username.trim()]) + user=rows[0] + const candidate=await passwordHash(password,user?.password_salt || Buffer.alloc(16)) + if(!user || candidate.length!==user.password_hash.length || !timingSafeEqual(candidate,user.password_hash))throw new GraphQLError('Feil brukernavn eller passord.') + token=await createSession(client,user.id) + await client.query('COMMIT') + }catch(error){await client.query('ROLLBACK');throw error}finally{client.release()} + return {token,user:await profileFor(user)} + }, + logout: async (_:unknown, { token }:ApiArgs) => { + await appSchemaReady + await pool.query('DELETE FROM norwegian_explorer_app.sessions WHERE token_hash=$1', [tokenHash(token)]) + return true + }, + toggleFavorite: async (_:unknown, { token, routeId }:ApiArgs) => { + if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + const user = await requireUser(token) + await routeCatalogReady + const routeResult = await pool.query(`SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 + UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, [routeId]) + if (!routeResult.rows[0]) throw new GraphQLError('Ruten finnes ikke.') + const client=await pool.connect() + try{ + await client.query('BEGIN') + await client.query('SELECT id FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE',[user.id]) + const removed=await client.query('DELETE FROM norwegian_explorer_app.favorites WHERE user_id=$1 AND route_id=$2 RETURNING 1',[user.id,routeId]) + if(!removed.rowCount)await client.query('INSERT INTO norwegian_explorer_app.favorites(user_id,route_id,route_name,route_number) VALUES($1,$2,$3,$4)',[user.id,routeId,routeResult.rows[0].name,routeResult.rows[0].number]) + await client.query('COMMIT') + }catch(error){await client.query('ROLLBACK');throw error}finally{client.release()} + return profileFor(user) + }, + saveCustomRoute: async (_:unknown, { token, id, name, description, points, routeId, roundTrip, stopIds }:ApiArgs) => { + const user=await requireUser(token) + if (!name.trim() || name.trim().length>100 || description.length>2000) throw new GraphQLError('Gi ruten et navn på maks 100 tegn og en beskrivelse på maks 2000 tegn.') + if (id && !/^[a-f0-9]{32}$/.test(id)) throw new GraphQLError('Ugyldig rute.') + const preview=roundTrip ? (await suggestRoundTrips(roundTrip.start,roundTrip.desiredLengthMeters,routeId)).find(item=>item.id===roundTrip.proposalId) : await planCustomRoute(points,routeId) + if(!preview)throw new GraphQLError('Rundturforslaget er ikke lenger tilgjengelig. Beregn nye forslag.') + const stops=(await routeStops(preview.geojson,stopIds || [])).stops + const customId=id || randomBytes(16).toString('hex') + const query=id ? `UPDATE norwegian_explorer_app.custom_routes SET name=$3,description=$4,waypoints=$5,geojson=$6,length_meters=$7,source_route_id=$8,source_name=$9,smart_plan=$10,stops=$11,updated_at=now() WHERE id=$1 AND user_id=$2 RETURNING id,name,description,waypoints,geojson,length_meters AS "lengthMeters",updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops` : + `INSERT INTO norwegian_explorer_app.custom_routes(id,user_id,name,description,waypoints,geojson,length_meters,source_route_id,source_name,smart_plan,stops) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id,name,description,waypoints,geojson,length_meters AS "lengthMeters",updated_at::text AS "updatedAt",source_route_id AS "sourceRouteId",source_name AS "sourceName",smart_plan::text AS "smartPlan",stops` + const { rows }=await pool.query(query,[customId,user.id,name.trim(),description.trim(),JSON.stringify(preview.waypoints),preview.geojson,preview.lengthMeters,preview.sourceRouteId,preview.sourceName,roundTrip ? JSON.stringify(roundTrip) : null,JSON.stringify(stops)]) + if (!rows[0]) throw new GraphQLError('Ruten finnes ikke på profilen din.') + return rows[0] + }, + deleteCustomRoute: async (_:unknown, { token, id }:ApiArgs) => { + const user=await requireUser(token) + const result=await pool.query('DELETE FROM norwegian_explorer_app.custom_routes WHERE id=$1 AND user_id=$2',[id,user.id]) + return (result.rowCount||0)>0 + }, + changePassword: async (_:unknown, { token, currentPassword, newPassword }:ApiArgs) => { + const user = await requireUser(token) + checkAuthAttempt(user.username) + if (currentPassword.length > 200) throw new GraphQLError('Gjeldende passord er feil.') + validateCredentials(user.username, newPassword) + if (currentPassword === newPassword) throw new GraphQLError('Velg et annet passord enn det du bruker nå.') + const client = await pool.connect() + try { + await client.query('BEGIN') + const { rows } = await client.query('SELECT password_hash,password_salt FROM norwegian_explorer_app.users WHERE id=$1 FOR UPDATE', [user.id]) + const stored = rows[0] + if (!stored) throw new GraphQLError('Du må logge inn på nytt.') + const candidate = await passwordHash(currentPassword, stored.password_salt) + if (!timingSafeEqual(candidate, stored.password_hash)) throw new GraphQLError('Gjeldende passord er feil.') + const salt = randomBytes(16) + const hash = await passwordHash(newPassword, salt) + await client.query('UPDATE norwegian_explorer_app.users SET password_hash=$2,password_salt=$3 WHERE id=$1', [user.id,hash,salt]) + await client.query('DELETE FROM norwegian_explorer_app.sessions WHERE user_id=$1', [user.id]) + await client.query('COMMIT') + return true + } catch (error) { + await client.query('ROLLBACK') + throw error + } finally { client.release() } + }, + setMonthlyGoal: async (_:unknown, { token, month, target }:ApiArgs) => { + const user = await requireUser(token) + if (!/^[0-9]{4}-(0[1-9]|1[0-2])$/.test(month)) throw new GraphQLError('Velg en gyldig måned.') + if (target == null) { + await pool.query('DELETE FROM norwegian_explorer_app.user_goals WHERE user_id=$1 AND month=$2', [user.id,month]) + } else { + if (!Number.isInteger(target) || target < 1 || target > 1000) throw new GraphQLError('Målet må være mellom 1 og 1000 turer.') + await pool.query(`INSERT INTO norwegian_explorer_app.user_goals(user_id,month,target) VALUES ($1,$2,$3) + ON CONFLICT (user_id,month) DO UPDATE SET target=excluded.target`, [user.id,month,target]) + } + return profileFor(user) + }, + saveTripDiary: async (_:unknown, { token, routeId, completedOn, diaryNote, rating }:ApiArgs) => { + const user = await requireUser(token) + if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + const date = completedOn || null + if (date && (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0,10) !== date)) throw new GraphQLError('Skriv inn en gyldig dato.') + if (diaryNote && diaryNote.length > 2000) throw new GraphQLError('Notatet kan være maks 2000 tegn.') + if (rating != null && (!Number.isInteger(rating) || rating < 1 || rating > 5)) throw new GraphQLError('Vurderingen må være mellom 1 og 5.') + const result = await pool.query(`UPDATE norwegian_explorer_app.user_routes + SET completed_on=$3,diary_note=$4,rating=$5,updated_at=now() + WHERE user_id=$1 AND route_id=$2 AND status='COMPLETED' RETURNING route_id`, + [user.id,routeId,date,diaryNote?.trim() || null,rating ?? null]) + if (!result.rowCount) throw new GraphQLError('Marker turen som gjennomført før du skriver i dagboken.') + return profileFor(user) + }, + setTripStatus: async (_:unknown, { token, routeId, status }:ApiArgs) => { + const user = await requireUser(token) + if (!routeId || !/^[a-f0-9]{32}$/.test(routeId)) throw new GraphQLError('Ugyldig rute.') + if (status == null) { + await pool.query('DELETE FROM norwegian_explorer_app.user_routes WHERE user_id=$1 AND route_id=$2', [user.id,routeId]) + } else { + await routeCatalogReady + const { rows } = await pool.query(`SELECT name,number FROM norwegian_explorer_app.route_catalog WHERE id=$1 + UNION ALL SELECT name,number FROM norwegian_explorer_app.route_loops WHERE id=$1`, [routeId]) + if (!rows[0]) throw new GraphQLError('Ruten finnes ikke.') + await pool.query(`INSERT INTO norwegian_explorer_app.user_routes(user_id,route_id,route_name,route_number,status) + VALUES ($1,$2,$3,$4,$5) ON CONFLICT (user_id,route_id) DO UPDATE SET status=excluded.status,updated_at=now()`, + [user.id,routeId,rows[0].name,rows[0].number,status]) + } + return profileFor(user) + }, + setHomeMunicipality: async (_:unknown, { token, code }:ApiArgs) => { + const user = await requireUser(token) + const cleanCode = code?.trim() || null + // Godta bare kommuner som faktisk finnes i rutedataene + if (cleanCode && !(await municipalityByCode(cleanCode))) throw new GraphQLError('Ukjent kommune.') + if (!(await hasHomeMunicipality)) throw new GraphQLError('Stedsvalg er ikke slått på i databasen ennå.') + const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users SET home_municipality=$2 + WHERE id=$1 RETURNING id,username,email,phone,avatar,home_municipality`, [user.id, cleanCode]) + return profileFor(rows[0]) + }, + updateProfile: async (_:unknown, { token, email, phone, avatar }:ApiArgs) => { + const user = await requireUser(token) + const cleanEmail = email?.trim() || null + const cleanPhone = phone?.trim() || null + const cleanAvatar = avatar?.trim() || null + if(cleanEmail && cleanEmail.length>254)throw new GraphQLError('E-postadressen er for lang.') + if (cleanEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail)) throw new GraphQLError('Skriv inn en gyldig e-postadresse.') + if (cleanPhone && !/^\+?[0-9 ()-]{6,20}$/.test(cleanPhone)) throw new GraphQLError('Skriv inn et gyldig telefonnummer.') + if (cleanAvatar && (!/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/.test(cleanAvatar) || cleanAvatar.length > 600000)) throw new GraphQLError('Profilbildet må være et bilde på under 450 KB.') + const { rows } = await pool.query(`UPDATE norwegian_explorer_app.users + SET email=$2,phone=$3,avatar=$4 WHERE id=$1 RETURNING id,username,email,phone,avatar,${await homeColumn()}`, + [user.id, cleanEmail, cleanPhone, cleanAvatar]) + return profileFor(rows[0]) + }, + } }, mountainResolvers], +}) +async function loadRoute(name:string, number:string) { + await routeCatalogReady + const { rows } = await pool.query(` + SELECT id,name,number,segment_count AS "segmentCount",length_meters AS "lengthMeters", + connected,maintainer,difficulty,route_type AS "routeType",description, + name_quality AS "nameQuality",d.duplicate_count AS "duplicateCount", + (SELECT count(*)::int FROM norwegian_explorer_app.route_catalog c2 + JOIN norwegian_explorer_app.route_duplicate_map d2 ON d2.route_id=c2.id AND d2.canonical_id=c2.id + WHERE lower(c2.name)=lower(c.name)) AS "variantCount", + ST_AsGeoJSON(ST_Transform(geom,4326)) AS geojson + FROM norwegian_explorer_app.route_catalog c + JOIN norwegian_explorer_app.route_duplicate_map d ON d.route_id=c.id + WHERE name=$1 AND number=$2 + AND NOT EXISTS (SELECT 1 FROM norwegian_explorer_app.route_exclusions e WHERE e.route_id=c.id) + UNION ALL + SELECT l.id,l.name,l.number,l.source_variant_count AS "segmentCount",l.length_meters AS "lengthMeters", + true AS connected,'Avledet fra registrerte rutevarianter' AS maintainer,NULL AS difficulty, + CASE WHEN l.number LIKE 'STREKNING_%' THEN 'Avledet strekning' ELSE 'Avledet rundtur' END AS "routeType", + 'Dette rutevalget er beregnet fra registrerte stier. Ingen forbindelser over datagap er lagt til. Rutevalget er ikke et offisielt turforslag.' AS description, + 'derived' AS "nameQuality",1 AS "duplicateCount", + (SELECT count(*)::int + 1 FROM norwegian_explorer_app.route_catalog c2 + JOIN norwegian_explorer_app.route_duplicate_map d2 ON d2.route_id=c2.id AND d2.canonical_id=c2.id + WHERE lower(c2.name)=lower(l.name)) AS "variantCount", + ST_AsGeoJSON(ST_Transform(l.geom,4326)) AS geojson + FROM norwegian_explorer_app.route_loops l WHERE l.name=$1 AND l.number=$2`, [name.trim(), number.trim()]) + return rows[0] || null +} +const yoga = createYoga({ schema, plugins:[requestSafety], batching:false, cors: false, graphiql: process.env.NODE_ENV !== 'production' }) +const server = createServer(yoga) +const port = Number(process.env.PORT || 4000) +server.listen(port, '127.0.0.1', () => console.log(`GraphQL: http://127.0.0.1:${port}/graphql`)) +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(async () => { await pool.end(); process.exit(0) })) +} diff --git a/server/requestSafety.ts b/server/requestSafety.ts new file mode 100644 index 0000000..71bc69b --- /dev/null +++ b/server/requestSafety.ts @@ -0,0 +1,41 @@ +import type {ValidationContext,DocumentNode,SelectionSetNode,FragmentDefinitionNode} from 'graphql' +import type {Plugin,GraphQLParams} from 'graphql-yoga' +import {GraphQLError,Kind} from 'graphql' +export function queryBudget(context:ValidationContext){ + return {Document(document:DocumentNode){ + const fragments=new Map(document.definitions.filter(d=>d.kind===Kind.FRAGMENT_DEFINITION).map(d=>[d.name.value,d])) + for(const operation of document.definitions.filter(d=>d.kind===Kind.OPERATION_DEFINITION)){ + let fields=0,roots=0 + function walk(set:SelectionSetNode|undefined,depth:number,path=new Set()){ + if(depth>12)throw new Error('Spørringen er for dypt nestet.') + for(const selection of set?.selections||[]){ + if(selection.kind===Kind.FIELD){if(++fields>300||(depth===1&&++roots>20))throw new Error('Spørringen inneholder for mange felt.');walk(selection.selectionSet,depth+1,path)} + else if(selection.kind===Kind.INLINE_FRAGMENT)walk(selection.selectionSet,depth,path) + else {const name=selection.name.value;if(path.has(name))throw new Error('Spørringen inneholder en fragmentsyklus.');const fragment=fragments.get(name);if(fragment)walk(fragment.selectionSet,depth,new Set([...path,name]))} + } + } + try{walk(operation.selectionSet,1)}catch(error){context.reportError(new GraphQLError(error instanceof Error?error.message:'Ugyldig spørring.'))} + } + }} +} +export async function boundedJson(request:Request):Promise{ + const reader=request.body?.getReader();const chunks:Buffer[]=[];let length=0 + if(!reader)throw new GraphQLError('Forespørselen mangler innhold.') + try{while(true){const {done,value}=await reader.read();if(done)break;length+=value.byteLength;if(length>6000000){await reader.cancel();throw new GraphQLError('Forespørselen er for stor.')}chunks.push(Buffer.from(value))}} + finally{reader.releaseLock()} + try{return JSON.parse(Buffer.concat(chunks).toString('utf8'))}catch{throw new GraphQLError('Ugyldig JSON i forespørselen.')} +} +export const requestSafety:Plugin={ + onRequestParse({request,setRequestParser,endResponse}){ + if(request.method==='POST'){ + if(request.headers.get('content-type')?.split(';')[0].trim().toLowerCase()!=='application/json'){endResponse(new Response('Bruk application/json.',{status:415}));return} + setRequestParser(boundedJson) + } + }, + onParams({request,params}){ + if(request.method!=='POST')throw new GraphQLError('Send GraphQL-spørringer med POST.') + if(!params.query||params.query.length>100000)throw new GraphQLError('Spørringen mangler eller er for stor.') + }, + onValidate({addValidationRule}){addValidationRule(queryBudget)}, + onResponse({response}){response.headers.set('Cache-Control','no-store');response.headers.set('X-Content-Type-Options','nosniff')} +} diff --git a/server/routeStops.ts b/server/routeStops.ts new file mode 100644 index 0000000..99c595f --- /dev/null +++ b/server/routeStops.ts @@ -0,0 +1,81 @@ +import {GraphQLError} from 'graphql' +import {pool} from './db.ts' +import {sampleRoute} from './elevation.ts' + +const kinds=['alpine_hut','wilderness_hut','chalet','shelter','lean_to','viewpoint','picnic_site','toilets','drinking_water','parking','camp_site','peak'] +const facilityGroups:Record={hut:['alpine_hut','wilderness_hut','chalet'],shelter:['shelter','lean_to'],viewpoint:['viewpoint'],picnic_site:['picnic_site'],toilets:['toilets'],drinking_water:['drinking_water'],parking:['parking'],camp_site:['camp_site'],peak:['peak']} +const facilityCache=new Map() +const facilityPending=new Map>() +let facilityIndexReady:Promise|null +async function ensureFacilityIndex(){ + if(!facilityIndexReady)facilityIndexReady=(async()=>{ + const client=await pool.connect() + try{ + await client.query('BEGIN') + await client.query("SET LOCAL statement_timeout='60s'") + await client.query(`CREATE MATERIALIZED VIEW IF NOT EXISTS norwegian_explorer_app.route_facility_segments AS + SELECT id,ST_Transform(part,4326)::geography geog FROM ( + SELECT id,ST_Subdivide(geom,128) part FROM norwegian_explorer_app.route_catalog + UNION ALL SELECT id,ST_Subdivide(geom,128) part FROM norwegian_explorer_app.route_loops + ) parts`) + await client.query('CREATE INDEX IF NOT EXISTS route_facility_segments_geog_idx ON norwegian_explorer_app.route_facility_segments USING gist(geog)') + await client.query('ANALYZE norwegian_explorer_app.route_facility_segments') + await client.query('COMMIT') + }catch(error){await client.query('ROLLBACK');throw error}finally{client.release()} + })().catch(error=>{facilityIndexReady=null;throw error}) + await facilityIndexReady +} +async function loadFacilityRouteIds(facility:string){ + if(!Object.hasOwn(facilityGroups,facility))throw new GraphQLError('Ugyldig kategori for steder nær ruten.') + const cached=facilityCache.get(facility) + if(cached&&cached.expires>Date.now())return cached.ids + await ensureFacilityIndex() + const ids=new Set() + for(let offset=0;;offset+=5000){ + const {rows}=await pool.query(`WITH places AS MATERIALIZED ( + SELECT ST_PointOnSurface(geom)::geography point FROM osm_import.objects + WHERE category='place' AND NOT ST_IsEmpty(geom) + AND coalesce(tags->>'tourism',tags->>'amenity',tags->>'natural')=ANY($1::text[]) + AND coalesce(tags->>'access','') NOT IN ('no','private') AND coalesce(tags->>'foot','') NOT IN ('no','private') + ORDER BY osm_type,osm_id LIMIT 5000 OFFSET $2 + ) SELECT (SELECT count(*)::int FROM places) count, ARRAY(SELECT DISTINCT s.id FROM places p + JOIN norwegian_explorer_app.route_facility_segments s ON ST_DWithin(s.geog,p.point,500)) ids`,[facilityGroups[facility],offset]) + for(const id of rows[0].ids)ids.add(id) + if(rows[0].count<5000)break + } + const result=[...ids];facilityCache.set(facility,{ids:result,expires:Date.now()+600000});return result +} +export async function nearbyFacilityRouteIds(facility:string){ + if(!Object.hasOwn(facilityGroups,facility))throw new GraphQLError('Ugyldig kategori for steder nær ruten.') + if(!facilityPending.has(facility))facilityPending.set(facility,loadFacilityRouteIds(facility).finally(()=>facilityPending.delete(facility))) + return facilityPending.get(facility) +} +export async function routeStops(geojson:string,ids:string[]|null=null){ + if(ids && ids.length===0)return {stops:[],truncated:false} + sampleRoute(geojson) + if(ids && (ids.length>20 || ids.some(id=>!/^([nw])\/[0-9]+$/.test(id)) || new Set(ids).size!==ids.length))throw new GraphQLError('Velg maks 20 forskjellige stopp.') + const {rows}=await pool.query(`WITH route AS ( + SELECT ST_SetSRID(ST_GeomFromGeoJSON($1),4326) geom + ), candidates AS ( + SELECT o.osm_type || '/' || o.osm_id::text id,o.name,o.tags,ST_PointOnSurface(o.geom) point,r.geom route, + coalesce(o.tags->>'tourism',o.tags->>'amenity',o.tags->>'natural') kind + FROM osm_import.objects o CROSS JOIN route r + WHERE o.category='place' AND NOT ST_IsEmpty(o.geom) + AND o.geom && ST_Expand(r.geom,0.05) + AND coalesce(o.tags->>'access','') NOT IN ('no','private') + AND coalesce(o.tags->>'foot','') NOT IN ('no','private') + AND ($2::text[] IS NULL OR o.osm_type || '/' || o.osm_id::text=ANY($2)) + ), nearby AS ( + SELECT *,ST_Distance(point::geography,route::geography) AS distance + FROM candidates WHERE kind=ANY($3::text[]) + AND ST_DWithin(point::geography,route::geography,500) + ) SELECT id,name,kind,ST_X(point) lon,ST_Y(point) lat,distance AS "distanceFromRouteMeters", + ST_LineLocatePoint(ST_Transform(route,25833),ST_Transform(point,25833))*ST_Length(route::geography) AS "distanceFromStartMeters", + tags->>'operator' operator,tags->>'opening_hours' AS "openingHours",tags->>'capacity' capacity, + tags->>'fee' fee,coalesce(tags->>'website',tags->>'contact:website') website + FROM nearby ORDER BY distance,id LIMIT 201`,[geojson,ids,kinds]) + const stops=rows.slice(0,200).map(row=>({...row,website:row.website&&/^https?:\/\//i.test(row.website)?row.website:null})) + .sort((a,b)=>a.distanceFromStartMeters-b.distanceFromStartMeters||a.id.localeCompare(b.id)) + if(ids && stops.length!==ids.length)throw new GraphQLError('Et valgt stopp finnes ikke lenger eller ligger mer enn 500 meter fra den nye ruten. Oppdater stoppene før lagring.') + return {stops,truncated:rows.length>200} +} diff --git a/server/server.ts b/server/server.ts index 75326da..99219b9 100644 --- a/server/server.ts +++ b/server/server.ts @@ -1,10 +1,10 @@ // GraphQL-delen for fjellområdesiden (fjellomrade.html): typene og spørringene for fjellområder og turpunkter. -// Fila starter ikke noen server selv. Den kobles inn i backenden i server/index.mjs, som startes med +// Fila starter ikke noen server selv. Den kobles inn i backenden i server/index.ts, som startes med // npm run dev:server, ved siden av rutesøket og innloggingen. import { getMountainAreas } from './mountainAreas.ts' import { getAreaPointsOfInterest, searchPointsOfInterest } from './pointsOfInterest.ts' -// «extend type Query» legger spørringene til i Query-typen som er definert i index.mjs +// «extend type Query» legger spørringene til i Query-typen som er definert i index.ts export const mountainTypeDefs = /* GraphQL */ ` enum PoiKind { HUT CABIN SHELTER PEAK } diff --git a/src/App.tsx b/src/App.tsx index 76f6e4e..e786abb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,12 @@ import 'maplibre-gl/dist/maplibre-gl.css' import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' import './App.css' import './Filters.css' +import './Explore.css' import Home from './Home' +import {graphql,TOKEN_KEY} from './graphql' import Profile, { AuthGate } from './Profile' +import ElevationProfile from './ElevationProfile' +import {placeCategories} from './placeCategories' maplibregl.setWorkerUrl(workerUrl) @@ -27,13 +31,17 @@ function variantLabel(number: string) { return `Nr. ${number}` } +const labels: Record = { parking:'Parkering', toilets:'Toalett', drinking_water:'Drikkevann', shelter:'Leskur', viewpoint:'Utsiktspunkt', alpine_hut:'Turhytte', wilderness_hut:'Villmarkshytte', picnic_site:'Rasteplass', camp_site:'Campingplass', peak:'Topp', saddle:'Skar', chalet:'Hytte', lean_to:'Gapahuk' } + function RouteMap({ route }: { route: Route }) { const container = useRef(null) + const osmMap = useRef(null) + const [osmKind,setOsmKind] = useState('all') const [error, setError] = useState('') const [showOsm, setShowOsm] = useState(false) const [osmStatus, setOsmStatus] = useState('') const [osmData, setOsmData] = useState(null) - const labels: Record = { parking:'Parkering', toilets:'Toalett', drinking_water:'Drikkevann', shelter:'Leskur', viewpoint:'Utsiktspunkt', alpine_hut:'Turhytte', wilderness_hut:'Villmarkshytte', picnic_site:'Rasteplass', camp_site:'Campingplass', peak:'Topp', saddle:'Skar' } + useEffect(() => { if (!container.current) return let map: maplibregl.Map | undefined @@ -57,6 +65,7 @@ function RouteMap({ route }: { route: Route }) { }, }) map.addControl(new maplibregl.NavigationControl(), 'top-right') + osmMap.current=map map.on('error', () => setError('En del av kartet kunne ikke lastes. Ruteinformasjonen er fortsatt tilgjengelig.')) map.on('load', () => { if (showOsm) { @@ -77,7 +86,7 @@ function RouteMap({ route }: { route: Route }) { map!.on('click','osm-places',event => { const feature = event.features?.[0] if (!feature) return - new maplibregl.Popup().setLngLat(event.lngLat).setText(String(feature.properties.name || feature.properties.kind || 'OSM-sted')).addTo(map!) + new maplibregl.Popup().setLngLat(event.lngLat).setText(String(feature.properties.name || labels[String(feature.properties.kind)] || 'OSM-sted')).addTo(map!) }) setOsmData(data) setOsmStatus(result.data.osmNearby.truncated ? 'Viser de første 1 000 objektene i området; utvalget er begrenset.' : `${data.features.length} OSM-objekter i området rundt rutens kartutsnitt.`) @@ -94,8 +103,15 @@ function RouteMap({ route }: { route: Route }) { // eslint-disable-next-line react-hooks/set-state-in-effect setError('Kartet kunne ikke åpnes i nettleseren. Du kan lese ruteinformasjonen ved siden av.') } - return () => { controller.abort(); map?.remove() } + return () => { controller.abort(); map?.remove(); osmMap.current=null } }, [route, showOsm]) + useEffect(()=>{ + const map=osmMap.current + if(!map?.getLayer('osm-places'))return + const kinds=placeCategories.find(item=>item.value===osmKind)?.kinds + map.setFilter('osm-places',kinds?['all',['==',['get','category'],'place'],['in',['get','kind'],['literal',kinds]]]:['==',['get','category'],'place']) + },[osmKind,osmData]) + const visiblePlaces=osmData?.features.filter(f=>f.properties?.category==='place'&&(osmKind==='all'||placeCategories.find(item=>item.value===osmKind)?.kinds.includes(String(f.properties?.kind)))) || [] return
Registrert rutestrekning
@@ -104,12 +120,13 @@ function RouteMap({ route }: { route: Route }) {
{showOsm && <> +

Grønn: valgt rute · Oransje stiplet: OSM-stier · Blå: steder

{osmStatus}

Stedene ligger i området rundt ruta, men er ikke nødvendigvis tilgjengelige fra den. Stedsmarkører er omtrentlige og viser ikke nødvendigvis inngangen. Stier er ikke ferdige turforslag.

Objekter merket «private» eller «no» for ferdsel er utelatt. Andre begrensninger kan gjelde.

- {osmData &&
Steder i området ({osmData.features.filter(f => f.properties?.category==='place').length}) - + {osmData &&
Steder i valgt kategori ({visiblePlaces.length}) +
}

© OpenStreetMap-bidragsytere · ODbL. Uttrekk fra Geofabrik, 27.09.2026.

} @@ -118,11 +135,21 @@ function RouteMap({ route }: { route: Route }) { } export default function App() { - // Hvilken side som vises. Hele appen kan brukes uten innlogging: forsiden vises først, - // turoversikten når man velger en tur, og innloggingssiden bare når man trykker «Logg inn». - // Innlogging trengs kun for det som må knyttes til en bruker (favoritter og «Mitt sted»). - const [view, setView] = useState<'home' | 'explore' | 'login'>('home') - const showExplore = useCallback(() => setView('explore'), []) + const [view,setView] = useState<'explore'|'profile'>('explore') + const [showHome,setShowHome]=useState(true) + const [showLogin,setShowLogin]=useState(false) + const [guest,setGuest]=useState(false) + const [authenticated, setAuthenticated] = useState(false) + const handleAuthenticated = useCallback(() => {setAuthenticated(true);setGuest(false);setShowLogin(false)}, []) + const handleLogout = useCallback(() => {setAuthenticated(false);setGuest(false);setShowLogin(false);setShowHome(true)}, []) + useEffect(()=>{ + const token=localStorage.getItem(TOKEN_KEY) + if(!token)return + const controller=new AbortController() + void graphql<{me:{username:string}|null}>('query($token:String!){me(token:$token){username}}',{token},controller.signal) + .then(data=>{if(!controller.signal.aborted&&localStorage.getItem(TOKEN_KEY)===token){setAuthenticated(Boolean(data.me));if(!data.me)localStorage.removeItem(TOKEN_KEY)}}).catch(()=>undefined) + return ()=>controller.abort() + },[]) const detailsRef = useRef(null) const [route, setRoute] = useState(null) const [routeVariants, setRouteVariants] = useState([]) @@ -135,6 +162,7 @@ export default function App() { const [maxInput, setMaxInput] = useState('') const [minLength, setMinLength] = useState(null) const [maxLength, setMaxLength] = useState(null) + const [facility,setFacility] = useState(null) const [difficulty, setDifficulty] = useState(null) const [routeType, setRouteType] = useState(null) const [county, setCounty] = useState(null) @@ -147,6 +175,7 @@ export default function App() { const [total, setTotal] = useState(0) const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading') function openRoute(item: { name: string; number: string }) { + setView('explore') setSelected({ ...item }) setStatus('loading') window.requestAnimationFrame(() => detailsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })) @@ -166,7 +195,7 @@ export default function App() { try { const response = await fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal, - body: JSON.stringify({ query: 'query($search:String!,$minLength:Float,$maxLength:Float,$difficulty:String,$routeType:String,$county:String,$municipality:String,$offset:Int!){ routes(search:$search,minLength:$minLength,maxLength:$maxLength,difficulty:$difficulty,routeType:$routeType,county:$county,municipality:$municipality,offset:$offset,limit:12){total items{key name variantCount minLengthMeters maxLengthMeters variants{id name number lengthMeters difficulty routeType duplicateCount variantCount}}} }', variables: { search, minLength, maxLength, difficulty, routeType, county, municipality, offset: page * 12 } }), + body: JSON.stringify({ query: 'query($search:String!,$minLength:Float,$maxLength:Float,$difficulty:String,$routeType:String,$county:String,$municipality:String,$facility:String,$offset:Int!){ routes(search:$search,minLength:$minLength,maxLength:$maxLength,difficulty:$difficulty,routeType:$routeType,county:$county,municipality:$municipality,facility:$facility,offset:$offset,limit:12){total items{key name variantCount minLengthMeters maxLengthMeters variants{id name number lengthMeters difficulty routeType duplicateCount variantCount}}} }', variables: { search, minLength, maxLength, difficulty, routeType, county, municipality, facility, offset: page * 12 } }), }) const result = await response.json() if (!response.ok || result.errors) throw new Error('Request failed') @@ -175,7 +204,7 @@ export default function App() { } void loadList() return () => controller.abort() - }, [search, minLength, maxLength, difficulty, routeType, county, municipality, page, listAttempt]) + }, [search, minLength, maxLength, difficulty, routeType, county, municipality, facility, page, listAttempt]) useEffect(() => { if (!selected) return const selectedRoute = selected @@ -198,30 +227,37 @@ export default function App() { void load() return () => controller.abort() }, [selected, attempt]) - if (view === 'home') return setView('login')} onSelectRoute={item => { openRoute(item); setView('explore') }} /> - // Etter innlogging går man til turoversikten, der profilen og favorittene ligger - if (view === 'login') return setView('home')} /> + if(showLogin&&!authenticated)return {setShowLogin(false);setShowHome(true)}} onSkip={()=>{setGuest(true);setShowLogin(false);setShowHome(false);setView('explore')}} /> + if(showHome)return {setGuest(!authenticated);setShowHome(false);setView('explore')}} onProfileClick={()=>{setView('profile');setShowHome(false)}} onLoginClick={()=>setShowLogin(true)} onSelectRoute={item=>{openRoute(item);setGuest(!authenticated);setShowHome(false)}} /> + if(!authenticated&&!guest)return setShowHome(true)} onSkip={()=>{setGuest(true);setView('explore')}} /> - return <> + return
Hopp til ruteinformasjon -
+
△ Norwegian Explorer
-

OPPDAG NORGE TIL FOTS

Ut på nye stier.

Utforsk registrerte turruter og bli kjent med veien videre.

-

FINN DIN NESTE TUR

Utforsk etter kategori

{(difficulty || routeType || county || municipality || minLength != null || maxLength != null) && }
+ } - + {status === 'ready' && route && } +
+ - +
} diff --git a/src/ElevationProfile.css b/src/ElevationProfile.css new file mode 100644 index 0000000..51a9890 --- /dev/null +++ b/src/ElevationProfile.css @@ -0,0 +1,13 @@ +.elevation-panel { margin: 24px 0; padding: 20px; background: #fff; border: 1px solid #dce1d7; border-radius: 12px; } +.elevation-panel h3 { margin-top: 0; } +.elevation-panel svg { display: block; width: 100%; max-width: 900px; margin: 20px 0; } +.elevation-panel svg text { fill: #647267; font-size: 11px; } +.elevation-stats { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; margin-top: 18px; } +.elevation-stats strong,.elevation-stats span { display: block; } +.elevation-stats span { color: #647267; font-size: 12px; margin-top: 6px; } +.elevation-slider { display: grid; gap: 8px; font-size: 13px; } +.elevation-slider input { width: 100%; accent-color: #126b51; } +.elevation-table { overflow: auto; max-height: 300px; } +.elevation-table table { border-collapse: collapse; width: 100%; } +.elevation-table td,.elevation-table th { text-align: left; padding: 8px; border-bottom: 1px solid #dce1d7; } +@media(max-width:600px){.elevation-stats{grid-template-columns:repeat(2,minmax(0,1fr))}} diff --git a/src/ElevationProfile.tsx b/src/ElevationProfile.tsx new file mode 100644 index 0000000..a008110 --- /dev/null +++ b/src/ElevationProfile.tsx @@ -0,0 +1,60 @@ +import {useEffect,useRef,useState} from 'react' +import {graphql} from './graphql' +import './ElevationProfile.css' + +type Point={distanceMeters:number;elevation:number|null} +type Profile={points:Point[];lengthMeters:number;sampleIntervalMeters:number;ascentMeters:number|null;descentMeters:number|null;minElevation:number|null;maxElevation:number|null;complete:boolean;missingCount:number;source:string} +function meters(value:number|null){return value==null?'Ikke tilgjengelig':`${Math.round(value).toLocaleString('nb-NO')} m`} + +export default function ElevationProfile({geojson}:{geojson:string}){ + const [result,setResult]=useState<{geojson:string;profile:Profile}|null>(null) + const [error,setError]=useState<{geojson:string;text:string}|null>(null) + const [busyGeo,setBusyGeo]=useState(null) + const [selected,setSelected]=useState(0) + const request=useRef(null) + useEffect(()=>()=>request.current?.abort(),[geojson]) + const profile=result?.geojson===geojson?result.profile:null + const busy=busyGeo===geojson + let connected=false + try{connected=JSON.parse(geojson).type==='LineString'}catch{ /* A malformed route cannot have a profile. */ } + + async function load(){ + request.current?.abort();const controller=new AbortController();request.current=controller + const token=localStorage.getItem('norwegian-explorer-session')||'' + setBusyGeo(geojson);setError(null) + try{ + const data=await graphql<{elevationProfile:Profile}>('query($token:String!,$geojson:String!){elevationProfile(token:$token,geojson:$geojson){points{distanceMeters elevation} lengthMeters sampleIntervalMeters ascentMeters descentMeters minElevation maxElevation complete missingCount source}}',{token,geojson},AbortSignal.any([controller.signal,AbortSignal.timeout(25000)])) + if(!controller.signal.aborted){setResult({geojson,profile:data.elevationProfile});setSelected(0)} + }catch(err){if(!controller.signal.aborted)setError({geojson,text:err instanceof Error?err.message:'Kunne ikke hente høydeprofil.'})} + finally{if(request.current===controller)setBusyGeo(null)} + } + const low=(profile?.minElevation??0)-5,high=(profile?.maxElevation??0)+5 + const x=(distance:number)=>48+distance/(profile?.lengthMeters||1)*564 + const y=(elevation:number)=>176-(elevation-low)/(high-low)*144 + let path='',pen=false + if(profile)for(const point of profile.points){if(point.elevation==null){pen=false;continue}path+=`${pen?'L':'M'}${x(point.distanceMeters).toFixed(2)},${y(point.elevation).toFixed(2)} `;pen=true} + const point=profile?.points[Math.min(selected,(profile?.points.length||1)-1)] + return
+

Høydeprofil

+ {!connected?

Velg en sammenhengende rute eller rutevariant for å vise høydeprofil. Flere adskilte kartlinjer har ingen entydig turrekkefølge.

:<> +

Høydene hentes fra Kartverket når du trykker på knappen. Utvalgte rutekoordinater sendes til tjenesten.

+ + {busy&&

Henter terrenghøyder langs ruten …

} + {error?.geojson===geojson&&

{error.text}

} + {profile&&<> +
{meters(profile.ascentMeters)}Opp
{meters(profile.descentMeters)}Ned
{meters(profile.minElevation)}Laveste målte punkt
{meters(profile.maxElevation)}Høyeste målte punkt
+ + + {Math.round(high)} m{Math.round(low)} m0 km{(profile.lengthMeters/1000).toLocaleString('nb-NO',{maximumFractionDigits:1})} km + + {point?.elevation!=null&&} + + + {point&&

{(point.distanceMeters/1000).toLocaleString('nb-NO',{maximumFractionDigits:2})} km fra start · {meters(point.elevation)} over havet

} + {!profile.complete&&

Høyde mangler for {profile.missingCount} punkter. Kurven har hull, og samlet opp/ned vises ikke.

} +

Omtrentlige terrenghøyder med cirka {Math.round(profile.sampleIntervalMeters)} meter mellom målepunktene. Stigning og høyeste/laveste punkt er estimater fra utvalget og kan overse korte bakker. Kilde: {profile.source}.

+
Høydene som tabell
{profile.points.map((p,i)=>)}
Fra start (km)Høyde (m)
{(p.distanceMeters/1000).toLocaleString('nb-NO',{maximumFractionDigits:2})}{p.elevation==null?'Mangler':Math.round(p.elevation)}
+ } + } +
+} diff --git a/src/Explore.css b/src/Explore.css new file mode 100644 index 0000000..1ff0e44 --- /dev/null +++ b/src/Explore.css @@ -0,0 +1,86 @@ +.explore-app,.auth-page{--ink:#23382c;--muted:#758075;--green:#435d47;--line:#e3e8df;--paper:#fafbf8;font-family:'Avenir Next',system-ui,sans-serif;color:var(--ink);background:var(--paper)} +.explore-app [hidden]{display:none!important} +.explore-app>header,.auth-page>header{height:86px;padding:0 max(5%,calc((100vw - 1320px)/2));background:var(--paper);border-bottom:1px solid var(--line)} +.explore-app .brand,.auth-page .brand{font-size:20px;letter-spacing:-.6px;gap:9px} +.explore-app button,.auth-page button{background:var(--green);border-radius:9px;font-weight:600;padding:12px 18px;transition:background .15s,transform .15s} +.explore-app button:hover:not(:disabled),.auth-page button:hover:not(:disabled){background:#304c37} +.explore-app .nav-tab{background:transparent;color:var(--muted);font-size:13px;padding:10px 12px} +.explore-app .nav-tab[aria-pressed=true]{color:var(--ink)} +.explore-app .profile-link{background:#eaf0e6;color:var(--green);font-size:13px;border-radius:9px} +.explore-app .nav-link{font-size:13px;color:var(--muted)} +.explore-app main{max-width:1432px;padding:24px 56px 24px} +.explore-hero{position:relative;min-height:480px;border-radius:16px;overflow:hidden;background:#385143;background-image:linear-gradient(90deg,rgba(17,37,24,.8),rgba(17,37,24,.36) 50%,rgba(17,37,24,.06)),url('/images/explore-nature.jpg');background-size:cover;background-position:center;display:flex;align-items:center;padding:60px;color:#fff} +.hero-copy{position:relative;max-width:670px;z-index:1} +.hero-copy .eyebrow{color:#d4dfcc;letter-spacing:2.4px;font-size:10px} +.hero-copy h1{font-size:clamp(40px,5.6vw,68px);font-weight:600;line-height:1.03;letter-spacing:-2.5px;margin:20px 0} +.hero-copy>p:not(.eyebrow){font-size:16px;line-height:1.65;color:#e6ebe1;margin-bottom:28px} +.hero-search{display:flex;align-items:center;gap:12px;background:#fff;padding:7px 7px 7px 17px;border-radius:10px;width:min(590px,100%);box-shadow:0 8px 24px #10211720;color:var(--ink)} +.hero-search>span{font-size:28px;color:#667c64;line-height:1} +.hero-search input{border:0;background:transparent;font:inherit;font-size:13px;flex:1;min-width:0;padding:9px 0;color:var(--ink)} +.hero-search button{font-size:12px;white-space:nowrap;display:flex;gap:14px;align-items:center} +.hero-image-note{position:absolute;right:18px;bottom:14px;font-size:9px;letter-spacing:.3px;color:#edf1e8;background:#152e2455;padding:5px 9px;border-radius:4px} +.visually-hidden{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap} +.explore-areas{padding:42px 0 28px} +.section-title{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:20px} +.section-title .eyebrow{font-size:9px;letter-spacing:1.9px;color:var(--muted);margin:0 0 10px} +.section-title h2{font-size:27px;letter-spacing:-.9px;font-weight:600;margin:0} +.section-title>a{font-size:12px;text-decoration:none;color:var(--green)} +.area-cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px} +.explore-app .area-card{position:relative;background:#eff3ea;color:var(--ink);text-align:left;border:1px solid transparent;padding:23px;min-height:154px;display:flex;flex-direction:column;align-items:flex-start} +.explore-app .area-card:nth-child(2){background:#ecefe8}.explore-app .area-card:nth-child(3){background:#e8efeb}.explore-app .area-card:nth-child(4){background:#f1efe4} +.explore-app .area-card:hover:not(:disabled),.explore-app .area-card[aria-pressed=true]{background:#dfe9d8;border-color:#869a7b} +.area-symbol{font-size:30px;font-weight:400;line-height:1;margin-bottom:20px;color:#677c60} +.area-name{font-size:17px;font-weight:600;letter-spacing:-.3px} +.area-count{font-size:10px;color:#73806e;margin-top:7px;font-weight:400} +.area-arrow{position:absolute;right:18px;top:20px;font-size:16px;color:#73806e} +.area-loading{font-size:13px;color:var(--muted)} +.explore-filters{border:1px solid var(--line);border-radius:10px;background:#fff;margin-bottom:34px;scroll-margin-top:24px} +.explore-filters>summary{display:flex;align-items:center;gap:16px;cursor:pointer;padding:19px 22px;font-size:13px;font-weight:600;list-style:none} +.explore-filters>summary::-webkit-details-marker{display:none} +.explore-filters>summary>span{font-size:11px;font-weight:400;color:var(--muted);flex:1} +.explore-filters>summary>b{font-size:12px;font-weight:500;color:var(--green);margin-left:auto;white-space:nowrap} +.explore-filters[open]>summary{border-bottom:1px solid var(--line)} +.explore-filters .category-section{padding:24px;margin:0}.explore-filters .category-heading h2{font-size:20px}.explore-filters .category-heading .eyebrow{display:none} +.explore-filters .category-group{border-radius:9px;padding:15px;background:#fafbf8} +.explore-app .category-buttons button{background:#edf1e9;color:var(--ink);font-size:12px;padding:10px}.explore-app .category-buttons button:hover:not(:disabled){background:#dce5d5} +.explore-filters .route-facility-filter{border:0;border-top:1px solid var(--line);border-radius:0;margin:0;padding:20px 24px} +.exact-length-form{padding:0 24px 24px}.exact-length-form .length-filters{align-items:end}.exact-length-form button{font-size:12px} +.explore-app .route-search{padding:0;background:transparent;border:0;border-radius:0;scroll-margin-top:24px} +.explore-app .route-search>p[role=status]{color:var(--muted);font-size:12px;margin-bottom:18px} +.explore-app .route-results{gap:16px;margin:0 0 24px} +.explore-app .route-results button{position:relative;background:#fff;color:var(--ink);border:1px solid var(--line);min-height:162px;padding:23px;display:flex;flex-direction:column;justify-content:space-between;box-shadow:0 3px 12px #23382c03} +.explore-app .route-results button:hover:not(:disabled){background:#f1f5ee;border-color:#a2b398;transform:translateY(-2px)} +.explore-app .route-results button[aria-pressed=true]{border-color:var(--green);background:#eef3e9} +.explore-app .route-results .route-card-top{display:flex;align-items:center;justify-content:space-between;margin:0 0 18px;color:#75866c;width:100%;font-size:17px} +.explore-app .route-results .route-type-label{font-size:9px;letter-spacing:1.5px;margin:0;font-weight:500} +.explore-app .route-results strong{font-size:17px;line-height:1.35;letter-spacing:-.3px;margin-bottom:15px;font-weight:600} +.explore-app .route-results .route-card-meta{font-size:12px;color:#788271;margin:0} +.explore-app .pagination button{border:1px solid var(--line);background:#fff;color:var(--green);font-size:12px;padding:10px 18px} +.explore-app .pagination button:hover:not(:disabled){background:#edf2e9} +.selected-title{display:flex;gap:16px;align-items:center;justify-content:space-between;margin:42px 0 20px}.selected-title h2{margin:0;font-size:26px}.selected-title button{font-size:12px;white-space:nowrap} +.explore-app .route-layout{border-radius:12px;box-shadow:none}.explore-app .route-card{padding:28px}.explore-app .route-card h2{font-size:28px} +.explore-app .text-button{background:transparent;color:var(--green)}.explore-app .text-button:hover:not(:disabled){background:transparent;color:#263c2b} +.profile-page-title{margin:32px 0}.profile-page-title h1{font-size:44px;letter-spacing:-1.6px}.profile-page-title>p:last-child{color:var(--muted);font-size:14px} +.explore-app .profile-panel{margin-top:24px;border-radius:12px;padding:30px}.explore-app .profile-tabs button{background:#eef2ea;color:var(--ink)} +.auth-page{background:#f4f6ef}.auth-main{padding:48px 24px}.auth-card{border-radius:14px;box-shadow:0 16px 45px #243a3010;padding:42px}.auth-intro h1{font-family:'Avenir Next',system-ui,sans-serif;font-size:44px;font-weight:600;letter-spacing:-1.8px}.auth-card .profile-form input{background:#fafbf8;border-color:#dce3d5;padding:14px}.auth-card .text-button{background:transparent;color:var(--green)} +@media(max-width:1050px){.explore-app main{padding:20px 28px}.explore-hero{padding:40px;min-height:430px}.explore-filters .category-groups{grid-template-columns:repeat(2,minmax(0,1fr))}.area-card{padding:18px}.header-actions{gap:8px}} +@media(max-width:720px){.explore-app>header,.auth-page>header{padding:0 20px;height:74px}.explore-app .brand{font-size:16px}.explore-app .nav-link{display:none}.explore-app main{padding:16px}.explore-hero{padding:30px 24px;min-height:430px;background-position:60% center}.hero-copy h1{font-size:48px}.hero-search{gap:8px;padding-left:12px}.hero-search input{font-size:12px}.hero-search button{padding:12px;font-size:11px}.area-cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.section-title h2{font-size:23px}.section-title>a{font-size:11px}.explore-filters>summary>span{display:none}.explore-filters .category-groups{grid-template-columns:1fr}.explore-app .route-results{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.explore-app .route-results button{padding:16px;min-height:165px}.explore-app .route-results strong{font-size:15px}.selected-title{align-items:start;flex-direction:column}.auth-card{padding:28px}.exact-length-form .length-filters{display:grid}.explore-app .nav-tab{font-size:11px;padding:8px}.explore-app .profile-link{font-size:11px;padding:9px}.explore-app .profile-panel{padding:20px}} +@media(max-width:390px){.explore-app .brand span{font-size:14px}.explore-app .brand{gap:4px}.explore-app .route-results{grid-template-columns:1fr}.hero-copy h1{font-size:42px}.hero-search>span{display:none}.hero-search button span{display:none}} +@media(prefers-reduced-motion:reduce){.explore-app button{transition:none}.explore-app .route-results button:hover{transform:none}} +/* Give closed native selects the same appearance across browsers. */ +.explore-app select{ + -webkit-appearance:none;appearance:none; + display:block;max-width:100%;min-height:46px; + padding:12px 42px 12px 14px!important; + border:1px solid #d9e1d3!important;border-radius:9px!important; + background-color:#fff!important; + background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'%3E%3Cpath d='m4 6 4 4 4-4' stroke='%23566b50' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")!important; + background-repeat:no-repeat!important;background-position:right 14px center!important; + color:#293e2e!important;font-family:inherit;font-size:13px!important;line-height:1.4; + box-shadow:none;cursor:pointer; +} +.explore-app select:hover{border-color:#9bad91!important} +.explore-app select:focus-visible{outline:2px solid #677f5d;outline-offset:3px} +.explore-app select:disabled{opacity:.5;cursor:default} +.explore-app .geography-category label{font-size:11px;color:#718069;font-weight:500;margin:16px 0 7px} +.explore-app .geography-category h3{font-size:13px;font-weight:600;margin-bottom:16px} diff --git a/src/Filters.css b/src/Filters.css index b5fcd78..835ac44 100644 --- a/src/Filters.css +++ b/src/Filters.css @@ -6,6 +6,10 @@ .route-variant-selector label { display: block; margin-bottom: 7px; font-size: 12px; font-weight: 700; } .route-variant-selector select { width: 100%; min-width: 0; padding: 10px; border: 1px solid #9cac9e; border-radius: 7px; background: #fff; color: #203b32; font: inherit; } .selected-heading { scroll-margin-top: 20px; } +.route-facility-filter { padding: 20px; background: #fff; border: 1px solid #dce1d7; border-radius: 12px; margin-bottom: 20px; } +.route-facility-filter label { display: grid; gap: 8px; font-weight: 600; max-width: 420px; } +.route-facility-filter select,.osm-category select { font: inherit; padding: 10px; background: white; border: 1px solid #9cac9e; border-radius: 8px; min-width: 0; } +.route-facility-filter p { font-size: 12px; color: #647267; } .category-section { margin-bottom: 32px; } .category-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 16px; } .category-heading h2 { margin: 6px 0 0; font-size: 26px; } diff --git a/src/Fjellomrade.tsx b/src/Fjellomrade.tsx index e0f2f58..fc86591 100644 --- a/src/Fjellomrade.tsx +++ b/src/Fjellomrade.tsx @@ -224,7 +224,7 @@ function Overview({ areas, error, notFound, onOpen }: OverviewProps) { return (
+
+
{user.stats.completedCount}Gjennomførte turer
+
{(user.stats.totalLengthMeters / 1000).toLocaleString('nb-NO', { maximumFractionDigits: 1 })} kmRegistrert rutelengde
+
{user.stats.countyCount}Fylker rutene berører
+
+

Basert på turene du markerer som gjennomført. Lengden er registrert rutelengde, ikke målt distanse du har gått.

+
+

Ditt turmål

+
{ event.preventDefault(); const values = new FormData(event.currentTarget); void saveGoal(Number(values.get('target'))) }}> + + + + {goal && } +
+ {goal ?

{monthlyCompleted} av {goal.target} turer · {monthlyCompleted >= goal.target ? 'Målet er nådd!' : `${goal.target-monthlyCompleted} igjen`}

:

Sett et mål for måneden og følg fremdriften din.

} +

Turer teller når de er markert gjennomført og har en dato i turdagboken i denne måneden. Hver rute telles én gang.

+
+ {selected?.id &&

{selected.name}

+ + + +
} + {message &&

{message}

} +
+

Dine merker

{badges.filter(item => item.unlocked).length} av {badges.length} oppnådd
+
    {badges.map(item =>
  • + +
    {item.title}{item.unlocked ? 'Oppnådd' : 'Ikke oppnådd ennå'}

    {item.description}

    +
  • )}
+

Merkene følger din nåværende turhistorikk. Endrer eller fjerner du turstatus, notater eller mål, oppdateres merkene også.

+
+ + {tab === 'settings' &&

Profilinnstillinger

{user.avatar && } @@ -193,10 +304,37 @@ export default function Profile({ selected, onSelectRoute, disabled, onLogout }:
- - {selected?.id && } - {message &&

{message}

} -

Favoritter ({user.favorites.length})

- {user.favorites.length === 0 ?

Du har ikke lagret noen ruter ennå.

:
    {user.favorites.map(item =>
  • )}
} +

Endre passord

+

Når passordet endres, logges du ut på alle enheter. Logg inn igjen med det nye passordet.

+
+ + + + + {passwordMessage &&

{passwordMessage}

} +
+
} + {tab === 'custom' &&
} + {tab !== 'settings' && tab !== 'custom' &&
+

{tab === 'favorites' ? 'Favorittene dine' : tab === 'planned' ? 'Neste turer' : 'Turene du har gjennomført'}

+ {(tab === 'favorites' ? user.favorites : user.trips.filter(t => t.status === (tab === 'planned' ? 'PLANNED' : 'COMPLETED'))).length === 0 ?
{tab === 'favorites' ? 'Finn en tur du vil ta vare på' : tab === 'planned' ? 'Hvor går neste tur?' : 'Din turhistorikk starter her'}

{tab === 'favorites' ? 'Åpne en rute og lagre den som favoritt.' : tab === 'planned' ? 'Velg en favoritt eller søk etter en rute og trykk Planlegg tur.' : 'Marker en rute som gjennomført etter turen.'}

: +
    {(tab === 'favorites' ? user.favorites : user.trips.filter(t => t.status === (tab === 'planned' ? 'PLANNED' : 'COMPLETED'))).map(item =>
  • + +
    + {tab !== 'completed' && } + {tab !== 'planned' && } + {tab !== 'favorites' && } +
    + {tab === 'completed' && isUserTrip(item) &&
    Turdagbok{item.completedOn ? ` · ${new Date(`${item.completedOn}T12:00:00`).toLocaleDateString('nb-NO')}` : ''}{item.rating ? ` · ${item.rating}/5` : ''} +
    { event.preventDefault(); void saveDiary(item.id, event.currentTarget) }}> + + +