diff --git a/hasura/metadata/databases/punkga-pg/tables/public_telegram_users.yaml b/hasura/metadata/databases/punkga-pg/tables/public_telegram_users.yaml new file mode 100644 index 00000000..215f97d6 --- /dev/null +++ b/hasura/metadata/databases/punkga-pg/tables/public_telegram_users.yaml @@ -0,0 +1,7 @@ +table: + name: telegram_users + schema: public +object_relationships: + - name: authorizer_user + using: + foreign_key_constraint_on: user_id diff --git a/hasura/metadata/databases/punkga-pg/tables/tables.yaml b/hasura/metadata/databases/punkga-pg/tables/tables.yaml index 59a8deaf..0b0ac2b6 100644 --- a/hasura/metadata/databases/punkga-pg/tables/tables.yaml +++ b/hasura/metadata/databases/punkga-pg/tables/tables.yaml @@ -34,6 +34,7 @@ - "!include public_system_key.yaml" - "!include public_tag_languages.yaml" - "!include public_tags.yaml" +- "!include public_telegram_users.yaml" - "!include public_user_campaign.yaml" - "!include public_user_campaign_reward.yaml" - "!include public_user_level.yaml" diff --git a/hasura/migrations/punkga-pg/1724642251768_create_table_public_telegram_users/down.sql b/hasura/migrations/punkga-pg/1724642251768_create_table_public_telegram_users/down.sql new file mode 100644 index 00000000..f847a6be --- /dev/null +++ b/hasura/migrations/punkga-pg/1724642251768_create_table_public_telegram_users/down.sql @@ -0,0 +1 @@ +DROP TABLE "public"."telegram_users"; diff --git a/hasura/migrations/punkga-pg/1724642251768_create_table_public_telegram_users/up.sql b/hasura/migrations/punkga-pg/1724642251768_create_table_public_telegram_users/up.sql new file mode 100644 index 00000000..9746557a --- /dev/null +++ b/hasura/migrations/punkga-pg/1724642251768_create_table_public_telegram_users/up.sql @@ -0,0 +1,17 @@ +CREATE TABLE "public"."telegram_users" ("id" serial NOT NULL, "telegram_id" text NOT NULL, "username" text NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "updated_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("id") ); +CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() +RETURNS TRIGGER AS $$ +DECLARE + _new record; +BEGIN + _new := NEW; + _new."updated_at" = NOW(); + RETURN _new; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER "set_public_telegram_users_updated_at" +BEFORE UPDATE ON "public"."telegram_users" +FOR EACH ROW +EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); +COMMENT ON TRIGGER "set_public_telegram_users_updated_at" ON "public"."telegram_users" +IS 'trigger to set value of column "updated_at" to current timestamp on row update'; diff --git a/hasura/migrations/punkga-pg/1724645892288_alter_table_public_telegram_users_add_column_user_id/down.sql b/hasura/migrations/punkga-pg/1724645892288_alter_table_public_telegram_users_add_column_user_id/down.sql new file mode 100644 index 00000000..1b80d538 --- /dev/null +++ b/hasura/migrations/punkga-pg/1724645892288_alter_table_public_telegram_users_add_column_user_id/down.sql @@ -0,0 +1,4 @@ +-- Could not auto-generate a down migration. +-- Please write an appropriate down migration for the SQL below: +-- alter table "public"."telegram_users" add column "user_id" bpchar +-- null; diff --git a/hasura/migrations/punkga-pg/1724645892288_alter_table_public_telegram_users_add_column_user_id/up.sql b/hasura/migrations/punkga-pg/1724645892288_alter_table_public_telegram_users_add_column_user_id/up.sql new file mode 100644 index 00000000..06963a8a --- /dev/null +++ b/hasura/migrations/punkga-pg/1724645892288_alter_table_public_telegram_users_add_column_user_id/up.sql @@ -0,0 +1,2 @@ +alter table "public"."telegram_users" add column "user_id" bpchar + null; diff --git a/hasura/migrations/punkga-pg/1724645916362_set_fk_public_telegram_users_user_id/down.sql b/hasura/migrations/punkga-pg/1724645916362_set_fk_public_telegram_users_user_id/down.sql new file mode 100644 index 00000000..706f1482 --- /dev/null +++ b/hasura/migrations/punkga-pg/1724645916362_set_fk_public_telegram_users_user_id/down.sql @@ -0,0 +1 @@ +alter table "public"."telegram_users" drop constraint "telegram_users_user_id_fkey"; diff --git a/hasura/migrations/punkga-pg/1724645916362_set_fk_public_telegram_users_user_id/up.sql b/hasura/migrations/punkga-pg/1724645916362_set_fk_public_telegram_users_user_id/up.sql new file mode 100644 index 00000000..b2d89dd1 --- /dev/null +++ b/hasura/migrations/punkga-pg/1724645916362_set_fk_public_telegram_users_user_id/up.sql @@ -0,0 +1,5 @@ +alter table "public"."telegram_users" + add constraint "telegram_users_user_id_fkey" + foreign key ("user_id") + references "public"."authorizer_users" + ("id") on update set null on delete set null; diff --git a/hasura/migrations/punkga-pg/1724656307108_alter_table_public_telegram_users_add_unique_telegram_id/down.sql b/hasura/migrations/punkga-pg/1724656307108_alter_table_public_telegram_users_add_unique_telegram_id/down.sql new file mode 100644 index 00000000..8f1a886f --- /dev/null +++ b/hasura/migrations/punkga-pg/1724656307108_alter_table_public_telegram_users_add_unique_telegram_id/down.sql @@ -0,0 +1 @@ +alter table "public"."telegram_users" drop constraint "telegram_users_telegram_id_key"; diff --git a/hasura/migrations/punkga-pg/1724656307108_alter_table_public_telegram_users_add_unique_telegram_id/up.sql b/hasura/migrations/punkga-pg/1724656307108_alter_table_public_telegram_users_add_unique_telegram_id/up.sql new file mode 100644 index 00000000..d1569331 --- /dev/null +++ b/hasura/migrations/punkga-pg/1724656307108_alter_table_public_telegram_users_add_unique_telegram_id/up.sql @@ -0,0 +1 @@ +alter table "public"."telegram_users" add constraint "telegram_users_telegram_id_key" unique ("telegram_id"); diff --git a/src/app.module.ts b/src/app.module.ts index b6a87d47..76bc10ab 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,6 +26,7 @@ import { ChainModule } from './modules/chain/chain.module'; import { ChainGateWayModule } from './chain-gateway/chain-gateway.module'; import { ArtworkModule } from './modules/artwork/artwork.module'; import { AlbumModule } from './modules/album/album.module'; +import { TelegramModule } from './modules/telegram/telegram.module'; @Module({ imports: [ @@ -75,6 +76,7 @@ import { AlbumModule } from './modules/album/album.module'; ChainGateWayModule, ArtworkModule, AlbumModule, + TelegramModule, ], controllers: [], providers: [ diff --git a/src/auth/auth.guard.ts b/src/auth/auth.guard.ts index d38a38eb..219d8b31 100644 --- a/src/auth/auth.guard.ts +++ b/src/auth/auth.guard.ts @@ -4,19 +4,102 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; -// import { jwtConstants } from './constants'; +import { createHmac } from 'crypto'; import { Request } from 'express'; import { readFile } from 'fs/promises'; import * as path from 'path'; +import { ITelegramUser } from './interfaces/telegram-user.interface'; +import { GraphqlService } from '../modules/graphql/graphql.service'; +import { Role } from './role.enum'; @Injectable() export class AuthGuard implements CanActivate { - constructor(private jwtService: JwtService) {} + constructor( + private jwtService: JwtService, + private configService: ConfigService, + private graphqlSvc: GraphqlService + ) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); - const token = this.extractTokenFromHeader(request); + const token = this.extractBrearerTokenFromHeader(request); + if (token) { + return this.bearerAuth(request); + } + + return this.telegramAuth(request); + } + + private async telegramAuth(request: Request) { + const telegramInitData = this.extractTelegramInitData(request); + if (!telegramInitData) { + throw new UnauthorizedException(); + } + + const user: ITelegramUser = this.verifyTelegramWebAppData(telegramInitData); + // insert user + + if (!user) { + throw new UnauthorizedException(); + } + + const insertResult = await this.insertTelegramUser({ + object: { + telegram_id: user.id.toString(), + username: user.username, + }, + }); + if (insertResult.errors) + throw new UnauthorizedException(JSON.stringify(insertResult)); + + request['user'] = { + userId: insertResult.data.insert_telegram_users_one.authorizer_user?.id, + roles: [Role.TelegramUser], + telegramUserId: insertResult.data.insert_telegram_users_one.id, + }; + + return true; + } + + private verifyTelegramWebAppData(telegramInitData: string) { + const TELEGRAM_BOT_TOKEN = + this.configService.get('telgram.bot_token'); + // / The data is a query string, which is composed of a series of field-value pairs. + const encoded = decodeURIComponent(telegramInitData); + + // HMAC-SHA-256 signature of the bot's token with the constant string WebAppData used as a key. + const secret = createHmac('sha256', 'WebAppData').update( + TELEGRAM_BOT_TOKEN + ); + + // Data-check-string is a chain of all received fields'. + const arr = encoded.split('&'); + const hashIndex = arr.findIndex((str) => str.startsWith('hash=')); + const hash = arr.splice(hashIndex)[0].split('=')[1]; + // sorted alphabetically + arr.sort((a, b) => a.localeCompare(b)); + // in the format key= with a line feed character ('\n', 0x0A) used as separator + // e.g., 'auth_date=\nquery_id=\nuser= + const dataCheckString = arr.join('\n'); + + // The hexadecimal representation of the HMAC-SHA-256 signature of the data-check-string with the secret key + const _hash = createHmac('sha256', secret.digest()) + .update(dataCheckString) + .digest('hex'); + + // if hash are equal the data may be used on your server. + // Complex data types are represented as JSON-serialized objects. + if (_hash !== hash) throw new UnauthorizedException(); + + const userIndex = arr.findIndex((str) => str.startsWith('user=')); + const user = JSON.parse(arr.splice(userIndex)[0].split('=')[1]); + return user; + } + + private async bearerAuth(request: Request): Promise { + const token = this.extractBrearerTokenFromHeader(request); if (!token) { throw new UnauthorizedException(); } @@ -43,8 +126,38 @@ export class AuthGuard implements CanActivate { return true; } - private extractTokenFromHeader(request: Request): string | undefined { + private extractBrearerTokenFromHeader(request: Request): string | undefined { const [type, token] = request.headers.authorization?.split(' ') ?? []; return type === 'Bearer' ? token : undefined; } + + private extractTelegramInitData(request: Request): string | undefined { + return request.headers.authorization; + } + + private insertTelegramUser(variables: any) { + const headers = { + 'x-hasura-admin-secret': this.configService.get( + 'graphql.adminSecret' + ), + }; + + return this.graphqlSvc.query( + this.configService.get('graphql.endpoint'), + '', + `mutation insert_telegram_users_one($object: telegram_users_insert_input = {}) { + insert_telegram_users_one(object: $object, on_conflict: {constraint: telegram_users_telegram_id_key, update_columns: updated_at}) { + id + authorizer_user { + id + email + nickname + } + } + }`, + 'insert_telegram_users_one', + variables, + headers + ); + } } diff --git a/src/auth/dto/user.dto.ts b/src/auth/dto/user.dto.ts index 02422434..91edc6ac 100644 --- a/src/auth/dto/user.dto.ts +++ b/src/auth/dto/user.dto.ts @@ -2,4 +2,5 @@ export class UserDto { userId: string; roles: string[]; token: string; + telegramUserId?: string; } diff --git a/src/auth/interfaces/telegram-user.interface.ts b/src/auth/interfaces/telegram-user.interface.ts new file mode 100644 index 00000000..1580bc8b --- /dev/null +++ b/src/auth/interfaces/telegram-user.interface.ts @@ -0,0 +1,4 @@ +export interface ITelegramUser { + id: string; + username: string; +} diff --git a/src/auth/role.enum.ts b/src/auth/role.enum.ts index 9c0f0e09..6a861dff 100644 --- a/src/auth/role.enum.ts +++ b/src/auth/role.enum.ts @@ -2,4 +2,5 @@ export enum Role { User = 'user', Creator = 'creator', Admin = 'admin', + TelegramUser = 'telegram_user', } diff --git a/src/modules/album/album.graphql.ts b/src/modules/album/album.graphql.ts index f6c53df3..f7e85d9d 100644 --- a/src/modules/album/album.graphql.ts +++ b/src/modules/album/album.graphql.ts @@ -24,6 +24,7 @@ export class AlbumGraphql { name show disable + thumbnail_url created_at artworks_aggregate(where: {creator_id: {_eq: $creator_id}}) { aggregate { @@ -36,6 +37,7 @@ export class AlbumGraphql { name show disable + thumbnail_url created_at artworks_aggregate { aggregate { @@ -43,7 +45,7 @@ export class AlbumGraphql { } } } - } + } `, 'list_album', variables, diff --git a/src/modules/album/album.service.ts b/src/modules/album/album.service.ts index a02c55cc..405d3c20 100644 --- a/src/modules/album/album.service.ts +++ b/src/modules/album/album.service.ts @@ -36,6 +36,8 @@ export class AlbumService { }, }); + if (result.errors) return result; + const albumId = result.data.insert_albums_one.id; let thumbnail_url = ''; @@ -88,6 +90,7 @@ export class AlbumService { if (thumbnail_url !== '') { const updateResult = await this.albumGraphql.update({ id: albumId, + creator_id: creatorId, data: { thumbnail_url, }, @@ -124,8 +127,8 @@ export class AlbumService { const { limit, offset } = query; return this.albumGraphql.getListAlbum({ creator_id: creatorId, - limit, - offset, + limit: Number(limit), + offset: Number(offset), }); } diff --git a/src/modules/chapter/chapter.service.ts b/src/modules/chapter/chapter.service.ts index 86a3e58c..75fa8b62 100644 --- a/src/modules/chapter/chapter.service.ts +++ b/src/modules/chapter/chapter.service.ts @@ -174,14 +174,18 @@ export class ChapterService { return updateResult; } } - const collectionIdListStr = collection_ids.toString().split(','); - let collectionIdList = Array.from(collectionIdListStr, Number); - const updateResult = await this.addChapterCollection( - chapterId, - collectionIdList - ); - if (updateResult.errors && updateResult.errors.length > 0) { - return updateResult; + if (collection_ids) { + const collectionIdListStr = collection_ids.toString().split(','); + let collectionIdList = Array.from(collectionIdListStr, Number); + if (collectionIdList.length > 0) { + const updateResult = await this.addChapterCollection( + chapterId, + collectionIdList + ); + if (updateResult.errors && updateResult.errors.length > 0) { + return updateResult; + } + } } return result.data; } catch (errors) { @@ -252,15 +256,18 @@ export class ChapterService { UpdateChapterImage, JSON.parse(data.chapter_images) ); - - const collectionIdListStr = collection_ids.toString().split(','); - let collectionIdList = Array.from(collectionIdListStr, Number); - const updateResult = await this.addChapterCollection( - chapter_id, - collectionIdList - ); - if (updateResult.errors && updateResult.errors.length > 0) { - return updateResult; + if (collection_ids) { + const collectionIdListStr = collection_ids.toString().split(','); + let collectionIdList = Array.from(collectionIdListStr, Number); + if (collectionIdList.length > 0) { + const updateResult = await this.addChapterCollection( + chapter_id, + collectionIdList + ); + if (updateResult.errors && updateResult.errors.length > 0) { + return updateResult; + } + } } // upload chapter languages const uploadChapterResult = diff --git a/src/modules/graphql/graphql.module.ts b/src/modules/graphql/graphql.module.ts index d7fa53d0..30e58b9e 100644 --- a/src/modules/graphql/graphql.module.ts +++ b/src/modules/graphql/graphql.module.ts @@ -1,6 +1,7 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { GraphqlService } from './graphql.service'; +@Global() @Module({ providers: [GraphqlService], exports: [GraphqlService], diff --git a/src/modules/telegram/dto/link-user.dto.ts b/src/modules/telegram/dto/link-user.dto.ts new file mode 100644 index 00000000..58d88f9b --- /dev/null +++ b/src/modules/telegram/dto/link-user.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class LinkUserDto { + @ApiProperty() + email: string; + + @ApiProperty() + password: string; +} diff --git a/src/modules/telegram/telegram.controller.ts b/src/modules/telegram/telegram.controller.ts new file mode 100644 index 00000000..6000cc0e --- /dev/null +++ b/src/modules/telegram/telegram.controller.ts @@ -0,0 +1,43 @@ +import { + Body, + Controller, + Get, + Post, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { TelegramService } from './telegram.service'; +import { AuthGuard } from '../../auth/auth.guard'; +import { RolesGuard } from '../../auth/role.guard'; +import { Roles } from '../../auth/roles.decorator'; +import { Role } from '../../auth/role.enum'; +import { AuthUserInterceptor } from '../../interceptors/auth-user.interceptor'; +import { LinkUserDto } from './dto/link-user.dto'; + +@Controller('telegram') +@ApiTags('telegram') +export class TelegramController { + constructor(private readonly telegramSvc: TelegramService) {} + + @UseGuards(AuthGuard, RolesGuard) + @ApiBearerAuth() + @Roles(Role.TelegramUser) + @Post('connect') + @UseInterceptors(AuthUserInterceptor) + @ApiOperation({ summary: '' }) + connect() { + return this.telegramSvc.connect(); + } + + @UseGuards(AuthGuard, RolesGuard) + @ApiBearerAuth() + @Roles(Role.TelegramUser) + @Post('link') + @UseInterceptors(AuthUserInterceptor) + @ApiOperation({ summary: '' }) + link(@Body() body: LinkUserDto) { + return this.telegramSvc.link(body.email, body.password); + } +} diff --git a/src/modules/telegram/telegram.graphql.ts b/src/modules/telegram/telegram.graphql.ts new file mode 100644 index 00000000..c4e9aff4 --- /dev/null +++ b/src/modules/telegram/telegram.graphql.ts @@ -0,0 +1,68 @@ +import { ConfigService } from '@nestjs/config'; +import { GraphqlService } from '../graphql/graphql.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class TelegramGraphql { + constructor( + private configSvc: ConfigService, + private graphqlSvc: GraphqlService + ) {} + + getTelegramUser(variables) { + const headers = { + 'x-hasura-admin-secret': this.configSvc.get( + 'graphql.adminSecret' + ), + }; + + return this.graphqlSvc.query( + this.configSvc.get('graphql.endpoint'), + '', + `query telegram_users_by_pk($id: Int!) { + telegram_user: telegram_users_by_pk(id: $id) { + id + telegram_id + username + created_at + authorizer_user { + id + nickname + email + } + } + }`, + 'telegram_users_by_pk', + variables, + headers + ); + } + + updateTelegramUser(variables: any) { + const headers = { + 'x-hasura-admin-secret': this.configSvc.get( + 'graphql.adminSecret' + ), + }; + + return this.graphqlSvc.query( + this.configSvc.get('graphql.endpoint'), + '', + `mutation update_telegram_users_by_pk($id: Int!, $user_id: bpchar!) { + telegram_user: update_telegram_users_by_pk(pk_columns: {id: $id}, _set: {user_id: $user_id}) { + id + telegram_id + username + authorizer_user { + id + nickname + email + } + } + }`, + 'update_telegram_users_by_pk', + variables, + headers + ); + } +} diff --git a/src/modules/telegram/telegram.module.ts b/src/modules/telegram/telegram.module.ts new file mode 100644 index 00000000..e84298ef --- /dev/null +++ b/src/modules/telegram/telegram.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; + +import { GraphqlModule } from '../graphql/graphql.module'; +import { TelegramController } from './telegram.controller'; +import { TelegramService } from './telegram.service'; +import { TelegramGraphql } from './telegram.graphql'; + +@Module({ + imports: [JwtModule, GraphqlModule], + providers: [TelegramService, TelegramGraphql], + controllers: [TelegramController], +}) +export class TelegramModule {} diff --git a/src/modules/telegram/telegram.service.ts b/src/modules/telegram/telegram.service.ts new file mode 100644 index 00000000..4bba632b --- /dev/null +++ b/src/modules/telegram/telegram.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHmac } from 'crypto'; +import { ContextProvider } from '../../providers/contex.provider'; +import { TelegramGraphql } from './telegram.graphql'; +import { Authorizer } from '@authorizerdev/authorizer-js'; + +@Injectable() +export class TelegramService { + private readonly logger = new Logger(TelegramService.name); + + constructor( + private configService: ConfigService, + private telegramGraphql: TelegramGraphql + ) {} + + connect() { + const { telegramUserId } = ContextProvider.getAuthUser(); + return this.telegramGraphql.getTelegramUser({ + id: telegramUserId, + }); + } + + async link(email: string, password: string) { + const { telegramUserId } = ContextProvider.getAuthUser(); + + const query = ` + mutation userLogin($email: String!, $password: String!) { + login(params: {email: $email, password: $password}) { + user { + id + email + given_name + family_name + picture + roles + } + access_token + expires_in + message + } + } + `; + + const variables = { + email, + password, + }; + + const authRef = new Authorizer({ + redirectURL: this.configService.get('authorizer.redirectUrl'), // window.location.origin + authorizerURL: this.configService.get('authorizer.authorizerUrl'), + clientID: this.configService.get('authorizer.clientId'), // obtain your client id from authorizer dashboard + }); + + try { + const result = await authRef.graphqlQuery({ + query, + variables, + }); + + if (result.errors) return result; + + const userId = result.login.user.id; + return this.telegramGraphql.updateTelegramUser({ + id: telegramUserId, + user_id: userId, + }); + } catch (error) { + throw new UnauthorizedException(error.message); + } + } +}