Use Azure auth for no good reason
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.env
|
||||
build
|
||||
db.sqlite3
|
||||
dist
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@elysiajs/static": "catalog:",
|
||||
"@elysiajs/swagger": "catalog:",
|
||||
"common": "workspace:^",
|
||||
"effect": "catalog:",
|
||||
"elysia": "catalog:",
|
||||
"kysely": "catalog:",
|
||||
"kysely-bun-sqlite": "catalog:"
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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, UserId } from "common";
|
||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex } from "common";
|
||||
import { Effect, Option, pipe } from "effect";
|
||||
import { Elysia, error, t } from "elysia";
|
||||
import { sql } from "kysely";
|
||||
import { CLIENT_ID, EXTERNAL_OAUTH_CONFIGURATION, getUser, INTERNAL_OAUTH_CONFIGURATION, makeAuthorizationUrl, REDIRECT_URI, revalidateTokens } from "./auth";
|
||||
import * as Db from "./database";
|
||||
import * as Model from "./model";
|
||||
import { DbFromInstance } from "./services/db";
|
||||
import { SessionFromValue } from "./services/session";
|
||||
|
||||
const app = new Elysia()
|
||||
|
||||
@@ -36,37 +40,85 @@ const app = new Elysia()
|
||||
.where(sql`datetime()`, ">=", "expiresAt")
|
||||
.execute();
|
||||
|
||||
const sessionId = cookie.sessionId.value;
|
||||
if (sessionId === undefined) {
|
||||
return { user: null };
|
||||
}
|
||||
const sessionId = (cookie.sessionId.value as SessionId | undefined) ?? Db.generateSessionId();
|
||||
|
||||
const res = await db
|
||||
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(sessionId))
|
||||
.returning(["userId"])
|
||||
.where("sessionId", "=", sessionId)
|
||||
.returning(returning)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (res === undefined) {
|
||||
return { user: null };
|
||||
if (session === undefined) {
|
||||
session = await db
|
||||
.insertInto("Session")
|
||||
.values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||
.returning(returning)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.select(["userId", "username", "admin"])
|
||||
.where("userId", "=", res.userId)
|
||||
.executeTakeFirst();
|
||||
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,
|
||||
);
|
||||
|
||||
if (user === undefined) {
|
||||
return { user: null };
|
||||
}
|
||||
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 {
|
||||
user: {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin !== 0,
|
||||
session: {
|
||||
sessionId: session.sessionId,
|
||||
accessToken,
|
||||
idToken,
|
||||
refreshToken,
|
||||
roles,
|
||||
userId,
|
||||
codeVerifier: Option.fromNullable(session.codeVerifier),
|
||||
external: pipe(
|
||||
session.external,
|
||||
Option.fromNullable,
|
||||
Option.map((e) => e !== 0),
|
||||
),
|
||||
state: Option.fromNullable(session.state),
|
||||
},
|
||||
};
|
||||
})
|
||||
@@ -100,66 +152,87 @@ const app = new Elysia()
|
||||
|
||||
// --- MARK: AUTHENTICATION --------------------------------------------
|
||||
|
||||
.get("/me", ({ user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
return user;
|
||||
.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.User,
|
||||
200: Model.Me,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
},
|
||||
})
|
||||
|
||||
.post("/login", async ({ db, body: { username, password }, cookie }) => {
|
||||
.get("/login", async ({ db, query, redirect, session: { sessionId } }) => {
|
||||
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.selectAll()
|
||||
.where("username", "=", username)
|
||||
.executeTakeFirst();
|
||||
const url = await pipe(
|
||||
makeAuthorizationUrl({ external: "external" in query }),
|
||||
Effect.provide([
|
||||
DbFromInstance(db),
|
||||
SessionFromValue(sessionId),
|
||||
]),
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
if (user === undefined) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
return redirect(url, 302);
|
||||
}, {
|
||||
response: {
|
||||
302: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
const valid = await Bun.password.verify(password, user.password);
|
||||
if (!valid) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
.post("/login", async ({ db, request, redirect, session: { sessionId, external, codeVerifier } }) => {
|
||||
const data = await request.formData();
|
||||
|
||||
const sessionId = Db.generateSessionId();
|
||||
await db
|
||||
.insertInto("Session")
|
||||
.values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||
.execute();
|
||||
const code = data.get("code") as string | null;
|
||||
const state = data.get("state") as string | null;
|
||||
|
||||
const expiresAt = new Date().getTime() + 604800000;
|
||||
cookie.sessionId.set({
|
||||
value: sessionId,
|
||||
expires: new Date(expiresAt),
|
||||
httpOnly: true,
|
||||
sameSite: "none",
|
||||
secure: true,
|
||||
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": CLIENT_ID,
|
||||
"code": code,
|
||||
"redirect_uri": REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
"code_verifier": codeVerifier.value,
|
||||
"client_secret": process.env.CLIENT_SECRET!,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin !== 0,
|
||||
sessionId,
|
||||
};
|
||||
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(process.env.NODE_ENV === "production" ? "https://music.renati.me/" : "http://localhost:5173/", 303);
|
||||
}, {
|
||||
body: t.Object({
|
||||
username: t.String({ minLength: 1 }),
|
||||
password: t.String({ minLength: 1 }),
|
||||
}),
|
||||
response: {
|
||||
200: Model.User,
|
||||
401: t.Literal("Invalid username or password"),
|
||||
303: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -180,142 +253,29 @@ const app = new Elysia()
|
||||
.where("sessionId", "=", SessionId(sessionId))
|
||||
.execute();
|
||||
}, {
|
||||
|
||||
response: {
|
||||
204: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
.post("/change-password", async ({ db, body: { username, currentPassword, newPassword }, set }) => {
|
||||
|
||||
const user = await db
|
||||
.selectFrom("User")
|
||||
.selectAll()
|
||||
.where("username", "=", username)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (user === undefined) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
|
||||
const valid = await Bun.password.verify(currentPassword, user.password);
|
||||
if (!valid) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
|
||||
const password = await Bun.password.hash(newPassword);
|
||||
|
||||
const res = await db
|
||||
.updateTable("User")
|
||||
.set({ password })
|
||||
.where("username", "=", username)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Unauthorized", "Invalid username or password");
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
body: t.Object({
|
||||
username: t.String({ minLength: 1 }),
|
||||
currentPassword: t.String({ minLength: 1 }),
|
||||
newPassword: t.String({ minLength: 1 }),
|
||||
}),
|
||||
response: {
|
||||
204: t.Void(),
|
||||
401: t.Literal("Invalid username or password"),
|
||||
},
|
||||
})
|
||||
|
||||
// --- MARK: USER MANAGEMENT -------------------------------------------
|
||||
|
||||
.post("/user", async ({ db, body: { username, password, admin }, user }) => {
|
||||
.get("/user/:userId", async ({ params: { userId }, session: { accessToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(accessToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return error("Forbidden", "Operation allowed only for admin users");
|
||||
}
|
||||
const res = await pipe(
|
||||
{ accessToken: accessToken.value.token, userId },
|
||||
getUser,
|
||||
Effect.runPromise,
|
||||
);
|
||||
|
||||
const userId = UserId(Bun.randomUUIDv7());
|
||||
|
||||
const newUser = await db
|
||||
.insertInto("User")
|
||||
.values({
|
||||
userId,
|
||||
username,
|
||||
password: await Bun.password.hash(password),
|
||||
admin: admin ? 1 : 0,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
userId: newUser.userId,
|
||||
username: newUser.username,
|
||||
admin: newUser.admin !== 0,
|
||||
};
|
||||
}, {
|
||||
body: Model.User_Post,
|
||||
response: {
|
||||
200: Model.User,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Operation allowed only for admin users"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/user", async ({ db, query, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
let q = db
|
||||
.selectFrom("User")
|
||||
.select("userId")
|
||||
.orderBy(["username", "userId"])
|
||||
.offset(query.offset ?? 0)
|
||||
.limit(query.limit ?? 100);
|
||||
|
||||
if (query.username !== undefined) {
|
||||
q = q.where("username", "like", "%" + query.username + "%");
|
||||
}
|
||||
|
||||
const res = await q.execute();
|
||||
return res.map(({ userId }) => userId);
|
||||
}, {
|
||||
query: Model.User_Query,
|
||||
response: {
|
||||
200: t.Array(Model.UserId),
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/user/:userId", async ({ db, params: { userId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.selectFrom("User")
|
||||
.select(["userId", "username", "admin"])
|
||||
.where("userId", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (res === undefined) {
|
||||
return error("Not Found", undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
userId: res.userId,
|
||||
username: res.username,
|
||||
admin: res.admin !== 0,
|
||||
};
|
||||
return Option.match(res, {
|
||||
onNone: () => error("Not Found", undefined),
|
||||
onSome: ({ displayName }) => ({ userId, displayName }),
|
||||
});
|
||||
}, {
|
||||
params: t.Object({
|
||||
userId: Model.UserId,
|
||||
@@ -327,95 +287,23 @@ const app = new Elysia()
|
||||
},
|
||||
})
|
||||
|
||||
.patch("/user/:userId", async ({ db, body: { username, password, admin }, params: { userId }, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return error("Forbidden", "Operation allowed only for admin users");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("User")
|
||||
.set({
|
||||
...(username !== undefined ? { username } : undefined),
|
||||
...(password !== undefined ? { password: await Bun.password.hash(password) } : undefined),
|
||||
...(admin !== undefined ? { admin: admin ? 1 : 0 } : undefined),
|
||||
})
|
||||
.where("userId", "=", userId)
|
||||
.returning(["userId", "username", "admin"])
|
||||
.executeTakeFirst();
|
||||
|
||||
if (res === undefined) {
|
||||
return error("Not Found", undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
userId: res.userId,
|
||||
username: res.username,
|
||||
admin: res.admin !== 0,
|
||||
};
|
||||
}, {
|
||||
body: Model.User_Patch,
|
||||
params: t.Object({
|
||||
userId: Model.UserId,
|
||||
}),
|
||||
response: {
|
||||
200: Model.User,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Operation allowed only for admin users"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
.delete("/user/:userId", async ({ db, params: { userId }, set, user }) => {
|
||||
|
||||
if (user === null) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!user.admin) {
|
||||
return error("Forbidden", "Operation allowed only for admin users");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("User")
|
||||
.where("userId", "=", userId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
|
||||
if (res.length === 0) {
|
||||
return error("Not Found");
|
||||
}
|
||||
|
||||
set.status = "No Content";
|
||||
}, {
|
||||
params: t.Object({
|
||||
userId: Model.UserId,
|
||||
}),
|
||||
response: {
|
||||
204: t.Void(),
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Operation allowed only for admin users"),
|
||||
404: t.Void(),
|
||||
},
|
||||
})
|
||||
|
||||
// --- MARK: PIECE CRUD ------------------------------------------------
|
||||
|
||||
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
|
||||
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.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: user.userId, createdAt: sql`datetime()` })
|
||||
.values({ pieceId, name, composer, lyricist, arranger, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
@@ -428,12 +316,13 @@ const app = new Elysia()
|
||||
response: {
|
||||
200: Model.Piece,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/piece", async ({ db, query, user }) => {
|
||||
.get("/piece", async ({ db, query, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
@@ -466,9 +355,9 @@ const app = new Elysia()
|
||||
},
|
||||
})
|
||||
|
||||
.get("/piece/:pieceId", async ({ db, params: { pieceId }, user }) => {
|
||||
.get("/piece/:pieceId", async ({ db, params: { pieceId }, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
@@ -506,15 +395,19 @@ const app = new Elysia()
|
||||
},
|
||||
})
|
||||
|
||||
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
|
||||
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Piece")
|
||||
.set({ name, composer, lyricist, arranger, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
||||
.set({ name, composer, lyricist, arranger, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||
.where("pieceId", "=", pieceId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
@@ -549,16 +442,21 @@ const app = new Elysia()
|
||||
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, user }) => {
|
||||
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Piece")
|
||||
.where("pieceId", "=", pieceId)
|
||||
@@ -583,12 +481,16 @@ const app = new Elysia()
|
||||
|
||||
// --- MARK: ATTACHMENT CRUD -------------------------------------------
|
||||
|
||||
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => {
|
||||
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const attachmentId = AttachmentId(Bun.randomUUIDv7());
|
||||
const dataArray = new Uint8Array(await data.arrayBuffer());
|
||||
|
||||
@@ -603,7 +505,7 @@ const app = new Elysia()
|
||||
|
||||
const res = await db
|
||||
.insertInto("Attachment")
|
||||
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` })
|
||||
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
@@ -623,6 +525,7 @@ const app = new Elysia()
|
||||
response: {
|
||||
200: Model.Attachment,
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -633,9 +536,9 @@ const app = new Elysia()
|
||||
* hash). Thus, we reflect the ownership in the URLs.
|
||||
*/
|
||||
|
||||
.get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => {
|
||||
.get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, session: { idToken }, set }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
@@ -668,15 +571,19 @@ const app = new Elysia()
|
||||
},
|
||||
})
|
||||
|
||||
.put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => {
|
||||
.put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Attachment")
|
||||
.set({ filename, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
||||
.set({ filename, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||
.where((eb) => eb.and([
|
||||
eb("pieceId", "=", pieceId),
|
||||
eb("attachmentId", "=", attachmentId),
|
||||
@@ -703,16 +610,21 @@ const app = new Elysia()
|
||||
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, user }) => {
|
||||
.delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Attachment")
|
||||
.where((eb) => eb.and([
|
||||
@@ -735,23 +647,28 @@ const app = new Elysia()
|
||||
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 }, user }) => {
|
||||
.post("/repertoire", async ({ db, body: { name, entries }, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.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: user.userId, createdAt: sql`datetime()` })
|
||||
.values({ repertoireId, name, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
@@ -779,12 +696,13 @@ const app = new Elysia()
|
||||
}),
|
||||
response: {
|
||||
401: t.Literal("Session invalid or expired"),
|
||||
403: t.Literal("Must be an Editor"),
|
||||
},
|
||||
})
|
||||
|
||||
.get("/repertoire", async ({ db, query, user }) => {
|
||||
.get("/repertoire", async ({ db, query, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
@@ -809,9 +727,9 @@ const app = new Elysia()
|
||||
},
|
||||
})
|
||||
|
||||
.get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, user }) => {
|
||||
.get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
@@ -847,15 +765,19 @@ const app = new Elysia()
|
||||
},
|
||||
})
|
||||
|
||||
.put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, user }) => {
|
||||
.put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.updateTable("Repertoire")
|
||||
.set({ name, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
||||
.set({ name, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
.returningAll()
|
||||
.execute();
|
||||
@@ -893,16 +815,21 @@ const app = new Elysia()
|
||||
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, user }) => {
|
||||
.delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, session: { idToken } }) => {
|
||||
|
||||
if (user === null) {
|
||||
if (Option.isNone(idToken)) {
|
||||
return error("Unauthorized", "Session invalid or expired");
|
||||
}
|
||||
|
||||
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||
return error("Forbidden", "Must be an Editor");
|
||||
}
|
||||
|
||||
const res = await db
|
||||
.deleteFrom("Repertoire")
|
||||
.where("repertoireId", "=", repertoireId)
|
||||
@@ -921,6 +848,7 @@ const app = new Elysia()
|
||||
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
|
||||
|
||||
234
packages/backend/src/auth.ts
Normal file
234
packages/backend/src/auth.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { UserId } from "common";
|
||||
import { DateTime, Duration, Effect, Option, pipe } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import * as Model from "./model";
|
||||
import { Db } from "./services/db";
|
||||
import { Session } from "./services/session";
|
||||
|
||||
export const TENANT_ID = "0817c403-92e4-4648-a9aa-f688ffc5f97a";
|
||||
export const TENANT_SUBDOMAIN = "chkvoxastra";
|
||||
|
||||
export const CLIENT_ID = "e5948f7d-187b-44f9-80cd-63ffda86f9be";
|
||||
export const OAUTH_SCOPE = "email offline_access openid profile https://graph.microsoft.com/User.Read.All";
|
||||
|
||||
export const REDIRECT_URI = process.env.NODE_ENV === "production" ? "https://music.renati.me/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/${TENANT_ID}/oauth2/v2.0/authorize`,
|
||||
tokenEndpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`,
|
||||
});
|
||||
|
||||
export const EXTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze<OAuthConfiguration>({
|
||||
authorizationEndpoint: `https://${TENANT_SUBDOMAIN}.ciamlogin.com/${TENANT_ID}/oauth2/v2.0/authorize`,
|
||||
tokenEndpoint: `https://${TENANT_SUBDOMAIN}.ciamlogin.com/${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", 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": CLIENT_ID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshTokenValue,
|
||||
"client_secret": process.env.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,
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -18,7 +18,6 @@ export interface Database {
|
||||
Repertoire: RepertoireTable;
|
||||
RepertoireEntry: RepertoireEntryTable;
|
||||
Session: SessionTable;
|
||||
User: UserTable;
|
||||
}
|
||||
|
||||
export interface SystemInformation {
|
||||
@@ -79,7 +78,12 @@ export interface RepertoireEntryTable extends RepertoireEntryData {
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
userId: UserId;
|
||||
state: string | null;
|
||||
codeVerifier: string | null;
|
||||
accessToken: string | null;
|
||||
idToken: string | null;
|
||||
refreshToken: string | null;
|
||||
external: number | null;
|
||||
}
|
||||
|
||||
export interface SessionTable extends SessionData {
|
||||
@@ -87,13 +91,6 @@ export interface SessionTable extends SessionData {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface UserTable {
|
||||
userId: ColumnType<UserId, UserId, never>;
|
||||
username: string;
|
||||
password: string;
|
||||
admin: number;
|
||||
}
|
||||
|
||||
export type AccessLog = Selectable<AccessLogTable>;
|
||||
export type Attachment = Selectable<AttachmentTable>;
|
||||
export type File = Selectable<FileTable>;
|
||||
@@ -101,13 +98,12 @@ export type Piece = Selectable<PieceTable>;
|
||||
export type Repertoire = Selectable<RepertoireTable>;
|
||||
export type RepertoireEntry = Selectable<RepertoireEntryTable>;
|
||||
export type Session = Selectable<SessionTable>;
|
||||
export type User = Selectable<UserTable>;
|
||||
|
||||
function systemInformation<TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) {
|
||||
return schema
|
||||
.addColumn("createdBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade"))
|
||||
.addColumn("createdBy", "text")
|
||||
.addColumn("createdAt", "text", (c) => c.notNull())
|
||||
.addColumn("modifiedBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade"))
|
||||
.addColumn("modifiedBy", "text")
|
||||
.addColumn("modifiedAt", "text");
|
||||
}
|
||||
|
||||
@@ -199,7 +195,12 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
|
||||
.createTable("Session")
|
||||
.ifNotExists()
|
||||
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
||||
.addColumn("userId", "text", (c) => c.references("User.userId").onDelete("cascade").onUpdate("cascade"))
|
||||
.addColumn("state", "text")
|
||||
.addColumn("codeVerifier", "text")
|
||||
.addColumn("accessToken", "text")
|
||||
.addColumn("idToken", "text")
|
||||
.addColumn("refreshToken", "text")
|
||||
.addColumn("external", "boolean")
|
||||
.addColumn("expiresAt", "text", (c) => c.notNull())
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -2,6 +2,27 @@ import * as Common from "common";
|
||||
import * as Function from "common/Function";
|
||||
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(Function.unsafeCoerce<string, T>)
|
||||
.Encode(Function.unsafeCoerce<T, string>);
|
||||
@@ -44,6 +65,12 @@ export const Attachment = t.Object({
|
||||
...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 }),
|
||||
@@ -81,35 +108,15 @@ export const Repertoire_Query = t.Object({
|
||||
|
||||
export const User = t.Object({
|
||||
userId: UserId,
|
||||
username: t.String(),
|
||||
admin: t.Boolean(),
|
||||
});
|
||||
|
||||
export const User_Patch = t.Object({
|
||||
username: t.Optional(t.String()),
|
||||
password: t.Optional(t.String({ minLength: 8 })),
|
||||
admin: t.Optional(t.Boolean()),
|
||||
});
|
||||
|
||||
export const User_Post = t.Object({
|
||||
username: t.String(),
|
||||
password: t.String({ minLength: 8 }),
|
||||
admin: t.Boolean(),
|
||||
});
|
||||
|
||||
export const User_Query = t.Object({
|
||||
username: t.Optional(t.String()),
|
||||
...Pagination,
|
||||
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;
|
||||
export type User_Patch = typeof User_Patch.static;
|
||||
export type User_Post = typeof User_Post.static;
|
||||
export type User_Query = typeof User_Query.static;
|
||||
|
||||
31
packages/backend/src/services/db.ts
Normal file
31
packages/backend/src/services/db.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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,
|
||||
),
|
||||
}));
|
||||
6
packages/backend/src/services/session.ts
Normal file
6
packages/backend/src/services/session.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SessionId } from "common";
|
||||
import { Context, Layer } from "effect";
|
||||
|
||||
export class Session extends Context.Tag("Session")<Session, SessionId>() { }
|
||||
|
||||
export const SessionFromValue = Layer.succeed(Session);
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Home } from "@/routes/Home";
|
||||
import { Login } from "@/routes/Login";
|
||||
import { Piece } from "@/routes/Piece";
|
||||
import { Pieces } from "@/routes/Pieces";
|
||||
import { Repertoire } from "@/routes/Repertoire";
|
||||
@@ -63,10 +62,6 @@ const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
Component: Login,
|
||||
},
|
||||
], {
|
||||
future: {
|
||||
v7_fetcherPersist: true,
|
||||
|
||||
@@ -5,8 +5,7 @@ import { client, mapResponse } from "./client";
|
||||
|
||||
export interface User {
|
||||
readonly userId: UserId;
|
||||
readonly username: string;
|
||||
readonly admin: boolean;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
export interface SystemInformation {
|
||||
@@ -108,7 +107,7 @@ export const denormalizeRepertoire = ({
|
||||
);
|
||||
|
||||
const UserSemaphore = Effect.unsafeMakeSemaphore(1);
|
||||
const CacheSemaphore = Effect.unsafeMakeSemaphore(4);
|
||||
const PieceSemaphore = Effect.unsafeMakeSemaphore(4);
|
||||
const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
|
||||
|
||||
export const userLookup = (userId: UserId) => pipe(
|
||||
@@ -123,7 +122,7 @@ export const pieceLookup = (pieceId: PieceId) => pipe(
|
||||
Effect.flatMap(mapResponse),
|
||||
Effect.flatMap(denormalizePiece),
|
||||
Effect.map((x): Piece => x), // safely coerce to interface
|
||||
CacheSemaphore.withPermits(1),
|
||||
PieceSemaphore.withPermits(1),
|
||||
);
|
||||
|
||||
export const repertoireLookup = (repertoireId: RepertoireId) => pipe(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { API_URL_PREFIX } from "@/client";
|
||||
import { mapProp, Update, Updater, useStore } from "@/hooks/useStore";
|
||||
import { Treaty } from "@elysiajs/eden";
|
||||
import { Effect, Fiber, pipe } from "effect";
|
||||
@@ -57,8 +58,7 @@ export function useLoading<R extends Record<number, unknown>>(fn: () => Promise<
|
||||
|
||||
if (error !== null) {
|
||||
if (error.status === 401) {
|
||||
setUser(null);
|
||||
navigate("/login");
|
||||
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,31 +13,17 @@ export namespace Store {
|
||||
export interface User {
|
||||
readonly userId: UserId;
|
||||
readonly username: string;
|
||||
readonly admin: boolean;
|
||||
readonly roles: readonly string[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface Store {
|
||||
readonly loginUsername: string;
|
||||
readonly loginPassword: string;
|
||||
|
||||
readonly user: Store.User | null;
|
||||
|
||||
readonly setLoginUsername: Updater<string>;
|
||||
readonly setLoginPassword: Updater<string>;
|
||||
|
||||
readonly setUser: Updater<Store.User | null>;
|
||||
}
|
||||
|
||||
let store: Store = Object.freeze<Store>({
|
||||
loginUsername: "",
|
||||
loginPassword: "",
|
||||
|
||||
user: null,
|
||||
|
||||
setLoginUsername: (action) => set(mapProp("loginUsername", action)),
|
||||
setLoginPassword: (action) => set(mapProp("loginPassword", action)),
|
||||
|
||||
setUser: (action) => set(mapProp("user", action)),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { client } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useStore } from "@/hooks/useStore";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { FormEventHandler, useId, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function Login() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loginUsername = useStore(state => state.loginUsername);
|
||||
const loginPassword = useStore(state => state.loginPassword);
|
||||
|
||||
const setLoginUsername = useStore(state => state.setLoginUsername);
|
||||
const setLoginPassword = useStore(state => state.setLoginPassword);
|
||||
|
||||
const setUser = useStore(state => state.setUser);
|
||||
|
||||
const usernameId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { data, error } = await client.login.post({
|
||||
username: loginUsername,
|
||||
password: loginPassword,
|
||||
});
|
||||
|
||||
if (error !== null) {
|
||||
console.error(error.value);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoginUsername("");
|
||||
setLoginPassword("");
|
||||
setUser(data);
|
||||
|
||||
navigate("/");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<form onSubmit={onSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Repozytorium muzyczne</CardTitle>
|
||||
<CardDescription>Zaloguj się, aby kontynuować</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Label htmlFor={usernameId}>Nazwa użytkownika</Label>
|
||||
<Input
|
||||
id={usernameId}
|
||||
className="w-[32ch]"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={loginUsername}
|
||||
autoFocus
|
||||
required
|
||||
onChange={(e) => setLoginUsername(e.target.value)}
|
||||
/>
|
||||
<Label htmlFor={passwordId}>Hasło</Label>
|
||||
<Input
|
||||
id={passwordId}
|
||||
className="w-[32ch]"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={loginPassword}
|
||||
required
|
||||
onChange={(e) => setLoginPassword(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="animate-spin" />}
|
||||
Zaloguj się
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { client } from "@/client";
|
||||
import { API_URL_PREFIX, client } from "@/client";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { useStore } from "@/hooks/useStore";
|
||||
import { LogOut, Settings, User } from "lucide-react";
|
||||
import { Settings, User } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Link, Outlet, useNavigate } from "react-router-dom";
|
||||
import { Link, Outlet } from "react-router-dom";
|
||||
|
||||
export function Root() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const user = useStore(state => state.user);
|
||||
const setUser = useStore(state => state.setUser);
|
||||
|
||||
@@ -19,7 +17,7 @@ export function Root() {
|
||||
const { data, error } = await client.me.get();
|
||||
|
||||
if (error !== null) {
|
||||
navigate("/login");
|
||||
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,17 +27,6 @@ export function Root() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => void init(), []);
|
||||
|
||||
const onLogoutClick = async () => {
|
||||
const { error } = await client.logout.post();
|
||||
|
||||
if (error !== null) {
|
||||
console.error(error.value);
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
if (user === null) {
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden flex items-center justify-center">
|
||||
@@ -66,9 +53,6 @@ export function Root() {
|
||||
<Settings />Ustawienia
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onLogoutClick}>
|
||||
<LogOut />Wyloguj się
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -1,100 +1,6 @@
|
||||
import { client } from "@/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useStore } from "@/hooks/useStore";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { FormEventHandler, useId, useState } from "react";
|
||||
|
||||
export function Settings() {
|
||||
return (
|
||||
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
|
||||
<PasswordChangeCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordChangeCard() {
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword1, setNewPassword1] = useState("");
|
||||
const [newPassword2, setNewPassword2] = useState("");
|
||||
|
||||
const currentPasswordId = useId();
|
||||
const newPassword1Id = useId();
|
||||
const newPassword2Id = useId();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const user = useStore(store => store.user);
|
||||
|
||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await client["change-password"].post({
|
||||
username: user!.username,
|
||||
currentPassword,
|
||||
newPassword: newPassword1,
|
||||
});
|
||||
|
||||
if (error !== null) {
|
||||
console.error(error.value);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Zmiana hasła</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Label htmlFor={currentPasswordId}>Obecne hasło</Label>
|
||||
<Input
|
||||
id={currentPasswordId}
|
||||
className="w-[32ch]"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
required
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
<Label htmlFor={currentPasswordId}>Nowe hasło</Label>
|
||||
<Input
|
||||
id={newPassword1Id}
|
||||
className="w-[32ch]"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword1}
|
||||
required
|
||||
onChange={(e) => setNewPassword1(e.target.value)}
|
||||
/>
|
||||
<Label htmlFor={currentPasswordId}>Potwierdź hasło</Label>
|
||||
<Input
|
||||
id={newPassword2Id}
|
||||
className="w-[32ch]"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword2}
|
||||
required
|
||||
onChange={(e) => setNewPassword2(e.target.value)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-row justify-end">
|
||||
<Button type="submit" disabled={isLoading || newPassword1 !== newPassword2}>
|
||||
{isLoading && <Loader2 className="animate-spin" />}
|
||||
Zmień hasło
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function created({ createdAt, createdBy }: SystemInformation): ReactNode
|
||||
|
||||
if (Option.isSome(createdBy)) {
|
||||
nodes.push(<br />);
|
||||
nodes.push(`przez ${createdBy.value.username}`);
|
||||
nodes.push(`przez ${createdBy.value.displayName}`);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
@@ -48,7 +48,7 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo
|
||||
if (Option.isNone(modifiedBy)) {
|
||||
return "\u2014";
|
||||
} else {
|
||||
return `przez ${modifiedBy.value.username}`;
|
||||
return `przez ${modifiedBy.value.displayName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo
|
||||
|
||||
if (Option.isSome(modifiedBy)) {
|
||||
nodes.push(<br />);
|
||||
nodes.push(`przez ${modifiedBy.value.username}`);
|
||||
nodes.push(`przez ${modifiedBy.value.displayName}`);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
|
||||
108
pnpm-lock.yaml
generated
108
pnpm-lock.yaml
generated
@@ -146,6 +146,9 @@ importers:
|
||||
common:
|
||||
specifier: workspace:^
|
||||
version: link:../common
|
||||
effect:
|
||||
specifier: 'catalog:'
|
||||
version: 3.11.4
|
||||
elysia:
|
||||
specifier: 'catalog:'
|
||||
version: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)
|
||||
@@ -507,32 +510,42 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/eslint-utils@4.5.1':
|
||||
resolution: {integrity: sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
peerDependencies:
|
||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||
|
||||
'@eslint-community/regexpp@4.12.1':
|
||||
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
|
||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||
|
||||
'@eslint/config-array@0.19.1':
|
||||
resolution: {integrity: sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==}
|
||||
'@eslint/config-array@0.19.2':
|
||||
resolution: {integrity: sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/core@0.12.0':
|
||||
resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/core@0.9.1':
|
||||
resolution: {integrity: sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/eslintrc@3.2.0':
|
||||
resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==}
|
||||
'@eslint/eslintrc@3.3.0':
|
||||
resolution: {integrity: sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/js@9.17.0':
|
||||
resolution: {integrity: sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/object-schema@2.1.5':
|
||||
resolution: {integrity: sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==}
|
||||
'@eslint/object-schema@2.1.6':
|
||||
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@eslint/plugin-kit@0.2.4':
|
||||
resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==}
|
||||
'@eslint/plugin-kit@0.2.7':
|
||||
resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@floating-ui/core@1.6.8':
|
||||
@@ -569,8 +582,8 @@ packages:
|
||||
resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@humanwhocodes/retry@0.4.1':
|
||||
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
|
||||
'@humanwhocodes/retry@0.4.2':
|
||||
resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==}
|
||||
engines: {node: '>=18.18'}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
@@ -1347,6 +1360,15 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.0:
|
||||
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decompress-response@6.0.0:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1440,8 +1462,8 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
|
||||
|
||||
eslint-scope@8.2.0:
|
||||
resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==}
|
||||
eslint-scope@8.3.0:
|
||||
resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
eslint-visitor-keys@3.4.3:
|
||||
@@ -1531,8 +1553,8 @@ packages:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
flatted@3.3.2:
|
||||
resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
|
||||
foreground-child@3.3.0:
|
||||
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
||||
@@ -1659,8 +1681,8 @@ packages:
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
import-fresh@3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
imurmurhash@0.1.4:
|
||||
@@ -2760,28 +2782,37 @@ snapshots:
|
||||
eslint: 9.17.0(jiti@1.21.6)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/eslint-utils@4.5.1(eslint@9.17.0(jiti@1.21.6))':
|
||||
dependencies:
|
||||
eslint: 9.17.0(jiti@1.21.6)
|
||||
eslint-visitor-keys: 3.4.3
|
||||
|
||||
'@eslint-community/regexpp@4.12.1': {}
|
||||
|
||||
'@eslint/config-array@0.19.1':
|
||||
'@eslint/config-array@0.19.2':
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.5
|
||||
debug: 4.3.7
|
||||
'@eslint/object-schema': 2.1.6
|
||||
debug: 4.4.0
|
||||
minimatch: 3.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@eslint/core@0.12.0':
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@eslint/core@0.9.1':
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
|
||||
'@eslint/eslintrc@3.2.0':
|
||||
'@eslint/eslintrc@3.3.0':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
debug: 4.3.7
|
||||
debug: 4.4.0
|
||||
espree: 10.3.0
|
||||
globals: 14.0.0
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.0
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.0
|
||||
minimatch: 3.1.2
|
||||
strip-json-comments: 3.1.1
|
||||
@@ -2790,10 +2821,11 @@ snapshots:
|
||||
|
||||
'@eslint/js@9.17.0': {}
|
||||
|
||||
'@eslint/object-schema@2.1.5': {}
|
||||
'@eslint/object-schema@2.1.6': {}
|
||||
|
||||
'@eslint/plugin-kit@0.2.4':
|
||||
'@eslint/plugin-kit@0.2.7':
|
||||
dependencies:
|
||||
'@eslint/core': 0.12.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@floating-ui/core@1.6.8':
|
||||
@@ -2827,7 +2859,7 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/retry@0.3.1': {}
|
||||
|
||||
'@humanwhocodes/retry@0.4.1': {}
|
||||
'@humanwhocodes/retry@0.4.2': {}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
@@ -3620,6 +3652,10 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
debug@4.4.0:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decompress-response@6.0.0:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
@@ -3717,7 +3753,7 @@ snapshots:
|
||||
dependencies:
|
||||
eslint: 9.17.0(jiti@1.21.6)
|
||||
|
||||
eslint-scope@8.2.0:
|
||||
eslint-scope@8.3.0:
|
||||
dependencies:
|
||||
esrecurse: 4.3.0
|
||||
estraverse: 5.3.0
|
||||
@@ -3728,24 +3764,24 @@ snapshots:
|
||||
|
||||
eslint@9.17.0(jiti@1.21.6):
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.6))
|
||||
'@eslint-community/eslint-utils': 4.5.1(eslint@9.17.0(jiti@1.21.6))
|
||||
'@eslint-community/regexpp': 4.12.1
|
||||
'@eslint/config-array': 0.19.1
|
||||
'@eslint/config-array': 0.19.2
|
||||
'@eslint/core': 0.9.1
|
||||
'@eslint/eslintrc': 3.2.0
|
||||
'@eslint/eslintrc': 3.3.0
|
||||
'@eslint/js': 9.17.0
|
||||
'@eslint/plugin-kit': 0.2.4
|
||||
'@eslint/plugin-kit': 0.2.7
|
||||
'@humanfs/node': 0.16.6
|
||||
'@humanwhocodes/module-importer': 1.0.1
|
||||
'@humanwhocodes/retry': 0.4.1
|
||||
'@humanwhocodes/retry': 0.4.2
|
||||
'@types/estree': 1.0.6
|
||||
'@types/json-schema': 7.0.15
|
||||
ajv: 6.12.6
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.3.7
|
||||
debug: 4.4.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 8.2.0
|
||||
eslint-scope: 8.3.0
|
||||
eslint-visitor-keys: 4.2.0
|
||||
espree: 10.3.0
|
||||
esquery: 1.6.0
|
||||
@@ -3833,10 +3869,10 @@ snapshots:
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.3.2
|
||||
flatted: 3.3.3
|
||||
keyv: 4.5.4
|
||||
|
||||
flatted@3.3.2: {}
|
||||
flatted@3.3.3: {}
|
||||
|
||||
foreground-child@3.3.0:
|
||||
dependencies:
|
||||
@@ -3992,7 +4028,7 @@ snapshots:
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
import-fresh@3.3.0:
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
resolve-from: 4.0.0
|
||||
|
||||
Reference in New Issue
Block a user