6 Commits

Author SHA1 Message Date
Mathis HERRIOT
7615ec670e chore: bump version to 1.9.1
All checks were successful
CI/CD Pipeline / Valider backend (push) Successful in 1m36s
CI/CD Pipeline / Valider frontend (push) Successful in 1m42s
CI/CD Pipeline / Valider documentation (push) Successful in 1m47s
CI/CD Pipeline / Déploiement en Production (push) Successful in 5m58s
2026-01-29 17:44:55 +01:00
Mathis HERRIOT
40cfff683d fix: ensure decrypted PGP values are cast to text in SQL queries
- Added `::text` cast to `pgp_sym_decrypt` function calls for consistent data type handling.
2026-01-29 17:44:50 +01:00
Mathis HERRIOT
bb52782226 feat: enhance environment configuration and CORS handling
- Added `NEXT_PUBLIC_APP_URL` and `NEXT_PUBLIC_CONTACT_EMAIL` to environment variables for frontend configuration.
- Updated CORS logic to support domain-based restrictions with dynamic origin validation.
- Improved frontend image hostname resolution using environment-driven URLs.
- Standardized contact email usage across the application.
2026-01-29 17:34:53 +01:00
Mathis HERRIOT
6a70274623 fix: handle null enriched comment in comments service
- Added a null check for `enrichedComment` to prevent processing invalid data and potential runtime errors.
2026-01-29 17:22:00 +01:00
Mathis HERRIOT
aabc615b89 feat: enhance CORS and user connection handling in WebSocket gateway
- Improved CORS configuration to allow specific origins for development and mobile use cases.
- Added validation for token payload to ensure `sub` property is present.
- Enhanced user connection management by using `userId` consistently for online status tracking and room joining.
2026-01-29 17:21:53 +01:00
Mathis HERRIOT
f9b202375f feat: improve accessibility, security & user interaction in notifications and setup
- Replaced `div` with `button` elements in `NotificationHandler` for better semantics and accessibility.
- Added conditional QR Code reveal in 2FA setup with `blur` effect for enhanced security and user control.
- Enhanced messages layout for responsiveness on smaller screens with dynamic chat/sidebar toggling.
- Simplified legacy prop handling in `ShareDialog`.
2026-01-29 17:21:19 +01:00
14 changed files with 156 additions and 64 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/backend", "name": "@memegoat/backend",
"version": "1.9.0", "version": "1.9.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -33,6 +33,7 @@ export class CommentsService {
// Récupérer le commentaire avec les infos utilisateur pour le WebSocket // Récupérer le commentaire avec les infos utilisateur pour le WebSocket
const enrichedComment = await this.findOneEnriched(comment.id, userId); const enrichedComment = await this.findOneEnriched(comment.id, userId);
if (!enrichedComment) return null;
// Notifier les autres utilisateurs sur ce contenu (room de contenu) // Notifier les autres utilisateurs sur ce contenu (room de contenu)
this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment); this.eventsGateway.sendToContent(contentId, "new_comment", enrichedComment);

View File

