Merge pull request #31 from fedyna-k/PMPR-29

Pmpr 29
This commit is contained in:
Kevin FEDYNA
2025-01-22 14:51:35 +01:00
committed by GitHub
17 changed files with 355 additions and 173 deletions
+9 -1
View File
@@ -1,6 +1,14 @@
import { Database } from "@db/sqlite";
export default async function ensureDatabases() {
/**
* Ensure database file creation on new server start.
*
* Read all SQL files in init directory and create
* associated SQLite database file.
*
* **Must not be used out of statup use-case.**
*/
export default async function ensureDatabases(): Promise<void> {
await Deno.mkdir("databases/data", { recursive: true });
for await (const file of Deno.readDir("databases/init")) {
+16
View File
@@ -1,4 +1,17 @@
import { type RegularTagNode, type TextNode } from "@melvdouc/xml-parser";
import { AsyncRoute } from "$fresh/src/server/types.ts";
interface AuthenticatedState {
isAuthenticated: true;
session: CasContent;
}
interface UnauthenticatedState {
isAuthenticated: false;
session: undefined;
}
export type State = AuthenticatedState | UnauthenticatedState;
export interface AppProperties {
name: string;
@@ -48,3 +61,6 @@ export interface LoginJWT {
}
export type EmptyObject = Record<string | number | symbol, never>;
// deno-lint-ignore no-explicit-any
export type Route = AsyncRoute<any, State>;
+14 -4
View File
@@ -1,12 +1,22 @@
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
import { Route, State } from "$root/defaults/interfaces.ts";
import { ComponentChildren } from "preact";
export default function makeIndex(basePath: string) {
/**
* Generates index file based on `Index` fresh partial to avoid code duplication.
* @param basePath The base path of the module, should be `import.meta.url!`.
* @returns The `Index` fresh partial that will be displayed by default.
*
* @example
* import makeIndex from "$root/defaults/makeIndex.ts";
* export default makeIndex(import.meta.dirname!);
*/
export default function makeIndex(basePath: string): Route {
return async function Index(
request: Request,
context: FreshContext<State>,
) {
const index = (await import(`${basePath}/partials/index.tsx`)).Index;
): Promise<ComponentChildren | Response> {
const index: Route = (await import(`${basePath}/partials/index.tsx`)).Index;
return index(request, context);
};
}
+20 -7
View File
@@ -1,8 +1,12 @@
import { JSX } from "preact";
import { Partial } from "$fresh/runtime.ts";
import { FreshContext, RouteConfig } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
import { Route, State } from "$root/defaults/interfaces.ts";
/**
* Gets the `RouteConfig` config object for partial pages.
* @returns The partials config object.
*/
export function getPartialsConfig(): RouteConfig {
return {
skipAppWrapper: true,
@@ -10,12 +14,21 @@ export function getPartialsConfig(): RouteConfig {
};
}
export function makePartials(
page: (
request: Request,
context: FreshContext<State>,
) => Promise<JSX.Element>,
) {
/**
* Partialize the given page for optimized rendering.
* @param page The partial `Route` object to partialize.
* @returns The partialized version of `page`.
* @example
* // Page defintion...
* async function Page(_request: Request, context: FreshContext<State>) {
* return <h2>My super page!</h2>;
* }
*
* // Partial code that should be at each file's end.
* export const config = getPartialsConfig();
* export default makePartials(Page);
*/
export function makePartials(page: Route) {
return async function WrappedElements(
request: Request,
context: FreshContext<State>,
+1
View File
@@ -4,6 +4,7 @@
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
"pmpr": "deno run -A toolbox/cli.ts",
"compile": "deno compile -A --output \"/home/$USER/.deno/bin/pmpr\" toolbox/cli.ts",
"manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts",
"build": "deno run -A --unstable-ffi dev.ts build",
+1 -1
View File
@@ -1,6 +1,6 @@
import { FreshContext } from "$fresh/server.ts";
import { Partial } from "$fresh/runtime.ts";
import { State } from "$root/routes/_middleware.ts";
import { State } from "$root/defaults/interfaces.ts";
import { AppProperties } from "$root/defaults/interfaces.ts";
import Navbar from "$root/routes/(_islands)/Navbar.tsx";
+1 -1
View File
@@ -1,5 +1,5 @@
import { FreshContext } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
import { State } from "$root/defaults/interfaces.ts";
import Header from "$root/routes/(_components)/Header.tsx";
import Footer from "$root/routes/(_components)/Footer.tsx";
+38 -15
View File
@@ -1,7 +1,7 @@
import { FreshContext } from "$fresh/server.ts";
import { FreshContext, MiddlewareHandler } from "$fresh/server.ts";
import { getCookies } from "$std/http/cookie.ts";
import { getJwtPayload, isJwtValid } from "@popov/jwt";
import { CasContent, LoginJWT } from "$root/defaults/interfaces.ts";
import { CasContent, LoginJWT, State } from "$root/defaults/interfaces.ts";
const PUBLIC_ROUTES = [
"/",
@@ -13,31 +13,45 @@ const PUBLIC_ROUTES = [
];
const jwtKeyCache: Record<string, string> = {};
const deleteKey = (user: string) => delete jwtKeyCache[user];
export interface State {
isAuthenticated: boolean;
session: CasContent;
}
function isRoutePublic(route: string) {
return PUBLIC_ROUTES.includes(route) || route.match(/\..+$/);
/**
* Checks if the given route is public.
* @param route The route to check.
* @returns `true` if the route is public, `false` otherwise.
*/
function isRoutePublic(route: string): boolean {
return PUBLIC_ROUTES.includes(route) ||
!!(route.match(/\..+$/)?.[0] ?? false);
}
/**
* Get the given user's key, creating it if not already existing.
* @param user The key's user.
* @returns The user's key.
*/
export function getKey(user: string): string {
if (!jwtKeyCache[user]) {
const keyBuffer = new Uint8Array(32);
crypto.getRandomValues(keyBuffer);
jwtKeyCache[user] = new TextDecoder().decode(keyBuffer);
setTimeout(deleteKey, 0x75300, user);
}
return jwtKeyCache[user];
}
export const handler = [
export const handler: MiddlewareHandler<State>[] = [
/**
* Check if user is authenticated and add session to context accordingly.
* @param request The HTTP incomming request.
* @param context The Fresh context object with custom `State`.
* @returns The response from the next middleware.
*/
async function checkAuthentication(
request: Request,
context: FreshContext<State>,
) {
): Promise<Response> {
const cookies = getCookies(request.headers);
if (!cookies["sessionToken"]) {
context.state.isAuthenticated = false;
@@ -51,17 +65,26 @@ export const handler = [
cookies["sessionToken"],
key,
);
const session: CasContent =
(getJwtPayload(cookies["sessionToken"]) as LoginJWT).user;
context.state.session = session;
if (context.state.isAuthenticated) {
const session: CasContent =
(getJwtPayload(cookies["sessionToken"]) as LoginJWT).user;
context.state.session = session;
}
return await context.next();
},
/**
* Check if page can be accessed with or without authentication.
* Redirect if the page is private and the user isn't authenticated.
* @param request The HTTP incomming request.
* @param context The Fresh context object with `State` set up.
* @returns The response from the next middleware or from the page.
*/
async function ensureAuthentication(
request: Request,
context: FreshContext<State>,
) {
): Promise<Response> {
const url = new URL(request.url);
if (!isRoutePublic(url.pathname) && !context.state.isAuthenticated) {
+22 -5
View File
@@ -1,10 +1,24 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { AppProperties } from "$root/defaults/interfaces.ts";
import { AppProperties, State } from "$root/defaults/interfaces.ts";
import AppNavigator from "$root/routes/(_islands)/AppNavigator.tsx";
export const handler: Handlers = {
async GET(_request, context) {
const apps: Record<string, AppProperties> = {};
const apps: Record<string, AppProperties> = {};
export const handler: Handlers<Record<string, AppProperties>, State> = {
/**
* Generate the app catalog page from pages.
* Catalog is only computed once, then the cached version is used.
* @param _request The HTTP incomming request.
* @param context The Fresh context with `State`.
* @returns The rendered page with all apps as catalog.
*/
async GET(
_request: Request,
context: FreshContext<State, Record<string, AppProperties>>,
): Promise<Response> {
if (Object.keys(apps).length != 0) {
return context.render(apps);
}
for await (const appDir of Deno.readDir("routes/(apps)")) {
if (appDir.isFile) {
@@ -26,7 +40,10 @@ export const handler: Handlers = {
};
// deno-lint-ignore require-await
export default async function Apps(_request: Request, context: FreshContext) {
export default async function Apps(
_request: Request,
context: FreshContext<State, Record<string, AppProperties>>,
) {
return (
<>
<AppNavigator apps={context.data} />
+51 -43
View File
@@ -1,5 +1,5 @@
import { Handlers } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
import { FreshContext, Handlers } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import { parse, type RegularTagNode } from "@melvdouc/xml-parser";
import {
CasContent,
@@ -13,13 +13,23 @@ import { getKey } from "$root/routes/_middleware.ts";
const CAS = "https://ident.univ-amu.fr/cas";
function getTag(tag: CasTagNode): [string, string] {
/**
* Get the tag node value without "cas:" prefix in name.
* @param tag The CAS tag node.
* @returns The `[name, value]` pair.
*/
function getTag(tag: CasTagNode): [name: string, value: string] {
return [
tag.tagName.replace("cas:", ""),
tag.children[0].value,
];
}
/**
* Gets the user JWT token with a validity period of one hour.
* @param casResponse The CAS reponse parsed from XML.
* @returns The user JWT session token.
*/
function createUserJWT(casResponse: CasResponse): Promise<string> {
const nodes = casResponse.children[1].children.map(getTag);
const fullUserInfos: Record<string, string | string[]> = {};
@@ -28,7 +38,6 @@ function createUserJWT(casResponse: CasResponse): Promise<string> {
if (typeof fullUserInfos[key] == "string") {
fullUserInfos[key] = [fullUserInfos[key]];
}
if (Array.isArray(fullUserInfos[key])) {
fullUserInfos[key].push(value);
} else {
@@ -37,12 +46,10 @@ function createUserJWT(casResponse: CasResponse): Promise<string> {
});
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
const payload: LoginJWT = {
iss: "PolyMPR",
iat: now,
exp: now + oneHour,
exp: now + 0xe10,
aud: "PolyMPR",
user: fullUserInfos as unknown as CasContent,
};
@@ -51,51 +58,39 @@ function createUserJWT(casResponse: CasResponse): Promise<string> {
return createJwt(payload, key);
}
// deno-lint-ignore no-explicit-any
export const handler: Handlers<any, State> = {
async GET(request, context) {
export const handler: Handlers<null, State> = {
/**
* Handles all CAS protocol requests.
* @param request The incomming HTTP request.
* @param context The Fresh context with `State`.
* @returns The redirect corresponding to each step of the CAS protocol.
*/
async GET(
request: Request,
context: FreshContext<State, null>,
): Promise<Response> {
const url = new URL(request.url);
const ticket = url.searchParams.get("ticket");
const service = `${context.url.origin}/login`;
if (ticket) {
const response = await fetch(
`${CAS}/serviceValidate?service=${service}&ticket=${ticket}`,
);
const body = parse(await response.text()) as [RegularTagNode];
const casResponse = body[0].children[0] as CasResponse;
if (casResponse.tagName != "cas:authenticationSuccess") {
return new Response(null, {
status: 302,
headers: {
Location: `${CAS}/login?service=${service}`,
},
});
}
const headers = new Headers();
setCookie(headers, {
name: "sessionToken",
value: await createUserJWT(casResponse),
});
headers.set("Location", "/apps");
return new Response(null, {
status: 302,
headers,
});
}
if (context.state.isAuthenticated) {
if (!ticket) {
return new Response(null, {
status: 302,
headers: {
Location: "/apps",
Location: context.state.isAuthenticated
? "/apps"
: `${CAS}/login?service=${service}`,
},
});
} else {
}
const response = await fetch(
`${CAS}/serviceValidate?service=${service}&ticket=${ticket}`,
);
const body = parse(await response.text()) as [RegularTagNode];
const casResponse = body[0].children[0] as CasResponse;
if (casResponse.tagName != "cas:authenticationSuccess") {
return new Response(null, {
status: 302,
headers: {
@@ -103,5 +98,18 @@ export const handler: Handlers<any, State> = {
},
});
}
const headers = new Headers();
setCookie(headers, {
name: "sessionToken",
value: await createUserJWT(casResponse),
});
headers.set("Location", "/apps");
return new Response(null, {
status: 302,
headers,
});
},
};
+10 -5
View File
@@ -1,12 +1,17 @@
import { Handlers } from "$fresh/server.ts";
import { State } from "$root/routes/_middleware.ts";
import { FreshContext, Handlers } from "$fresh/server.ts";
import { State } from "$root/defaults/interfaces.ts";
import { deleteCookie } from "$std/http/cookie.ts";
const CAS = "https://ident.univ-amu.fr/cas";
// deno-lint-ignore no-explicit-any
export const handler: Handlers<any, State> = {
GET(_request, context) {
export const handler: Handlers<null, State> = {
/**
* Logout of amU CAS SSO system.
* @param _request The HTTP incomming request.
* @param context The Fresh context with `State`.
* @returns A redirect response to either CAS logout or home.
*/
GET(_request: Request, context: FreshContext<State, null>): Response {
if (context.state.isAuthenticated) {
const headers = new Headers();
+10 -91
View File
@@ -1,15 +1,17 @@
import { parseArgs, type ParseOptions } from "@std/cli/parse-args";
import { type ParseOptions } from "@std/cli/parse-args";
import { createModule } from "$root/toolbox/module/create.ts";
import { listModules } from "$root/toolbox/module/list.ts";
import { CLI, displayHelp } from "$root/toolbox/cli/help.ts";
import { main } from "$root/toolbox/cli/main.ts";
type CLICommand = (...args: Array<string>) => void;
type CLIAsyncCommand = (...args: Array<string>) => Promise<void>;
interface CLI {
[command: string]: CLI | CLICommand | CLIAsyncCommand;
}
/**
* CLI will use `args._`, but you can define options for global CLI.
*/
const argSpec: ParseOptions = {};
/**
* Configure CLI commands here.
*/
const cli: CLI = {
help: () => displayHelp(cli),
module: {
@@ -18,87 +20,4 @@ const cli: CLI = {
},
};
function displayHelp(
cli: CLI | CLICommand | CLIAsyncCommand,
errorMessage?: string,
): never {
const loggingFunction = errorMessage ? console.error : console.log;
if (errorMessage) {
console.error(errorMessage);
}
if (typeof cli == "function") {
loggingFunction("Usage:");
displayFunctionHelp(cli, loggingFunction);
} else {
loggingFunction("Commands:");
displayObjectHelp(cli, loggingFunction);
}
Deno.exit(errorMessage ? 1 : 0);
}
function displayObjectHelp(
cli: CLI,
loggingFunction: typeof console.log,
level: number = 1,
) {
for (const [key, value] of Object.entries(cli)) {
if (typeof value == "function") {
displayFunctionHelp(value, loggingFunction, level);
} else {
loggingFunction(`${" ".repeat(level * 2)}${key}`);
displayObjectHelp(value, loggingFunction, level + 1);
}
}
}
function displayFunctionHelp(
cli: CLICommand | CLIAsyncCommand,
loggingFunction: typeof console.log,
level: number = 1,
) {
const command = cli.name;
const matched = cli.toString().match(/(?<=^\()[^\)]+(?=\))/);
const args = matched?.[0].split(",").map((arg) => `<${arg.trim()}>`);
loggingFunction(
`${" ".repeat(level * 2)}${command} ${args?.join(" ") ?? ""}`,
);
}
function runCommand(
commands: Array<string | number>,
cli: CLI,
): never | {
command: CLICommand | CLIAsyncCommand;
args: Array<string | number>;
} {
if (commands.length == 0) {
displayHelp(cli, `No command provided.`);
}
const command = commands.shift()!.toString();
if (cli[command] == undefined) {
displayHelp(cli, `Command "${command}" doesn't exist.`);
}
if (typeof cli[command] == "object") {
return runCommand(commands, cli[command]);
}
if (cli[command].length != commands.length) {
displayHelp(cli[command], `Wrong usage of command "${command}".`);
}
return { command: cli[command], args: commands };
}
function main() {
const argv = parseArgs(Deno.args, argSpec);
const { command, args } = runCommand(argv._, cli);
command(...args.map((element) => element.toString()));
}
main();
main(cli, argSpec);
+40
View File
@@ -0,0 +1,40 @@
import {
CLI,
CLIAsyncCommand,
CLICommand,
displayHelp,
} from "$root/toolbox/cli/help.ts";
/**
* Run the command given by arguments.
* @param commands The given arguments.
* @param cli The CLI (sub-)configuration object.
* @returns The command or the help if commands are not valid.
*/
export function runCommand(
commands: Array<string | number>,
cli: CLI,
): never | {
command: CLICommand | CLIAsyncCommand;
args: Array<string | number>;
} {
if (commands.length == 0) {
displayHelp(cli, `No command provided.`);
}
const command = commands.shift()!.toString();
if (cli[command] == undefined) {
displayHelp(cli, `Command "${command}" doesn't exist.`);
}
if (typeof cli[command] == "object") {
return runCommand(commands, cli[command]);
}
if (cli[command].length != commands.length) {
displayHelp(cli[command], `Wrong usage of command "${command}".`);
}
return { command: cli[command], args: commands };
}
+70
View File
@@ -0,0 +1,70 @@
export type CLICommand = (...args: Array<string>) => void;
export type CLIAsyncCommand = (...args: Array<string>) => Promise<void>;
export interface CLI {
[command: string]: CLI | CLICommand | CLIAsyncCommand;
}
/**
* Display the help message for the CLI.
* @param cli The CLI (sub-)configuration object.
* @param errorMessage The error message to display.
*/
export function displayHelp(
cli: CLI | CLICommand | CLIAsyncCommand,
errorMessage?: string,
): never {
const loggingFunction = errorMessage ? console.error : console.log;
if (errorMessage) {
console.error(errorMessage);
}
if (typeof cli == "function") {
loggingFunction("Usage:");
displayFunctionHelp(cli, loggingFunction);
} else {
loggingFunction("Commands:");
displayObjectHelp(cli, loggingFunction);
}
Deno.exit(errorMessage ? 1 : 0);
}
/**
* Display object help recursivelly.
* @param cli The CLI (sub-)configuration object.
* @param loggingFunction The logging function to use.
* @param level The tab level.
*/
function displayObjectHelp(
cli: CLI,
loggingFunction: typeof console.log,
level: number = 1,
) {
for (const [key, value] of Object.entries(cli)) {
if (typeof value == "function") {
displayFunctionHelp(value, loggingFunction, level);
} else {
loggingFunction(`${" ".repeat(level * 2)}${key}`);
displayObjectHelp(value, loggingFunction, level + 1);
}
}
}
/**
* Display function help.
* @param cli The CLI function.
* @param loggingFunction The logging function to use.
* @param level The tab level.
*/
function displayFunctionHelp(
cli: CLICommand | CLIAsyncCommand,
loggingFunction: typeof console.log,
level: number = 1,
) {
const command = cli.name;
const matched = cli.toString().match(/(?<=^\()[^\)]+(?=\))/);
const args = matched?.[0].split(",").map((arg) => `<${arg.trim()}>`);
loggingFunction(
`${" ".repeat(level * 2)}${command} ${args?.join(" ") ?? ""}`,
);
}
+14
View File
@@ -0,0 +1,14 @@
import { parseArgs, ParseOptions } from "@std/cli/parse-args";
import { runCommand } from "$root/toolbox/cli/command.ts";
import { CLI } from "$root/toolbox/cli/help.ts";
/**
* Runs the CLI.
* @param cli The CLI configuration object.
* @param argSpec The Parse options for args.
*/
export function main(cli: CLI, argSpec: ParseOptions) {
const argv = parseArgs(Deno.args, argSpec);
const { command, args } = runCommand(argv._, cli);
command(...args.map((element) => element.toString()));
}
+35
View File
@@ -1,3 +1,7 @@
/**
* Creates a new module.
* @param name The module name.
*/
export async function createModule(name: string): Promise<void> {
if (!name.match(/^[a-zA-Z0-9](?:(?:\-(?!\-))?[a-zA-Z0-9]*)*[a-zA-Z0-9]$/)) {
console.error("Module names must be in kebab case.");
@@ -58,16 +62,32 @@ export async function createModule(name: string): Promise<void> {
formatter.output();
}
/**
* Creates a new file at given path.
* @param path The file path.
* @param content The file content.
* @returns The creation promise.
*/
function createFile(path: string, content: string): Promise<void> {
console.log(`Creating file ${path}...`);
return Deno.writeTextFile(path, content);
}
/**
* Creates a new directory at given path.
* @param path The directory path.
* @returns The creation promise.
*/
function createDir(path: string): Promise<void> {
console.log(`Creating directory ${path}...`);
return Deno.mkdir(path);
}
/**
* Create the index content.
* @param _name The module capitalized name.
* @returns The index content.
*/
function getIndexContent(_name: string) {
return `
import makeIndex from "$root/defaults/makeIndex.ts";
@@ -75,6 +95,11 @@ function getIndexContent(_name: string) {
`;
}
/**
* Create the partials index content.
* @param name The module capitalized name.
* @returns The partials index content.
*/
function getPartialIndexContent(name: string) {
return `
import {
@@ -93,6 +118,11 @@ function getPartialIndexContent(name: string) {
`;
}
/**
* Create the props content.
* @param name The module capitalized name.
* @returns The props content.
*/
function getPropsContent(name: string) {
return `
import { AppProperties } from "$root/defaults/interfaces.ts";
@@ -111,6 +141,11 @@ function getPropsContent(name: string) {
`;
}
/**
* Create the API example content.
* @param _name The module capitalized name.
* @returns The API example content.
*/
function getApiExampleContent(_name: string) {
return `
import { Handlers } from "$fresh/server.ts";
+3
View File
@@ -1,3 +1,6 @@
/**
* List all modules of PolyMPR.
*/
export async function listModules(): Promise<void> {
for await (const path of Deno.readDir("routes/(apps)")) {
if (path.isDirectory) {