Skip to content
Merged
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
93 changes: 92 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,92 @@
# funded
# funded

A simple **S**ingle **P**age **A**pp for getting funded. Built on
[Next.js](https://nextjs.org), styled with [Tailwindcss](https://tailwindcss.com), and deployed on [Vercel](https://vercel.com).

Development and support provided by [Tincre](https://tincre.com).

## Development Setup

Clone this repo. Then,

```bash
yarn install
yarn run dev
```

:rocket: The dev site will be running locally at `localhost:3000`. :rocket:
### Tests
Tests leverage `jest` and Kent Dodd's `react testing library`. These can
be found under the `__tests__` directory and run with `yarn run test`.

### Database Infrastructure

We generally use PostgreSQL databases via ORMs at Tincre.

In particular, this site uses [Prisma](https://prisma.io) for its database
ORM.

#### Updating tables

To update tables we use Prisma's tools.

> _Prisma has fantastic documentation https://www.prisma.io/docs/!_

Simply add a table via the schema classes in the `prisma/schema.prisma` file.

Once finished run `npx prisma format && npx migrate dev` to format/lint the
prisma schema and migrate it. Your client will update locally.

Commit the changes from `prisma/schema.prisma` and the migration file. :boom:

> _:warning: Production sites will need to be built twice on CI/CD infra, depending on your setup :warning:._

#### CRUD Operations

Database functionality lives in the `/lib` directory. In particular, a global
import `prisma` is in `prisma.js`.

This contains a `prisma` object you should use via a default export.

##### `db.js` or `db.ts`
CRUD operations are in the `lib/db*{.js|.ts}`. In particular, examples
of creating, reading, updating, and deleting table objects are extant within
this file.

> :notebook: Inline documentation is critical and provided using @jsdoc!

### Authentication Infrastructure
Auth is provided out of the box by [next-auth](https://next-auth.js.org). In particular,
we use the [Prisma Adapter](https://next-auth.js.org/adapters/prisma)
throughout this project.

#### Client-side Page Authentication

We add authentication to individual pages via the client, which checks for the auth session, validating it via the /api/auth/session backend api function via the `useSession` hook. This hook is populated by the `SessionProvider` Higher Order Component within the custom `_app.jsx` Next App overload.

After successful authentication, the redirected user will have a state session stored
in their browser.

#### Server-side Page Authentication

For server-side rendering and other protected api endpoints, such as /api/<protected>, we check for the session to confirm that the header provides the appropriate authentication.

This is handled seamlessly within via the next-auth library.


#### Providers

Right now we offer the following authentication providers:

- direct-to-email link,
- Google Accounts,
- Github,
- Gitlab,
- Microsoft,
- and Twitter.

We can and will add more at a later date.

## Contributions

We :heart: community contributions.
68 changes: 68 additions & 0 deletions lib/db.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Prisma } from "@prisma/client";
import prisma from "./prisma";

/**
* Create a new user session in the database
*
* @param sessionId the sessionId of the to-be created userSession; must be
unique
* @param email the email of the to-be created user; may be undefined
*
* @returns boolean true if the user session was created, false otherwise
*/
export async function createUserSession(sessionId, email) {
try {
await prisma.userSession.create({
data: { email: email || null, sessionId: sessionId },
});
return true;
} catch (error) {
console.log(`Error in createUserSession ${error?.message}`);
// https://www.prisma.io/docs/concepts/components/prisma-client/handling-exceptions-and-errors/
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
console.log(
`There is a unique constraint violation for ${error.message} and the user cannot be created.`
);
}
return false;
} else {
throw error;
}
}
}

/**
* Query a user session via a `sessionId`
* @param sessionId A sessionId of a user session in the database.
* @returns User session or null if the sessionId is not found.
*/
export async function queryUserBySessionId(sessionId) {
console.log(`Running queryUserBySessionId for ${sessionId}`);
const res = await prisma.userSession.findUnique({
where: { sessionId: sessionId },
});
// TODO Remove logs
console.log(JSON.stringify(res));
return res;
}

/**
* Update a user session via its sessionId with the given data to update.
*
* @param sessionId the string sessionId of the user session
* @param data an object with keys present in the `UserSession` schema
* @returns boolean true if updated successfully, false otherwise.
*/
export async function updateUserSession(sessionId, data) {
try {
await prisma.userSession.update({
where: { sessionId: sessionId },
data: data,
});
return true;
} catch (error) {
console.error(`Error in updateUserSession ${error.message}`);
return false;
}
}
53 changes: 53 additions & 0 deletions lib/node-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import jwt from "jsonwebtoken";
/**
* Encode (and sign) a given object as JWT.
*
* @param data object to encode in base64 and sign via HMAC and the given
* passphrase
* @param passphrase string secret passphrase for producing the HMAC hash of the
* data
* @returns string JWT token
*/
function encodeJwt(data, passphrase) {
const token = jwt.sign(data, passphrase);
return token;
}
/**
* Log an api func error given the route string and error object.
*
* @param route string route e.g. /api/docs
* @param error Error error object, hopefully including a message to be printed
*
* @returns string error message appended with route information
*/
function logApiError(route, error) {
const msg = `Error in route ${route}, not raising! ${error?.message}`;
console.error(msg);
return msg;
}
/*
* Return a string content security policy for this site given a nonce.
*
* @param isProd boolean set to true if production
*
* @returns string Content Security Policy
*/
function getCsp(isProd) {
let imgSrc = "res.cloudinary.com *.tincre.com cdn.sanity.io";
let csp = ``;
csp += `base-uri 'self';`;
csp += `form-action 'self' https://discord.gg https://discord.com/channels/865659205443518534`;
csp += `default-src 'self';`;
csp += `script-src 'self' ${isProd ? "" : "'unsafe-eval'"} *.tincre.com;`; // NextJS requires 'unsafe-eval'
// in dev (faster source maps)
csp += `style-src 'self' https://fonts.googleapis.com 'unsafe-inline' data:;`; // NextJS requires 'unsafe-inline'
csp += `img-src 'self' ${imgSrc} data: 'unsafe-inline' blob:;`;
csp += `font-src 'self' https://fonts.gstatic.com;`; // TODO
csp += `frame-src *;`; // TODO
csp += `media-src *;`; // TODO
csp += `object-src 'self';`;
csp += `connect-src 'self' https://vitals.vercel-insights.com`;
return csp;
}

export { getCsp, logApiError, encodeJwt };
17 changes: 17 additions & 0 deletions lib/prisma.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// https://github.com/prisma/deployment-example-vercel/blob/903ad88ffef160b5ffce18ba1344df41cc39cfa0/lib/prisma.js
import { PrismaClient } from "@prisma/client";

// Avoid instantiating too many instances of Prisma in development
// https://www.prisma.io/docs/support/help-articles/nextjs-prisma-client-dev-practices#problem
let prisma;

if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
prisma = global.prisma;
}

export default prisma;
38 changes: 38 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/* eslint-disable no-unused-vars */

// import { useRef, useState, useEffect, MutableRefObject } from 'react';
import jwtDecode from "jwt-decode";

export function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
/**
* A fetcher for useSwr
*
* @param uri string web endpoint
* @param options object `fetch` options. See fetch documentation.
* @returns JSON response
*/
const fetcher = async (uri, options) => {
let fetchOptions = options;
if (typeof options !== "object") fetchOptions = {};
const response = await fetch(uri, fetchOptions); // TODO can we just send an undefined
// param and avoid the options nonsense?
return response.json();
};

/**
* Decode a JWT token client side
*
* @param token string jwt (base64) encoded token
* @returns decoded string token
*/
function clientJwtDecode(token) {
if (!token)
throw new Error(
"Token must be defined or not null. Do better next time, thanks."
);
return jwtDecode(token); // Returns with the JwtPayload type
}
// Fall back to default handling
export { clientJwtDecode, fetcher };
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"test": "jest --coverage"
},
"dependencies": {
"@prisma/client": "^3.8.1",
"next": "^12.0.9",
"react": "^17.0.2",
"react-dom": "^17.0.2"
Expand All @@ -18,7 +19,14 @@
"eslint": "8.8.0",
"eslint-config-next": "12.0.9",
"jest": "^27.4.7",
"jsonwebtoken": "^8.5.1",
"jwt-decode": "^3.1.2",
"localforage": "^1.10.0",
"nanoid": "^3.2.0",
"postcss": "^8.4.5",
"tailwindcss": "^3.0.18"
"prisma": "^3.8.1",
"swr": "^1.2.0",
"tailwindcss": "^3.0.18",
"validator": "^13.7.0"
}
}
89 changes: 89 additions & 0 deletions pages/api/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { nanoid } from "nanoid/async";
import isEmail from "validator/lib/isEmail";

