вложения, канальчики, бим-бим + бам-бам

This commit is contained in:
2026-04-25 00:51:12 +06:00
parent 0b75148a3f
commit ad477ee813
61 changed files with 14636 additions and 375 deletions

View File

@@ -16,7 +16,7 @@ export const useAuth = createGlobalState(() => {
async function login(username: string, password: string): Promise<void> {
try {
const result = await chadApi<Me>('/login', {
const result = await chadApi<Me>('/auth/login', {
method: 'POST',
body: {
username,
@@ -33,7 +33,7 @@ export const useAuth = createGlobalState(() => {
async function register(username: string, password: string): Promise<void> {
try {
const result = await chadApi<Me>('/register', {
const result = await chadApi<Me>('/auth/register', {
method: 'POST',
body: {
username,
@@ -50,7 +50,7 @@ export const useAuth = createGlobalState(() => {
async function logout(): Promise<void> {
try {
await chadApi('/logout', { method: 'POST' })
await chadApi('/auth/logout', { method: 'POST' })
setMe(undefined)

View File

@@ -1,22 +1,25 @@
import chadApi from '#shared/chad-api'
import { createGlobalState } from '@vueuse/core'
export interface ChatClientMessage {
text: string
replyTo?: {
messageId: string
}
// replyTo?: {
// messageId: string
// }
}
export interface ChatMessage {
id: string
sender: string
senderId: string
text: string
createdAt: string
replyTo?: {
messageId: string
sender: string
text: string
}
updatedAt: string
attachments: string[]
// replyTo?: {
// messageId: string
// sender: string
// text: string
// }
}
export const useChat = createGlobalState(() => {
@@ -41,16 +44,16 @@ export const useChat = createGlobalState(() => {
})
}, { immediate: true, flush: 'sync' })
function sendMessage(message: ChatClientMessage) {
if (!signaling.connected.value)
return
async function sendMessage(message: ChatClientMessage) {
message.text = message.text.trim()
if (!message.text.length)
return
signaling.socket.value!.emit('chat:message', message)
await chadApi<ChatMessage>('/chat/send', {
method: 'POST',
body: message,
})
}
return {

View File

@@ -42,7 +42,7 @@ export const usePreferences = createGlobalState(() => {
async ([toggleInputHotkey, toggleOutputHotkey]) => {
try {
await chadApi(
'/preferences',
'/user/preferences',
{
method: 'PATCH',
body: {

View File

@@ -5,7 +5,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
if (!me.value) {
try {
setMe(await chadApi('/me', { method: 'GET' }))
setMe(await chadApi('/auth/me', { method: 'GET' }))
if (to.meta.auth !== false)
return navigateTo({ name: 'Index' })

View File

@@ -13,7 +13,7 @@ export default defineNuxtRouteMiddleware(async () => {
return
try {
const preferences = await chadApi<SyncedPreferences>('/preferences', { method: 'GET' })
const preferences = await chadApi<SyncedPreferences>('/user/preferences', { method: 'GET' })
if (!preferences)
return

View File

@@ -10,25 +10,35 @@
:key="message.id"
class="w-fit max-w-[60%]"
:class="{
'ml-auto': message.sender === me?.username,
'ml-auto': message.senderId === me?.userId,
}"
>
<p
v-if="message.sender !== me?.username"
v-if="message.senderId !== me?.userId"
class="text-sm text-muted-color mb-1"
>
{{ message.sender }}
{{ message.senderId }}
</p>
<div
class="px-3 py-2 rounded-lg"
:class="{
'bg-surface-800': message.sender !== me?.username,
'bg-surface-700': message.sender === me?.username,
'bg-surface-800 rounded-tl': message.senderId !== me?.userId,
'bg-surface-700 rounded-tr': message.senderId === me?.userId,
}"
>
<p class="[&>a]:break-all" @click="handleMessageClick" v-html="parseMessageText(message.text)" />
<div v-if="message.attachments.length > 0" class="flex flex-col gap-2 mt-2">
<img
v-for="attachmentId in message.attachments"
:key="attachmentId"
class="rounded-xl max-w-60"
:src="`http://localhost:4000/chad/attachment/${attachmentId}`"
:alt="attachmentId"
>
</div>
<p class="mt-1 text-right text-sm text-muted-color" :title="formatDate(message.createdAt, 'dd.MM.yyyy, HH:mm')">
{{ formatDate(message.createdAt) }}
</p>

View File

@@ -52,7 +52,7 @@ async function save() {
saving.value = true
const updatedMe = await chadApi('/profile', {
const updatedMe = await chadApi('/user/profile', {
method: 'PATCH',
body: {
displayName: displayName.value,

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Chad",
"version": "0.3.0-rc.2",
"version": "0.3.0-rc.3",
"identifier": "xyz.koptilnya.chad",
"build": {
"frontendDist": "../.output/public",

677
server/Api.ts Normal file
View File

@@ -0,0 +1,677 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (
securityData: SecurityDataType | null,
) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown>
extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = "";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) =>
fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
};
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter(
(key) => "undefined" !== typeof query[key],
);
return keys
.map((key) =>
Array.isArray(query[key])
? this.addArrayQueryParam(query, key)
: this.addQueryParam(query, key),
)
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.JsonApi]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.Text]: (input: any) =>
input !== null && typeof input !== "string"
? JSON.stringify(input)
: input,
[ContentType.FormData]: (input: any) => {
if (input instanceof FormData) {
return input;
}
return Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData());
},
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected mergeRequestParams(
params1: RequestParams,
params2?: RequestParams,
): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (
cancelToken: CancelToken,
): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
public request = async <T = any, E = any>({
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
const secureParams =
((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
this.securityWorker &&
(await this.securityWorker(this.securityData))) ||
{};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
return this.customFetch(
`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`,
{
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData
? { "Content-Type": type }
: {}),
},
signal:
(cancelToken
? this.createAbortSignal(cancelToken)
: requestParams.signal) || null,
body:
typeof body === "undefined" || body === null
? null
: payloadFormatter(body),
},
).then(async (response) => {
const r = response as HttpResponse<T, E>;
r.data = null as unknown as T;
r.error = null as unknown as E;
const responseToParse = responseFormat ? response.clone() : response;
const data = !responseFormat
? r
: await responseToParse[responseFormat]()
.then((data) => {
if (r.ok) {
r.data = data;
} else {
r.error = data;
}
return r;
})
.catch((e) => {
r.error = e;
return r;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
if (!response.ok) throw data;
return data;
});
};
}
/**
* @title Chad API
* @version 1.0.0
*/
export class Api<
SecurityDataType extends unknown,
> extends HttpClient<SecurityDataType> {
chad = {
/**
* @description Pass file to multipart/form-data
*
* @tags Attachment
* @name AttachmentUpload
* @summary Upload attachment
* @request POST:/chad/attachment/upload
*/
attachmentUpload: (params: RequestParams = {}) =>
this.request<
string,
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/attachment/upload`,
method: "POST",
format: "json",
...params,
}),
/**
* No description
*
* @tags Attachment
* @name AttachmentGet
* @summary Get attachment
* @request GET:/chad/attachment/{id}
*/
attachmentGet: (id: string, params: RequestParams = {}) =>
this.request<
any,
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/attachment/${id}`,
method: "GET",
format: "json",
...params,
}),
/**
* No description
*
* @tags Auth
* @name AuthRegister
* @summary Register
* @request POST:/chad/auth/register
*/
authRegister: (
data: {
/** @minLength 1 */
username: string;
/** @minLength 6 */
password: string;
},
params: RequestParams = {},
) =>
this.request<
{
id: string;
username: string;
displayName: string;
/** @format date-time */
createdAt: string;
},
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/auth/register`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Auth
* @name AuthLogin
* @summary Login
* @request POST:/chad/auth/login
*/
authLogin: (
data: {
/** @minLength 1 */
username: string;
/** @minLength 1 */
password: string;
},
params: RequestParams = {},
) =>
this.request<
{
id: string;
username: string;
displayName: string;
/** @format date-time */
createdAt: string;
},
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/auth/login`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Auth
* @name AuthMe
* @summary Me
* @request GET:/chad/auth/me
*/
authMe: (params: RequestParams = {}) =>
this.request<
{
id: string;
username: string;
displayName: string;
/** @format date-time */
createdAt: string;
},
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/auth/me`,
method: "GET",
format: "json",
...params,
}),
/**
* No description
*
* @tags Auth
* @name AuthLogout
* @summary Logout
* @request POST:/chad/auth/logout
*/
authLogout: (params: RequestParams = {}) =>
this.request<
any,
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/auth/logout`,
method: "POST",
...params,
}),
/**
* No description
*
* @tags Chat
* @name ChatSend
* @summary Send message
* @request POST:/chad/chat/send
*/
chatSend: (
data: {
/** @minLength 1 */
text: string;
},
params: RequestParams = {},
) =>
this.request<
{
/** @format uuid */
id: string;
/** @format uuid */
senderId: string;
/** @minLength 1 */
text: string;
/** @format date-time */
createdAt: string;
/** @format date-time */
updatedAt: string;
},
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/chat/send`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* No description
*
* @tags Chat
* @name ChatMessages
* @summary Get messages
* @request GET:/chad/chat
*/
chatMessages: (
query: {
/**
* Cursor to message
* @format uuid
*/
cursor?: string;
/**
* @min 1
* @max 100
* @default 2
*/
limit: number;
},
params: RequestParams = {},
) =>
this.request<
{
messages: {
/** @format uuid */
id: string;
/** @format uuid */
senderId: string;
/** @minLength 1 */
text: string;
/** @format date-time */
createdAt: string;
/** @format date-time */
updatedAt: string;
}[];
/**
* Cursor to last message
* @format uuid
*/
nextCursor?: string;
},
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/chat`,
method: "GET",
query: query,
format: "json",
...params,
}),
/**
* No description
*
* @tags User
* @name UserGet
* @summary Get user
* @request GET:/chad/user
*/
userGet: (
query?: {
username?: string;
},
params: RequestParams = {},
) =>
this.request<
{
id: string;
username: string;
displayName: string;
/** @format date-time */
createdAt: string;
},
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/user`,
method: "GET",
query: query,
format: "json",
...params,
}),
/**
* No description
*
* @tags User
* @name UserGetPreferences
* @summary Get preferences
* @request GET:/chad/user/preferences
*/
userGetPreferences: (params: RequestParams = {}) =>
this.request<
{
toggleInputHotkey: string;
toggleOutputHotkey: string;
},
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/user/preferences`,
method: "GET",
format: "json",
...params,
}),
/**
* No description
*
* @tags User
* @name UserUpdatePreferences
* @summary Update preferences
* @request PATCH:/chad/user/preferences
*/
userUpdatePreferences: (
data: {
toggleInputHotkey?: string;
toggleOutputHotkey?: string;
},
params: RequestParams = {},
) =>
this.request<
any,
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/user/preferences`,
method: "PATCH",
body: data,
type: ContentType.Json,
...params,
}),
/**
* No description
*
* @tags User
* @name UserUpdateProfile
* @summary Update profile
* @request PATCH:/chad/profile
*/
userUpdateProfile: (
data: {
displayName: string;
},
params: RequestParams = {},
) =>
this.request<
{
id: string;
username: string;
displayName: string;
/** @format date-time */
createdAt: string;
},
{
statusCode: number;
error: string;
message: string;
}
>({
path: `/chad/profile`,
method: "PATCH",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
};
}

View File

@@ -1,34 +0,0 @@
import { PrismaAdapter } from '@lucia-auth/adapter-prisma'
import { Lucia } from 'lucia'
import prisma from '../prisma/client.ts'
declare module 'lucia' {
interface Register {
Lucia: typeof Lucia
UserId: string
DatabaseUserAttributes: DatabaseUserAttributes
}
}
interface DatabaseUserAttributes {
id: string
displayName: string
username: string
}
export const auth = new Lucia(new PrismaAdapter(prisma.session, prisma.user), {
sessionCookie: {
attributes: {
sameSite: 'none',
},
},
getUserAttributes: ({ id, displayName, username }) => {
return {
id,
displayName,
username,
}
},
})
export type Auth = typeof auth

6
server/nodemon.json Normal file
View File

@@ -0,0 +1,6 @@
{
"watch": ["."],
"ext": ".ts,.js",
"ignore": ["node_modules", ".idea", "dist"],
"exec": "ts-node --transpile-only server.ts"
}

View File

@@ -1,6 +1,7 @@
{
"name": "server",
"scripts": {
"dev": "nodemon",
"start": "ts-node --transpile-only server.ts",
"db:deploy": "npx prisma migrate deploy && npx prisma generate"
},
@@ -11,8 +12,13 @@
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/multipart": "^10.0.0",
"@fastify/sensible": "^6.0.4",
"@fastify/swagger": "^9.7.0",
"@fastify/type-provider-typebox": "^6.1.0",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@prisma/client": "^6.17.0",
"@prisma/adapter-better-sqlite3": "^7.7.0",
"@prisma/client": "7",
"@scalar/fastify-api-reference": "^1.52.3",
"bcrypt": "^6.0.0",
"consola": "^3.4.2",
"dotenv": "^17.2.3",
@@ -20,8 +26,9 @@
"fastify-plugin": "^5.1.0",
"lucia": "^3.2.2",
"mediasoup": "^3.19.3",
"prisma": "^6.17.0",
"prisma": "7",
"socket.io": "^4.8.1",
"typebox": "^1.1.27",
"uuid": "^13.0.0",
"ws": "^8.18.3",
"zod": "^4.1.12"
@@ -29,8 +36,10 @@
"devDependencies": {
"@antfu/eslint-config": "^5.4.1",
"@types/bcrypt": "^6",
"@types/better-sqlite3": "^7.6.13",
"@types/ws": "^8",
"eslint": "^9.36.0",
"nodemon": "^3.1.14",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},

View File

@@ -1,40 +1,76 @@
import type { Session, User } from 'lucia'
import type { Session } from 'lucia'
import { PrismaAdapter } from '@lucia-auth/adapter-prisma'
import fp from 'fastify-plugin'
import { auth } from '../auth/lucia.ts'
import { Lucia } from 'lucia'
interface DatabaseUserAttributes {
id: string
displayName: string
username: string
createdAt: Date
}
declare module 'lucia' {
interface Register {
Lucia: Lucia
UserId: string
DatabaseUserAttributes: DatabaseUserAttributes
}
}
declare module 'fastify' {
interface FastifyInstance {
lucia: Lucia
}
interface FastifyRequest {
user: User | null
user: DatabaseUserAttributes | null
session: Session | null
}
interface FastifyContextConfig {
skipAuth: boolean
}
}
export default fp(async (fastify) => {
const lucia = new Lucia<any, DatabaseUserAttributes>(new PrismaAdapter(fastify.prisma.session, fastify.prisma.user), {
sessionCookie: {
attributes: {
sameSite: 'none',
},
},
getUserAttributes: (attrs) => {
return attrs
},
})
fastify.decorate('lucia', lucia)
fastify.decorateRequest('user', null)
fastify.decorateRequest('session', null)
fastify.addHook('preHandler', async (req, reply) => {
fastify.addHook('onRequest', async (req, reply) => {
try {
const sessionId = auth.readSessionCookie(req.headers.cookie ?? '')
const sessionId = lucia.readSessionCookie(req.headers.cookie ?? '')
if (!sessionId)
return
const { session, user } = await auth.validateSession(sessionId ?? '')
const { session, user } = await lucia.validateSession(sessionId ?? '')
if (session && session.fresh) {
const cookie = auth.createSessionCookie(session.id)
const cookie = lucia.createSessionCookie(session.id)
reply.setCookie(cookie.name, cookie.value, cookie.attributes)
}
if (!session) {
const blank = auth.createBlankSessionCookie()
const blank = lucia.createBlankSessionCookie()
reply.setCookie(blank.name, blank.value, blank.attributes)
}
req.user = user
req.user = user as DatabaseUserAttributes
req.session = session
}
catch {
@@ -42,4 +78,21 @@ export default fp(async (fastify) => {
req.session = null
}
})
fastify.addHook('onRequest', (req, reply, done) => {
if (req.is404 || req.routeOptions.schema?.hide || req.routeOptions.config.skipAuth) {
done()
return
}
if (!req.user) {
reply.unauthorized()
}
done()
})
}, {
name: 'auth',
dependencies: ['prisma'],
})

View File

@@ -0,0 +1,27 @@
import type { FastifyPluginAsync } from 'fastify'
import type { Type } from 'typebox'
import type { UserSchema } from '../schemas/auth.ts'
import type { ChatMessageSchema } from '../schemas/chat.ts'
import { EventEmitter } from 'node:events'
import fp from 'fastify-plugin'
declare module 'fastify' {
interface FastifyInstance {
bus: EventEmitter
}
}
interface EventMap {
'chat:new-message': [Type.Static<typeof ChatMessageSchema>]
'user:profile-updated': [Type.Static<typeof UserSchema>]
}
const plugin: FastifyPluginAsync = fp(async (fastify) => {
const bus = new EventEmitter<EventMap>()
fastify.decorate('bus', bus)
}, {
name: 'event-bus',
})
export default plugin

32
server/plugins/prisma.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { FastifyPluginAsync } from 'fastify'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import fp from 'fastify-plugin'
import { PrismaClient } from '../prisma/generated-client/client.ts'
declare module 'fastify' {
interface FastifyInstance {
prisma: PrismaClient
}
}
const plugin: FastifyPluginAsync = fp(async (fastify) => {
const prisma = new PrismaClient({
log: ['query', 'error', 'warn'],
adapter: new PrismaBetterSqlite3({
url: process.env.DATABASE_URL!,
}),
})
await prisma.$connect()
fastify.log.info('Testing DB Connection. OK')
fastify.decorate('prisma', prisma)
fastify.addHook('onClose', async (fastify) => {
await fastify.prisma.$disconnect()
})
}, {
name: 'prisma',
})
export default plugin

View File

@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify'
import type { ServerOptions } from 'socket.io'
import type { MessageSelect } from '../prisma/generated-client/models/Message.ts'
import fp from 'fastify-plugin'
import { Server } from 'socket.io'
import registerChatSocket from '../socket/chat.ts'
@@ -23,9 +24,11 @@ export default fp<Partial<ServerOptions>>(
await fastify.io.close()
})
fastify.ready(async () => {
await registerWebrtcSocket(fastify.io, fastify.mediasoupRouter)
await registerChatSocket(fastify.io)
await registerWebrtcSocket(fastify.io, fastify.mediasoupRouter, fastify.prisma)
await registerChatSocket(fastify.io)
fastify.bus.on('chat:new-message', async (message: MessageSelect) => {
fastify.io.emit('chat:new-message', message)
})
},
{ name: 'socket-io', dependencies: ['mediasoup-worker', 'mediasoup-router'] },

13
server/prisma.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig, env } from 'prisma/config'
import 'dotenv/config'
export default defineConfig({
schema: './prisma/schema.prisma',
migrations: {
path: './prisma/migrations',
seed: 'ts-node ./prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
},
})

View File

@@ -1,7 +0,0 @@
import { PrismaClient } from '@prisma/client'
const client = new PrismaClient({
log: ['query', 'error', 'warn'],
})
export default client

View File

@@ -0,0 +1,54 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser.ts'
export { Prisma }
export * as $Enums from './enums.ts'
export * from './enums.ts';
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Session
*
*/
export type Session = Prisma.SessionModel
/**
* Model UserPreferences
*
*/
export type UserPreferences = Prisma.UserPreferencesModel
/**
* Model Attachment
*
*/
export type Attachment = Prisma.AttachmentModel
/**
* Model Message
*
*/
export type Message = Prisma.MessageModel
/**
* Model MessageAttachment
*
*/
export type MessageAttachment = Prisma.MessageAttachmentModel
/**
* Model Channel
*
*/
export type Channel = Prisma.ChannelModel

View File

@@ -0,0 +1,78 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import * as $Class from "./internal/class.ts"
import * as Prisma from "./internal/prismaNamespace.ts"
export * as $Enums from './enums.ts'
export * from "./enums.ts"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model Session
*
*/
export type Session = Prisma.SessionModel
/**
* Model UserPreferences
*
*/
export type UserPreferences = Prisma.UserPreferencesModel
/**
* Model Attachment
*
*/
export type Attachment = Prisma.AttachmentModel
/**
* Model Message
*
*/
export type Message = Prisma.MessageModel
/**
* Model MessageAttachment
*
*/
export type MessageAttachment = Prisma.MessageAttachmentModel
/**
* Model Channel
*
*/
export type Channel = Prisma.ChannelModel

View File

@@ -0,0 +1,298 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.ts"
import type * as Prisma from "./internal/prismaNamespace.ts"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}

View File

@@ -0,0 +1,15 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
// This file is empty because there are no enums in the schema.
export {}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models.ts'
export type * from './prismaNamespace.ts'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
Session: 'Session',
UserPreferences: 'UserPreferences',
Attachment: 'Attachment',
Message: 'Message',
MessageAttachment: 'MessageAttachment',
Channel: 'Channel'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const UserScalarFieldEnum = {
id: 'id',
username: 'username',
password: 'password',
displayName: 'displayName',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const SessionScalarFieldEnum = {
id: 'id',
userId: 'userId',
expiresAt: 'expiresAt'
} as const
export type SessionScalarFieldEnum = (typeof SessionScalarFieldEnum)[keyof typeof SessionScalarFieldEnum]
export const UserPreferencesScalarFieldEnum = {
userId: 'userId',
toggleInputHotkey: 'toggleInputHotkey',
toggleOutputHotkey: 'toggleOutputHotkey'
} as const
export type UserPreferencesScalarFieldEnum = (typeof UserPreferencesScalarFieldEnum)[keyof typeof UserPreferencesScalarFieldEnum]
export const AttachmentScalarFieldEnum = {
id: 'id',
name: 'name',
mimetype: 'mimetype',
size: 'size',
createdAt: 'createdAt'
} as const
export type AttachmentScalarFieldEnum = (typeof AttachmentScalarFieldEnum)[keyof typeof AttachmentScalarFieldEnum]
export const MessageScalarFieldEnum = {
id: 'id',
text: 'text',
senderId: 'senderId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type MessageScalarFieldEnum = (typeof MessageScalarFieldEnum)[keyof typeof MessageScalarFieldEnum]
export const MessageAttachmentScalarFieldEnum = {
messageId: 'messageId',
attachmentId: 'attachmentId'
} as const
export type MessageAttachmentScalarFieldEnum = (typeof MessageAttachmentScalarFieldEnum)[keyof typeof MessageAttachmentScalarFieldEnum]
export const ChannelScalarFieldEnum = {
id: 'id',
name: 'name',
persistent: 'persistent'
} as const
export type ChannelScalarFieldEnum = (typeof ChannelScalarFieldEnum)[keyof typeof ChannelScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]

View File

@@ -0,0 +1,18 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/User.ts'
export type * from './models/Session.ts'
export type * from './models/UserPreferences.ts'
export type * from './models/Attachment.ts'
export type * from './models/Message.ts'
export type * from './models/MessageAttachment.ts'
export type * from './models/Channel.ts'
export type * from './commonInputTypes.ts'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
/*
Warnings:
- You are about to drop the column `volumes` on the `UserPreferences` table. All the data in the column will be lost.
*/
-- CreateTable
CREATE TABLE "Attachment" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"mimetype" TEXT NOT NULL,
"size" INTEGER NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_UserPreferences" (
"userId" TEXT NOT NULL PRIMARY KEY,
"toggleInputHotkey" TEXT DEFAULT '',
"toggleOutputHotkey" TEXT DEFAULT '',
CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_UserPreferences" ("toggleInputHotkey", "toggleOutputHotkey", "userId") SELECT "toggleInputHotkey", "toggleOutputHotkey", "userId" FROM "UserPreferences";
DROP TABLE "UserPreferences";
ALTER TABLE "new_UserPreferences" RENAME TO "UserPreferences";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,15 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Attachment" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"mimetype" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO "new_Attachment" ("id", "mimetype", "name", "size") SELECT "id", "mimetype", "name", "size" FROM "Attachment";
DROP TABLE "Attachment";
ALTER TABLE "new_Attachment" RENAME TO "Attachment";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,27 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "User_id_fkey" FOREIGN KEY ("id") REFERENCES "UserPreferences" ("userId") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_User" ("createdAt", "displayName", "id", "password", "updatedAt", "username") SELECT "createdAt", "displayName", "id", "password", "updatedAt", "username" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE TABLE "new_UserPreferences" (
"userId" TEXT NOT NULL PRIMARY KEY,
"toggleInputHotkey" TEXT DEFAULT '',
"toggleOutputHotkey" TEXT DEFAULT ''
);
INSERT INTO "new_UserPreferences" ("toggleInputHotkey", "toggleOutputHotkey", "userId") SELECT "toggleInputHotkey", "toggleOutputHotkey", "userId" FROM "UserPreferences";
DROP TABLE "UserPreferences";
ALTER TABLE "new_UserPreferences" RENAME TO "UserPreferences";
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,27 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_User" ("createdAt", "displayName", "id", "password", "updatedAt", "username") SELECT "createdAt", "displayName", "id", "password", "updatedAt", "username" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE TABLE "new_UserPreferences" (
"userId" TEXT NOT NULL PRIMARY KEY,
"toggleInputHotkey" TEXT DEFAULT '',
"toggleOutputHotkey" TEXT DEFAULT '',
CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_UserPreferences" ("toggleInputHotkey", "toggleOutputHotkey", "userId") SELECT "toggleInputHotkey", "toggleOutputHotkey", "userId" FROM "UserPreferences";
DROP TABLE "UserPreferences";
ALTER TABLE "new_UserPreferences" RENAME TO "UserPreferences";
CREATE UNIQUE INDEX "UserPreferences_userId_key" ON "UserPreferences"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "Message" (
"id" TEXT NOT NULL PRIMARY KEY,
"text" TEXT NOT NULL,
"senderId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "MessageAttachment" (
"messageId" TEXT NOT NULL,
"attachmentId" TEXT NOT NULL,
PRIMARY KEY ("messageId", "attachmentId"),
CONSTRAINT "MessageAttachment_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "MessageAttachment_attachmentId_fkey" FOREIGN KEY ("attachmentId") REFERENCES "Attachment" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

View File

@@ -0,0 +1,6 @@
-- CreateTable
CREATE TABLE "Channel" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"persistent" BOOLEAN NOT NULL
);

View File

@@ -1,11 +1,10 @@
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
// output = "./generated/client"
provider = "prisma-client"
output = "./generated-client"
}
model User {
@@ -18,6 +17,7 @@ model User {
Session Session[]
UserPreferences UserPreferences?
Messages Message[]
}
model Session {
@@ -25,16 +25,52 @@ model Session {
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
@@index([userId])
}
model UserPreferences {
userId String @id
userId String @id @unique
toggleInputHotkey String? @default("")
toggleOutputHotkey String? @default("")
volumes Json? @default("{}")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
}
model Attachment {
id String @id @default(uuid())
name String
mimetype String
size Int
createdAt DateTime @default(now())
message MessageAttachment[]
}
model Message {
id String @id @default(uuid())
text String
senderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sender User? @relation(references: [id], fields: [senderId], onDelete: SetNull)
attachments MessageAttachment[]
}
model MessageAttachment {
messageId String
attachmentId String
message Message @relation(fields: [messageId], references: [id])
attachment Attachment @relation(fields: [attachmentId], references: [id])
@@id([messageId, attachmentId])
}
model Channel {
id String @id @default(uuid())
name String
persistent Boolean
}

33
server/prisma/seed.ts Normal file
View File

@@ -0,0 +1,33 @@
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { PrismaClient } from './generated-client/client.ts'
import 'dotenv/config'
const prisma = new PrismaClient({
adapter: new PrismaBetterSqlite3({
url: process.env.DATABASE_URL!,
}),
})
async function main() {
const _now = new Date()
await prisma.channel.upsert({
where: { id: 'default' },
create: {
id: 'default',
name: 'Default channel',
persistent: true,
},
update: {
persistent: true,
},
})
}
main()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

View File

@@ -0,0 +1,96 @@
import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { Type } from 'typebox'
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
const uploadDir = path.join(process.cwd(), 'uploads')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
fastify.post(
'/attachment/upload',
{
schema: {
summary: 'Upload attachment',
tags: ['Attachment'],
operationId: 'attachment.upload',
description: 'Pass file to multipart/form-data',
response: {
200: Type.String({ format: 'uuid', description: 'Attachment UUID' }),
},
},
},
async (req, reply) => {
const data = await req.file()
if (!data) {
return reply.notAcceptable()
}
const meta = await fastify.prisma.attachment.create({
data: {
name: data.filename,
mimetype: data.mimetype,
size: 0,
},
})
if (!meta) {
return reply.notAcceptable()
}
const filePath = path.join(process.cwd(), 'uploads', meta.id)
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(filePath)
data.file.pipe(writeStream)
data.file.on('end', resolve)
data.file.on('error', reject)
})
return meta.id
},
)
fastify.get(
'/attachment/:id',
{
schema: {
summary: 'Get attachment',
tags: ['Attachment'],
operationId: 'attachment.get',
params: Type.Object({
id: Type.String({ format: 'uuid' }),
}),
response: {
200: Type.Any({ description: 'Attachment content' }),
},
},
config: {
skipAuth: true,
},
},
async (req, reply) => {
const meta = await fastify.prisma.attachment.findFirst({
where: { id: req.params.id },
})
if (!meta) {
return reply.notFound('Attachment not found')
}
const filePath = path.join(process.cwd(), 'uploads', meta.id)
reply.type(meta.mimetype)
reply.header('Cache-Control', 'public, max-age=31536000')
reply.header('Content-Disposition', `inline; filename="${meta.name}"`)
return fs.createReadStream(filePath)
},
)
}
export default plugin

View File

@@ -1,33 +0,0 @@
import type { FastifyInstance } from 'fastify'
import bcrypt from 'bcrypt'
import { z } from 'zod'
export default function (fastify: FastifyInstance) {
fastify.post('/attachments/upload', async (req, reply) => {
try {
const schema = z.object({
file: z.file(),
})
const input = schema.parse(req.body)
// const file = req.file({ limits: { } })
const id = await bcrypt.hash(input.file, 10)
return {
id,
}
}
catch (err) {
fastify.log.error(err)
reply.code(400)
if (err instanceof z.ZodError) {
reply.send({ error: z.prettifyError(err) })
}
else {
reply.send({ error: err.message })
}
}
})
}

View File

@@ -1,118 +1,146 @@
import type { FastifyInstance } from 'fastify'
import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { auth } from '../auth/lucia.ts'
import prisma from '../prisma/client.ts'
import { Type } from 'typebox'
import { CreateUserSchema, UserSchema } from '../schemas/auth.ts'
export default function (fastify: FastifyInstance) {
fastify.post('/register', async (req, reply) => {
try {
const schema = z.object({
username: z.string().min(1),
password: z.string().min(6),
})
const input = schema.parse(req.body)
const hashed = await bcrypt.hash(input.password, 10)
const user = await prisma.user.create({
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
fastify.post(
'/auth/register',
{
schema: {
summary: 'Register',
tags: ['Auth'],
operationId: 'auth.register',
body: CreateUserSchema,
response: {
200: UserSchema,
},
},
config: {
skipAuth: true,
},
},
async (req, reply) => {
const hashed = await bcrypt.hash(req.body.password, 10)
const user = await fastify.prisma.user.create({
data: {
username: input.username,
username: req.body.username,
password: hashed,
displayName: input.username,
displayName: req.body.username,
UserPreferences: {
create: {},
},
},
})
const session = await auth.createSession(user.id, {})
const cookie = auth.createSessionCookie(session.id)
const session = await fastify.lucia.createSession(user.id, {})
const cookie = fastify.lucia.createSessionCookie(session.id)
reply.setCookie(cookie.name, cookie.value, cookie.attributes)
return {
id: user.id,
username: user.username,
displayName: user.displayName,
displayName: user.username,
createdAt: user.createdAt.toISOString(),
}
}
catch (err) {
fastify.log.error(err)
reply.code(400)
},
)
if (err instanceof z.ZodError) {
reply.send({ error: z.prettifyError(err) })
}
else {
reply.send({ error: err.message })
}
}
})
fastify.post('/login', async (req, reply) => {
try {
const schema = z.object({
username: z.string().min(1),
password: z.string(),
})
const input = schema.parse(req.body)
const user = await prisma.user.findFirst({
where: { username: input.username },
fastify.post(
'/auth/login',
{
schema: {
summary: 'Login',
tags: ['Auth'],
operationId: 'auth.login',
body: Type.Object({
username: Type.String({ minLength: 1 }),
password: Type.String({ minLength: 1 }),
}),
response: {
200: UserSchema,
},
},
config: {
skipAuth: true,
},
},
async (req, reply) => {
const user = await fastify.prisma.user.findFirst({
where: { username: req.body.username },
select: {
id: true,
username: true,
displayName: true,
createdAt: true,
password: true,
},
})
if (!user) {
return reply.code(404).send({ error: 'Incorrect username or password' })
return reply.notFound('Incorrect username or password')
}
const validPassword = await bcrypt.compare(input.password, user.password)
const validPassword = await bcrypt.compare(req.body.password, user.password)
if (!validPassword) {
return reply.code(404).send({ error: 'Incorrect username or password' })
return reply.notFound('Incorrect username or password')
}
const session = await auth.createSession(user.id, {})
const cookie = auth.createSessionCookie(session.id)
const session = await fastify.lucia.createSession(user.id, {})
const cookie = fastify.lucia.createSessionCookie(session.id)
reply.setCookie(cookie.name, cookie.value, cookie.attributes)
return {
...user,
createdAt: user.createdAt.toISOString(),
}
},
)
fastify.get(
'/auth/me',
{
schema: {
summary: 'Me',
tags: ['Auth'],
operationId: 'auth.me',
response: {
200: UserSchema,
},
},
},
async (req) => {
const user = req.user!
return {
id: user.id,
username: user.username,
displayName: user.displayName,
createdAt: user.createdAt.toISOString(),
}
}
catch (err) {
fastify.log.error(err)
reply.code(400)
},
)
if (err instanceof z.ZodError) {
reply.send({ error: z.prettifyError(err) })
}
else {
reply.send({ error: err.message })
}
}
})
fastify.get('/me', async (req, reply) => {
if (req.user) {
return req.user
}
reply.code(401).send(false)
})
fastify.post('/logout', async (req, reply) => {
try {
fastify.post(
'/auth/logout',
{
schema: {
summary: 'Logout',
tags: ['Auth'],
operationId: 'auth.logout',
},
},
async (req, reply) => {
if (req.session)
await auth.invalidateSession(req.session.id)
await fastify.lucia.invalidateSession(req.session.id)
const blank = auth.createBlankSessionCookie()
const blank = fastify.lucia.createBlankSessionCookie()
reply.setCookie(blank.name, blank.value, blank.attributes)
return true
}
catch (err) {
fastify.log.error(err)
reply.code(400).send({ error: err.message })
}
})
},
)
}
export default plugin

111
server/routes/chat.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import { Type } from 'typebox'
import { ChatMessageSchema, NewChatMessageSchema } from '../schemas/chat.ts'
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
fastify.post(
'/chat/send',
{
schema: {
summary: 'Send message',
tags: ['Chat'],
operationId: 'chat.send',
body: NewChatMessageSchema,
response: {
200: ChatMessageSchema,
},
},
},
async (req, reply) => {
const user = req.user!
const message = await fastify.prisma.message.create({
data: {
text: req.body.text,
senderId: user.id,
attachments: {
create: (req.body.attachments ?? []).map((attachmentId) => {
return {
attachment: {
connect: {
id: attachmentId,
},
},
}
}),
},
},
})
if (!message) {
return reply.unprocessableEntity()
}
const response = {
id: message.id,
senderId: user.id,
text: message.text,
createdAt: message.createdAt.toISOString(),
updatedAt: message.updatedAt.toISOString(),
attachments: req.body.attachments ?? [],
}
fastify.bus.emit('chat:new-message', response)
return response
},
)
fastify.get(
'/chat',
{
schema: {
summary: 'Get messages',
tags: ['Chat'],
operationId: 'chat.messages',
querystring: Type.Object({
cursor: Type.Optional(Type.String({ format: 'uuid', description: 'Cursor to message' })),
limit: Type.Number({ minimum: 1, maximum: 100, default: 10 }),
}),
response: {
200: Type.Object({
messages: Type.Array(ChatMessageSchema),
nextCursor: Type.Optional(Type.String({ format: 'uuid', description: 'Cursor to last message' })),
}),
},
},
},
// eslint-disable-next-line ts/ban-ts-comment
// @ts-expect-error
async (req) => {
const messages = await fastify.prisma.message.findMany({
orderBy: { createdAt: 'desc' },
take: req.query.limit + 1,
include: { attachments: true },
...(req.query.cursor && {
cursor: {
id: req.query.cursor,
},
// skip: 1,
}),
})
const hasMore = messages.length > req.query.limit
const cursorMessage = hasMore ? messages.pop() : undefined
return {
messages: messages.map((message) => {
return {
...message,
createdAt: message.createdAt.toISOString(),
updatedAt: message.updatedAt.toISOString(),
attachments: message.attachments.map(({ attachmentId }) => attachmentId),
}
}),
nextCursor: cursorMessage?.id,
}
},
)
}
export default plugin

View File

@@ -1,97 +1,158 @@
import type { FastifyInstance } from 'fastify'
import type { Namespace } from '../types/webrtc.ts'
import { z } from 'zod'
import prisma from '../prisma/client.ts'
import { socketToClient } from '../utils/socket-to-client.ts'
import type { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox'
import { Type } from 'typebox'
import { UserSchema } from '../schemas/auth.ts'
import { UpdateUserPreferencesSchema, UserPreferencesSchema } from '../schemas/user.ts'
export default function (fastify: FastifyInstance) {
fastify.get('/preferences', async (req, reply) => {
if (req.user) {
return prisma.userPreferences.findFirst({ where: { userId: req.user.id } })
}
reply.code(401).send(false)
})
fastify.patch('/preferences', async (req, reply) => {
if (!req.user) {
reply.code(401).send(false)
return
}
try {
const schema = z.object({
toggleInputHotkey: z.string().optional(),
toggleOutputHotkey: z.string().optional(),
volumes: z.record(z.string(), z.number()).optional(),
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
fastify.get(
'/user',
{
schema: {
summary: 'Get user',
tags: ['User'],
operationId: 'user.get',
querystring: Type.Partial(Type.Object({
username: Type.String(),
})),
response: {
200: UserSchema,
},
},
},
async (req, reply) => {
const user = await fastify.prisma.user.findFirst({
where: { username: req.query.username },
select: {
id: true,
username: true,
displayName: true,
createdAt: true,
},
})
const input = schema.parse(req.body)
return prisma.userPreferences.upsert({
where: { userId: req.user.id },
if (!user) {
return reply.notFound('User not found')
}
return {
...user,
createdAt: user.createdAt.toISOString(),
}
},
)
fastify.get(
'/user/preferences',
{
schema: {
summary: 'Get preferences',
tags: ['User'],
operationId: 'user.getPreferences',
response: {
200: UserPreferencesSchema,
},
},
},
async (req, reply) => {
const user = req.user!
const preferences = await fastify.prisma.userPreferences.upsert({
where: { userId: user.id },
create: { userId: user.id },
update: {},
})
if (!preferences) {
return reply.notFound('User preferences not found')
}
return {
toggleInputHotkey: preferences.toggleInputHotkey || '',
toggleOutputHotkey: preferences.toggleOutputHotkey || '',
}
},
)
fastify.patch(
'/user/preferences',
{
schema: {
summary: 'Update preferences',
tags: ['User'],
operationId: 'user.updatePreferences',
body: UpdateUserPreferencesSchema,
},
},
async (req) => {
const user = req.user!
return fastify.prisma.userPreferences.upsert({
where: { userId: user.id },
create: {
userId: req.user.id,
...input,
userId: user.id,
...req.body,
},
update: input,
update: req.body,
})
}
catch (err) {
fastify.log.error(err)
reply.code(400)
},
)
if (err instanceof z.ZodError) {
reply.send({ error: z.prettifyError(err) })
}
else {
reply.send({ error: err.message })
}
}
})
fastify.patch(
'/profile',
{
schema: {
summary: 'Update profile',
tags: ['User'],
operationId: 'user.updateProfile',
body: Type.Object({
displayName: Type.String(),
}),
response: {
200: UserSchema,
},
},
},
async (req, reply) => {
const user = req.user!
fastify.patch('/profile', async (req, reply) => {
if (!req.user) {
reply.code(401).send(false)
return
}
try {
const schema = z.object({
displayName: z.string().optional(),
})
const input = schema.parse(req.body)
const updatedUser = prisma.user.update({
where: { id: req.user.id },
const updatedUser = await fastify.prisma.user.update({
where: { id: user.id },
data: {
displayName: input.displayName,
displayName: req.body.displayName,
},
select: {
id: true,
username: true,
displayName: true,
createdAt: true,
},
})
const namespace: Namespace = fastify.io.of('/webrtc')
const sockets = await namespace.fetchSockets()
const found = sockets.find(socket => socket.data.joined && socket.data.userId === req.user!.id)
if (found) {
found.data.displayName = input.displayName
namespace.emit('clientChanged', found.id, socketToClient(found))
if (!updatedUser) {
return reply.notFound('User not found')
}
return updatedUser
}
catch (err) {
fastify.log.error(err)
reply.code(400)
const response = {
...updatedUser,
createdAt: updatedUser.createdAt.toISOString(),
}
if (err instanceof z.ZodError) {
reply.send({ error: z.prettifyError(err) })
}
else {
reply.send({ error: err.message })
}
}
})
fastify.bus.emit('user:profile-updated', response)
// TODO: подписаться в webrtc
// const namespace: Namespace = fastify.io.of('/webrtc')
// const sockets = await namespace.fetchSockets()
//
// const found = sockets.find(socket => socket.data.joined && socket.data.userId === req.user!.id)
//
// if (found) {
// found.data.displayName = req.body.displayName
// namespace.emit('clientChanged', found.id, socketToClient(found))
// }
return response
},
)
}
export default plugin

View File

@@ -0,0 +1,11 @@
import { Type } from 'typebox'
export const AttachmentSchema = Type.Object({
id: Type.String(),
name: Type.String(),
mimetype: Type.String(),
size: Type.Number({ minimum: 0 }),
createdAt: Type.String({ format: 'date-time' }),
// message: Type.MessageAttachment(),
}, { title: 'Attachment', description: 'Attachment' })

13
server/schemas/auth.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Type } from 'typebox'
export const UserSchema = Type.Object({
id: Type.String(),
username: Type.String(),
displayName: Type.String(),
createdAt: Type.String({ format: 'date-time' }),
}, { title: 'User', description: 'User' })
export const CreateUserSchema = Type.Object({
username: Type.String({ minLength: 1 }),
password: Type.String({ minLength: 6 }),
}, { title: 'CreateUser' })

26
server/schemas/chat.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Type } from 'typebox'
export const ReplySchema = Type.Object({
messageId: Type.String({ format: 'uuid' }),
senderId: Type.String({ format: 'uuid' }),
text: Type.String(),
}, { title: 'Reply', description: 'Reply' })
export const ChatMessageSchema = Type.Object({
id: Type.String({ format: 'uuid' }),
senderId: Type.String({ format: 'uuid' }),
text: Type.String({ minLength: 1 }),
createdAt: Type.String({ format: 'date-time' }),
updatedAt: Type.String({ format: 'date-time' }),
attachments: Type.Array(Type.String({ format: 'uuid' })),
// replyTo: ReplySchema,
}, { title: 'ChatMessage', description: 'ChatMessage' })
export const NewChatMessageSchema = Type.Object({
text: Type.String({ minLength: 1 }),
attachments: Type.Optional(Type.Array(Type.String({ format: 'uuid' }))),
// replyTo: Type.Object({
// messageId: Type.String({ format: 'uuid' }),
// }),
}, { title: 'NewChatMessage', description: 'NewChatMessage' })

7
server/schemas/common.ts Normal file
View File

@@ -0,0 +1,7 @@
import { Type } from 'typebox'
export const ErrorReplySchema = Type.Object({
statusCode: Type.Number(),
error: Type.String(),
message: Type.String(),
}, { title: 'ResponseError', description: 'Response Error' })

11
server/schemas/user.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Type } from 'typebox'
export const UserPreferencesSchema = Type.Object({
toggleInputHotkey: Type.String(),
toggleOutputHotkey: Type.String(),
}, { title: 'UserPreferences', description: 'UserPreferences' })
export const UpdateUserPreferencesSchema = Type.Partial(
UserPreferencesSchema,
{ title: 'UpdateUserPreferences', description: 'UpdateUserPreferences' },
)

View File

@@ -1,19 +1,80 @@
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import FastifyAutoLoad from '@fastify/autoload'
import FastifyCookie from '@fastify/cookie'
import FastifyCors from '@fastify/cors'
import FastifyMultipart from '@fastify/multipart'
import FastifySensible from '@fastify/sensible'
import FastifySwagger from '@fastify/swagger'
import FastifyApiReference from '@scalar/fastify-api-reference'
import Fastify from 'fastify'
import prisma from './prisma/client.ts'
console.log(process.env.DATABASE_URL)
import { Prisma } from './prisma/generated-client/client.ts'
import { ErrorReplySchema } from './schemas/common.ts'
import 'dotenv/config'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const fastify = Fastify({
logger: true,
}).withTypeProvider<TypeBoxTypeProvider>()
fastify.register(FastifySensible)
fastify.setErrorHandler((error, request, reply) => {
if (error instanceof Prisma.PrismaClientValidationError) {
reply.notAcceptable()
return
}
if (error.statusCode) {
reply.getHttpError(error.statusCode, error.message)
return
}
reply.badRequest(error.message)
})
fastify.register(FastifySwagger, {
openapi: {
info: {
version: '1.0.0',
title: 'Chad API',
},
},
transform: ({ schema, url }) => {
if (!url.startsWith('/chad'))
return { schema, url }
const transformedSchema: typeof schema = schema ?? {}
const responseSchema: any = transformedSchema.response ?? {}
responseSchema['4xx'] ??= ErrorReplySchema
responseSchema['5xx'] ??= ErrorReplySchema
transformedSchema.response = responseSchema
return { schema: transformedSchema, url }
},
})
fastify.register(FastifyApiReference, {
routePrefix: '/reference',
configuration: {
showOperationId: true,
showDeveloperTools: 'never',
pageTitle: 'Chad API',
customCss: `
.scalar-mcp-layer,
.agent-button-container,
.t-doc__sidebar > div > button:last-child {
display: none !important;
}
`,
},
})
fastify.register(FastifyCors, {
@@ -44,9 +105,6 @@ fastify.register(FastifyAutoLoad, {
try {
await fastify.listen({ port, host: '0.0.0.0' })
await prisma.$connect()
fastify.log.info('Testing DB Connection. OK')
}
catch (err) {
fastify.log.error(err)

View File

@@ -1,28 +1,7 @@
import type { Server as SocketServer } from 'socket.io'
import type { ChatClientMessage, ChatMessage } from '../types/chat.ts'
import { v4 as uuidv4 } from 'uuid'
export default async function (io: SocketServer) {
const messages: ChatMessage[] = []
io.on('connection', async (socket) => {
socket.on('chat:message', async (clientMessage: ChatClientMessage, cb) => {
const message: ChatMessage = {
id: uuidv4(),
createdAt: new Date().toISOString(),
sender: socket.data.username,
text: clientMessage.text,
}
console.log(message)
messages.push(message)
if (messages.length > 5000) {
messages.shift()
}
io.emit('chat:new-message', message)
})
})
}

View File

@@ -1,14 +1,14 @@
import type { types } from 'mediasoup'
import type { Server as SocketServer } from 'socket.io'
import type { PrismaClient } from '../prisma/generated-client/client.ts'
import type {
ChadClient,
SomeSocket,
} from '../types/webrtc.ts'
import { consola } from 'consola'
import prisma from '../prisma/client.ts'
import { socketToClient } from '../utils/socket-to-client.ts'
export default async function (io: SocketServer, router: types.Router) {
export default async function (io: SocketServer, router: types.Router, prisma: PrismaClient) {
const audioLevelObserver = await router.createAudioLevelObserver({
maxEntries: 10,
threshold: -80,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

File diff suppressed because it is too large Load Diff