This commit is contained in:
rizky 2024-08-28 03:45:00 -07:00
parent fef9da2ab0
commit 46d5d1d777
13 changed files with 564 additions and 222 deletions

View File

@ -1,4 +1,4 @@
import { ServerContext, SessionContext } from "./server-route"; import type { ServerContext, SessionContext } from "lib/session/type";
export const sessionContext = <T>(sf: any) => { export const sessionContext = <T>(sf: any) => {
return sf as unknown as SessionContext<T>; return sf as unknown as SessionContext<T>;

View File

@ -1,6 +1,6 @@
import { _post } from "lib/utils/post"; import { _post } from "lib/utils/post";
import { addRoute, createRouter, findRoute } from "rou3"; import { addRoute, createRouter, findRoute } from "rou3";
import { ServerContext, SessionContext } from "./session/type"; import { ServerContext, SessionContext } from "../session/type";
type RouteFn = (...arg: any[]) => Promise<any>; type RouteFn = (...arg: any[]) => Promise<any>;

View File

@ -1,49 +0,0 @@
import { newClientRouter } from "../server-route";
import { sessionRouter } from "./router/session-router";
import { sessionClientStore } from "./store/client-store";
import { ClientSession } from "./type";
export const newClientSession = <T>(arg?: {
on: Partial<{
messageReceived: (session: ClientSession<T>) => Promise<void>;
afterLogin: (session: ClientSession<T>) => Promise<void>;
afterLogout: (session: ClientSession<T>) => Promise<void>;
afterRecheck: (session: ClientSession<T>) => Promise<void>;
}>;
}) => {
const store = sessionClientStore<T>();
const client = newClientRouter(sessionRouter);
const session: ClientSession<T> = {
status: "checking",
current: null,
async connect() {
const url = new URL(location.href);
url.protocol = "wss:";
const ws = new WebSocket(url);
ws.onopen = () => {
ws.send("ok");
};
ws.onmessage = (m) => {
console.log(m);
};
// const current = await store.load();
// if (!current) {
// this.status = "guest";
// } else {
// this.status = await client.check(current.uid, current.sid);
// }
return { status: this.status };
},
async login(arg: {
method: "user-pass";
username: string;
password: string;
}) {},
async logout() {},
};
session.connect();
return session;
};

View File

@ -1,50 +0,0 @@
/// <reference types="bun-types" />
import { useServerRouter } from "../server-route";
import { sessionRouter } from "./router/session-router";
import { newSessionStore } from "./store/session-store";
import { ServerContext } from "./type";
type SessionServerHandler = {
cleanup: () => Promise<void>;
handle: (arg: ServerContext) => Promise<Response>;
};
export const createSessionServer = <T>(arg: {
encrypt?: boolean;
server_router?: ReturnType<typeof useServerRouter>;
site_id?: string;
}): SessionServerHandler => {
const session_store = newSessionStore<T>(arg.site_id);
const session_router = useServerRouter(sessionRouter);
const server_handler: SessionServerHandler = {
async cleanup() {},
async handle(server_arg) {
const { req, handle, url } = server_arg;
const route_arg = {
...server_arg,
session: {
...session_store,
current: undefined,
},
};
if (url.pathname.startsWith("/_session/")) {
const res = await session_router.handle(route_arg);
if (res) return res;
}
if (arg.server_router) {
const res = await arg.server_router.handle(route_arg);
if (res) return res;
}
return handle(req);
},
};
return server_handler;
};

223
session/client-session.ts Executable file
View File

@ -0,0 +1,223 @@
import { ConsoleLogWriter } from "drizzle-orm";
import { newClientRouter } from "../server/server-route";
import { sessionRouter } from "./router/session-router";
import { sessionClientStore } from "./store/client-store";
import { ClientSession, SessionAuth, SessionData } from "./type";
export const newClientSession = <T>(arg?: {
on?: Partial<{
messageReceived: (session: ClientSession<T>) => Promise<void>;
afterLogin: (session: ClientSession<T>) => Promise<void>;
afterLogout: (session: ClientSession<T>) => Promise<void>;
afterInit: (session: ClientSession<T>) => Promise<void>;
}>;
}) => {
const store = sessionClientStore<T>();
const client = newClientRouter(sessionRouter);
const login_promise = { resolve: null as any, reject: null as any };
const logout_promise = { resolve: null as any, reject: null as any };
const session: ClientSession<T> = {
status: "checking",
wsid: "",
current: null,
connected: false,
get connectURL() {
const url = new URL(location.href);
url.protocol = "wss:";
url.hash = "";
return url;
},
connect(auth?: SessionAuth) {
return new Promise<void>(async (done, reject) => {
const current = this.current;
if (current || auth) {
if (this.ws) {
await wsReady(this.ws);
}
const ws = new WebSocket(this.connectURL);
this.ws = ws;
ws.onclose = () => {
session.connected = false;
console.log(this.status);
if (this.status === "logout") {
store.clear();
this.current = null;
if (arg?.on?.afterLogout) {
arg.on.afterLogout(session);
}
logout_promise.resolve();
logout_promise.resolve = null;
logout_promise.reject = null;
} else {
setTimeout(() => {
console.warn("Reconnecting Session WS...");
this.connect();
}, 2000);
}
};
ws.onopen = () => {
if (session.current) {
ws.send(
JSON.stringify({
uid: session.current.uid,
sid: session.current.sid,
})
);
} else {
if (auth) {
ws.send(JSON.stringify(auth));
} else {
if (ws.readyState === ws.OPEN) {
ws.close();
}
}
}
};
ws.onmessage = async (m) => {
if (!session.connected) {
try {
const parsed = JSON.parse(m.data) as
| {
status: "ok";
wsid: string;
session: SessionData<T>;
}
| { status: "failed" };
if (parsed.status === "ok") {
session.wsid = parsed.wsid;
session.current = parsed.session;
if (login_promise.resolve) {
session.connected = true;
login_promise.resolve(session.current);
await store.save(session.current);
}
} else {
if (login_promise.reject) login_promise.reject();
}
login_promise.resolve = null;
login_promise.reject = null;
if (arg?.on?.afterLogin) {
arg.on.afterLogin(session);
}
done();
return;
} catch (e) {
reject(e);
}
if (ws.readyState === ws.OPEN) {
ws.close();
}
}
};
} else {
done();
}
});
},
async init() {
const current = await store.load();
if (!current) {
this.status = "guest";
} else {
this.current = current;
this.status = await client.check(current.uid, current.sid);
if (this.status !== "active") {
await store.clear();
this.current = null;
} else {
await this.connect();
}
}
if (arg?.on?.afterInit) {
arg.on.afterInit(session);
}
return { status: this.status };
},
login(auth: SessionAuth) {
return new Promise<SessionData<T>>(async (resolve, reject) => {
if (isEditor) {
resolve({} as any);
return;
}
login_promise.resolve = resolve;
login_promise.reject = reject;
if (this.status === "checking") {
await new Promise<void>((done) => {
const ival = setInterval(() => {
if (this.status !== "checking") {
clearInterval(ival);
done();
}
}, 100);
});
}
if (this.status === "guest") {
this.connect(auth);
} else {
console.error(
`\
Session login failed, current status is: ${this.status}.
Login is prevented, please logout first before re-login!`
);
if (this.current) {
resolve(this.current);
} else {
if (auth) {
this.connect(auth);
} else {
reject("Current session not found");
}
}
}
});
},
logout() {
return new Promise<void>(async (resolve, reject) => {
if (isEditor) {
resolve({} as any);
return;
}
logout_promise.resolve = resolve;
logout_promise.reject = reject;
if (this.status === "active" && this.ws) {
await wsReady(this.ws);
this.status = "logout";
this.ws.send(JSON.stringify({ action: "logout" }));
}
});
},
};
if (!isEditor) {
session.init();
}
return session;
};
const wsReady = async (ws: WebSocket) => {
if (ws) {
if (ws.readyState === ws.OPEN) return;
else {
ws.close();
await new Promise<void>((done) => {
const ival = setInterval(() => {
if (ws.readyState === ws.CLOSED) {
clearInterval(ival);
done();
}
}, 100);
});
}
}
};

View File

@ -4,8 +4,15 @@ import { ClientSessionStatus } from "../type";
export default async function (this: any, uid: string, sid: string) { export default async function (this: any, uid: string, sid: string) {
const ctx = sessionContext<EsensiSession>(this); const ctx = sessionContext<EsensiSession>(this);
let result = "invalid" as ClientSessionStatus; let result = "invalid" as ClientSessionStatus;
const session = ctx.session.findFirst({ uid, sid });
if (session) {
if (!session.expired_at || session.expired_at > Date.now()) {
result = "active";
} else {
result = "expired";
}
}
return result; return result;
} }

234
session/server-session.ts Executable file
View File

@ -0,0 +1,234 @@
/// <reference types="bun-types" />
import { ServerWebSocket } from "bun";
import { useServerRouter } from "../server/server-route";
import { sessionRouter } from "./router/session-router";
import { newSessionStore } from "./store/session-store";
import {
ServerContext,
SessionAuth,
SessionStore,
SingleSession,
} from "./type";
import { createId } from "@paralleldrive/cuid2";
import { ConsoleLogWriter } from "drizzle-orm";
type WS = ServerWebSocket<{ url: string }>;
type SessionServerHandler = {
cleanup: () => Promise<void>;
handle: (arg: ServerContext) => Promise<Response>;
ws: {
sids: Record<string, WS[]>;
index: Record<string, WS>;
conns: Map<WS, { sid: string; uid: string; wsid: string }>;
init: () => PrasiServer["ws"];
};
};
export const initSessionServer = <T>(
server: PrasiServer,
arg: {
encrypt?: boolean;
router?: ReturnType<typeof useServerRouter>;
login: (
session: SessionStore<T>,
arg: SessionAuth
) => Promise<SingleSession<T> | false>;
}
) => {
try {
const session_store = newSessionStore<T>(server.site_id);
const session_router = useServerRouter(sessionRouter);
const server_handler: SessionServerHandler = {
async cleanup() {},
ws: {
sids: {},
index: {},
conns: new Map(),
init() {
return {
async message(ws, message) {
const sids = server_handler.ws.sids;
const conns = server_handler.ws.conns;
const index = server_handler.ws.index;
const conn = conns.get(ws);
if (conn) {
try {
if (typeof message === "string") {
const parsed = JSON.parse(message) as { action: "logout" };
if (parsed.action === "logout") {
const sid = `${conn.uid}-${conn.sid}`;
if (!sids[sid]) {
sids[sid] = [];
}
sids[sid].forEach((e) => {
conns.delete(e);
e.close();
});
session_store.update(
{ sid: conn.sid, uid: conn.uid },
{
active: false,
wsid: [],
}
);
}
}
} catch (e) {}
} else {
try {
if (typeof message === "string") {
const activateSession = (result: SingleSession<T>) => {
try {
const wsid = createId();
conns.set(ws, {
wsid,
uid: result.uid,
sid: result.sid,
});
index[wsid] = ws;
const sid = `${result.uid}-${result.sid}`;
if (!sids[sid]) {
sids[sid] = [];
}
sids[sid].push(ws);
const wsids = Object.values(sids[sid]).map(
(e) => server_handler.ws.conns.get(e)?.sid || ""
);
session_store.update(
{ sid: result.sid, uid: result.uid },
{
wsid: wsids,
}
);
ws.send(
JSON.stringify({
status: "ok",
wsid,
session: result,
})
);
} catch (e) {
console.error(e);
}
};
const parsed = JSON.parse(message) as
| {
method: undefined;
uid: string;
sid: string;
}
| SessionAuth;
if (parsed) {
if (parsed.method) {
if (parsed.method === "user-pass") {
const result = await arg.login(session_store, parsed);
if (result) {
activateSession(result);
} else {
ws.send(JSON.stringify({ status: "failed" }));
}
}
} else {
const result = session_store.findFirst({
uid: parsed.uid,
sid: parsed.sid,
});
if (result) {
if (
result.active &&
(!result.expired_at ||
(result.expired_at &&
result.expired_at > Date.now()))
) {
activateSession(result);
} else {
session_store.update(
{
uid: parsed.uid,
sid: parsed.sid,
},
{ active: false }
);
ws.send(JSON.stringify({ status: "expired" }));
}
}
}
}
}
} catch (e) {
console.error(e);
}
}
},
close(ws, code, reason) {
const result = server_handler.ws.conns.get(ws);
if (result) {
const sid = `${result.uid}-${result.sid}`;
if (server_handler.ws.sids[sid]) {
server_handler.ws.sids[sid] = server_handler.ws.sids[
sid
].filter((e) => e !== ws);
session_store.update(
{ sid: result.sid },
{
wsid: Object.values(server_handler.ws.sids[sid]).map(
(e) => server_handler.ws.conns.get(e)?.sid || ""
),
}
);
}
delete server_handler.ws.index[result.wsid];
server_handler.ws.conns.delete(ws);
}
},
};
},
},
async handle(server_arg) {
const { req, handle, url } = server_arg;
const route_arg = {
...server_arg,
session: {
...session_store,
current: undefined,
},
};
if (url.pathname.startsWith("/_session/")) {
const res = await session_router.handle(route_arg);
if (res) return res;
}
if (arg.router) {
const res = await arg.router.handle(route_arg);
if (res) return res;
}
return handle(req);
},
};
server.ws = server_handler.ws.init();
server.session = server_handler;
} catch (e) {
console.log(e);
}
};

View File

@ -12,4 +12,14 @@ export const sessionClientStore = <T>() => ({
} }
return null; return null;
}, },
async save(arg: SessionData<T>): Promise<void> {
if (w._prasi?.site_id) {
localStorage.setItem(`sid-${w._prasi?.site_id}`, JSON.stringify(arg));
}
},
async clear(): Promise<void> {
if (w._prasi?.site_id) {
localStorage.removeItem(`sid-${w._prasi?.site_id}`);
}
},
}); });

