feat: add preview feature for social media sharing (#293)

This commit is contained in:
Copilot
2025-10-20 10:56:19 -03:00
committed by GitHub
parent 39dc94b7f8
commit cce9847242
24 changed files with 443 additions and 35 deletions

View File

@@ -536,4 +536,17 @@ export class ReverseShareController {
return reply.status(500).send({ error: "Internal server error" });
}
}
async getReverseShareMetadataByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const metadata = await this.reverseShareService.getReverseShareMetadataByAlias(alias);
return reply.send(metadata);
} catch (error: any) {
if (error.message === "Reverse share not found") {
return reply.status(404).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -592,4 +592,32 @@ export async function reverseShareRoutes(app: FastifyInstance) {
},
reverseShareController.copyFileToUserFiles.bind(reverseShareController)
);
app.get(
"/reverse-shares/alias/:alias/metadata",
{
schema: {
tags: ["Reverse Share"],
operationId: "getReverseShareMetadataByAlias",
summary: "Get reverse share metadata by alias for Open Graph",
description: "Get lightweight metadata for a reverse share by alias, used for social media previews",
params: z.object({
alias: z.string().describe("Alias of the reverse share"),
}),
response: {
200: z.object({
name: z.string().nullable(),
description: z.string().nullable(),
totalFiles: z.number(),
hasPassword: z.boolean(),
isExpired: z.boolean(),
isInactive: z.boolean(),
maxFiles: z.number().nullable(),
}),
404: z.object({ error: z.string() }),
},
},
},
reverseShareController.getReverseShareMetadataByAlias.bind(reverseShareController)
);
}

View File

@@ -773,4 +773,30 @@ export class ReverseShareService {
updatedAt: file.updatedAt.toISOString(),
};
}
async getReverseShareMetadataByAlias(alias: string) {
const reverseShare = await this.reverseShareRepository.findByAlias(alias);
if (!reverseShare) {
throw new Error("Reverse share not found");
}
// Check if reverse share is expired
const isExpired = reverseShare.expiration && new Date(reverseShare.expiration) < new Date();
// Check if inactive
const isInactive = !reverseShare.isActive;
const totalFiles = reverseShare.files?.length || 0;
const hasPassword = !!reverseShare.password;
return {
name: reverseShare.name,
description: reverseShare.description,
totalFiles,
hasPassword,
isExpired,
isInactive,
maxFiles: reverseShare.maxFiles,
};
}
}

View File

