This commit is contained in:
Халимов Рустам
2026-03-10 21:11:59 +03:00
parent a3c0d35263
commit b4b4b8e0d3
80 changed files with 23310 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
/**
* Полная очистка базы данных от тестовых данных.
* Удаляет ВСЕ: пользователей, чаты, сообщения, истории, дружбы.
* Таблицы и схема остаются на месте.
*
* Запуск: npx tsx prisma/clean-db.ts
*/
import { PrismaClient } from '@prisma/client';
import fs from 'fs';
import path from 'path';
const prisma = new PrismaClient();
async function cleanDatabase() {
console.log('⚠️ ВНИМАНИЕ: Полная очистка базы данных!\n');
// Удаляем в правильном порядке (зависимости → родители)
const counts: Record<string, number> = {};
// 1. Зависимые таблицы
const r1 = await prisma.hiddenMessage.deleteMany();
counts['HiddenMessage'] = r1.count;
const r2 = await prisma.readReceipt.deleteMany();
counts['ReadReceipt'] = r2.count;
const r3 = await prisma.reaction.deleteMany();
counts['Reaction'] = r3.count;
const r4 = await prisma.pinnedMessage.deleteMany();
counts['PinnedMessage'] = r4.count;
const r5 = await prisma.media.deleteMany();
counts['Media'] = r5.count;
const r6 = await prisma.storyView.deleteMany();
counts['StoryView'] = r6.count;
const r7 = await prisma.story.deleteMany();
counts['Story'] = r7.count;
// 2. Сообщения
const r8 = await prisma.message.deleteMany();
counts['Message'] = r8.count;
// 3. Чаты
const r9 = await prisma.chatMember.deleteMany();
counts['ChatMember'] = r9.count;
const r10 = await prisma.chat.deleteMany();
counts['Chat'] = r10.count;
// 4. Дружбы
const r11 = await prisma.friendship.deleteMany();
counts['Friendship'] = r11.count;
// 5. Пользователи
const r12 = await prisma.user.deleteMany();
counts['User'] = r12.count;
// 6. Чистка папки uploads (кроме avatars/.gitkeep)
const uploadsDir = path.join(__dirname, '..', 'uploads');
let filesDeleted = 0;
if (fs.existsSync(uploadsDir)) {
const entries = fs.readdirSync(uploadsDir);
for (const entry of entries) {
const fullPath = path.join(uploadsDir, entry);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
// Для папки avatars — очистить содержимое, но оставить папку
if (entry === 'avatars') {
const avatarFiles = fs.readdirSync(fullPath);
for (const f of avatarFiles) {
if (f === '.gitkeep') continue;
fs.unlinkSync(path.join(fullPath, f));
filesDeleted++;
}
}
} else {
// Файлы в корне uploads
if (entry !== '.gitkeep') {
fs.unlinkSync(fullPath);
filesDeleted++;
}
}
}
}
// Вывод результатов
console.log('┌──────────────────────────────────────┐');
console.log('│ 🧹 База данных очищена! │');
console.log('├──────────────────────────────────────┤');
for (const [table, count] of Object.entries(counts)) {
if (count > 0) {
console.log(`${table.padEnd(20)} ${String(count).padStart(6)} удалено │`);
}
}
if (filesDeleted > 0) {
console.log(`${'Файлы (uploads)'.padEnd(20)} ${String(filesDeleted).padStart(6)} удалено │`);
}
console.log('└──────────────────────────────────────┘');
console.log('\n✅ Готово. БД чистая, можно начинать с нуля.');
}
cleanDatabase()
.catch((e) => {
console.error('❌ Ошибка очистки:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

BIN
apps/server/prisma/dev.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,69 @@
/**
* Encrypt existing unencrypted files in the uploads directory.
*
* Run once after enabling ENCRYPTION_KEY:
* npx ts-node prisma/encrypt-existing-files.ts
*
* Safe to re-run — skips already-encrypted files (decryption test).
*/
import '../src/config'; // loads .env & initialises encryption
import path from 'path';
import fs from 'fs';
import { isEncryptionEnabled, encryptFileInPlace, decryptFileToBuffer } from '../src/encrypt';
const UPLOADS_ROOT = path.join(__dirname, '../uploads');
function walkDir(dir: string): string[] {
const files: string[] = [];
if (!fs.existsSync(dir)) return files;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walkDir(full));
} else {
files.push(full);
}
}
return files;
}
async function main() {
if (!isEncryptionEnabled()) {
console.error('❌ ENCRYPTION_KEY не задан в .env — сначала укажите ключ шифрования.');
process.exit(1);
}
console.log('🔒 Начало шифрования файлов в uploads/…\n');
const allFiles = walkDir(UPLOADS_ROOT);
console.log(`📁 Найдено ${allFiles.length} файлов`);
let encrypted = 0;
let skipped = 0;
for (const filePath of allFiles) {
const relPath = path.relative(UPLOADS_ROOT, filePath);
try {
// Try to decrypt — if it works, file is already encrypted
const decrypted = decryptFileToBuffer(filePath);
if (decrypted !== null) {
skipped++;
continue;
}
// File is not encrypted — encrypt it
encryptFileInPlace(filePath);
encrypted++;
process.stdout.write(`${encrypted} зашифровано, ${skipped} пропущено\r`);
} catch (e) {
console.error(`\n ❌ Ошибка с файлом ${relPath}:`, e);
}
}
console.log(`\n\n✅ Готово! Зашифровано ${encrypted} файлов, пропущено ${skipped} (уже зашифрованы).`);
}
main().catch((e) => {
console.error('Ошибка:', e);
process.exit(1);
});

