Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,41 +1,65 @@
# ========== Build Web App ==========
FROM node:20-alpine AS web

USER node
WORKDIR /home/node

# Copy root package files
COPY package*.json .npmrc ./
COPY --chown=node web/package.json web/

# Copy only web package.json
COPY web/package.json web/

# Install dependencies for web
RUN npm \
--no-fund \
--no-update-notifier \
--workspace web \
ci

COPY --chown=node web/ web/
# Copy web source
COPY web/ web/

# Build web app
RUN npm --workspace web run build

# ========== Build API ==========
FROM node:20-alpine

# Install tini
RUN apk add --no-cache tini

USER node
WORKDIR /home/node


# Copy root package files
COPY package*.json .npmrc ./

# Copy API package.json
COPY api/package.json api/

# Create node_modules folder with right permissions
RUN mkdir -p /home/node/api/node_modules

# Set permissions while root user has access
RUN chown -R node:node /home/node/api

# Switch to node user after chown
USER node

# Install API dependencies
RUN npm \
--no-fund \
--no-update-notifier \
--omit dev \
--workspace api \
ci

# Copy runtime files
COPY --chown=node bin/start.sh .
COPY --chown=node api/ api/
COPY --from=web /home/node/api/static api/static/

# Final settings
EXPOSE 80
ENV PORT=80

ENTRYPOINT [ "./start.sh" ]
ENTRYPOINT ["./start.sh"]
12 changes: 9 additions & 3 deletions api/api.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { Router } from "express";
import dotenv from "dotenv";
import express from "express";
const { Router } = express;

import authRouter from "./auth/authController.js";
import db from "./db.js";
import { lookupEmail } from "./functions/lookupEmail.js";
import { processImportFiles } from "./functions/processImportFiles.js";
import { updateDbUsers } from "./functions/updateDbUsers.js";
import { updateUsersActivity } from "./functions/updateUsersActivity.js";
import messageRouter from "./messages/messageRouter.js";
import messageRouter from "./message/messageRouter.js";
import { processUpload } from "./middlewares/processUpload.js";
import { zipExtractor } from "./middlewares/zipExtractor.js";
import logger from "./utils/logger.js";
import { sudo } from "./utils/middleware.js";

dotenv.config();
const api = Router();

api.use(sudo);
api.use("/auth", authRouter);
api.use("/message", messageRouter);

