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

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

@@ -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