View File

@@ -0,0 +1,67 @@
/**
* Migrate existing plain-text messages to encrypted form.
*
* Run once after enabling ENCRYPTION_KEY:
* npx ts-node prisma/encrypt-existing.ts
*
* Safe to re-run — skips already-encrypted messages ("enc:v1:" prefix).
*/
import '../src/config'; // loads .env & initialises encryption
import { PrismaClient } from '@prisma/client';
import { encryptText, isEncryptionEnabled } from '../src/encrypt';
// Use raw PrismaClient to bypass the encryption middleware (avoid double-encryption)
const rawPrisma = new PrismaClient();
async function main() {
if (!isEncryptionEnabled()) {
console.error('❌ ENCRYPTION_KEY не задан в .env — сначала укажите ключ шифрования.');
process.exit(1);
}
console.log('🔒 Начало шифрования существующих сообщений…\n');
// We bypass the Prisma middleware by using $queryRawUnsafe for the SELECT,
// then use raw UPDATE to avoid double-encryption via middleware.
const messages: Array<{ id: string; content: string | null; quote: string | null }> =
await rawPrisma.$queryRaw`
SELECT id, content, quote FROM "Message"
WHERE (content IS NOT NULL AND content != '' AND content NOT LIKE 'enc:v1:%')
OR (quote IS NOT NULL AND quote != '' AND quote NOT LIKE 'enc:v1:%')
`;
console.log(`📝 Найдено ${messages.length} незашифрованных сообщений`);
let encrypted = 0;
const BATCH_SIZE = 500;
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
const batch = messages.slice(i, i + BATCH_SIZE);
await rawPrisma.$transaction(
batch.map((msg) => {
const newContent = msg.content && !msg.content.startsWith('enc:v1:')
? encryptText(msg.content) : null;
const newQuote = msg.quote && !msg.quote.startsWith('enc:v1:')
? encryptText(msg.quote) : null;
return rawPrisma.$executeRaw`
UPDATE "Message"
SET content = COALESCE(${newContent}::text, content),
quote = COALESCE(${newQuote}::text, quote)
WHERE id = ${msg.id}
`;
})
);
encrypted += batch.length;
process.stdout.write(`${encrypted}/${messages.length}\r`);
}
console.log(`\n\n✅ Готово! Зашифровано ${encrypted} сообщений.`);
console.log('⚠ СОХРАНИТЕ КЛЮЧ ENCRYPTION_KEY В НАДЁЖНОМ МЕСТЕ — без него данные не восстановить!');
await rawPrisma.$disconnect();
}
main().catch((e) => {
console.error('Ошибка миграции:', e);
process.exit(1);
});

View File

