mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
fix: Move more directories
This commit is contained in:
40
cloud/packages/functions/src/api/account.ts
Normal file
40
cloud/packages/functions/src/api/account.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Hono } from "hono";
|
||||
import { notPublic } from "./utils";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { ErrorResponses, Result } from "./utils";
|
||||
import { Account } from "@nestri/core/account/index";
|
||||
|
||||
export namespace AccountApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Account"],
|
||||
summary: "Get user account",
|
||||
description: "Get the current user's account details",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Account.Info.openapi({
|
||||
description: "User account information",
|
||||
example: { ...Examples.User, profiles: [Examples.SteamAccount] }
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "User account details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Account.list()
|
||||
}, 200)
|
||||
)
|
||||
}
|
||||
93
cloud/packages/functions/src/api/friend.ts
Normal file
93
cloud/packages/functions/src/api/friend.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { validator } from "hono-openapi/zod";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { ErrorResponses, notPublic, Result } from "./utils";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
export namespace FriendApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Friend"],
|
||||
summary: "List friends accounts",
|
||||
description: "List all this user's friends accounts",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Friend.Info.array().openapi({
|
||||
description: "All friends accounts",
|
||||
example: [Examples.Friend]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Friends accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Friend.list()
|
||||
})
|
||||
)
|
||||
.get("/:id",
|
||||
describeRoute({
|
||||
tags: ["Friend"],
|
||||
summary: "Get a friend",
|
||||
description: "Get a friend's details by their SteamID",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Friend.Info.openapi({
|
||||
description: "Friend's accounts",
|
||||
example: Examples.Friend
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Friends accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the friend to get",
|
||||
example: Examples.Friend.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const friendSteamID = c.req.valid("param").id
|
||||
|
||||
const friend = await Friend.fromFriendID(friendSteamID)
|
||||
|
||||
if (!friend) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Friend ${friendSteamID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: friend
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
92
cloud/packages/functions/src/api/game.ts
Normal file
92
cloud/packages/functions/src/api/game.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod"
|
||||
import { Hono } from "hono";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { Game } from "@nestri/core/game/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Library } from "@nestri/core/library/index";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { ErrorResponses, notPublic, Result, validator } from "./utils";
|
||||
|
||||
export namespace GameApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "List games",
|
||||
description: "List all the games on this user's library",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Game.Info.array().openapi({
|
||||
description: "All games in the library",
|
||||
example: [Examples.Game]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "All games in the library"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Library.list()
|
||||
})
|
||||
)
|
||||
.get("/:id",
|
||||
describeRoute({
|
||||
tags: ["Game"],
|
||||
summary: "Get game",
|
||||
description: "Get a game by its id, it does not have to be in user's library",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Game.Info.openapi({
|
||||
description: "Game details",
|
||||
example: Examples.Game
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Game details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the game to get",
|
||||
example: Examples.Game.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const gameID = c.req.valid("param").id
|
||||
|
||||
const game = await Game.fromID(gameID)
|
||||
|
||||
if (!game) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Game ${gameID} does not exist`
|
||||
)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: game
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
96
cloud/packages/functions/src/api/index.ts
Normal file
96
cloud/packages/functions/src/api/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Hono } from "hono";
|
||||
import { GameApi } from "./game";
|
||||
import { SteamApi } from "./steam";
|
||||
import { auth } from "./utils/auth";
|
||||
import { FriendApi } from "./friend";
|
||||
import { logger } from "hono/logger";
|
||||
import { AccountApi } from "./account";
|
||||
import { openAPISpecs } from "hono-openapi";
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { handle, streamHandle } from "hono/aws-lambda";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
patchLogger();
|
||||
|
||||
export const app = new Hono();
|
||||
app
|
||||
.use(logger())
|
||||
.use(async (c, next) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return next();
|
||||
})
|
||||
.use(auth)
|
||||
|
||||
const routes = app
|
||||
.get("/", (c) => c.text("Hello World!"))
|
||||
.route("/games", GameApi.route)
|
||||
.route("/steam", SteamApi.route)
|
||||
.route("/friends", FriendApi.route)
|
||||
.route("/account", AccountApi.route)
|
||||
.onError((error, c) => {
|
||||
if (error instanceof VisibleError) {
|
||||
console.error("api error:", error);
|
||||
// @ts-expect-error
|
||||
return c.json(error.toResponse(), error.statusCode());
|
||||
}
|
||||
// Handle HTTP exceptions
|
||||
if (error instanceof HTTPException) {
|
||||
console.error("http error:", error);
|
||||
return c.json(
|
||||
{
|
||||
type: "validation",
|
||||
code: ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
message: "Invalid request",
|
||||
},
|
||||
error.status,
|
||||
);
|
||||
}
|
||||
console.error("unhandled error:", error);
|
||||
return c.json(
|
||||
{
|
||||
type: "internal",
|
||||
code: ErrorCodes.Server.INTERNAL_ERROR,
|
||||
message: "Internal server error",
|
||||
},
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/doc",
|
||||
openAPISpecs(routes, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Nestri API",
|
||||
description: "The Nestri API gives you the power to run your own customized cloud gaming platform.",
|
||||
version: "0.0.1",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
Bearer: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "JWT",
|
||||
},
|
||||
TeamID: {
|
||||
type: "apiKey",
|
||||
description: "The steam ID to use for this query",
|
||||
in: "header",
|
||||
name: "x-nestri-steam"
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ Bearer: [], TeamID: [] }],
|
||||
servers: [
|
||||
{ description: "Production", url: "https://api.nestri.io" },
|
||||
{ description: "Sandbox", url: "https://api.dev.nestri.io" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type Routes = typeof routes;
|
||||
|
||||
export const handler = process.env.SST_LIVE ? handle(app) : streamHandle(app);
|
||||
28
cloud/packages/functions/src/api/realtime/actor-core.ts
Normal file
28
cloud/packages/functions/src/api/realtime/actor-core.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { actor } from "actor-core";
|
||||
|
||||
// Define a chat room actor
|
||||
const chatRoom = actor({
|
||||
// Initialize state when the actor is first created
|
||||
createState: () => ({
|
||||
messages: [] as any[],
|
||||
}),
|
||||
|
||||
// Define actions clients can call
|
||||
actions: {
|
||||
// Action to send a message
|
||||
sendMessage: (c, sender, text) => {
|
||||
// Update state
|
||||
c.state.messages.push({ sender, text });
|
||||
|
||||
// Broadcast to all connected clients
|
||||
c.broadcast("newMessage", { sender, text });
|
||||
},
|
||||
|
||||
// Action to get chat history
|
||||
getHistory: (c) => {
|
||||
return c.state.messages;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default chatRoom;
|
||||
28
cloud/packages/functions/src/api/realtime/index.ts
Normal file
28
cloud/packages/functions/src/api/realtime/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { setup } from "actor-core";
|
||||
import chatRoom from "./actor-core";
|
||||
import { createRouter } from "@actor-core/bun";
|
||||
import {
|
||||
FileSystemGlobalState,
|
||||
FileSystemActorDriver,
|
||||
FileSystemManagerDriver,
|
||||
} from "@actor-core/file-system";
|
||||
|
||||
export namespace Realtime {
|
||||
const app = setup({
|
||||
actors: { chatRoom },
|
||||
basePath: "/realtime"
|
||||
});
|
||||
|
||||
const fsState = new FileSystemGlobalState("/tmp");
|
||||
|
||||
const realtimeRouter = createRouter(app, {
|
||||
topology: "standalone",
|
||||
drivers: {
|
||||
manager: new FileSystemManagerDriver(app, fsState),
|
||||
actor: new FileSystemActorDriver(fsState),
|
||||
}
|
||||
});
|
||||
|
||||
export const route = realtimeRouter.router;
|
||||
export const webSocketHandler = realtimeRouter.webSocketHandler;
|
||||
}
|
||||
165
cloud/packages/functions/src/api/steam.ts
Normal file
165
cloud/packages/functions/src/api/steam.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { z } from "zod";
|
||||
import { Hono } from "hono";
|
||||
import { Resource } from "sst";
|
||||
import { describeRoute } from "hono-openapi";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Examples } from "@nestri/core/examples";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
import { ErrorResponses, validator, Result, notPublic } from "./utils";
|
||||
|
||||
export namespace SteamApi {
|
||||
export const route = new Hono()
|
||||
.get("/",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "List Steam accounts",
|
||||
description: "List all Steam accounts belonging to this user",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: Result(
|
||||
Steam.Info.array().openapi({
|
||||
description: "All linked Steam accounts",
|
||||
example: [Examples.SteamAccount]
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Linked Steam accounts details"
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
notPublic,
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Steam.list()
|
||||
})
|
||||
)
|
||||
.get("/callback/:id",
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the user to login",
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const cookieID = getCookie(c, "user_id");
|
||||
|
||||
const userID = c.req.valid("param").id;
|
||||
|
||||
if (!cookieID || cookieID !== userID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"You should not be here"
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser = await User.fromID(userID);
|
||||
if (!currentUser) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
const params = new URL(c.req.url).searchParams;
|
||||
|
||||
// Verify OpenID response and get steamID
|
||||
const steamID = await Client.verifyOpenIDResponse(params);
|
||||
|
||||
// If verification failed, return error
|
||||
if (!steamID) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"Invalid OpenID authentication response"
|
||||
);
|
||||
}
|
||||
|
||||
const user = (await Client.getUserInfo([steamID]))[0];
|
||||
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
"internal",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"Steam user data is missing"
|
||||
);
|
||||
}
|
||||
|
||||
const wasAdded = await Steam.create({ ...user, userID });
|
||||
|
||||
if (!wasAdded) {
|
||||
// Update the owner of the Steam account
|
||||
await Steam.updateOwner({ userID, steamID })
|
||||
}
|
||||
|
||||
return c.html(
|
||||
`
|
||||
<script>
|
||||
window.location.href = "about:blank";
|
||||
window.close()
|
||||
</script>
|
||||
`
|
||||
)
|
||||
}
|
||||
)
|
||||
.get("/popup/:id",
|
||||
describeRoute({
|
||||
tags: ["Steam"],
|
||||
summary: "Login to Steam",
|
||||
description: "Login to Steam in a popup",
|
||||
responses: {
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429],
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().openapi({
|
||||
description: "ID of the user to login",
|
||||
example: Examples.User.id,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const userID = c.req.valid("param").id;
|
||||
|
||||
const user = await User.fromID(userID);
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} not found`
|
||||
)
|
||||
}
|
||||
|
||||
setCookie(c, "user_id", user.id);
|
||||
|
||||
const returnUrl = `${new URL(Resource.Urls.api).origin}/steam/callback/${userID}`
|
||||
|
||||
const params = new URLSearchParams({
|
||||
'openid.ns': 'http://specs.openid.net/auth/2.0',
|
||||
'openid.mode': 'checkid_setup',
|
||||
'openid.return_to': returnUrl,
|
||||
'openid.realm': new URL(returnUrl).origin,
|
||||
'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
|
||||
'user_id': user.id
|
||||
});
|
||||
|
||||
return c.redirect(`https://steamcommunity.com/openid/login?${params.toString()}`, 302)
|
||||
}
|
||||
)
|
||||
}
|
||||
77
cloud/packages/functions/src/api/utils/auth.ts
Normal file
77
cloud/packages/functions/src/api/utils/auth.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../../subjects";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { type MiddlewareHandler } from "hono";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { ErrorCodes, VisibleError } from "@nestri/core/error";
|
||||
|
||||
const client = createClient({
|
||||
clientID: "api",
|
||||
issuer: Resource.Auth.url,
|
||||
});
|
||||
|
||||
export const notPublic: MiddlewareHandler = async (c, next) => {
|
||||
const actor = Actor.use();
|
||||
if (actor.type === "public")
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
"Missing authorization header",
|
||||
);
|
||||
return next();
|
||||
};
|
||||
|
||||
export const auth: MiddlewareHandler = async (c, next) => {
|
||||
const authHeader = c.req.header("authorization");
|
||||
if (!authHeader) return Actor.provide("public", {}, next);
|
||||
const match = authHeader.match(/^Bearer (.+)$/);
|
||||
if (!match) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||
"Invalid personal access token",
|
||||
);
|
||||
}
|
||||
const bearerToken = match[1];
|
||||
let result = await client.verify(subjects, bearerToken!);
|
||||
if (result.err) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||
"Invalid bearer token",
|
||||
);
|
||||
}
|
||||
|
||||
if (result.subject.type === "user") {
|
||||
const steamID = c.req.header("x-nestri-steam");
|
||||
if (!steamID) {
|
||||
return Actor.provide(result.subject.type, result.subject.properties, next);
|
||||
}
|
||||
const userID = result.subject.properties.userID
|
||||
return Actor.provide(
|
||||
"steam",
|
||||
{
|
||||
steamID
|
||||
},
|
||||
async () => {
|
||||
const steamAcc = await Steam.confirmOwnerShip(userID)
|
||||
if (!steamAcc) {
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`
|
||||
)
|
||||
}
|
||||
return Actor.provide(
|
||||
"member",
|
||||
{
|
||||
steamID,
|
||||
userID,
|
||||
},
|
||||
next)
|
||||
});
|
||||
}
|
||||
|
||||
return Actor.provide("public", {}, next);
|
||||
};
|
||||
129
cloud/packages/functions/src/api/utils/error.ts
Normal file
129
cloud/packages/functions/src/api/utils/error.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
import { ErrorResponse } from "@nestri/core/error";
|
||||
|
||||
export const ErrorResponses = {
|
||||
400: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Validation error",
|
||||
example: {
|
||||
type: "validation",
|
||||
code: "invalid_parameter",
|
||||
message: "The request was invalid",
|
||||
param: "email",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Bad Request - The request could not be understood or was missing required parameters.",
|
||||
},
|
||||
401: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Authentication error",
|
||||
example: {
|
||||
type: "authentication",
|
||||
code: "unauthorized",
|
||||
message: "Authentication required",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Unauthorized - Authentication is required and has failed or has not been provided.",
|
||||
},
|
||||
403: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Permission error",
|
||||
example: {
|
||||
type: "forbidden",
|
||||
code: "permission_denied",
|
||||
message: "You do not have permission to access this resource",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Forbidden - You do not have permission to access this resource.",
|
||||
},
|
||||
404: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Not found error",
|
||||
example: {
|
||||
type: "not_found",
|
||||
code: "resource_not_found",
|
||||
message: "The requested resource could not be found",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Not Found - The requested resource does not exist.",
|
||||
},
|
||||
409: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Conflict Error",
|
||||
example: {
|
||||
type: "already_exists",
|
||||
code: "resource_already_exists",
|
||||
message: "The resource could not be created because it already exists",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Conflict - The resource could not be created because it already exists.",
|
||||
},
|
||||
429: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Rate limit error",
|
||||
example: {
|
||||
type: "rate_limit",
|
||||
code: "too_many_requests",
|
||||
message: "Rate limit exceeded",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description:
|
||||
"Too Many Requests - You have made too many requests in a short period of time.",
|
||||
},
|
||||
500: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
ErrorResponse.openapi({
|
||||
description: "Server error",
|
||||
example: {
|
||||
type: "internal",
|
||||
code: "internal_error",
|
||||
message: "Internal server error",
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
description: "Internal Server Error - Something went wrong on our end.",
|
||||
},
|
||||
};
|
||||
20
cloud/packages/functions/src/api/utils/hook.ts
Normal file
20
cloud/packages/functions/src/api/utils/hook.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ZodError, ZodSchema, z } from 'zod';
|
||||
import type { Env, ValidationTargets, Context, TypedResponse, Input, MiddlewareHandler } from 'hono';
|
||||
|
||||
type Hook<T, E extends Env, P extends string, Target extends keyof ValidationTargets = keyof ValidationTargets, O = {}> = (result: ({
|
||||
success: true;
|
||||
data: T;
|
||||
} | {
|
||||
success: false;
|
||||
error: ZodError;
|
||||
data: T;
|
||||
}) & {
|
||||
target: Target;
|
||||
}, c: Context<E, P>) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
|
||||
type HasUndefined<T> = undefined extends T ? true : false;
|
||||
declare const zValidator: <T extends ZodSchema<any, z.ZodTypeDef, any>, Target extends keyof ValidationTargets, E extends Env, P extends string, In = z.input<T>, Out = z.output<T>, I extends Input = {
|
||||
in: HasUndefined<In> extends true ? { [K in Target]?: (In extends ValidationTargets[K] ? In : { [K2 in keyof In]?: ValidationTargets[K][K2] | undefined; }) | undefined; } : { [K_1 in Target]: In extends ValidationTargets[K_1] ? In : { [K2_1 in keyof In]: ValidationTargets[K_1][K2_1]; }; };
|
||||
out: { [K_2 in Target]: Out; };
|
||||
}, V extends I = I>(target: Target, schema: T, hook?: Hook<z.TypeOf<T>, E, P, Target, {}> | undefined) => MiddlewareHandler<E, P, V>;
|
||||
|
||||
export { type Hook, zValidator };
|
||||
4
cloud/packages/functions/src/api/utils/index.ts
Normal file
4
cloud/packages/functions/src/api/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./auth";
|
||||
export * from "./error";
|
||||
export * from "./result";
|
||||
export * from "./validator";
|
||||
6
cloud/packages/functions/src/api/utils/result.ts
Normal file
6
cloud/packages/functions/src/api/utils/result.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { resolver } from "hono-openapi/zod";
|
||||
|
||||
export function Result<T extends z.ZodTypeAny>(schema: T) {
|
||||
return resolver(z.object({ data: schema }));
|
||||
}
|
||||
77
cloud/packages/functions/src/api/utils/validator.ts
Normal file
77
cloud/packages/functions/src/api/utils/validator.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Hook } from "./hook";
|
||||
import { z, ZodSchema } from "zod";
|
||||
import { ErrorCodes } from "@nestri/core/error";
|
||||
import { validator as zodValidator } from "hono-openapi/zod";
|
||||
import type { MiddlewareHandler, ValidationTargets } from "hono";
|
||||
|
||||
type ZodIssueExtended = z.ZodIssue & {
|
||||
expected?: unknown;
|
||||
received?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator wrapper around hono-openapi/zod validator that formats errors
|
||||
*/
|
||||
export const validator = <
|
||||
T extends ZodSchema,
|
||||
Target extends keyof ValidationTargets
|
||||
>(
|
||||
target: Target,
|
||||
schema: T
|
||||
): MiddlewareHandler<
|
||||
Record<string, unknown>,
|
||||
string,
|
||||
{
|
||||
in: {
|
||||
[K in Target]: z.input<T>;
|
||||
};
|
||||
out: {
|
||||
[K in Target]: z.output<T>;
|
||||
};
|
||||
}
|
||||
> => {
|
||||
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (
|
||||
result,
|
||||
c,
|
||||
) => {
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues || result.error.errors || [];
|
||||
const firstIssue = issues[0];
|
||||
const fieldPath = Array.isArray(firstIssue?.path)
|
||||
? firstIssue.path.join(".")
|
||||
: firstIssue?.path;
|
||||
|
||||
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
|
||||
if (firstIssue?.code === "invalid_type" && firstIssue?.received === "undefined") {
|
||||
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
|
||||
} else if (
|
||||
["invalid_string", "invalid_date", "invalid_regex"].includes(firstIssue?.code as string)
|
||||
) {
|
||||
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
const response = {
|
||||
type: "validation",
|
||||
code: errorCode,
|
||||
message: firstIssue?.message,
|
||||
param: fieldPath,
|
||||
details: issues.length > 1
|
||||
? {
|
||||
issues: issues.map((issue: ZodIssueExtended) => ({
|
||||
path: Array.isArray(issue.path) ? issue.path.join(".") : issue.path,
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
expected: issue.expected,
|
||||
received: issue.received,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
console.log("Validation error in validator:", response);
|
||||
return c.json(response, 400);
|
||||
}
|
||||
};
|
||||
|
||||
return zodValidator(target, schema, standardErrorHandler);
|
||||
};
|
||||
12
cloud/packages/functions/src/auth/adapters/discord.ts
Normal file
12
cloud/packages/functions/src/auth/adapters/discord.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Oauth2Adapter, type Oauth2WrappedConfig } from "../ui/oauth2"
|
||||
|
||||
export function DiscordAdapter(config: Oauth2WrappedConfig) {
|
||||
return Oauth2Adapter({
|
||||
type: "discord",
|
||||
...config,
|
||||
endpoint: {
|
||||
authorization: "https://discord.com/oauth2/authorize",
|
||||
token: "https://discord.com/api/oauth2/token",
|
||||
},
|
||||
})
|
||||
}
|
||||
12
cloud/packages/functions/src/auth/adapters/github.ts
Normal file
12
cloud/packages/functions/src/auth/adapters/github.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Oauth2Adapter, type Oauth2WrappedConfig } from "../ui/oauth2"
|
||||
|
||||
export function GithubAdapter(config: Oauth2WrappedConfig) {
|
||||
return Oauth2Adapter({
|
||||
...config,
|
||||
type: "github",
|
||||
endpoint: {
|
||||
authorization: "https://github.com/login/oauth/authorize",
|
||||
token: "https://github.com/login/oauth/access_token",
|
||||
},
|
||||
})
|
||||
}
|
||||
3
cloud/packages/functions/src/auth/adapters/index.ts
Normal file
3
cloud/packages/functions/src/auth/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./discord"
|
||||
export * from "./github"
|
||||
export * from "./password"
|
||||
441
cloud/packages/functions/src/auth/adapters/password.ts
Normal file
441
cloud/packages/functions/src/auth/adapters/password.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
// import { UnknownStateError } from "@openauthjs/openauth/error"
|
||||
import { Storage } from "@openauthjs/openauth/storage/storage"
|
||||
import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
import { generateUnbiasedDigits, timingSafeCompare } from "@openauthjs/openauth/random"
|
||||
|
||||
export interface PasswordHasher<T> {
|
||||
hash(password: string): Promise<T>
|
||||
verify(password: string, compare: T): Promise<boolean>
|
||||
}
|
||||
|
||||
export interface PasswordConfig {
|
||||
length?: number
|
||||
hasher?: PasswordHasher<any>
|
||||
login: (
|
||||
req: Request,
|
||||
form?: FormData,
|
||||
error?: PasswordLoginError,
|
||||
) => Promise<Response>
|
||||
register: (
|
||||
req: Request,
|
||||
state: PasswordRegisterState,
|
||||
form?: FormData,
|
||||
error?: PasswordRegisterError,
|
||||
) => Promise<Response>
|
||||
change: (
|
||||
req: Request,
|
||||
state: PasswordChangeState,
|
||||
form?: FormData,
|
||||
error?: PasswordChangeError,
|
||||
) => Promise<Response>
|
||||
sendCode: (email: string, code: string) => Promise<void>
|
||||
}
|
||||
|
||||
export type PasswordRegisterState =
|
||||
| {
|
||||
type: "start"
|
||||
}
|
||||
| {
|
||||
type: "code"
|
||||
code: string
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export type PasswordRegisterError =
|
||||
| {
|
||||
type: "invalid_code"
|
||||
}
|
||||
| {
|
||||
type: "email_taken"
|
||||
}
|
||||
| {
|
||||
type: "invalid_email"
|
||||
}
|
||||
| {
|
||||
type: "invalid_password"
|
||||
}
|
||||
| {
|
||||
type: "invalid_username"
|
||||
}| {
|
||||
type: "username_taken"
|
||||
}
|
||||
|
||||
export type PasswordChangeState =
|
||||
| {
|
||||
type: "start"
|
||||
redirect: string
|
||||
}
|
||||
| {
|
||||
type: "code"
|
||||
code: string
|
||||
email: string
|
||||
redirect: string
|
||||
}
|
||||
| {
|
||||
type: "update"
|
||||
redirect: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export type PasswordChangeError =
|
||||
| {
|
||||
type: "invalid_email"
|
||||
}
|
||||
| {
|
||||
type: "invalid_code"
|
||||
}
|
||||
| {
|
||||
type: "invalid_password"
|
||||
}
|
||||
| {
|
||||
type: "password_mismatch"
|
||||
}
|
||||
|
||||
export type PasswordLoginError =
|
||||
| {
|
||||
type: "invalid_password"
|
||||
}
|
||||
| {
|
||||
type: "invalid_email"
|
||||
}
|
||||
|
||||
export function PasswordAdapter(config: PasswordConfig) {
|
||||
const hasher = config.hasher ?? ScryptHasher()
|
||||
function generate() {
|
||||
return generateUnbiasedDigits(6)
|
||||
}
|
||||
return {
|
||||
type: "password",
|
||||
init(routes, ctx) {
|
||||
routes.get("/authorize", async (c) =>
|
||||
ctx.forward(c, await config.login(c.req.raw)),
|
||||
)
|
||||
|
||||
routes.post("/authorize", async (c) => {
|
||||
const fd = await c.req.formData()
|
||||
async function error(err: PasswordLoginError) {
|
||||
return ctx.forward(c, await config.login(c.req.raw, fd, err))
|
||||
}
|
||||
const email = fd.get("email")?.toString()?.toLowerCase()
|
||||
if (!email) return error({ type: "invalid_email" })
|
||||
const hash = await Storage.get<HashedPassword>(ctx.storage, [
|
||||
"email",
|
||||
email,
|
||||
"password",
|
||||
])
|
||||
const password = fd.get("password")?.toString()
|
||||
if (!password || !hash || !(await hasher.verify(password, hash)))
|
||||
return error({ type: "invalid_password" })
|
||||
return ctx.success(
|
||||
c,
|
||||
{
|
||||
email: email,
|
||||
},
|
||||
{
|
||||
invalidate: async (subject) => {
|
||||
await Storage.set(
|
||||
ctx.storage,
|
||||
["email", email, "subject"],
|
||||
subject,
|
||||
)
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
routes.get("/register", async (c) => {
|
||||
const state: PasswordRegisterState = {
|
||||
type: "start",
|
||||
}
|
||||
await ctx.set(c, "adapter", 60 * 60 * 24, state)
|
||||
return ctx.forward(c, await config.register(c.req.raw, state))
|
||||
})
|
||||
|
||||
routes.post("/register", async (c) => {
|
||||
const fd = await c.req.formData()
|
||||
const email = fd.get("email")?.toString()?.toLowerCase()
|
||||
const action = fd.get("action")?.toString()
|
||||
const adapter = await ctx.get<PasswordRegisterState>(c, "adapter")
|
||||
|
||||
async function transition(
|
||||
next: PasswordRegisterState,
|
||||
err?: PasswordRegisterError,
|
||||
) {
|
||||
await ctx.set<PasswordRegisterState>(c, "adapter", 60 * 60 * 24, next)
|
||||
return ctx.forward(c, await config.register(c.req.raw, next, fd, err))
|
||||
}
|
||||
|
||||
if (action === "register" && adapter.type === "start") {
|
||||
const password = fd.get("password")?.toString()
|
||||
const username = fd.get("username")?.toString()
|
||||
const usernameRegex = /^[a-zA-Z]{1,32}$/;
|
||||
if (!email) return transition(adapter, { type: "invalid_email" })
|
||||
if (!username) return transition(adapter, { type: "invalid_username" })
|
||||
if (!password)
|
||||
return transition(adapter, { type: "invalid_password" })
|
||||
if (!usernameRegex.test(username))
|
||||
return transition(adapter, { type: "invalid_username" })
|
||||
const existing = await Storage.get(ctx.storage, [
|
||||
"email",
|
||||
email,
|
||||
"password",
|
||||
])
|
||||
if (existing) return transition(adapter, { type: "email_taken" })
|
||||
// const existingUsername = await Profiles.fromUsername(username)
|
||||
// if (existingUsername) return transition(adapter, { type: "username_taken" })
|
||||
const code = generate()
|
||||
await config.sendCode(email, code)
|
||||
return transition({
|
||||
type: "code",
|
||||
code,
|
||||
password: await hasher.hash(password),
|
||||
email,
|
||||
username
|
||||
})
|
||||
}
|
||||
|
||||
if (action === "verify" && adapter.type === "code") {
|
||||
const code = fd.get("code")?.toString()
|
||||
if (!code || !timingSafeCompare(code, adapter.code))
|
||||
return transition(adapter, { type: "invalid_code" })
|
||||
const existing = await Storage.get(ctx.storage, [
|
||||
"email",
|
||||
adapter.email,
|
||||
"password",
|
||||
])
|
||||
if (existing)
|
||||
return transition({ type: "start" }, { type: "email_taken" })
|
||||
await Storage.set(
|
||||
ctx.storage,
|
||||
["email", adapter.email, "password"],
|
||||
adapter.password,
|
||||
)
|
||||
return ctx.success(c, {
|
||||
email: adapter.email,
|
||||
username: adapter.username
|
||||
})
|
||||
}
|
||||
|
||||
return transition({ type: "start" })
|
||||
})
|
||||
|
||||
routes.get("/change", async (c) => {
|
||||
let redirect =
|
||||
c.req.query("redirect_uri") || getRelativeUrl(c, "./authorize")
|
||||
const state: PasswordChangeState = {
|
||||
type: "start",
|
||||
redirect,
|
||||
}
|
||||
await ctx.set(c, "adapter", 60 * 60 * 24, state)
|
||||
return ctx.forward(c, await config.change(c.req.raw, state))
|
||||
})
|
||||
|
||||
routes.post("/change", async (c) => {
|
||||
const fd = await c.req.formData()
|
||||
const action = fd.get("action")?.toString()
|
||||
const adapter = await ctx.get<PasswordChangeState>(c, "adapter")
|
||||
if (!adapter) throw new UnknownStateError()
|
||||
|
||||
async function transition(
|
||||
next: PasswordChangeState,
|
||||
err?: PasswordChangeError,
|
||||
) {
|
||||
await ctx.set<PasswordChangeState>(c, "adapter", 60 * 60 * 24, next)
|
||||
return ctx.forward(c, await config.change(c.req.raw, next, fd, err))
|
||||
}
|
||||
|
||||
if (action === "code") {
|
||||
const email = fd.get("email")?.toString()?.toLowerCase()
|
||||
if (!email)
|
||||
return transition(
|
||||
{ type: "start", redirect: adapter.redirect },
|
||||
{ type: "invalid_email" },
|
||||
)
|
||||
const code = generate()
|
||||
await config.sendCode(email, code)
|
||||
|
||||
return transition({
|
||||
type: "code",
|
||||
code,
|
||||
email,
|
||||
redirect: adapter.redirect,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === "verify" && adapter.type === "code") {
|
||||
const code = fd.get("code")?.toString()
|
||||
if (!code || !timingSafeCompare(code, adapter.code))
|
||||
return transition(adapter, { type: "invalid_code" })
|
||||
return transition({
|
||||
type: "update",
|
||||
email: adapter.email,
|
||||
redirect: adapter.redirect,
|
||||
})
|
||||
}
|
||||
|
||||
if (action === "update" && adapter.type === "update") {
|
||||
const existing = await Storage.get(ctx.storage, [
|
||||
"email",
|
||||
adapter.email,
|
||||
"password",
|
||||
])
|
||||
if (!existing) return c.redirect(adapter.redirect, 302)
|
||||
|
||||
const password = fd.get("password")?.toString()
|
||||
const repeat = fd.get("repeat")?.toString()
|
||||
if (!password)
|
||||
return transition(adapter, { type: "invalid_password" })
|
||||
if (password !== repeat)
|
||||
return transition(adapter, { type: "password_mismatch" })
|
||||
|
||||
await Storage.set(
|
||||
ctx.storage,
|
||||
["email", adapter.email, "password"],
|
||||
await hasher.hash(password),
|
||||
)
|
||||
const subject = await Storage.get<string>(ctx.storage, [
|
||||
"email",
|
||||
adapter.email,
|
||||
"subject",
|
||||
])
|
||||
if (subject) await ctx.invalidate(subject)
|
||||
|
||||
return c.redirect(adapter.redirect, 302)
|
||||
}
|
||||
|
||||
return transition({ type: "start", redirect: adapter.redirect })
|
||||
})
|
||||
},
|
||||
} satisfies Provider<{ email: string; username?:string }>
|
||||
}
|
||||
|
||||
import * as jose from "jose"
|
||||
import { TextEncoder } from "node:util"
|
||||
|
||||
interface HashedPassword {}
|
||||
|
||||
export function PBKDF2Hasher(opts?: { interations?: number }): PasswordHasher<{
|
||||
hash: string
|
||||
salt: string
|
||||
iterations: number
|
||||
}> {
|
||||
const iterations = opts?.interations ?? 600000
|
||||
return {
|
||||
async hash(password) {
|
||||
const encoder = new TextEncoder()
|
||||
const bytes = encoder.encode(password)
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
bytes,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
)
|
||||
const hash = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt: salt,
|
||||
iterations,
|
||||
},
|
||||
keyMaterial,
|
||||
256,
|
||||
)
|
||||
const hashBase64 = jose.base64url.encode(new Uint8Array(hash))
|
||||
const saltBase64 = jose.base64url.encode(salt)
|
||||
return {
|
||||
hash: hashBase64,
|
||||
salt: saltBase64,
|
||||
iterations,
|
||||
}
|
||||
},
|
||||
async verify(password, compare) {
|
||||
const encoder = new TextEncoder()
|
||||
const passwordBytes = encoder.encode(password)
|
||||
const salt = jose.base64url.decode(compare.salt)
|
||||
const params = {
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-256",
|
||||
salt,
|
||||
iterations: compare.iterations,
|
||||
}
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
passwordBytes,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits"],
|
||||
)
|
||||
const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256)
|
||||
const hashBase64 = jose.base64url.encode(new Uint8Array(hash))
|
||||
return hashBase64 === compare.hash
|
||||
},
|
||||
}
|
||||
}
|
||||
import { timingSafeEqual, randomBytes, scrypt } from "node:crypto"
|
||||
import { getRelativeUrl } from "@openauthjs/openauth/util"
|
||||
import { UnknownStateError } from "@openauthjs/openauth/error"
|
||||
|
||||
export function ScryptHasher(opts?: {
|
||||
N?: number
|
||||
r?: number
|
||||
p?: number
|
||||
}): PasswordHasher<{
|
||||
hash: string
|
||||
salt: string
|
||||
N: number
|
||||
r: number
|
||||
p: number
|
||||
}> {
|
||||
const N = opts?.N ?? 16384
|
||||
const r = opts?.r ?? 8
|
||||
const p = opts?.p ?? 1
|
||||
|
||||
return {
|
||||
async hash(password) {
|
||||
const salt = randomBytes(16)
|
||||
const keyLength = 32 // 256 bits
|
||||
|
||||
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey) => {
|
||||
if (err) reject(err)
|
||||
else resolve(derivedKey)
|
||||
})
|
||||
})
|
||||
|
||||
const hashBase64 = derivedKey.toString("base64")
|
||||
const saltBase64 = salt.toString("base64")
|
||||
|
||||
return {
|
||||
hash: hashBase64,
|
||||
salt: saltBase64,
|
||||
N,
|
||||
r,
|
||||
p,
|
||||
}
|
||||
},
|
||||
|
||||
async verify(password, compare) {
|
||||
const salt = Buffer.from(compare.salt, "base64")
|
||||
const keyLength = 32 // 256 bits
|
||||
|
||||
const derivedKey = await new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(
|
||||
password,
|
||||
salt,
|
||||
keyLength,
|
||||
{ N: compare.N, r: compare.r, p: compare.p },
|
||||
(err, derivedKey) => {
|
||||
if (err) reject(err)
|
||||
else resolve(derivedKey)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
return timingSafeEqual(derivedKey, Buffer.from(compare.hash, "base64"))
|
||||
},
|
||||
}
|
||||
}
|
||||
159
cloud/packages/functions/src/auth/index.ts
Normal file
159
cloud/packages/functions/src/auth/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Resource } from "sst";
|
||||
import { logger } from "hono/logger";
|
||||
import { subjects } from "../subjects";
|
||||
import { handle } from "hono/aws-lambda";
|
||||
import { PasswordUI, Select } from "./ui";
|
||||
import { issuer } from "@openauthjs/openauth";
|
||||
import { User } from "@nestri/core/user/index";
|
||||
import { Email } from "@nestri/core/email/index";
|
||||
import { patchLogger } from "../utils/patch-logger";
|
||||
import { handleDiscord, handleGithub } from "./utils";
|
||||
import { DiscordAdapter, PasswordAdapter, GithubAdapter } from "./adapters";
|
||||
|
||||
patchLogger();
|
||||
|
||||
const app = issuer({
|
||||
select: Select(),
|
||||
theme: {
|
||||
title: "Nestri | Auth",
|
||||
primary: "#FF4F01",
|
||||
//TODO: Change this in prod
|
||||
logo: "https://nestri.io/logo.webp",
|
||||
favicon: "https://nestri.io/seo/favicon.ico",
|
||||
background: {
|
||||
light: "#F5F5F5",
|
||||
dark: "#171717"
|
||||
},
|
||||
radius: "lg",
|
||||
font: {
|
||||
family: "Geist, sans-serif",
|
||||
},
|
||||
css: `@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100;200;300;400;500;600;700;800;900&display=swap');`,
|
||||
},
|
||||
subjects,
|
||||
providers: {
|
||||
github: GithubAdapter({
|
||||
clientID: Resource.GithubClientID.value,
|
||||
clientSecret: Resource.GithubClientSecret.value,
|
||||
scopes: ["user:email"]
|
||||
}),
|
||||
discord: DiscordAdapter({
|
||||
clientID: Resource.DiscordClientID.value,
|
||||
clientSecret: Resource.DiscordClientSecret.value,
|
||||
scopes: ["email", "identify"]
|
||||
}),
|
||||
password: PasswordAdapter(
|
||||
PasswordUI({
|
||||
sendCode: async (email, code) => {
|
||||
// Do not debug show code in production
|
||||
if (Resource.App.stage != "production") {
|
||||
console.log("email & code:", email, code)
|
||||
}
|
||||
await Email.send(
|
||||
"auth",
|
||||
email,
|
||||
`Nestri code: ${code}`,
|
||||
`Your Nestri login code is ${code}`,
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
},
|
||||
allow: async (input) => {
|
||||
const url = new URL(input.redirectURI);
|
||||
const hostname = url.hostname;
|
||||
if (hostname.endsWith("nestri.io")) return true;
|
||||
if (hostname === "localhost") return true;
|
||||
return false;
|
||||
},
|
||||
success: async (ctx, value, req) => {
|
||||
if (value.provider === "password") {
|
||||
const email = value.email
|
||||
const username = value.username
|
||||
const matching = await User.fromEmail(email)
|
||||
|
||||
//Sign Up
|
||||
if (username && !matching) {
|
||||
const userID = await User.create({
|
||||
name: username,
|
||||
email,
|
||||
});
|
||||
|
||||
if (!userID) throw new Error("Error creating user");
|
||||
|
||||
return ctx.subject("user", {
|
||||
userID,
|
||||
email
|
||||
}, {
|
||||
subject: userID
|
||||
});
|
||||
|
||||
} else if (matching) {
|
||||
await User.acknowledgeLogin(matching.id)
|
||||
|
||||
//Sign In
|
||||
return ctx.subject("user", {
|
||||
userID: matching.id,
|
||||
email
|
||||
}, {
|
||||
subject: matching.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let user;
|
||||
|
||||
if (value.provider === "github") {
|
||||
const access = value.tokenset.access;
|
||||
user = await handleGithub(access)
|
||||
}
|
||||
|
||||
if (value.provider === "discord") {
|
||||
const access = value.tokenset.access
|
||||
user = await handleDiscord(access)
|
||||
}
|
||||
|
||||
if (user) {
|
||||
try {
|
||||
const matching = await User.fromEmail(user.primary.email);
|
||||
|
||||
//Sign Up
|
||||
if (!matching) {
|
||||
const userID = await User.create({
|
||||
email: user.primary.email,
|
||||
name: user.username,
|
||||
avatarUrl: user.avatar,
|
||||
});
|
||||
|
||||
if (!userID) throw new Error("Error creating user");
|
||||
|
||||
return ctx.subject("user", {
|
||||
userID,
|
||||
email: user.primary.email
|
||||
}, {
|
||||
subject: userID
|
||||
});
|
||||
} else {
|
||||
await User.acknowledgeLogin(matching.id)
|
||||
|
||||
//Sign In
|
||||
return await ctx.subject("user", {
|
||||
userID: matching.id,
|
||||
email: user.primary.email
|
||||
}, {
|
||||
subject: matching.id
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("error registering the user", error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
throw new Error("Something went seriously wrong");
|
||||
},
|
||||
}).use(logger())
|
||||
|
||||
export const handler = handle(app);
|
||||
279
cloud/packages/functions/src/auth/ui/base.tsx
Normal file
279
cloud/packages/functions/src/auth/ui/base.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import { css } from "./css"
|
||||
import { type PropsWithChildren } from "hono/jsx"
|
||||
import { getTheme } from "@openauthjs/openauth/ui/theme"
|
||||
|
||||
export function Layout(
|
||||
props: PropsWithChildren<{
|
||||
size?: "small",
|
||||
page?: "root" | "password" | "popup"
|
||||
}>,
|
||||
) {
|
||||
const theme = getTheme()
|
||||
function get(key: "primary" | "background" | "logo", mode: "light" | "dark") {
|
||||
if (!theme) return
|
||||
if (!theme[key]) return
|
||||
if (typeof theme[key] === "string") return theme[key]
|
||||
|
||||
return theme[key][mode] as string | undefined
|
||||
}
|
||||
|
||||
const radius = (() => {
|
||||
if (theme?.radius === "none") return "0"
|
||||
if (theme?.radius === "sm") return "1"
|
||||
if (theme?.radius === "md") return "1.25"
|
||||
if (theme?.radius === "lg") return "1.5"
|
||||
if (theme?.radius === "full") return "1000000000001"
|
||||
return "1"
|
||||
})()
|
||||
|
||||
const script = "const DEFAULT_COLORS = ['#6A5ACD', '#E63525','#20B2AA', '#E87D58'];" +
|
||||
"const getModulo = (value, divisor, useEvenCheck) => {" +
|
||||
"const remainder = value % divisor;" +
|
||||
"if (useEvenCheck && Math.floor(value / Math.pow(10, useEvenCheck) % 10) % 2 === 0) {" +
|
||||
" return -remainder;" +
|
||||
" }" +
|
||||
" return remainder;" +
|
||||
" };" +
|
||||
"const generateColors = (name, colors = DEFAULT_COLORS) => {" +
|
||||
"const hashCode = name.split('').reduce((acc, char) => {" +
|
||||
"acc = ((acc << 5) - acc) + char.charCodeAt(0);" +
|
||||
" return acc & acc;" +
|
||||
" }, 0);" +
|
||||
"const hash = Math.abs(hashCode);" +
|
||||
"const numColors = colors.length;" +
|
||||
"return Array.from({ length: 3 }, (_, index) => ({" +
|
||||
"color: colors[(hash + index) % numColors]," +
|
||||
"translateX: getModulo(hash * (index + 1), 4, 1)," +
|
||||
"translateY: getModulo(hash * (index + 1), 4, 2)," +
|
||||
" scale: 1.2 + getModulo(hash * (index + 1), 2) / 10," +
|
||||
" rotate: getModulo(hash * (index + 1), 360, 1)" +
|
||||
"}));" +
|
||||
"};" +
|
||||
"const generateFallbackAvatar = (text = 'wanjohi', size = 80, colors = DEFAULT_COLORS) => {" +
|
||||
" const colorData = generateColors(text, colors);" +
|
||||
" return '<svg viewBox=\"0 0 ' + size + ' ' + size + '\" fill=\"none\" role=\"img\" aria-describedby=\"' + text + '\" width=\"' + size + '\" height=\"' + size + '\">' +" +
|
||||
" '<title id=\"' + text + '\">Fallback avatar for ' + text + '</title>' +" +
|
||||
" '<mask id=\"mask__marble\" maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"' + size + '\" height=\"' + size + '\">' +" +
|
||||
" '<rect width=\"' + size + '\" height=\"' + size + '\" rx=\"' + (size * 2) + '\" fill=\"#FFFFFF\" />' +" +
|
||||
" '</mask>' +" +
|
||||
" '<g mask=\"url(#mask__marble)\">' +" +
|
||||
" '<rect width=\"' + size + '\" height=\"' + size + '\" fill=\"' + colorData[0].color + '\" />' +" +
|
||||
" '<path filter=\"url(#prefix__filter0_f)\" d=\"M32.414 59.35L50.376 70.5H72.5v-71H33.728L26.5 13.381l19.057 27.08L32.414 59.35z\" fill=\"' + colorData[1].color + '\" transform=\"translate(' + colorData[1].translateX + ' ' + colorData[1].translateY + ') rotate(' + colorData[1].rotate + ' ' + (size / 2) + ' ' + (size / 2) + ') scale(' + colorData[1].scale + ')\" />' +" +
|
||||
" '<path filter=\"url(#prefix__filter0_f)\" style=\"mix-blend-mode: overlay\" d=\"M22.216 24L0 46.75l14.108 38.129L78 86l-3.081-59.276-22.378 4.005 12.972 20.186-23.35 27.395L22.215 24z\" fill=\"' + colorData[2].color + '\" transform=\"translate(' + colorData[2].translateX + ' ' + colorData[2].translateY + ') rotate(' + colorData[2].rotate + ' ' + (size / 2) + ' ' + (size / 2) + ') scale(' + colorData[2].scale + ')\" />' +" +
|
||||
" '</g>' +" +
|
||||
" '<defs>' +" +
|
||||
" '<filter id=\"prefix__filter0_f\" filterUnits=\"userSpaceOnUse\" color-interpolation-filters=\"sRGB\">' +" +
|
||||
" '<feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\" />' +" +
|
||||
" '<feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\" />' +" +
|
||||
" '<feGaussianBlur stdDeviation=\"7\" result=\"effect1_foregroundBlur\" />' +" +
|
||||
" '</filter>' +" +
|
||||
" '</defs>' +" +
|
||||
" '</svg>';" +
|
||||
"};" +
|
||||
"const input = document.getElementById('username');" +
|
||||
"const avatarSpan = document.getElementById('username-icon');" +
|
||||
"input.addEventListener('input', (e) => {" +
|
||||
" avatarSpan.innerHTML = generateFallbackAvatar(e.target.value);" +
|
||||
"});";
|
||||
|
||||
const authWindowScript = `
|
||||
const openAuthWindow = async (provider) => {
|
||||
const POLL_INTERVAL = 300;
|
||||
const BASE_URL = window.location.origin;
|
||||
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
const createDesktopWindow = (authUrl) => {
|
||||
const config = {
|
||||
width: 700,
|
||||
height: 700,
|
||||
features: "toolbar=no,location=no,directories=no,status=no,menubar=no,scrollbars=no,resizable=no,copyhistory=no"
|
||||
};
|
||||
|
||||
const top = window.top.outerHeight / 2 + window.top.screenY - (config.height / 2);
|
||||
const left = window.top.outerWidth / 2 + window.top.screenX - (config.width / 2);
|
||||
|
||||
return window.open(
|
||||
authUrl,
|
||||
'Auth Popup',
|
||||
\`width=\${config.width},height=\${config.height},left=\${left},top=\${top},\${config.features}\`
|
||||
);
|
||||
};
|
||||
|
||||
const monitorAuthWindow = (targetWindow) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handleAuthSuccess = (event) => {
|
||||
if (event.origin !== BASE_URL) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'auth_success') {
|
||||
cleanup();
|
||||
window.location.href = window.location.origin + "/" + provider + "/callback" + data.searchParams;
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore invalid JSON messages
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleAuthSuccess);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (targetWindow.closed) {
|
||||
cleanup();
|
||||
reject(new Error('Authentication window was closed'));
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
function cleanup() {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('message', handleAuthSuccess);
|
||||
if (!targetWindow.closed) {
|
||||
targetWindow.location.href = 'about:blank'
|
||||
targetWindow.close();
|
||||
}
|
||||
window.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const authUrl = \`\${BASE_URL}/\${provider}/authorize\`;
|
||||
const newWindow = isMobile ? window.open(authUrl, '_blank') : createDesktopWindow(authUrl);
|
||||
|
||||
if (!newWindow) {
|
||||
throw new Error('Failed to open authentication window');
|
||||
}
|
||||
|
||||
return monitorAuthWindow(newWindow);
|
||||
};
|
||||
|
||||
|
||||
const buttons = document.querySelectorAll('button[id^="button-"]');
|
||||
const formRoot = document.querySelector('[data-component="form-root"]');
|
||||
|
||||
const setLoadingState = (activeProvider) => {
|
||||
formRoot.setAttribute('data-disabled', 'true');
|
||||
|
||||
buttons.forEach(button => {
|
||||
button.style.pointerEvents = 'none';
|
||||
|
||||
const provider = button.id.replace('button-', '');
|
||||
if (provider === activeProvider) {
|
||||
button.setAttribute('data-loading', 'true');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetState = () => {
|
||||
formRoot.removeAttribute('data-disabled');
|
||||
|
||||
buttons.forEach(button => {
|
||||
button.style.pointerEvents = '';
|
||||
button.removeAttribute('data-loading');
|
||||
});
|
||||
};
|
||||
|
||||
buttons.forEach(button => {
|
||||
const provider = button.id.replace('button-', '');
|
||||
|
||||
if (provider === "password"){
|
||||
button.addEventListener('click', async (e) => {
|
||||
window.location.href = window.location.origin + "/" + provider + "/authorize";
|
||||
})
|
||||
} else {
|
||||
button.addEventListener('click', async (e) => {
|
||||
try {
|
||||
setLoadingState(provider);
|
||||
await openAuthWindow(provider);
|
||||
} catch (error) {
|
||||
resetState();
|
||||
console.error(\`Authentication failed for \${provider}:\`, error);
|
||||
}
|
||||
// finally {
|
||||
// resetState();
|
||||
// }
|
||||
});
|
||||
}
|
||||
});`;
|
||||
|
||||
const callbackScript = `
|
||||
if (window.opener == null) {
|
||||
window.location.href = "about:blank";
|
||||
}
|
||||
|
||||
const searchParams = window.location.search;
|
||||
|
||||
try {
|
||||
window.opener.postMessage(
|
||||
JSON.stringify({
|
||||
type: 'auth_success',
|
||||
searchParams: searchParams
|
||||
}),
|
||||
window.location.origin
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Failed to send message to parent window:', e);
|
||||
}`;
|
||||
return (
|
||||
<html
|
||||
style={{
|
||||
"--color-background-light": get("background", "light"),
|
||||
"--color-background-dark": get("background", "dark"),
|
||||
"--color-primary-light": get("primary", "light"),
|
||||
"--color-primary-dark": get("primary", "dark"),
|
||||
"--font-family": theme?.font?.family,
|
||||
"--font-scale": theme?.font?.scale,
|
||||
"--border-radius": radius,
|
||||
backgroundColor: get("background", "dark"),
|
||||
}}
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{theme?.title || "OpenAuthJS"}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href={theme?.favicon} />
|
||||
<style dangerouslySetInnerHTML={{ __html: css() }} />
|
||||
{theme?.css && (
|
||||
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
|
||||
)}
|
||||
</head>
|
||||
<body>
|
||||
<div data-component="root">
|
||||
<main data-component="center" data-size={props.size}>
|
||||
{props.children}
|
||||
</main>
|
||||
<section data-component="logo-footer" >
|
||||
<svg viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height={157} width={695} >
|
||||
<g stroke-linecap="round" fill-rule="evenodd" font-size="9pt" stroke="currentColor" stroke-width="0.25mm" fill="currentColor" style="stroke:currentColor;stroke-width:0.25mm;fill:currentColor">
|
||||
<path
|
||||
fill="url(#paint1)"
|
||||
pathLength="1"
|
||||
stroke="url(#paint1)"
|
||||
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="paint1" x1="317.5" x2="314.007" y1="-51.5" y2="126">
|
||||
<stop stop-color="white"></stop>
|
||||
<stop offset="1" stop-opacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</section>
|
||||
</div>
|
||||
{props.page === "password" && (
|
||||
<script dangerouslySetInnerHTML={{ __html: script }} />
|
||||
)}
|
||||
{props.page === "root" && (
|
||||
<script dangerouslySetInnerHTML={{ __html: authWindowScript }} />
|
||||
)}
|
||||
{props.page === "popup" && (
|
||||
<script dangerouslySetInnerHTML={{ __html: callbackScript }} />
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
586
cloud/packages/functions/src/auth/ui/css.ts
Normal file
586
cloud/packages/functions/src/auth/ui/css.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
export function css() {
|
||||
return `
|
||||
@import url("https://unpkg.com/tailwindcss@3.4.15/src/css/preflight.css");
|
||||
|
||||
:root {
|
||||
--color-background-dark: #0e0e11;
|
||||
--color-background-light: #ffffff;
|
||||
--color-primary-dark: #6772e5;
|
||||
--color-primary-light: #6772e5;
|
||||
--border-radius: 0;
|
||||
|
||||
--color-background: var(--color-background-dark);
|
||||
--color-primary: var(--color-primary-dark);
|
||||
|
||||
--spinner-size: 16px;
|
||||
--spinner-color: #FFF;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
--color-background: var(--color-background-light);
|
||||
--color-primary: var(--color-primary-light);
|
||||
}
|
||||
|
||||
--color-high: oklch(
|
||||
from var(--color-background) clamp(0, calc((l - 0.714) * -1000), 1) 0 0
|
||||
);
|
||||
--color-low: oklch(from var(--color-background) clamp(0, calc((l - 0.714) * 1000), 1) 0 0);
|
||||
--lightness-high: color-mix(
|
||||
in oklch,
|
||||
var(--color-high) 0%,
|
||||
oklch(var(--color-high) 0 0)
|
||||
);
|
||||
--lightness-low: color-mix(
|
||||
in oklch,
|
||||
var(--color-low) 0%,
|
||||
oklch(var(--color-low) 0 0)
|
||||
);
|
||||
--font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-scale: 1;
|
||||
|
||||
--font-size-xs: calc(0.75rem * var(--font-scale));
|
||||
--font-size-sm: calc(0.875rem * var(--font-scale));
|
||||
--font-size-md: calc(1rem * var(--font-scale));
|
||||
--font-size-lg: calc(1.125rem * var(--font-scale));
|
||||
--font-size-xl: calc(1.25rem * var(--font-scale));
|
||||
--font-size-2xl: calc(1.5rem * var(--font-scale));
|
||||
}
|
||||
|
||||
html, html * {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-component="root"] {
|
||||
font-family: var(--font-family);
|
||||
background-color: var(--color-background);
|
||||
padding: 1rem 1rem 0;
|
||||
color: white;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
color: var(--color-high);
|
||||
}
|
||||
|
||||
[data-component="logo-footer"] {
|
||||
position: fixed;
|
||||
bottom: -1px;
|
||||
font-size: 100%;
|
||||
max-width: 1440px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
|
||||
& > svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(40%);
|
||||
opacity: 70%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="popup"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 500;
|
||||
|
||||
& [data-component="spinner"]{
|
||||
--spinner-size: 24px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="center"] {
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
padding: 0 0 120px 0;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
&[data-size="small"] {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="link"] {
|
||||
text-decoration: underline;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-component="label"] {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
[data-component="input"] {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
padding-left: 36px;
|
||||
border: 1px solid transparent;
|
||||
--background: oklch(
|
||||
from var(--color-background) calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h
|
||||
);
|
||||
background: var(--background);
|
||||
border-color: #343434;
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #161616,0 0 0 4px #707070
|
||||
}
|
||||
|
||||
&:user-invalid:focus {
|
||||
box-shadow: 0 0 0 2px #161616,0 0 0 4px #ff6369;
|
||||
}
|
||||
|
||||
&:user-invalid:not(:focus) {
|
||||
border-color: #ff6369;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
border-color: #e2e2e2;
|
||||
color: #171717;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fcfcfc,0 0 0 4px #8f8f8f;
|
||||
}
|
||||
|
||||
&:user-invalid:focus {
|
||||
box-shadow: 0 0 0 2px #fcfcfc, 0 0 0 4px #cd2b31;
|
||||
}
|
||||
|
||||
&:user-invalid:not(:focus) {
|
||||
border-color: #cd2b31;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="button"] {
|
||||
height: 2.5rem;
|
||||
cursor: pointer;
|
||||
margin-top: 3px;
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
border-radius: calc(var(--border-radius) * 0.25rem);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: oklch(from var(--color-primary) clamp(0, calc((l - 0.714) * -1000), 1) 0 0);
|
||||
|
||||
&[data-color="ghost"] {
|
||||
background: transparent;
|
||||
color: var(--color-high);
|
||||
border: 1px solid
|
||||
oklch(
|
||||
from var(--color-background)
|
||||
calc(clamp(0.22, l + (-0.12 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.06), 0.88)) c h
|
||||
);
|
||||
}
|
||||
|
||||
&:focus [data-component="spinner"]{
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="button-root"] {
|
||||
transition-property: border-color,background,color,transform,box-shadow;
|
||||
transition-duration: .15s;
|
||||
transition-timing-function: ease;
|
||||
height: 48px;
|
||||
cursor: pointer;
|
||||
padding: 0px 14px;
|
||||
margin-top: 3px;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 2px solid #00000014;
|
||||
--spinner-color: #000;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border: 2px solid #ffffff24;
|
||||
--spinner-color: #FFF;
|
||||
}
|
||||
|
||||
&[data-color="github"] {
|
||||
background: #24292e;
|
||||
color: #fff;
|
||||
border: 2px solid #1B1F22;
|
||||
&:hover {
|
||||
background: #434D56;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border: 1px solid transparent;
|
||||
background: #434D56;
|
||||
&:hover {
|
||||
background: #24292e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-color="discord"] {
|
||||
background: #4445e7;
|
||||
border: 2px solid #3836cc;
|
||||
color: #fff;
|
||||
&:hover {
|
||||
background: #5865F2;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border: 1px solid transparent;
|
||||
background: #5865F2;
|
||||
&:hover {
|
||||
background: #4445e7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background:rgb(229, 229, 229);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&:hover {
|
||||
background:rgb(38, 38,38);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="icon"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="form"] {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-loading="true"]{
|
||||
& [data-component="spinner"]{
|
||||
display: block;
|
||||
}
|
||||
|
||||
& [data-slot="icon"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-disabled="true"] {
|
||||
& button {
|
||||
background: rgb(229,229,229) !important;
|
||||
border: 2px solid #00000014 !important;
|
||||
opacity: .7 !important;
|
||||
color: inherit !important;
|
||||
cursor: not-allowed !important;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: rgb(38, 38,38) !important;
|
||||
border: 2px solid #ffffff24 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="form-root"] {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-component="form-header"] {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: #a0a0a0;
|
||||
max-width: 400px;
|
||||
font-weight: 400;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #6f6f6f
|
||||
}
|
||||
|
||||
& > hr {
|
||||
border:0;
|
||||
background: #282828;
|
||||
height:2px;
|
||||
width:100%;
|
||||
margin-top:4px;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
background: #e8e8e8
|
||||
}
|
||||
}
|
||||
|
||||
& > h1 {
|
||||
color: #ededed;
|
||||
font-weight:500;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing:-.020625rem;
|
||||
line-height:1.5rem;
|
||||
margin:0;
|
||||
overflow-wrap:break-word;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #171717
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="form-header-root"] {
|
||||
color: #FFF;
|
||||
max-width: 400px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
line-height: 2.5rem;
|
||||
letter-spacing: -0.049375rem;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #000
|
||||
}
|
||||
|
||||
// & > hr {
|
||||
// border:0;
|
||||
// background: #282828;
|
||||
// height:2px;
|
||||
// width:100%;
|
||||
// margin-top:4px;
|
||||
|
||||
// @media (prefers-color-scheme: light) {
|
||||
// background: #e8e8e8
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
[data-component="input-container"] {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
color: #a0a0a0;
|
||||
max-width: 400px;
|
||||
font-weight: 400px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #6f6f6f
|
||||
}
|
||||
|
||||
& > small {
|
||||
color: #ff6369;
|
||||
display: block;
|
||||
line-height: 1rem;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
color: #cd2b31;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-error="true"] {
|
||||
& input {
|
||||
border-color: #ff6369;
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px #161616,0 0 0 4px #ff6369;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
border-color: #cd2b31;
|
||||
:focus {
|
||||
box-shadow: 0 0 0 2px #fcfcfc, 0 0 0 4px #cd2b31;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="input-wrapper"] {
|
||||
position: relative;
|
||||
width:100%;
|
||||
}
|
||||
|
||||
[data-component="input-icon"] {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 8px;
|
||||
width: 20px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
|
||||
& > svg {
|
||||
width:20px;
|
||||
height:20px;
|
||||
display:block;
|
||||
max-width:100%;
|
||||
}
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:focus {
|
||||
transition: background-color 0s 600000s, color 0s 600000s !important;
|
||||
}
|
||||
|
||||
|
||||
[data-component="spinner"] {
|
||||
height: var(--spinner-size,20px);
|
||||
width: var(--spinner-size,20px);
|
||||
margin-left: calc(var(--spinner-size,20px)*-1px);
|
||||
display: none;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
height: var(--spinner-size,20px);
|
||||
width: var(--spinner-size,20px);
|
||||
}
|
||||
|
||||
& > div > div {
|
||||
animation: spin 1.2s linear infinite;
|
||||
background: var(--spinner-color);
|
||||
border-radius: 9999px;
|
||||
height: 8%;
|
||||
left: -10%;
|
||||
position: absolute;
|
||||
top: -3.9%;
|
||||
width: 24%;
|
||||
}
|
||||
|
||||
& > div > div:first-child {
|
||||
animation-delay: -1.2s;
|
||||
transform: rotate(.0001deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(2) {
|
||||
animation-delay: -1.1s;
|
||||
transform: rotate(30deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(3) {
|
||||
animation-delay: -1s;
|
||||
transform: rotate(60deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(4) {
|
||||
animation-delay: -.9s;
|
||||
transform: rotate(90deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(5) {
|
||||
animation-delay: -.8s;
|
||||
transform: rotate(120deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(6) {
|
||||
animation-delay: -.7s;
|
||||
transform: rotate(150deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(7) {
|
||||
animation-delay: -.6s;
|
||||
transform: rotate(180deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(8) {
|
||||
animation-delay: -.5s;
|
||||
transform: rotate(210deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(9) {
|
||||
animation-delay: -.4s;
|
||||
transform: rotate(240deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(10) {
|
||||
animation-delay: -.3s;
|
||||
transform: rotate(270deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(11) {
|
||||
animation-delay: -.2s;
|
||||
transform: rotate(300deg) translate(146%);
|
||||
}
|
||||
|
||||
& > div > div:nth-child(12) {
|
||||
animation-delay: -.1s;
|
||||
transform: rotate(330deg) translate(146%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: .15;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
`
|
||||
}
|
||||
2
cloud/packages/functions/src/auth/ui/index.ts
Normal file
2
cloud/packages/functions/src/auth/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./password"
|
||||
export * from "./select"
|
||||
138
cloud/packages/functions/src/auth/ui/oauth2.tsx
Normal file
138
cloud/packages/functions/src/auth/ui/oauth2.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import fetch from "node-fetch"
|
||||
import { Layout } from "./base"
|
||||
import { OauthError } from "@openauthjs/openauth/error"
|
||||
import { getRelativeUrl } from "@openauthjs/openauth/util"
|
||||
import { type Provider } from "@openauthjs/openauth/provider/provider"
|
||||
|
||||
export interface Oauth2Config {
|
||||
type?: string
|
||||
clientID: string
|
||||
clientSecret: string
|
||||
endpoint: {
|
||||
authorization: string
|
||||
token: string
|
||||
}
|
||||
scopes: string[]
|
||||
query?: Record<string, string>
|
||||
}
|
||||
|
||||
export type Oauth2WrappedConfig = Omit<Oauth2Config, "endpoint" | "name">
|
||||
|
||||
export interface Oauth2Token {
|
||||
access: string
|
||||
refresh: string
|
||||
expiry: number
|
||||
raw: Record<string, any>
|
||||
}
|
||||
|
||||
interface AdapterState {
|
||||
state: string
|
||||
redirect: string
|
||||
}
|
||||
|
||||
export function Oauth2Adapter(
|
||||
config: Oauth2Config,
|
||||
): Provider<{ tokenset: Oauth2Token; clientID: string }> {
|
||||
const query = config.query || {}
|
||||
return {
|
||||
type: config.type || "oauth2",
|
||||
init(routes, ctx) {
|
||||
routes.get("/authorize", async (c) => {
|
||||
const state = crypto.randomUUID()
|
||||
await ctx.set<AdapterState>(c, "adapter", 60 * 10, {
|
||||
state,
|
||||
redirect: getRelativeUrl(c, "./popup"),
|
||||
})
|
||||
const authorization = new URL(config.endpoint.authorization)
|
||||
authorization.searchParams.set("client_id", config.clientID)
|
||||
authorization.searchParams.set(
|
||||
"redirect_uri",
|
||||
getRelativeUrl(c, "./popup"),
|
||||
)
|
||||
authorization.searchParams.set("response_type", "code")
|
||||
authorization.searchParams.set("state", state)
|
||||
authorization.searchParams.set("scope", config.scopes.join(" "))
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
authorization.searchParams.set(key, value)
|
||||
}
|
||||
return c.redirect(authorization.toString())
|
||||
})
|
||||
|
||||
routes.get("/popup", async (c) => {
|
||||
const jsx = (
|
||||
<Layout page="popup">
|
||||
<div data-component="popup">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
Nestri is verifying your connection...
|
||||
</div>
|
||||
</Layout>
|
||||
) as string
|
||||
return new Response(jsx.toString(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
routes.get("/callback", async (c) => {
|
||||
const adapter = (await ctx.get(c, "adapter")) as AdapterState
|
||||
const code = c.req.query("code")
|
||||
const state = c.req.query("state")
|
||||
const error = c.req.query("error")
|
||||
if (error) {
|
||||
console.log("oauth2 error", error)
|
||||
throw new OauthError(
|
||||
error.toString() as any,
|
||||
c.req.query("error_description")?.toString() || "",
|
||||
)
|
||||
}
|
||||
if (!adapter || !code || (adapter.state && state !== adapter.state))
|
||||
return c.redirect(getRelativeUrl(c, "./authorize"))
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.clientID,
|
||||
client_secret: config.clientSecret,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: adapter.redirect,
|
||||
})
|
||||
const json: any = await fetch(config.endpoint.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: body.toString(),
|
||||
}).then((r) => r.json())
|
||||
if ("error" in json) {
|
||||
console.error("oauth2 error", error)
|
||||
throw new OauthError(json.error, json.error_description)
|
||||
}
|
||||
return ctx.success(c, {
|
||||
clientID: config.clientID,
|
||||
tokenset: {
|
||||
get access() {
|
||||
return json.access_token
|
||||
},
|
||||
get refresh() {
|
||||
return json.refresh_token
|
||||
},
|
||||
get expiry() {
|
||||
return json.expires_in
|
||||
},
|
||||
get raw() {
|
||||
return json
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
481
cloud/packages/functions/src/auth/ui/password.tsx
Normal file
481
cloud/packages/functions/src/auth/ui/password.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
import {
|
||||
type PasswordChangeError,
|
||||
type PasswordConfig,
|
||||
type PasswordLoginError,
|
||||
type PasswordRegisterError,
|
||||
} from "../adapters"
|
||||
// import { Layout } from "@openauthjs/openauth/ui/base"
|
||||
import { Layout } from "./base"
|
||||
import "@openauthjs/openauth/ui/form"
|
||||
// import { FormAlert } from "@openauthjs/openauth/ui/form"
|
||||
|
||||
const DEFAULT_COPY = {
|
||||
error_email_taken: "There is already an account with this email.",
|
||||
error_username_taken: "There is already an account with this username.",
|
||||
error_invalid_code: "Code is incorrect.",
|
||||
error_invalid_email: "Email is not valid.",
|
||||
error_invalid_password: "Password is incorrect.",
|
||||
error_invalid_username: "Username can only contain letters.",
|
||||
error_password_mismatch: "Passwords do not match.",
|
||||
register_title: "Welcome to the app",
|
||||
register_description: "Sign in with your email",
|
||||
login_title: "Welcome to the app",
|
||||
login_description: "Sign in with your email",
|
||||
register: "Register",
|
||||
register_prompt: "Don't have an account?",
|
||||
login_prompt: "Already have an account?",
|
||||
login: "Login",
|
||||
change_prompt: "Forgot your password?",
|
||||
change: "Well that sucks",
|
||||
code_resend: "Resend code",
|
||||
code_return: "Back to",
|
||||
logo: "A",
|
||||
input_email: "john@doe.com",
|
||||
input_password: "●●●●●●●●●●●",
|
||||
input_code: "●●●●●●",
|
||||
input_username: "john",
|
||||
input_repeat: "●●●●●●●●●●●",
|
||||
button_continue: "Continue",
|
||||
} satisfies {
|
||||
[key in `error_${| PasswordLoginError["type"]
|
||||
| PasswordRegisterError["type"]
|
||||
| PasswordChangeError["type"]}`]: string
|
||||
} & Record<string, string>
|
||||
|
||||
export type PasswordUICopy = typeof DEFAULT_COPY
|
||||
|
||||
export interface PasswordUIOptions {
|
||||
sendCode: PasswordConfig["sendCode"]
|
||||
copy?: Partial<PasswordUICopy>
|
||||
}
|
||||
|
||||
export function PasswordUI(input: PasswordUIOptions) {
|
||||
const copy = {
|
||||
...DEFAULT_COPY,
|
||||
...input.copy,
|
||||
}
|
||||
return {
|
||||
sendCode: input.sendCode,
|
||||
login: async (_req, form, error): Promise<Response> => {
|
||||
const emailError = ["invalid_email", "email_taken"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
const passwordError = ["invalid_password", "password_mismatch"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
const jsx = (
|
||||
<Layout page="password">
|
||||
<div data-component="form-header">
|
||||
<h1>Login</h1>
|
||||
<span>
|
||||
{copy.register_prompt}{" "}
|
||||
<a data-component="link" href="register">
|
||||
{copy.register}
|
||||
</a>
|
||||
</span>
|
||||
<hr />
|
||||
</div>
|
||||
<form data-component="form" method="post">
|
||||
{/* <FormAlert message={error?.type && copy?.[`error_${error.type}`]} /> */}
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Email</span>
|
||||
<div data-error={emailError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor">
|
||||
<path d="M7 9l5 3.5L17 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
</path>
|
||||
<path d="M2 17V7a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2z" stroke="currentColor" stroke-width="1.5">
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder={copy.input_email}
|
||||
autofocus={!error}
|
||||
value={form?.get("email")?.toString()}
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && emailError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Password</span>
|
||||
<div data-error={passwordError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus={error?.type === "invalid_password"}
|
||||
required
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && passwordError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<button data-component="button">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{copy.button_continue}
|
||||
</button>
|
||||
<div style={{ padding: "2px 0" }} data-component="form-header">
|
||||
<hr />
|
||||
<span>
|
||||
{copy.change_prompt}{" "}
|
||||
<a data-component="link" href="change">
|
||||
{copy.change}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</Layout>
|
||||
)
|
||||
return new Response(jsx.toString(), {
|
||||
status: error ? 401 : 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
},
|
||||
register: async (_req, state, form, error): Promise<Response> => {
|
||||
const emailError = ["invalid_email", "email_taken"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
const passwordError = ["invalid_password", "password_mismatch"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
//Just in case the server does it
|
||||
const codeError = ["invalid_code"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
const usernameError = ["invalid_username", "username_taken"].includes(
|
||||
error?.type || "",
|
||||
);
|
||||
|
||||
const jsx = (
|
||||
<Layout page="password">
|
||||
<div data-component="form-header">
|
||||
<h1>Register</h1>
|
||||
<span>
|
||||
{copy.login_prompt}{" "}
|
||||
<a data-component="link" href="authorize">
|
||||
{copy.login}
|
||||
</a>
|
||||
</span>
|
||||
<hr></hr>
|
||||
</div>
|
||||
<form data-component="form" method="post">
|
||||
{/* <FormAlert message={error?.type && copy?.[`error_${error.type}`]} /> */}
|
||||
{state.type === "start" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="register" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Email</span>
|
||||
<div data-error={emailError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor">
|
||||
<path d="M7 9l5 3.5L17 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
</path>
|
||||
<path d="M2 17V7a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2z" stroke="currentColor" stroke-width="1.5">
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus={!error || emailError}
|
||||
type="email"
|
||||
name="email"
|
||||
value={!emailError ? form?.get("email")?.toString() : ""}
|
||||
required
|
||||
placeholder={copy.input_email}
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && emailError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Username</span>
|
||||
<div data-error={usernameError} data-component="input-wrapper">
|
||||
<span id="username-icon" data-component="input-icon">
|
||||
<svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4.271 18.346S6.5 15.5 12 15.5s7.73 2.846 7.73 2.846M12 12a3 3 0 100-6 3 3 0 000 6z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
|
||||
</span>
|
||||
<input
|
||||
id="username"
|
||||
data-component="input"
|
||||
autofocus={usernameError}
|
||||
type="text"
|
||||
name="username"
|
||||
placeholder={copy.input_username}
|
||||
required
|
||||
value={
|
||||
!usernameError ? form?.get("username")?.toString() : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && usernameError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Password</span>
|
||||
<div data-error={passwordError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
id="password"
|
||||
autofocus={passwordError}
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
required
|
||||
value={
|
||||
!passwordError ? form?.get("password")?.toString() : ""
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && passwordError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<button data-component="button">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{copy.button_continue}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.type === "code" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="verify" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Code</span>
|
||||
<div data-error={codeError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M2.43 8.25a1 1 0 0 1 1-1h.952c1.063 0 1.952.853 1.952 1.938v6.562a1 1 0 1 1-2 0v-6.5H3.43a1 1 0 0 1-1-1m5.714 0a1 1 0 0 1 1-1h2.857c1.064 0 1.953.853 1.953 1.938v1.874A1.945 1.945 0 0 1 12 13h-1.857v1.75h2.81a1 1 0 1 1 0 2h-2.858a1.945 1.945 0 0 1-1.952-1.937v-1.876c0-1.084.889-1.937 1.952-1.937h1.858V9.25h-2.81a1 1 0 0 1-1-1m7.619 0a1 1 0 0 1 1-1h2.857c1.063 0 1.953.853 1.953 1.938v5.624a1.945 1.945 0 0 1-1.953 1.938h-2.857a1 1 0 1 1 0-2h2.81V13h-2.81a1 1 0 1 1 0-2h2.81V9.25h-2.81a1 1 0 0 1-1-1" clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
name="code"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
placeholder={copy.input_code}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && codeError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<button data-component="button">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{copy.button_continue}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Layout>
|
||||
) as string
|
||||
return new Response(jsx.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
},
|
||||
change: async (_req, state, form, error): Promise<Response> => {
|
||||
const passwordError = ["invalid_password", "password_mismatch"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
const emailError = ["invalid_email", "email_taken"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
const codeError = ["invalid_code"].includes(
|
||||
error?.type || "",
|
||||
)
|
||||
|
||||
const jsx = (
|
||||
<Layout page="password">
|
||||
<div data-component="form-header">
|
||||
<h1>Forgot Password</h1>
|
||||
{state.type != "update" && (
|
||||
<span>
|
||||
Suddenly had an epiphany?{" "}
|
||||
<a data-component="link" href="authorize">
|
||||
{copy.login}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
<hr />
|
||||
</div>
|
||||
<form data-component="form" method="post" replace>
|
||||
{/* <FormAlert message={error?.type && copy?.[`error_${error.type}`]} /> */}
|
||||
{state.type === "start" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="code" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Email</span>
|
||||
<div data-error={emailError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="currentColor">
|
||||
<path d="M7 9l5 3.5L17 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
</path>
|
||||
<path d="M2 17V7a2 2 0 012-2h16a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2z" stroke="currentColor" stroke-width="1.5">
|
||||
</path>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
value={form?.get("email")?.toString()}
|
||||
placeholder={copy.input_email}
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && emailError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state.type === "code" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="verify" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Code</span>
|
||||
<div data-error={codeError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24"><path fill="currentColor" fill-rule="evenodd" d="M2.43 8.25a1 1 0 0 1 1-1h.952c1.063 0 1.952.853 1.952 1.938v6.562a1 1 0 1 1-2 0v-6.5H3.43a1 1 0 0 1-1-1m5.714 0a1 1 0 0 1 1-1h2.857c1.064 0 1.953.853 1.953 1.938v1.874A1.945 1.945 0 0 1 12 13h-1.857v1.75h2.81a1 1 0 1 1 0 2h-2.858a1.945 1.945 0 0 1-1.952-1.937v-1.876c0-1.084.889-1.937 1.952-1.937h1.858V9.25h-2.81a1 1 0 0 1-1-1m7.619 0a1 1 0 0 1 1-1h2.857c1.063 0 1.953.853 1.953 1.938v5.624a1.945 1.945 0 0 1-1.953 1.938h-2.857a1 1 0 1 1 0-2h2.81V13h-2.81a1 1 0 1 1 0-2h2.81V9.25h-2.81a1 1 0 0 1-1-1" clip-rule="evenodd" /></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
name="code"
|
||||
minLength={6}
|
||||
maxLength={6}
|
||||
required
|
||||
placeholder={copy.input_code}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && codeError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{state.type === "update" && (
|
||||
<>
|
||||
<input type="hidden" name="action" value="update" />
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Password</span>
|
||||
<div data-error={passwordError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
autofocus
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={copy.input_password}
|
||||
required
|
||||
value={
|
||||
!passwordError ? form?.get("password")?.toString() : ""
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && passwordError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
<div
|
||||
data-component="input-container"
|
||||
>
|
||||
<span>Confirm Password</span>
|
||||
<div data-error={passwordError} data-component="input-wrapper">
|
||||
<span data-component="input-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
</span>
|
||||
<input
|
||||
data-component="input"
|
||||
type="password"
|
||||
name="repeat"
|
||||
required
|
||||
value={
|
||||
!passwordError ? form?.get("password")?.toString() : ""
|
||||
}
|
||||
placeholder={copy.input_repeat}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<small>{error?.type && passwordError && copy?.[`error_${error.type}`]}</small>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button data-component="button">
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{copy.button_continue}
|
||||
</button>
|
||||
</form>
|
||||
{state.type === "code" && (
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="code" />
|
||||
<input type="hidden" name="email" value={state.email} />
|
||||
</form>
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
return new Response(jsx.toString(), {
|
||||
status: error ? 400 : 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
},
|
||||
} satisfies PasswordConfig
|
||||
}
|
||||
122
cloud/packages/functions/src/auth/ui/select.tsx
Normal file
122
cloud/packages/functions/src/auth/ui/select.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import { Layout } from "./base"
|
||||
|
||||
export interface SelectProps {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
hide?: boolean
|
||||
display?: string
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export function Select(props?: SelectProps) {
|
||||
return async (
|
||||
providers: Record<string, string>,
|
||||
_req: Request,
|
||||
): Promise<Response> => {
|
||||
const jsx = (
|
||||
<Layout page="root">
|
||||
<div data-component="form-header-root">
|
||||
<h1>Welcome to Nestri</h1>
|
||||
</div>
|
||||
<div
|
||||
// data-disabled="true"
|
||||
data-component="form-root">
|
||||
{Object.entries(providers).map(([key, type]) => {
|
||||
const match = props?.providers?.[key]
|
||||
if (match?.hide) return
|
||||
const icon = ICON[key]
|
||||
return (
|
||||
<button
|
||||
id={`button-${key}`}
|
||||
data-component="button-root"
|
||||
// data-loading={key == "password" && "true"}
|
||||
data-color={key}
|
||||
>
|
||||
{icon && (
|
||||
<>
|
||||
<div data-component="spinner">
|
||||
<div>
|
||||
{new Array(12).fill(0).map((i, k) => (
|
||||
<div key={k} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<i data-slot="icon">{icon}</i>
|
||||
</>
|
||||
)}
|
||||
Continue with {match?.display || DISPLAY[type] || type}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
return new Response(jsx.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const DISPLAY: Record<string, string> = {
|
||||
twitch: "Twitch",
|
||||
google: "Google",
|
||||
github: "GitHub",
|
||||
discord: "Discord",
|
||||
password: "Password",
|
||||
}
|
||||
|
||||
const ICON: Record<string, any> = {
|
||||
code: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 52 52"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.55,36.91A6.55,6.55,0,1,1,2,43.45,6.54,6.54,0,0,1,8.55,36.91Zm17.45,0a6.55,6.55,0,1,1-6.55,6.54A6.55,6.55,0,0,1,26,36.91Zm17.45,0a6.55,6.55,0,1,1-6.54,6.54A6.54,6.54,0,0,1,43.45,36.91ZM8.55,19.45A6.55,6.55,0,1,1,2,26,6.55,6.55,0,0,1,8.55,19.45Zm17.45,0A6.55,6.55,0,1,1,19.45,26,6.56,6.56,0,0,1,26,19.45Zm17.45,0A6.55,6.55,0,1,1,36.91,26,6.55,6.55,0,0,1,43.45,19.45ZM8.55,2A6.55,6.55,0,1,1,2,8.55,6.54,6.54,0,0,1,8.55,2ZM26,2a6.55,6.55,0,1,1-6.55,6.55A6.55,6.55,0,0,1,26,2ZM43.45,2a6.55,6.55,0,1,1-6.54,6.55A6.55,6.55,0,0,1,43.45,2Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
password: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M18 16.663a3.5 3.5 0 0 1-1.373-1.163a3.5 3.5 0 0 1-.627-2a3.5 3.5 0 1 1 4.5 3.355V17l1.146 1.146a.5.5 0 0 1 0 .708L20.5 20l1.161 1.161a.5.5 0 0 1 .015.692l-1.823 1.984a.5.5 0 0 1-.722.015l-.985-.984a.5.5 0 0 1-.146-.354zM20.5 13a1 1 0 1 0-2 0a1 1 0 0 0 2 0M12 22.001c1.969 0 3.64-.354 5-1.069v-1.76c-1.223.883-2.88 1.33-5 1.33c-2.738 0-4.704-.747-5.957-2.214a2.25 2.25 0 0 1-.54-1.462v-.577a.75.75 0 0 1 .75-.75h9.215a4.5 4.5 0 0 1-.44-1.5H6.252a2.25 2.25 0 0 0-2.25 2.25v.578c0 .892.32 1.756.9 2.435c1.565 1.834 3.952 2.74 7.097 2.74m0-19.996a5 5 0 1 1 0 10a5 5 0 0 1 0-10m0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7" /></svg>
|
||||
// <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M12 16.5v-2m-7.732 4.345c.225 1.67 1.608 2.979 3.292 3.056c1.416.065 2.855.099 4.44.099s3.024-.034 4.44-.1c1.684-.076 3.067-1.385 3.292-3.055c.147-1.09.268-2.207.268-3.345s-.121-2.255-.268-3.345c-.225-1.67-1.608-2.979-3.292-3.056A95 95 0 0 0 12 9c-1.585 0-3.024.034-4.44.1c-1.684.076-3.067 1.385-3.292 3.055C4.12 13.245 4 14.362 4 15.5s.121 2.255.268 3.345" /><path d="M7.5 9V6.5a4.5 4.5 0 0 1 9 0V9" /></g></svg>
|
||||
),
|
||||
twitch: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M40.1 32L10 108.9v314.3h107V480h60.2l56.8-56.8h87l117-117V32H40.1zm357.8 254.1L331 353H224l-56.8 56.8V353H76.9V72.1h321v214zM331 149v116.9h-40.1V149H331zm-107 0v116.9h-40.1V149H224z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
google: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 488 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
github: (
|
||||
<svg role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
discord: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.566 18.566 0 0 0-5.487 0a12.36 12.36 0 0 0-.617-1.23A.077.077 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.07.07 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055a20.03 20.03 0 0 0 5.993 2.98a.078.078 0 0 0 .084-.026a13.83 13.83 0 0 0 1.226-1.963a.074.074 0 0 0-.041-.104a13.201 13.201 0 0 1-1.872-.878a.075.075 0 0 1-.008-.125c.126-.093.252-.19.372-.287a.075.075 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.075.075 0 0 1 .079.009c.12.098.245.195.372.288a.075.075 0 0 1-.006.125c-.598.344-1.22.635-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.077.077 0 0 0 .084.028a19.963 19.963 0 0 0 6.002-2.981a.076.076 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028M8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38c0-1.312.956-2.38 2.157-2.38c1.21 0 2.176 1.077 2.157 2.38c0 1.312-.956 2.38-2.157 2.38m7.975 0c-1.183 0-2.157-1.069-2.157-2.38c0-1.312.955-2.38 2.157-2.38c1.21 0 2.176 1.077 2.157 2.38c0 1.312-.946 2.38-2.157 2.38" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
38
cloud/packages/functions/src/auth/utils/discord.ts
Normal file
38
cloud/packages/functions/src/auth/utils/discord.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import fetch from "node-fetch"
|
||||
|
||||
export const handleDiscord = async (accessKey: string) => {
|
||||
try {
|
||||
const response = await fetch("https://discord.com/api/v10/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const user = await response.json();
|
||||
|
||||
if (!user.verified) {
|
||||
throw new Error("Email not verified");
|
||||
}
|
||||
|
||||
return {
|
||||
primary: {
|
||||
email: user.email,
|
||||
verified: user.verified,
|
||||
primary: true
|
||||
},
|
||||
avatar: user.avatar
|
||||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
|
||||
: null,
|
||||
username: user.global_name ?? user.username,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Discord OAuth error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
}
|
||||
40
cloud/packages/functions/src/auth/utils/github.ts
Normal file
40
cloud/packages/functions/src/auth/utils/github.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import fetch from "node-fetch";
|
||||
|
||||
export const handleGithub = async (accessKey: string) => {
|
||||
const headers = {
|
||||
Authorization: `token ${accessKey}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"User-Agent": "Nestri"
|
||||
};
|
||||
|
||||
try {
|
||||
const [emails, user] = await Promise.all([
|
||||
fetch("https://api.github.com/user/emails", { headers }).then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to fetch emails: ${r.status}`);
|
||||
return r.json();
|
||||
}),
|
||||
fetch("https://api.github.com/user", { headers }).then(r => {
|
||||
if (!r.ok) throw new Error(`Failed to fetch user: ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
]);
|
||||
|
||||
const primaryEmail = emails.find((email: { primary: boolean }) => email.primary);
|
||||
|
||||
if (!primaryEmail.verified) {
|
||||
throw new Error("Email not verified");
|
||||
}
|
||||
// console.log("raw user", user)
|
||||
|
||||
const { email, primary, verified } = primaryEmail;
|
||||
|
||||
return {
|
||||
primary: { email, primary, verified },
|
||||
avatar: user.avatar_url,
|
||||
username: user.name ?? user.login,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('GitHub OAuth error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
2
cloud/packages/functions/src/auth/utils/index.ts
Normal file
2
cloud/packages/functions/src/auth/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./discord"
|
||||
export * from "./github"
|
||||
358
cloud/packages/functions/src/events/index.ts
Normal file
358
cloud/packages/functions/src/events/index.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import "zod-openapi/extend";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Actor } from "@nestri/core/actor";
|
||||
import { Game } from "@nestri/core/game/index";
|
||||
import { Steam } from "@nestri/core/steam/index";
|
||||
import { Client } from "@nestri/core/client/index";
|
||||
import { Friend } from "@nestri/core/friend/index";
|
||||
import { Images } from "@nestri/core/images/index";
|
||||
import { Library } from "@nestri/core/library/index";
|
||||
import { chunkArray } from "@nestri/core/utils/index";
|
||||
import { BaseGame } from "@nestri/core/base-game/index";
|
||||
import { Categories } from "@nestri/core/categories/index";
|
||||
import { ImageTypeEnum } from "@nestri/core/images/images.sql";
|
||||
import { PutObjectCommand, S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const s3 = new S3Client({});
|
||||
|
||||
export const handler = bus.subscriber(
|
||||
[
|
||||
Library.Events.Add,
|
||||
BaseGame.Events.New,
|
||||
Steam.Events.Updated,
|
||||
Steam.Events.Created,
|
||||
BaseGame.Events.NewBoxArt,
|
||||
BaseGame.Events.NewHeroArt,
|
||||
],
|
||||
async (event) => {
|
||||
console.log(event.type, event.properties, event.metadata);
|
||||
switch (event.type) {
|
||||
case "new_image.save": {
|
||||
const input = event.properties;
|
||||
const image = await Client.getImageInfo({ url: input.url, type: input.type });
|
||||
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
baseGameID: input.appID,
|
||||
position: image.position,
|
||||
fileSize: image.fileSize,
|
||||
sourceUrl: image.sourceUrl,
|
||||
dimensions: image.dimensions,
|
||||
extractedColor: image.averageColor,
|
||||
});
|
||||
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
// Save to s3 because it doesn't already exist
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
Body: image.buffer,
|
||||
...(image.format && { ContentType: `image/${image.format}` }),
|
||||
Metadata: {
|
||||
type: image.type,
|
||||
appID: input.appID,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "new_box_art_image.save": {
|
||||
const input = event.properties;
|
||||
|
||||
const image = await Client.createBoxArt(input);
|
||||
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
baseGameID: input.appID,
|
||||
position: image.position,
|
||||
fileSize: image.fileSize,
|
||||
sourceUrl: image.sourceUrl,
|
||||
dimensions: image.dimensions,
|
||||
extractedColor: image.averageColor,
|
||||
});
|
||||
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
// Save to s3 because it doesn't already exist
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
Body: image.buffer,
|
||||
...(image.format && { ContentType: `image/${image.format}` }),
|
||||
Metadata: {
|
||||
type: image.type,
|
||||
appID: input.appID,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "new_hero_art_image.save": {
|
||||
const input = event.properties;
|
||||
|
||||
const images = await Client.createHeroArt(input);
|
||||
|
||||
await Promise.all(
|
||||
images.map(async (image) => {
|
||||
await Images.create({
|
||||
type: image.type,
|
||||
imageHash: image.hash,
|
||||
baseGameID: input.appID,
|
||||
position: image.position,
|
||||
fileSize: image.fileSize,
|
||||
sourceUrl: image.sourceUrl,
|
||||
dimensions: image.dimensions,
|
||||
extractedColor: image.averageColor,
|
||||
});
|
||||
|
||||
try {
|
||||
//Check whether the image already exists
|
||||
await s3.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
})
|
||||
);
|
||||
|
||||
} catch (e) {
|
||||
// Save to s3 because it doesn't already exist
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: Resource.Storage.name,
|
||||
Key: `images/${image.hash}`,
|
||||
Body: image.buffer,
|
||||
...(image.format && { ContentType: `image/${image.format}` }),
|
||||
Metadata: {
|
||||
type: image.type,
|
||||
appID: input.appID,
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
break;
|
||||
}
|
||||
case "library.add": {
|
||||
|
||||
await Actor.provide(
|
||||
event.metadata.actor.type,
|
||||
event.metadata.actor.properties,
|
||||
async () => {
|
||||
const game = event.properties
|
||||
// First check whether the base_game exists, if not get it
|
||||
const appID = game.appID.toString();
|
||||
const exists = await BaseGame.fromID(appID);
|
||||
|
||||
if (!exists) {
|
||||
const appInfo = await Client.getAppInfo(appID);
|
||||
|
||||
await BaseGame.create({
|
||||
id: appID,
|
||||
name: appInfo.name,
|
||||
size: appInfo.size,
|
||||
slug: appInfo.slug,
|
||||
links: appInfo.links,
|
||||
score: appInfo.score,
|
||||
description: appInfo.description,
|
||||
releaseDate: appInfo.releaseDate,
|
||||
primaryGenre: appInfo.primaryGenre,
|
||||
compatibility: appInfo.compatibility,
|
||||
controllerSupport: appInfo.controllerSupport,
|
||||
})
|
||||
|
||||
const allCategories = [...appInfo.tags, ...appInfo.genres, ...appInfo.publishers, ...appInfo.developers, ...appInfo.categories, ...appInfo.franchises]
|
||||
|
||||
const uniqueCategories = Array.from(
|
||||
new Map(allCategories.map(c => [`${c.type}:${c.slug}`, c])).values()
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
uniqueCategories.map(async (cat) => {
|
||||
//Create category if it doesn't exist
|
||||
await Categories.create({
|
||||
type: cat.type, slug: cat.slug, name: cat.name
|
||||
})
|
||||
|
||||
//Create game if it doesn't exist
|
||||
await Game.create({ baseGameID: appID, categorySlug: cat.slug, categoryType: cat.type })
|
||||
})
|
||||
)
|
||||
|
||||
const imageUrls = appInfo.images
|
||||
|
||||
await Promise.all(
|
||||
ImageTypeEnum.enumValues.map(async (type) => {
|
||||
switch (type) {
|
||||
case "backdrop": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "backdrop", url: imageUrls.backdrop })
|
||||
break;
|
||||
}
|
||||
case "banner": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "banner", url: imageUrls.banner })
|
||||
break;
|
||||
}
|
||||
case "icon": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "icon", url: imageUrls.icon })
|
||||
break;
|
||||
}
|
||||
case "logo": {
|
||||
await bus.publish(Resource.Bus, BaseGame.Events.New, { appID, type: "logo", url: imageUrls.logo })
|
||||
break;
|
||||
}
|
||||
case "poster": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.New,
|
||||
{ appID, type: "poster", url: imageUrls.poster }
|
||||
)
|
||||
break;
|
||||
}
|
||||
case "heroArt": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.NewHeroArt,
|
||||
{ appID, backdropUrl: imageUrls.backdrop, screenshots: imageUrls.screenshots }
|
||||
)
|
||||
break;
|
||||
}
|
||||
case "boxArt": {
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
BaseGame.Events.NewBoxArt,
|
||||
{ appID, logoUrl: imageUrls.logo, backgroundUrl: imageUrls.backdrop }
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Add to user's library
|
||||
await Library.add({
|
||||
baseGameID: appID,
|
||||
lastPlayed: game.lastPlayed ? new Date(game.lastPlayed) : null,
|
||||
totalPlaytime: game.totalPlaytime,
|
||||
})
|
||||
})
|
||||
|
||||
break;
|
||||
}
|
||||
case "steam_account.created":
|
||||
case "steam_account.updated": {
|
||||
const userID = event.properties.userID;
|
||||
|
||||
try {
|
||||
const steamID = event.properties.steamID;
|
||||
// Get friends info
|
||||
const friends = await Client.getFriendsList(steamID);
|
||||
|
||||
const friendSteamIDs = friends.friendslist.friends.map(f => f.steamid);
|
||||
|
||||
// Steam API has a limit of requesting 100 friends at a go
|
||||
const friendChunks = chunkArray(friendSteamIDs, 100);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
friendChunks.map(async (friendIDs) => {
|
||||
const friendsInfo = await Client.getUserInfo(friendIDs)
|
||||
|
||||
return await Promise.all(
|
||||
friendsInfo.map(async (friend) => {
|
||||
const wasAdded = await Steam.create(friend);
|
||||
|
||||
if (!wasAdded) {
|
||||
console.log(`Friend ${friend.id} already exists`)
|
||||
}
|
||||
|
||||
await Friend.add({ friendSteamID: friend.id, steamID })
|
||||
|
||||
return friend.id
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
settled
|
||||
.filter(result => result.status === 'rejected')
|
||||
.forEach(result => console.warn('[putFriends] failed:', (result as PromiseRejectedResult).reason))
|
||||
|
||||
const prod = (Resource.App.stage === "production" || Resource.App.stage === "dev")
|
||||
|
||||
const friendIDs = [
|
||||
steamID,
|
||||
...(prod ? settled
|
||||
.filter(result => result.status === "fulfilled")
|
||||
.map(f => f.value)
|
||||
.flat() : [])
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
friendIDs.map(async (currentSteamID) => {
|
||||
// Get user library
|
||||
const gameLibrary = await Client.getUserLibrary(currentSteamID);
|
||||
|
||||
const queryLib = await Promise.allSettled(
|
||||
gameLibrary.response.games.map(async (game) => {
|
||||
await Actor.provide(
|
||||
"steam",
|
||||
{
|
||||
steamID: currentSteamID,
|
||||
},
|
||||
async () => {
|
||||
|
||||
await bus.publish(
|
||||
Resource.Bus,
|
||||
Library.Events.Add,
|
||||
{
|
||||
appID: game.appid,
|
||||
totalPlaytime: game.playtime_forever,
|
||||
lastPlayed: game.rtime_last_played ? new Date(game.rtime_last_played * 1000) : null,
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
queryLib
|
||||
.filter(i => i.status === "rejected")
|
||||
.forEach(e => console.warn(`[pushUserLib]: Failed to push user library to queue: ${e.reason}`))
|
||||
})
|
||||
)
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to process Steam data for user ${userID}:`, error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
93
cloud/packages/functions/src/queues/retry.ts
Normal file
93
cloud/packages/functions/src/queues/retry.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Resource } from "sst";
|
||||
import type { SQSHandler } from "aws-lambda";
|
||||
import {
|
||||
SQSClient,
|
||||
SendMessageCommand
|
||||
} from "@aws-sdk/client-sqs";
|
||||
import {
|
||||
LambdaClient,
|
||||
InvokeCommand,
|
||||
GetFunctionCommand,
|
||||
ResourceNotFoundException,
|
||||
} from "@aws-sdk/client-lambda";
|
||||
|
||||
const lambda = new LambdaClient({});
|
||||
lambda.middlewareStack.remove("recursionDetectionMiddleware");
|
||||
const sqs = new SQSClient({});
|
||||
sqs.middlewareStack.remove("recursionDetectionMiddleware");
|
||||
|
||||
export const handler: SQSHandler = async (evt) => {
|
||||
for (const record of evt.Records) {
|
||||
const parsed = JSON.parse(record.body);
|
||||
console.log("body", parsed);
|
||||
const functionName = parsed.requestContext.functionArn
|
||||
.replace(":$LATEST", "")
|
||||
.split(":")
|
||||
.pop();
|
||||
if (parsed.responsePayload) {
|
||||
const attempt = (parsed.requestPayload.attempts || 0) + 1;
|
||||
|
||||
const info = await lambda.send(
|
||||
new GetFunctionCommand({
|
||||
FunctionName: functionName,
|
||||
}),
|
||||
);
|
||||
const max =
|
||||
Number.parseInt(
|
||||
info.Configuration?.Environment?.Variables?.RETRIES || "",
|
||||
) || 0;
|
||||
console.log("max retries", max);
|
||||
if (attempt > max) {
|
||||
console.log(`giving up after ${attempt} retries`);
|
||||
// send to dlq
|
||||
await sqs.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: Resource.Dlq.url,
|
||||
MessageBody: JSON.stringify({
|
||||
requestPayload: parsed.requestPayload,
|
||||
requestContext: parsed.requestContext,
|
||||
responsePayload: parsed.responsePayload,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const seconds = Math.min(Math.pow(2, attempt), 900);
|
||||
console.log(
|
||||
"delaying retry by ",
|
||||
seconds,
|
||||
"seconds for attempt",
|
||||
attempt,
|
||||
);
|
||||
parsed.requestPayload.attempts = attempt;
|
||||
await sqs.send(
|
||||
new SendMessageCommand({
|
||||
QueueUrl: Resource.RetryQueue.url,
|
||||
DelaySeconds: seconds,
|
||||
MessageBody: JSON.stringify({
|
||||
requestPayload: parsed.requestPayload,
|
||||
requestContext: parsed.requestContext,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed.responsePayload) {
|
||||
console.log("triggering function");
|
||||
try {
|
||||
await lambda.send(
|
||||
new InvokeCommand({
|
||||
InvocationType: "Event",
|
||||
Payload: Buffer.from(JSON.stringify(parsed.requestPayload)),
|
||||
FunctionName: functionName,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof ResourceNotFoundException) {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
cloud/packages/functions/src/realtime/authorizer.ts
Normal file
40
cloud/packages/functions/src/realtime/authorizer.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Resource } from "sst";
|
||||
import { subjects } from "../subjects";
|
||||
import { realtime } from "sst/aws/realtime";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
|
||||
const client = createClient({
|
||||
clientID: "realtime",
|
||||
issuer: Resource.Auth.url
|
||||
});
|
||||
|
||||
export const handler = realtime.authorizer(async (token) => {
|
||||
|
||||
console.log("token", token)
|
||||
|
||||
const result = await client.verify(subjects, token);
|
||||
|
||||
if (result.err) {
|
||||
console.log("error", result.err)
|
||||
return {
|
||||
subscribe: [],
|
||||
publish: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (result.subject.type == "machine") {
|
||||
console.log("machineID", result.subject.properties.machineID)
|
||||
console.log("fingerprint", result.subject.properties.fingerprint)
|
||||
|
||||
return {
|
||||
//It can publish and listen to other instances under this machineID
|
||||
publish: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.fingerprint}/*`],
|
||||
subscribe: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.fingerprint}/*`],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
publish: [],
|
||||
subscribe: [],
|
||||
};
|
||||
});
|
||||
64
cloud/packages/functions/src/realtime/create.ts
Normal file
64
cloud/packages/functions/src/realtime/create.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs";
|
||||
const client = new ECSClient()
|
||||
|
||||
export const handler = async (event: any) => {
|
||||
console.log("event", event)
|
||||
const clusterArn = process.env.ECS_CLUSTER
|
||||
const taskDefinitionArn = process.env.TASK_DEFINITION
|
||||
const authFingerprintKey = process.env.AUTH_FINGERPRINT
|
||||
|
||||
try {
|
||||
|
||||
const runResponse = await client.send(new RunTaskCommand({
|
||||
taskDefinition: taskDefinitionArn,
|
||||
cluster: clusterArn,
|
||||
count: 1,
|
||||
launchType: "EC2",
|
||||
overrides: {
|
||||
containerOverrides: [
|
||||
{
|
||||
name: "nestri",
|
||||
environment: [
|
||||
{
|
||||
name: "AUTH_FINGERPRINT_KEY",
|
||||
value: authFingerprintKey
|
||||
},
|
||||
{
|
||||
name: "NESTRI_ROOM",
|
||||
value: "testing-right-now"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
// Check if tasks were started
|
||||
if (!runResponse.tasks || runResponse.tasks.length === 0) {
|
||||
throw new Error("No tasks were started");
|
||||
}
|
||||
|
||||
// Extract task details
|
||||
const task = runResponse.tasks[0];
|
||||
const taskArn = task?.taskArn!;
|
||||
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
|
||||
const taskStatus = task?.lastStatus!;
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
status: "sent",
|
||||
taskId: taskId,
|
||||
taskStatus: taskStatus,
|
||||
taskArn: taskArn
|
||||
}, null, 2),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error starting task:", err);
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({ error: "Failed to start task" }, null, 2),
|
||||
};
|
||||
|
||||
}
|
||||
};
|
||||
4
cloud/packages/functions/src/realtime/subscriber.ts
Normal file
4
cloud/packages/functions/src/realtime/subscriber.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const handler = async (event: any) => {
|
||||
console.log(event);
|
||||
return "ok";
|
||||
};
|
||||
9
cloud/packages/functions/src/subjects.ts
Normal file
9
cloud/packages/functions/src/subjects.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod"
|
||||
import { createSubjects } from "@openauthjs/openauth/subject"
|
||||
|
||||
export const subjects = createSubjects({
|
||||
user: z.object({
|
||||
email: z.string(),
|
||||
userID: z.string(),
|
||||
})
|
||||
})
|
||||
30
cloud/packages/functions/src/utils/patch-logger.ts
Normal file
30
cloud/packages/functions/src/utils/patch-logger.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { format } from "util";
|
||||
|
||||
/**
|
||||
* Overrides the default Node.js console logging methods with a custom logger.
|
||||
*
|
||||
* This function patches console.log, console.warn, console.error, console.trace, and console.debug so that each logs
|
||||
* messages prefixed with a log level. The messages are formatted using Node.js formatting conventions, with newline
|
||||
* characters replaced by carriage returns, and are written directly to standard output.
|
||||
*
|
||||
* @example
|
||||
* patchLogger();
|
||||
* console.info("Server started on port %d", 3000);
|
||||
*/
|
||||
export function patchLogger() {
|
||||
const log =
|
||||
(level: "INFO" | "WARN" | "TRACE" | "DEBUG" | "ERROR") =>
|
||||
(msg: string, ...rest: any[]) => {
|
||||
let formattedMessage = format(msg, ...rest);
|
||||
// Split by newlines, prefix each line with the level, and join back
|
||||
const lines = formattedMessage.split('\n');
|
||||
const prefixedLines = lines.map(line => `${level}\t${line}`);
|
||||
const output = prefixedLines.join('\n');
|
||||
process.stdout.write(output + '\n');
|
||||
};
|
||||
console.log = log("INFO");
|
||||
console.warn = log("WARN");
|
||||
console.error = log("ERROR");
|
||||
console.trace = log("TRACE");
|
||||
console.debug = log("DEBUG");
|
||||
}
|
||||
Reference in New Issue
Block a user