import {
createUserSession,
queryUserBySessionId,
updateUserSession,
} from "../../lib/db";
import { encodeJwt, logApiError } from "../../lib/node-utils";
import prisma from "../../lib/prisma";

async function createId() {
const id = await nanoid();
return id;
}

export default async function handler(req, res) {
try {
const jwtKey = process.env.SIGNING_PASSPHRASE;
if (req.method === "GET") {
console.log("Running GET session request");
const data = req?.body;
const { sessionId } = data;
if (typeof sessionId !== "undefined") {
const sessionData = await queryUserBySessionId(sessionId);
console.log(`Session data ${JSON.stringify(sessionData)}`);
if (typeof sessionData !== "undefined") {
console.log(`Successfully retrieved ${sessionData.sessionId}`);
const encoded = encodeJwt(
{
sessionId: sessionData.sessionId,
},
jwtKey
);
res.status(200).json({ token: encoded });
}
res.status(200).json();
} else {
res.status(200).json();
}
}
if (req.method === "POST") {
const data = req?.body;
console.log(`POST: Data are ${JSON.stringify(data)}`);
const email = isEmail(data?.email || "") ? data.email : null;
const inputData = {
sessionId: await createId(),
email: email,
};
const isCreated = await createUserSession(
inputData.sessionId,
inputData.email
);
if (!isCreated)
throw new Error(
`DB User Session was not created for ${inputData.sessionId}`
);
const encoded = encodeJwt({ sessionId: inputData.sessionId }, jwtKey);
res.status(200).json({ token: encoded });
}
if (req.method === "PUT") {
const data = req?.body;
console.log(`PUT: Data are ${JSON.stringify(data)}`);
const email = isEmail(data?.email || "") ? data.email : null;
console.log(`Data are sessionId: ${data?.sessionId} email: ${email}`);
if (typeof data?.sessionId === "undefined" || !email) {
console.log(`Not found ${data?.sessionId} ${data?.email}`);
res.status(404).json();
}
const inputData = {
sessionId: data?.sessionId,
email: email,
};
const isUpdated = await updateUserSession(data?.sessionId, inputData);
if (!isUpdated)
throw new Error(
`DB User Session was not updated for ${data?.sessionId}`
);
console.log(`Encoded for update\n${JSON.stringify(inputData)}`);
const encoded = encodeJwt({ sessionId: data?.sessionId }, jwtKey);
res.status(200).json({ token: encoded });
}
} catch (error) {
logApiError("/api/session", error);
res.status(400).json(error);
} finally {
prisma.$disconnect;
}
}
Loading