@@ -0,0 +1,195 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
username String @unique
displayName String @default("")
email String? @unique
phone String?
password String
avatar String?
bio String?
birthday String?
createdAt DateTime @default(now())
lastSeen DateTime @default(now())
isOnline Boolean @default(false)
hideStoryViews Boolean @default(false)
registrationIp String?
messages Message[]
chatMembers ChatMember[]
reactions Reaction[]
readReceipts ReadReceipt[]
forwardedMessages Message[] @relation("ForwardedFrom")
stories Story[]
storyViews StoryView[]
friendshipsSent Friendship[] @relation("FriendshipsSent")
friendshipsReceived Friendship[] @relation("FriendshipsReceived")
}
model Chat {
id String @id @default(uuid())
type String @default("personal")
name String?
avatar String?
createdAt DateTime @default(now())
members ChatMember[]
messages Message[]
pinnedMessages PinnedMessage[]
}
model ChatMember {
id String @id @default(uuid())
chatId String
userId String
role String @default("member")
joinedAt DateTime @default(now())
isMuted Boolean @default(false)
isArchived Boolean @default(false)
isPinned Boolean @default(false)
clearedAt DateTime?
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([chatId, userId])
}
model Message {
id String @id @default(uuid())
chatId String
senderId String
content String?
type String @default("text")
replyToId String?
quote String?
forwardedFromId String?
isEdited Boolean @default(false)
isDeleted Boolean @default(false)
scheduledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
sender User @relation(fields: [senderId], references: [id])
replyTo Message? @relation("Reply", fields: [replyToId], references: [id])
replies Message[] @relation("Reply")
forwardedFrom User? @relation("ForwardedFrom", fields: [forwardedFromId], references: [id])
media Media[]
reactions Reaction[]
readBy ReadReceipt[]
pinnedIn PinnedMessage[]
hiddenBy HiddenMessage[]
}
model Media {
id String @id @default(uuid())
messageId String
type String
url String
filename String?
thumbnail String?
size Int?
duration Float?
width Int?
height Int?
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
}
model Reaction {
id String @id @default(uuid())
messageId String
userId String
emoji String
createdAt DateTime @default(now())
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([messageId, userId, emoji])
}
model ReadReceipt {
id String @id @default(uuid())
messageId String
userId String
readAt DateTime @default(now())
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([messageId, userId])
}
model PinnedMessage {
id String @id @default(uuid())
chatId String
messageId String
pinnedAt DateTime @default(now())
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@unique([chatId, messageId])
}
model Story {
id String @id @default(uuid())
userId String
type String @default("text")
mediaUrl String?
content String?
bgColor String?
createdAt DateTime @default(now())
expiresAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
views StoryView[]
}
model StoryView {
id String @id @default(uuid())
storyId String
userId String
viewedAt DateTime @default(now())
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@unique([storyId, userId])
}
model HiddenMessage {
id String @id @default(uuid())
messageId String
userId String
hiddenAt DateTime @default(now())
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@unique([messageId, userId])
}
model Friendship {
id String @id @default(uuid())
userId String
friendId String
status String @default("pending") // pending | accepted | declined
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation("FriendshipsSent", fields: [userId], references: [id], onDelete: Cascade)
friend User @relation("FriendshipsReceived", fields: [friendId], references: [id], onDelete: Cascade)
@@unique([userId, friendId])
}

View File

@@ -0,0 +1,48 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('Заполнение базы данных...\n');
const password = await bcrypt.hash('demo123', 10);
const usersData = [
{ username: 'evgeniy', displayName: 'Евгений', bio: 'Создатель Vortex' },
{ username: 'anastasia', displayName: 'Анастасия', bio: 'Дизайнер интерфейсов' },
{ username: 'artem', displayName: 'Артём', bio: 'Frontend разработчик' },
{ username: 'polina', displayName: 'Полина', bio: 'Backend разработчик' },
{ username: 'daniil', displayName: 'Даниил', bio: 'DevOps инженер' },
{ username: 'vladimir', displayName: 'Владимир', bio: 'Product Manager' },
];
const users = await Promise.all(
usersData.map((u) =>
prisma.user.upsert({
where: { username: u.username },
update: { displayName: u.displayName, bio: u.bio },
create: {
username: u.username,
displayName: u.displayName,
password,
bio: u.bio,
isOnline: false,
},
})
)
);
console.log(`Создано ${users.length} пользователей`);
console.log('\n--- Тестовые аккаунты ---');
console.log('Пароль для всех: demo123\n');
for (const user of users) {
console.log(` ${user.username} (${user.displayName})`);
}
console.log('\nЗаполнение завершено!');
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());