fix: Move more directories

This commit is contained in:
Wanjohi
2025-09-06 16:50:44 +03:00
parent 1c1c73910b
commit 9818165a90
248 changed files with 9 additions and 9566 deletions

View 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)
)
}

View 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
})
}
)
}

View 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
})
}
)
}

View 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);

View 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;

View 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;
}

View 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)
}
)
}

View 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);
};

View 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.",
},
};

View 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 };

View File

@@ -0,0 +1,4 @@
export * from "./auth";
export * from "./error";
export * from "./result";
export * from "./validator";

View 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 }));
}

View 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);
};

View 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",
},
})
}

View 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",
},
})
}

View File

@@ -0,0 +1,3 @@
export * from "./discord"
export * from "./github"
export * from "./password"

View 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"))
},
}
}

View 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);

View 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>
)
}

View 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;
}
}
`
}

View File

@@ -0,0 +1,2 @@
export * from "./password"
export * from "./select"

View 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
},
},
})
})
},
}
}

View 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
}

View 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>
)
}

View 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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,2 @@
export * from "./discord"
export * from "./github"

View 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;
}
}
},
);

View 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;
}
}
}
}

View 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: [],
};
});

View 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),
};
}
};

View File

@@ -0,0 +1,4 @@
export const handler = async (event: any) => {
console.log(event);
return "ok";
};

View 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(),
})
})

View 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");
}