at main 6.0 kB view raw
1import express, { NextFunction, Response } from "express"; 2import * as OpenApiValidator from "express-openapi-validator"; 3import { json } from "body-parser"; 4import { Database } from "sqlite-async"; 5import { compare } from "bcryptjs"; 6import cors from "cors"; 7import z, { ZodError } from "zod"; 8import { 9 CORS_ORIGIN, 10 JWT_REFRESH_SECRET, 11 JWT_SECRET, 12 TOKEN_EXPIRY_MINUTES, 13} from "./config"; 14import { 15 Account, 16 LoginRequest, 17 TokenPair, 18 User as UserResponse, 19} from "../generated"; 20import { readFileSync } from "fs"; 21import { generateRefreshToken, generateToken, verifyToken } from "./auth"; 22import { User, Request, TransactionsQuerySchema, LoginSchema } from "./schema"; 23import { HttpError } from "express-openapi-validator/dist/framework/types"; 24import { AccountService } from "./services/accounts.service"; 25import { TransactionService } from "./services/transactions.service"; 26 27const authenticateToken = async ( 28 req: Request, 29 res: Response, 30 next: NextFunction, 31) => { 32 const authHeader = req.headers["authorization"]; 33 const token = (authHeader ?? "").split(" ")[1]; 34 35 if (!token) { 36 return res 37 .status(401) 38 .json({ message: "Missing authorization token" }) 39 .send(); 40 } 41 42 try { 43 req.user = await verifyToken(token, JWT_SECRET); 44 next(); 45 } catch (err) { 46 return res.status(403).json({ message: "Invalid token" }).send(); 47 } 48}; 49 50interface AppConfig { 51 db: Database; 52 specPath?: string; 53} 54 55export const build = ({ db, specPath = "./src/openapi.yaml" }: AppConfig) => { 56 const app = express(); 57 58 const accountService = new AccountService(db); 59 const transactionService = new TransactionService(db); 60 61 app.use(json()); 62 app.use(cors<Request>({ origin: CORS_ORIGIN })); 63 64 app.use( 65 OpenApiValidator.middleware({ 66 apiSpec: specPath, 67 validateRequests: false, 68 validateResponses: true, 69 }), 70 ); 71 72 app.get("/", (req: Request, res: Response) => { 73 res.type("text/html"); 74 res.send(readFileSync("./src/swagger.html").toString()); 75 }); 76 77 app.get("/openapi.yaml", (req: Request, res: Response) => { 78 res.type("application/yaml"); 79 res.send(readFileSync(specPath).toString()); 80 }); 81 82 app.post("/login", async ({ body }: Request<LoginRequest>, res: Response) => { 83 const { username, password } = LoginSchema.parse(body); 84 85 const user = await db.get<User>("SELECT * FROM users WHERE username = ?", [ 86 username, 87 ]); 88 89 if (!user || !(await compare(password, user.password))) { 90 return res.status(401).json({ errors: ["Invalid credentials"] }); 91 } 92 93 const now = new Date(); 94 95 const expires = new Date(now.getTime() + TOKEN_EXPIRY_MINUTES * 60 * 1000); 96 97 const response: TokenPair = { 98 expires: expires.toISOString(), 99 accessToken: generateToken(user), 100 refreshToken: generateRefreshToken(user), 101 }; 102 103 res.json(response).status(200); 104 }); 105 106 app.post("/refresh-token", async ({ body }: Request, res: Response) => { 107 const { refreshToken } = body; 108 109 if (!refreshToken) { 110 return res.status(401).json({ message: "Missing refresh token" }); 111 } 112 113 try { 114 const user = await verifyToken(refreshToken, JWT_REFRESH_SECRET); 115 116 const now = new Date(); 117 const expires = new Date( 118 now.getTime() + TOKEN_EXPIRY_MINUTES * 60 * 1000, 119 ); 120 121 const response: TokenPair = { 122 expires: expires.toISOString(), 123 accessToken: generateToken(user), 124 refreshToken: generateRefreshToken(user), 125 }; 126 127 res.json(response); 128 } catch (err) { 129 return res 130 .status(401) 131 .json({ message: `Invalid refresh token: ${err.message}` }) 132 .send(); 133 } 134 }); 135 136 // Protected endpoints 137 app.get("/me", authenticateToken, async (req: Request, res: Response) => { 138 const user = await db.get<User>("SELECT * FROM users WHERE id = ?", [ 139 req.user.id, 140 ]); 141 142 const response: UserResponse = { 143 id: user.id, 144 username: user.username, 145 fullname: user.fullname, 146 created: new Date(user.created).toISOString(), 147 }; 148 149 res?.json(response); 150 }); 151 152 app.get( 153 "/accounts/:id", 154 authenticateToken, 155 async ( 156 { user, params: { id } }: Request<{ id: number }>, 157 res: Response, 158 ) => { 159 const account: Account = await accountService.getAccountById(user, id); 160 161 if (!account) { 162 return res 163 .status(404) 164 .json({ message: `Account with id ${id} not found` }) 165 .send(); 166 } 167 168 res.json(account); 169 }, 170 ); 171 172 app.get( 173 "/accounts", 174 authenticateToken, 175 async ({ user }: Request, res: Response) => { 176 res.json(await accountService.getAccountsForUser(user)); 177 }, 178 ); 179 180 app.get( 181 "/cards", 182 authenticateToken, 183 async ({ user }: Request, res: Response) => { 184 const rows = await db.all("SELECT * FROM cards WHERE user_id = ?", [ 185 user.id, 186 ]); 187 188 return res.json(rows ?? []); 189 }, 190 ); 191 192 app.get( 193 "/transaction-types", 194 authenticateToken, 195 async ({ query, user }: Request, res: Response) => { 196 const { accountId } = TransactionsQuerySchema.parse(query); 197 198 return res.json( 199 await transactionService.getTransactionTypes(user.id, accountId), 200 ); 201 }, 202 ); 203 204 app.get( 205 "/transactions", 206 authenticateToken, 207 async ({ query, user: { id: userId } }: Request, res: Response) => { 208 const queryParams = TransactionsQuerySchema.parse(query); 209 210 const response = await transactionService.paginatedTransactions( 211 userId, 212 queryParams, 213 ); 214 215 res.json(response); 216 }, 217 ); 218 219 app.use((err: Error, req: Request, res: Response, next: NextFunction) => { 220 if (err instanceof ZodError) { 221 res.status(400).json(z.treeifyError(err)); 222 return; 223 } 224 225 if (err instanceof HttpError) { 226 res.status(err.status).json({ message: err.message }).send(); 227 return; 228 } 229 230 res 231 .status(500) 232 .json({ error: `Internal server error: ${err.message}` }) 233 .send(); 234 }); 235 236 return app; 237};