Skip to content

Commit

Permalink
Rewrite everything for Cloudflare workers
Browse files Browse the repository at this point in the history
  • Loading branch information
bokub committed Jul 11, 2024
1 parent 8c81617 commit 9b11c94
Show file tree
Hide file tree
Showing 13 changed files with 658 additions and 137 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ node_modules
dist
.vercel
test.md
.wrangler
.dev.vars
96 changes: 58 additions & 38 deletions api/[type].ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import axios from 'axios';
import * as qs from 'qs';
import { VercelRequest, VercelResponse } from '@vercel/node';
import pino from 'pino';
import { z } from 'zod';
import { getAPIToken } from '../lib/token';
import { dataPoints, dataURLs } from '../lib/url';
import { isTokenValid } from '../lib/auth';
import { Env } from '../lib/env';

const logger = pino();
const { BASE_URL } = process.env;

const schema = z.object({
type: z.enum(dataPoints),
Expand All @@ -17,53 +16,71 @@ const schema = z.object({
end: z.string().regex(/20[0-9]{2}-[0-9]{2}-[0-9]{2}/),
});

export default async (req: VercelRequest, res: VercelResponse) => {
export const onRequest: PagesFunction<Env> = async ({ request: req, params, env }) => {
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
return new Response(null, {
status: 200,
});
}
if (req.method !== 'GET') {
res.status(405).send({ status: 405, message: 'Seule la méthode GET est autorisée' });
return;
return Response.json({ status: 405, message: 'Seule la méthode GET est autorisée' }, { status: 405 });
}

// Validate query string
const input = schema.safeParse(req.query);
if (!input.success) {
res.status(400).send({
status: 400,
message: 'Paramètres invalides',
error: input.error,
});
return;
const { BASE_URL, JWT_SECRET } = env;
const { searchParams } = new URL(req.url);

// Validate input
const input = schema.safeParse({
type: params.type,
prm: searchParams.get('prm'),
start: searchParams.get('start'),
end: searchParams.get('end'),
});

if (input.success === false) {
return Response.json(
{
status: 400,
message: 'Paramètres invalides',
error: input.error,
},
{ status: 400 }
);
}

const { type, prm, start, end } = input.data;

// Validate user token
const userToken = req.headers.authorization?.split(' ')[1];
const authHeader = req.headers.get('Authorization');
const userToken = authHeader?.split(' ')[1];
if (!userToken) {
res.status(400).send({ status: 400, message: "Le header 'Authorization' est manquant ou invalide" });
return;
return Response.json(
{ status: 400, message: "Le header 'Authorization' est manquant ou invalide" },
{ status: 400 }
);
}
if (!isTokenValid(userToken, prm)) {
res.status(401).send({ status: 401, message: "Votre token est invalide ou ne permet pas d'accéder à ce PRM" });
return;
if (!(await isTokenValid(userToken, prm, JWT_SECRET))) {
return Response.json(
{ status: 401, message: "Votre token est invalide ou ne permet pas d'accéder à ce PRM" },
{ status: 401 }
);
}

// Get Enedis token
let apiToken: string;
try {
apiToken = await getAPIToken();
apiToken = await getAPIToken(env);
} catch (e) {
logger.error({ message: 'cannot refresh token', error: e });
res.status(500).send({ status: 500, message: 'Impossible de rafraîchir le token. Réessayez plus tard' });
return;
return Response.json(
{ status: 500, message: 'Impossible de rafraîchir le token. Réessayez plus tard' },
{ status: 500 }
);
}

// Fetch data
try {
const { data } = await axios.get(
const response = await fetch(
`${BASE_URL}/${dataURLs[type]}?${qs.stringify({
start,
end,
Expand All @@ -77,19 +94,22 @@ export default async (req: VercelRequest, res: VercelResponse) => {
},
}
);
res.send(data.meter_reading);
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status && e.response?.data) {
logger.error({ message: 'cannot retrieve data from Enedis', error: e.toJSON() });
res.status(e.response.status).send({
status: e.response.status,
message: 'The Enedis API returned an error',
error: e.response.data,
});
} else {
logger.error({ message: 'cannot call Enedis', error: e });

res.status(500).send({ status: 500, message: 'Erreur inconnue. Réessayez plus tard' });
if (!response.ok) {
return Response.json(
{
status: response.status,
message: 'The Enedis API returned an error',
error: await response.json(),
},
{ status: response.status }
);
}

const data: { meter_reading: any } = await response.json();
return Response.json(data.meter_reading);
} catch (e) {
logger.error({ message: 'cannot call Enedis', error: e });
return Response.json({ status: 500, message: 'Erreur inconnue. Réessayez plus tard' }, { status: 500 });
}
};
12 changes: 5 additions & 7 deletions api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { Env } from '../lib/env';

const { CLIENT_ID, SANDBOX } = process.env;

export default (req: VercelRequest, res: VercelResponse) => {
export const onRequest: PagesFunction<Env> = async ({ request: req, env }) => {
const state = 'v2_' + Array.from({ length: 8 }, () => Math.random().toString(36)[2]).join('');
const baseURI = SANDBOX ? 'https://ext.hml.api.enedis.fr' : 'https://mon-compte-particulier.enedis.fr';
const baseURI = env.SANDBOX ? 'https://ext.hml.api.enedis.fr' : 'https://mon-compte-particulier.enedis.fr';

return res.redirect(
return Response.redirect(
`${baseURI}/dataconnect/v1/oauth2/authorize` +
`?client_id=${CLIENT_ID}&state=${state}&duration=P3Y&response_type=code`
`?client_id=${env.CLIENT_ID}&state=${state}&duration=P3Y&response_type=code`
);
};
20 changes: 13 additions & 7 deletions api/callback.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { VercelRequest, VercelResponse } from '@vercel/node';
import { generateToken } from '../lib/auth';
import { Env } from '../lib/env';
import { z } from 'zod';
import pino from 'pino';
const logger = pino();

export default async (req: VercelRequest, res: VercelResponse) => {
export const onRequest: PagesFunction<Env> = async ({ request: req, env }) => {
try {
const queryPRMs = z.string().parse(req.query.usage_point_id).split(/[,;]/g);
const { searchParams } = new URL(req.url);
const queryPRMs = z.string().parse(searchParams.get('usage_point_id')).split(/[,;]/g);

const authToken = generateToken(queryPRMs);
res.setHeader('Set-Cookie', `conso-token=${authToken}; SameSite=Strict; Path=/`);
res.redirect('/token');
const authToken = await generateToken(queryPRMs, env.JWT_SECRET);
return new Response('/token', {
status: 302,
headers: {
Location: '/token',
'Set-Cookie': `conso-token=${authToken}; SameSite=Strict; Path=/`,
},
});
} catch (e) {
logger.error({ message: 'cannot generate Auth token', error: e });
res.status(500).send({ status: 500, message: 'internal server error' });
return Response.json({ status: 500, message: 'internal server error' }, { status: 500 });
}
};
9 changes: 9 additions & 0 deletions api/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"lib": ["esnext"],
"types": ["@cloudflare/workers-types"],
"moduleResolution": "node"
}
}
19 changes: 11 additions & 8 deletions lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import jwt from 'jsonwebtoken';
import * as jose from 'jose';

const { JWT_SECRET } = process.env as { JWT_SECRET: string };
const alg = 'HS256';

export function generateToken(usagePointIds: string[]) {
return jwt.sign({ sub: usagePointIds, exp: Math.floor(Date.now() / 1000) + 3 * 364 * 24 * 3600 }, JWT_SECRET, {
algorithm: 'HS256',
});
export async function generateToken(usagePointIds: string[], secret: string) {
return await new jose.SignJWT({})
.setProtectedHeader({ alg })
.setIssuedAt()
.setExpirationTime(`${364 + 365 + 365}d`)
.setSubject(usagePointIds as any)
.sign(new TextEncoder().encode(secret));
}

export function isTokenValid(token: string, usagePointId: string): boolean {
export async function isTokenValid(token: string, usagePointId: string, secret: string): Promise<boolean> {
try {
const decoded = jwt.verify(token, JWT_SECRET);
const { payload: decoded } = await jose.jwtVerify(token, new TextEncoder().encode(secret));
return Array.isArray(decoded.sub) && decoded.sub.includes(usagePointId);
} catch (err) {
return false;
Expand Down
10 changes: 10 additions & 0 deletions lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { KVNamespace } from '@cloudflare/workers-types';

export interface Env {
BASE_URL: string;
JWT_SECRET: string;
CLIENT_ID: string;
CLIENT_SECRET: string;
SANDBOX: string | undefined;
CONSO_API: KVNamespace;
}
45 changes: 27 additions & 18 deletions lib/token.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
import axios from 'axios';
import * as qs from 'qs';
import pino from 'pino';
import { Env } from './env';

const logger = pino();
const { BASE_URL, CLIENT_ID, CLIENT_SECRET } = process.env;

let token: string;
let tokenExpiration: number;
const KV_KEY = 'access_token';

export async function getAPIToken(): Promise<string> {
if (token && new Date().getTime() < tokenExpiration) {
export async function getAPIToken(env: Env): Promise<string> {
const token = await env.CONSO_API.get(KV_KEY);
if (token) {
return token;
}

const { data } = await axios({
method: 'post',
url: `${BASE_URL}/oauth2/v3/token?${qs.stringify({
const response = await fetch(
`${env.BASE_URL}/oauth2/v3/token?${qs.stringify({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
client_id: env.CLIENT_ID,
client_secret: env.CLIENT_SECRET,
})}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);

if (!response.ok) {
return Promise.reject(response);
}

const data: { access_token: string } = await response.json();
logger.info({ message: 'token refreshed', token: data.access_token });

token = data.access_token;
tokenExpiration = new Date().getTime() + 3 * 3600 * 1000; // token expiration is 3 hours later
// Save in KV store
await env.CONSO_API.put(KV_KEY, data.access_token, {
expirationTtl: 3 * 3600, // token expiration is 3 hours later
});

return token;
return data.access_token;
}
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,36 +1,39 @@
{
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"serve": "wrangler pages dev dist --kv=CONSO_API",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"prepare": "husky install",
"ipban": "zx ./.github/scripts/ipban.mjs"
},
"prettier": "@bokub/prettier-config",
"packageManager": "[email protected]",
"devDependencies": {
"@bokub/prettier-config": "^2.1.0",
"@cloudflare/workers-types": "^4.20240620.0",
"@nuxt-themes/elements": "^0.5.2",
"@nuxt-themes/typography": "^0.4.3",
"@nuxt/content": "^2.3.0",
"@nuxtjs/tailwindcss": "^6.2.0",
"@types/canvas-confetti": "^1.6.0",
"@types/jsonwebtoken": "^9.0.1",
"@types/qs": "^6.9.7",
"@vercel/node": "^2.8.15",
"axios": "^1.3.6",
"canvas-confetti": "^1.6.0",
"chart.js": "^4.2.1",
"husky": "^8.0.0",
"jsonwebtoken": "^9.0.0",
"jose": "^5.6.3",
"nuxt": "^3.4.0",
"nuxt-umami": "^2.5.1",
"pino": "^8.8.0",
"prettier": "^2.8.3",
"pretty-quick": "^3.1.3",
"qs": "^6.11.0",
"typescript": "^5.5.3",
"wrangler": "^3.63.2",
"zod": "^3.20.2",
"zx": "^7.2.3"
}
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"exclude": ["api/**/*"],
"extends": "./.nuxt/tsconfig.json"
}
5 changes: 0 additions & 5 deletions vercel.json

This file was deleted.

1 change: 1 addition & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
compatibility_date = "2024-07-01"
Loading

0 comments on commit 9b11c94

Please sign in to comment.