develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
24 changed files with 767 additions and 2293 deletions
Showing only changes of commit 5c0ca0e139 - Show all commits

View File

@@ -9,7 +9,7 @@ plugins:
# opt:
# - paths=source_relative
- remote: buf.build/community/stephenh-ts-proto
out: ../stream-ui/src/server/utils/proto
out: ./src/server/utils/proto
opt:
- env=node
- esModuleInterop=true

View File

@@ -8,6 +8,7 @@
"@bufbuild/protobuf": "^2.11.0",
"@grpc/grpc-js": "^1.14.3",
"@hattip/adapter-node": "^0.0.49",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hono/node-server": "^1.19.11",
"@hono/zod-validator": "^0.7.6",
"@pinia/colada": "^0.21.7",
@@ -190,6 +191,8 @@
"@hattip/walk": ["@hattip/walk@0.0.49", "", { "dependencies": { "@hattip/headers": "0.0.49", "cac": "^6.7.14", "mime-types": "^2.1.35" }, "bin": { "hattip-walk": "cli.js" } }, "sha512-AgJgKLooZyQnzMfoFg5Mo/aHM+HGBC9ExpXIjNqGimYTRgNbL/K7X5EM1kR2JY90BNKk9lo6Usq1T/nWFdT7TQ=="],
"@hiogawa/tiny-rpc": ["@hiogawa/tiny-rpc@0.2.3-pre.18", "", {}, "sha512-BiNHrutG9G9yV622QvkxZxF+PhkaH2Aspp4/X1KYTfnaQTcg4fFUTBWf5Kf533swon2SuVJwi6U6H1LQbhVOQQ=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],

View File

@@ -13,6 +13,7 @@
"@bufbuild/protobuf": "^2.11.0",
"@grpc/grpc-js": "^1.14.3",
"@hattip/adapter-node": "^0.0.49",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hono/node-server": "^1.19.11",
"@hono/zod-validator": "^0.7.6",
"@pinia/colada": "^0.21.7",

View File

@@ -3,7 +3,7 @@ syntax = "proto3";
package stream.User.v1;
option go_package = "stream/proto/gen/go/User/v1;Userv1";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
service UserService {
@@ -14,6 +14,7 @@ service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
rpc UpdateUserPassword(UpdateUserPasswordRequest) returns (google.protobuf.Empty);
// Preferences
rpc GetPreferences(GetPreferencesRequest) returns (GetPreferencesResponse);
@@ -21,7 +22,10 @@ service UserService {
}
// ─── User Messages ───────────────────────────────────────────────────────────
message UpdateUserPasswordRequest {
string id = 1;
string new_password = 2;
}
message GetUserRequest {
string id = 1;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,65 @@
export const customFetch: typeof fetch = (input, init) => {
return fetch(input, {
...init,
credentials: 'include',
import { TinyRpcClientAdapter, TinyRpcError } from "@hiogawa/tiny-rpc";
import { Result } from "@hiogawa/utils";
const GET_PAYLOAD_PARAM = "payload";
export function httpClientAdapter(opts: {
url: string;
pathsForGET?: string[];
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter {
return {
send: async (data) => {
const url = [opts.url, data.path].join("/");
const payload = JSON.stringify(data.args);
const method = opts.pathsForGET?.includes(data.path)
? "GET"
: "POST";
const extraHeaders = opts.headers ? await opts.headers() : {};
let req: Request;
if (method === "GET") {
req = new Request(
url +
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
);
} else {
req = new Request(url, {
method: "POST",
body: payload,
headers: {
"content-type": "application/json; charset=utf-8",
...extraHeaders
},
credentials: "include",
});
};
}
let res: Response;
res = await fetch(req);
if (!res.ok) {
// throw new Error(`HTTP error: ${res.status}`);
throw new Error(
JSON.stringify({
status: res.status,
statusText: res.statusText,
data: { message: await res.text() },
internal: true,
})
);
// throw TinyRpcError.deserialize(res.status);
}
const result: Result<unknown, unknown> = JSON.parse(
await res.text()
);
if (!result.ok) {
throw TinyRpcError.deserialize(result.value);
}
return result.value;
},
};
}

View File

@@ -1,125 +1,70 @@
import { tryGetContext } from 'hono/context-storage';
import { TinyRpcClientAdapter, TinyRpcError } from "@hiogawa/tiny-rpc";
import { Result } from "@hiogawa/utils";
import { tryGetContext } from "hono/context-storage";
// export const baseAPIURL = 'https://api.pipic.fun';
export const baseAPIURL = 'http://localhost:8080';
const GET_PAYLOAD_PARAM = "payload";
type RequestOptions = RequestInit | { raw: Request };
const isRequest = (input: URL | RequestInfo): input is Request =>
typeof Request !== 'undefined' && input instanceof Request;
const isRequestLikeOptions = (options: RequestOptions): options is { raw: Request } =>
typeof options === 'object' && options !== null && 'raw' in options && options.raw instanceof Request;
const resolveInputUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
if (input instanceof URL) return new URL(input.toString());
if (isRequest(input)) return new URL(input.url);
const baseUrl = new URL(currentRequestUrl);
baseUrl.pathname = '/';
baseUrl.search = '';
baseUrl.hash = '';
return new URL(input, baseUrl);
};
const resolveApiUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
const inputUrl = resolveInputUrl(input, currentRequestUrl);
const apiUrl = new URL(baseAPIURL);
apiUrl.pathname = inputUrl.pathname.replace(/^\/?r(?=\/|$)/, '') || '/';
apiUrl.search = inputUrl.search;
apiUrl.hash = inputUrl.hash;
return apiUrl;
};
const getOptionHeaders = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.headers : options.headers;
const getOptionMethod = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.method : options.method;
const getOptionBody = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.body : options.body;
const getOptionSignal = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.signal : options.signal;
const getOptionCredentials = (options: RequestOptions) =>
isRequestLikeOptions(options) ? undefined : options.credentials;
const mergeHeaders = (input: URL | RequestInfo, options: RequestOptions) => {
const c = tryGetContext<any>();
const mergedHeaders = new Headers(c?.req.raw.headers ?? undefined);
const inputHeaders = isRequest(input) ? input.headers : undefined;
const optionHeaders = getOptionHeaders(options);
new Headers(inputHeaders).forEach((value, key) => {
mergedHeaders.set(key, value);
export function httpClientAdapter(opts: {
url: string;
pathsForGET?: string[];
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter {
return {
send: async (data) => {
const url = [opts.url, data.path].join("/");
const payload = JSON.stringify(data.args);
const method = opts.pathsForGET?.includes(data.path)
? "GET"
: "POST";
let req: Request;
if (method === "GET") {
req = new Request(
url +
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
);
} else {
req = new Request(url, {
method: "POST",
body: payload,
headers: {
"content-type": "application/json; charset=utf-8",
},
credentials: "include",
});
new Headers(optionHeaders).forEach((value, key) => {
mergedHeaders.set(key, value);
});
mergedHeaders.delete('host');
mergedHeaders.delete('connection');
mergedHeaders.delete('content-length');
mergedHeaders.delete('transfer-encoding');
return mergedHeaders;
};
const resolveMethod = (input: URL | RequestInfo, options: RequestOptions) => {
const method = getOptionMethod(options);
if (method) return method;
if (isRequest(input)) return input.method;
return 'GET';
};
const resolveBody = (input: URL | RequestInfo, options: RequestOptions, method: string) => {
if (method === 'GET' || method === 'HEAD') return undefined;
const body = getOptionBody(options);
if (typeof body !== 'undefined') return body;
if (isRequest(input)) return input.body;
return undefined;
};
export const customFetch = (input: URL | RequestInfo, options: RequestOptions = {}) => {
}
let res: Response;
if (import.meta.env.SSR) {
const c = tryGetContext<any>();
if (!c) {
throw new Error('Hono context not found in SSR');
throw new Error("Hono context not found in SSR");
}
const apiUrl = resolveApiUrl(input, c.req.url);
const method = resolveMethod(input, options);
const body = resolveBody(input, options, method.toUpperCase());
const requestOptions: RequestInit & { duplex?: 'half' } = {
...(isRequestLikeOptions(options) ? {} : options),
method,
headers: mergeHeaders(input, options),
body,
credentials: getOptionCredentials(options) ?? 'include',
signal: getOptionSignal(options) ?? (isRequest(input) ? input.signal : undefined),
};
if (body) {
requestOptions.duplex = 'half';
}
return fetch(apiUrl, requestOptions).then((response) => {
const setCookies = typeof response.headers.getSetCookie === 'function'
? response.headers.getSetCookie()
: response.headers.get('set-cookie')
? [response.headers.get('set-cookie')!]
: [];
for (const cookie of setCookies) {
c.header('Set-Cookie', cookie, { append: true });
}
return response;
Object.entries(c.req.header()).forEach(([k, v]) => {
req.headers.append(k, v);
});
};
res = await c.get("fetch")(req);
} else {
res = await fetch(req);
}
if (!res.ok) {
// throw new Error(`HTTP error: ${res.status}`);
throw new Error(
JSON.stringify({
status: res.status,
statusText: res.statusText,
data: { message: await res.text() },
internal: true,
})
);
// throw TinyRpcError.deserialize(res.status);
}
const result: Result<unknown, unknown> = JSON.parse(
await res.text()
);
if (!result.ok) {
throw TinyRpcError.deserialize(result.value);
}
return result.value;
},
};
}

19
src/api/rpcclient.ts Normal file
View File

@@ -0,0 +1,19 @@
import {
proxyTinyRpc,
TinyRpcClientAdapter,
TinyRpcError,
} from "@hiogawa/tiny-rpc";
import { Result } from "@hiogawa/utils";
import { httpClientAdapter } from "@httpClientAdapter";
// console.log("httpClientAdapter module:", httpClientAdapter.toString());
declare let __host__: string;
const endpoint = "/rpc";
const url = import.meta.env.SSR ? "http://localhost" : "";
import { type RpcRoutes } from "@/server/routes/rpc";
export const client = proxyTinyRpc<RpcRoutes>({
adapter: httpClientAdapter({
url: url + endpoint,
pathsForGET: [],
}),
});

View File

@@ -1,6 +1,5 @@
import { Hono } from 'hono';
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
import { setupMiddlewares } from './server/middlewares/setup';
import { registerDisplayRoutes } from './server/routes/display';
import { registerManifestRoutes } from './server/routes/manifest';
@@ -8,15 +7,15 @@ import { registerMergeRoutes } from './server/routes/merge';
import { registerSSRRoutes } from './server/routes/ssr';
import { registerWellKnownRoutes } from './server/routes/wellKnown';
import { setupServices } from './server/services/grpcClient';
import { registerRpcRoutes } from './server/routes/rpc';
const app = new Hono();
// Global middlewares
setupMiddlewares(app);
setupServices(app);
// API proxy middleware (handles /r/*)
app.use(apiProxyMiddleware);
// Routes
registerWellKnownRoutes(app);
registerRpcRoutes(app);
registerMergeRoutes(app);
registerDisplayRoutes(app);
registerManifestRoutes(app);

View File

@@ -1,11 +0,0 @@
import { customFetch } from '@httpClientAdapter';
import type { Context, Next } from 'hono';
export async function apiProxyMiddleware(c: Context, next: Next) {
const path = c.req.path;
if (path !== '/r' && !path.startsWith('/r/')) {
return await next();
}
return customFetch(c.req.url, c.req)
}

View File

@@ -0,0 +1,59 @@
import { MiddlewareHandler } from "hono";
import { getCookie } from "hono/cookie";
import { HTTPException } from "hono/http-exception";
import { getUserServiceClient } from "../services/grpcClient";
import { generateAndSetTokens } from "../utils";
export const authenticate: MiddlewareHandler = async (ctx, next) => {
let payload
let cause
const jwtProvider = ctx.get("jwtProvider");
const token = getCookie(ctx, "access_token");
if (!token) {
throw new HTTPException(401, {
message: 'Unauthorized',
})
}
try {
payload = await jwtProvider.parseToken(token);
} catch (e) {
cause = e
}
if (!payload) {
const refreshToken = getCookie(ctx, "refresh_token");
if (!refreshToken) {
throw new HTTPException(401, {
message: 'Unauthorized',
})
}
const refreshPayload = await jwtProvider.parseToken(refreshToken);
if (!refreshPayload) {
throw new HTTPException(401)
}
const redis = ctx.get("redis");
const refreshUuid = refreshPayload["refresh_uuid"];
const userId = await redis.get("refresh_uuid:" + refreshUuid);
if (!userId) {
throw new HTTPException(401)
}
const userData = await getUserServiceClient().getUser({ id: userId });
// userData.user
redis.del("refresh_uuid:" + refreshUuid);
const tokenPair = await generateAndSetTokens(ctx, userData.user!);
payload = {
user_id: userId,
email: userData.user!.email,
role: userData.user!.role,
token_id: tokenPair.accessUUID,
}
}
if (!payload.user_id || !payload.role) {
throw new HTTPException(401, {
message: 'Unauthorized',
})
}
ctx.set('jwtPayload', payload)
ctx.set('userId', payload.user_id)
ctx.set("role", payload.role)
await next();
};

View File

@@ -4,6 +4,7 @@ import { contextStorage } from "hono/context-storage";
import { cors } from "hono/cors";
import { languageDetector } from "hono/language";
import isMobile from "is-mobile";
import { JwtProvider } from "../utils/token";
type AppFetch = (
input: string | Request | URL,
requestInit?: RequestInit
@@ -14,6 +15,9 @@ declare module "hono" {
fetch: AppFetch;
isMobile: boolean;
redis: RedisClient;
jwtProvider: JwtProvider;
userId: string;
role: string;
}
}
@@ -29,7 +33,11 @@ export function setupMiddlewares(app: Hono) {
lookupFromHeaderKey: "accept-language",
order: ["cookie", "header"],
}),
contextStorage()
contextStorage(),
async (c, next) => {
c.set("jwtProvider", JwtProvider.newJWTProvider("your-secret-key"));
await next();
}
);
app.use(cors(), async (c, next) => {
@@ -44,7 +52,7 @@ export function setupMiddlewares(app: Hono) {
await next();
});
app.use(async (c, next) => {
client
return await client
.connect()
.then(() => {
c.set("redis", client);

View File

@@ -1,7 +1,11 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import z from 'zod';
import { getUserServiceClient } from '../services/grpcClient';
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import z, { success } from "zod";
import { getUserServiceClient } from "../services/grpcClient";
import { generateAndSetTokens } from "../utils";
import { getCookie, setCookie } from "hono/cookie";
import { jwt } from "hono/jwt";
import { authenticate } from "../middlewares/authenticate";
// authGroup := r.Group("/auth")
// {
// authGroup.POST("/login", authHandler.Login)
@@ -12,68 +16,124 @@ import { getUserServiceClient } from '../services/grpcClient';
// authGroup.GET("/google/callback", authHandler.GoogleCallback)
// }
const authRoute = new Hono();
authRoute.post('/login', zValidator('json', z.object({ email: z.email(), password: z.string().min(6) })), async (c) => {
const data = c.req.valid("json")
const user = await getUserServiceClient().getUserByEmail(data);
authRoute.post(
"/login",
zValidator(
"json",
z.object({
email: z.email("Invalid email or password"),
password: z.string().min(6, "Invalid email or password"),
}),
),
async (c) => {
const { email, password } = c.req.valid("json");
const user = await getUserServiceClient().getUserByEmail({ email });
if (!user) {
return c.json({ error: 'Invalid email or password' }, 401);
return c.json({ error: "Invalid email or password" }, 401);
}
if (user.password !== data.password) {
return c.json({ error: 'Invalid email or password' }, 401);
const isMatch = Bun.password.verifySync(password, user.user!.password!, "bcrypt");
if (!isMatch) {
return c.json({ error: "Invalid email or password" }, 401);
}
// const user = await getUserServiceClient().getUserByEmail({ email }, (err, response) => {
// if (err) {
// console.error("Error fetching user by email", err);
// return null;
// }
// return response;
// });
// return c.json({ message: 'Login endpoint' });
});
authRoute.post('/register', zValidator('json', z.object({ email: z.email(), password: z.string().min(6) })), async (c) => {
return c.json({ message: 'Register endpoint' });
});
authRoute.post('/forgot-password', zValidator('json', z.object({ email: z.email() })), async (c) => {
return c.json({ message: 'Forgot Password endpoint' });
});
authRoute.post('/reset-password', zValidator('json', z.object({ token: z.string(), password: z.string().min(6) })), async (c) => {
return c.json({ message: 'Reset Password endpoint' });
});
authRoute.get('/google/login', zValidator('query', z.object({ redirect_uri: z.string().url() })), async (c) => {
return c.json({ message: 'Google Login endpoint' });
});
authRoute.get('/google/callback', zValidator('query', z.object({ code: z.string(), state: z.string() })), async (c) => {
return c.json({ message: 'Google Callback endpoint' });
});
await generateAndSetTokens(c, user.user!);
return c.json({ message: "Login successful" });
},
);
authRoute.post(
"/register",
zValidator(
"json",
z.object({
email: z.email("Invalid email"),
username: z.string().min(3, "Username must be at least 3 characters"),
password: z.string().min(6, "Password must be at least 6 characters"),
}),
),
async (c) => {
const { email, username, password } = c.req.valid("json");
const user = await getUserServiceClient().createUser({
email,
username,
password: Bun.password.hashSync(password, { algorithm: "bcrypt", cost: 12 }),
});
delete user.user?.password;
return c.json({ success: true, user: user.user });
},
);
authRoute.post(
"/forgot-password",
zValidator("json", z.object({ email: z.email("Invalid email") })),
async (c) => {
const { email } = c.req.valid("json");
const user = await getUserServiceClient().getUserByEmail({ email });
if (user) {
const redis = c.get("redis");
const resetToken = crypto.randomUUID();
redis?.set("reset_pw:" + resetToken, user.user?.id || "", "EX", 15 * 60);
//TODO: Connect to email service to send reset link with token
}
return c.json({ message: "If email exists, a reset link has been sent" });
},
);
authRoute.post(
"/reset-password",
zValidator(
"json",
z.object({ token: z.string(), password: z.string().min(6) }),
),
async (c) => {
const { token, password } = c.req.valid("json");
const redis = c.get("redis");
const userId = await redis?.get("reset_pw:" + token);
if (userId) {
// Update the user's password in the database
await getUserServiceClient().updateUserPassword({
id: userId,
newPassword: Bun.password.hashSync(password, {
algorithm: "bcrypt",
cost: 12,
}),
});
}
return c.json({ message: "Reset Password endpoint" });
},
);
authRoute.get(
"/google/login",
zValidator("query", z.object({ redirect_uri: z.string().url() })),
async (c) => {
//TODO: Implement Google OAuth flow
return c.json({ message: "Google Login endpoint" });
},
);
authRoute.get(
"/google/callback",
zValidator("query", z.object({ code: z.string(), state: z.string() })),
async (c) => {
//TODO: Implement Google OAuth flow
return c.json({ message: "Google Callback endpoint" });
},
);
export function registerAuthRoutes(app: Hono) {
// app.post('/merge', async (c) => {
// try {
// const body = await c.req.json();
// const { filename, chunks, size } = body;
// if (!filename || !Array.isArray(chunks) || chunks.length === 0) {
// return c.json({ error: 'invalid payload' }, 400);
// }
// const hostError = validateChunkUrls(chunks);
// if (hostError) return c.json({ error: hostError }, 400);
// const manifest = createManifest(filename, chunks, size);
// await saveManifest(manifest);
// return c.json({
// status: 'ok',
// id: manifest.id,
// filename: manifest.filename,
// total_parts: manifest.total_parts,
// size: manifest.size,
// playback_url: `/display/${manifest.id}`,
// play_url: `/play/index/${manifest.id}`,
// manifest_url: `/manifest/${manifest.id}`,
// });
// } catch (e: any) {
// return c.json({ error: e?.message ?? String(e) }, 500);
// }
// });
app.route("/auth", authRoute);
app.get(
"/logout",
authenticate,
async (c) => {
const payload = c.get("jwtPayload") as any;
const redis = c.get("redis");
redis.del("refresh_uuid:" + payload["refresh_uuid"]);
setCookie(c, "access_token", "", {
expires: new Date(0),
httpOnly: true,
secure: false,
});
setCookie(c, "refresh_token", "", {
expires: new Date(0),
httpOnly: true,
secure: false,
});
return c.json({ message: "Logged out successfully" });
},
);
}

View File

@@ -0,0 +1,42 @@
import { authenticate } from "@/server/middlewares/authenticate";
import {
exposeTinyRpc,
httpServerAdapter,
validateFn,
} from "@hiogawa/tiny-rpc";
import { tinyassert } from "@hiogawa/utils";
import { Hono } from "hono";
import { getContext } from "hono/context-storage";
import { jwt } from "hono/jwt";
import { z } from "zod";
import { meMethods } from "./me";
const routes = {
// define as a bare function
checkId: (id: string) => {
const context = getContext();
console.log(context.req.raw.headers);
return id === "good";
},
...meMethods
};
export type RpcRoutes = typeof routes;
export const endpoint = "/rpc";
export const pathsForGET: (keyof typeof routes)[] = ["checkId"];
export function registerRpcRoutes(app: Hono) {
app.use(endpoint, authenticate, async (c, next) => {
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
return await next();
}
const handler = exposeTinyRpc({
routes,
adapter: httpServerAdapter({ endpoint }),
});
const res = await handler({ request: c.req.raw });
if (res) {
return res;
}
return await next();
});
}

View File

@@ -0,0 +1,56 @@
import { validateFn } from "@hiogawa/tiny-rpc";
import { getContext } from "hono/context-storage";
import z from "zod";
export const meMethods = {
getMe: async () => {
const context = getContext();
const userServiceClient = context.get("userServiceClient");
const user = await userServiceClient.getUser({ id: context.get("userId") });
const userPreferences = await userServiceClient.getPreferences({ userId: context.get("userId") });
delete user.user?.password
return {
...user.user,
...userPreferences.preferences
};
},
updateMe: validateFn(z.object({
username: z.string().min(3).optional(),
avatar: z.url().optional(),
role: z.string().optional(),
planId: z.string().optional(),
}))(
async (data) => {
const context = getContext();
const user = await context.get("userServiceClient").updateUser({
id: context.get("userId"),
username: data.username,
avatar: data.avatar,
role: data.role,
planId: data.planId,
});
delete user.user?.password
return user.user;
}
),
ChangePassword: validateFn(z.object({
oldPassword: z.string().min(6),
newPassword: z.string().min(6),
}))(
async (data) => {
const context = getContext();
const user = await context.get("userServiceClient").getUser({ id: context.get("userId") });
if (!user.user) {
throw new Error("User not found");
}
const isMatch = Bun.password.verifySync(data.oldPassword, user.user!.password!, "bcrypt");
if (!isMatch) {
throw new Error("Invalid password");
}
await context.get("userServiceClient").updateUserPassword({
id: context.get("userId"),
newPassword: Bun.password.hashSync(data.newPassword, { algorithm: "bcrypt", cost: 12 }),
});
}
)
};

View File

@@ -4,4 +4,17 @@ export function registerWellKnownRoutes(app: Hono) {
app.get("/.well-known/*", (c) => {
return c.json({ ok: true });
});
app.get("/health/live", (c) => {
return c.json({ status: "alive" });
});
app.get("/health/ready", (c) => {
return c.json({ status: "ready" });
});
app.get("/health/detailed", (c) => {
return c.json({ status: "detailed", uptime: process.uptime() });
});
// app.get("/metrics", (c) => {
// //TODO: Implement metrics endpoint/ Prometheus integration
// return c.json({ message: "Metrics endpoint" });
// });
}

View File

@@ -55,6 +55,6 @@ export const getUserServiceClient = () => {
export const setupServices = (app: Hono) => {
app.use("*", async (c, next) => {
c.set("userServiceClient", promisifyClient(new UserServiceClient(grpcAddress(), getCredentials())));
await next();
return await next();
});
}

View File

@@ -1,43 +1,78 @@
import { ClientUnaryCall, ServiceError, status } from "@grpc/grpc-js";
type UnaryCallback<TRes> = (
error: ServiceError | null,
response: TRes
) => void;
type UnaryLike<TReq, TRes> = (
req: TReq,
callback: UnaryCallback<TRes>
) => ClientUnaryCall;
// 1. Định nghĩa lại UnaryCallback để bắt được kiểu TRes chính xác hơn
type UnaryCallback<TRes> = (error: ServiceError | null, response: TRes) => void;
type RequestOf<T> = T extends (
req: infer TReq,
callback: UnaryCallback<any>
) => ClientUnaryCall
? TReq
: never;
// 2. Ép TypeScript tìm đúng Overload có Callback
// Chúng ta sử dụng tham số thứ 2 của hàm (index 1) để lấy TRes
type ResponseOf<T> = T extends {
(req: any, callback: UnaryCallback<infer TRes>): ClientUnaryCall;
(req: any, metadata: any, callback: UnaryCallback<infer TRes>): ClientUnaryCall;
(req: any, metadata: any, options: any, callback: UnaryCallback<infer TRes>): ClientUnaryCall;
} ? TRes : any;
type ResponseOf<T> = T extends (
req: any,
callback: UnaryCallback<infer TRes>
) => ClientUnaryCall
? TRes
: never;
type RequestOf<T> = T extends {
(req: infer TReq, callback: UnaryCallback<any>): ClientUnaryCall;
(req: infer TReq, metadata: any, callback: UnaryCallback<any>): ClientUnaryCall;
} ? TReq : any;
/**
* Lấy ra overload đúng dạng (req, callback) => ClientUnaryCall
*/
type ExtractUnaryOverload<T> = Extract<T, UnaryLike<any, any>>;
// 3. Filter để chỉ lấy các Method thực sự là Unary
type UnaryKeys<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => ClientUnaryCall ? K : never;
}[keyof T];
export type PromisifiedClient<TClient> = {
[K in keyof TClient as ExtractUnaryOverload<TClient[K]> extends never
? never
: K]: (
req: RequestOf<ExtractUnaryOverload<TClient[K]>>
) => Promise<ResponseOf<ExtractUnaryOverload<TClient[K]>>>;
[K in UnaryKeys<TClient>]: (
req: RequestOf<TClient[K]>
) => Promise<ResponseOf<TClient[K]>>;
};
// ... Các hàm normalizeGrpcError giữ nguyên ...
const grpcCodeToHttpStatus = (code?: number) => {
export function promisifyClient<TClient extends object>(
client: TClient
): PromisifiedClient<TClient> {
const result = {} as any;
// Thay vì quét Prototype, ta quét các key thực tế hiện có trên instance của client
// gRPC dynamic clients thường định nghĩa method trực tiếp hoặc qua proxy
const allKeys = new Set([
...Object.getOwnPropertyNames(client),
...Object.getOwnPropertyNames(Object.getPrototypeOf(client))
]);
allKeys.forEach((key) => {
if (key === "constructor") return;
const originalMethod = (client as any)[key];
// Chỉ xử lý nếu nó là function và không phải là các hàm tiện ích của gRPC (bắt đầu bằng $)
if (typeof originalMethod === "function" && !key.startsWith('$')) {
result[key] = (req: any) =>
new Promise((resolve, reject) => {
// QUAN TRỌNG: Sử dụng .bind(client) hoặc .call(client, ...)
// để tránh lỗi "No implementation found" do mất context 'this'
originalMethod.call(
client,
req,
(error: ServiceError | null, response: any) => {
if (error) {
reject(normalizeGrpcError(error));
return;
}
resolve(response);
}
);
});
}
});
return result;
}
function grpcCodeToHttpStatus (code?: number) {
switch (code) {
case status.INVALID_ARGUMENT:
return 400;
@@ -51,7 +86,7 @@ const grpcCodeToHttpStatus = (code?: number) => {
return 500;
}
};
const normalizeGrpcError = (error: ServiceError) => {
function normalizeGrpcError(error: ServiceError) {
const normalized = new Error(error.details || error.message) as Error & {
status?: number;
code?: number;
@@ -77,33 +112,3 @@ const normalizeGrpcError = (error: ServiceError) => {
return normalized;
};
export function promisifyClient<TClient extends object>(
client: TClient
): PromisifiedClient<TClient> {
const proto = Object.getPrototypeOf(client);
const result: Record<string, unknown> = {};
for (const key of Object.getOwnPropertyNames(proto)) {
if (key === "constructor") continue;
const value = (client as Record<string, unknown>)[key];
if (typeof value !== "function") continue;
result[key] = (req: unknown) =>
new Promise((resolve, reject) => {
(value as Function).call(
client,
req,
(error: ServiceError | null, response: unknown) => {
if (error) {
reject(normalizeGrpcError(error));
return;
}
resolve(response);
}
);
});
}
return result as PromisifiedClient<TClient>;
}

View File

@@ -1,9 +1,10 @@
import { RedisClient } from "bun";
import { Context } from "hono";
import { tryGetContext } from "hono/context-storage";
import {
setCookie
} from 'hono/cookie';
import { JWTProvider } from "./token";
import { User } from "./proto/v1/user";
export const redisClient = (): RedisClient => {
const context = tryGetContext<any>();
const redis = context?.get("redis") as RedisClient | undefined;
@@ -13,26 +14,26 @@ export const redisClient = (): RedisClient => {
return redis;
};
export async function generateAndSetTokens(userID: string, email: string, role: string) {
const redis = redisClient();
const context = tryGetContext<any>();
await JWTProvider("your-secret-key").generateTokenPair(userID, email, role).then((td) => {
redis.set("refresh_uuid:" + td.refreshUUID, userID, "EX", td.rtExpires - Math.floor(Date.now() / 1000));
if (context) {
setCookie(context, "access_token", td.accessToken, {
export async function generateAndSetTokens(c: Context, userData: User) {
const redis = c.get("redis");
const jwtProvider = c.get("jwtProvider");
return await jwtProvider.generateTokenPair(userData.id!, userData.email!, userData.role!).then((td) => {
redis.set("refresh_uuid:" + td.refreshUUID, userData.id!, "EX", td.rtExpires - Math.floor(Date.now() / 1000));
setCookie(c, "access_token", td.accessToken, {
expires: new Date(td.atExpires * 1000),
httpOnly: true,
secure: false,
path: "/",
});
setCookie(context, "refresh_token", td.refreshToken, {
setCookie(c, "refresh_token", td.refreshToken, {
expires: new Date(td.rtExpires * 1000),
httpOnly: true,
secure: false,
path: "/",
});
}
return td;
}).catch((e) => {
console.error("Error generating tokens", e);
throw e;
});
}

View File

@@ -0,0 +1,86 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.11.4
// protoc unknown
// source: google/protobuf/empty.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
export const protobufPackage = "google.protobuf";
/**
* A generic empty message that you can re-use to avoid defining duplicated
* empty messages in your APIs. A typical example is to use it as the request
* or the response type of an API method. For instance:
*
* service Foo {
* rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty);
* }
*/
export interface Empty {
}
function createBaseEmpty(): Empty {
return {};
}
export const Empty: MessageFns<Empty> = {
encode(_: Empty, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Empty {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseEmpty();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(_: any): Empty {
return {};
},
toJSON(_: Empty): unknown {
const obj: any = {};
return obj;
},
create<I extends Exact<DeepPartial<Empty>, I>>(base?: I): Empty {
return Empty.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Empty>, I>>(_: I): Empty {
const message = createBaseEmpty();
return message;
},
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}

View File

@@ -18,10 +18,17 @@ import {
type ServiceError,
type UntypedServiceImplementation,
} from "@grpc/grpc-js";
import { Empty } from "../google/protobuf/empty";
import { Timestamp } from "../google/protobuf/timestamp";
export const protobufPackage = "stream.User.v1";
/** ─── User Messages ─────────────────────────────────────────────────────────── */
export interface UpdateUserPasswordRequest {
id?: string | undefined;
newPassword?: string | undefined;
}
export interface GetUserRequest {
id?: string | undefined;
}
@@ -128,6 +135,86 @@ export interface Preferences {
encrytionM3u8?: boolean | undefined;
}
function createBaseUpdateUserPasswordRequest(): UpdateUserPasswordRequest {
return { id: "", newPassword: "" };
}
export const UpdateUserPasswordRequest: MessageFns<UpdateUserPasswordRequest> = {
encode(message: UpdateUserPasswordRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.id !== undefined && message.id !== "") {
writer.uint32(10).string(message.id);
}
if (message.newPassword !== undefined && message.newPassword !== "") {
writer.uint32(18).string(message.newPassword);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): UpdateUserPasswordRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseUpdateUserPasswordRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.id = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.newPassword = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): UpdateUserPasswordRequest {
return {
id: isSet(object.id) ? globalThis.String(object.id) : "",
newPassword: isSet(object.newPassword)
? globalThis.String(object.newPassword)
: isSet(object.new_password)
? globalThis.String(object.new_password)
: "",
};
},
toJSON(message: UpdateUserPasswordRequest): unknown {
const obj: any = {};
if (message.id !== undefined && message.id !== "") {
obj.id = message.id;
}
if (message.newPassword !== undefined && message.newPassword !== "") {
obj.newPassword = message.newPassword;
}
return obj;
},
create<I extends Exact<DeepPartial<UpdateUserPasswordRequest>, I>>(base?: I): UpdateUserPasswordRequest {
return UpdateUserPasswordRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<UpdateUserPasswordRequest>, I>>(object: I): UpdateUserPasswordRequest {
const message = createBaseUpdateUserPasswordRequest();
message.id = object.id ?? "";
message.newPassword = object.newPassword ?? "";
return message;
},
};
function createBaseGetUserRequest(): GetUserRequest {
return { id: "" };
}
@@ -1849,6 +1936,16 @@ export const UserServiceService = {
responseSerialize: (value: DeleteUserResponse): Buffer => Buffer.from(DeleteUserResponse.encode(value).finish()),
responseDeserialize: (value: Buffer): DeleteUserResponse => DeleteUserResponse.decode(value),
},
updateUserPassword: {
path: "/stream.User.v1.UserService/UpdateUserPassword",
requestStream: false,
responseStream: false,
requestSerialize: (value: UpdateUserPasswordRequest): Buffer =>
Buffer.from(UpdateUserPasswordRequest.encode(value).finish()),
requestDeserialize: (value: Buffer): UpdateUserPasswordRequest => UpdateUserPasswordRequest.decode(value),
responseSerialize: (value: Empty): Buffer => Buffer.from(Empty.encode(value).finish()),
responseDeserialize: (value: Buffer): Empty => Empty.decode(value),
},
/** Preferences */
getPreferences: {
path: "/stream.User.v1.UserService/GetPreferences",
@@ -1882,6 +1979,7 @@ export interface UserServiceServer extends UntypedServiceImplementation {
createUser: handleUnaryCall<CreateUserRequest, CreateUserResponse>;
updateUser: handleUnaryCall<UpdateUserRequest, UpdateUserResponse>;
deleteUser: handleUnaryCall<DeleteUserRequest, DeleteUserResponse>;
updateUserPassword: handleUnaryCall<UpdateUserPasswordRequest, Empty>;
/** Preferences */
getPreferences: handleUnaryCall<GetPreferencesRequest, GetPreferencesResponse>;
upsertPreferences: handleUnaryCall<UpsertPreferencesRequest, UpsertPreferencesResponse>;
@@ -1979,6 +2077,21 @@ export interface UserServiceClient extends Client {
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: DeleteUserResponse) => void,
): ClientUnaryCall;
updateUserPassword(
request: UpdateUserPasswordRequest,
callback: (error: ServiceError | null, response: Empty) => void,
): ClientUnaryCall;
updateUserPassword(
request: UpdateUserPasswordRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: Empty) => void,
): ClientUnaryCall;
updateUserPassword(
request: UpdateUserPasswordRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: Empty) => void,
): ClientUnaryCall;
/** Preferences */
getPreferences(
request: GetPreferencesRequest,

View File

@@ -9,9 +9,7 @@ export interface Provider {
role: string
): Promise<TokenPair>
parseToken(token: string): Promise<JWTPayload>
parseMapToken(token: string): Promise<Record<string, any>>
parseToken(token: string): Promise<JwtClaims>
}
export interface TokenPair {
@@ -30,7 +28,7 @@ export interface Claims {
tokenID: string
}
interface JwtClaims {
export interface JwtClaims extends JWTPayload {
user_id: string
email: string
role: string
@@ -42,7 +40,7 @@ interface JwtClaims {
export class JwtProvider implements Provider {
constructor(private secret: string) {}
static newJWTProvider(secret: string): Provider {
static newJWTProvider(secret: string): JwtProvider {
return new JwtProvider(secret)
}
@@ -86,26 +84,12 @@ export class JwtProvider implements Provider {
return td
}
async parseToken(token: string): Promise<JWTPayload> {
const payload = (await verify(token, this.secret, "HS256"))
async parseToken(token: string): Promise<JwtClaims> {
const payload = await verify(token, this.secret, "HS256") as JwtClaims
if (!payload) {
throw new Error("invalid token")
}
return payload
}
async parseMapToken(token: string): Promise<JWTPayload> {
const payload = await verify(token, this.secret, "HS256")
if (!payload) {
throw new Error("invalid token")
}
return payload
}
}
export function JWTProvider(secret: string): Provider {
return new JwtProvider(secret)
}

View File

@@ -1,5 +1,7 @@
import { client, type AuthUserPayload, type ResponseResponse } from '@/api/client';
// import { client, type AuthUserPayload, type ResponseResponse } from '@/api/client';
import { client } from '@/api/rpcclient';
import { TinyMqttClient } from '@/lib/liteMqtt';
import type { User } from '@/server/utils/proto/v1/user';
import { useTranslation } from 'i18next-vue';
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
@@ -11,23 +13,8 @@ type ProfileUpdatePayload = {
locale?: string;
};
type AuthResponseBody = ResponseResponse & {
data?: AuthUserPayload | { user?: AuthUserPayload };
};
const mqttBrokerUrl = 'wss://mqtt-dashboard.com:8884/mqtt';
const extractUser = (body?: AuthResponseBody | null): AuthUserPayload | null => {
const data = body?.data;
if (!data) return null;
if (typeof data === 'object' && 'user' in data && data.user) {
return data.user;
}
return data as AuthUserPayload;
};
const getGoogleLoginPath = () => {
const basePath = client.baseUrl.startsWith('/') ? client.baseUrl : `/${client.baseUrl}`;
return `${basePath}/auth/google/login`;
@@ -71,12 +58,10 @@ export const useAuthStore = defineStore('auth', () => {
});
watch(() => user.value?.language, (lng) => i18next.changeLanguage(lng))
async function fetchMe() {
const response = await client.me.getMe({ baseUrl: '/r' });
const nextUser = extractUser(response.data as AuthResponseBody);
user.value = nextUser;
i18next.changeLanguage(nextUser?.language)
return nextUser;
const response = await client.getMe();
user.value = await client.getMe();
i18next.changeLanguage(response?.language || 'en');
return response;
}
async function init() {

7
src/type.d.ts vendored
View File

@@ -8,5 +8,10 @@ declare module '*.vue' {
}
declare module "@httpClientAdapter" {
export const customFetch: typeof fetch;
import { TinyRpcClientAdapter } from "@hiogawa/tiny-rpc";
export function httpClientAdapter(opts: {
url: string;
pathsForGET?: string[];
headers?: () => Promise<{ Authorization?: undefined; } | { Authorization: string; }>
}): TinyRpcClientAdapter;
}