View File

@ -14,6 +14,7 @@ export const session = sqliteTable(
), ),
active: integer("active", { mode: "boolean" }), active: integer("active", { mode: "boolean" }),
data: text("data", { mode: "json" }).notNull(), data: text("data", { mode: "json" }).notNull(),
wsid: text("wsid", { mode: "json" }).default([]),
expired_at: integer("expired_at", { mode: "timestamp_ms" }), expired_at: integer("expired_at", { mode: "timestamp_ms" }),
}, },
(table) => { (table) => {

View File

@ -1,10 +1,10 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import Database from "bun:sqlite"; import Database from "bun:sqlite";
import { and, eq, sql } from "drizzle-orm"; import { and, ConsoleLogWriter, eq, sql } from "drizzle-orm";
import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite"; import { BunSQLiteDatabase, drizzle } from "drizzle-orm/bun-sqlite";
import { mkdirSync } from "fs"; import { mkdirSync } from "fs";
import { join } from "path"; import { join } from "path";
import { dir } from "../../utils/dir"; import { dir } from "../../server/utils/dir";
import { SessionData, SessionStore, SingleSession } from "../type"; import { SessionData, SessionStore, SingleSession } from "../type";
import { session } from "./schema"; import { session } from "./schema";
@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS session (
created_at integer NOT NULL DEFAULT current_timestamp, created_at integer NOT NULL DEFAULT current_timestamp,
active integer, active integer,
data text NOT NULL, data text NOT NULL,
wsid text default \`[]\`,
expired_at integer expired_at integer
); );
CREATE INDEX IF NOT EXISTS expired_at_idx ON session (expired_at); CREATE INDEX IF NOT EXISTS expired_at_idx ON session (expired_at);
@ -68,24 +69,64 @@ CREATE INDEX IF NOT EXISTS expired_at_idx ON session (expired_at);
return { return {
create(data) { create(data) {
return createSingleStore( try {
return createSingleStore(
db.session
.insert(session)
.values({
uid: data.uid,
data,
active: true,
expired_at: data.expired_at
? new Date(data.expired_at * 1000)
: undefined,
})
.returning()
.get()
);
} catch (e) {
console.error("Session Create Error:\n", e);
}
},
update(where, data) {
try {
db.session db.session
.insert(session) .update(session)
.values({ .set(data)
uid: data.uid, .where(
data, and(
active: true, ...Object.entries(where || {}).map(([k, v]) => {
expired_at: data.expired_at return eq(k as any, v);
? new Date(data.expired_at * 1000) })
: undefined, )
}) )
.returning() .get();
.get() } catch (e) {
); console.error("Session Update Error:\n", e);
}
}, },
findFirst(arg) { findFirst(arg) {
return createSingleStore( try {
db.session return createSingleStore(
db.session
.select()
.from(session)
.where(
and(
...Object.entries(arg || {}).map(([k, v]) => {
return eq((session as any)[k], v);
})
)
)
.get()
);
} catch (e) {
console.error("Session FindFirst Error:\n", e);
}
},
findMany(arg) {
try {
return db.session
.select() .select()
.from(session) .from(session)
.where( .where(
@ -95,22 +136,11 @@ CREATE INDEX IF NOT EXISTS expired_at_idx ON session (expired_at);
}) })
) )
) )
.get() .all()
); .map((e) => createSingleStore(e));
}, } catch (e) {
findMany(arg) { console.error("Session FindMany Error:\n", e);
return db.session }
.select()
.from(session)
.where(
and(
...Object.entries(arg || {}).map(([k, v]) => {
return eq((session as any)[k], v);
})
)
)
.all()
.map((e) => createSingleStore(e));
}, },
} as SessionStore<T>; } as SessionStore<T>;
}; };

View File

@ -32,6 +32,10 @@ export type SessionStore<T> = {
data?: T; data?: T;
expired_at?: number; expired_at?: number;
}) => SingleSession<T>; }) => SingleSession<T>;
update: (
where: Partial<FilterSessionArg>,
data: { active?: boolean; wsid?: string[] }
) => SingleSession<T>[];
findMany: (arg?: Partial<FilterSessionArg>) => SingleSession<T>[]; findMany: (arg?: Partial<FilterSessionArg>) => SingleSession<T>[];
findFirst: (arg?: Partial<FilterSessionArg>) => null | SingleSession<T>; findFirst: (arg?: Partial<FilterSessionArg>) => null | SingleSession<T>;
}; };
@ -40,16 +44,22 @@ export type ServerSession<T> = SessionStore<T> & {
current?: SingleSession<T>; current?: SingleSession<T>;
}; };
export type ClientSessionStatus = "checking" | "guest" | "expired" | "active"; export type ClientSessionStatus =
| "checking"
| "guest"
| "expired"
| "active"
| "logout";
export type ClientSession<T> = { export type ClientSession<T> = {
status: ClientSessionStatus; status: ClientSessionStatus;
current: null | SessionData<T>; current: null | SessionData<T>;
connect(): Promise<{ status: ClientSessionStatus }>; wsid: string;
login(arg: { connectURL: URL;
method: "user-pass"; ws?: WebSocket;
username: string; init(): Promise<{ status: ClientSessionStatus }>;
password: string; connect(auth?: SessionAuth): Promise<void>;
}): Promise<void>; connected: boolean;
login(auth: SessionAuth): Promise<SessionData<T>>;
logout(): Promise<void>; logout(): Promise<void>;
}; };
@ -66,3 +76,9 @@ export type ServerContext = {
pathname: string; pathname: string;
}; };
}; };
export type SessionAuth = {
method: "user-pass";
username: string;
password: string;
};

View File

@ -1,80 +0,0 @@
import { SessionData } from "lib/server/session/server-session";
const w = window as unknown as {
_prasi_session: any;
_prasi: { site_id: string };
};
type SessionResponse<T> =
| { active: false; reason: string }
| { active: true; data: T; token: string };
export const sessionClient = async <T extends SessionData<any>>(arg: {
editorSampleData: T;
auth: {
mode: "user-pass";
};
on?: Partial<{
active: (arg: { token: string; data: T }) => any;
expired: () => any;
logout: () => any;
broadcast: (arg: { data: any }) => any;
}>;
}): Promise<Session<T>> => {
const session: Session<T> = {
active: false,
id_site: isEditor ? "" : w._prasi.site_id,
async login() {},
async logout() {},
token: "",
data: {} as T,
};
if (isEditor) {
session.active = true;
session.data = arg.editorSampleData;
return session;
}
if (w._prasi_session) return w._prasi_session as Session<T>;
const s = localStorage.getItem(`prss-${session.id_site}`);
if (!s) {
session.active = false;
} else {
let url = siteurl("/_session");
if (
location.hostname === "prasi.avolut.com" ||
location.host === "localhost:4550"
) {
const newurl = new URL(location.href);
newurl.pathname = `/_proxy/${url}`;
url = newurl.toString();
}
const prss = await fetch(url, { method: "POST", body: s });
try {
const resp = (await prss.json()) as SessionResponse<T>;
if (resp) {
if (resp.active) {
session.data = resp.data;
session.active = true;
session.token = resp.token;
} else {
console.warn("Session inactive, reason:" + resp.reason);
}
}
} catch (e) {
console.warn("Failed to activate session");
}
}
return session;
};
export type Session<T extends SessionData<any>> = {
active: boolean;
id_site: string;
login: (arg: { username: string; password: string }) => Promise<void>;
logout: () => Promise<void>;
token: string;
data: T;
};