diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 97d3426..ea047a0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -8,6 +8,22 @@ datasource db { url = env("POSTGRES_URI") } +model Stream { + id String @id @default(uuid()) + title String + thumbnailUrl String? @map("thumbnail_url") + ingressId String? @unique @map("ingress_id") + serverUrl String? @map("server_url") + key String? @map("key") + isLive Boolean @default(false) @map("is_live") + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String? @unique @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("stream") +} + model SocialLink { id String @id @default(uuid()) title String @@ -37,6 +53,7 @@ model User { deactivatedAt DateTime? @map("deactivated_at") socialLink SocialLink[] totpSecret String? @map("totp_secret") + stream Stream? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/core/core.module.ts b/backend/src/core/core.module.ts index e13eb46..7dee345 100644 --- a/backend/src/core/core.module.ts +++ b/backend/src/core/core.module.ts @@ -9,6 +9,7 @@ import { VerificationModule } from '@/src/module/auth/verification/verification. import { CronModule } from '@/src/module/cron/cron.module' import { MailModule } from '@/src/module/libs/mail/mail.module' import { StorageModule } from '@/src/module/libs/storage/storage.module' +import { StreamModule } from '@/src/module/stream/stream.module' import { IS_DEV } from '@/src/shared/util/is-dev.util' import { ApolloDriver } from '@nestjs/apollo' import { Module } from '@nestjs/common' @@ -41,6 +42,7 @@ import { RedisModule } from './redis/redis.module' TotpModule, DeactivateModule, ProfileModule, + StreamModule, ], }) export class CoreModule {} diff --git a/backend/src/core/graphql/schema.gql b/backend/src/core/graphql/schema.gql index 5ee7ea4..0513969 100644 --- a/backend/src/core/graphql/schema.gql +++ b/backend/src/core/graphql/schema.gql @@ -22,6 +22,11 @@ input ChangeProfileInfoInput { name: String! } +input ChangeStreamInfoInput { + categoryId: String! + title: String! +} + input CreateUserInput { email: String! name: String! @@ -50,6 +55,12 @@ input EnableTotpInput { secret: String! } +input FilterInput { + searchTerm: String + skip: Float + take: Float +} + type LocationModel { city: String! country: String! @@ -68,6 +79,8 @@ type Mutation { changePassword(data: ChangePasswordInput!): UserModel! changeProfileAvatar(avatar: Upload!): Boolean! changeProfileInfo(data: ChangeProfileInfoInput!): UserModel! + changeStreamInfo(data: ChangeStreamInfoInput!): StreamModel! + changeStreamThumbnail(thumbnail: Upload!): Boolean! clearSessionCookie: Boolean! createSocialLink(data: SocialLinkInput!): SocialLinkModel! createUser(data: CreateUserInput!): UserModel! @@ -79,11 +92,12 @@ type Mutation { removeProfileAvatar: Boolean! removeSession(id: String!): Boolean! removeSocialLink(id: String!): Boolean! + removeStreamThumbnail: Boolean! reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean! resetPassword(data: ResetPasswordInput!): Boolean! setNewPassword(data: NewPasswordInput!): Boolean! updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel! - verifyAccount(data: VerificationInput!): UserModel! + verifyAccount(data: VerificationInput!): AuthModel! } input NewPasswordInput { @@ -93,8 +107,10 @@ input NewPasswordInput { } type Query { + findAllStreams(filters: FilterInput!): [StreamModel!]! findCurrentSession: SessionModel! findProfile: UserModel! + findRandomStreams: [StreamModel!]! findSessionsByUser: [SessionModel!]! findSocialLinks: [SocialLinkModel!]! generateTotpSecret: TotpModel! @@ -137,6 +153,20 @@ input SocialLinkOrderInput { position: Float! } +type StreamModel { + createdAt: DateTime! + id: ID! + ingressId: String + isLive: Boolean! + key: String + serverUrl: String + thumbnailUrl: String + title: String! + updatedAt: DateTime! + user: UserModel! + userId: ID! +} + type TotpModel { qrcodeUrl: String! secret: String! @@ -160,6 +190,7 @@ type UserModel { name: String! password: String! socialLink: [SocialLinkModel!]! + stream: StreamModel! totpSecret: String updatedAt: DateTime! } diff --git a/backend/src/module/auth/account/account.service.ts b/backend/src/module/auth/account/account.service.ts index bb93271..6597ace 100644 --- a/backend/src/module/auth/account/account.service.ts +++ b/backend/src/module/auth/account/account.service.ts @@ -51,6 +51,11 @@ export class AccountService { email, password: await hash(password), displayName: name, + stream: { + create: { + title: `Stream by ${name}`, + }, + }, }, }) diff --git a/backend/src/module/auth/account/models/user.model.ts b/backend/src/module/auth/account/models/user.model.ts index 20ec045..3d03aa2 100644 --- a/backend/src/module/auth/account/models/user.model.ts +++ b/backend/src/module/auth/account/models/user.model.ts @@ -1,4 +1,5 @@ -import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model'; +import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model' +import { StreamModel } from '@/src/module/stream/models/stream.model' import { Field, ID, ObjectType } from '@nestjs/graphql' import { User } from '@prisma/generated' @@ -46,6 +47,9 @@ export class UserModel implements User { @Field(() => [SocialLinkModel]) socialLink: SocialLinkModel[] + @Field(() => StreamModel) + stream: StreamModel + @Field(() => Date) createdAt: Date diff --git a/backend/src/module/auth/verification/verification.resolver.ts b/backend/src/module/auth/verification/verification.resolver.ts index 670dac7..2e51be5 100644 --- a/backend/src/module/auth/verification/verification.resolver.ts +++ b/backend/src/module/auth/verification/verification.resolver.ts @@ -1,4 +1,4 @@ -import { UserModel } from '@/src/module/auth/account/models/user.model' +import { AuthModel } from '@/src/module/auth/account/models/auth.model' import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input' import { UserAgent } from '@/src/shared/decorators/user-agent.decorator' import { GqlContext } from '@/src/shared/types/gql-context.types' @@ -9,7 +9,7 @@ import { VerificationService } from './verification.service' export class VerificationResolver { constructor(private readonly verificationService: VerificationService) {} - @Mutation(() => UserModel, { name: 'verifyAccount' }) + @Mutation(() => AuthModel, { name: 'verifyAccount' }) public async verify( @Context() { req }: GqlContext, @Args('data') input: VerificationInput, diff --git a/backend/src/module/stream/inputs/change-stream-info.input.ts b/backend/src/module/stream/inputs/change-stream-info.input.ts new file mode 100644 index 0000000..df5ad56 --- /dev/null +++ b/backend/src/module/stream/inputs/change-stream-info.input.ts @@ -0,0 +1,15 @@ +import { Field, InputType } from '@nestjs/graphql' +import { IsNotEmpty, IsString } from 'class-validator' + +@InputType() +export class ChangeStreamInfoInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + title: string + + @Field(() => String) + @IsString() + @IsNotEmpty() + categoryId: string +} diff --git a/backend/src/module/stream/inputs/filter.input.ts b/backend/src/module/stream/inputs/filter.input.ts new file mode 100644 index 0000000..a1addf6 --- /dev/null +++ b/backend/src/module/stream/inputs/filter.input.ts @@ -0,0 +1,20 @@ +import { Field, InputType } from '@nestjs/graphql' +import { IsNumber, IsOptional, IsString } from 'class-validator' + +@InputType() +export class FilterInput { + @Field(() => Number, { nullable: true }) + @IsNumber() + @IsOptional() + public take?: number + + @Field(() => Number, { nullable: true }) + @IsNumber() + @IsOptional() + public skip?: number + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + public searchTerm?: string +} diff --git a/backend/src/module/stream/models/stream.model.ts b/backend/src/module/stream/models/stream.model.ts new file mode 100644 index 0000000..3356a04 --- /dev/null +++ b/backend/src/module/stream/models/stream.model.ts @@ -0,0 +1,39 @@ +import { UserModel } from '@/src/module/auth/account/models/user.model' +import { Field, ID, ObjectType } from '@nestjs/graphql' +import { Stream } from '@prisma/generated' + +@ObjectType() +export class StreamModel implements Stream { + @Field(() => ID) + id: string + + @Field(() => ID) + userId: UserModel['id'] + + @Field(() => UserModel) + user: UserModel + + @Field(() => String) + title: string + + @Field(() => String, { nullable: true }) + key: string + + @Field(() => String, { nullable: true }) + thumbnailUrl: string + + @Field(() => String, { nullable: true }) + serverUrl: string + + @Field(() => Boolean) + isLive: boolean + + @Field(() => String, { nullable: true }) + ingressId: string + + @Field(() => Date) + updatedAt: Date + + @Field(() => Date) + createdAt: Date +} diff --git a/backend/src/module/stream/stream.module.ts b/backend/src/module/stream/stream.module.ts new file mode 100644 index 0000000..77f0a9d --- /dev/null +++ b/backend/src/module/stream/stream.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { StreamService } from './stream.service' +import { StreamResolver } from './stream.resolver' + +@Module({ + providers: [StreamResolver, StreamService], +}) +export class StreamModule {} diff --git a/backend/src/module/stream/stream.resolver.ts b/backend/src/module/stream/stream.resolver.ts new file mode 100644 index 0000000..47abb95 --- /dev/null +++ b/backend/src/module/stream/stream.resolver.ts @@ -0,0 +1,52 @@ +import { ChangeStreamInfoInput } from '@/src/module/stream/inputs/change-stream-info.input' +import { FilterInput } from '@/src/module/stream/inputs/filter.input' +import { StreamModel } from '@/src/module/stream/models/stream.model' +import { Authorization } from '@/src/shared/decorators/auth.decorator' +import { Authorized } from '@/src/shared/decorators/authorized.decorator' +import { FileValidationPipe } from '@/src/shared/pipes/file-validation.pipe' +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' +import { User } from '@prisma/generated' +import * as Upload from 'graphql-upload/Upload.js' +import * as GraphQLUpload from 'graphql-upload/GraphQLUpload.js' +import { StreamService } from './stream.service' + +@Resolver('Stream') +export class StreamResolver { + constructor(private readonly streamService: StreamService) {} + + @Query(() => [StreamModel], { name: 'findAllStreams' }) + public async findAll( + @Args('filters') input: FilterInput, + ) { + return this.streamService.findAll(input) + } + + @Query(() => [StreamModel], { name: 'findRandomStreams' }) + public async findRandomStreams() { + return this.streamService.findRandom() + } + + @Authorization() + @Mutation(() => StreamModel, { name: 'changeStreamInfo' }) + public async changeInfo( + @Authorized() user: User, + @Args('data') input: ChangeStreamInfoInput, + ) { + return this.streamService.changeInfo(user, input) + } + + @Authorization() + @Mutation(() => Boolean, { name: 'changeStreamThumbnail' }) + public async changeThumbnail( + @Authorized() user: User, + @Args('thumbnail', { type: () => GraphQLUpload }, FileValidationPipe) file: Upload, + ) { + return this.streamService.changeThumbnail(user, file) + } + + @Authorization() + @Mutation(() => Boolean, { name: 'removeStreamThumbnail' }) + public async removeAvatar(@Authorized() user: User) { + return this.streamService.removeThumbnail(user) + } +} diff --git a/backend/src/module/stream/stream.service.ts b/backend/src/module/stream/stream.service.ts new file mode 100644 index 0000000..d5dbe08 --- /dev/null +++ b/backend/src/module/stream/stream.service.ts @@ -0,0 +1,153 @@ +import { Prisma, User } from '@/prisma/generated' +import { PrismaService } from '@/src/core/prisma/prisma.service' +import { StorageService } from '@/src/module/libs/storage/storage.service' +import { ChangeStreamInfoInput } from '@/src/module/stream/inputs/change-stream-info.input' +import { FilterInput } from '@/src/module/stream/inputs/filter.input' +import { Injectable, NotFoundException } from '@nestjs/common' +import * as Upload from 'graphql-upload/Upload' +import sharp from 'sharp' + +@Injectable() +export class StreamService { + constructor( + private readonly prismaService: PrismaService, + private readonly storageService: StorageService, + ) { + } + + public async findAll(input: FilterInput = {}) { + const { take, skip, searchTerm } = input + const whereClause = searchTerm + ? this.findBySearchTermFilter(searchTerm) + : undefined + + return this.prismaService.stream.findMany({ + take: take ?? 12, + skip: skip ?? 0, + where: { + user: { isDeactivated: false }, + ...whereClause, + }, + include: { user: true }, + orderBy: { + createdAt: 'desc', + }, + }) + } + + public async findRandom() { + const total = await this.prismaService.stream.count({ + where: { + user: { + isDeactivated: false, + }, + }, + }) + + const randomIndexes = new Set() + while (randomIndexes.size < 2) { + const randomIndex = Math.floor(Math.random() * total) + randomIndexes.add(randomIndex) + } + + const streams = await this.prismaService.stream.findMany({ + where: { + user: { + isDeactivated: false, + }, + }, + take: total, + skip: 0, + include: { user: true }, + }) + + return Array.from(randomIndexes).map(index => streams[index]) + } + + public findBySearchTermFilter(searchTerm: NonNullable): Prisma.StreamWhereInput { + return { + OR: [ + { + title: { + contains: searchTerm, + mode: 'insensitive', + }, + }, + { + user: { + name: { + contains: searchTerm, + mode: 'insensitive', + }, + }, + }, + ], + } + } + + public async changeInfo(user: User, input: ChangeStreamInfoInput) { + const { categoryId, title } = input + return this.prismaService.stream.update({ + where: { userId: user.id }, + data: { title }, + }) + } + + public async changeThumbnail(user: User, file: Upload) { + const stream = await this.findStreamByUser(user) + + if (stream.thumbnailUrl) { + await this.storageService.remove(stream.thumbnailUrl) + } + + const chunks: Buffer[] = [] + + for await (const chunk of file.createReadStream()) { + chunks.push(chunk) + } + + const buffer = Buffer.concat(chunks) + const fileName = `/streams/${user.name}.webp` + + const processedBuffer = await sharp(buffer, { animated: file.filename.endsWith('.gif') }) + .resize(1280, 720) + .webp() + .toBuffer() + + await this.storageService.upload(processedBuffer, fileName, 'image/webp') + + return this.prismaService.stream.update({ + where: { id: stream.id }, + data: { thumbnailUrl: fileName }, + }) + } + + public async removeThumbnail(user: User) { + const stream = await this.findStreamByUser(user) + + if (!stream.thumbnailUrl) { + return true + } + + await this.storageService.remove(stream.thumbnailUrl) + + return this.prismaService.stream.update({ + where: { id: stream.id }, + data: { thumbnailUrl: null }, + }) + } + + private async findStreamByUser(user: User) { + const stream = await this.prismaService.stream.findUnique({ + where: { + userId: user.id, + }, + }) + + if (!stream) { + throw new NotFoundException('Не найден такой стрим') + } + + return stream + } +}