@@ -41,7 +41,9 @@ export const pgpEncrypted = customType<{ data: string; driverData: Buffer }>({
export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T { export function withAutomaticPgpDecrypt<T extends AnyPgColumn>(column: T): T {
const originalGetSQL = column.getSQL.bind(column); const originalGetSQL = column.getSQL.bind(column);
column.getSQL = () => column.getSQL = () =>
sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})`.mapWith(column); sql`pgp_sym_decrypt(${originalGetSQL()}, ${getPgpKey()})::text`.mapWith(
column,
);
return column; return column;
} }
@@ -59,5 +61,7 @@ export function pgpSymDecrypt(
column: AnyPgColumn, column: AnyPgColumn,
key: string | SQL, key: string | SQL,
): SQL<string> { ): SQL<string> {
return sql`pgp_sym_decrypt(${column}, ${key})`.mapWith(column) as SQL<string>; return sql`pgp_sym_decrypt(${column}, ${key})::text`.mapWith(
column,
) as SQL<string>;
} }

View File

@@ -16,14 +16,36 @@ import { getSessionOptions, SessionData } from "../auth/session.config";
import { JwtService } from "../crypto/services/jwt.service"; import { JwtService } from "../crypto/services/jwt.service";
@WebSocketGateway({ @WebSocketGateway({
transports: ["websocket"],
cors: { cors: {
origin: ( origin: (
_origin: string, origin: string,
callback: (err: Error | null, allow?: boolean) => void, callback: (err: Error | null, allow?: boolean) => void,
) => { ) => {
// En production, on pourrait restreindre ici // Autoriser si pas d'origine (ex: app mobile ou serveur à serveur)
// Pour l'instant on autorise tout en mode credentials pour faciliter le déploiement multi-domaines // ou si on est en développement local
callback(null, true); if (
!origin ||
origin.includes("localhost") ||
origin.includes("127.0.0.1")
) {
callback(null, true);
return;
}
// En production, on peut restreindre via une variable d'environnement
const domainName = process.env.CORS_DOMAIN_NAME;
if (!domainName || domainName === "*") {
callback(null, true);
return;
}
const allowedOrigins = domainName.split(",").map((o) => o.trim());
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
}, },
credentials: true, credentials: true,
methods: ["GET", "POST"], methods: ["GET", "POST"],
@@ -73,17 +95,22 @@ export class EventsGateway
} }
const payload = await this.jwtService.verifyJwt(session.accessToken); const payload = await this.jwtService.verifyJwt(session.accessToken);
if (!payload.sub) {
throw new Error("Invalid token payload: missing sub");
}
client.data.user = payload; client.data.user = payload;
// Rejoindre une room personnelle pour les notifications // Rejoindre une room personnelle pour les notifications
client.join(`user:${payload.sub}`); client.join(`user:${payload.sub}`);
// Gérer le statut en ligne // Gérer le statut en ligne
if (!this.onlineUsers.has(payload.sub)) { const userId = payload.sub as string;
this.onlineUsers.set(payload.sub, new Set()); if (!this.onlineUsers.has(userId)) {
this.server.emit("user_status", { userId: payload.sub, status: "online" }); this.onlineUsers.set(userId, new Set());
this.server.emit("user_status", { userId, status: "online" });
} }
this.onlineUsers.get(payload.sub)?.add(client.id); this.onlineUsers.get(userId)?.add(client.id);
this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`); this.logger.log(`Client connected: ${client.id} (User: ${payload.sub})`);
} catch (error) { } catch (error) {

View File

@@ -131,6 +131,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://api.memegoat.fr}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://memegoat.fr}
NEXT_PUBLIC_CONTACT_EMAIL: ${MAIL_FROM:-noreply@memegoat.fr}
depends_on: depends_on:
- backend - backend

View File

@@ -1,5 +1,16 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "https://memegoat.fr";
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.memegoat.fr";
const getHostname = (url: string) => {
try {
return new URL(url).hostname;
} catch {
return url;
}
};
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
reactCompiler: true, reactCompiler: true,
@@ -7,11 +18,11 @@ const nextConfig: NextConfig = {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "memegoat.fr", hostname: getHostname(appUrl),
}, },
{ {
protocol: "https", protocol: "https",
hostname: "api.memegoat.fr", hostname: getHostname(apiUrl),
}, },
], ],
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/frontend", "name": "@memegoat/frontend",
"version": "1.9.0", "version": "1.9.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View File

@@ -63,7 +63,9 @@ export default function HelpPage() {
<p className="text-muted-foreground"> <p className="text-muted-foreground">
N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email. N'hésitez pas à nous contacter sur nos réseaux sociaux ou par email.
</p> </p>
<p className="font-semibold text-primary">contact@memegoat.fr</p> <p className="font-semibold text-primary">
{process.env.NEXT_PUBLIC_CONTACT_EMAIL || "contact@memegoat.fr"}
</p>
</div> </div>
</div> </div>
); );

View File