api.post("/subscribe", async (req, res) => {
Expand Down
6 changes: 5 additions & 1 deletion api/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import app from "./app.js";
describe("/api", () => {
describe("GET /message", () => {
it("returns a message", async () => {
await request(app).get("/api/message").expect(200, "Hello, world!");
await request(app)
.get("/api/message")
.expect(200, "Hello, world!")
//add a timeout to the test
.timeout(10000);
});
});
});
21 changes: 20 additions & 1 deletion api/app.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import bodyParser from "body-parser";
import cors from "cors";
import express from "express";
import session from "express-session";
import passport from "passport";

import apiRouter from "./api.js";
import { getSessionStore } from "./db.js";
import db from "./db.js";
import config from "./utils/config.cjs";
import {
Expand All @@ -13,7 +18,16 @@ import {
} from "./utils/middleware.js";

const apiRoot = "/api";
const sessionConfig = {
cookie: {},
resave: false,
saveUninitialized: true,
secret: config.sessionSecret,
store: getSessionStore(),
};

const app = express();
app.use(cors());

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
Expand All @@ -22,17 +36,22 @@ app.use(configuredMorgan());

if (config.production) {
app.enable("trust proxy");
sessionConfig.cookie.secure = true;
app.use(httpsOnly());
}

app.use(session(sessionConfig));
app.use(passport.authenticate("session"));

app.get(
"/healthz",
"/health",
asyncHandler(async (_, res) => {
await db.query("SELECT 1;");
res.sendStatus(200);
}),
);

app.use(bodyParser.json());
app.use(apiRoot, apiRouter);

app.use(clientRouter(apiRoot));
Expand Down
2 changes: 1 addition & 1 deletion api/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import app from "./app.js";

describe("base API endpoints", () => {
it("exposes a health endpoint", async () => {
await request(app).get("/healthz").expect(200);
await request(app).get("/health").expect(200);
});
});
73 changes: 73 additions & 0 deletions api/auth/authController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Router } from "express";
import passport from "passport";
import { Strategy as GitHubStrategy } from "passport-github2";

import config from "../utils/config.cjs";
import { authOnly, methodNotAllowed } from "../utils/middleware.js";

import * as service from "./authService.js";

const router = Router();

passport.use(
"github",
new GitHubStrategy(
{ ...config.oauth },
async (accessToken, refreshToken, profile, done) => {
try {
const user = await service.logIn({
email: profile.emails?.[0]?.value,
gitHubId: profile.id,
name: profile.displayName ?? profile.username,
});
done(null, user);
} catch (err) {
done(err);
}
},
),
);

passport.deserializeUser(async (user, done) => {
try {
done(null, await service.deserialize(user));
} catch (err) {
done(err);
}
});

passport.serializeUser((user, done) => done(null, user.id));

router
.route("/callback")
.get(
passport.authenticate("github", {
successRedirect: "/dashboard",
failureRedirect: "/login",
}),
)
.all(methodNotAllowed);

router
.route("/login")
.get(passport.authenticate("github"))
.all(methodNotAllowed);

router
.route("/logout")
.post((req, res, next) => {
req.logout((err) => {
if (err) {
return next(err);
}
res.redirect("/");
});
})
.all(methodNotAllowed);

router
.route("/principal")
.get(authOnly, (req, res) => res.json(req.user))
.all(methodNotAllowed);

export default router;
13 changes: 13 additions & 0 deletions api/auth/authService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as usersService from "../users/usersService.js";

export async function deserialize(id) {
return await usersService.getById(id);
}

export async function logIn(profile) {
const existing = await usersService.findByGitHubId(profile.gitHubId);
if (existing) {
return existing;
}
return await usersService.create(profile);
}
73 changes: 73 additions & 0 deletions api/db.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import pgSession from "connect-pg-simple";
import session, { MemoryStore } from "express-session";
import pg from "pg";
import format from "pg-format";

import config from "./utils/config.cjs";
import logger from "./utils/logger.js";

/** @type {pg.Pool} */
Expand Down Expand Up @@ -28,3 +32,72 @@ export default {
return pool.query.apply(pool, args);
},
};

export const ErrorCodes = {
UNIQUE_VIOLATION: "23505",
};

export const getSessionStore = () => {
const store = config.sessionStore;
switch (store) {
case "memory":
return new MemoryStore();
case "postgres":
return new (pgSession(session))({ pool, tableName: "sessions" });
default:
throw new Error(`unknown store type: ${store}`);
}
};

/**
* Create an insert query for the given table name and columns.
* @param {string} tableName
* @param {string[]} columns
* @returns {string}
*/
export function insertQuery(tableName, columns) {
return format(
"INSERT INTO %I (%s) VALUES (%s) RETURNING *;",
tableName,
columns.map((column) => format.ident(column)).join(", "),
columns.map((_, index) => `$${index + 1}`).join(", "),
);
}

/**
* {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates Tagged template}
* to turn a mutliline query in backticks into a single line.
* @example
* const myQuery = singleLine`
* SELECT *
* FROM some_table
* WHERE some_field = $1;
* `;
* @param {string[]} strings
* @param {string[]} replacements
* @returns {string}
*/
export function singleLine(strings, ...replacements) {
return strings
.flatMap((string, index) => [string, replacements[index]])
.join(" ")
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "")
.join(" ");
}
/**
* Create an update query for the given table name and columns.
* @param {string} tableName
* @param {string[]} columns
* @returns {string}
*/
export function updateQuery(tableName, columns) {
return format(
"UPDATE %I SET %s WHERE id = $1 RETURNING *;",
tableName,
columns
.map((column, index) => `${format.ident(column)} = $${index + 2}`)
.join(", "),
);
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ import * as repository from "./messageRepository.js";

export async function getMessage() {
const [first] = await repository.getAll();
if (!first) {
throw new Error("No message found in the database.");
}
return first.content;
}
Loading
Loading