Compare commits
20 Commits
fd7409fe09
...
ac5cb96f97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac5cb96f97
|
||
|
|
2389d2c2c6
|
||
|
|
694031c05b
|
||
|
|
cbf7bfcb0a
|
||
|
|
9fb890699a
|
||
|
|
9439c004e2
|
||
|
|
27954daf64
|
||
|
|
7001082fb2
|
||
|
|
04ca5090df
|
||
|
|
19ceac1303
|
||
|
|
381ca24501
|
||
|
|
eefe2906ed
|
||
|
|
8ee0491c96
|
||
|
|
73aea94d88
|
||
|
|
7761e26d32
|
||
|
|
6c4f1694ba
|
||
|
|
0a84ad1595
|
||
|
|
43b4334971
|
||
|
|
07f905d7c9
|
||
|
|
72f3bb7723
|
@@ -1,25 +0,0 @@
|
||||
name: Backend Lint
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'backend/**'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Run lint
|
||||
run: pnpm -F @memegoat/backend lint
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Documentation Lint
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'documentation/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'documentation/**'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Run lint
|
||||
run: pnpm -F @memegoat/documentation lint
|
||||
@@ -1,11 +1,15 @@
|
||||
name: Frontend Lint
|
||||
name: Lint
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'backend/**'
|
||||
- 'documentation/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- 'backend/**'
|
||||
- 'documentation/**'
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -21,5 +25,12 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Run lint
|
||||
- name: Lint Frontend
|
||||
if: success() || failure()
|
||||
run: pnpm -F @memegoat/frontend lint
|
||||
- name: Lint Backend
|
||||
if: success() || failure()
|
||||
run: pnpm -F @memegoat/backend lint
|
||||
- name: Lint Documentation
|
||||
if: success() || failure()
|
||||
run: pnpm -F @bypass/documentation lint
|
||||
19
backend/drizzle.config.ts
Normal file
19
backend/drizzle.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import * as process from "node:process";
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/database/schemas/index.ts',
|
||||
out: '.migrations',
|
||||
dialect: "postgresql",
|
||||
casing: "snake_case",
|
||||
dbCredentials: {
|
||||
host: String(process.env.POSTGRES_HOST || "localhost"),
|
||||
port: Number(process.env.POSTGRES_PORT || 5432),
|
||||
database: String(process.env.POSTGRES_DB || "app"),
|
||||
user: String(process.env.POSTGRES_USER || "app"),
|
||||
password: String(process.env.POSTGRES_PASSWORD || "app"),
|
||||
ssl: false,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
@@ -17,12 +17,19 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -33,7 +40,9 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
@@ -42,6 +51,7 @@
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
|
||||
10
backend/src/database/database.module.ts
Normal file
10
backend/src/database/database.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { DatabaseService } from "./database.service";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
86
backend/src/database/database.service.ts
Normal file
86
backend/src/database/database.service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Yidhra Studio. - All Rights Reserved
|
||||
* Updated : 25/04/2025 10:52
|
||||
*
|
||||
* Unauthorized copying or redistribution of this file in source and binary forms via any medium
|
||||
* is strictly prohibited.
|
||||
*/
|
||||
|
||||
import {Injectable, Logger, OnModuleDestroy, OnModuleInit} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import * as schema from "./schemas";
|
||||
import {Pool} from "pg";
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseService.name);
|
||||
private readonly pool!: Pool
|
||||
public readonly db: ReturnType<typeof drizzle>;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService
|
||||
) {
|
||||
// Create the PostgreSQL client
|
||||
const connectionString = this.getDatabaseConnectionString();
|
||||
this.pool = new Pool({ connectionString });
|
||||
// Recreate drizzle with initialized pool
|
||||
this.db = drizzle(this.pool, { schema });
|
||||
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
// Run migrations if in production mode
|
||||
if (this.configService.get("NODE_ENV") === "production") {
|
||||
this.logger.log("Running database migrations...");
|
||||
await migrate(this.db, { migrationsFolder: ".migrations" });
|
||||
this.logger.log("Database migrations completed successfully");
|
||||
} else {
|
||||
this.logger.debug("Skipping migrations in non-production environment");
|
||||
}
|
||||
this.logger.log("Database connection established successfully");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to initialize database connection", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
// Close the database connection
|
||||
await this.pool.end();
|
||||
this.logger.log("Database connection closed successfully");
|
||||
} catch (error) {
|
||||
this.logger.error("Error closing database connection", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the database connection string from environment variables
|
||||
private getDatabaseConnectionString(): string {
|
||||
this.logger.debug('Getting database connection string from environment variables');
|
||||
|
||||
const password = this.configService.get<string>("POSTGRES_PASSWORD");
|
||||
const username = this.configService.get<string>("POSTGRES_USER");
|
||||
const host = this.configService.get<string>("POSTGRES_HOST");
|
||||
const port = this.configService.get<string>("POSTGRES_PORT");
|
||||
const database = this.configService.get<string>("POSTGRES_DB");
|
||||
|
||||
const missingVars: string[] = [];
|
||||
if (!password) missingVars.push("POSTGRES_PASSWORD");
|
||||
if (!username) missingVars.push("POSTGRES_USER");
|
||||
if (!host) missingVars.push("POSTGRES_HOST");
|
||||
if (!port) missingVars.push("POSTGRES_PORT");
|
||||
if (!database) missingVars.push("POSTGRES_DB");
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
const errorMessage = `Database configuration is missing. Missing variables: ${missingVars.join(", ")}. Please check your .env file.`;
|
||||
this.logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
this.logger.debug(`Database connection configured for ${username}@${host}:${port}/${database}`);
|
||||
return `postgres://${username}:${password}@${host}:${port}/${database}`;
|
||||
}
|
||||
}
|
||||
18
backend/src/database/schemas/api_keys.ts
Normal file
18
backend/src/database/schemas/api_keys.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { pgTable, varchar, timestamp, uuid, index, boolean } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
|
||||
export const apiKeys = pgTable('api_keys', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull().references(() => users.uuid, { onDelete: 'cascade' }),
|
||||
keyHash: varchar('key_hash', { length: 128 }).notNull().unique(), // Haché pour la sécurité (SHA-256)
|
||||
name: varchar('name', { length: 128 }).notNull(), // Nom donné par l'utilisateur (ex: "My App")
|
||||
prefix: varchar('prefix', { length: 8 }).notNull(), // Pour identification visuelle (ex: "mg_...")
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_keys_user_id_idx').on(table.userId),
|
||||
keyHashIdx: index('api_keys_key_hash_idx').on(table.keyHash),
|
||||
}));
|
||||
25
backend/src/database/schemas/audit_logs.ts
Normal file
25
backend/src/database/schemas/audit_logs.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { pgTable, varchar, timestamp, uuid, index, jsonb } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
|
||||
export const auditLogs = pgTable('audit_logs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').references(() => users.uuid, { onDelete: 'set null' }), // L'utilisateur qui a fait l'action
|
||||
action: varchar('action', { length: 64 }).notNull(), // ex: 'PII_ACCESS', 'USER_DELETE', 'ROLE_CHANGE'
|
||||
entityType: varchar('entity_type', { length: 64 }).notNull(), // ex: 'users', 'contents'
|
||||
entityId: uuid('entity_id'), // ID de l'entité concernée
|
||||
|
||||
// Détails de l'action pour la conformité
|
||||
details: jsonb('details'), // Données supplémentaires (ex: quelles colonnes ont changé)
|
||||
ipHash: varchar('ip_hash', { length: 64 }), // IP de l'auteur (hachée pour RGPD)
|
||||
userAgent: varchar('user_agent', { length: 255 }),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('audit_logs_user_id_idx').on(table.userId),
|
||||
actionIdx: index('audit_logs_action_idx').on(table.action),
|
||||
entityIdx: index('audit_logs_entity_idx').on(table.entityType, table.entityId),
|
||||
createdAtIdx: index('audit_logs_created_at_idx').on(table.createdAt),
|
||||
}));
|
||||
|
||||
export type AuditLogInDb = typeof auditLogs.$inferSelect;
|
||||
export type NewAuditLogInDb = typeof auditLogs.$inferInsert;
|
||||
32
backend/src/database/schemas/content.ts
Normal file
32
backend/src/database/schemas/content.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { pgTable, varchar, timestamp, uuid, pgEnum, index, primaryKey, integer } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
import { tags } from './tags';
|
||||
|
||||
export const contentType = pgEnum('content_type', ['meme', 'gif']);
|
||||
|
||||
export const contents = pgTable('contents', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull().references(() => users.uuid, { onDelete: 'cascade' }),
|
||||
type: contentType('type').notNull(),
|
||||
title: varchar('title', { length: 255 }).notNull(),
|
||||
storageKey: varchar('storage_key', { length: 512 }).notNull().unique(), // Clé interne S3
|
||||
mimeType: varchar('mime_type', { length: 128 }).notNull(), // Pour le Content-Type HTTP
|
||||
fileSize: integer('file_size').notNull(), // Taille en octets
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }), // Soft delete
|
||||
}, (table) => ({
|
||||
userIdIdx: index('contents_user_id_idx').on(table.userId),
|
||||
storageKeyIdx: index('contents_storage_key_idx').on(table.storageKey),
|
||||
deletedAtIdx: index('contents_deleted_at_idx').on(table.deletedAt),
|
||||
}));
|
||||
|
||||
export const contentsToTags = pgTable('contents_to_tags', {
|
||||
contentId: uuid('content_id').notNull().references(() => contents.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.contentId, t.tagId] }),
|
||||
}));
|
||||
|
||||
export type ContentInDb = typeof contents.$inferSelect;
|
||||
export type NewContentInDb = typeof contents.$inferInsert;
|
||||
8
backend/src/database/schemas/index.ts
Normal file
8
backend/src/database/schemas/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './users';
|
||||
export * from './rbac';
|
||||
export * from './sessions';
|
||||
export * from './api_keys';
|
||||
export * from './tags';
|
||||
export * from './content';
|
||||
export * from './reports';
|
||||
export * from './audit_logs';
|
||||
36
backend/src/database/schemas/rbac.ts
Normal file
36
backend/src/database/schemas/rbac.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { pgTable, varchar, timestamp, uuid, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
|
||||
export const roles = pgTable('roles', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: varchar('name', { length: 64 }).notNull().unique(),
|
||||
slug: varchar('slug', { length: 64 }).notNull().unique(),
|
||||
description: varchar('description', { length: 128 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
slugIdx: index('roles_slug_idx').on(table.slug),
|
||||
}));
|
||||
|
||||
export const permissions = pgTable('permissions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: varchar('name', { length: 64 }).notNull().unique(),
|
||||
slug: varchar('slug', { length: 64 }).notNull().unique(),
|
||||
description: varchar('description', { length: 128 }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
slugIdx: index('permissions_slug_idx').on(table.slug),
|
||||
}));
|
||||
|
||||
export const rolesToPermissions = pgTable('roles_to_permissions', {
|
||||
roleId: uuid('role_id').notNull().references(() => roles.id, { onDelete: 'cascade' }),
|
||||
permissionId: uuid('permission_id').notNull().references(() => permissions.id, { onDelete: 'cascade' }),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.roleId, t.permissionId] }),
|
||||
}));
|
||||
|
||||
export const usersToRoles = pgTable('users_to_roles', {
|
||||
userId: uuid('user_id').notNull().references(() => users.uuid, { onDelete: 'cascade' }),
|
||||
roleId: uuid('role_id').notNull().references(() => roles.id, { onDelete: 'cascade' }),
|
||||
}, (t) => ({
|
||||
pk: primaryKey({ columns: [t.userId, t.roleId] }),
|
||||
}));
|
||||
33
backend/src/database/schemas/reports.ts
Normal file
33
backend/src/database/schemas/reports.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { pgTable, varchar, timestamp, uuid, pgEnum, index, text } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
import { contents } from './content';
|
||||
import { tags } from './tags';
|
||||
|
||||
export const reportStatus = pgEnum('report_status', ['pending', 'reviewed', 'resolved', 'dismissed']);
|
||||
export const reportReason = pgEnum('report_reason', ['inappropriate', 'spam', 'copyright', 'other']);
|
||||
|
||||
export const reports = pgTable('reports', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
reporterId: uuid('reporter_id').notNull().references(() => users.uuid, { onDelete: 'cascade' }),
|
||||
|
||||
// Le signalement peut porter sur un contenu OU un tag
|
||||
contentId: uuid('content_id').references(() => contents.id, { onDelete: 'cascade' }),
|
||||
tagId: uuid('tag_id').references(() => tags.id, { onDelete: 'cascade' }),
|
||||
|
||||
reason: reportReason('reason').notNull(),
|
||||
description: text('description'),
|
||||
status: reportStatus('status').default('pending').notNull(),
|
||||
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }), // Pour purge automatique RGPD
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
reporterIdx: index('reports_reporter_id_idx').on(table.reporterId),
|
||||
contentIdx: index('reports_content_id_idx').on(table.contentId),
|
||||
tagIdx: index('reports_tag_id_idx').on(table.tagId),
|
||||
statusIdx: index('reports_status_idx').on(table.status),
|
||||
expiresAtIdx: index('reports_expires_at_idx').on(table.expiresAt),
|
||||
}));
|
||||
|
||||
export type ReportInDb = typeof reports.$inferSelect;
|
||||
export type NewReportInDb = typeof reports.$inferInsert;
|
||||
18
backend/src/database/schemas/sessions.ts
Normal file
18
backend/src/database/schemas/sessions.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { pgTable, varchar, timestamp, uuid, index, boolean } from 'drizzle-orm/pg-core';
|
||||
import { users } from './users';
|
||||
|
||||
export const sessions = pgTable('sessions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: uuid('user_id').notNull().references(() => users.uuid, { onDelete: 'cascade' }),
|
||||
refreshToken: varchar('refresh_token', { length: 512 }).notNull().unique(),
|
||||
userAgent: varchar('user_agent', { length: 255 }),
|
||||
ipHash: varchar('ip_hash', { length: 64 }), // IP hachée pour la protection de la vie privée (RGPD)
|
||||
isValid: boolean('is_valid').notNull().default(true),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('sessions_user_id_idx').on(table.userId),
|
||||
refreshTokenIdx: index('sessions_refresh_token_idx').on(table.refreshToken),
|
||||
expiresAtIdx: index('sessions_expires_at_idx').on(table.expiresAt),
|
||||
}));
|
||||
14
backend/src/database/schemas/tags.ts
Normal file
14
backend/src/database/schemas/tags.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { pgTable, varchar, timestamp, uuid, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const tags = pgTable('tags', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: varchar('name', { length: 64 }).notNull().unique(),
|
||||
slug: varchar('slug', { length: 64 }).notNull().unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => ({
|
||||
slugIdx: index('tags_slug_idx').on(table.slug),
|
||||
}));
|
||||
|
||||
export type TagInDb = typeof tags.$inferSelect;
|
||||
export type NewTagInDb = typeof tags.$inferInsert;
|
||||
46
backend/src/database/schemas/users.ts
Normal file
46
backend/src/database/schemas/users.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {pgTable, varchar, timestamp, uuid, pgEnum, index, boolean, customType} from 'drizzle-orm/pg-core';
|
||||
|
||||
// Type personnalisé pour les données chiffrées PGP (stockées en bytea dans Postgres)
|
||||
const pgpEncrypted = customType<{ data: string; driverData: string }>({
|
||||
dataType() {
|
||||
return 'bytea';
|
||||
},
|
||||
});
|
||||
|
||||
export const userStatus = pgEnum("user_status", ["active", "verification", "suspended", "pending", "deleted"])
|
||||
|
||||
export const users = pgTable('users', {
|
||||
uuid: uuid().primaryKey().defaultRandom(),
|
||||
status: userStatus('status').default('pending').notNull(),
|
||||
|
||||
// Données Personnelles (PII) - Chiffrées nativement
|
||||
email: pgpEncrypted('email').notNull(),
|
||||
emailHash: varchar('email_hash', { length: 64 }).notNull().unique(), // Indexé pour recherche rapide et unicité
|
||||
displayName: varchar('display_name', { length: 32 }),
|
||||
|
||||
username: varchar('username', { length: 32 }).notNull().unique(),
|
||||
passwordHash: varchar('password_hash', { length: 72 }).notNull(),
|
||||
|
||||
// Sécurité
|
||||
twoFactorSecret: pgpEncrypted('two_factor_secret'),
|
||||
isTwoFactorEnabled: boolean('is_two_factor_enabled').notNull().default(false),
|
||||
|
||||
// RGPD & Conformité
|
||||
termsVersion: varchar('terms_version', { length: 16 }), // Version des CGU acceptées
|
||||
privacyVersion: varchar('privacy_version', { length: 16 }), // Version de la Privacy Policy acceptée
|
||||
gdprAcceptedAt: timestamp('gdpr_accepted_at', { withTimezone: true }),
|
||||
|
||||
// Dates de cycle de vie (Standards Entreprise)
|
||||
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }), // Soft delete (Droit à l'oubli)
|
||||
}, (table) => ({
|
||||
uuidIdx: index('users_uuid_idx').on(table.uuid),
|
||||
emailHashIdx: index('users_email_hash_idx').on(table.emailHash),
|
||||
usernameIdx: index('users_username_idx').on(table.username),
|
||||
statusIdx: index('users_status_idx').on(table.status),
|
||||
}));
|
||||
|
||||
export type UserInDb = typeof users.$inferSelect;
|
||||
export type NewUserInDb = typeof users.$inferInsert;
|
||||
9
documentation/content/docs/_meta.json
Normal file
9
documentation/content/docs/_meta.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"index": "Introduction",
|
||||
"stack": "Stack Technologique",
|
||||
"database": "Modèle de Données",
|
||||
"api": "API & Intégrations",
|
||||
"security": "Sécurité",
|
||||
"compliance": "Conformité (RGPD)",
|
||||
"deployment": "Déploiement & Tests"
|
||||
}
|
||||
21
documentation/content/docs/api.mdx
Normal file
21
documentation/content/docs/api.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: API & Intégrations
|
||||
description: Documentation des API et services tiers
|
||||
---
|
||||
|
||||
## 🔌 API & Intégrations
|
||||
|
||||
### Documentation API
|
||||
|
||||
Documentation MDX.
|
||||
|
||||
### Authentification
|
||||
|
||||
Le système utilise plusieurs méthodes d'authentification sécurisées :
|
||||
- **Sessions (JWT)** : Utilisation de JSON Web Tokens signés pour les sessions utilisateurs via le web. Les sessions sont persistées en base de données (`sessions`) pour permettre la révocation (Logout) et le suivi des appareils connectés.
|
||||
- **API Keys** : Pour les intégrations programmatiques. Les clés sont hachées en base de données (`key_hash`) et associées à un utilisateur. Elles peuvent être nommées et révoquées individuellement.
|
||||
- **Double Authentification (2FA)** : Support natif (TOTP) avec secret chiffré en base de données.
|
||||
|
||||
### Webhooks / Services Externes
|
||||
|
||||
Liste des intégrations tierces.
|
||||
44
documentation/content/docs/compliance.mdx
Normal file
44
documentation/content/docs/compliance.mdx
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Conformité (RGPD & France)
|
||||
description: Mesures prises pour la conformité au RGPD et aux réglementations françaises.
|
||||
---
|
||||
|
||||
## ⚖️ Conformité Réglementaire
|
||||
|
||||
Le projet Memegoat s'inscrit dans une démarche de respect de la vie privée et de protection des données à caractère personnel, conformément au **Règlement Général sur la Protection des Données (RGPD)** de l'Union Européenne et aux recommandations de la **CNIL**.
|
||||
|
||||
### 🛡️ Principes Fondamentaux
|
||||
|
||||
- **Minimisation des données** : Seules les données strictement nécessaires au fonctionnement du service sont collectées.
|
||||
- **Transparence** : Les utilisateurs sont informés de la finalité des traitements de leurs données.
|
||||
- **Sécurité** : Mise en œuvre de mesures techniques et organisationnelles pour protéger les données.
|
||||
|
||||
### 🔒 Mesures Techniques de Protection
|
||||
|
||||
Conformément à la section [Sécurité](/docs/security), les mesures suivantes sont appliquées :
|
||||
- **Chiffrement au repos** : Utilisation de **PGP (pgcrypto)** pour les données identifiantes.
|
||||
- **Hachage aveugle** : Pour permettre les opérations sur données chiffrées sans compromettre la confidentialité.
|
||||
- **Hachage des mots de passe** : Utilisation de l'algorithme **Argon2id**.
|
||||
- **Communications sécurisées** : Utilisation de **TLS 1.3** via Caddy.
|
||||
|
||||
### 👤 Droits des Utilisateurs
|
||||
|
||||
Conformément au RGPD, les utilisateurs disposent des droits suivants, facilités par l'architecture technique :
|
||||
- **Droit à l'effacement (droit à l'oubli)** : Mis en œuvre via un mécanisme de **Soft Delete** (`deleted_at`), suivi d'une purge définitive des données après un délai de conservation légal.
|
||||
- **Droit d'accès et portabilité** : L'utilisation de schémas structurés (Drizzle/PostgreSQL) permet l'extraction facile des données d'un utilisateur sur demande.
|
||||
- **Gestion du consentement** : Suivi rigoureux des versions de CGU et de politique de confidentialité acceptées (`terms_version`, `privacy_version`, `gdpr_accepted_at`).
|
||||
|
||||
### ⏳ Conservation et Purge
|
||||
|
||||
- **Purge Automatique** : Les données liées aux signalements (`reports`) disposent d'une date d'expiration (`expires_at`) pour garantir qu'elles ne sont pas conservées au-delà du nécessaire.
|
||||
- **Anonymisation technique** : Les adresses IP stockées dans les tables `audit_logs` et `sessions` sont hachées (`ip_hash`), ce qui permet d'identifier des comportements malveillants tout en protégeant l'identité réelle de l'utilisateur.
|
||||
- **Logs d'Audit** : Les journaux d'audit sont conservés pendant une période glissante pour répondre aux obligations de sécurité tout en respectant la minimisation.
|
||||
|
||||
### 📍 Hébergement des Données
|
||||
|
||||
Les données sont hébergées au sein de l'Union Européenne sur des serveurs dédiés chez **Hetzner**, garantissant un cadre juridique protecteur pour les données des citoyens européens.
|
||||
|
||||
### 🔗 Références
|
||||
|
||||
- [Site officiel de la CNIL](https://www.cnil.fr/fr/reglement-europeen-protection-donnees)
|
||||
- [Texte officiel du RGPD](https://eur-lex.europa.eu/legal-content/FR/TXT/?uri=CELEX%3A32016R0679)
|
||||
257
documentation/content/docs/database.mdx
Normal file
257
documentation/content/docs/database.mdx
Normal file
@@ -0,0 +1,257 @@
|
||||
---
|
||||
title: Modèle de Données
|
||||
description: Structure et organisation des données
|
||||
---
|
||||
|
||||
## 📊 Modèle de Données
|
||||
|
||||
### Conceptuel (MCD)
|
||||
|
||||
Le Modèle Conceptuel de Données décrit les grandes entités du système et leurs relations métier, incluant la gestion des accès et la modération.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USER ||--o{ CONTENT : "publie"
|
||||
USER ||--o{ REPORT : "signale"
|
||||
USER ||--o{ USER_ROLE : "possede"
|
||||
USER ||--o{ SESSION : "detient"
|
||||
USER ||--o{ API_KEY : "genere"
|
||||
USER ||--o{ AUDIT_LOG : "genere"
|
||||
|
||||
CONTENT ||--o{ CONTENT_TAG : "possede"
|
||||
TAG ||--o{ CONTENT_TAG : "est_lie_a"
|
||||
CONTENT ||--o{ REPORT : "est_signale"
|
||||
TAG ||--o{ REPORT : "est_signale"
|
||||
|
||||
ROLE ||--o{ USER_ROLE : "attribue_a"
|
||||
ROLE ||--o{ ROLE_PERMISSION : "possede"
|
||||
PERMISSION ||--o{ ROLE_PERMISSION : "est_lie_a"
|
||||
|
||||
USER {
|
||||
string username
|
||||
string email
|
||||
string display_name
|
||||
string status
|
||||
}
|
||||
CONTENT {
|
||||
string title
|
||||
string type
|
||||
string storage_key
|
||||
}
|
||||
TAG {
|
||||
string name
|
||||
string slug
|
||||
}
|
||||
ROLE {
|
||||
string name
|
||||
string slug
|
||||
}
|
||||
REPORT {
|
||||
string reason
|
||||
string status
|
||||
}
|
||||
SESSION {
|
||||
string refresh_token
|
||||
boolean is_valid
|
||||
}
|
||||
API_KEY {
|
||||
string name
|
||||
string prefix
|
||||
boolean is_active
|
||||
}
|
||||
AUDIT_LOG {
|
||||
string action
|
||||
string entity_type
|
||||
jsonb details
|
||||
}
|
||||
```
|
||||
|
||||
### Logique (MLD)
|
||||
|
||||
Le Modèle Logique de Données précise les tables, les colonnes et les clés étrangères (FK).
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users {
|
||||
uuid uuid PK
|
||||
varchar username
|
||||
bytea email
|
||||
varchar email_hash
|
||||
varchar display_name
|
||||
varchar password_hash
|
||||
user_status status
|
||||
bytea two_factor_secret
|
||||
boolean is_two_factor_enabled
|
||||
varchar terms_version
|
||||
varchar privacy_version
|
||||
timestamp gdpr_accepted_at
|
||||
timestamp last_login_at
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
timestamp deleted_at
|
||||
}
|
||||
contents {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
content_type type
|
||||
varchar title
|
||||
varchar storage_key
|
||||
varchar mime_type
|
||||
integer file_size
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
timestamp deleted_at
|
||||
}
|
||||
tags {
|
||||
uuid id PK
|
||||
varchar name
|
||||
varchar slug
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
contents_to_tags {
|
||||
uuid content_id PK, FK
|
||||
uuid tag_id PK, FK
|
||||
}
|
||||
roles {
|
||||
uuid id PK
|
||||
varchar name
|
||||
varchar slug
|
||||
varchar description
|
||||
timestamp created_at
|
||||
}
|
||||
permissions {
|
||||
uuid id PK
|
||||
varchar name
|
||||
varchar slug
|
||||
varchar description
|
||||
timestamp created_at
|
||||
}
|
||||
roles_to_permissions {
|
||||
uuid role_id PK, FK
|
||||
uuid permission_id PK, FK
|
||||
}
|
||||
users_to_roles {
|
||||
uuid user_id PK, FK
|
||||
uuid role_id PK, FK
|
||||
}
|
||||
reports {
|
||||
uuid id PK
|
||||
uuid reporter_id FK
|
||||
uuid content_id FK
|
||||
uuid tag_id FK
|
||||
report_reason reason
|
||||
text description
|
||||
report_status status
|
||||
timestamp expires_at
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
sessions {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
varchar refresh_token
|
||||
varchar user_agent
|
||||
varchar ip_hash
|
||||
boolean is_valid
|
||||
timestamp expires_at
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
api_keys {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
varchar key_hash
|
||||
varchar name
|
||||
varchar prefix
|
||||
boolean is_active
|
||||
timestamp last_used_at
|
||||
timestamp expires_at
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
audit_logs {
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
varchar action
|
||||
varchar entity_type
|
||||
uuid entity_id
|
||||
jsonb details
|
||||
varchar ip_hash
|
||||
varchar user_agent
|
||||
timestamp created_at
|
||||
}
|
||||
|
||||
users ||--o{ contents : "user_id"
|
||||
users ||--o{ users_to_roles : "user_id"
|
||||
roles ||--o{ users_to_roles : "role_id"
|
||||
roles ||--o{ roles_to_permissions : "role_id"
|
||||
permissions ||--o{ roles_to_permissions : "permission_id"
|
||||
contents ||--o{ contents_to_tags : "content_id"
|
||||
tags ||--o{ contents_to_tags : "tag_id"
|
||||
users ||--o{ reports : "reporter_id"
|
||||
contents ||--o{ reports : "content_id"
|
||||
tags ||--o{ reports : "tag_id"
|
||||
users ||--o{ sessions : "user_id"
|
||||
users ||--o{ api_keys : "user_id"
|
||||
users ||--o{ audit_logs : "user_id"
|
||||
```
|
||||
|
||||
### Physique (MPD)
|
||||
|
||||
Le Modèle Physique de Données détaille l'implémentation spécifique pour **PostgreSQL**.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users {
|
||||
uuid uuid "DEFAULT gen_random_uuid()"
|
||||
bytea email "ENCRYPTED, NOT NULL"
|
||||
varchar email_hash "UNIQUE, INDEXED"
|
||||
varchar username "UNIQUE, NOT NULL"
|
||||
varchar password_hash "NOT NULL"
|
||||
bytea two_factor_secret "ENCRYPTED"
|
||||
boolean is_two_factor_enabled "DEFAULT false"
|
||||
timestamp gdpr_accepted_at "NULLABLE"
|
||||
timestamp deleted_at "SOFT DELETE"
|
||||
}
|
||||
contents {
|
||||
uuid id "DEFAULT gen_random_uuid()"
|
||||
uuid user_id "REFERENCES users(uuid)"
|
||||
varchar storage_key "UNIQUE, NOT NULL"
|
||||
integer file_size "NOT NULL"
|
||||
timestamp deleted_at "SOFT DELETE"
|
||||
}
|
||||
reports {
|
||||
uuid id "DEFAULT gen_random_uuid()"
|
||||
uuid reporter_id "REFERENCES users(uuid)"
|
||||
timestamp expires_at "RGPD PURGE"
|
||||
}
|
||||
api_keys {
|
||||
uuid id "DEFAULT gen_random_uuid()"
|
||||
varchar key_hash "UNIQUE, INDEXED, SHA-256"
|
||||
varchar prefix "NOT NULL"
|
||||
}
|
||||
audit_logs {
|
||||
uuid id "DEFAULT gen_random_uuid()"
|
||||
jsonb details "STORED AS JSONB"
|
||||
varchar ip_hash "RGPD COMPLIANT"
|
||||
}
|
||||
sessions {
|
||||
uuid id "DEFAULT gen_random_uuid()"
|
||||
varchar refresh_token "UNIQUE, NOT NULL"
|
||||
varchar ip_hash "RGPD COMPLIANT"
|
||||
}
|
||||
```
|
||||
|
||||
#### Sécurité et Chiffrement
|
||||
- **Chiffrement PGP (Native)** : Les colonnes `email` et `two_factor_secret` sont stockées au format `bytea` et chiffrées/déchiffrées via les fonctions `pgp_sym_encrypt` et `pgp_sym_decrypt` de PostgreSQL (via l'extension `pgcrypto`).
|
||||
- **Hachage aveugle (Blind Indexing)** : La colonne `email_hash` stocke un hash (SHA-256) de l'email pour permettre les recherches d'unicité et les recherches rapides sans déchiffrer la donnée.
|
||||
|
||||
#### Index et Optimisations
|
||||
- **Index B-Tree** systématiques sur toutes les clés étrangères (`user_id`, `role_id`, etc.).
|
||||
- **Index sur `deleted_at`** : Pour optimiser les requêtes excluant les données supprimées logiciellement.
|
||||
- **Index unique sur `email_hash`** et `username`.
|
||||
|
||||
#### Conformité RGPD
|
||||
- **Soft Delete** : Implémenté via `deleted_at` pour permettre le "droit à l'oubli" tout en conservant l'intégrité référentielle temporaire.
|
||||
- **Purge Automatique** : La colonne `expires_at` dans la table `reports` permet de programmer la suppression automatique des données de signalement après traitement.
|
||||
18
documentation/content/docs/deployment.mdx
Normal file
18
documentation/content/docs/deployment.mdx
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Déploiement & Tests
|
||||
description: Procédures de déploiement et stratégie de tests
|
||||
---
|
||||
|
||||
## 🚀 Déploiement
|
||||
|
||||
### Architecture de service
|
||||
|
||||
Un conteneur **Caddy** est utilisé en tant que reverse proxy pour fournir le TLS et la gestion du FQDN.
|
||||
|
||||
### Pré-requis
|
||||
|
||||
Liste des outils nécessaires (Node.js, pnpm, Docker).
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
- **Unitaires** : sur le backend
|
||||
@@ -1,13 +1,42 @@
|
||||
---
|
||||
title: Hello World
|
||||
description: Your first document
|
||||
title: Introduction
|
||||
description: Détails techniques du projet Memegoat
|
||||
---
|
||||
|
||||
Welcome to the docs! You can start writing documents in `/content/docs`.
|
||||
# 🐐 Détails Techniques - Memegoat
|
||||
|
||||
## What is Next?
|
||||
Ce document regroupe l'ensemble des spécifications techniques du projet Memegoat.
|
||||
|
||||
<Cards>
|
||||
<Card title="Learn more about Next.js" href="https://nextjs.org/docs" />
|
||||
<Card title="Learn more about Fumadocs" href="https://fumadocs.dev" />
|
||||
</Cards>
|
||||
## 🏗️ Architecture Globale
|
||||
|
||||
### Vue d'ensemble
|
||||
|
||||
Description de l'architecture en monorepo et des interactions entre les services.
|
||||
|
||||
### Diagrammes
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
User([Utilisateur])
|
||||
Caddy[Reverse Proxy: Caddy]
|
||||
Frontend[Frontend: Next.js]
|
||||
Backend[Backend: NestJS]
|
||||
DB[(Database: PostgreSQL)]
|
||||
Storage[Storage: S3 Compatible]
|
||||
|
||||
User <--> Caddy
|
||||
Caddy <--> Frontend
|
||||
Caddy <--> Backend
|
||||
Backend <--> DB
|
||||
Backend <--> Storage
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
Consultez les différentes sections pour plus de détails :
|
||||
- [Stack Technologique](/docs/stack)
|
||||
- [Modèle de Données](/docs/database)
|
||||
- [Sécurité](/docs/security)
|
||||
- [Conformité RGPD](/docs/compliance)
|
||||
- [API & Intégrations](/docs/api)
|
||||
- [Déploiement](/docs/deployment)
|
||||
|
||||
26
documentation/content/docs/security.mdx
Normal file
26
documentation/content/docs/security.mdx
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Sécurité
|
||||
description: Mesures de sécurité implémentées
|
||||
---
|
||||
|
||||
## 🔐 Sécurité
|
||||
|
||||
### Protection des Données (At Rest)
|
||||
|
||||
- **Chiffrement PGP Natif** : Les données identifiantes (PII) comme l'email, le nom d'affichage et le **secret 2FA** sont chiffrées dans PostgreSQL via `pgcrypto` (`pgp_sym_encrypt`). Les clés de déchiffrement ne sont jamais stockées en base de données.
|
||||
- **Hachage aveugle (Blind Indexing)** : Pour permettre la recherche et l'unicité sur les données chiffrées (comme l'email), un hash non réversible (SHA-256) est stocké séparément (`email_hash`).
|
||||
- **Hachage des mots de passe** : Utilisation d'**Argon2id** (via `@node-rs/argon2`), configuré selon les recommandations de l'ANSSI pour résister aux attaques par force brute et par table de correspondance.
|
||||
|
||||
### Sécurité des Communications (In Transit)
|
||||
|
||||
- **TLS 1.3** : Assuré par le reverse proxy **Caddy** avec renouvellement automatique des certificats Let's Encrypt.
|
||||
- **Protocoles d'Authentification** :
|
||||
- **Sessions (JWT)** : Les jetons de rafraîchissement (`refresh_token`) sont stockés de manière sécurisée en base de données. L'IP de l'utilisateur est hachée (`ip_hash`) pour concilier sécurité et respect de la vie privée.
|
||||
- **API Keys** : Les clés API sont hachées en base de données (**SHA-256**) via la colonne `key_hash`. Seul un préfixe est conservé en clair pour l'identification.
|
||||
|
||||
### Infrastructure & Défense
|
||||
|
||||
- **Rate Limiting** : Protection contre le brute-force et le déni de service (DoS).
|
||||
- **CORS Policy** : Restriction stricte des origines autorisées.
|
||||
- **RBAC (Role Based Access Control)** : Gestion granulaire des permissions avec une structure complète de rôles et de permissions liées (`roles`, `permissions`, `roles_to_permissions`).
|
||||
- **Audit Logs** : Traçabilité complète des actions sensibles via la table `audit_logs`. Elle enregistre l'action, l'entité concernée, les détails au format JSONB, ainsi que l'IP hachée et le User-Agent pour l'imputabilité.
|
||||
27
documentation/content/docs/stack.mdx
Normal file
27
documentation/content/docs/stack.mdx
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: Stack Technologique
|
||||
description: Technologies utilisées dans le projet Memegoat
|
||||
---
|
||||
|
||||
## 🛠️ Stack Technologique
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Framework** : NextJS
|
||||
- **Gestion d'état** : Zustand
|
||||
- **Style** : Tailwind CSS
|
||||
- **Composants UI** : Shadcn/ui
|
||||
|
||||
### Backend
|
||||
|
||||
- **Framework** : NestJS
|
||||
- **Langage** : TypeScript
|
||||
- **Base de données** : PostgresQL
|
||||
- **ORM** : DrizzleORM
|
||||
|
||||
### Infrastructure & DevOps
|
||||
|
||||
- **Conteneurisation** : Docker / Docker Compose
|
||||
- **Reverse Proxy & TLS** : Caddy
|
||||
- **CI/CD** : Gitea Actions
|
||||
- **Hébergement** : Hetzner Dedicated Server
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Components
|
||||
description: Components
|
||||
---
|
||||
|
||||
## Code Block
|
||||
|
||||
```js
|
||||
console.log('Hello World');
|
||||
```
|
||||
|
||||
## Cards
|
||||
|
||||
<Cards>
|
||||
<Card title="Learn more about Next.js" href="https://nextjs.org/docs" />
|
||||
<Card title="Learn more about Fumadocs" href="https://fumadocs.dev" />
|
||||
</Cards>
|
||||
920
pnpm-lock.yaml
generated
920
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user