diff --git a/buf.gen.yaml b/buf.gen.yaml index 2ae2aaa..b5276b2 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -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 diff --git a/bun.lock b/bun.lock index 88a7457..92e6c8f 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 7db5c9f..52d9d60 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/proto/v1/user.proto b/proto/v1/user.proto index 2d07c41..bad3eb9 100644 --- a/proto/v1/user.proto +++ b/proto/v1/user.proto @@ -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; } diff --git a/src/api/client.ts b/src/api/client.ts deleted file mode 100644 index e358ca8..0000000 --- a/src/api/client.ts +++ /dev/null @@ -1,1962 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ -// @ts-nocheck -/* - * --------------------------------------------------------------- - * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## - * ## ## - * ## AUTHOR: acacode ## - * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## - * --------------------------------------------------------------- - */ - -import { customFetch } from '@httpClientAdapter'; - -export interface AdminCreateAdminPaymentRequest { - payment_method: string; - plan_id: string; - term_months: number; - topup_amount?: number; - user_id: string; -} - -export interface AdminCreateAdminUserRequest { - email: string; - /** @minLength 6 */ - password: string; - plan_id?: string; - role?: string; - username?: string; -} - -export interface AdminDashboardPayload { - active_subscriptions?: number; - new_users_today?: number; - new_videos_today?: number; - total_ad_templates?: number; - total_payments?: number; - total_revenue?: number; - total_storage_used?: number; - total_users?: number; - total_videos?: number; -} - -export interface AdminSaveAdminAdTemplateRequest { - ad_format?: string; - description?: string; - duration?: number; - is_active?: boolean; - is_default?: boolean; - name: string; - user_id: string; - vast_tag_url: string; -} - -export interface AdminSaveAdminVideoRequest { - ad_template_id?: string; - description?: string; - duration?: number; - format?: string; - size: number; - status?: string; - title: string; - url: string; - user_id: string; -} - -export interface AdminSavePlanRequest { - cycle: string; - description?: string; - features?: string[]; - is_active?: boolean; - name: string; - price: number; - storage_limit: number; - upload_limit: number; -} - -export interface AdminUpdateAdminPaymentRequest { - status: string; -} - -export interface AdminUpdateAdminUserRequest { - email?: string; - password?: string; - plan_id?: string; - role?: string; - username?: string; -} - -export interface AdminUpdateUserRoleRequest { - role: string; -} - -export interface AdtemplatesSaveAdTemplateRequest { - ad_format?: string; - description?: string; - duration?: number; - is_active?: boolean; - is_default?: boolean; - name: string; - vast_tag_url: string; -} - -export interface AdtemplatesTemplateListPayload { - templates?: ManualAdTemplate[]; -} - -export interface AdtemplatesTemplatePayload { - template?: ManualAdTemplate; -} - -export interface AuthChangePasswordRequest { - current_password: string; - /** @minLength 6 */ - new_password: string; -} - -export interface AuthForgotPasswordRequest { - email: string; -} - -export interface AuthLoginRequest { - email: string; - password: string; -} - -export interface AuthRegisterRequest { - email: string; - /** @minLength 6 */ - password: string; - username: string; -} - -export interface AuthResetPasswordRequest { - /** @minLength 6 */ - new_password: string; - token: string; -} - -export interface AuthUpdateMeRequest { - email?: string; - language?: string; - locale?: string; - username?: string; -} - -export interface AuthUserPayload { - avatar?: string; - created_at?: string; - email?: string; - google_id?: string; - id?: string; - language?: string; - locale?: string; - plan_expires_at?: string; - plan_expiring_soon?: boolean; - plan_id?: string; - plan_payment_method?: string; - plan_started_at?: string; - plan_term_months?: number; - role?: string; - storage_used?: number; - updated_at?: string; - username?: string; - wallet_balance?: number; -} - -export interface DomainsCreateDomainRequest { - name: string; -} - -export interface ManualAdTemplate { - ad_format?: string; - created_at?: string; - description?: string; - duration?: number; - id?: string; - is_active?: boolean; - is_default?: boolean; - name?: string; - updated_at?: string; - user_id?: string; - vast_tag_url?: string; -} - -export interface ModelPlan { - cycle?: string; - description?: string; - duration_limit?: number; - features?: string[]; - id?: string; - is_active?: boolean; - name?: string; - price?: number; - quality_limit?: string; - storage_limit?: number; - upload_limit?: number; -} - -export interface ModelVideo { - created_at?: string; - description?: string; - duration?: number; - format?: string; - hls_path?: string; - hls_token?: string; - id?: string; - name?: string; - processing_status?: string; - size?: number; - status?: string; - storage_type?: string; - thumbnail?: string; - title?: string; - updated_at?: string; - url?: string; - user_id?: string; - views?: number; -} - -export interface PaymentCreatePaymentRequest { - payment_method: string; - plan_id: string; - term_months: number; - topup_amount?: number; -} - -export interface PaymentTopupWalletRequest { - amount: number; -} - -export interface PreferencesSettingsPreferencesRequest { - airplay?: boolean; - autoplay?: boolean; - chromecast?: boolean; - email_notifications?: boolean; - language?: string; - locale?: string; - loop?: boolean; - marketing_notifications?: boolean; - muted?: boolean; - pip?: boolean; - push_notifications?: boolean; - show_controls?: boolean; - telegram_notifications?: boolean; -} - -export interface ResponseResponse { - code?: number; - data?: any; - message?: string; -} - -export interface UsageUsagePayload { - total_storage?: number; - total_videos?: number; - user_id?: string; -} - -export interface VideoCreateVideoRequest { - description?: string; - /** Maybe client knows, or we process later */ - duration?: number; - format?: string; - size: number; - title: string; - /** The S3 Key or Full URL */ - url: string; -} - -export interface VideoUpdateVideoRequest { - ad_template_id?: string; - description?: string; - title: string; -} - -export interface VideoUploadURLRequest { - content_type: string; - filename: string; - size: number; -} - -export type QueryParamsType = Record; -export type ResponseFormat = keyof Omit; - -export interface FullRequestParams extends Omit { - /** set parameter to `true` for call `securityWorker` for this request */ - secure?: boolean; - /** request path */ - path: string; - /** content type of request body */ - type?: ContentType; - /** query params */ - query?: QueryParamsType; - /** format of response (i.e. response.json() -> format: "json") */ - format?: ResponseFormat; - /** request body */ - body?: unknown; - /** base url */ - baseUrl?: string; - /** request cancellation token */ - cancelToken?: CancelToken; -} - -export type RequestParams = Omit< - FullRequestParams, - "body" | "method" | "query" | "path" ->; - -export interface ApiConfig { - baseUrl?: string; - baseApiParams?: Omit; - securityWorker?: ( - securityData: SecurityDataType | null, - ) => Promise | RequestParams | void; - customFetch?: typeof fetch; -} - -export interface HttpResponse - extends Response { - data: D; - error: E; -} - -type CancelToken = Symbol | string | number; - -export enum ContentType { - Json = "application/json", - JsonApi = "application/vnd.api+json", - FormData = "multipart/form-data", - UrlEncoded = "application/x-www-form-urlencoded", - Text = "text/plain", -} - -export class HttpClient { - public baseUrl: string = "//localhost:8080"; - private securityData: SecurityDataType | null = null; - private securityWorker?: ApiConfig["securityWorker"]; - private abortControllers = new Map(); - private customFetch = (...fetchParams: Parameters) => - fetch(...fetchParams); - - private baseApiParams: RequestParams = { - credentials: "same-origin", - headers: {}, - redirect: "follow", - referrerPolicy: "no-referrer", - }; - - constructor(apiConfig: ApiConfig = {}) { - Object.assign(this, apiConfig); - } - - public setSecurityData = (data: SecurityDataType | null) => { - this.securityData = data; - }; - - protected encodeQueryParam(key: string, value: any) { - const encodedKey = encodeURIComponent(key); - return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`; - } - - protected addQueryParam(query: QueryParamsType, key: string) { - return this.encodeQueryParam(key, query[key]); - } - - protected addArrayQueryParam(query: QueryParamsType, key: string) { - const value = query[key]; - return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); - } - - protected toQueryString(rawQuery?: QueryParamsType): string { - const query = rawQuery || {}; - const keys = Object.keys(query).filter( - (key) => "undefined" !== typeof query[key], - ); - return keys - .map((key) => - Array.isArray(query[key]) - ? this.addArrayQueryParam(query, key) - : this.addQueryParam(query, key), - ) - .join("&"); - } - - protected addQueryParams(rawQuery?: QueryParamsType): string { - const queryString = this.toQueryString(rawQuery); - return queryString ? `?${queryString}` : ""; - } - - private contentFormatters: Record any> = { - [ContentType.Json]: (input: any) => - input !== null && (typeof input === "object" || typeof input === "string") - ? JSON.stringify(input) - : input, - [ContentType.JsonApi]: (input: any) => - input !== null && (typeof input === "object" || typeof input === "string") - ? JSON.stringify(input) - : input, - [ContentType.Text]: (input: any) => - input !== null && typeof input !== "string" - ? JSON.stringify(input) - : input, - [ContentType.FormData]: (input: any) => { - if (input instanceof FormData) { - return input; - } - - return Object.keys(input || {}).reduce((formData, key) => { - const property = input[key]; - formData.append( - key, - property instanceof Blob - ? property - : typeof property === "object" && property !== null - ? JSON.stringify(property) - : `${property}`, - ); - return formData; - }, new FormData()); - }, - [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), - }; - - protected mergeRequestParams( - params1: RequestParams, - params2?: RequestParams, - ): RequestParams { - return { - ...this.baseApiParams, - ...params1, - ...(params2 || {}), - headers: { - ...(this.baseApiParams.headers || {}), - ...(params1.headers || {}), - ...((params2 && params2.headers) || {}), - }, - }; - } - - protected createAbortSignal = ( - cancelToken: CancelToken, - ): AbortSignal | undefined => { - if (this.abortControllers.has(cancelToken)) { - const abortController = this.abortControllers.get(cancelToken); - if (abortController) { - return abortController.signal; - } - return void 0; - } - - const abortController = new AbortController(); - this.abortControllers.set(cancelToken, abortController); - return abortController.signal; - }; - - public abortRequest = (cancelToken: CancelToken) => { - const abortController = this.abortControllers.get(cancelToken); - - if (abortController) { - abortController.abort(); - this.abortControllers.delete(cancelToken); - } - }; - - public request = async ({ - body, - secure, - path, - type, - query, - format, - baseUrl, - cancelToken, - ...params - }: FullRequestParams): Promise> => { - const secureParams = - ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && - this.securityWorker && - (await this.securityWorker(this.securityData))) || - {}; - const requestParams = this.mergeRequestParams(params, secureParams); - const queryString = query && this.toQueryString(query); - const payloadFormatter = this.contentFormatters[type || ContentType.Json]; - const responseFormat = format || requestParams.format; - - return this.customFetch( - `${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, - { - ...requestParams, - headers: { - ...(requestParams.headers || {}), - ...(type && type !== ContentType.FormData - ? { "Content-Type": type } - : {}), - }, - signal: - (cancelToken - ? this.createAbortSignal(cancelToken) - : requestParams.signal) || null, - body: - typeof body === "undefined" || body === null - ? null - : payloadFormatter(body), - }, - ).then(async (response) => { - const r = response as HttpResponse; - r.data = null as unknown as T; - r.error = null as unknown as E; - - const responseToParse = responseFormat ? response.clone() : response; - const data = !responseFormat - ? r - : await responseToParse[responseFormat]() - .then((data) => { - if (r.ok) { - r.data = data; - } else { - r.error = data; - } - return r; - }) - .catch((e) => { - r.error = e; - return r; - }); - - if (cancelToken) { - this.abortControllers.delete(cancelToken); - } - - if (!response.ok) throw data; - return data; - }); - }; -} - -/** - * @title Stream API - * @version 1.0 - * @license Apache 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html) - * @termsOfService http://swagger.io/terms/ - * @baseUrl //localhost:8080 - * @contact API Support (http://www.swagger.io/support) - * - * This is the API server for Stream application. - */ -export class Api< - SecurityDataType extends unknown, -> extends HttpClient { - adTemplates = { - /** - * @description Get all VAST ad templates for the current user - * - * @tags ad-templates - * @name AdTemplatesList - * @summary List Ad Templates - * @request GET:/ad-templates - * @secure - */ - adTemplatesList: (params: RequestParams = {}) => - this.request< - ResponseResponse & { - data?: AdtemplatesTemplateListPayload; - }, - ResponseResponse - >({ - path: `/ad-templates`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Create a VAST ad template for the current user - * - * @tags ad-templates - * @name AdTemplatesCreate - * @summary Create Ad Template - * @request POST:/ad-templates - * @secure - */ - adTemplatesCreate: ( - request: AdtemplatesSaveAdTemplateRequest, - params: RequestParams = {}, - ) => - this.request< - ResponseResponse & { - data?: AdtemplatesTemplatePayload; - }, - ResponseResponse - >({ - path: `/ad-templates`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Update a VAST ad template for the current user - * - * @tags ad-templates - * @name AdTemplatesUpdate - * @summary Update Ad Template - * @request PUT:/ad-templates/{id} - * @secure - */ - adTemplatesUpdate: ( - id: string, - request: AdtemplatesSaveAdTemplateRequest, - params: RequestParams = {}, - ) => - this.request< - ResponseResponse & { - data?: AdtemplatesTemplatePayload; - }, - ResponseResponse - >({ - path: `/ad-templates/${id}`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Delete a VAST ad template for the current user - * - * @tags ad-templates - * @name AdTemplatesDelete - * @summary Delete Ad Template - * @request DELETE:/ad-templates/{id} - * @secure - */ - adTemplatesDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/ad-templates/${id}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - }; - admin = { - /** - * @description Get paginated list of all ad templates across users (admin only) - * - * @tags admin - * @name AdTemplatesList - * @summary List All Ad Templates - * @request GET:/admin/ad-templates - * @secure - */ - adTemplatesList: ( - query?: { - /** - * Page - * @default 1 - */ - page?: number; - /** - * Limit - * @default 20 - */ - limit?: number; - /** Filter by user ID */ - user_id?: string; - /** Search by name */ - search?: string; - }, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/ad-templates`, - method: "GET", - query: query, - secure: true, - format: "json", - ...params, - }), - - /** - * @description Create an ad template for any user (admin only) - * - * @tags admin - * @name AdTemplatesCreate - * @summary Create Ad Template - * @request POST:/admin/ad-templates - * @secure - */ - adTemplatesCreate: ( - request: AdminSaveAdminAdTemplateRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/ad-templates`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Get ad template detail (admin only) - * - * @tags admin - * @name AdTemplatesDetail - * @summary Get Ad Template Detail - * @request GET:/admin/ad-templates/{id} - * @secure - */ - adTemplatesDetail: (id: string, params: RequestParams = {}) => - this.request({ - path: `/admin/ad-templates/${id}`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Update an ad template for any user (admin only) - * - * @tags admin - * @name AdTemplatesUpdate - * @summary Update Ad Template - * @request PUT:/admin/ad-templates/{id} - * @secure - */ - adTemplatesUpdate: ( - id: string, - request: AdminSaveAdminAdTemplateRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/ad-templates/${id}`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Delete any ad template by ID (admin only) - * - * @tags admin - * @name AdTemplatesDelete - * @summary Delete Ad Template (Admin) - * @request DELETE:/admin/ad-templates/{id} - * @secure - */ - adTemplatesDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/admin/ad-templates/${id}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Get system-wide statistics for the admin dashboard - * - * @tags admin - * @name DashboardList - * @summary Admin Dashboard - * @request GET:/admin/dashboard - * @secure - */ - dashboardList: (params: RequestParams = {}) => - this.request< - ResponseResponse & { - data?: AdminDashboardPayload; - }, - ResponseResponse - >({ - path: `/admin/dashboard`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Get paginated list of all payments across users (admin only) - * - * @tags admin - * @name PaymentsList - * @summary List All Payments - * @request GET:/admin/payments - * @secure - */ - paymentsList: ( - query?: { - /** - * Page - * @default 1 - */ - page?: number; - /** - * Limit - * @default 20 - */ - limit?: number; - /** Filter by user ID */ - user_id?: string; - /** Filter by status */ - status?: string; - }, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/payments`, - method: "GET", - query: query, - secure: true, - format: "json", - ...params, - }), - - /** - * @description Create a manual subscription charge for a user (admin only) - * - * @tags admin - * @name PaymentsCreate - * @summary Create Payment - * @request POST:/admin/payments - * @secure - */ - paymentsCreate: ( - request: AdminCreateAdminPaymentRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/payments`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Get payment detail (admin only) - * - * @tags admin - * @name PaymentsDetail - * @summary Get Payment Detail - * @request GET:/admin/payments/{id} - * @secure - */ - paymentsDetail: (id: string, params: RequestParams = {}) => - this.request({ - path: `/admin/payments/${id}`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Update payment status safely without hard delete (admin only) - * - * @tags admin - * @name PaymentsUpdate - * @summary Update Payment - * @request PUT:/admin/payments/{id} - * @secure - */ - paymentsUpdate: ( - id: string, - request: AdminUpdateAdminPaymentRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/payments/${id}`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Get all plans with usage counts (admin only) - * - * @tags admin - * @name PlansList - * @summary List Plans - * @request GET:/admin/plans - * @secure - */ - plansList: (params: RequestParams = {}) => - this.request({ - path: `/admin/plans`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Create a plan (admin only) - * - * @tags admin - * @name PlansCreate - * @summary Create Plan - * @request POST:/admin/plans - * @secure - */ - plansCreate: (request: AdminSavePlanRequest, params: RequestParams = {}) => - this.request({ - path: `/admin/plans`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Update a plan (admin only) - * - * @tags admin - * @name PlansUpdate - * @summary Update Plan - * @request PUT:/admin/plans/{id} - * @secure - */ - plansUpdate: ( - id: string, - request: AdminSavePlanRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/plans/${id}`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Delete a plan, or deactivate it if already used (admin only) - * - * @tags admin - * @name PlansDelete - * @summary Delete Plan - * @request DELETE:/admin/plans/{id} - * @secure - */ - plansDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/admin/plans/${id}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Get paginated list of all users (admin only) - * - * @tags admin - * @name UsersList - * @summary List Users - * @request GET:/admin/users - * @secure - */ - usersList: ( - query?: { - /** - * Page - * @default 1 - */ - page?: number; - /** - * Limit - * @default 20 - */ - limit?: number; - /** Search by email or username */ - search?: string; - /** Filter by role */ - role?: string; - }, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/users`, - method: "GET", - query: query, - secure: true, - format: "json", - ...params, - }), - - /** - * @description Create a user from admin panel (admin only) - * - * @tags admin - * @name UsersCreate - * @summary Create User - * @request POST:/admin/users - * @secure - */ - usersCreate: ( - request: AdminCreateAdminUserRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/users`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Get detailed info about a single user (admin only) - * - * @tags admin - * @name UsersDetail - * @summary Get User Detail - * @request GET:/admin/users/{id} - * @secure - */ - usersDetail: (id: string, params: RequestParams = {}) => - this.request({ - path: `/admin/users/${id}`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Update a user from admin panel (admin only) - * - * @tags admin - * @name UsersUpdate - * @summary Update User - * @request PUT:/admin/users/{id} - * @secure - */ - usersUpdate: ( - id: string, - request: AdminUpdateAdminUserRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/users/${id}`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Delete a user and their data (admin only) - * - * @tags admin - * @name UsersDelete - * @summary Delete User - * @request DELETE:/admin/users/{id} - * @secure - */ - usersDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/admin/users/${id}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Change user role (admin only). Valid: USER, ADMIN, BLOCK - * - * @tags admin - * @name UsersRoleUpdate - * @summary Update User Role - * @request PUT:/admin/users/{id}/role - * @secure - */ - usersRoleUpdate: ( - id: string, - request: AdminUpdateUserRoleRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/users/${id}/role`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Get paginated list of all videos across users (admin only) - * - * @tags admin - * @name VideosList - * @summary List All Videos - * @request GET:/admin/videos - * @secure - */ - videosList: ( - query?: { - /** - * Page - * @default 1 - */ - page?: number; - /** - * Limit - * @default 20 - */ - limit?: number; - /** Search by title */ - search?: string; - /** Filter by user ID */ - user_id?: string; - /** Filter by status */ - status?: string; - }, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/videos`, - method: "GET", - query: query, - secure: true, - format: "json", - ...params, - }), - - /** - * @description Create a manual video record for a user (admin only) - * - * @tags admin - * @name VideosCreate - * @summary Create Video - * @request POST:/admin/videos - * @secure - */ - videosCreate: ( - request: AdminSaveAdminVideoRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/videos`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Get video detail by ID (admin only) - * - * @tags admin - * @name VideosDetail - * @summary Get Video Detail - * @request GET:/admin/videos/{id} - * @secure - */ - videosDetail: (id: string, params: RequestParams = {}) => - this.request({ - path: `/admin/videos/${id}`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Update video metadata and status (admin only) - * - * @tags admin - * @name VideosUpdate - * @summary Update Video - * @request PUT:/admin/videos/{id} - * @secure - */ - videosUpdate: ( - id: string, - request: AdminSaveAdminVideoRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/admin/videos/${id}`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Delete any video by ID (admin only) - * - * @tags admin - * @name VideosDelete - * @summary Delete Video (Admin) - * @request DELETE:/admin/videos/{id} - * @secure - */ - videosDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/admin/videos/${id}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - }; - auth = { - /** - * @description Change the authenticated user's local password - * - * @tags auth - * @name ChangePasswordCreate - * @summary Change Password - * @request POST:/auth/change-password - * @secure - */ - changePasswordCreate: ( - request: AuthChangePasswordRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/auth/change-password`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Request password reset link - * - * @tags auth - * @name ForgotPasswordCreate - * @summary Forgot Password - * @request POST:/auth/forgot-password - */ - forgotPasswordCreate: ( - request: AuthForgotPasswordRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/auth/forgot-password`, - method: "POST", - body: request, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Callback for Google Login - * - * @tags auth - * @name GoogleCallbackList - * @summary Google Callback - * @request GET:/auth/google/callback - */ - googleCallbackList: (params: RequestParams = {}) => - this.request< - ResponseResponse & { - data?: AuthUserPayload; - }, - ResponseResponse - >({ - path: `/auth/google/callback`, - method: "GET", - format: "json", - ...params, - }), - - /** - * @description Redirect to Google for Login - * - * @tags auth - * @name GoogleLoginList - * @summary Google Login - * @request GET:/auth/google/login - */ - googleLoginList: (params: RequestParams = {}) => - this.request({ - path: `/auth/google/login`, - method: "GET", - ...params, - }), - - /** - * @description Login with email and password - * - * @tags auth - * @name LoginCreate - * @summary Login - * @request POST:/auth/login - */ - loginCreate: (request: AuthLoginRequest, params: RequestParams = {}) => - this.request< - ResponseResponse & { - data?: AuthUserPayload; - }, - ResponseResponse - >({ - path: `/auth/login`, - method: "POST", - body: request, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Logout user and clear cookies - * - * @tags auth - * @name LogoutCreate - * @summary Logout - * @request POST:/auth/logout - * @secure - */ - logoutCreate: (params: RequestParams = {}) => - this.request({ - path: `/auth/logout`, - method: "POST", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Register a new user - * - * @tags auth - * @name RegisterCreate - * @summary Register - * @request POST:/auth/register - */ - registerCreate: ( - request: AuthRegisterRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/auth/register`, - method: "POST", - body: request, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Reset password using token - * - * @tags auth - * @name ResetPasswordCreate - * @summary Reset Password - * @request POST:/auth/reset-password - */ - resetPasswordCreate: ( - request: AuthResetPasswordRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/auth/reset-password`, - method: "POST", - body: request, - type: ContentType.Json, - format: "json", - ...params, - }), - }; - domains = { - /** - * @description Get all whitelisted domains for the current user - * - * @tags domains - * @name DomainsList - * @summary List Domains - * @request GET:/domains - * @secure - */ - domainsList: (params: RequestParams = {}) => - this.request({ - path: `/domains`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Add a domain to the current user's whitelist - * - * @tags domains - * @name DomainsCreate - * @summary Create Domain - * @request POST:/domains - * @secure - */ - domainsCreate: ( - request: DomainsCreateDomainRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/domains`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Remove a domain from the current user's whitelist - * - * @tags domains - * @name DomainsDelete - * @summary Delete Domain - * @request DELETE:/domains/{id} - * @secure - */ - domainsDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/domains/${id}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - }; - me = { - /** - * @description Get the authenticated user's profile payload - * - * @tags auth - * @name GetMe - * @summary Get Current User - * @request GET:/me - * @secure - */ - getMe: (params: RequestParams = {}) => - this.request({ - path: `/me`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Update the authenticated user's profile information - * - * @tags auth - * @name PutMe - * @summary Update Current User - * @request PUT:/me - * @secure - */ - putMe: (request: AuthUpdateMeRequest, params: RequestParams = {}) => - this.request({ - path: `/me`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Permanently delete the authenticated user's account and related data - * - * @tags auth - * @name DeleteMe - * @summary Delete My Account - * @request DELETE:/me - * @secure - */ - deleteMe: (params: RequestParams = {}) => - this.request({ - path: `/me`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Remove videos and settings-related resources for the authenticated user - * - * @tags auth - * @name ClearDataCreate - * @summary Clear My Data - * @request POST:/me/clear-data - * @secure - */ - clearDataCreate: (params: RequestParams = {}) => - this.request({ - path: `/me/clear-data`, - method: "POST", - secure: true, - format: "json", - ...params, - }), - }; - notifications = { - /** - * @description Get notifications for the current user - * - * @tags notifications - * @name NotificationsList - * @summary List Notifications - * @request GET:/notifications - * @secure - */ - notificationsList: (params: RequestParams = {}) => - this.request({ - path: `/notifications`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Delete all notifications for the current user - * - * @tags notifications - * @name NotificationsDelete - * @summary Clear Notifications - * @request DELETE:/notifications - * @secure - */ - notificationsDelete: (params: RequestParams = {}) => - this.request({ - path: `/notifications`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Mark all notifications as read for the current user - * - * @tags notifications - * @name ReadAllCreate - * @summary Mark All Notifications Read - * @request POST:/notifications/read-all - * @secure - */ - readAllCreate: (params: RequestParams = {}) => - this.request({ - path: `/notifications/read-all`, - method: "POST", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Delete a single notification for the current user - * - * @tags notifications - * @name NotificationsDelete2 - * @summary Delete Notification - * @request DELETE:/notifications/{id} - * @originalName notificationsDelete - * @duplicate - * @secure - */ - notificationsDelete2: (id: string, params: RequestParams = {}) => - this.request({ - path: `/notifications/${id}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Mark a single notification as read for the current user - * - * @tags notifications - * @name ReadCreate - * @summary Mark Notification Read - * @request POST:/notifications/{id}/read - * @secure - */ - readCreate: (id: string, params: RequestParams = {}) => - this.request({ - path: `/notifications/${id}/read`, - method: "POST", - secure: true, - format: "json", - ...params, - }), - }; - payments = { - /** - * @description Create a new payment for buying or renewing a plan - * - * @tags payment - * @name PaymentsCreate - * @summary Create Payment - * @request POST:/payments - * @secure - */ - paymentsCreate: ( - request: PaymentCreatePaymentRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/payments`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Get payment history for the current user - * - * @tags payment - * @name HistoryList - * @summary List Payment History - * @request GET:/payments/history - * @secure - */ - historyList: (params: RequestParams = {}) => - this.request({ - path: `/payments/history`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Download invoice text for a payment or wallet top-up - * - * @tags payment - * @name InvoiceList - * @summary Download Invoice - * @request GET:/payments/{id}/invoice - * @secure - */ - invoiceList: (id: string, params: RequestParams = {}) => - this.request({ - path: `/payments/${id}/invoice`, - method: "GET", - secure: true, - ...params, - }), - }; - plans = { - /** - * @description Get all active plans - * - * @tags plan - * @name PlansList - * @summary List Plans - * @request GET:/plans - * @secure - */ - plansList: (params: RequestParams = {}) => - this.request< - ResponseResponse & { - data?: ModelPlan[]; - }, - ResponseResponse - >({ - path: `/plans`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - }; - settings = { - /** - * @description Get notification, player, and locale preferences for the current user - * - * @tags settings - * @name PreferencesList - * @summary Get Preferences - * @request GET:/settings/preferences - * @secure - */ - preferencesList: (params: RequestParams = {}) => - this.request({ - path: `/settings/preferences`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Update notification, player, and locale preferences for the current user - * - * @tags settings - * @name PreferencesUpdate - * @summary Update Preferences - * @request PUT:/settings/preferences - * @secure - */ - preferencesUpdate: ( - request: PreferencesSettingsPreferencesRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/settings/preferences`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - }; - usage = { - /** - * @description Get the authenticated user's total video count and total storage usage - * - * @tags usage - * @name UsageList - * @summary Get Usage - * @request GET:/usage - * @secure - */ - usageList: (params: RequestParams = {}) => - this.request< - ResponseResponse & { - data?: UsageUsagePayload; - }, - ResponseResponse - >({ - path: `/usage`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - }; - videos = { - /** - * @description Get paginated videos - * - * @tags video - * @name VideosList - * @summary List Videos - * @request GET:/videos - * @secure - */ - videosList: ( - query?: { - /** - * Page number - * @default 1 - */ - page?: number; - /** - * Page size - * @default 10 - */ - limit?: number; - }, - params: RequestParams = {}, - ) => - this.request({ - path: `/videos`, - method: "GET", - query: query, - secure: true, - format: "json", - ...params, - }), - - /** - * @description Create video record after upload - * - * @tags video - * @name VideosCreate - * @summary Create Video - * @request POST:/videos - * @secure - */ - videosCreate: ( - request: VideoCreateVideoRequest, - params: RequestParams = {}, - ) => - this.request< - ResponseResponse & { - data?: ModelVideo; - }, - ResponseResponse - >({ - path: `/videos`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Generate presigned URL for video upload - * - * @tags video - * @name UploadUrlCreate - * @summary Get Upload URL - * @request POST:/videos/upload-url - * @secure - */ - uploadUrlCreate: ( - request: VideoUploadURLRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/videos/upload-url`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Get video details by ID - * - * @tags video - * @name VideosDetail - * @summary Get Video - * @request GET:/videos/{id} - * @secure - */ - videosDetail: (id: string, params: RequestParams = {}) => - this.request< - ResponseResponse & { - data?: ModelVideo; - }, - ResponseResponse - >({ - path: `/videos/${id}`, - method: "GET", - secure: true, - format: "json", - ...params, - }), - - /** - * @description Update title and description for a video owned by the current user - * - * @tags video - * @name VideosUpdate - * @summary Update Video - * @request PUT:/videos/{id} - * @secure - */ - videosUpdate: ( - id: string, - request: VideoUpdateVideoRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/videos/${id}`, - method: "PUT", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - - /** - * @description Delete a video owned by the current user - * - * @tags video - * @name VideosDelete - * @summary Delete Video - * @request DELETE:/videos/{id} - * @secure - */ - videosDelete: (id: string, params: RequestParams = {}) => - this.request({ - path: `/videos/${id}`, - method: "DELETE", - secure: true, - format: "json", - ...params, - }), - }; - wallet = { - /** - * @description Add funds to wallet balance for the current user - * - * @tags payment - * @name TopupsCreate - * @summary Top Up Wallet - * @request POST:/wallet/topups - * @secure - */ - topupsCreate: ( - request: PaymentTopupWalletRequest, - params: RequestParams = {}, - ) => - this.request({ - path: `/wallet/topups`, - method: "POST", - body: request, - secure: true, - type: ContentType.Json, - format: "json", - ...params, - }), - }; -} - -export const client = new Api({ - baseUrl: '/r', - customFetch, -}); diff --git a/src/api/httpClientAdapter.client.ts b/src/api/httpClientAdapter.client.ts index bf6abaa..6431b06 100644 --- a/src/api/httpClientAdapter.client.ts +++ b/src/api/httpClientAdapter.client.ts @@ -1,6 +1,65 @@ -export const customFetch: typeof fetch = (input, init) => { - return fetch(input, { - ...init, - credentials: 'include', - }); -}; \ No newline at end of file +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; +}): 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 = JSON.parse( + await res.text() + ); + if (!result.ok) { + throw TinyRpcError.deserialize(result.value); + } + return result.value; + }, + }; +} \ No newline at end of file diff --git a/src/api/httpClientAdapter.server.ts b/src/api/httpClientAdapter.server.ts index 40767e1..5ac17f6 100644 --- a/src/api/httpClientAdapter.server.ts +++ b/src/api/httpClientAdapter.server.ts @@ -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(); - 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); - }); - - 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 = {}) => { - const c = tryGetContext(); - if (!c) { - 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; - }); -}; +export function httpClientAdapter(opts: { + url: string; + pathsForGET?: string[]; + headers?: () => Promise> | Record; +}): 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", + }); + } + let res: Response; + if (import.meta.env.SSR) { + const c = tryGetContext(); + if (!c) { + throw new Error("Hono context not found in SSR"); + } + 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 = JSON.parse( + await res.text() + ); + if (!result.ok) { + throw TinyRpcError.deserialize(result.value); + } + return result.value; + }, + }; +} \ No newline at end of file diff --git a/src/api/rpcclient.ts b/src/api/rpcclient.ts new file mode 100644 index 0000000..223819a --- /dev/null +++ b/src/api/rpcclient.ts @@ -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({ + adapter: httpClientAdapter({ + url: url + endpoint, + pathsForGET: [], + }), +}); \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 7c6fe95..e111174 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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); diff --git a/src/server/middlewares/apiProxy.ts b/src/server/middlewares/apiProxy.ts deleted file mode 100644 index 8933309..0000000 --- a/src/server/middlewares/apiProxy.ts +++ /dev/null @@ -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) -} diff --git a/src/server/middlewares/authenticate.ts b/src/server/middlewares/authenticate.ts new file mode 100644 index 0000000..1c1a14f --- /dev/null +++ b/src/server/middlewares/authenticate.ts @@ -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(); +}; \ No newline at end of file diff --git a/src/server/middlewares/setup.ts b/src/server/middlewares/setup.ts index a8bf372..1280e92 100644 --- a/src/server/middlewares/setup.ts +++ b/src/server/middlewares/setup.ts @@ -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); diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts index f0d9cd9..3d7f369 100644 --- a/src/server/routes/auth.ts +++ b/src/server/routes/auth.ts @@ -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); - if (!user) { - return c.json({ error: 'Invalid email or password' }, 401); - } - if (user.password !== data.password) { - 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' }); -}); +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); + } + const isMatch = Bun.password.verifySync(password, user.user!.password!, "bcrypt"); + if (!isMatch) { + return c.json({ error: "Invalid email or password" }, 401); + } + 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" }); + }, + ); } diff --git a/src/server/routes/rpc/index.ts b/src/server/routes/rpc/index.ts new file mode 100644 index 0000000..d1c3acd --- /dev/null +++ b/src/server/routes/rpc/index.ts @@ -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(); + }); +} diff --git a/src/server/routes/rpc/me.ts b/src/server/routes/rpc/me.ts new file mode 100644 index 0000000..bed453e --- /dev/null +++ b/src/server/routes/rpc/me.ts @@ -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 }), + }); + } + ) +}; \ No newline at end of file diff --git a/src/server/routes/wellKnown.ts b/src/server/routes/wellKnown.ts index 0d8b035..e0212e2 100644 --- a/src/server/routes/wellKnown.ts +++ b/src/server/routes/wellKnown.ts @@ -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" }); + // }); } diff --git a/src/server/services/grpcClient.ts b/src/server/services/grpcClient.ts index 1a498a3..bb318cb 100644 --- a/src/server/services/grpcClient.ts +++ b/src/server/services/grpcClient.ts @@ -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(); }); } \ No newline at end of file diff --git a/src/server/utils/grpcHelper.ts b/src/server/utils/grpcHelper.ts index c255d4c..11dea52 100644 --- a/src/server/utils/grpcHelper.ts +++ b/src/server/utils/grpcHelper.ts @@ -1,43 +1,78 @@ import { ClientUnaryCall, ServiceError, status } from "@grpc/grpc-js"; -type UnaryCallback = ( - error: ServiceError | null, - response: TRes -) => void; -type UnaryLike = ( - req: TReq, - callback: UnaryCallback -) => ClientUnaryCall; +// 1. Định nghĩa lại UnaryCallback để bắt được kiểu TRes chính xác hơn +type UnaryCallback = (error: ServiceError | null, response: TRes) => void; -type RequestOf = T extends ( - req: infer TReq, - callback: UnaryCallback -) => 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 extends { + (req: any, callback: UnaryCallback): ClientUnaryCall; + (req: any, metadata: any, callback: UnaryCallback): ClientUnaryCall; + (req: any, metadata: any, options: any, callback: UnaryCallback): ClientUnaryCall; +} ? TRes : any; -type ResponseOf = T extends ( - req: any, - callback: UnaryCallback -) => ClientUnaryCall - ? TRes - : never; +type RequestOf = T extends { + (req: infer TReq, callback: UnaryCallback): ClientUnaryCall; + (req: infer TReq, metadata: any, callback: UnaryCallback): ClientUnaryCall; +} ? TReq : any; -/** - * Lấy ra overload đúng dạng (req, callback) => ClientUnaryCall - */ -type ExtractUnaryOverload = Extract>; +// 3. Filter để chỉ lấy các Method thực sự là Unary +type UnaryKeys = { + [K in keyof T]: T[K] extends (...args: any[]) => ClientUnaryCall ? K : never; +}[keyof T]; export type PromisifiedClient = { - [K in keyof TClient as ExtractUnaryOverload extends never - ? never - : K]: ( - req: RequestOf> - ) => Promise>>; + [K in UnaryKeys]: ( + req: RequestOf + ) => Promise>; }; +// ... Các hàm normalizeGrpcError giữ nguyên ... -const grpcCodeToHttpStatus = (code?: number) => { +export function promisifyClient( + client: TClient +): PromisifiedClient { + 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( - client: TClient -): PromisifiedClient { - const proto = Object.getPrototypeOf(client); - const result: Record = {}; - - for (const key of Object.getOwnPropertyNames(proto)) { - if (key === "constructor") continue; - - const value = (client as Record)[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; -} \ No newline at end of file diff --git a/src/server/utils/index.ts b/src/server/utils/index.ts index 218c419..caef645 100644 --- a/src/server/utils/index.ts +++ b/src/server/utils/index.ts @@ -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(); 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(); - 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; }); } \ No newline at end of file diff --git a/src/server/utils/proto/google/protobuf/empty.ts b/src/server/utils/proto/google/protobuf/empty.ts new file mode 100644 index 0000000..9b9971a --- /dev/null +++ b/src/server/utils/proto/google/protobuf/empty.ts @@ -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 = { + 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>>(base?: I): Empty { + return Empty.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(_: I): Empty { + const message = createBaseEmpty(); + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +export type DeepPartial = T extends Builtin ? T + : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +export type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +export interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/src/server/utils/proto/v1/user.ts b/src/server/utils/proto/v1/user.ts index 4314af7..90380ee 100644 --- a/src/server/utils/proto/v1/user.ts +++ b/src/server/utils/proto/v1/user.ts @@ -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 = { + 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>>(base?: I): UpdateUserPasswordRequest { + return UpdateUserPasswordRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, 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; updateUser: handleUnaryCall; deleteUser: handleUnaryCall; + updateUserPassword: handleUnaryCall; /** Preferences */ getPreferences: handleUnaryCall; upsertPreferences: handleUnaryCall; @@ -1979,6 +2077,21 @@ export interface UserServiceClient extends Client { options: Partial, 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, + callback: (error: ServiceError | null, response: Empty) => void, + ): ClientUnaryCall; /** Preferences */ getPreferences( request: GetPreferencesRequest, diff --git a/src/server/utils/token.ts b/src/server/utils/token.ts index 4ba027f..0acdb7a 100644 --- a/src/server/utils/token.ts +++ b/src/server/utils/token.ts @@ -9,9 +9,7 @@ export interface Provider { role: string ): Promise - parseToken(token: string): Promise - - parseMapToken(token: string): Promise> + parseToken(token: string): Promise } 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 { - const payload = (await verify(token, this.secret, "HS256")) + async parseToken(token: string): Promise { + const payload = await verify(token, this.secret, "HS256") as JwtClaims if (!payload) { throw new Error("invalid token") } return payload } - - async parseMapToken(token: string): Promise { - 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) } \ No newline at end of file diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 775967b..f513a39 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -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() { diff --git a/src/type.d.ts b/src/type.d.ts index 814a535..03c3b91 100644 --- a/src/type.d.ts +++ b/src/type.d.ts @@ -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; } \ No newline at end of file