diff --git a/backend/.migrations/0006_friendly_adam_warlock.sql b/backend/.migrations/0006_friendly_adam_warlock.sql new file mode 100644 index 0000000..7019dbc --- /dev/null +++ b/backend/.migrations/0006_friendly_adam_warlock.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "avatar_url" varchar(512);--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "bio" varchar(255); \ No newline at end of file diff --git a/backend/.migrations/meta/0006_snapshot.json b/backend/.migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..9088ab8 --- /dev/null +++ b/backend/.migrations/meta/0006_snapshot.json @@ -0,0 +1,1652 @@ +{ + "id": "538dd2c7-758f-41bd-8b34-f5a6b6b289dd", + "prevId": "6dbf8343-dced-4b01-a3fd-8c246b0b88e8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_uuid_fk": { + "name": "api_keys_user_id_users_uuid_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_action_idx": { + "name": "audit_logs_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_entity_idx": { + "name": "audit_logs_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_uuid_fk": { + "name": "audit_logs_user_id_users_uuid_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "icon_url": { + "name": "icon_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categories_slug_idx": { + "name": "categories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contents": { + "name": "contents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "content_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "contents_user_id_idx": { + "name": "contents_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contents_storage_key_idx": { + "name": "contents_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contents_deleted_at_idx": { + "name": "contents_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contents_user_id_users_uuid_fk": { + "name": "contents_user_id_users_uuid_fk", + "tableFrom": "contents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contents_category_id_categories_id_fk": { + "name": "contents_category_id_categories_id_fk", + "tableFrom": "contents", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "contents_slug_unique": { + "name": "contents_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "contents_storage_key_unique": { + "name": "contents_storage_key_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contents_to_tags": { + "name": "contents_to_tags", + "schema": "", + "columns": { + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "contents_to_tags_content_id_contents_id_fk": { + "name": "contents_to_tags_content_id_contents_id_fk", + "tableFrom": "contents_to_tags", + "tableTo": "contents", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contents_to_tags_tag_id_tags_id_fk": { + "name": "contents_to_tags_tag_id_tags_id_fk", + "tableFrom": "contents_to_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contents_to_tags_content_id_tag_id_pk": { + "name": "contents_to_tags_content_id_tag_id_pk", + "columns": [ + "content_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_uuid_fk": { + "name": "favorites_user_id_users_uuid_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_content_id_contents_id_fk": { + "name": "favorites_content_id_contents_id_fk", + "tableFrom": "favorites", + "tableTo": "contents", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "favorites_user_id_content_id_pk": { + "name": "favorites_user_id_content_id_pk", + "columns": [ + "user_id", + "content_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_slug_idx": { + "name": "permissions_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "permissions_name_unique": { + "name": "permissions_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "permissions_slug_unique": { + "name": "permissions_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "roles_slug_idx": { + "name": "roles_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "roles_name_unique": { + "name": "roles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "roles_slug_unique": { + "name": "roles_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles_to_permissions": { + "name": "roles_to_permissions", + "schema": "", + "columns": { + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "roles_to_permissions_role_id_roles_id_fk": { + "name": "roles_to_permissions_role_id_roles_id_fk", + "tableFrom": "roles_to_permissions", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "roles_to_permissions_permission_id_permissions_id_fk": { + "name": "roles_to_permissions_permission_id_permissions_id_fk", + "tableFrom": "roles_to_permissions", + "tableTo": "permissions", + "columnsFrom": [ + "permission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "roles_to_permissions_role_id_permission_id_pk": { + "name": "roles_to_permissions_role_id_permission_id_pk", + "columns": [ + "role_id", + "permission_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users_to_roles": { + "name": "users_to_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "users_to_roles_user_id_users_uuid_fk": { + "name": "users_to_roles_user_id_users_uuid_fk", + "tableFrom": "users_to_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "users_to_roles_role_id_roles_id_fk": { + "name": "users_to_roles_role_id_roles_id_fk", + "tableFrom": "users_to_roles", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_roles_user_id_role_id_pk": { + "name": "users_to_roles_user_id_role_id_pk", + "columns": [ + "user_id", + "role_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_id": { + "name": "content_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_reporter_id_idx": { + "name": "reports_reporter_id_idx", + "columns": [ + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_content_id_idx": { + "name": "reports_content_id_idx", + "columns": [ + { + "expression": "content_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_tag_id_idx": { + "name": "reports_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_expires_at_idx": { + "name": "reports_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_uuid_fk": { + "name": "reports_reporter_id_users_uuid_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_content_id_contents_id_fk": { + "name": "reports_content_id_contents_id_fk", + "tableFrom": "reports", + "tableTo": "contents", + "columnsFrom": [ + "content_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_tag_id_tags_id_fk": { + "name": "reports_tag_id_tags_id_fk", + "tableFrom": "reports", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "is_valid": { + "name": "is_valid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_refresh_token_idx": { + "name": "sessions_refresh_token_idx", + "columns": [ + { + "expression": "refresh_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_uuid_fk": { + "name": "sessions_user_id_users_uuid_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_refresh_token_unique": { + "name": "sessions_refresh_token_unique", + "nullsNotDistinct": false, + "columns": [ + "refresh_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tags_slug_idx": { + "name": "tags_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_user_id_users_uuid_fk": { + "name": "tags_user_id_users_uuid_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "tags_slug_unique": { + "name": "tags_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "uuid": { + "name": "uuid", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email": { + "name": "email", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "two_factor_secret": { + "name": "two_factor_secret", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "is_two_factor_enabled": { + "name": "is_two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "terms_version": { + "name": "terms_version", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "privacy_version": { + "name": "privacy_version", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "gdpr_accepted_at": { + "name": "gdpr_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_uuid_idx": { + "name": "users_uuid_idx", + "columns": [ + { + "expression": "uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_hash_idx": { + "name": "users_email_hash_idx", + "columns": [ + { + "expression": "email_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_status_idx": { + "name": "users_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_hash_unique": { + "name": "users_email_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "email_hash" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.content_type": { + "name": "content_type", + "schema": "public", + "values": [ + "meme", + "gif" + ] + }, + "public.report_reason": { + "name": "report_reason", + "schema": "public", + "values": [ + "inappropriate", + "spam", + "copyright", + "other" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "pending", + "reviewed", + "resolved", + "dismissed" + ] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": [ + "active", + "verification", + "suspended", + "pending", + "deleted" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/.migrations/meta/_journal.json b/backend/.migrations/meta/_journal.json index d88fd1c..cd41171 100644 --- a/backend/.migrations/meta/_journal.json +++ b/backend/.migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1768420201679, "tag": "0005_perpetual_silverclaw", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1768423315172, + "tag": "0006_friendly_adam_warlock", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 446163f..4518a44 100644 --- a/backend/package.json +++ b/backend/package.json @@ -107,7 +107,7 @@ "coverageDirectory": "../coverage", "testEnvironment": "node", "transformIgnorePatterns": [ - "node_modules/(?!(jose|@noble)/)" + "node_modules/(?!(.pnpm/)?(jose|@noble|uuid)/)" ], "transform": { "^.+\\.(t|j)sx?$": "ts-jest" diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000..08acf29 --- /dev/null +++ b/backend/src/admin/admin.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { Roles } from "../auth/decorators/roles.decorator"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { RolesGuard } from "../auth/guards/roles.guard"; +import { AdminService } from "./admin.service"; + +@Controller("admin") +@UseGuards(AuthGuard, RolesGuard) +@Roles("admin") +export class AdminController { + constructor(private readonly adminService: AdminService) {} + + @Get("stats") + getStats() { + return this.adminService.getStats(); + } +} diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..b4f9ffc --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; +import { CategoriesModule } from "../categories/categories.module"; +import { ContentsModule } from "../contents/contents.module"; +import { UsersModule } from "../users/users.module"; +import { AdminController } from "./admin.controller"; +import { AdminService } from "./admin.service"; + +@Module({ + imports: [AuthModule, UsersModule, ContentsModule, CategoriesModule], + controllers: [AdminController], + providers: [AdminService], +}) +export class AdminModule {} diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts new file mode 100644 index 0000000..6c8dc0f --- /dev/null +++ b/backend/src/admin/admin.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from "@nestjs/common"; +import { CategoriesRepository } from "../categories/repositories/categories.repository"; +import { ContentsRepository } from "../contents/repositories/contents.repository"; +import { UsersRepository } from "../users/repositories/users.repository"; + +@Injectable() +export class AdminService { + constructor( + private readonly usersRepository: UsersRepository, + private readonly contentsRepository: ContentsRepository, + private readonly categoriesRepository: CategoriesRepository, + ) {} + + async getStats() { + const [userCount, contentCount, categoryCount] = await Promise.all([ + this.usersRepository.countAll(), + this.contentsRepository.count({}), + this.categoriesRepository.countAll(), + ]); + + return { + users: userCount, + contents: contentCount, + categories: categoryCount, + }; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 41608f7..5b46aab 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config"; import { ScheduleModule } from "@nestjs/schedule"; import { ThrottlerModule } from "@nestjs/throttler"; import { redisStore } from "cache-manager-redis-yet"; +import { AdminModule } from "./admin/admin.module"; import { ApiKeysModule } from "./api-keys/api-keys.module"; import { AppController } from "./app.controller"; import { AppService } from "./app.service"; @@ -42,6 +43,7 @@ import { UsersModule } from "./users/users.module"; SessionsModule, ReportsModule, ApiKeysModule, + AdminModule, ScheduleModule.forRoot(), ThrottlerModule.forRootAsync({ imports: [ConfigModule], diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 303727c..015222c 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -5,6 +5,9 @@ import { SessionsModule } from "../sessions/sessions.module"; import { UsersModule } from "../users/users.module"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; +import { AuthGuard } from "./guards/auth.guard"; +import { OptionalAuthGuard } from "./guards/optional-auth.guard"; +import { RolesGuard } from "./guards/roles.guard"; import { RbacService } from "./rbac.service"; import { RbacRepository } from "./repositories/rbac.repository"; @@ -16,7 +19,21 @@ import { RbacRepository } from "./repositories/rbac.repository"; DatabaseModule, ], controllers: [AuthController], - providers: [AuthService, RbacService, RbacRepository], - exports: [AuthService, RbacService, RbacRepository], + providers: [ + AuthService, + RbacService, + RbacRepository, + AuthGuard, + OptionalAuthGuard, + RolesGuard, + ], + exports: [ + AuthService, + RbacService, + RbacRepository, + AuthGuard, + OptionalAuthGuard, + RolesGuard, + ], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 4164ad7..6564cb8 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -1,3 +1,7 @@ +jest.mock("uuid", () => ({ + v4: jest.fn(() => "mocked-uuid"), +})); + import { Test, TestingModule } from "@nestjs/testing"; jest.mock("@noble/post-quantum/ml-kem.js", () => ({ diff --git a/backend/src/auth/guards/optional-auth.guard.ts b/backend/src/auth/guards/optional-auth.guard.ts new file mode 100644 index 0000000..0da01cf --- /dev/null +++ b/backend/src/auth/guards/optional-auth.guard.ts @@ -0,0 +1,39 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { getIronSession } from "iron-session"; +import { JwtService } from "../../crypto/services/jwt.service"; +import { getSessionOptions, SessionData } from "../session.config"; + +@Injectable() +export class OptionalAuthGuard implements CanActivate { + constructor( + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + + const session = await getIronSession( + request, + response, + getSessionOptions(this.configService.get("SESSION_PASSWORD") as string), + ); + + const token = session.accessToken; + + if (!token) { + return true; + } + + try { + const payload = await this.jwtService.verifyJwt(token); + request.user = payload; + } catch { + // Ignore invalid tokens for optional auth + } + + return true; + } +} diff --git a/backend/src/categories/repositories/categories.repository.ts b/backend/src/categories/repositories/categories.repository.ts index a15b7c8..6545d64 100644 --- a/backend/src/categories/repositories/categories.repository.ts +++ b/backend/src/categories/repositories/categories.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { DatabaseService } from "../../database/database.service"; import { categories } from "../../database/schemas"; import type { CreateCategoryDto } from "../dto/create-category.dto"; @@ -16,6 +16,13 @@ export class CategoriesRepository { .orderBy(categories.name); } + async countAll() { + const result = await this.databaseService.db + .select({ count: sql`count(*)` }) + .from(categories); + return Number(result[0].count); + } + async findOne(id: string) { const result = await this.databaseService.db .select() diff --git a/backend/src/common/interfaces/media.interface.ts b/backend/src/common/interfaces/media.interface.ts index 0f5e548..e043a0b 100644 --- a/backend/src/common/interfaces/media.interface.ts +++ b/backend/src/common/interfaces/media.interface.ts @@ -17,6 +17,7 @@ export interface IMediaService { processImage( buffer: Buffer, format?: "webp" | "avif", + resize?: { width?: number; height?: number }, ): Promise; processVideo( buffer: Buffer, diff --git a/backend/src/contents/contents.controller.ts b/backend/src/contents/contents.controller.ts index cbdcf7b..8743f45 100644 --- a/backend/src/contents/contents.controller.ts +++ b/backend/src/contents/contents.controller.ts @@ -19,8 +19,11 @@ import { UseInterceptors, } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; -import type { Request, Response } from "express"; +import type { Response } from "express"; +import { Roles } from "../auth/decorators/roles.decorator"; import { AuthGuard } from "../auth/guards/auth.guard"; +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard"; +import { RolesGuard } from "../auth/guards/roles.guard"; import type { AuthenticatedRequest } from "../common/interfaces/request.interface"; import { ContentsService } from "./contents.service"; import { CreateContentDto } from "./dto/create-content.dto"; @@ -65,10 +68,12 @@ export class ContentsController { } @Get("explore") + @UseGuards(OptionalAuthGuard) @UseInterceptors(CacheInterceptor) @CacheTTL(60) @Header("Cache-Control", "public, max-age=60") explore( + @Req() req: AuthenticatedRequest, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, @Query("sort") sort?: "trend" | "recent", @@ -78,7 +83,7 @@ export class ContentsController { @Query("query") query?: string, @Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe) favoritesOnly?: boolean, - @Query("userId") userId?: string, + @Query("userId") userIdQuery?: string, ) { return this.contentsService.findAll({ limit, @@ -89,42 +94,57 @@ export class ContentsController { author, query, favoritesOnly, - userId, + userId: userIdQuery || req.user?.sub, }); } @Get("trends") + @UseGuards(OptionalAuthGuard) @UseInterceptors(CacheInterceptor) @CacheTTL(300) @Header("Cache-Control", "public, max-age=300") trends( + @Req() req: AuthenticatedRequest, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, ) { - return this.contentsService.findAll({ limit, offset, sortBy: "trend" }); + return this.contentsService.findAll({ + limit, + offset, + sortBy: "trend", + userId: req.user?.sub, + }); } @Get("recent") + @UseGuards(OptionalAuthGuard) @UseInterceptors(CacheInterceptor) @CacheTTL(60) @Header("Cache-Control", "public, max-age=60") recent( + @Req() req: AuthenticatedRequest, @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, @Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number, ) { - return this.contentsService.findAll({ limit, offset, sortBy: "recent" }); + return this.contentsService.findAll({ + limit, + offset, + sortBy: "recent", + userId: req.user?.sub, + }); } @Get(":idOrSlug") + @UseGuards(OptionalAuthGuard) @UseInterceptors(CacheInterceptor) @CacheTTL(3600) @Header("Cache-Control", "public, max-age=3600") async findOne( @Param("idOrSlug") idOrSlug: string, - @Req() req: Request, + @Req() req: AuthenticatedRequest, @Res() res: Response, ) { - const content = await this.contentsService.findOne(idOrSlug); + const content = await this.contentsService.findOne(idOrSlug, req.user?.sub); if (!content) { throw new NotFoundException("Contenu non trouvé"); } @@ -158,4 +178,11 @@ export class ContentsController { remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) { return this.contentsService.remove(id, req.user.sub); } + + @Delete(":id/admin") + @UseGuards(AuthGuard, RolesGuard) + @Roles("admin") + removeAdmin(@Param("id") id: string) { + return this.contentsService.removeAdmin(id); + } } diff --git a/backend/src/contents/contents.service.ts b/backend/src/contents/contents.service.ts index c0f58ec..d933c66 100644 --- a/backend/src/contents/contents.service.ts +++ b/backend/src/contents/contents.service.ts @@ -126,7 +126,18 @@ export class ContentsService { this.contentsRepository.count(options), ]); - return { data, totalCount }; + const processedData = data.map((content) => ({ + ...content, + url: this.getFileUrl(content.storageKey), + author: { + ...content.author, + avatarUrl: content.author?.avatarUrl + ? this.getFileUrl(content.author.avatarUrl) + : null, + }, + })); + + return { data: processedData, totalCount }; } async create(userId: string, data: CreateContentDto) { @@ -162,8 +173,30 @@ export class ContentsService { return deleted; } - async findOne(idOrSlug: string) { - return this.contentsRepository.findOne(idOrSlug); + async removeAdmin(id: string) { + this.logger.log(`Removing content ${id} by admin`); + const deleted = await this.contentsRepository.softDeleteAdmin(id); + + if (deleted) { + await this.clearContentsCache(); + } + return deleted; + } + + async findOne(idOrSlug: string, userId?: string) { + const content = await this.contentsRepository.findOne(idOrSlug, userId); + if (!content) return null; + + return { + ...content, + url: this.getFileUrl(content.storageKey), + author: { + ...content.author, + avatarUrl: content.author?.avatarUrl + ? this.getFileUrl(content.author.avatarUrl) + : null, + }, + }; } generateBotHtml(content: { title: string; storageKey: string }): string { diff --git a/backend/src/contents/repositories/contents.repository.ts b/backend/src/contents/repositories/contents.repository.ts index 8fbc2d0..126b09c 100644 --- a/backend/src/contents/repositories/contents.repository.ts +++ b/backend/src/contents/repositories/contents.repository.ts @@ -135,11 +135,20 @@ export class ContentsRepository { fileSize: contents.fileSize, views: contents.views, usageCount: contents.usageCount, + favoritesCount: + sql`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith( + Number, + ), + isLiked: userId + ? sql`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})` + : sql`false`, createdAt: contents.createdAt, updatedAt: contents.updatedAt, author: { id: users.uuid, username: users.username, + displayName: users.displayName, + avatarUrl: users.avatarUrl, }, category: { id: categories.id, @@ -215,7 +224,7 @@ export class ContentsRepository { }); } - async findOne(idOrSlug: string) { + async findOne(idOrSlug: string, userId?: string) { const [result] = await this.databaseService.db .select({ id: contents.id, @@ -227,11 +236,31 @@ export class ContentsRepository { fileSize: contents.fileSize, views: contents.views, usageCount: contents.usageCount, + favoritesCount: + sql`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith( + Number, + ), + isLiked: userId + ? sql`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})` + : sql`false`, createdAt: contents.createdAt, updatedAt: contents.updatedAt, userId: contents.userId, + author: { + id: users.uuid, + username: users.username, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }, + category: { + id: categories.id, + name: categories.name, + slug: categories.slug, + }, }) .from(contents) + .leftJoin(users, eq(contents.userId, users.uuid)) + .leftJoin(categories, eq(contents.categoryId, categories.id)) .where( and( isNull(contents.deletedAt), @@ -240,7 +269,20 @@ export class ContentsRepository { ) .limit(1); - return result; + if (!result) return null; + + const tagsForContent = await this.databaseService.db + .select({ + name: tags.name, + }) + .from(contentsToTags) + .innerJoin(tags, eq(contentsToTags.tagId, tags.id)) + .where(eq(contentsToTags.contentId, result.id)); + + return { + ...result, + tags: tagsForContent.map((t) => t.name), + }; } async count(options: { @@ -353,6 +395,15 @@ export class ContentsRepository { return deleted; } + async softDeleteAdmin(id: string) { + const [deleted] = await this.databaseService.db + .update(contents) + .set({ deletedAt: new Date() }) + .where(eq(contents.id, id)) + .returning(); + return deleted; + } + async findBySlug(slug: string) { const [result] = await this.databaseService.db .select() diff --git a/backend/src/database/schemas/users.ts b/backend/src/database/schemas/users.ts index fb9c55f..86ce9dc 100644 --- a/backend/src/database/schemas/users.ts +++ b/backend/src/database/schemas/users.ts @@ -30,6 +30,8 @@ export const users = pgTable( username: varchar("username", { length: 32 }).notNull().unique(), passwordHash: varchar("password_hash", { length: 100 }).notNull(), + avatarUrl: varchar("avatar_url", { length: 512 }), + bio: varchar("bio", { length: 255 }), // Sécurité twoFactorSecret: pgpEncrypted("two_factor_secret"), diff --git a/backend/src/media/media.service.ts b/backend/src/media/media.service.ts index ca26c08..2e225e3 100644 --- a/backend/src/media/media.service.ts +++ b/backend/src/media/media.service.ts @@ -83,8 +83,9 @@ export class MediaService implements IMediaService { async processImage( buffer: Buffer, format: "webp" | "avif" = "webp", + resize?: { width?: number; height?: number }, ): Promise { - return this.imageProcessor.process(buffer, { format }); + return this.imageProcessor.process(buffer, { format, resize }); } async processVideo( diff --git a/backend/src/media/strategies/image-processor.strategy.ts b/backend/src/media/strategies/image-processor.strategy.ts index 49f8acf..a9ac4db 100644 --- a/backend/src/media/strategies/image-processor.strategy.ts +++ b/backend/src/media/strategies/image-processor.strategy.ts @@ -13,11 +13,22 @@ export class ImageProcessorStrategy implements IMediaProcessorStrategy { async process( buffer: Buffer, - options: { format: "webp" | "avif" } = { format: "webp" }, + options: { + format: "webp" | "avif"; + resize?: { width?: number; height?: number }; + } = { format: "webp" }, ): Promise { try { - const { format } = options; + const { format, resize } = options; let pipeline = sharp(buffer); + + if (resize) { + pipeline = pipeline.resize(resize.width, resize.height, { + fit: "cover", + position: "center", + }); + } + const metadata = await pipeline.metadata(); if (format === "webp") { diff --git a/backend/src/reports/reports.service.spec.ts b/backend/src/reports/reports.service.spec.ts index a741493..eb5fc69 100644 --- a/backend/src/reports/reports.service.spec.ts +++ b/backend/src/reports/reports.service.spec.ts @@ -33,7 +33,7 @@ describe("ReportsService", () => { describe("create", () => { it("should create a report", async () => { const reporterId = "u1"; - const data = { contentId: "c1", reason: "spam" }; + const data = { contentId: "c1", reason: "spam" } as const; mockReportsRepository.create.mockResolvedValue({ id: "r1", ...data, diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts index 7a4a0af..8b7eedf 100644 --- a/backend/src/users/dto/update-user.dto.ts +++ b/backend/src/users/dto/update-user.dto.ts @@ -5,4 +5,13 @@ export class UpdateUserDto { @IsString() @MaxLength(32) displayName?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + bio?: string; + + @IsOptional() + @IsString() + avatarUrl?: string; } diff --git a/backend/src/users/repositories/users.repository.ts b/backend/src/users/repositories/users.repository.ts index 7a3f538..efb558b 100644 --- a/backend/src/users/repositories/users.repository.ts +++ b/backend/src/users/repositories/users.repository.ts @@ -43,6 +43,8 @@ export class UsersRepository { username: users.username, email: users.email, displayName: users.displayName, + avatarUrl: users.avatarUrl, + bio: users.bio, status: users.status, isTwoFactorEnabled: users.isTwoFactorEnabled, createdAt: users.createdAt, @@ -66,7 +68,9 @@ export class UsersRepository { .select({ uuid: users.uuid, username: users.username, + email: users.email, displayName: users.displayName, + avatarUrl: users.avatarUrl, status: users.status, createdAt: users.createdAt, }) @@ -81,6 +85,8 @@ export class UsersRepository { uuid: users.uuid, username: users.username, displayName: users.displayName, + avatarUrl: users.avatarUrl, + bio: users.bio, createdAt: users.createdAt, }) .from(users) diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index c036337..e49dfcb 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -13,9 +13,11 @@ import { Post, Query, Req, + UploadedFile, UseGuards, UseInterceptors, } from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; import { AuthService } from "../auth/auth.service"; import { Roles } from "../auth/decorators/roles.decorator"; import { AuthGuard } from "../auth/guards/auth.guard"; @@ -74,6 +76,16 @@ export class UsersController { return this.usersService.update(req.user.sub, updateUserDto); } + @Post("me/avatar") + @UseGuards(AuthGuard) + @UseInterceptors(FileInterceptor("file")) + updateAvatar( + @Req() req: AuthenticatedRequest, + @UploadedFile() file: Express.Multer.File, + ) { + return this.usersService.updateAvatar(req.user.sub, file); + } + @Patch("me/consent") @UseGuards(AuthGuard) updateConsent( @@ -93,6 +105,13 @@ export class UsersController { return this.usersService.remove(req.user.sub); } + @Delete(":uuid") + @UseGuards(AuthGuard, RolesGuard) + @Roles("admin") + removeAdmin(@Param("uuid") uuid: string) { + return this.usersService.remove(uuid); + } + // Double Authentification (2FA) @Post("me/2fa/setup") @UseGuards(AuthGuard) diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 21678c9..dd4daa1 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -2,12 +2,20 @@ import { forwardRef, Module } from "@nestjs/common"; import { AuthModule } from "../auth/auth.module"; import { CryptoModule } from "../crypto/crypto.module"; import { DatabaseModule } from "../database/database.module"; +import { MediaModule } from "../media/media.module"; +import { S3Module } from "../s3/s3.module"; import { UsersRepository } from "./repositories/users.repository"; import { UsersController } from "./users.controller"; import { UsersService } from "./users.service"; @Module({ - imports: [DatabaseModule, CryptoModule, forwardRef(() => AuthModule)], + imports: [ + DatabaseModule, + CryptoModule, + forwardRef(() => AuthModule), + MediaModule, + S3Module, + ], controllers: [UsersController], providers: [UsersService, UsersRepository], exports: [UsersService, UsersRepository], diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 2bc21f5..4ba94c3 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -1,3 +1,7 @@ +jest.mock("uuid", () => ({ + v4: jest.fn(() => "mocked-uuid"), +})); + jest.mock("@noble/post-quantum/ml-kem.js", () => ({ ml_kem768: { keygen: jest.fn(), @@ -12,7 +16,11 @@ jest.mock("jose", () => ({ })); import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { ConfigService } from "@nestjs/config"; import { Test, TestingModule } from "@nestjs/testing"; +import { RbacService } from "../auth/rbac.service"; +import { MediaService } from "../media/media.service"; +import { S3Service } from "../s3/s3.service"; import { UsersRepository } from "./repositories/users.repository"; import { UsersService } from "./users.service"; @@ -39,6 +47,23 @@ describe("UsersService", () => { del: jest.fn(), }; + const mockRbacService = { + getUserRoles: jest.fn(), + }; + + const mockMediaService = { + scanFile: jest.fn(), + processImage: jest.fn(), + }; + + const mockS3Service = { + uploadFile: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + beforeEach(async () => { jest.clearAllMocks(); @@ -47,6 +72,10 @@ describe("UsersService", () => { UsersService, { provide: UsersRepository, useValue: mockUsersRepository }, { provide: CACHE_MANAGER, useValue: mockCacheManager }, + { provide: RbacService, useValue: mockRbacService }, + { provide: MediaService, useValue: mockMediaService }, + { provide: S3Service, useValue: mockS3Service }, + { provide: ConfigService, useValue: mockConfigService }, ], }).compile(); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 8c7a97e..ad2f4d9 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,6 +1,19 @@ import { CACHE_MANAGER } from "@nestjs/cache-manager"; -import { Inject, Injectable, Logger } from "@nestjs/common"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import type { Cache } from "cache-manager"; +import { v4 as uuidv4 } from "uuid"; +import { RbacService } from "../auth/rbac.service"; +import type { IMediaService } from "../common/interfaces/media.interface"; +import type { IStorageService } from "../common/interfaces/storage.interface"; +import { MediaService } from "../media/media.service"; +import { S3Service } from "../s3/s3.service"; import { UpdateUserDto } from "./dto/update-user.dto"; import { UsersRepository } from "./repositories/users.repository"; @@ -11,6 +24,11 @@ export class UsersService { constructor( private readonly usersRepository: UsersRepository, @Inject(CACHE_MANAGER) private cacheManager: Cache, + @Inject(forwardRef(() => RbacService)) + private readonly rbacService: RbacService, + @Inject(MediaService) private readonly mediaService: IMediaService, + @Inject(S3Service) private readonly s3Service: IStorageService, + private readonly configService: ConfigService, ) {} private async clearUserCache(username?: string) { @@ -33,7 +51,19 @@ export class UsersService { } async findOneWithPrivateData(uuid: string) { - return await this.usersRepository.findOneWithPrivateData(uuid); + const [user, roles] = await Promise.all([ + this.usersRepository.findOneWithPrivateData(uuid), + this.rbacService.getUserRoles(uuid), + ]); + + if (!user) return null; + + return { + ...user, + avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + role: roles.includes("admin") ? "admin" : "user", + roles, + }; } async findAll(limit: number, offset: number) { @@ -42,11 +72,22 @@ export class UsersService { this.usersRepository.countAll(), ]); - return { data, totalCount }; + const processedData = data.map((user) => ({ + ...user, + avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + })); + + return { data: processedData, totalCount }; } async findPublicProfile(username: string) { - return await this.usersRepository.findByUsername(username); + const user = await this.usersRepository.findByUsername(username); + if (!user) return null; + + return { + ...user, + avatarUrl: user.avatarUrl ? this.getFileUrl(user.avatarUrl) : null, + }; } async findOne(uuid: string) { @@ -63,6 +104,47 @@ export class UsersService { return result; } + async updateAvatar(uuid: string, file: Express.Multer.File) { + this.logger.log(`Updating avatar for user ${uuid}`); + + // Validation du format et de la taille + const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp"]; + if (!allowedMimeTypes.includes(file.mimetype)) { + throw new BadRequestException( + "Format d'image non supporté. Formats acceptés: png, jpeg, webp.", + ); + } + + if (file.size > 2 * 1024 * 1024) { + throw new BadRequestException("Image trop volumineuse. Limite: 2 Mo."); + } + + // 1. Scan Antivirus + const scanResult = await this.mediaService.scanFile( + file.buffer, + file.originalname, + ); + if (scanResult.isInfected) { + throw new BadRequestException( + `Le fichier est infecté par ${scanResult.virusName}`, + ); + } + + // 2. Traitement (WebP + Redimensionnement 512x512) + const processed = await this.mediaService.processImage(file.buffer, "webp", { + width: 512, + height: 512, + }); + + // 3. Upload vers S3 + const key = `avatars/${uuid}/${Date.now()}-${uuidv4()}.${processed.extension}`; + await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType); + + // 4. Mise à jour de la base de données + const user = await this.update(uuid, { avatarUrl: key }); + return user[0]; + } + async updateConsent( uuid: string, termsVersion: string, @@ -111,4 +193,17 @@ export class UsersService { async remove(uuid: string) { return await this.usersRepository.softDeleteUserAndContents(uuid); } + + private getFileUrl(storageKey: string): string { + const endpoint = this.configService.get("S3_ENDPOINT"); + const port = this.configService.get("S3_PORT"); + const protocol = + this.configService.get("S3_USE_SSL") === true ? "https" : "http"; + const bucket = this.configService.get("S3_BUCKET_NAME"); + + if (endpoint === "localhost" || endpoint === "127.0.0.1") { + return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`; + } + return `${protocol}://${endpoint}/${bucket}/${storageKey}`; + } } diff --git a/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx b/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx index 6645116..2f3d59f 100644 --- a/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx +++ b/frontend/src/app/(dashboard)/@modal/(.)meme/[slug]/page.tsx @@ -10,6 +10,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; +import { ViewCounter } from "@/components/view-counter"; import { ContentService } from "@/services/content.service"; import type { Content } from "@/types/content"; @@ -45,6 +46,7 @@ export default function MemeModal({ ) : content ? (
+
) : ( diff --git a/frontend/src/app/(dashboard)/admin/categories/page.tsx b/frontend/src/app/(dashboard)/admin/categories/page.tsx new file mode 100644 index 0000000..8c29daf --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/categories/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { CategoryService } from "@/services/category.service"; +import type { Category } from "@/types/content"; + +export default function AdminCategoriesPage() { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + CategoryService.getAll() + .then(setCategories) + .catch((err) => console.error(err)) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+

+ Catégories ({categories.length}) +

+
+
+ + + + Nom + Slug + Description + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + /* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */ + + + + + + + + + + + + )) + ) : categories.length === 0 ? ( + + + Aucune catégorie trouvée. + + + ) : ( + categories.map((category) => ( + + + {category.name} + + {category.slug} + + {category.description || "Aucune description"} + + + )) + )} + +
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/contents/page.tsx b/frontend/src/app/(dashboard)/admin/contents/page.tsx new file mode 100644 index 0000000..da21f71 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/contents/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Download, Eye, Image as ImageIcon, Trash2, Video } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ContentService } from "@/services/content.service"; +import type { Content } from "@/types/content"; + +export default function AdminContentsPage() { + const [contents, setContents] = useState([]); + const [loading, setLoading] = useState(true); + const [totalCount, setTotalCount] = useState(0); + + useEffect(() => { + ContentService.getExplore({ limit: 20 }) + .then((res) => { + setContents(res.data); + setTotalCount(res.totalCount); + }) + .catch((err) => console.error(err)) + .finally(() => setLoading(false)); + }, []); + + const handleDelete = async (id: string) => { + if (!confirm("Êtes-vous sûr de vouloir supprimer ce contenu ?")) return; + + try { + await ContentService.removeAdmin(id); + setContents(contents.filter((c) => c.id !== id)); + setTotalCount((prev) => prev - 1); + } catch (error) { + console.error(error); + } + }; + + return ( +
+
+

+ Contenus ({totalCount}) +

+
+
+ + + + Contenu + Catégorie + Auteur + Stats + Date + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + /* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */ + + + + + + + + + + + + + + + + + + )) + ) : contents.length === 0 ? ( + + + Aucun contenu trouvé. + + + ) : ( + contents.map((content) => ( + + +
+
+ {content.type === "image" ? ( + + ) : ( +
+
+
{content.title}
+
+ {content.type} • {content.mimeType} +
+
+
+
+ + + {content.category?.name || "Sans catégorie"} + + + @{content.author.username} + +
+
+ {content.views} +
+
+ {content.usageCount} +
+
+
+ + {format(new Date(content.createdAt), "dd/MM/yyyy", { locale: fr })} + + + + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/page.tsx b/frontend/src/app/(dashboard)/admin/page.tsx new file mode 100644 index 0000000..373bdf3 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { AlertCircle, FileText, LayoutGrid, Users } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { type AdminStats, adminService } from "@/services/admin.service"; + +export default function AdminDashboardPage() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + adminService + .getStats() + .then(setStats) + .catch((err) => { + console.error(err); + setError("Impossible de charger les statistiques."); + }) + .finally(() => setLoading(false)); + }, []); + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + const statCards = [ + { + title: "Utilisateurs", + value: stats?.users, + icon: Users, + href: "/admin/users", + color: "text-blue-500", + }, + { + title: "Contenus", + value: stats?.contents, + icon: FileText, + href: "/admin/contents", + color: "text-green-500", + }, + { + title: "Catégories", + value: stats?.categories, + icon: LayoutGrid, + href: "/admin/categories", + color: "text-purple-500", + }, + ]; + + return ( +
+
+

Dashboard Admin

+
+
+ {statCards.map((card) => ( + + + + {card.title} + + + + {loading ? ( + + ) : ( +
{card.value}
+ )} +
+
+ + ))} +
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/admin/users/page.tsx b/frontend/src/app/(dashboard)/admin/users/page.tsx new file mode 100644 index 0000000..1fdee58 --- /dev/null +++ b/frontend/src/app/(dashboard)/admin/users/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { UserService } from "@/services/user.service"; +import type { User } from "@/types/user"; + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [totalCount, setTotalCount] = useState(0); + + useEffect(() => { + UserService.getUsersAdmin() + .then((res) => { + setUsers(res.data); + setTotalCount(res.totalCount); + }) + .catch((err) => { + console.error(err); + }) + .finally(() => setLoading(false)); + }, []); + + const handleDelete = async (uuid: string) => { + if ( + !confirm( + "Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.", + ) + ) + return; + + try { + await UserService.removeUserAdmin(uuid); + setUsers(users.filter((u) => u.uuid !== uuid)); + setTotalCount((prev) => prev - 1); + } catch (error) { + console.error(error); + } + }; + + return ( +
+
+

+ Utilisateurs ({totalCount}) +

+
+
+ + + + Utilisateur + Email + Rôle + Status + Date d'inscription + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + /* biome-ignore lint/suspicious/noArrayIndexKey: skeleton items don't have unique IDs */ + + + + + + + + + + + + + + + + + + )) + ) : users.length === 0 ? ( + + + Aucun utilisateur trouvé. + + + ) : ( + users.map((user) => ( + + + {user.displayName || user.username} +
@{user.username}
+
+ {user.email} + + + {user.role} + + + + + {user.status} + + + + {format(new Date(user.createdAt), "PPP", { locale: fr })} + + + + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/help/page.tsx b/frontend/src/app/(dashboard)/help/page.tsx new file mode 100644 index 0000000..a07d148 --- /dev/null +++ b/frontend/src/app/(dashboard)/help/page.tsx @@ -0,0 +1,70 @@ +import { HelpCircle } from "lucide-react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; + +export default function HelpPage() { + const faqs = [ + { + question: "Comment puis-je publier un mème ?", + answer: + "Pour publier un mème, vous devez être connecté à votre compte. Cliquez sur le bouton 'Publier' dans la barre latérale, choisissez votre fichier (image ou GIF), donnez-lui un titre et une catégorie, puis validez.", + }, + { + question: "Quels formats de fichiers sont acceptés ?", + answer: + "Nous acceptons les images au format PNG, JPEG, WebP et les GIF animés. La taille maximale recommandée est de 2 Mo.", + }, + { + question: "Comment fonctionnent les favoris ?", + answer: + "En cliquant sur l'icône de cœur sur un mème, vous l'ajoutez à vos favoris. Vous pouvez retrouver tous vos mèmes favoris dans l'onglet 'Mes Favoris' de votre profil.", + }, + { + question: "Puis-je supprimer un mème que j'ai publié ?", + answer: + "Oui, vous pouvez supprimer vos propres mèmes en vous rendant sur votre profil, en sélectionnant le mème et en cliquant sur l'option de suppression.", + }, + { + question: "Comment fonctionne le système de recherche ?", + answer: + "Vous pouvez rechercher des mèmes par titre en utilisant la barre de recherche dans la colonne de droite. Vous pouvez également filtrer par catégories ou par tags populaires.", + }, + ]; + + return ( +
+
+
+ +
+

Centre d'aide

+
+ +
+

Foire Aux Questions

+ + {faqs.map((faq, index) => ( + + {faq.question} + + {faq.answer} + + + ))} + +
+ +
+

Vous ne trouvez pas de réponse ?

+

+ N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. +

+

contact@memegoat.local

+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/meme/[slug]/page.tsx b/frontend/src/app/(dashboard)/meme/[slug]/page.tsx index 1c978f6..62df4ba 100644 --- a/frontend/src/app/(dashboard)/meme/[slug]/page.tsx +++ b/frontend/src/app/(dashboard)/meme/[slug]/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { ContentCard } from "@/components/content-card"; import { Button } from "@/components/ui/button"; +import { ViewCounter } from "@/components/view-counter"; import { ContentService } from "@/services/content.service"; export const revalidate = 3600; // ISR: Revalider toutes les heures @@ -40,6 +41,7 @@ export default async function MemePage({ return (
+ (null); + + const handleAvatarClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + await UserService.updateAvatar(file); + toast.success("Avatar mis à jour avec succès !"); + await refreshUser?.(); + } catch (error) { + console.error(error); + toast.error("Erreur lors de la mise à jour de l'avatar."); + } + }; const fetchMyMemes = React.useCallback( (params: { limit: number; offset: number }) => @@ -72,12 +95,28 @@ export default function ProfilePage() {
- - - - {user.username.slice(0, 2).toUpperCase()} - - +
+ + + + {user.username.slice(0, 2).toUpperCase()} + + + + +

@@ -85,6 +124,9 @@ export default function ProfilePage() {

@{user.username}

+ {user.bio && ( +

{user.bio}

+ )}
diff --git a/frontend/src/app/(dashboard)/recent/page.tsx b/frontend/src/app/(dashboard)/recent/page.tsx index 328500b..a82ba89 100644 --- a/frontend/src/app/(dashboard)/recent/page.tsx +++ b/frontend/src/app/(dashboard)/recent/page.tsx @@ -1,15 +1,20 @@ -"use client"; - +import type { Metadata } from "next"; import * as React from "react"; -import { ContentList } from "@/components/content-list"; -import { ContentService } from "@/services/content.service"; +import { HomeContent } from "@/components/home-content"; + +export const metadata: Metadata = { + title: "Nouveautés", + description: "Les tout derniers mèmes fraîchement débarqués sur MemeGoat.", +}; export default function RecentPage() { - const fetchFn = React.useCallback( - (params: { limit: number; offset: number }) => - ContentService.getRecent(params.limit, params.offset), - [], + return ( + Chargement des nouveautés...
+ } + > + + ); - - return ; } diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..f5e56ea --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, Save, User as UserIcon } from "lucide-react"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/providers/auth-provider"; +import { UserService } from "@/services/user.service"; + +const settingsSchema = z.object({ + displayName: z.string().max(32, "Le nom d'affichage est trop long").optional(), + bio: z.string().max(255, "La bio est trop longue").optional(), +}); + +type SettingsFormValues = z.infer; + +export default function SettingsPage() { + const { user, isLoading, refreshUser } = useAuth(); + const [isSaving, setIsSaving] = React.useState(false); + + const form = useForm({ + resolver: zodResolver(settingsSchema), + defaultValues: { + displayName: "", + bio: "", + }, + }); + + React.useEffect(() => { + if (user) { + form.reset({ + displayName: user.displayName || "", + bio: user.bio || "", + }); + } + }, [user, form]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ( +
+ + + Accès refusé + + Vous devez être connecté pour accéder aux paramètres. + + + +
+ ); + } + + const onSubmit = async (values: SettingsFormValues) => { + setIsSaving(true); + try { + await UserService.updateMe(values); + toast.success("Paramètres mis à jour !"); + await refreshUser(); + } catch (error) { + console.error(error); + toast.error("Erreur lors de la mise à jour des paramètres."); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+ +
+

Paramètres du profil

+
+ + + + Informations personnelles + + Mettez à jour vos informations publiques. Ces données seront visibles par + les autres utilisateurs. + + + +
+ +
+ + Nom d'utilisateur + + + + + Le nom d'utilisateur ne peut pas être modifié. + + + + ( + + Nom d'affichage + + + + + Le nom qui sera affiché sur votre profil et vos mèmes. + + + + )} + /> + + ( + + Bio + +