Add Telegram ownership for voice reviews
All checks were successful
Build and deploy Backend / build (push) Successful in 1m1s
All checks were successful
Build and deploy Backend / build (push) Successful in 1m1s
This commit is contained in:
24
prisma/migrations/2_add_telegram_users/migration.sql
Normal file
24
prisma/migrations/2_add_telegram_users/migration.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "VoiceExperience" ADD COLUMN "userId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"telegramId" TEXT NOT NULL,
|
||||||
|
"username" TEXT,
|
||||||
|
"firstName" TEXT,
|
||||||
|
"lastName" TEXT,
|
||||||
|
"photoUrl" TEXT,
|
||||||
|
"languageCode" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_telegramId_key" ON "User"("telegramId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceExperience" ADD CONSTRAINT "VoiceExperience_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -27,10 +27,25 @@ model Place {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
telegramId String @unique
|
||||||
|
username String?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
photoUrl String?
|
||||||
|
languageCode String?
|
||||||
|
voiceExperiences VoiceExperience[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model VoiceExperience {
|
model VoiceExperience {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
placeId String
|
placeId String
|
||||||
place Place @relation(fields: [placeId], references: [id])
|
place Place @relation(fields: [placeId], references: [id])
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
durationSeconds Int
|
durationSeconds Int
|
||||||
audioObjectKey String
|
audioObjectKey String
|
||||||
status VoiceExperienceStatus @default(UPLOADED)
|
status VoiceExperienceStatus @default(UPLOADED)
|
||||||
|
|||||||
98
src/auth/telegram.ts
Normal file
98
src/auth/telegram.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { prisma } from '../prisma.js';
|
||||||
|
|
||||||
|
type TelegramInitDataUser = {
|
||||||
|
id: number;
|
||||||
|
username?: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
language_code?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hmacSha256(key: string | Buffer, value: string) {
|
||||||
|
return createHmac('sha256', key).update(value).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidHash(receivedHash: string, expectedHash: Buffer) {
|
||||||
|
const received = Buffer.from(receivedHash, 'hex');
|
||||||
|
if (received.length !== expectedHash.length) {
|
||||||
|
throw new Error('Telegram init data hash is invalid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timingSafeEqual(received, expectedHash)) {
|
||||||
|
throw new Error('Telegram init data hash is invalid.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTelegramInitData(initData: string) {
|
||||||
|
if (!config.telegramMiniAppBotToken) {
|
||||||
|
throw new Error('TELEGRAM_MINI_APP_BOT_TOKEN is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(initData);
|
||||||
|
const receivedHash = params.get('hash');
|
||||||
|
if (!receivedHash) {
|
||||||
|
throw new Error('Telegram init data hash is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDate = Number(params.get('auth_date'));
|
||||||
|
if (!Number.isFinite(authDate)) {
|
||||||
|
throw new Error('Telegram auth_date is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ageSeconds = Math.floor(Date.now() / 1000) - authDate;
|
||||||
|
if (ageSeconds > config.telegramAuthMaxAgeSeconds) {
|
||||||
|
throw new Error('Telegram init data is expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataCheckString = [...params.entries()]
|
||||||
|
.filter(([key]) => key !== 'hash')
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const secretKey = hmacSha256('WebAppData', config.telegramMiniAppBotToken);
|
||||||
|
const expectedHash = hmacSha256(secretKey, dataCheckString);
|
||||||
|
assertValidHash(receivedHash, expectedHash);
|
||||||
|
|
||||||
|
const rawUser = params.get('user');
|
||||||
|
if (!rawUser) {
|
||||||
|
throw new Error('Telegram user is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(rawUser) as TelegramInitDataUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCreateTelegramUser(initData: string) {
|
||||||
|
const user = parseTelegramInitData(initData);
|
||||||
|
|
||||||
|
return prisma.user.upsert({
|
||||||
|
where: { telegramId: String(user.id) },
|
||||||
|
create: {
|
||||||
|
telegramId: String(user.id),
|
||||||
|
username: user.username,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
photoUrl: user.photo_url,
|
||||||
|
languageCode: user.language_code,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
username: user.username,
|
||||||
|
firstName: user.first_name,
|
||||||
|
lastName: user.last_name,
|
||||||
|
photoUrl: user.photo_url,
|
||||||
|
languageCode: user.language_code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireTelegramUser(initData?: string) {
|
||||||
|
if (!initData) {
|
||||||
|
throw new Error('Telegram authorization is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOrCreateTelegramUser(initData);
|
||||||
|
}
|
||||||
@@ -5,4 +5,8 @@ export const config = {
|
|||||||
port: Number(process.env.PORT ?? '4000'),
|
port: Number(process.env.PORT ?? '4000'),
|
||||||
databaseUrl: process.env.DATABASE_URL ?? '',
|
databaseUrl: process.env.DATABASE_URL ?? '',
|
||||||
hatchetToken: process.env.HATCHET_CLIENT_TOKEN ?? '',
|
hatchetToken: process.env.HATCHET_CLIENT_TOKEN ?? '',
|
||||||
|
telegramMiniAppBotToken: process.env.TELEGRAM_MINI_APP_BOT_TOKEN ?? '',
|
||||||
|
telegramAuthMaxAgeSeconds: Number(
|
||||||
|
process.env.TELEGRAM_AUTH_MAX_AGE_SECONDS ?? '86400',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -130,9 +130,22 @@ exports.Prisma.PlaceScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.UserScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
telegramId: 'telegramId',
|
||||||
|
username: 'username',
|
||||||
|
firstName: 'firstName',
|
||||||
|
lastName: 'lastName',
|
||||||
|
photoUrl: 'photoUrl',
|
||||||
|
languageCode: 'languageCode',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
updatedAt: 'updatedAt'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.VoiceExperienceScalarFieldEnum = {
|
exports.Prisma.VoiceExperienceScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
placeId: 'placeId',
|
placeId: 'placeId',
|
||||||
|
userId: 'userId',
|
||||||
durationSeconds: 'durationSeconds',
|
durationSeconds: 'durationSeconds',
|
||||||
audioObjectKey: 'audioObjectKey',
|
audioObjectKey: 'audioObjectKey',
|
||||||
status: 'status',
|
status: 'status',
|
||||||
@@ -157,16 +170,16 @@ exports.Prisma.QueryMode = {
|
|||||||
insensitive: 'insensitive'
|
insensitive: 'insensitive'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.Prisma.NullsOrder = {
|
||||||
|
first: 'first',
|
||||||
|
last: 'last'
|
||||||
|
};
|
||||||
|
|
||||||
exports.Prisma.JsonNullValueFilter = {
|
exports.Prisma.JsonNullValueFilter = {
|
||||||
DbNull: Prisma.DbNull,
|
DbNull: Prisma.DbNull,
|
||||||
JsonNull: Prisma.JsonNull,
|
JsonNull: Prisma.JsonNull,
|
||||||
AnyNull: Prisma.AnyNull
|
AnyNull: Prisma.AnyNull
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.Prisma.NullsOrder = {
|
|
||||||
first: 'first',
|
|
||||||
last: 'last'
|
|
||||||
};
|
|
||||||
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
|
exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
|
||||||
UPLOADED: 'UPLOADED',
|
UPLOADED: 'UPLOADED',
|
||||||
TRANSCRIBING: 'TRANSCRIBING',
|
TRANSCRIBING: 'TRANSCRIBING',
|
||||||
@@ -178,6 +191,7 @@ exports.VoiceExperienceStatus = exports.$Enums.VoiceExperienceStatus = {
|
|||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Place: 'Place',
|
Place: 'Place',
|
||||||
|
User: 'User',
|
||||||
VoiceExperience: 'VoiceExperience'
|
VoiceExperience: 'VoiceExperience'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
1919
src/generated/prisma/index.d.ts
vendored
1919
src/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-5316d8e66293a4954be6e26169692366997bf25fe9186e98c87e62eed3dc691e",
|
"name": "prisma-client-359590a92aedb32557346cd24ba7dd77e7dd4a806bf36f6641715b1b22de87e3",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "default.js",
|
"browser": "default.js",
|
||||||
|
|||||||
@@ -27,10 +27,25 @@ model Place {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
telegramId String @unique
|
||||||
|
username String?
|
||||||
|
firstName String?
|
||||||
|
lastName String?
|
||||||
|
photoUrl String?
|
||||||
|
languageCode String?
|
||||||
|
voiceExperiences VoiceExperience[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model VoiceExperience {
|
model VoiceExperience {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
placeId String
|
placeId String
|
||||||
place Place @relation(fields: [placeId], references: [id])
|
place Place @relation(fields: [placeId], references: [id])
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
durationSeconds Int
|
durationSeconds Int
|
||||||
audioObjectKey String
|
audioObjectKey String
|
||||||
status VoiceExperienceStatus @default(UPLOADED)
|
status VoiceExperienceStatus @default(UPLOADED)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export async function listPlaces() {
|
|||||||
experiences: {
|
experiences: {
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 10,
|
take: 10,
|
||||||
|
include: { user: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
@@ -28,7 +29,7 @@ export async function listPlaces() {
|
|||||||
|
|
||||||
export async function listVoiceExperiences() {
|
export async function listVoiceExperiences() {
|
||||||
const experiences = await prisma.voiceExperience.findMany({
|
const experiences = await prisma.voiceExperience.findMany({
|
||||||
include: { place: true },
|
include: { place: true, user: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { GraphQLJSONObject } from './scalars.js';
|
import { GraphQLJSONObject } from './scalars.js';
|
||||||
|
import { getOrCreateTelegramUser, requireTelegramUser } from '../auth/telegram.js';
|
||||||
import { listPlaces, listVoiceExperiences } from './places.js';
|
import { listPlaces, listVoiceExperiences } from './places.js';
|
||||||
import { createVoiceExperience } from './voice-experiences.js';
|
import { createVoiceExperience } from './voice-experiences.js';
|
||||||
|
|
||||||
|
export type GraphqlContext = {
|
||||||
|
telegramInitData?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const schema = /* GraphQL */ `
|
export const schema = /* GraphQL */ `
|
||||||
scalar JSON
|
scalar JSON
|
||||||
|
|
||||||
@@ -23,9 +28,20 @@ export const schema = /* GraphQL */ `
|
|||||||
experiences: [VoiceExperience!]!
|
experiences: [VoiceExperience!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
telegramId: String!
|
||||||
|
username: String
|
||||||
|
firstName: String
|
||||||
|
lastName: String
|
||||||
|
photoUrl: String
|
||||||
|
languageCode: String
|
||||||
|
}
|
||||||
|
|
||||||
type VoiceExperience {
|
type VoiceExperience {
|
||||||
id: ID!
|
id: ID!
|
||||||
place: Place!
|
place: Place!
|
||||||
|
user: User
|
||||||
status: VoiceExperienceStatus!
|
status: VoiceExperienceStatus!
|
||||||
durationSeconds: Int!
|
durationSeconds: Int!
|
||||||
audioObjectKey: String!
|
audioObjectKey: String!
|
||||||
@@ -43,6 +59,14 @@ export const schema = /* GraphQL */ `
|
|||||||
audioObjectKey: String!
|
audioObjectKey: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input AuthenticateTelegramInput {
|
||||||
|
initData: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthPayload {
|
||||||
|
user: User!
|
||||||
|
}
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
health: String!
|
health: String!
|
||||||
places: [Place!]!
|
places: [Place!]!
|
||||||
@@ -50,6 +74,7 @@ export const schema = /* GraphQL */ `
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
authenticateTelegram(input: AuthenticateTelegramInput!): AuthPayload!
|
||||||
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
|
createVoiceExperience(input: CreateVoiceExperienceInput!): VoiceExperience!
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -62,9 +87,18 @@ export const resolvers = {
|
|||||||
voiceExperiences: () => listVoiceExperiences(),
|
voiceExperiences: () => listVoiceExperiences(),
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
|
authenticateTelegram: async (
|
||||||
|
_: unknown,
|
||||||
|
args: { input: { initData: string } },
|
||||||
|
) => ({ user: await getOrCreateTelegramUser(args.input.initData) }),
|
||||||
createVoiceExperience: async (
|
createVoiceExperience: async (
|
||||||
_: unknown,
|
_: unknown,
|
||||||
args: { input: Parameters<typeof createVoiceExperience>[0] },
|
args: { input: Parameters<typeof createVoiceExperience>[0] },
|
||||||
) => createVoiceExperience(args.input),
|
context: unknown,
|
||||||
|
) => {
|
||||||
|
const graphqlContext = context as GraphqlContext;
|
||||||
|
const user = await requireTelegramUser(graphqlContext.telegramInitData);
|
||||||
|
return createVoiceExperience(args.input, user.id);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export type CreateVoiceExperienceInput = {
|
|||||||
audioObjectKey: string;
|
audioObjectKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createVoiceExperience(input: CreateVoiceExperienceInput) {
|
export async function createVoiceExperience(
|
||||||
|
input: CreateVoiceExperienceInput,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
if (input.durationSeconds < minimumVoiceExperienceSeconds) {
|
if (input.durationSeconds < minimumVoiceExperienceSeconds) {
|
||||||
throw new Error('Voice experience must be at least 30 seconds.');
|
throw new Error('Voice experience must be at least 30 seconds.');
|
||||||
}
|
}
|
||||||
@@ -35,11 +38,12 @@ export async function createVoiceExperience(input: CreateVoiceExperienceInput) {
|
|||||||
const experience = await prisma.voiceExperience.create({
|
const experience = await prisma.voiceExperience.create({
|
||||||
data: {
|
data: {
|
||||||
placeId: place.id,
|
placeId: place.id,
|
||||||
|
userId,
|
||||||
durationSeconds: input.durationSeconds,
|
durationSeconds: input.durationSeconds,
|
||||||
audioObjectKey: input.audioObjectKey,
|
audioObjectKey: input.audioObjectKey,
|
||||||
status: 'UPLOADED',
|
status: 'UPLOADED',
|
||||||
},
|
},
|
||||||
include: { place: true },
|
include: { place: true, user: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await enqueueVoiceExperience({ experienceId: experience.id });
|
await enqueueVoiceExperience({ experienceId: experience.id });
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ app.register(mercurius, {
|
|||||||
schema,
|
schema,
|
||||||
resolvers,
|
resolvers,
|
||||||
graphiql: true,
|
graphiql: true,
|
||||||
|
context: async (request) => {
|
||||||
|
const header = request.headers['x-telegram-init-data'];
|
||||||
|
return {
|
||||||
|
telegramInitData: Array.isArray(header) ? header[0] : header,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/health', async () => ({ ok: true }));
|
app.get('/health', async () => ({ ok: true }));
|
||||||
|
|||||||
Reference in New Issue
Block a user