@@ -295,4 +295,17 @@ export class ShareController {
return reply.status(400).send({ error: error.message });
}
}
async getShareMetadataByAlias(request: FastifyRequest, reply: FastifyReply) {
try {
const { alias } = request.params as { alias: string };
const metadata = await this.shareService.getShareMetadataByAlias(alias);
return reply.send(metadata);
} catch (error: any) {
if (error.message === "Share not found") {
return reply.status(404).send({ error: error.message });
}
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -17,6 +17,9 @@ export interface IShareRepository {
findShareBySecurityId(
securityId: string
): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[] }) | null>;
findShareByAlias(
alias: string
): Promise<(Share & { security: ShareSecurity; files: any[]; folders: any[]; recipients: any[] }) | null>;
updateShare(id: string, data: Partial<Share>): Promise<Share>;
updateShareSecurity(id: string, data: Partial<ShareSecurity>): Promise<ShareSecurity>;
deleteShare(id: string): Promise<Share>;
@@ -130,6 +133,41 @@ export class PrismaShareRepository implements IShareRepository {
});
}
async findShareByAlias(alias: string) {
const shareAlias = await prisma.shareAlias.findUnique({
where: { alias },
include: {
share: {
include: {
security: true,
files: true,
folders: {
select: {
id: true,
name: true,
description: true,
objectName: true,
parentId: true,
userId: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
files: true,
children: true,
},
},
},
},
recipients: true,
},
},
},
});
return shareAlias?.share || null;
}
async updateShare(id: string, data: Partial<Share>): Promise<Share> {
return prisma.share.update({
where: { id },

View File

@@ -347,4 +347,32 @@ export async function shareRoutes(app: FastifyInstance) {
},
shareController.notifyRecipients.bind(shareController)
);
app.get(
"/shares/alias/:alias/metadata",
{
schema: {
tags: ["Share"],
operationId: "getShareMetadataByAlias",
summary: "Get share metadata by alias for Open Graph",
description: "Get lightweight metadata for a share by alias, used for social media previews",
params: z.object({
alias: z.string().describe("The share alias"),
}),
response: {
200: z.object({
name: z.string().nullable(),
description: z.string().nullable(),
totalFiles: z.number(),
totalFolders: z.number(),
hasPassword: z.boolean(),
isExpired: z.boolean(),
isMaxViewsReached: z.boolean(),
}),
404: z.object({ error: z.string() }),
},
},
},
shareController.getShareMetadataByAlias.bind(shareController)
);
}

View File

@@ -440,4 +440,31 @@ export class ShareService {
notifiedRecipients,
};
}
async getShareMetadataByAlias(alias: string) {
const share = await this.shareRepository.findShareByAlias(alias);
if (!share) {
throw new Error("Share not found");
}
// Check if share is expired
const isExpired = share.expiration && new Date(share.expiration) < new Date();
// Check if max views reached
const isMaxViewsReached = share.security.maxViews !== null && share.views >= share.security.maxViews;
const totalFiles = share.files?.length || 0;
const totalFolders = share.folders?.length || 0;
const hasPassword = !!share.security.password;
return {
name: share.name,
description: share.description,
totalFiles,
totalFolders,
hasPassword,
isExpired,
isMaxViewsReached,
};
}
}

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "رفع الملفات - Palmr",
"description": "رفع الملفات عبر الرابط المشترك"
"description": "رفع الملفات عبر الرابط المشترك",
"descriptionWithLimit": "تحميل الملفات (الحد الأقصى {limit} ملفات)"
},
"layout": {
"defaultTitle": "رفع الملفات",
@@ -1365,7 +1366,11 @@
"description": "قد يكون تم حذف هذه المشاركة أو انتهت صلاحيتها."
},
"pageTitle": "المشاركة",
"downloadAll": "تحميل الكل"
"downloadAll": "تحميل الكل",
"metadata": {
"defaultDescription": "مشاركة الملفات بشكل آمن",
"filesShared": "{count, plural, =1 {ملف واحد تمت مشاركته} other {# ملفات تمت مشاركتها}}"
}
},
"shareActions": {
"deleteTitle": "حذف المشاركة",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "Dateien senden - Palmr",
"description": "Senden Sie Dateien über den geteilten Link"
"description": "Senden Sie Dateien über den geteilten Link",
"descriptionWithLimit": "Dateien hochladen (max. {limit} Dateien)"
},
"layout": {
"defaultTitle": "Dateien senden",
@@ -1363,7 +1364,11 @@
"description": "Diese Freigabe wurde möglicherweise gelöscht oder ist abgelaufen."
},
"pageTitle": "Freigabe",
"downloadAll": "Alle herunterladen"
"downloadAll": "Alle herunterladen",
"metadata": {
"defaultDescription": "Dateien sicher teilen",
"filesShared": "{count, plural, =1 {1 Datei geteilt} other {# Dateien geteilt}}"
}
},
"shareActions": {
"deleteTitle": "Freigabe Löschen",

View File

@@ -1057,7 +1057,8 @@
"upload": {
"metadata": {
"title": "Send Files - Palmr",
"description": "Send files through the shared link"
"description": "Send files through the shared link",
"descriptionWithLimit": "Upload files (max {limit} files)"
},
"layout": {
"defaultTitle": "Send Files",
@@ -1361,7 +1362,11 @@
"title": "Share Not Found",
"description": "This share may have been deleted or expired."
},
"pageTitle": "Share"
"pageTitle": "Share",
"metadata": {
"defaultDescription": "Share files securely",
"filesShared": "{count, plural, =1 {1 file shared} other {# files shared}}"
}
},
"shareActions": {
"fileTitle": "Share File",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "Enviar Archivos - Palmr",
"description": "Envía archivos a través del enlace compartido"
"description": "Envía archivos a través del enlace compartido",
"descriptionWithLimit": "Subir archivos (máx. {limit} archivos)"
},
"layout": {
"defaultTitle": "Enviar Archivos",
@@ -1363,7 +1364,11 @@
"description": "Esta compartición puede haber sido eliminada o haber expirado."
},
"pageTitle": "Compartición",
"downloadAll": "Descargar Todo"
"downloadAll": "Descargar Todo",
"metadata": {
"defaultDescription": "Compartir archivos de forma segura",
"filesShared": "{count, plural, =1 {1 archivo compartido} other {# archivos compartidos}}"
}
},
"shareActions": {
"deleteTitle": "Eliminar Compartir",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "Envoyer des Fichiers - Palmr",
"description": "Envoyez des fichiers via le lien partagé"
"description": "Envoyez des fichiers via le lien partagé",
"descriptionWithLimit": "Télécharger des fichiers (max {limit} fichiers)"
},
"layout": {
"defaultTitle": "Envoyer des Fichiers",
@@ -1363,7 +1364,11 @@
"description": "Ce partage a peut-être été supprimé ou a expiré."
},
"pageTitle": "Partage",
"downloadAll": "Tout Télécharger"
"downloadAll": "Tout Télécharger",
"metadata": {
"defaultDescription": "Partager des fichiers en toute sécurité",
"filesShared": "{count, plural, =1 {1 fichier partagé} other {# fichiers partagés}}"
}
},
"shareActions": {
"deleteTitle": "Supprimer le Partage",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "फ़ाइलें भेजें - पाल्मर",
"description": "साझा किए गए लिंक के माध्यम से फ़ाइलें भेजें"
"description": "साझा किए गए लिंक के माध्यम से फ़ाइलें भेजें",
"descriptionWithLimit": "फ़ाइलें अपलोड करें (अधिकतम {limit} फ़ाइलें)"
},
"layout": {
"defaultTitle": "फ़ाइलें भेजें",
@@ -1363,7 +1364,11 @@
"description": "यह साझाकरण हटा दिया गया हो सकता है या समाप्त हो चुका है।"
},
"pageTitle": "साझाकरण",
"downloadAll": "सभी डाउनलोड करें"
"downloadAll": "सभी डाउनलोड करें",
"metadata": {
"defaultDescription": "फाइलों को सुरक्षित रूप से साझा करें",
"filesShared": "{count, plural, =1 {1 फ़ाइल साझा की गई} other {# फ़ाइलें साझा की गईं}}"
}
},
"shareActions": {
"deleteTitle": "साझाकरण हटाएं",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "Invia File - Palmr",
"description": "Invia file attraverso il link condiviso"
"description": "Invia file attraverso il link condiviso",
"descriptionWithLimit": "Carica file (max {limit} file)"
},
"layout": {
"defaultTitle": "Invia File",
@@ -1363,7 +1364,11 @@
"description": "Questa condivisione potrebbe essere stata eliminata o è scaduta."
},
"pageTitle": "Condivisione",
"downloadAll": "Scarica Tutto"
"downloadAll": "Scarica Tutto",
"metadata": {
"defaultDescription": "Condividi file in modo sicuro",
"filesShared": "{count, plural, =1 {1 file condiviso} other {# file condivisi}}"
}
},
"shareActions": {
"deleteTitle": "Elimina Condivisione",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "ファイルを送信 - Palmr",
"description": "共有リンクを通じてファイルを送信"
"description": "共有リンクを通じてファイルを送信",
"descriptionWithLimit": "ファイルをアップロード(最大{limit}ファイル)"
},
"layout": {
"defaultTitle": "ファイルを送信",
@@ -1363,7 +1364,11 @@
"description": "この共有は削除されたか、期限が切れている可能性があります。"
},
"pageTitle": "共有",
"downloadAll": "すべてダウンロード"
"downloadAll": "すべてダウンロード",
"metadata": {
"defaultDescription": "ファイルを安全に共有",
"filesShared": "{count, plural, =1 {1 ファイルが共有されました} other {# ファイルが共有されました}}"
}
},
"shareActions": {
"deleteTitle": "共有を削除",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "파일 보내기 - Palmr",
"description": "공유된 링크를 통해 파일 보내기"
"description": "공유된 링크를 통해 파일 보내기",
"descriptionWithLimit": "파일 업로드 (최대 {limit}개 파일)"
},
"layout": {
"defaultTitle": "파일 보내기",
@@ -1363,7 +1364,11 @@
"description": "이 공유는 삭제되었거나 만료되었을 수 있습니다."
},
"pageTitle": "공유",
"downloadAll": "모두 다운로드"
"downloadAll": "모두 다운로드",
"metadata": {
"defaultDescription": "파일을 안전하게 공유",
"filesShared": "{count, plural, =1 {1개 파일 공유됨} other {#개 파일 공유됨}}"
}
},
"shareActions": {
"deleteTitle": "공유 삭제",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "Bestanden Verzenden - Palmr",
"description": "Verzend bestanden via de gedeelde link"
"description": "Verzend bestanden via de gedeelde link",
"descriptionWithLimit": "Upload bestanden (max {limit} bestanden)"
},
"layout": {
"defaultTitle": "Bestanden Verzenden",
@@ -1363,7 +1364,11 @@
"description": "Dit delen is mogelijk verwijderd of verlopen."
},
"pageTitle": "Delen",
"downloadAll": "Alles Downloaden"
"downloadAll": "Alles Downloaden",
"metadata": {
"defaultDescription": "Bestanden veilig delen",
"filesShared": "{count, plural, =1 {1 bestand gedeeld} other {# bestanden gedeeld}}"
}
},
"shareActions": {
"deleteTitle": "Delen Verwijderen",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "Wyślij pliki - Palmr",
"description": "Wysyłaj pliki za pośrednictwem udostępnionego linku"
"description": "Wysyłaj pliki za pośrednictwem udostępnionego linku",
"descriptionWithLimit": "Prześlij pliki (maks. {limit} plików)"
},
"layout": {
"defaultTitle": "Wyślij pliki",
@@ -1363,7 +1364,11 @@
"description": "To udostępnienie mogło zostać usunięte lub wygasło."
},
"pageTitle": "Udostępnij",
"downloadAll": "Pobierz wszystkie"
"downloadAll": "Pobierz wszystkie",
"metadata": {
"defaultDescription": "Bezpiecznie udostępniaj pliki",
"filesShared": "{count, plural, =1 {1 plik udostępniony} other {# plików udostępnionych}}"
}
},
"shareActions": {
"deleteTitle": "Usuń udostępnienie",

View File

@@ -1057,7 +1057,8 @@
"upload": {
"metadata": {
"title": "Enviar Arquivos - Palmr",
"description": "Envie arquivos através do link compartilhado"
"description": "Envie arquivos através do link compartilhado",
"descriptionWithLimit": "Enviar arquivos (máx. {limit} arquivos)"
},
"layout": {
"defaultTitle": "Enviar Arquivos",
@@ -1364,7 +1365,11 @@
"description": "Este compartilhamento pode ter sido excluído ou expirado."
},
"pageTitle": "Compartilhamento",
"downloadAll": "Baixar todos"
"downloadAll": "Baixar todos",
"metadata": {
"defaultDescription": "Compartilhar arquivos com segurança",
"filesShared": "{count, plural, =1 {1 arquivo compartilhado} other {# arquivos compartilhados}}"
}
},
"shareActions": {
"deleteTitle": "Excluir Compartilhamento",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "Отправка файлов - Palmr",
"description": "Отправка файлов через общую ссылку"
"description": "Отправка файлов через общую ссылку",
"descriptionWithLimit": "Загрузить файлы (макс. {limit} файлов)"
},
"layout": {
"defaultTitle": "Отправка файлов",
@@ -1363,7 +1364,11 @@
"description": "Этот общий доступ может быть удален или истек."
},
"pageTitle": "Общий доступ",
"downloadAll": "Скачать все"
"downloadAll": "Скачать все",
"metadata": {
"defaultDescription": "Безопасный обмен файлами",
"filesShared": "{count, plural, =1 {1 файл отправлен} other {# файлов отправлено}}"
}
},
"shareActions": {
"deleteTitle": "Удалить Общий Доступ",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "Dosya Gönder - Palmr",
"description": "Paylaşılan bağlantı üzerinden dosya gönderin"
"description": "Paylaşılan bağlantı üzerinden dosya gönderin",
"descriptionWithLimit": "Dosya yükle (maks. {limit} dosya)"
},
"layout": {
"defaultTitle": "Dosya Gönder",
@@ -1363,7 +1364,11 @@
"description": "Bu paylaşım silinmiş veya süresi dolmuş olabilir."
},
"pageTitle": "Paylaşım",
"downloadAll": "Tümünü İndir"
"downloadAll": "Tümünü İndir",
"metadata": {
"defaultDescription": "Dosyaları güvenli bir şekilde paylaşın",
"filesShared": "{count, plural, =1 {1 dosya paylaşıldı} other {# dosya paylaşıldı}}"
}
},
"shareActions": {
"deleteTitle": "Paylaşımı Sil",

View File

@@ -1056,7 +1056,8 @@
"upload": {
"metadata": {
"title": "上传文件 - Palmr",
"description": "通过共享链接上传文件"
"description": "通过共享链接上传文件",
"descriptionWithLimit": "上传文件(最多 {limit} 个文件)"
},
"layout": {
"defaultTitle": "上传文件",
@@ -1363,7 +1364,11 @@
"description": "该共享可能已被删除或已过期。"
},
"pageTitle": "共享",
"downloadAll": "下载所有"
"downloadAll": "下载所有",
"metadata": {
"defaultDescription": "安全共享文件",
"filesShared": "{count, plural, =1 {已共享 1 个文件} other {已共享 # 个文件}}"
}
},
"shareActions": {
"deleteTitle": "删除共享",

View File

@@ -1,12 +1,91 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
export async function generateMetadata(): Promise<Metadata> {
async function getReverseShareMetadata(alias: string) {
try {
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const response = await fetch(`${API_BASE_URL}/reverse-shares/alias/${alias}/metadata`, {
cache: "no-store",
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Error fetching reverse share metadata:", error);
return null;
}
}
async function getAppInfo() {
try {
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const response = await fetch(`${API_BASE_URL}/app/info`, {
cache: "no-store",
});
if (!response.ok) {
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
}
return await response.json();
} catch (error) {
console.error("Error fetching app info:", error);
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
}
}
function getBaseUrl(): string {
const headersList = headers();
const protocol = headersList.get("x-forwarded-proto") || "http";
const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000";
return `${protocol}://${host}`;
}
export async function generateMetadata({ params }: { params: { alias: string } }): Promise<Metadata> {
const t = await getTranslations();
const metadata = await getReverseShareMetadata(params.alias);
const appInfo = await getAppInfo();
const title = metadata?.name || t("reverseShares.upload.metadata.title");
const description =
metadata?.description ||
(metadata?.maxFiles
? t("reverseShares.upload.metadata.descriptionWithLimit", { limit: metadata.maxFiles })
: t("reverseShares.upload.metadata.description"));
const baseUrl = getBaseUrl();
const shareUrl = `${baseUrl}/r/${params.alias}`;
return {
title: t("reverseShares.upload.metadata.title"),
description: t("reverseShares.upload.metadata.description"),
title,
description,
openGraph: {
title,
description,
url: shareUrl,
siteName: appInfo.appName || "Palmr",
type: "website",
images: appInfo.appLogo
? [
{
url: appInfo.appLogo,
width: 1200,
height: 630,
alt: appInfo.appName || "Palmr",
},
]
: [],
},
twitter: {
card: "summary_large_image",
title,
description,
images: appInfo.appLogo ? [appInfo.appLogo] : [],
},
};
}

View File

@@ -1,15 +1,96 @@
import { Metadata } from "next";
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";
interface LayoutProps {
children: React.ReactNode;
params: { alias: string };
}
export async function generateMetadata(): Promise<Metadata> {
async function getShareMetadata(alias: string) {
try {
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const response = await fetch(`${API_BASE_URL}/shares/alias/${alias}/metadata`, {
cache: "no-store",
});
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Error fetching share metadata:", error);
return null;
}
}
async function getAppInfo() {
try {
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
const response = await fetch(`${API_BASE_URL}/app/info`, {
cache: "no-store",
});
if (!response.ok) {
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
}
return await response.json();
} catch (error) {
console.error("Error fetching app info:", error);
return { appName: "Palmr", appDescription: "File sharing platform", appLogo: null };
}
}
function getBaseUrl(): string {
const headersList = headers();
const protocol = headersList.get("x-forwarded-proto") || "http";
const host = headersList.get("x-forwarded-host") || headersList.get("host") || "localhost:3000";
return `${protocol}://${host}`;
}
export async function generateMetadata({ params }: { params: { alias: string } }): Promise<Metadata> {
const t = await getTranslations();
const metadata = await getShareMetadata(params.alias);
const appInfo = await getAppInfo();
const title = metadata?.name || t("share.pageTitle");
const description =
metadata?.description ||
(metadata?.totalFiles
? t("share.metadata.filesShared", { count: metadata.totalFiles + (metadata.totalFolders || 0) })
: appInfo.appDescription || t("share.metadata.defaultDescription"));
const baseUrl = getBaseUrl();
const shareUrl = `${baseUrl}/s/${params.alias}`;
return {
title: `${t("share.pageTitle")}`,
title,
description,
openGraph: {
title,
description,
url: shareUrl,
siteName: appInfo.appName || "Palmr",
type: "website",
images: appInfo.appLogo
? [
{
url: appInfo.appLogo,
width: 1200,
height: 630,
alt: appInfo.appName || "Palmr",
},
]
: [],
},
twitter: {
card: "summary_large_image",
title,
description,
images: appInfo.appLogo ? [appInfo.appLogo] : [],
},
};
}