Extend and fix session management, /me API
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Schema as S } from "@effect/schema";
|
||||
import { Login } from "common/api";
|
||||
import { AccessLog } from "common/db";
|
||||
import { SessionId } from "common";
|
||||
import { Login, Me } from "common/api";
|
||||
import { AccessLog, SessionData } from "common/db";
|
||||
import { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect";
|
||||
import { Api } from "make-api";
|
||||
import { brotliCompress } from "node:zlib";
|
||||
@@ -24,7 +25,8 @@ const match = (api: Api.Api.Any) => Effect.gen(function* () {
|
||||
));
|
||||
});
|
||||
|
||||
const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-])\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i;
|
||||
const COOKIE_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*=\s*([!#$%&'()*+./:<=>?@[\]^_`{|}~a-z0-9-]+)/i;
|
||||
const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i;
|
||||
|
||||
const acceptsBrotli = Effect.gen(function* () {
|
||||
const req = yield* Request;
|
||||
@@ -48,7 +50,7 @@ const acceptsBrotli = Effect.gen(function* () {
|
||||
.split(/\s*,\s*/)
|
||||
.map((token) => token.match(WEIGHTED_TOKEN_REGEX))
|
||||
.filter((match) => match !== null)
|
||||
.map((match) => Object.freeze({
|
||||
.map((match) => ({
|
||||
coding: match[1],
|
||||
...(match[2] ? { quality: Number.parseFloat(match[2]) } : undefined),
|
||||
}));
|
||||
@@ -56,6 +58,24 @@ const acceptsBrotli = Effect.gen(function* () {
|
||||
return codings.some(({ coding, quality = 1 }) => coding === "br" && quality > 0);
|
||||
});
|
||||
|
||||
const getCookie = (name: string): Effect.Effect<O.Option<string>, never, Request> => Effect.gen(function* () {
|
||||
const req = yield* Request;
|
||||
|
||||
const cookieString = req.headers["cookie"];
|
||||
|
||||
if (!cookieString) {
|
||||
return O.none();
|
||||
}
|
||||
|
||||
const cookieValue = cookieString
|
||||
.split(/\s*;\s*/)
|
||||
.map((cookie) => cookie.match(COOKIE_REGEX))
|
||||
.filter((match): match is RegExpMatchArray => match !== null && match[1] === name)
|
||||
.map((match) => match[2])[0];
|
||||
|
||||
return O.fromNullable(cookieValue);
|
||||
});
|
||||
|
||||
const requestJson = <A, I, R>(schema: S.Schema<A, I, R>) => {
|
||||
const decoder = S.decodeUnknown(schema);
|
||||
return pipe(
|
||||
@@ -66,7 +86,7 @@ const requestJson = <A, I, R>(schema: S.Schema<A, I, R>) => {
|
||||
),
|
||||
Effect.flatMap((req) => req.json),
|
||||
Effect.flatMap(decoder),
|
||||
Effect.mapError((error) => new RequestError({ status: 400, body: error.message }))
|
||||
Effect.mapError((error) => new RequestError({ status: 400, body: error.message })),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -87,7 +107,8 @@ export const app = pipe(
|
||||
ip: O.getOrNull(req.ip),
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(accessLog));
|
||||
yield* db.createAccessLog(accessLog);
|
||||
console.log(`${DateTime.toDateUtc(accessLog.timestamp).toISOString()} ${accessLog.requestId} ${accessLog.method} ${accessLog.pathname}?${new URLSearchParams(accessLog.query).toString()} ${accessLog.ip}`);
|
||||
|
||||
if (yield* match(Login)) {
|
||||
|
||||
@@ -103,7 +124,8 @@ export const app = pipe(
|
||||
return yield* new RequestError({ status: 401, body: "Invalid username or password" });
|
||||
}
|
||||
|
||||
const sessionId = yield* db.createSession(user.userId);
|
||||
const sessionData = SessionData.make({ userId: user.userId });
|
||||
const sessionId = yield* db.createSession(sessionData);
|
||||
|
||||
const responseData = Login.props.response[200].schema.make({
|
||||
userId: user.userId,
|
||||
@@ -119,7 +141,91 @@ export const app = pipe(
|
||||
const responseArray = new TextEncoder().encode(responseJson);
|
||||
|
||||
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
|
||||
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; Secure`;
|
||||
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
||||
|
||||
if (yield* acceptsBrotli) {
|
||||
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
|
||||
brotliCompress(responseArray, (error, result) => {
|
||||
if (error !== null) {
|
||||
callback(Effect.die(error));
|
||||
} else {
|
||||
callback(Effect.succeed(result));
|
||||
}
|
||||
});
|
||||
});
|
||||
return new Response(responseBrotli, {
|
||||
headers: {
|
||||
"Content-Encoding": "br",
|
||||
"Content-Length": responseArray.byteLength.toString(),
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": setCookie,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return new Response(responseArray, {
|
||||
headers: {
|
||||
"Content-Length": responseArray.byteLength.toString(),
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": setCookie,
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (yield* match(Me)) {
|
||||
|
||||
const sessionId = yield* pipe(
|
||||
getCookie("sessionId"),
|
||||
Effect.flatten,
|
||||
Effect.flatMap(S.decodeUnknown(SessionId)),
|
||||
Effect.mapError(() => new RequestError({ status: 401, body: "Session invalid or expired" })),
|
||||
);
|
||||
|
||||
const sessionData = yield* pipe(
|
||||
db.getAndRefreshSessionData(sessionId),
|
||||
Effect.optionFromOptional,
|
||||
Effect.map(O.getOrNull),
|
||||
);
|
||||
|
||||
if (sessionData === null) {
|
||||
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Set-Cookie": setCookie,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = yield* pipe(
|
||||
db.getUserById(sessionData.userId),
|
||||
Effect.optionFromOptional,
|
||||
Effect.map(O.getOrNull),
|
||||
);
|
||||
|
||||
if (user === null) {
|
||||
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Set-Cookie": setCookie,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const responseData = Me.props.response[200].schema.make({
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
admin: user.admin,
|
||||
});
|
||||
|
||||
const responseJson = yield* pipe(
|
||||
responseData,
|
||||
S.encode(Me.props.response[200].schema),
|
||||
Effect.map(JSON.stringify),
|
||||
);
|
||||
const responseArray = new TextEncoder().encode(responseJson);
|
||||
|
||||
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
|
||||
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
||||
|
||||
if (yield* acceptsBrotli) {
|
||||
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
|
||||
|
||||
Reference in New Issue
Block a user