@@ -2,7 +2,7 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Search, Send, UserPlus, X } from "lucide-react"; import { ArrowLeft, Search, Send, UserPlus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import * as React from "react"; import * as React from "react";
@@ -238,7 +238,11 @@ export default function MessagesPage() {
return ( return (
<div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950"> <div className="h-[calc(100vh-4rem)] flex overflow-hidden bg-white dark:bg-zinc-950">
{/* Sidebar - Liste des conversations */} {/* Sidebar - Liste des conversations */}
<div className="w-80 border-r flex flex-col"> <div
className={`w-full md:w-80 border-r flex flex-col ${
activeConv ? "hidden md:flex" : "flex"
}`}
>
<div className="p-4 border-b"> <div className="p-4 border-b">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Messages</h2> <h2 className="text-xl font-bold">Messages</h2>
@@ -378,14 +382,26 @@ export default function MessagesPage() {
</div> </div>
{/* Zone de chat */} {/* Zone de chat */}
<div className="flex-1 flex flex-col"> <div
className={`flex-1 flex flex-col ${
!activeConv ? "hidden md:flex" : "flex"
}`}
>
{activeConv ? ( {activeConv ? (
<> <>
{/* Header */} {/* Header */}
<div className="p-4 border-b flex items-center justify-between"> <div className="p-4 border-b flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="md:hidden rounded-full"
onClick={() => setActiveConv(null)}
>
<ArrowLeft className="h-5 w-5" />
</Button>
<Link <Link
href={`/user/${activeConv.recipient.username}`} href={`/user/${activeConv.recipient.username}`}
className="flex items-center gap-3 hover:opacity-80 transition-opacity" className="flex-1 flex items-center gap-3 hover:opacity-80 transition-opacity"
> >
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage src={activeConv.recipient.avatarUrl} /> <AvatarImage src={activeConv.recipient.avatarUrl} />

View File

@@ -32,7 +32,7 @@ export const metadata: Metadata = {
openGraph: { openGraph: {
type: "website", type: "website",
locale: "fr_FR", locale: "fr_FR",
url: "https://memegoat.local", url: "/",
siteName: "MemeGoat", siteName: "MemeGoat",
title: "MemeGoat | Partagez vos meilleurs mèmes", title: "MemeGoat | Partagez vos meilleurs mèmes",
description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !", description: "La plateforme ultime pour les mèmes. Rejoignez le troupeau !",

View File

@@ -30,10 +30,9 @@ export function NotificationHandler() {
toast.custom( toast.custom(
(t) => ( (t) => (
<div <button
role="button" type="button"
tabIndex={0} className="flex items-start gap-3 bg-white dark:bg-zinc-900 p-4 rounded-xl shadow-lg border border-zinc-200 dark:border-zinc-800 w-full max-w-sm cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors text-left"
className="flex items-start gap-3 bg-white dark:bg-zinc-900 p-4 rounded-xl shadow-lg border border-zinc-200 dark:border-zinc-800 w-full max-w-sm cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
onClick={() => { onClick={() => {
toast.dismiss(t); toast.dismiss(t);
if (data.type === "message") { if (data.type === "message") {
@@ -42,16 +41,6 @@ export function NotificationHandler() {
router.push(`/meme/${data.contentId}`); router.push(`/meme/${data.contentId}`);
} }
}} }}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
toast.dismiss(t);
if (data.type === "message") {
router.push("/messages");
} else if (data.contentId) {
router.push(`/meme/${data.contentId}`);
}
}
}}
> >
<div className="bg-primary/10 p-2 rounded-full shrink-0"> <div className="bg-primary/10 p-2 rounded-full shrink-0">
{data.type === "comment" && ( {data.type === "comment" && (
@@ -71,15 +60,15 @@ export function NotificationHandler() {
</div> </div>
<button <button
type="button" type="button"
className="text-muted-foreground hover:text-foreground p-1 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
toast.dismiss(t); toast.dismiss(t);
}} }}
className="text-muted-foreground hover:text-foreground"
> >
<Bell className="h-3 w-3" /> <Bell className="h-3 w-3" />
</button> </button>
</div> </button>
), ),
{ {
duration: 5000, duration: 5000,
@@ -91,20 +80,23 @@ export function NotificationHandler() {
socket.on("notification", handleNotification); socket.on("notification", handleNotification);
// Aussi pour les nouveaux messages (si on veut un toast global) // Aussi pour les nouveaux messages (si on veut un toast global)
socket.on("new_message", (data: { message: any }) => { socket.on(
if (window.location.pathname !== "/messages") { "new_message",
toast( (data: { message: { text: string; sender?: { username: string } } }) => {
`Nouveau message de @${data.message.sender?.username || "un membre"}`, if (window.location.pathname !== "/messages") {
{ toast(
description: data.message.text.substring(0, 50), `Nouveau message de @${data.message.sender?.username || "un membre"}`,
action: { {
label: "Voir", description: data.message.text.substring(0, 50),
onClick: () => router.push("/messages"), action: {
label: "Voir",
onClick: () => router.push("/messages"),
},
}, },
}, );
); }
} },
}); );
return () => { return () => {
socket.off("notification"); socket.off("notification");

View File

@@ -28,9 +28,10 @@ interface ShareDialogProps {
export function ShareDialog({ export function ShareDialog({
contentId, contentId,
contentTitle, contentTitle,
contentUrl: _unused, // Support legacy prop
open, open,
onOpenChange, onOpenChange,
}: Omit<ShareDialogProps, "contentUrl">) { }: ShareDialogProps) {
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const [results, setResults] = React.useState<User[]>([]); const [results, setResults] = React.useState<User[]>([]);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);

View File

@@ -29,6 +29,7 @@ export function TwoFactorSetup() {
const [secret, setSecret] = useState<string | null>(null); const [secret, setSecret] = useState<string | null>(null);
const [otpValue, setOtpValue] = useState(""); const [otpValue, setOtpValue] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isRevealed, setIsRevealed] = useState(false);
const handleSetup = async () => { const handleSetup = async () => {
setIsLoading(true); setIsLoading(true);
@@ -152,24 +153,59 @@ export function TwoFactorSetup() {
</CardHeader> </CardHeader>
<CardContent className="flex flex-col items-center gap-6"> <CardContent className="flex flex-col items-center gap-6">
{qrCode && ( {qrCode && (
<div className="bg-white p-4 rounded-xl border-4 border-zinc-100"> <div className="relative group">
<Image <div
src={qrCode} className={`bg-white p-4 rounded-xl border-4 border-zinc-100 transition-all duration-300 ${
alt="QR Code 2FA" !isRevealed ? "blur-md select-none" : ""
width={192} }`}
height={192} >
className="w-48 h-48" <Image
unoptimized src={qrCode}
/> alt="QR Code 2FA"
width={192}
height={192}
className="w-48 h-48"
unoptimized
/>
</div>
{!isRevealed && (
<div className="absolute inset-0 flex items-center justify-center">
<Button
variant="secondary"
size="sm"
onClick={() => setIsRevealed(true)}
className="shadow-lg"
>
Afficher le QR Code
</Button>
</div>
)}
</div> </div>
)} )}
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<p className="text-sm font-medium text-center"> <p className="text-sm font-medium text-center">
Ou entrez ce code manuellement : Ou entrez ce code manuellement :
</p> </p>
<code className="block p-2 bg-muted text-center rounded text-xs font-mono break-all"> <div className="relative group">
{secret} <code
</code> className={`block p-2 bg-muted text-center rounded text-xs font-mono break-all transition-all duration-300 ${
!isRevealed ? "blur-[3px] select-none" : ""
}`}
>
{secret}
</code>
{!isRevealed && (
<div className="absolute inset-0 flex items-center justify-center">
<button
type="button"
onClick={() => setIsRevealed(true)}
className="text-[10px] font-bold uppercase tracking-wider text-primary hover:underline"
>
Afficher le code
</button>
</div>
)}
</div>
</div> </div>
<div className="flex flex-col items-center gap-4 w-full border-t pt-6"> <div className="flex flex-col items-center gap-4 w-full border-t pt-6">
<p className="text-sm font-medium"> <p className="text-sm font-medium">

View File

@@ -1,6 +1,6 @@
{ {
"name": "@memegoat/source", "name": "@memegoat/source",
"version": "1.9.0", "version": "1.9.1",
"description": "", "description": "",
"scripts": { "scripts": {
"version:get": "cmake -P version.cmake GET", "version:get": "cmake -P version.cmake GET",