Skip to content

Commit 3b167ff

Browse files
Merge pull request #46 from sebastiankrll/feature/vatsim-auth
Feature/vatsim auth
2 parents c6e39a1 + 8eb8b6f commit 3b167ff

49 files changed

Lines changed: 1491 additions & 211 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"@sr24/typescript-config": "*",
1313
"@types/cors": "^2.8.19",
1414
"@types/express": "^5.0.5",
15+
"@types/jsonwebtoken": "^9.0.10",
1516
"@types/ws": "^8.18.1",
1617
"@types/xml2js": "^0.4.14",
1718
"tsup": "^8.5.0",
@@ -25,6 +26,7 @@
2526
"express": "^5.1.0",
2627
"express-rate-limit": "^8.2.1",
2728
"helmet": "^8.1.0",
29+
"jsonwebtoken": "^9.0.3",
2830
"xml2js": "^0.6.2"
2931
}
3032
}

apps/api/src/auth.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { NextFunction, Request, Response } from "express";
2+
import jwt from "jsonwebtoken";
3+
4+
interface JWTPayload {
5+
vatsim?: {
6+
cid: number;
7+
};
8+
sub?: string;
9+
}
10+
11+
export interface AuthRequest extends Request {
12+
user?: {
13+
cid: number;
14+
};
15+
}
16+
17+
export const authHandler = (req: AuthRequest, res: Response, next: NextFunction) => {
18+
const authHeader = req.headers.authorization;
19+
const token = authHeader?.split(" ")[1];
20+
21+
if (!token) {
22+
res.status(401).json({ error: "Authentication required" });
23+
return;
24+
}
25+
26+
const secret = process.env.NEXTAUTH_SECRET;
27+
if (!secret) {
28+
console.error("NEXTAUTH_SECRET is not set");
29+
res.status(500).json({ error: "Server configuration error" });
30+
return;
31+
}
32+
33+
try {
34+
const decoded = jwt.verify(token, secret) as JWTPayload;
35+
36+
if (!decoded.vatsim?.cid) {
37+
res.status(401).json({ error: "Invalid token" });
38+
return;
39+
}
40+
41+
req.user = {
42+
cid: decoded.vatsim.cid,
43+
};
44+
next();
45+
} catch (_err) {
46+
res.status(403).json({ error: "Invalid or expired token" });
47+
}
48+
};

apps/api/src/error.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type express from "express";
2+
3+
export const errorHandler =
4+
(fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<void> | Promise<any>) =>
5+
(req: express.Request, res: express.Response, next: express.NextFunction) => {
6+
Promise.resolve(fn(req, res, next)).catch(next);
7+
};

apps/api/src/index.ts

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import "dotenv/config";
2-
import { pgFindAirportFlights, pgHealthCheck, pgShutdown } from "@sr24/db/pg";
2+
import { pgFindAirportFlights, pgHealthCheck, pgShutdown, prisma } from "@sr24/db/pg";
33
import { rdsConnect, rdsGetMultiple, rdsGetRing, rdsGetSingle, rdsGetTimeSeries, rdsHealthCheck, rdsShutdown } from "@sr24/db/redis";
44
import cors from "cors";
55
import express from "express";
66
import rateLimit from "express-rate-limit";
77
import helmet from "helmet";
8+
import { type AuthRequest, authHandler } from "./auth.js";
9+
import { errorHandler } from "./error.js";
810
import { validateCallsign, validateICAO, validateNumber, validateString } from "./validation.js";
911
import { getMetar, getTaf } from "./weather.js";
1012

@@ -29,18 +31,10 @@ app.use(cors());
2931
app.use(express.json());
3032
app.use(limiter);
3133

