JUMBO refactor, still work in progress
This commit is contained in:
@@ -8,12 +8,9 @@
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "catalog:",
|
||||
"@elysiajs/static": "catalog:",
|
||||
"@elysiajs/swagger": "catalog:",
|
||||
"cbor2": "catalog:",
|
||||
"common": "workspace:^",
|
||||
"effect": "catalog:",
|
||||
"elysia": "catalog:",
|
||||
"kysely": "catalog:",
|
||||
"kysely-bun-sqlite": "catalog:"
|
||||
}
|
||||
|
||||
106
packages/backend/src/api.ts
Normal file
106
packages/backend/src/api.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import * as Api from "common/Api";
|
||||
import * as Cbor from "common/Cbor";
|
||||
import * as Client from "common/Client";
|
||||
import { Cause, Effect, Either, HashMap, Inspectable, Option, pipe } from "effect";
|
||||
|
||||
/* NOTE We shouldn't need to extract this to a separate type, but if we don't do
|
||||
* it the TypeScript parser in VS Code sort of blows up and the syntax colors
|
||||
* turn wrong.
|
||||
*/
|
||||
type Return<Impl extends Api.ApiBundleImpl<any>> = Effect.Effect<Response, never, Effect.Effect.Context<ReturnType<Impl[keyof Impl]>>>;
|
||||
|
||||
const catchToResponse = Effect.catchAll((error: unknown) => pipe(
|
||||
error,
|
||||
Inspectable.toJSON,
|
||||
Cbor.encode,
|
||||
Effect.map(Client.cborBody),
|
||||
Effect.matchEffect({
|
||||
onSuccess: ({ body, headers }) => Effect.fail(new Response(body, { status: 500, headers })),
|
||||
onFailure: () => Effect.fail(new Response(null, { status: 500 })),
|
||||
}),
|
||||
));
|
||||
|
||||
export const implement = <
|
||||
const Record extends { readonly [_: string]: Api.ApiAny },
|
||||
const Impl extends Api.ApiBundleImpl<Record>,
|
||||
>(
|
||||
bundle: Api.ApiBundle<Record>,
|
||||
impl: Impl,
|
||||
): (key: string, request: Request) => Return<Impl> => {
|
||||
return (key, requestObject) => {
|
||||
/* Force both return types to be `Response`. We can use the error route
|
||||
* for it's short-circuit capabilities.
|
||||
*/
|
||||
const effect: Effect.Effect<Response, Response, any> = Effect.gen(function* () {
|
||||
const maybeApi = HashMap.get(bundle.map, key);
|
||||
if (Option.isNone(maybeApi)) {
|
||||
return RESPONSE_API_NOT_FOUND;
|
||||
}
|
||||
const { value: api } = maybeApi;
|
||||
|
||||
const fn = impl[key];
|
||||
if (fn === undefined) {
|
||||
return RESPONSE_API_NOT_IMPLEMENTED;
|
||||
}
|
||||
|
||||
const request = yield* pipe(
|
||||
requestObject,
|
||||
Client.decodeBody(api.request),
|
||||
catchToResponse,
|
||||
);
|
||||
|
||||
const result = yield* pipe(
|
||||
request,
|
||||
fn,
|
||||
Effect.sandbox,
|
||||
Effect.either,
|
||||
);
|
||||
|
||||
const { status, response } = pipe(
|
||||
result,
|
||||
Either.match({
|
||||
onLeft: (cause) => pipe(
|
||||
cause,
|
||||
Cause.failureOrCause,
|
||||
Either.match({
|
||||
onLeft: (error) => ({
|
||||
status: 400,
|
||||
response: pipe(
|
||||
error,
|
||||
Client.encodeBody(api.error),
|
||||
catchToResponse,
|
||||
),
|
||||
}),
|
||||
onRight: (die) => ({
|
||||
status: 500,
|
||||
response: pipe(
|
||||
die,
|
||||
Inspectable.toJSON,
|
||||
Cbor.encode,
|
||||
Effect.map(Client.cborBody),
|
||||
catchToResponse,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
onRight: (response) => ({
|
||||
status: 200,
|
||||
response: pipe(
|
||||
response,
|
||||
Client.encodeBody(api.response),
|
||||
catchToResponse,
|
||||
),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { body, headers } = yield* response;
|
||||
return new Response(body, { status, headers });
|
||||
});
|
||||
|
||||
return Effect.catchAll(effect, Effect.succeed);
|
||||
};
|
||||
};
|
||||
|
||||
const RESPONSE_API_NOT_FOUND = new Response(null, { status: 404 });
|
||||
const RESPONSE_API_NOT_IMPLEMENTED = new Response(null, { status: 501 });
|
||||
@@ -1,864 +1,160 @@
|
||||
import cors from "@elysiajs/cors";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex } from "common";
|
||||
import { Effect, Option, pipe, Redacted } from "effect";
|
||||
import { Elysia, error, t } from "elysia";
|
||||
import { sql } from "kysely";
|
||||
import { EXTERNAL_OAUTH_CONFIGURATION, getUser, INTERNAL_OAUTH_CONFIGURATION, makeAuthorizationUrl, REDIRECT_URI, revalidateTokens } from "./auth";
|
||||
import * as Body from "common/Body";
|
||||
import { fetch } from "common/Fetch";
|
||||
import { Cause, Effect, Layer, Option, pipe, Record, Redacted, Stream } from "effect";
|
||||
import * as path from "node:path";
|
||||
import { config } from "./config";
|
||||
import * as Db from "./database";
|
||||
import * as Model from "./model";
|
||||
import { DbFromInstance } from "./services/db";
|
||||
import { SessionFromValue } from "./services/session";
|
||||
import * as Authentication from "./services/Authentication";
|
||||
import * as Database from "./services/Database";
|
||||
import { handle } from "./the_api";
|
||||
|
||||
const app = new Elysia()
|
||||
const FRONTEND_ROOT = "packages/frontend/build";
|
||||
const FRONTEND_ASSETS_ROOT = path.join(FRONTEND_ROOT, "assets");
|
||||
|
||||
.use(swagger({
|
||||
scalarConfig: {
|
||||
authentication: {
|
||||
securitySchemes: {
|
||||
cookieAuth: {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "sessionId",
|
||||
},
|
||||
},
|
||||
const assetRoutes = await pipe(
|
||||
Stream.fromAsyncIterable(
|
||||
new Bun.Glob("**/*").scan(FRONTEND_ASSETS_ROOT),
|
||||
(error) => new Cause.UnknownException(error),
|
||||
),
|
||||
Stream.map((filepath): [string, Response] => [
|
||||
`/assets/${filepath}`,
|
||||
new Response(Bun.file(path.join(FRONTEND_ASSETS_ROOT, filepath))),
|
||||
]),
|
||||
Stream.runCollect,
|
||||
Effect.map(Record.fromEntries),
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
const CORS_HEADERS: [string, string][] = [
|
||||
["Access-Control-Allow-Origin", "http://localhost:5173"],
|
||||
["Access-Control-Allow-Methods", "POST, OPTIONS"],
|
||||
["Access-Control-Allow-Credentials", "true"],
|
||||
["Access-Control-Allow-Headers", "Content-Type"],
|
||||
];
|
||||
|
||||
const homepage = new Response(Bun.file(path.join(FRONTEND_ROOT, "index.html")));
|
||||
|
||||
const databaseLayer = Database.FromPath(config.DB_PATH);
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
...assetRoutes,
|
||||
"/login": {
|
||||
GET: async (req) => {
|
||||
const res = await pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl({
|
||||
external: new URL(req.url).searchParams.has("external"),
|
||||
sessionId,
|
||||
})),
|
||||
Effect.map((url) => Response.redirect(url)),
|
||||
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
return res;
|
||||
},
|
||||
},
|
||||
swaggerOptions: {
|
||||
withCredentials: true,
|
||||
},
|
||||
}))
|
||||
POST: (req) => Effect.gen(function* () {
|
||||
const { sessionId } = yield* Authentication.Authentication;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
.use(cors({ origin: config.NODE_ENV === "production" ? false : "localhost:5173" }))
|
||||
const data = yield* Body.formData(req);
|
||||
|
||||
.decorate("db", await Db.initDatabase(config.DB_PATH))
|
||||
const code = data.get("code") as string | null;
|
||||
const state = data.get("state") as string | null;
|
||||
|
||||
.resolve(async ({ db, cookie }) => {
|
||||
await db
|
||||
.deleteFrom("Session")
|
||||
.where(sql`datetime()`, ">=", "expiresAt")
|
||||
.execute();
|
||||
const session = yield* db
|
||||
.selectFrom("Session")
|
||||
.select(["external", "codeVerifier"])
|
||||
.where("sessionId", "=", sessionId)
|
||||
.$call(Database.executeTakeFirst);
|
||||
|
||||
const sessionId = (cookie.sessionId.value as SessionId | undefined) ?? Db.generateSessionId();
|
||||
|
||||
const expiresAt = new Date().getTime() + 604800000;
|
||||
cookie.sessionId.set({
|
||||
value: sessionId,
|
||||
expires: new Date(expiresAt),
|
||||
httpOnly: true,
|
||||
sameSite: "none",
|
||||
secure: true,
|
||||
});
|
||||
|
||||
const returning = [
|
||||
"sessionId",
|
||||
"accessToken",
|
||||
"codeVerifier",
|
||||
"external",
|
||||
"idToken",
|
||||
"refreshToken",
|
||||
"state",
|
||||
] as const;
|
||||
|
||||
let session = await db
|
||||
.updateTable("Session")
|
||||
.set({ expiresAt: sql`datetime('now', '+7 days') ` })
|
||||
.where("sessionId", "=", sessionId)
|
||||
.returning(returning)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (session === undefined) {
|
||||
session = await db
|
||||
.insertInto("Session")
|
||||
.values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||
.returning(returning)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
const { accessToken, idToken, refreshToken, roles, userId } = await pipe(
|
||||
{
|
||||
accessToken: Option.fromNullable(session.accessToken),
|
||||
idToken: Option.fromNullable(session.idToken),
|
||||
refreshToken: Option.fromNullable(session.refreshToken),
|
||||
external: Boolean(session.external),
|
||||
},
|
||||
revalidateTokens,
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
await db
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
accessToken: pipe(
|
||||
accessToken,
|
||||
Option.map((at) => at.token),
|
||||
Option.getOrNull,
|
||||
),
|
||||
idToken: pipe(
|
||||
idToken,
|
||||
Option.map((it) => it.token),
|
||||
Option.getOrNull,
|
||||
),
|
||||
refreshToken: Option.getOrNull(refreshToken),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return {
|
||||
session: {
|
||||
sessionId: session.sessionId,
|
||||
accessToken,
|
||||
idToken,
|
||||
refreshToken,
|
||||
roles,
|
||||
userId,
|
||||
codeVerifier: Option.fromNullable(session.codeVerifier),
|
||||
external: pipe(
|
||||
const external = pipe(
|
||||
session.external,
|
||||
Option.fromNullable,
|
||||
Option.map((e) => e !== 0),
|
||||
),
|
||||
state: Option.fromNullable(session.state),
|
||||
},
|
||||
};
|
||||
})
|
||||
Option.map((external) => external !== 0),
|
||||
);
|
||||
|
||||
.onTransform(async ({ db, request, server }) => {
|
||||
const codeVerifier = Option.fromNullable(session.codeVerifier);
|
||||
|
||||
const requestId = RequestId(Bun.randomUUIDv7("hex"));
|
||||
const timestamp = new Date().toISOString();
|
||||
const { method } = request;
|
||||
const url = new URL(request.url);
|
||||
const { pathname } = url;
|
||||
const query = JSON.stringify(Object.fromEntries(url.searchParams.entries()));
|
||||
const ip = server?.requestIP(request)?.address ?? null;
|
||||
if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) {
|
||||
const { tokenEndpoint } = external.value
|
||||
? Authentication.EXTERNAL_OAUTH_CONFIGURATION
|
||||
: Authentication.INTERNAL_OAUTH_CONFIGURATION;
|
||||
|
||||
await db
|
||||
.insertInto("AccessLog")
|
||||
.values({ requestId, timestamp, method, pathname, query, ip })
|
||||
.execute();
|
||||
const res = yield* fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"client_id": config.CLIENT_ID,
|
||||
"code": code,
|
||||
"redirect_uri": Authentication.REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
"code_verifier": codeVerifier.value,
|
||||
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
console.log(`${timestamp} ${method} ${request.url} ${ip}`);
|
||||
})
|
||||
const {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
id_token: idToken,
|
||||
} = (yield* Body.json(res)) as {
|
||||
access_token: string,
|
||||
refresh_token: string,
|
||||
id_token: string,
|
||||
};
|
||||
|
||||
.use(staticPlugin({
|
||||
assets: "packages/frontend/build/assets",
|
||||
prefix: "/assets",
|
||||
alwaysStatic: true,
|
||||
indexHTML: false,
|
||||
}))
|
||||
yield* db
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
codeVerifier: null,
|
||||
state: null,
|
||||
})
|
||||
.where("sessionId", "=", sessionId)
|
||||
.$call(Database.execute);
|
||||
}
|
||||
|
||||
.group("/api/v1", (app) => app
|
||||
|
||||
// --- MARK: AUTHENTICATION --------------------------------------------
|
||||
|
||||
.get("/me", ({ session: { idToken, roles } }) => {
|
||||
return Option.match(idToken, {
|
||||
onNone: () => error("Unauthorized", "Session invalid or expired"),
|
||||
onSome: ({ payload: { oid, name } }) => ({
|
||||
userId: oid,
|
||||
username: name,
|
||||
roles: roles as string[],
|
||||
}),
|
||||
});
|
||||
}, {
|
||||
response: {
|
||||
200: Model.Me,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/login", async ({ db, query, redirect, session: { sessionId } }) => {
|
||||
|
||||
const url = await pipe(
|
||||
makeAuthorizationUrl({ external: "external" in query }),
|
||||
Effect.provide([
|
||||
DbFromInstance(db),
|
||||
SessionFromValue(sessionId),
|
||||
]),
|
||||
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||
}).pipe(
|
||||
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
|
||||
Effect.runPromise,
|
||||
);
|
||||
),
|
||||
},
|
||||
"/api/:key": async (req, server) => {
|
||||
|
||||
return redirect(url, 302) as unknown as void;
|
||||
}, {
|
||||
response: {
|
||||
302: t.Void(),
|
||||
},
|
||||
})
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`${timestamp} ${req.method} ${req.url} ${server.requestIP(req)?.address}`);
|
||||
|
||||
.post("/login", async ({ db, request, redirect, session: { sessionId, external, codeVerifier } }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
const code = data.get("code") as string | null;
|
||||
const state = data.get("state") as string | null;
|
||||
|
||||
if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) {
|
||||
const { tokenEndpoint } = external.value ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
||||
|
||||
const res = await fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"client_id": config.CLIENT_ID,
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
"code_verifier": codeVerifier.value,
|
||||
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
||||
}).toString(),
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
|
||||
const {
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
id_token: idToken,
|
||||
} = await res.json() as { access_token: string, refresh_token: string, id_token: string };
|
||||
|
||||
await db
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
codeVerifier: null,
|
||||
state: null,
|
||||
})
|
||||
.where("sessionId", "=", sessionId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
return redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303) as unknown as void;
|
||||
}, {
|
||||
response: {
|
||||
303: t.Void(),
|
||||
},
|
||||
})
|
||||
const authenticationLayer = Authentication.Live(req);
|
||||
const layers = Layer.provideMerge(authenticationLayer, databaseLayer);
|
||||
|
||||
.post("/logout", async ({ db, cookie, set }) => {
|
||||
|
||||
set.status = "No Content";
|
||||
|
||||
const sessionCookie = cookie.sessionId;
|
||||
sessionCookie.remove();
|
||||
|
||||
const sessionId = sessionCookie.value;
|
||||
if (sessionId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.deleteFrom("Session")
|
||||
.where("sessionId", "=", SessionId(sessionId))
|
||||
.execute();
|
||||
}, {
|
||||
response: {
|
||||
204: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
// --- MARK: USER MANAGEMENT -------------------------------------------
|
||||
|
||||
.get("/user/:userId", async ({ params: { userId }, session: { accessToken } }) => {
|
||||
|
||||
if (Option.isNone(accessToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
const res = await pipe(
|
||||
{ accessToken: accessToken.value.token, userId },
|
||||
getUser,
|
||||
const response = await pipe(
|
||||
handle(req.params.key, req),
|
||||
Effect.provide(layers),
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
return Option.match(res, {
|
||||
onNone: () => error("Not Found", new Response() as unknown as void),
|
||||
onSome: ({ displayName }) => ({ userId, displayName }),
|
||||
});
|
||||
}, {
|
||||
params: t.Object({
|
||||
userId: Model.UserId,
|
||||
}),
|
||||
response: {
|
||||
200: Model.User,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
// --- MARK: PIECE CRUD ------------------------------------------------
|
||||
|
||||
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
for (const [name, value] of CORS_HEADERS) {
|
||||
response.headers.set(name, value);
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const pieceId = PieceId(Bun.randomUUIDv7());
|
||||
|
||||
const res = await db
|
||||
.insertInto("Piece")
|
||||
.values({ pieceId, name, composer, lyricist, arranger, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
...res,
|
||||
attachments: [],
|
||||
};
|
||||
}, {
|
||||
body: Model.Piece_Post,
|
||||
response: {
|
||||
200: Model.Piece,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/piece", async ({ db, query, session: { idToken } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
let q = db
|
||||
.selectFrom("Piece")
|
||||
.select("pieceId")
|
||||
.orderBy(["name", "composer", "arranger"])
|
||||
.offset(query.offset ?? 0)
|
||||
.limit(query.limit ?? 100);
|
||||
|
||||
if (query.name !== undefined) {
|
||||
q = q.where("name", "like", "%" + query.name + "%");
|
||||
}
|
||||
|
||||
if (query.author !== undefined) {
|
||||
q = q.where((eb) => eb.or([
|
||||
eb("composer", "like", "%" + query.author + "%"),
|
||||
eb("arranger", "like", "%" + query.author + "%"),
|
||||
eb("lyricist", "like", "%" + query.author + "%"),
|
||||
]));
|
||||
}
|
||||
|
||||
const res = await q.execute();
|
||||
return res.map(({ pieceId }) => pieceId);
|
||||
}, {
|
||||
query: Model.Piece_Query,
|
||||
response: {
|
||||
200: t.Array(Model.PieceId),
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/piece/:pieceId", async ({ db, params: { pieceId }, session: { idToken } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
const piece = await db
|
||||
.selectFrom("Piece")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (piece === undefined) {
|
||||
return error("Not Found", new Response() as unknown as void);
|
||||
}
|
||||
|
||||
const attachments = await db
|
||||
.selectFrom("Attachment")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
...piece,
|
||||
attachments: attachments.map(({ sha256, ...rest }) => ({
|
||||
sha256: Sha256_Hex(Buffer.from(sha256).toString("hex")),
|
||||
...rest,
|
||||
})),
|
||||
};
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: Model.PieceId,
|
||||
}),
|
||||
response: {
|
||||
200: Model.Piece,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Piece")
|
||||
.set({ name, composer, lyricist, arranger, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found", new Response() as unknown as void);
|
||||
}
|
||||
|
||||
const attachments = await db
|
||||
.selectFrom("Attachment")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.execute();
|
||||
|
||||
return {
|
||||
...res[0],
|
||||
attachments: attachments.map(({ sha256, ...rest }) => ({
|
||||
sha256: Sha256_Hex(Buffer.from(sha256).toString("hex")),
|
||||
...rest,
|
||||
})),
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: Model.PieceId,
|
||||
}),
|
||||
response: {
|
||||
200: Model.Piece,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Piece")
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found", new Response() as unknown as void);
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: Model.PieceId,
|
||||
}),
|
||||
response: {
|
||||
204: t.Void(),
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
// --- MARK: ATTACHMENT CRUD -------------------------------------------
|
||||
|
||||
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const attachmentId = AttachmentId(Bun.randomUUIDv7());
|
||||
const dataArray = new Uint8Array(await data.arrayBuffer());
|
||||
|
||||
const sha256 = Sha256_Bin(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(dataArray, sha256);
|
||||
|
||||
await db
|
||||
.insertInto("File")
|
||||
.values({ sha256, data: dataArray })
|
||||
.onConflict((cb) => cb.column("sha256").doNothing())
|
||||
.execute();
|
||||
|
||||
const res = await db
|
||||
.insertInto("Attachment")
|
||||
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
...res,
|
||||
sha256: Sha256_Hex(Buffer.from(res.sha256).toString("hex")),
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
filename: t.String({ minLength: 1 }),
|
||||
mediaType: t.String({ minLength: 1 }),
|
||||
data: t.File(),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: Model.PieceId,
|
||||
}),
|
||||
response: {
|
||||
200: Model.Attachment,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
},
|
||||
})
|
||||
|
||||
/* NOTE The piece ID is reduntant, because attachment IDs are unique for
|
||||
* the entire DB, not just per piece. However, we consider a piece to be
|
||||
* the sole owner of an attachment, i.e. attachments are not shared
|
||||
* (attachments are deduplicated on file storage level by their SHA-256
|
||||
* hash). Thus, we reflect the ownership in the URLs.
|
||||
*/
|
||||
|
||||
.get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, session: { idToken }, set }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.selectFrom("File")
|
||||
.innerJoin("Attachment", "File.sha256", "Attachment.sha256")
|
||||
.select(["Attachment.filename", "Attachment.mediaType", "File.data"])
|
||||
.where((eb) => eb.and([
|
||||
eb("Attachment.pieceId", "=", pieceId),
|
||||
eb("Attachment.attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.executeTakeFirst();
|
||||
|
||||
if (res === undefined) {
|
||||
return error("Not Found", new Response() as unknown as void);
|
||||
}
|
||||
|
||||
set.headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(res.filename)}`;
|
||||
set.headers["content-type"] = res.mediaType;
|
||||
return new File([res.data], res.filename, { type: res.mediaType });
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: Model.PieceId,
|
||||
attachmentId: Model.AttachmentId,
|
||||
}),
|
||||
response: {
|
||||
200: t.File(),
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
.put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Attachment")
|
||||
.set({ filename, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||
.where((eb) => eb.and([
|
||||
eb("pieceId", "=", pieceId),
|
||||
eb("attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found", new Response() as unknown as void);
|
||||
}
|
||||
|
||||
return {
|
||||
...res[0],
|
||||
sha256: Sha256_Hex(Buffer.from(res[0].sha256).toString("hex")),
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
filename: t.String({ minLength: 1 }),
|
||||
}),
|
||||
params: t.Object({
|
||||
pieceId: Model.PieceId,
|
||||
attachmentId: Model.AttachmentId,
|
||||
}),
|
||||
response: {
|
||||
200: Model.Attachment,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
.delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Attachment")
|
||||
.where((eb) => eb.and([
|
||||
eb("pieceId", "=", pieceId),
|
||||
eb("attachmentId", "=", attachmentId),
|
||||
]))
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found", new Response() as unknown as void);
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
params: t.Object({
|
||||
pieceId: Model.PieceId,
|
||||
attachmentId: Model.AttachmentId,
|
||||
}),
|
||||
response: {
|
||||
204: t.Void(),
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
// --- MARK: REPERTOIRE CRUD -------------------------------------------
|
||||
|
||||
.post("/repertoire", async ({ db, body: { name, entries }, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const repertoireId = RepertoireId(Bun.randomUUIDv7());
|
||||
|
||||
const repertoire = await db
|
||||
.insertInto("Repertoire")
|
||||
.values({ repertoireId, name, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const dbEntries: Db.RepertoireEntry[] = [];
|
||||
for (let i = 0; i < entries.length; ++i) {
|
||||
const entry = entries[i];
|
||||
const dbEntry = await db
|
||||
.insertInto("RepertoireEntry")
|
||||
.values({ ...entry, repertoireId, order: i })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
dbEntries.push(dbEntry);
|
||||
}
|
||||
|
||||
return {
|
||||
...repertoire,
|
||||
entries: dbEntries,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
entries: t.Array(t.Object({
|
||||
pieceId: Model.PieceId,
|
||||
})),
|
||||
}),
|
||||
response: {
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/repertoire", async ({ db, query, session: { idToken } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
let q = db
|
||||
.selectFrom("Repertoire")
|
||||
.select("repertoireId")
|
||||
.orderBy(["name"])
|
||||
.offset(query.offset ?? 0)
|
||||
.limit(query.limit ?? 100);
|
||||
|
||||
if (query.name !== undefined) {
|
||||
q = q.where("name", "like", "%" + query.name + "%");
|
||||
}
|
||||
|
||||
const res = await q.execute();
|
||||
return res.map(({ repertoireId }) => repertoireId);
|
||||
}, {
|
||||
query: Model.Repertoire_Query,
|
||||
response: {
|
||||
200: t.Array(Model.RepertoireId),
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, session: { idToken } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
const repertoire = await db
|
||||
.selectFrom("Repertoire")
|
||||
.selectAll()
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (repertoire === undefined) {
|
||||
return error("Not Found", new Response() as unknown as void);
|
||||
}
|
||||
|
||||
const entries = await db
|
||||
.selectFrom("RepertoireEntry")
|
||||
.select(["pieceId"])
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.orderBy("order")
|
||||
.execute();
|
||||
|
||||
return {
|
||||
...repertoire,
|
||||
entries: entries.map(({ pieceId }) => pieceId),
|
||||
};
|
||||
}, {
|
||||
params: t.Object({
|
||||
repertoireId: Model.RepertoireId,
|
||||
}),
|
||||
response: {
|
||||
200: Model.Repertoire,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
.put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Repertoire")
|
||||
.set({ name, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found", new Response() as unknown as void);
|
||||
}
|
||||
|
||||
await db
|
||||
.deleteFrom("RepertoireEntry")
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.execute();
|
||||
|
||||
for (let i = 0; i < entries.length; ++i) {
|
||||
const entry = entries[i];
|
||||
await db
|
||||
.insertInto("RepertoireEntry")
|
||||
.values({ pieceId: entry, repertoireId, order: i })
|
||||
.returning(["pieceId"])
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
return {
|
||||
...res[0],
|
||||
entries,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
entries: t.Array(Model.PieceId),
|
||||
}),
|
||||
params: t.Object({
|
||||
repertoireId: Model.RepertoireId,
|
||||
}),
|
||||
response: {
|
||||
200: Model.Repertoire,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
.delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, session: { idToken, roles } }) => {
|
||||
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Repertoire")
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
params: t.Object({
|
||||
repertoireId: Model.RepertoireId,
|
||||
}),
|
||||
response: {
|
||||
204: t.Void(),
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
404: t.Void(),
|
||||
},
|
||||
// eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here
|
||||
})
|
||||
)
|
||||
|
||||
.get("*", () => Bun.file("packages/frontend/build/index.html"));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
app.listen(config.PORT);
|
||||
export type App = typeof app;
|
||||
return response;
|
||||
},
|
||||
"/*": homepage,
|
||||
},
|
||||
websocket: {
|
||||
message: () => { },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import { UserId } from "common";
|
||||
import { DateTime, Duration, Effect, Option, pipe, Redacted } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import { config } from "./config";
|
||||
import * as Model from "./model";
|
||||
import { Db } from "./services/db";
|
||||
import { Session } from "./services/session";
|
||||
|
||||
export const OAUTH_SCOPE = "email offline_access openid profile https://graph.microsoft.com/User.Read.All";
|
||||
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/api/v1/login` : "http://localhost:3000/api/v1/login";
|
||||
|
||||
export const EXPIRATION_BUFFER = Duration.seconds(10);
|
||||
|
||||
export interface OAuthConfiguration {
|
||||
readonly authorizationEndpoint: string;
|
||||
readonly tokenEndpoint: string;
|
||||
}
|
||||
|
||||
export const INTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze<OAuthConfiguration>({
|
||||
authorizationEndpoint: `https://login.microsoftonline.com/${config.TENANT_ID}/oauth2/v2.0/authorize`,
|
||||
tokenEndpoint: `https://login.microsoftonline.com/${config.TENANT_ID}/oauth2/v2.0/token`,
|
||||
});
|
||||
|
||||
export const EXTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze<OAuthConfiguration>({
|
||||
authorizationEndpoint: `https://${config.TENANT_SUBDOMAIN}.ciamlogin.com/${config.TENANT_ID}/oauth2/v2.0/authorize`,
|
||||
tokenEndpoint: `https://${config.TENANT_SUBDOMAIN}.ciamlogin.com/${config.TENANT_ID}/oauth2/v2.0/token`,
|
||||
});
|
||||
|
||||
export namespace makeAuthorizationUrl {
|
||||
export interface Args {
|
||||
readonly external: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export const makeAuthorizationUrl = Effect.fn("makeAuthorizationUrl")(
|
||||
function* ({ external }: makeAuthorizationUrl.Args) {
|
||||
const { db, execute } = yield* Db;
|
||||
const sessionId = yield* Session;
|
||||
|
||||
const { codeVerifier, codeChallenge } = generateCodeVerifier();
|
||||
const state = generateRandomState();
|
||||
|
||||
yield* db
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
codeVerifier,
|
||||
state,
|
||||
external: external ? 1 : 0,
|
||||
accessToken: null,
|
||||
idToken: null,
|
||||
refreshToken: null,
|
||||
})
|
||||
.where("sessionId", "=", sessionId)
|
||||
.$call(execute);
|
||||
|
||||
const { authorizationEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
||||
|
||||
const url = new URL(authorizationEndpoint);
|
||||
url.searchParams.set("client_id", config.CLIENT_ID);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", OAUTH_SCOPE);
|
||||
url.searchParams.set("response_mode", "form_post");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("prompt", "select_account");
|
||||
url.searchParams.set("code_challenge", codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
return url.toString();
|
||||
},
|
||||
);
|
||||
|
||||
export namespace revaildateTokens {
|
||||
export interface Args {
|
||||
readonly accessToken: Option.Option<string>;
|
||||
readonly idToken: Option.Option<string>;
|
||||
readonly refreshToken: Option.Option<string>;
|
||||
readonly external: boolean;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly accessToken: Option.Option<{
|
||||
readonly token: string,
|
||||
readonly payload: Model.AccessTokenPayload,
|
||||
}>;
|
||||
readonly idToken: Option.Option<{
|
||||
readonly token: string,
|
||||
readonly payload: Model.IdTokenPayload,
|
||||
}>;
|
||||
readonly refreshToken: Option.Option<string>;
|
||||
readonly userId: Option.Option<UserId>;
|
||||
readonly roles: readonly string[];
|
||||
}
|
||||
}
|
||||
|
||||
export const revalidateTokens = Effect.fn("revaildateTokens")(
|
||||
function* ({ accessToken, idToken, refreshToken, external }: revaildateTokens.Args) {
|
||||
|
||||
const accessTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.AccessTokenPayload>);
|
||||
const idTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.IdTokenPayload>);
|
||||
|
||||
const expirationThreshold = yield* pipe(
|
||||
DateTime.now,
|
||||
Effect.map(DateTime.addDuration(EXPIRATION_BUFFER)),
|
||||
);
|
||||
|
||||
// Token expired or missing
|
||||
if (Option.match(accessTokenPayload, {
|
||||
onNone: constant(false),
|
||||
onSome: (atp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * atp.exp)),
|
||||
}) || Option.match(idTokenPayload, {
|
||||
onNone: constant(false),
|
||||
onSome: (itp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * itp.exp)),
|
||||
})) {
|
||||
|
||||
accessToken = Option.none();
|
||||
idToken = Option.none();
|
||||
|
||||
// try refreshing
|
||||
if (Option.isSome(refreshToken)) {
|
||||
const refreshTokenValue = refreshToken.value;
|
||||
const { tokenEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
||||
|
||||
const res = yield* Effect.promise((signal) => fetch(tokenEndpoint, {
|
||||
method: "POST",
|
||||
signal,
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"client_id": config.CLIENT_ID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshTokenValue,
|
||||
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
||||
}).toString(),
|
||||
}));
|
||||
|
||||
const json = (yield* Effect.promise(() => res.json())) as { access_token: string, refresh_token: string, id_token: string };
|
||||
|
||||
accessToken = Option.some(json.access_token);
|
||||
idToken = Option.some(json.id_token);
|
||||
refreshToken = Option.some(json.refresh_token);
|
||||
}
|
||||
}
|
||||
|
||||
const it = Option.map(idToken, (it) => Object.freeze({
|
||||
token: it,
|
||||
payload: getJwtTokenPayload<Model.IdTokenPayload>(it),
|
||||
}));
|
||||
|
||||
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
|
||||
accessToken: Option.map(accessToken, (at) => Object.freeze({
|
||||
token: at,
|
||||
payload: getJwtTokenPayload<Model.AccessTokenPayload>(at),
|
||||
})),
|
||||
idToken: it,
|
||||
refreshToken,
|
||||
userId: Option.map(it, ({ payload: { oid } }) => oid),
|
||||
roles: Option.match(it, {
|
||||
onNone: constant(Object.freeze([])),
|
||||
onSome: ({ payload: { roles } }) => roles ?? Object.freeze([]),
|
||||
}),
|
||||
});
|
||||
|
||||
return res;
|
||||
},
|
||||
);
|
||||
|
||||
function getJwtTokenPayload<O extends object = object>(token: string): O {
|
||||
return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8")) as O;
|
||||
}
|
||||
|
||||
export namespace generateCodeVerifier {
|
||||
export interface Result {
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(byteLength: number = 32): generateCodeVerifier.Result {
|
||||
const codeVerifierBytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(codeVerifierBytes);
|
||||
const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url");
|
||||
|
||||
const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii");
|
||||
const codeVerifierAsciiArray = new Uint8Array(
|
||||
codeVerifierAsciiBuffer.buffer,
|
||||
codeVerifierAsciiBuffer.byteOffset,
|
||||
codeVerifierAsciiBuffer.length,
|
||||
);
|
||||
const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url");
|
||||
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
export function generateRandomState(byteLength: number = 32): string {
|
||||
const array = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(array);
|
||||
const state = Buffer.from(array).toString("base64url");
|
||||
return state;
|
||||
}
|
||||
|
||||
export namespace getUser {
|
||||
export interface Args {
|
||||
readonly accessToken: string;
|
||||
readonly userId: UserId;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly displayName: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const getUser = Effect.fn("getUser")(
|
||||
function* ({ accessToken, userId }: getUser.Args) {
|
||||
const res = yield* Effect.promise((signal) => fetch(`https://graph.microsoft.com/v1.0/users/${userId}?$select=displayName`, {
|
||||
signal,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
},
|
||||
}));
|
||||
|
||||
if (res.status === 404) {
|
||||
return Option.none();
|
||||
}
|
||||
|
||||
const json = (yield* Effect.promise(() => res.json())) as getUser.Result;
|
||||
return Option.some(json);
|
||||
},
|
||||
);
|
||||
@@ -11,7 +11,7 @@ export const Config = Schema.Struct({
|
||||
),
|
||||
DB_PATH: pipe(
|
||||
Schema.String,
|
||||
Schema.optional,
|
||||
Schema.optionalWith({ default: constant("db.sqlite3") }),
|
||||
),
|
||||
HOSTNAME: Schema.String,
|
||||
NODE_ENV: pipe(
|
||||
@@ -22,8 +22,10 @@ export const Config = Schema.Struct({
|
||||
Schema.NumberFromString,
|
||||
Schema.optionalWith({ default: constant(3000) }),
|
||||
),
|
||||
TENANT_ID: Schema.UUID,
|
||||
TENANT_SUBDOMAIN: Schema.String,
|
||||
OAUTH_AUTHORIZATION_ENDPOINT: Schema.String,
|
||||
OAUTH_TOKEN_ENDPOINT: Schema.String,
|
||||
POCKET_ID_API_ORIGIN: Schema.String,
|
||||
POCKET_ID_API_KEY: Schema.String,
|
||||
});
|
||||
|
||||
export type Config = typeof Config.Type;
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import * as Common from "common";
|
||||
import { unsafeCoerce } from "effect";
|
||||
import { t } from "elysia";
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
readonly aud: string;
|
||||
readonly iss: string;
|
||||
readonly iat: number;
|
||||
readonly nbf: number;
|
||||
readonly exp: number;
|
||||
readonly name: string;
|
||||
readonly oid: Common.UserId;
|
||||
}
|
||||
|
||||
export interface IdTokenPayload {
|
||||
readonly aud: string;
|
||||
readonly iss: string;
|
||||
readonly iat: number;
|
||||
readonly nbf: number;
|
||||
readonly exp: number;
|
||||
readonly name: string;
|
||||
readonly oid: Common.UserId;
|
||||
readonly roles?: readonly string[];
|
||||
}
|
||||
|
||||
const brandedString = <T>() => t.Transform(t.String())
|
||||
.Decode(unsafeCoerce<string, T>)
|
||||
.Encode(unsafeCoerce<T, string>);
|
||||
|
||||
export const Sha256_Hex = brandedString<Common.Sha256_Hex>();
|
||||
export const AttachmentId = brandedString<Common.AttachmentId>();
|
||||
export const PieceId = brandedString<Common.PieceId>();
|
||||
export const RepertoireId = brandedString<Common.RepertoireId>();
|
||||
export const RequestId = brandedString<Common.RequestId>();
|
||||
export const SessionId = brandedString<Common.SessionId>();
|
||||
export const UserId = brandedString<Common.UserId>();
|
||||
|
||||
const SystemInformation = Object.freeze({
|
||||
createdBy: t.Nullable(UserId),
|
||||
createdAt: t.String(),
|
||||
modifiedBy: t.Nullable(UserId),
|
||||
modifiedAt: t.Nullable(t.String()),
|
||||
});
|
||||
|
||||
const Pagination = Object.freeze({
|
||||
offset: t.Optional(t.Numeric({ minimum: 0 })),
|
||||
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 100 })),
|
||||
});
|
||||
|
||||
export const AccessLog = t.Object({
|
||||
requestId: RequestId,
|
||||
timestamp: t.String(),
|
||||
method: t.String(),
|
||||
pathname: t.String(),
|
||||
query: t.String(),
|
||||
ip: t.Nullable(t.String()),
|
||||
});
|
||||
|
||||
export const Attachment = t.Object({
|
||||
attachmentId: AttachmentId,
|
||||
pieceId: PieceId,
|
||||
filename: t.String(),
|
||||
sha256: Sha256_Hex,
|
||||
mediaType: t.String(),
|
||||
...SystemInformation,
|
||||
});
|
||||
|
||||
export const Me = t.Object({
|
||||
userId: UserId,
|
||||
username: t.String(),
|
||||
roles: t.Array(t.String()),
|
||||
});
|
||||
|
||||
export const Piece = t.Object({
|
||||
pieceId: PieceId,
|
||||
name: t.String({ minLength: 1 }),
|
||||
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||
attachments: t.Array(Attachment),
|
||||
...SystemInformation,
|
||||
});
|
||||
|
||||
export const Piece_Post = t.Object({
|
||||
name: t.String({ minLength: 1 }),
|
||||
composer: t.Nullable(t.String({ minLength: 1 })),
|
||||
lyricist: t.Nullable(t.String({ minLength: 1 })),
|
||||
arranger: t.Nullable(t.String({ minLength: 1 })),
|
||||
});
|
||||
|
||||
export const Piece_Query = t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
author: t.Optional(t.String()),
|
||||
...Pagination,
|
||||
});
|
||||
|
||||
export const Repertoire = t.Object({
|
||||
repertoireId: RepertoireId,
|
||||
name: t.String(),
|
||||
entries: t.Array(PieceId),
|
||||
...SystemInformation,
|
||||
});
|
||||
|
||||
export const Repertoire_Query = t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
...Pagination,
|
||||
});
|
||||
|
||||
export const User = t.Object({
|
||||
userId: UserId,
|
||||
displayName: t.String(),
|
||||
});
|
||||
|
||||
export type AccessLog = typeof AccessLog.static;
|
||||
export type Attachment = typeof Attachment.static;
|
||||
export type Me = typeof Me.static;
|
||||
export type Piece = typeof Piece.static;
|
||||
export type Piece_Post = typeof Piece_Post.static;
|
||||
export type Piece_Query = typeof Piece_Query.static;
|
||||
export type Repertoire = typeof Repertoire.static;
|
||||
export type Repertoire_Query = typeof Repertoire_Query.static;
|
||||
export type User = typeof User.static;
|
||||
397
packages/backend/src/services/Authentication.ts
Normal file
397
packages/backend/src/services/Authentication.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { config } from "backend/config";
|
||||
import { BunRequest } from "bun";
|
||||
import { SessionId, UserId } from "common";
|
||||
import { fetch, FetchError } from "common/Fetch";
|
||||
import { Me, NotFound, Other, Role, Unauthenticated } from "common/the_api";
|
||||
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import { sql } from "kysely";
|
||||
import * as Database from "./Database";
|
||||
|
||||
export interface AuthenticationInterface {
|
||||
readonly me: Effect.Effect<Me, Unauthenticated>;
|
||||
readonly logout: Effect.Effect<void>;
|
||||
readonly sessionId: SessionId;
|
||||
readonly getUser: (userId: UserId) => Effect.Effect<Other, FetchError | Unauthenticated | NotFound>;
|
||||
}
|
||||
|
||||
export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { }
|
||||
|
||||
export const OAUTH_SCOPE = "email openid profile offline_access";
|
||||
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login";
|
||||
|
||||
export const EXPIRATION_BUFFER = Duration.seconds(10);
|
||||
|
||||
export const SESSION_COOKIE_NAME = "sessionId";
|
||||
|
||||
const ALL_ROLES = HashSet.fromIterable(Object.values(Role));
|
||||
|
||||
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
|
||||
const database = yield* Database.Database;
|
||||
|
||||
yield* database
|
||||
.deleteFrom("Session")
|
||||
.where(sql`datetime()`, ">=", "expiresAt")
|
||||
.$call(Database.execute);
|
||||
|
||||
const sessionId = pipe(
|
||||
request.cookies.get(SESSION_COOKIE_NAME),
|
||||
Option.fromNullable,
|
||||
Option.map(SessionId.make),
|
||||
Option.getOrElse(generateSessionId),
|
||||
);
|
||||
|
||||
request.cookies.set(SESSION_COOKIE_NAME, sessionId, {
|
||||
expires: yield* pipe(
|
||||
DateTime.now,
|
||||
Effect.map(DateTime.addDuration("7 days")),
|
||||
Effect.map(DateTime.toDateUtc),
|
||||
),
|
||||
httpOnly: true,
|
||||
sameSite: "none",
|
||||
secure: true,
|
||||
});
|
||||
|
||||
const returning = [
|
||||
"sessionId",
|
||||
"accessToken",
|
||||
"codeVerifier",
|
||||
"idToken",
|
||||
"refreshToken",
|
||||
"state",
|
||||
] as const;
|
||||
|
||||
const session = yield* database
|
||||
.updateTable("Session")
|
||||
.set({ expiresAt: sql`datetime('now', '+7 days') ` })
|
||||
.where("sessionId", "=", sessionId)
|
||||
.returning(returning)
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.catchTag("NoSuchElementException", () => database
|
||||
.insertInto("Session")
|
||||
.values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||
.returning(returning)
|
||||
.$call(Database.executeTakeFirstOrDie)
|
||||
));
|
||||
|
||||
const { accessToken, idToken, refreshToken } = yield* revalidateTokens({
|
||||
accessToken: Option.fromNullable(session.accessToken),
|
||||
idToken: Option.fromNullable(session.idToken),
|
||||
refreshToken: Option.fromNullable(session.refreshToken),
|
||||
});
|
||||
|
||||
yield* database
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
accessToken: pipe(
|
||||
accessToken,
|
||||
Option.map((at) => at.token),
|
||||
Option.getOrNull,
|
||||
),
|
||||
idToken: pipe(
|
||||
idToken,
|
||||
Option.map((it) => it.token),
|
||||
Option.getOrNull,
|
||||
),
|
||||
refreshToken: Option.getOrNull(refreshToken),
|
||||
})
|
||||
.$call(Database.execute);
|
||||
|
||||
const state = Object.freeze({
|
||||
sessionId: session.sessionId,
|
||||
accessToken,
|
||||
idToken,
|
||||
refreshToken,
|
||||
codeVerifier: Option.fromNullable(session.codeVerifier),
|
||||
state: Option.fromNullable(session.state),
|
||||
});
|
||||
|
||||
return Object.freeze<AuthenticationInterface>({
|
||||
me: pipe(
|
||||
state.idToken,
|
||||
Option.map(({ payload }) => {
|
||||
const userData: Me = Object.freeze<Me>({
|
||||
userId: payload.sub,
|
||||
displayName: payload.display_name,
|
||||
roles: ALL_ROLES,
|
||||
});
|
||||
return userData;
|
||||
}),
|
||||
Option.match({
|
||||
onNone: () => Effect.fail(Unauthenticated.make()),
|
||||
onSome: Effect.succeed,
|
||||
}),
|
||||
),
|
||||
logout: database
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
accessToken: null,
|
||||
codeVerifier: null,
|
||||
expiresAt: sql`datetime('now', '+7 days')`,
|
||||
idToken: null,
|
||||
refreshToken: null,
|
||||
state: null,
|
||||
})
|
||||
.where("sessionId", "=", state.sessionId)
|
||||
.$call(Database.execute),
|
||||
sessionId: state.sessionId,
|
||||
getUser: (userId) => pipe(
|
||||
getDisplayName(userId),
|
||||
Effect.map((displayName) => Object.freeze<Other>({ userId, displayName })),
|
||||
),
|
||||
});
|
||||
}));
|
||||
|
||||
export const Test = (me: Option.Option<Me>, other: HashMap.HashMap<UserId, Other>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
|
||||
me: Option.match(me, {
|
||||
onNone: () => Effect.fail(Unauthenticated.make()),
|
||||
onSome: (me) => Effect.succeed(me),
|
||||
}),
|
||||
logout: Effect.void,
|
||||
sessionId: generateSessionId(),
|
||||
getUser: Option.match(me, {
|
||||
onNone: () => constant(Effect.fail(Unauthenticated.make())),
|
||||
onSome: () => (userId) => pipe(
|
||||
other,
|
||||
HashMap.get(userId),
|
||||
Option.match({
|
||||
onNone: () => Effect.fail(NotFound.make()),
|
||||
onSome: Effect.succeed,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})));
|
||||
|
||||
export const AccessTokenPayload = Schema.Struct({
|
||||
aud: pipe(
|
||||
Schema.String,
|
||||
Schema.HashSet,
|
||||
),
|
||||
exp: Schema.DateTimeUtc,
|
||||
iat: Schema.DateTimeUtc,
|
||||
iss: Schema.String,
|
||||
sub: UserId,
|
||||
});
|
||||
|
||||
export const IdTokenPayload = Schema.Struct({
|
||||
aud: pipe(
|
||||
Schema.String,
|
||||
Schema.HashSet,
|
||||
),
|
||||
exp: Schema.DateTimeUtc,
|
||||
iat: Schema.DateTimeUtc,
|
||||
iss: Schema.String,
|
||||
sub: UserId,
|
||||
|
||||
name: Schema.String,
|
||||
given_name: Schema.String,
|
||||
family_name: Schema.String,
|
||||
display_name: Schema.String,
|
||||
preferred_username: Schema.String,
|
||||
|
||||
email: Schema.String,
|
||||
email_verified: Schema.Boolean,
|
||||
|
||||
picture: Schema.String,
|
||||
});
|
||||
|
||||
export type AccessTokenPayload = typeof AccessTokenPayload.Type;
|
||||
export type IdTokenPayload = typeof IdTokenPayload.Type;
|
||||
|
||||
function generateCodeVerifier(byteLength: number = 32) {
|
||||
const codeVerifierBytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(codeVerifierBytes);
|
||||
const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url");
|
||||
|
||||
const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii");
|
||||
const codeVerifierAsciiArray = new Uint8Array(
|
||||
codeVerifierAsciiBuffer.buffer,
|
||||
codeVerifierAsciiBuffer.byteOffset,
|
||||
codeVerifierAsciiBuffer.length,
|
||||
);
|
||||
const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url");
|
||||
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
function generateSessionId(byteLength: number = 32): SessionId {
|
||||
const array = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(array);
|
||||
const string = Buffer.from(array).toString("base64url");
|
||||
return SessionId.make(string);
|
||||
};
|
||||
|
||||
function generateRandomState(byteLength: number = 32): string {
|
||||
const array = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(array);
|
||||
const state = Buffer.from(array).toString("base64url");
|
||||
return state;
|
||||
}
|
||||
|
||||
const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
|
||||
const decoder = Schema.decodeUnknown(schema);
|
||||
return (token: string) => {
|
||||
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
|
||||
return pipe(
|
||||
decoder(json),
|
||||
Effect.orDie,
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const getDisplayName = (userId: UserId) => Effect.gen(function* () {
|
||||
const url = new URL(`/api/users/${userId}`, config.POCKET_ID_API_ORIGIN);
|
||||
const res = yield* fetch(url, {
|
||||
headers: {
|
||||
"X-API-KEY": config.POCKET_ID_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return yield* Effect.fail(NotFound.make());
|
||||
}
|
||||
|
||||
const json = yield* Effect.promise(() => res.json());
|
||||
const displayName: string = json.displayName;
|
||||
return displayName;
|
||||
});
|
||||
|
||||
export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () {
|
||||
const database = yield* Database.Database;
|
||||
|
||||
const { codeVerifier, codeChallenge } = generateCodeVerifier();
|
||||
const state = generateRandomState();
|
||||
|
||||
yield* database
|
||||
.updateTable("Session")
|
||||
.set({
|
||||
codeVerifier,
|
||||
state,
|
||||
accessToken: null,
|
||||
idToken: null,
|
||||
refreshToken: null,
|
||||
})
|
||||
.where("sessionId", "=", sessionId)
|
||||
.$call(Database.execute);
|
||||
|
||||
const url = new URL(config.OAUTH_AUTHORIZATION_ENDPOINT);
|
||||
url.searchParams.set("client_id", config.CLIENT_ID);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", OAUTH_SCOPE);
|
||||
url.searchParams.set("response_mode", "form_post");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
namespace revaildateTokens {
|
||||
export interface Args {
|
||||
readonly accessToken: Option.Option<string>;
|
||||
readonly idToken: Option.Option<string>;
|
||||
readonly refreshToken: Option.Option<string>;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly accessToken: Option.Option<{
|
||||
readonly token: string,
|
||||
readonly payload: AccessTokenPayload,
|
||||
}>;
|
||||
readonly idToken: Option.Option<{
|
||||
readonly token: string,
|
||||
readonly payload: IdTokenPayload,
|
||||
}>;
|
||||
readonly refreshToken: Option.Option<string>;
|
||||
}
|
||||
}
|
||||
|
||||
const revalidateTokens = ({ accessToken, idToken, refreshToken }: revaildateTokens.Args) => Effect.gen(function* () {
|
||||
|
||||
const accessTokenPayload = yield* pipe(
|
||||
accessToken,
|
||||
Effect.transposeMapOption(getJwtTokenPayload(AccessTokenPayload)),
|
||||
);
|
||||
|
||||
const idTokenPayload = yield* pipe(
|
||||
idToken,
|
||||
Effect.transposeMapOption(getJwtTokenPayload(IdTokenPayload)),
|
||||
);
|
||||
|
||||
const expirationThreshold = DateTime.addDuration(
|
||||
DateTime.unsafeNow(),
|
||||
EXPIRATION_BUFFER,
|
||||
);
|
||||
|
||||
// Token expired or missing
|
||||
if (Option.match(accessTokenPayload, {
|
||||
onNone: constant(false),
|
||||
onSome: (atp) => DateTime.greaterThan(expirationThreshold, atp.exp),
|
||||
}) || Option.match(idTokenPayload, {
|
||||
onNone: constant(false),
|
||||
onSome: (itp) => DateTime.greaterThan(expirationThreshold, itp.exp),
|
||||
})) {
|
||||
|
||||
accessToken = Option.none();
|
||||
idToken = Option.none();
|
||||
|
||||
// try refreshing
|
||||
if (Option.isSome(refreshToken)) {
|
||||
const refreshTokenValue = refreshToken.value;
|
||||
|
||||
const res = yield* fetch(config.OAUTH_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"client_id": config.CLIENT_ID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshTokenValue,
|
||||
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
const json: {
|
||||
access_token: string,
|
||||
refresh_token: string,
|
||||
id_token: string,
|
||||
} = yield* Effect.promise(() => res.json());
|
||||
|
||||
accessToken = Option.some(json.access_token);
|
||||
idToken = Option.some(json.id_token);
|
||||
refreshToken = Option.some(json.refresh_token);
|
||||
}
|
||||
}
|
||||
|
||||
const it = yield* pipe(
|
||||
idToken,
|
||||
Option.map((it) => pipe(
|
||||
Effect.Do,
|
||||
Effect.let("token", () => it),
|
||||
Effect.bind("payload", ({ token }) => getJwtTokenPayload(IdTokenPayload)(token)),
|
||||
)),
|
||||
Effect.transposeOption,
|
||||
Effect.map(Option.map((it) => Object.freeze(it))),
|
||||
);
|
||||
|
||||
const at = yield* pipe(
|
||||
accessToken,
|
||||
Option.map((at) => pipe(
|
||||
Effect.Do,
|
||||
Effect.let("token", () => at),
|
||||
Effect.bind("payload", ({ token }) => getJwtTokenPayload(AccessTokenPayload)(token)),
|
||||
)),
|
||||
Effect.transposeOption,
|
||||
Effect.map(Option.map((at) => Object.freeze(at))),
|
||||
);
|
||||
|
||||
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
|
||||
accessToken: at,
|
||||
idToken: it,
|
||||
refreshToken,
|
||||
});
|
||||
|
||||
return res;
|
||||
});
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Database as BunSqliteDatabase } from "bun:sqlite";
|
||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, UserId } from "common";
|
||||
import { HashSet } from "effect";
|
||||
import { ColumnType, CompiledQuery, CreateTableBuilder, Kysely, Selectable } from "kysely";
|
||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common";
|
||||
import { Cause, Context, Effect, Either, HashSet, Layer, pipe, Runtime } from "effect";
|
||||
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, Transaction } from "kysely";
|
||||
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
||||
|
||||
export function generateSessionId(byteLength: number = 32): SessionId {
|
||||
const array = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(array);
|
||||
const string = Buffer.from(array).toString("base64url");
|
||||
return SessionId(string);
|
||||
};
|
||||
// --- MARK: KYSELY SCHEMA -----------------------------------------------------
|
||||
|
||||
export interface Database {
|
||||
AccessLog: AccessLogTable;
|
||||
export interface DatabaseSchema {
|
||||
Attachment: AttachmentTable;
|
||||
File: FileTable;
|
||||
Piece: PieceTable;
|
||||
@@ -30,18 +24,9 @@ export interface SystemInformation {
|
||||
modifiedAt: ColumnType<string | null, null, string>;
|
||||
}
|
||||
|
||||
export interface AccessLogTable {
|
||||
requestId: ColumnType<RequestId, RequestId, never>;
|
||||
timestamp: ColumnType<string, string, never>;
|
||||
method: ColumnType<string, string, never>;
|
||||
pathname: ColumnType<string, string, never>;
|
||||
query: ColumnType<string, string, never>;
|
||||
ip: ColumnType<string | null, string | null, never>;
|
||||
}
|
||||
|
||||
export interface AttachmentData {
|
||||
pieceId: ColumnType<PieceId, PieceId, never>;
|
||||
sha256: Sha256_Bin;
|
||||
sha256: Sha256;
|
||||
filename: string;
|
||||
mediaType: string;
|
||||
}
|
||||
@@ -51,7 +36,7 @@ export interface AttachmentTable extends AttachmentData, SystemInformation {
|
||||
}
|
||||
|
||||
export interface FileTable {
|
||||
sha256: ColumnType<Sha256_Bin, Sha256_Bin, never>;
|
||||
sha256: ColumnType<Sha256, Sha256, never>;
|
||||
data: ColumnType<Uint8Array, Uint8Array, never>;
|
||||
}
|
||||
|
||||
@@ -91,7 +76,6 @@ export interface SessionData {
|
||||
accessToken: string | null;
|
||||
idToken: string | null;
|
||||
refreshToken: string | null;
|
||||
external: number | null;
|
||||
}
|
||||
|
||||
export interface SessionTable extends SessionData {
|
||||
@@ -107,7 +91,6 @@ export interface SqliteSchemaTable {
|
||||
sql: ColumnType<string | null, never, never>;
|
||||
}
|
||||
|
||||
export type AccessLog = Selectable<AccessLogTable>;
|
||||
export type Attachment = Selectable<AttachmentTable>;
|
||||
export type File = Selectable<FileTable>;
|
||||
export type Option = Selectable<OptionTable>;
|
||||
@@ -117,65 +100,112 @@ export type RepertoireEntry = Selectable<RepertoireEntryTable>;
|
||||
export type Session = Selectable<SessionTable>;
|
||||
export type SqliteSchema = Selectable<SqliteSchemaTable>;
|
||||
|
||||
function systemInformation<TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) {
|
||||
return schema
|
||||
// --- MARK: EFFECT LAYERS -----------------------------------------------------
|
||||
|
||||
export type ArrayOrSingle<T> = T | readonly T[];
|
||||
|
||||
export type MockData = {
|
||||
readonly [K in keyof DatabaseSchema]?: ArrayOrSingle<Insertable<DatabaseSchema[K]>>;
|
||||
};
|
||||
|
||||
export interface Executable<O> {
|
||||
execute(): Promise<O>;
|
||||
}
|
||||
|
||||
export interface ExecutableTakeFirst<O> {
|
||||
executeTakeFirst(): Promise<O | undefined>;
|
||||
}
|
||||
|
||||
export class Database extends Context.Tag("Db")<Database, Kysely<DatabaseSchema>>() { }
|
||||
|
||||
export const FromPath = (path: string) => Layer.effect(Database, initDatabase(path));
|
||||
|
||||
export const Mocked = (data: MockData) => Layer.effect(Database, Effect.gen(function* () {
|
||||
const database = yield* initDatabase(":memory:");
|
||||
|
||||
yield* transaction(database, (trx) => Effect.gen(function* () {
|
||||
for (const [table, values] of Object.entries(data)) {
|
||||
yield* trx
|
||||
.insertInto(table as keyof typeof data)
|
||||
.values(values)
|
||||
.$call(execute);
|
||||
}
|
||||
}));
|
||||
|
||||
return database;
|
||||
}));
|
||||
|
||||
export const execute = <O>(executable: Executable<O>) => Effect.promise(() => Object.freeze(executable.execute()));
|
||||
|
||||
export const executeTakeFirst = <O>(executable: ExecutableTakeFirst<O>) => pipe(
|
||||
Effect.promise(() => executable.executeTakeFirst()),
|
||||
Effect.flatMap(Effect.fromNullable),
|
||||
);
|
||||
|
||||
export const executeTakeFirstOrDie = <O>(executable: ExecutableTakeFirst<O>) => pipe(
|
||||
Effect.promise(() => executable.executeTakeFirst()),
|
||||
Effect.flatMap(Effect.fromNullable),
|
||||
Effect.orDie,
|
||||
);
|
||||
|
||||
export const transaction = <Schema, A, E, R>(database: Kysely<Schema>, callback: (trx: Transaction<Schema>) => Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => Effect.gen(function* () {
|
||||
const runtime = yield* Effect.runtime<R>();
|
||||
const promise = database.transaction().execute((trx) => Runtime.runPromise(runtime, callback(trx)));
|
||||
return yield* pipe(
|
||||
Effect.tryPromise(() => promise),
|
||||
Effect.catchAll((error) => {
|
||||
if (!Runtime.isFiberFailure(error.error)) {
|
||||
return Effect.die(error);
|
||||
}
|
||||
|
||||
const cause = error.error[Runtime.FiberFailureCauseId] as Cause.Cause<E>;
|
||||
return Either.match(Cause.failureOrCause(cause), {
|
||||
onLeft: Effect.fail,
|
||||
onRight: Effect.die,
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const initDatabase = (filename: string) => Effect.gen(function* () {
|
||||
|
||||
const systemInformation = <TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) => schema
|
||||
.addColumn("createdBy", "text")
|
||||
.addColumn("createdAt", "text", (c) => c.notNull())
|
||||
.addColumn("modifiedBy", "text")
|
||||
.addColumn("modifiedAt", "text");
|
||||
}
|
||||
|
||||
export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kysely<Database>> {
|
||||
|
||||
const database = new BunSqliteDatabase(filename, { create: true, readwrite: true });
|
||||
const dialect = new BunSqliteDialect({ database });
|
||||
const db = new Kysely<Database>({ dialect });
|
||||
const db = new Kysely<DatabaseSchema>({ dialect });
|
||||
|
||||
await db.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON"));
|
||||
yield* Effect.promise(() => db.executeQuery(CompiledQuery.raw("PRAGMA foreign_keys = ON")));
|
||||
|
||||
const tables = await db
|
||||
const tables = yield* db
|
||||
.selectFrom("sqlite_schema")
|
||||
.select("name")
|
||||
.where("type", "=", "table")
|
||||
.execute()
|
||||
.then((tables) => HashSet.make(...tables.map(({ name }) => name)));
|
||||
.$call(execute)
|
||||
.pipe(Effect.map((tables) => HashSet.make(...tables.map(({ name }) => name))));
|
||||
|
||||
if (!HashSet.has(tables, "Option")) {
|
||||
await db.schema
|
||||
.createTable("AccessLog")
|
||||
.ifNotExists()
|
||||
.addColumn("requestId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("timestamp", "text", (c) => c.notNull())
|
||||
.addColumn("method", "text", (c) => c.notNull())
|
||||
.addColumn("pathname", "text", (c) => c.notNull())
|
||||
.addColumn("query", "text", (c) => c.notNull())
|
||||
.addColumn("ip", "text")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("AccessLog_timestamp")
|
||||
.ifNotExists()
|
||||
.on("AccessLog")
|
||||
.column("timestamp")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createTable("File")
|
||||
.ifNotExists()
|
||||
.addColumn("sha256", "blob", (c) => c.notNull().primaryKey())
|
||||
.addColumn("data", "blob", (c) => c.notNull())
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createTable("User")
|
||||
.ifNotExists()
|
||||
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("username", "text", (c) => c.notNull().unique())
|
||||
.addColumn("password", "text", (c) => c.notNull())
|
||||
.addColumn("admin", "boolean", (c) => c.notNull())
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createTable("Piece")
|
||||
.ifNotExists()
|
||||
.addColumn("pieceId", "text", (c) => c.notNull().primaryKey())
|
||||
@@ -184,40 +214,40 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
|
||||
.addColumn("lyricist", "text")
|
||||
.addColumn("arranger", "text")
|
||||
.$call(systemInformation)
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createIndex("Piece_name_composer_arranger")
|
||||
.ifNotExists()
|
||||
.on("Piece")
|
||||
.columns(["name", "composer", "arranger"])
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createTable("Repertoire")
|
||||
.ifNotExists()
|
||||
.addColumn("repertoireId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("name", "text", (c) => c.notNull())
|
||||
.$call(systemInformation)
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createIndex("Repertoire_name")
|
||||
.ifNotExists()
|
||||
.on("Repertoire")
|
||||
.column("name")
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createTable("RepertoireEntry")
|
||||
.ifNotExists()
|
||||
.addColumn("repertoireId", "text", (c) => c.notNull().references("Repertoire.repertoireId").onDelete("cascade").onUpdate("cascade"))
|
||||
.addColumn("order", "integer", (c) => c.notNull())
|
||||
.addColumn("pieceId", "text", (c) => c.notNull().references("Piece.pieceId").onDelete("restrict").onUpdate("restrict"))
|
||||
.addPrimaryKeyConstraint("pk_RepertoireEntry", ["repertoireId", "order"])
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createTable("Session")
|
||||
.ifNotExists()
|
||||
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
||||
@@ -226,11 +256,10 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
|
||||
.addColumn("accessToken", "text")
|
||||
.addColumn("idToken", "text")
|
||||
.addColumn("refreshToken", "text")
|
||||
.addColumn("external", "boolean")
|
||||
.addColumn("expiresAt", "text", (c) => c.notNull())
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createTable("Attachment")
|
||||
.ifNotExists()
|
||||
.addColumn("attachmentId", "text", (c) => c.notNull().primaryKey())
|
||||
@@ -239,27 +268,27 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
|
||||
.addColumn("filename", "text", (c) => c.notNull())
|
||||
.addColumn("mediaType", "text", (c) => c.notNull())
|
||||
.$call(systemInformation)
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createIndex("Attachment_pieceId_filename")
|
||||
.ifNotExists()
|
||||
.on("Attachment")
|
||||
.columns(["pieceId", "filename"])
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db.schema
|
||||
yield* db.schema
|
||||
.createTable("Option")
|
||||
.ifNotExists()
|
||||
.addColumn("key", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("value", "text", (c) => c.notNull())
|
||||
.execute();
|
||||
.$call(execute);
|
||||
|
||||
await db
|
||||
yield* db
|
||||
.insertInto("Option")
|
||||
.values({ key: "database_version", value: "0" })
|
||||
.execute();
|
||||
.$call(execute);
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Cause, Context, Effect, Layer, pipe } from "effect";
|
||||
import { Kysely } from "kysely";
|
||||
import { Database } from "../database";
|
||||
|
||||
export interface Executable<O> {
|
||||
execute(): Promise<O[]>;
|
||||
executeTakeFirst(): Promise<O | undefined>;
|
||||
}
|
||||
|
||||
export interface DbInterface {
|
||||
readonly db: Kysely<Database>;
|
||||
readonly execute: <O>(executable: Executable<O>) => Effect.Effect<readonly O[]>;
|
||||
readonly executeTakeFirst: <O>(executable: Executable<O>) => Effect.Effect<O, Cause.NoSuchElementException>;
|
||||
readonly executeTakeFirstOrDefect: <O>(executable: Executable<O>) => Effect.Effect<O>;
|
||||
}
|
||||
|
||||
export class Db extends Context.Tag("Db")<Db, DbInterface>() { }
|
||||
|
||||
export const DbFromInstance = (db: Kysely<Database>) => Layer.succeed(Db, Object.freeze<DbInterface>({
|
||||
db,
|
||||
execute: (executable) => Effect.promise(() => Object.freeze(executable.execute())),
|
||||
executeTakeFirst: (executable) => pipe(
|
||||
Effect.promise(() => executable.executeTakeFirst()),
|
||||
Effect.flatMap(Effect.fromNullable),
|
||||
),
|
||||
executeTakeFirstOrDefect: (executable) => pipe(
|
||||
Effect.promise(() => executable.executeTakeFirst()),
|
||||
Effect.flatMap(Effect.fromNullable),
|
||||
Effect.orDie,
|
||||
),
|
||||
}));
|
||||
@@ -1,6 +0,0 @@
|
||||
import { SessionId } from "common";
|
||||
import { Context, Layer } from "effect";
|
||||
|
||||
export class Session extends Context.Tag("Session")<Session, SessionId>() { }
|
||||
|
||||
export const SessionFromValue = Layer.succeed(Session);
|
||||
486
packages/backend/src/the_api.ts
Normal file
486
packages/backend/src/the_api.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { AttachmentId, PieceId, RepertoireId, Sha256 } from "common";
|
||||
import api, { NotFound, Role, Unauthorized } from "common/the_api";
|
||||
import { DateTime, Effect, HashSet, Option, pipe } from "effect";
|
||||
import { sql } from "kysely";
|
||||
import { implement } from "./api";
|
||||
import * as Authentication from "./services/Authentication";
|
||||
import * as Database from "./services/Database";
|
||||
|
||||
const READ_ACCESS = HashSet.make(Role.Editor, Role.Viewer);
|
||||
const WRITE_ACCESS = HashSet.make(Role.Editor);
|
||||
|
||||
const requireReadAccess = pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ me }) => me),
|
||||
Effect.flatMap((user) => HashSet.isSubset(user.roles, READ_ACCESS)
|
||||
? Effect.succeed(user)
|
||||
: Effect.fail(Unauthorized.make())
|
||||
),
|
||||
);
|
||||
|
||||
const requireWriteAccess = pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ me }) => me),
|
||||
Effect.flatMap((user) => HashSet.isSubset(user.roles, WRITE_ACCESS)
|
||||
? Effect.succeed(user)
|
||||
: Effect.fail(Unauthorized.make())
|
||||
),
|
||||
);
|
||||
|
||||
export const handle = implement(api, {
|
||||
|
||||
// --- Authentication ---
|
||||
|
||||
me: () => pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ me }) => me),
|
||||
),
|
||||
logout: () => pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ logout }) => logout),
|
||||
),
|
||||
getUser: (userId) => pipe(
|
||||
Authentication.Authentication,
|
||||
Effect.flatMap(({ getUser }) => getUser(userId)),
|
||||
),
|
||||
|
||||
// --- Piece CRUD ---
|
||||
|
||||
createPiece: (piece) => Effect.gen(function* () {
|
||||
const { userId } = yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const res = yield* db
|
||||
.insertInto("Piece")
|
||||
.values({
|
||||
pieceId: PieceId.make(Bun.randomUUIDv7()),
|
||||
name: piece.name,
|
||||
composer: Option.getOrNull(piece.composer),
|
||||
lyricist: Option.getOrNull(piece.lyricist),
|
||||
arranger: Option.getOrNull(piece.arranger),
|
||||
createdBy: userId,
|
||||
createdAt: sql`datetime()`,
|
||||
})
|
||||
.returningAll()
|
||||
.$call(Database.executeTakeFirstOrDie);
|
||||
|
||||
return {
|
||||
pieceId: res.pieceId,
|
||||
name: res.name,
|
||||
composer: Option.fromNullable(res.composer),
|
||||
lyricist: Option.fromNullable(res.lyricist),
|
||||
arranger: Option.fromNullable(res.arranger),
|
||||
attachments: [],
|
||||
createdBy: Option.fromNullable(res.createdBy),
|
||||
createdAt: DateTime.unsafeMake(res.createdAt),
|
||||
modifiedBy: Option.fromNullable(res.modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
res.modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
};
|
||||
}),
|
||||
getPiece: (pieceId) => Effect.gen(function* () {
|
||||
yield* requireReadAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const piece = yield* db
|
||||
.selectFrom("Piece")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
|
||||
const attachments = yield* db
|
||||
.selectFrom("Attachment")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.$call(Database.execute);
|
||||
|
||||
return {
|
||||
pieceId: piece.pieceId,
|
||||
name: piece.name,
|
||||
composer: Option.fromNullable(piece.composer),
|
||||
lyricist: Option.fromNullable(piece.lyricist),
|
||||
arranger: Option.fromNullable(piece.arranger),
|
||||
attachments: attachments.map(({
|
||||
createdBy,
|
||||
createdAt,
|
||||
modifiedBy,
|
||||
modifiedAt,
|
||||
...rest
|
||||
}) => ({
|
||||
createdBy: Option.fromNullable(createdBy),
|
||||
createdAt: DateTime.unsafeMake(createdAt),
|
||||
modifiedBy: Option.fromNullable(modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
...rest,
|
||||
})),
|
||||
createdBy: Option.fromNullable(piece.createdBy),
|
||||
createdAt: DateTime.unsafeMake(piece.createdAt),
|
||||
modifiedBy: Option.fromNullable(piece.modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
piece.modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
};
|
||||
}),
|
||||
queryPieces: ({ name, author, offset, limit }) => Effect.gen(function* () {
|
||||
yield* requireReadAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
let query = db
|
||||
.selectFrom("Piece")
|
||||
.select("pieceId")
|
||||
.orderBy(["name", "composer", "arranger"])
|
||||
.offset(offset)
|
||||
.limit(limit);
|
||||
|
||||
query = Option.match(name, {
|
||||
onNone: () => query,
|
||||
onSome: (name) => query.where("name", "like", "%" + name + "%"),
|
||||
});
|
||||
|
||||
query = Option.match(author, {
|
||||
onNone: () => query,
|
||||
onSome: (author) => query.where((eb) => eb.or([
|
||||
eb("composer", "like", "%" + author + "%"),
|
||||
eb("arranger", "like", "%" + author + "%"),
|
||||
eb("lyricist", "like", "%" + author + "%"),
|
||||
]))
|
||||
});
|
||||
|
||||
const res = yield* query.$call(Database.execute);
|
||||
return res.map(({ pieceId }) => pieceId);
|
||||
}),
|
||||
updatePiece: ({ pieceId, ...piece }) => Effect.gen(function* () {
|
||||
const { userId } = yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const res = yield* db
|
||||
.updateTable("Piece")
|
||||
.set({
|
||||
name: piece.name,
|
||||
composer: Option.getOrUndefined(piece.composer),
|
||||
lyricist: Option.getOrUndefined(piece.lyricist),
|
||||
arranger: Option.getOrUndefined(piece.arranger),
|
||||
modifiedBy: userId,
|
||||
modifiedAt: sql`datetime()`,
|
||||
})
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returningAll()
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
|
||||
const attachments = yield* db
|
||||
.selectFrom("Attachment")
|
||||
.selectAll()
|
||||
.where("pieceId", "=", pieceId)
|
||||
.$call(Database.execute);
|
||||
|
||||
return {
|
||||
pieceId: res.pieceId,
|
||||
name: res.name,
|
||||
composer: Option.fromNullable(res.composer),
|
||||
lyricist: Option.fromNullable(res.lyricist),
|
||||
arranger: Option.fromNullable(res.arranger),
|
||||
attachments: attachments.map(({
|
||||
createdBy,
|
||||
createdAt,
|
||||
modifiedBy,
|
||||
modifiedAt,
|
||||
...rest
|
||||
}) => ({
|
||||
createdBy: Option.fromNullable(createdBy),
|
||||
createdAt: DateTime.unsafeMake(createdAt),
|
||||
modifiedBy: Option.fromNullable(modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
...rest,
|
||||
})),
|
||||
createdBy: Option.fromNullable(res.createdBy),
|
||||
createdAt: DateTime.unsafeMake(res.createdAt),
|
||||
modifiedBy: Option.fromNullable(res.modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
res.modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
};
|
||||
}),
|
||||
deletePiece: (pieceId) => Effect.gen(function* () {
|
||||
yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
yield* db
|
||||
.deleteFrom("Piece")
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returning("pieceId")
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
}),
|
||||
|
||||
// --- Attachment CRUD ---
|
||||
|
||||
createAttachment: (attachment) => Effect.gen(function* () {
|
||||
const { userId } = yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength));
|
||||
Bun.SHA256.hash(attachment.data, sha256);
|
||||
|
||||
yield* db
|
||||
.insertInto("File")
|
||||
.values({ sha256, data: attachment.data })
|
||||
.onConflict((cb) => cb.column("sha256").doNothing())
|
||||
.$call(Database.execute);
|
||||
|
||||
const res = yield* db
|
||||
.insertInto("Attachment")
|
||||
.values({
|
||||
attachmentId: AttachmentId.make(Bun.randomUUIDv7()),
|
||||
pieceId: attachment.pieceId,
|
||||
filename: attachment.filename,
|
||||
mediaType: attachment.mediaType,
|
||||
sha256,
|
||||
createdBy: userId,
|
||||
createdAt: sql`datetime()`,
|
||||
})
|
||||
.returningAll()
|
||||
.$call(Database.executeTakeFirstOrDie);
|
||||
|
||||
return {
|
||||
attachmentId: res.attachmentId,
|
||||
pieceId: res.pieceId,
|
||||
filename: res.filename,
|
||||
sha256: res.sha256,
|
||||
mediaType: res.mediaType,
|
||||
createdBy: Option.fromNullable(res.createdBy),
|
||||
createdAt: DateTime.unsafeMake(res.createdAt),
|
||||
modifiedBy: Option.fromNullable(res.modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
res.modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
};
|
||||
}),
|
||||
getAttachment: (attachmentId) => Effect.gen(function* () {
|
||||
yield* requireReadAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const res = yield* db
|
||||
.selectFrom("File")
|
||||
.innerJoin("Attachment", "File.sha256", "Attachment.sha256")
|
||||
.select(["Attachment.filename", "Attachment.mediaType", "File.data"])
|
||||
.where("Attachment.attachmentId", "=", attachmentId)
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
|
||||
return res;
|
||||
}),
|
||||
updateAttachment: ({ attachmentId, ...attachment }) => Effect.gen(function* () {
|
||||
const { userId } = yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const res = yield* db
|
||||
.updateTable("Attachment")
|
||||
.set({
|
||||
...attachment,
|
||||
modifiedBy: userId,
|
||||
modifiedAt: sql`datetime()`,
|
||||
})
|
||||
.returningAll()
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
|
||||
return {
|
||||
attachmentId: res.attachmentId,
|
||||
pieceId: res.pieceId,
|
||||
filename: res.filename,
|
||||
sha256: res.sha256,
|
||||
mediaType: res.mediaType,
|
||||
createdBy: Option.fromNullable(res.createdBy),
|
||||
createdAt: DateTime.unsafeMake(res.createdAt),
|
||||
modifiedBy: Option.fromNullable(res.modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
res.modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
};
|
||||
}),
|
||||
deleteAttachment: (attachmentId) => Effect.gen(function* () {
|
||||
yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
yield* db
|
||||
.deleteFrom("Attachment")
|
||||
.where("attachmentId", "=", attachmentId)
|
||||
.returning("attachmentId")
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
}),
|
||||
|
||||
// --- Repertoire CRUD ---
|
||||
|
||||
createRepertoire: (repertoire) => Effect.gen(function* () {
|
||||
const { userId } = yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const repertoireId = RepertoireId.make(Bun.randomUUIDv7());
|
||||
|
||||
const res = yield* db
|
||||
.insertInto("Repertoire")
|
||||
.values({
|
||||
repertoireId,
|
||||
name: repertoire.name,
|
||||
createdBy: userId,
|
||||
createdAt: sql`datetime()`,
|
||||
})
|
||||
.returningAll()
|
||||
.$call(Database.executeTakeFirstOrDie);
|
||||
|
||||
const entries = yield* pipe(
|
||||
repertoire.entries,
|
||||
Effect.forEach((pieceId, i) => db
|
||||
.insertInto("RepertoireEntry")
|
||||
.values({ pieceId, repertoireId, order: i })
|
||||
.returningAll()
|
||||
.$call(Database.executeTakeFirstOrDie)
|
||||
.pipe(Effect.map(({ pieceId }) => pieceId)),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
repertoireId: res.repertoireId,
|
||||
name: res.name,
|
||||
entries,
|
||||
createdBy: Option.fromNullable(res.createdBy),
|
||||
createdAt: DateTime.unsafeMake(res.createdAt),
|
||||
modifiedBy: Option.fromNullable(res.modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
res.modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
};
|
||||
}),
|
||||
getRepertoire: (repertoireId) => Effect.gen(function* () {
|
||||
yield* requireReadAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const repertoire = yield* db
|
||||
.selectFrom("Repertoire")
|
||||
.selectAll()
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
|
||||
const entries = yield* db
|
||||
.selectFrom("RepertoireEntry")
|
||||
.select("pieceId")
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.orderBy("order")
|
||||
.$call(Database.execute);
|
||||
|
||||
return {
|
||||
repertoireId: repertoire.repertoireId,
|
||||
name: repertoire.name,
|
||||
entries: entries.map(({ pieceId }) => pieceId),
|
||||
createdBy: Option.fromNullable(repertoire.createdBy),
|
||||
createdAt: DateTime.unsafeMake(repertoire.createdAt),
|
||||
modifiedBy: Option.fromNullable(repertoire.modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
repertoire.modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
};
|
||||
}),
|
||||
queryRepertoire: ({ name, offset, limit }) => Effect.gen(function* () {
|
||||
yield* requireReadAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
let query = db
|
||||
.selectFrom("Repertoire")
|
||||
.select("repertoireId")
|
||||
.orderBy("name")
|
||||
.offset(offset)
|
||||
.limit(limit);
|
||||
|
||||
query = Option.match(name, {
|
||||
onNone: () => query,
|
||||
onSome: (name) => query.where("name", "like", "%" + name + "%"),
|
||||
});
|
||||
|
||||
const res = yield* query.$call(Database.execute);
|
||||
return res.map(({ repertoireId }) => repertoireId);
|
||||
}),
|
||||
updateRepertoire: ({ repertoireId, ...repertoire }) => Effect.gen(function* () {
|
||||
const { userId } = yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
const res = yield* db
|
||||
.updateTable("Repertoire")
|
||||
.set({
|
||||
name: repertoire.name,
|
||||
modifiedBy: userId,
|
||||
modifiedAt: sql`datetime()`,
|
||||
})
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.returningAll()
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
|
||||
yield* db
|
||||
.deleteFrom("RepertoireEntry")
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.$call(Database.execute);
|
||||
|
||||
const entries = yield* pipe(
|
||||
repertoire.entries,
|
||||
Effect.forEach((pieceId, i) => db
|
||||
.insertInto("RepertoireEntry")
|
||||
.values({ pieceId, repertoireId, order: i })
|
||||
.returningAll()
|
||||
.$call(Database.executeTakeFirstOrDie)
|
||||
.pipe(Effect.map(({ pieceId }) => pieceId)),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
repertoireId: res.repertoireId,
|
||||
name: res.name,
|
||||
entries,
|
||||
createdBy: Option.fromNullable(res.createdBy),
|
||||
createdAt: DateTime.unsafeMake(res.createdAt),
|
||||
modifiedBy: Option.fromNullable(res.modifiedBy),
|
||||
modifiedAt: pipe(
|
||||
res.modifiedAt,
|
||||
Option.fromNullable,
|
||||
Option.map(DateTime.unsafeMake),
|
||||
),
|
||||
};
|
||||
}),
|
||||
deleteRepertoire: (repertoireId) => Effect.gen(function* () {
|
||||
yield* requireWriteAccess;
|
||||
const db = yield* Database.Database;
|
||||
|
||||
yield* db
|
||||
.deleteFrom("Repertoire")
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.returning("repertoireId")
|
||||
.$call(Database.executeTakeFirst)
|
||||
.pipe(Effect.mapError(() => NotFound.make()));
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user