32-
// Async error wrapper
33-
const asyncHandler =
34-
(fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<void> | Promise<any>) =>
35-
(req: express.Request, res: express.Response, next: express.NextFunction) => {
36-
// console.log(`Incoming request: ${req.method} ${req.originalUrl}`);
37-
Promise.resolve(fn(req, res, next)).catch(next);
38-
};
39-
4034
// Health check endpoints
4135
app.get(
4236
"/health",
43-
asyncHandler(async (_req, res) => {
37+
errorHandler(async (_req, res) => {
4438
const startTime = Date.now();
4539
const health = {
4640
status: "ok",
@@ -82,7 +76,7 @@ app.get(
8276

8377
app.get(
8478
"/health/live",
85-
asyncHandler(async (_req, res) => {
79+
errorHandler(async (_req, res) => {
8680
res.json({
8781
status: "alive",
8882
timestamp: new Date().toISOString(),
@@ -92,7 +86,7 @@ app.get(
9286

9387
app.get(
9488
"/health/ready",
95-
asyncHandler(async (_req, res) => {
89+
errorHandler(async (_req, res) => {
9690
try {
9791
const redisHealthy = await rdsHealthCheck();
9892
const pgHealthy = await pgHealthCheck();
@@ -126,7 +120,7 @@ app.get(
126120

127121
app.get(
128122
"/data/init",
129-
asyncHandler(async (_req, res) => {
123+
errorHandler(async (_req, res) => {
130124
const all = await rdsGetSingle("ws:all");
131125
if (!all) {
132126
res.status(404).json({ error: "Initial data not found" });
@@ -142,7 +136,7 @@ app.get(
142136

143137
app.get(
144138
"/data/pilot/:id",
145-
asyncHandler(async (req, res) => {
139+
errorHandler(async (req, res) => {
146140
const id = validateString(req.params.id, "Pilot ID", 1, 10);
147141

148142
const pilot = await rdsGetSingle(`pilot:${id}`);
@@ -157,7 +151,7 @@ app.get(
157151

158152
app.get(
159153
"/data/airport/:icao",
160-
asyncHandler(async (req, res) => {
154+
errorHandler(async (req, res) => {
161155
const icao = validateICAO(req.params.icao).toUpperCase();
162156

163157
const airport = await rdsGetSingle(`airport:${icao}`);
@@ -172,7 +166,7 @@ app.get(
172166

173167
app.get(
174168
"/data/weather/:icao",
175-
asyncHandler(async (req, res) => {
169+
errorHandler(async (req, res) => {
176170
const icao = validateICAO(req.params.icao).toUpperCase();
177171
const metar = getMetar(icao);
178172
const taf = getTaf(icao);
@@ -183,7 +177,7 @@ app.get(
183177

184178
app.get(
185179
"/data/controllers/:callsigns",
186-
asyncHandler(async (req, res) => {
180+
errorHandler(async (req, res) => {
187181
const callsignArray = req.params.callsigns.split(",").map((cs) => validateCallsign(cs.trim()));
188182

189183
if (callsignArray.length === 0) {
@@ -204,7 +198,7 @@ app.get(
204198

205199
app.get(
206200
"/data/track/:id",
207-
asyncHandler(async (req, res) => {
201+
errorHandler(async (req, res) => {
208202
const id = validateString(req.params.id, "Track ID", 1, 10);
209203

210204
// const trackPoints = await pgGetTrackPointsByid(id);
@@ -220,7 +214,7 @@ app.get(
220214

221215
app.get(
222216
"/data/aircraft/:reg",
223-
asyncHandler(async (req, res) => {
217+
errorHandler(async (req, res) => {
224218
const reg = validateString(req.params.reg, "Aircraft Registration", 1, 10).toUpperCase();
225219

226220
const aircraft = await rdsGetSingle(`static_fleet:${reg}`);
@@ -235,7 +229,7 @@ app.get(
235229

236230
app.get(
237231
"/data/dashboard/",
238-
asyncHandler(async (_req, res) => {
232+
errorHandler(async (_req, res) => {
239233
const stats = await rdsGetSingle(`dashboard:stats`);
240234
const history = await rdsGetRing(`dashboard:history`, 24 * 60 * 60 * 1000);
241235
const events = await rdsGetSingle(`dashboard:events`);
@@ -251,7 +245,7 @@ app.get(
251245

252246
app.get(
253247
"/data/airport/:icao/flights",
254-
asyncHandler(async (req, res) => {
248+
errorHandler(async (req, res) => {
255249
const icao = validateICAO(req.params.icao).toUpperCase();
256250
const direction = (String(req.query.direction || "dep").toLowerCase() === "arr" ? "arr" : "dep") as "dep" | "arr";
257251
const limit = validateNumber(req.query.limit || 20, "Limit", 1, 30);
@@ -263,6 +257,48 @@ app.get(
263257
}),
264258
);
265259

260+
app.get(
261+
"/user/settings",
262+
authHandler,
263+
errorHandler(async (req: AuthRequest, res) => {
264+
const cid = BigInt(req.user?.cid || 0);
265+
266+
const user = await prisma.user.findUnique({
267+
where: { cid },
268+
select: { settings: true },
269+
});
270+
271+
if (!user) {
272+
res.status(404).json({ error: "User not found" });
273+
return;
274+
}
275+
276+
res.json({ settings: user.settings || {} });
277+
}),
278+
);
279+
280+
app.post(
281+
"/user/settings",
282+
authHandler,
283+
errorHandler(async (req: AuthRequest, res) => {
284+
const cid = BigInt(req.user?.cid || 0);
285+
const settings = req.body;
286+
287+
if (!settings || typeof settings !== "object") {
288+
res.status(400).json({ error: "Invalid settings data" });
289+
return;
290+
}
291+
292+
const user = await prisma.user.upsert({
293+
where: { cid },
294+
update: { settings },
295+
create: { cid, settings },
296+
});
297+
298+
res.json({ settings: user.settings });
299+
}),
300+
);
301+
266302
app.use((_req, res) => {
267303
res.status(404).json({ error: "Endpoint not found" });
268304
});

apps/web/Dockerfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,21 @@ COPY --from=prepare /app/out/full/ .
2121
ARG NEXT_PUBLIC_API_URL
2222
ARG NEXT_PUBLIC_WEBSOCKET_URL
2323
ARG NEXT_PUBLIC_R2_BUCKET_URL
24+
ARG API_URL
25+
ARG NEXTAUTH_SECRET
26+
ARG NEXTAUTH_URL
27+
ARG VATSIM_CLIENT_ID
28+
ARG VATSIM_CLIENT_SECRET
29+
ARG VATSIM_AUTH_URL
2430
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
2531
ENV NEXT_PUBLIC_WEBSOCKET_URL=$NEXT_PUBLIC_WEBSOCKET_URL
2632
ENV NEXT_PUBLIC_R2_BUCKET_URL=$NEXT_PUBLIC_R2_BUCKET_URL
33+
ENV API_URL=$API_URL
34+
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
35+
ENV NEXTAUTH_URL=$NEXTAUTH_URL
36+
ENV VATSIM_CLIENT_ID=$VATSIM_CLIENT_ID
37+
ENV VATSIM_CLIENT_SECRET=$VATSIM_CLIENT_SECRET
38+
ENV VATSIM_AUTH_URL=$VATSIM_AUTH_URL
2739

2840
RUN npm run build
2941

File renamed without changes.
File renamed without changes.
File renamed without changes.

apps/web/app/(map)/layout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import OMap from "@/components/Map/Map";
2+
3+
export default function MapLayout({ children }: { children: React.ReactNode }) {
4+
return <OMap>{children}</OMap>;
5+
}

0 commit comments

Comments
 (0)