[Release] v3.2.5-beta (#320)

This commit is contained in:
Daniel Luiz Alves
2025-10-21 13:47:12 -03:00
committed by GitHub
39 changed files with 863 additions and 124 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-docs",
"version": "3.2.4-beta",
"version": "3.2.5-beta",
"description": "Docs for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-api",
"version": "3.2.4-beta",
"version": "3.2.5-beta",
"description": "API for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -617,6 +617,11 @@ export class AuthProvidersService {
return await this.linkProviderToExistingUser(existingUser, provider.id, String(externalId), userInfo);
}
// Check if auto-registration is disabled
if (provider.autoRegister === false) {
throw new Error(`User registration via ${provider.displayName || provider.name} is disabled`);
}
return await this.createNewUserWithProvider(userInfo, provider.id, String(externalId));
}

View File

@@ -1,3 +1,4 @@
import * as fs from "fs";
import bcrypt from "bcryptjs";
import { FastifyReply, FastifyRequest } from "fastify";
@@ -8,6 +9,7 @@ import {
generateUniqueFileNameForRename,
parseFileName,
} from "../../utils/file-name-generator";
import { getContentType } from "../../utils/mime-types";
import { ConfigService } from "../config/service";
import {
CheckFileInput,
@@ -200,11 +202,10 @@ export class FileController {
async getDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName: encodedObjectName } = request.params as {
const { objectName, password } = request.query as {
objectName: string;
password?: string;
};
const objectName = decodeURIComponent(encodedObjectName);
const { password } = request.query as { password?: string };
if (!objectName) {
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
@@ -218,7 +219,8 @@ export class FileController {
let hasAccess = false;
console.log("Requested file with password " + password);
// Don't log raw passwords. Log only whether a password was provided (for debugging access flow).
console.log(`Requested file access for object="${objectName}" passwordProvided=${password ? true : false}`);
const shares = await prisma.share.findMany({
where: {
@@ -270,6 +272,118 @@ export class FileController {
}
}
async downloadFile(request: FastifyRequest, reply: FastifyReply) {
try {
const { objectName, password } = request.query as {
objectName: string;
password?: string;
};
if (!objectName) {
return reply.status(400).send({ error: "The 'objectName' parameter is required." });
}
const fileRecord = await prisma.file.findFirst({ where: { objectName } });
if (!fileRecord) {
if (objectName.startsWith("reverse-shares/")) {
const reverseShareFile = await prisma.reverseShareFile.findFirst({
where: { objectName },
include: {
reverseShare: true,
},
});
if (!reverseShareFile) {
return reply.status(404).send({ error: "File not found." });
}
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (!userId || reverseShareFile.reverseShare.creatorId !== userId) {
return reply.status(401).send({ error: "Unauthorized access to file." });
}
} catch (err) {
return reply.status(401).send({ error: "Unauthorized access to file." });
}
const storageProvider = (this.fileService as any).storageProvider;
const filePath = storageProvider.getFilePath(objectName);
const contentType = getContentType(reverseShareFile.name);
const fileName = reverseShareFile.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
const stream = fs.createReadStream(filePath);
return reply.send(stream);
}
return reply.status(404).send({ error: "File not found." });
}
let hasAccess = false;
const shares = await prisma.share.findMany({
where: {
files: {
some: {
id: fileRecord.id,
},
},
},
include: {
security: true,
},
});
for (const share of shares) {
if (!share.security.password) {
hasAccess = true;
break;
} else if (password) {
const isPasswordValid = await bcrypt.compare(password, share.security.password);
if (isPasswordValid) {
hasAccess = true;
break;
}
}
}
if (!hasAccess) {
try {
await request.jwtVerify();
const userId = (request as any).user?.userId;
if (userId && fileRecord.userId === userId) {
hasAccess = true;
}
} catch (err) {}
}
if (!hasAccess) {
return reply.status(401).send({ error: "Unauthorized access to file." });
}
const storageProvider = (this.fileService as any).storageProvider;
const filePath = storageProvider.getFilePath(objectName);
const contentType = getContentType(fileRecord.name);
const fileName = fileRecord.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
const stream = fs.createReadStream(filePath);
return reply.send(stream);
} catch (error) {
console.error("Error in downloadFile:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
async listFiles(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify();
@@ -471,6 +585,51 @@ export class FileController {
}
}
async embedFile(request: FastifyRequest, reply: FastifyReply) {
try {
const { id } = request.params as { id: string };
if (!id) {
return reply.status(400).send({ error: "File ID is required." });
}
const fileRecord = await prisma.file.findUnique({ where: { id } });
if (!fileRecord) {
return reply.status(404).send({ error: "File not found." });
}
const extension = fileRecord.extension.toLowerCase();
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico", "avif"];
const videoExts = ["mp4", "webm", "ogg", "mov", "avi", "mkv", "flv", "wmv"];
const audioExts = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "wma"];
const isMedia = imageExts.includes(extension) || videoExts.includes(extension) || audioExts.includes(extension);
if (!isMedia) {
return reply.status(403).send({
error: "Embed is only allowed for images, videos, and audio files.",
});
}
const storageProvider = (this.fileService as any).storageProvider;
const filePath = storageProvider.getFilePath(fileRecord.objectName);
const contentType = getContentType(fileRecord.name);
const fileName = fileRecord.name;
reply.header("Content-Type", contentType);
reply.header("Content-Disposition", `inline; filename="${encodeURIComponent(fileName)}"`);
reply.header("Cache-Control", "public, max-age=31536000"); // Cache por 1 ano
const stream = fs.createReadStream(filePath);
return reply.send(stream);
} catch (error) {
console.error("Error in embedFile:", error);
return reply.status(500).send({ error: "Internal server error." });
}
}
private async getAllUserFilesRecursively(userId: string): Promise<any[]> {
const rootFiles = await prisma.file.findMany({
where: { userId, folderId: null },

View File

@@ -106,17 +106,15 @@ export async function fileRoutes(app: FastifyInstance) {
);
app.get(
"/files/:objectName/download",
"/files/download-url",
{
schema: {
tags: ["File"],
operationId: "getDownloadUrl",
summary: "Get Download URL",
description: "Generates a pre-signed URL for downloading a file",
params: z.object({
objectName: z.string().min(1, "The objectName is required"),
}),
querystring: z.object({
objectName: z.string().min(1, "The objectName is required"),
password: z.string().optional().describe("Share password if required"),
}),
response: {
@@ -133,6 +131,46 @@ export async function fileRoutes(app: FastifyInstance) {
fileController.getDownloadUrl.bind(fileController)
);
app.get(
"/embed/:id",
{
schema: {
tags: ["File"],
operationId: "embedFile",
summary: "Embed File (Public Access)",
description:
"Returns a media file (image/video/audio) for public embedding without authentication. Only works for media files.",
params: z.object({
id: z.string().min(1, "File ID is required").describe("The file ID"),
}),
response: {
400: z.object({ error: z.string().describe("Error message") }),
403: z.object({ error: z.string().describe("Error message - not a media file") }),
404: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
fileController.embedFile.bind(fileController)
);
app.get(
"/files/download",
{
schema: {
tags: ["File"],
operationId: "downloadFile",
summary: "Download File",
description: "Downloads a file directly (returns file content)",
querystring: z.object({
objectName: z.string().min(1, "The objectName is required"),
password: z.string().optional().describe("Share password if required"),
}),
},
},
fileController.downloadFile.bind(fileController)
);
app.get(
"/files",
{

View File

@@ -84,7 +84,6 @@ export class FilesystemController {
const result = await this.handleChunkedUpload(request, chunkMetadata, tokenData.objectName);
if (result.isComplete) {
provider.consumeUploadToken(token);
reply.status(200).send({
message: "File uploaded successfully",
objectName: result.finalPath,
@@ -104,7 +103,6 @@ export class FilesystemController {
}
} else {
await this.uploadFileStream(request, provider, tokenData.objectName);
provider.consumeUploadToken(token);
reply.status(200).send({ message: "File uploaded successfully" });
}
} catch (error) {
@@ -271,8 +269,6 @@ export class FilesystemController {
reply.header("Content-Length", fileSize);
await this.downloadFileStream(reply, provider, tokenData.objectName, downloadId);
}
provider.consumeDownloadToken(token);
} finally {
this.memoryManager.endDownload(downloadId);
}

View File

@@ -192,13 +192,9 @@ export class FilesystemStorageProvider implements StorageProvider {
return `/api/filesystem/upload/${token}`;
}
async getPresignedGetUrl(objectName: string, expires: number, fileName?: string): Promise<string> {
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = Date.now() + expires * 1000;
this.downloadTokens.set(token, { objectName, expiresAt, fileName });
return `/api/filesystem/download/${token}`;
async getPresignedGetUrl(objectName: string): Promise<string> {
const encodedObjectName = encodeURIComponent(objectName);
return `/api/files/download?objectName=${encodedObjectName}`;
}
async deleteObject(objectName: string): Promise<void> {
@@ -636,13 +632,8 @@ export class FilesystemStorageProvider implements StorageProvider {
return { objectName: data.objectName, fileName: data.fileName };
}
consumeUploadToken(token: string): void {
this.uploadTokens.delete(token);
}
consumeDownloadToken(token: string): void {
this.downloadTokens.delete(token);
}
// Tokens are automatically cleaned up by cleanExpiredTokens() every 5 minutes
// No need to manually consume tokens - allows reuse for previews, range requests, etc.
private async cleanupTempFile(tempPath: string): Promise<void> {
try {

View File

@@ -150,7 +150,9 @@
"move": "نقل",
"rename": "إعادة تسمية",
"search": "بحث",
"share": "مشاركة"
"share": "مشاركة",
"copied": "تم النسخ",
"copy": "نسخ"
},
"createShare": {
"title": "إنشاء مشاركة",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "معاينة الملف",
"description": "معاينة وتنزيل الملف",
"loading": "جاري التحميل...",
"notAvailable": "المعاينة غير متاحة لهذا النوع من الملفات.",
"downloadToView": "استخدم زر التحميل لتنزيل الملف.",
@@ -1932,5 +1935,17 @@
"passwordRequired": "كلمة المرور مطلوبة",
"nameRequired": "الاسم مطلوب",
"required": "هذا الحقل مطلوب"
},
"embedCode": {
"title": "تضمين الصورة",
"description": "استخدم هذه الأكواد لتضمين هذه الصورة في المنتديات أو المواقع الإلكترونية أو المنصات الأخرى",
"tabs": {
"directLink": "رابط مباشر",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "عنوان URL مباشر لملف الصورة",
"htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
"bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Verschieben",
"rename": "Umbenennen",
"search": "Suchen",
"share": "Teilen"
"share": "Teilen",
"copied": "Kopiert",
"copy": "Kopieren"
},
"createShare": {
"title": "Freigabe Erstellen",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Datei-Vorschau",
"description": "Vorschau und Download der Datei",
"loading": "Laden...",
"notAvailable": "Vorschau für diesen Dateityp nicht verfügbar.",
"downloadToView": "Verwenden Sie die Download-Schaltfläche, um die Datei herunterzuladen.",
@@ -1930,5 +1933,17 @@
"passwordRequired": "Passwort ist erforderlich",
"nameRequired": "Name ist erforderlich",
"required": "Dieses Feld ist erforderlich"
},
"embedCode": {
"title": "Bild einbetten",
"description": "Verwenden Sie diese Codes, um dieses Bild in Foren, Websites oder anderen Plattformen einzubetten",
"tabs": {
"directLink": "Direkter Link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Direkte URL zur Bilddatei",
"htmlDescription": "Verwenden Sie diesen Code, um das Bild in HTML-Seiten einzubetten",
"bbcodeDescription": "Verwenden Sie diesen Code, um das Bild in Foren einzubetten, die BBCode unterstützen"
}
}
}

View File

@@ -150,7 +150,9 @@
"rename": "Rename",
"move": "Move",
"share": "Share",
"search": "Search"
"search": "Search",
"copy": "Copy",
"copied": "Copied"
},
"createShare": {
"title": "Create Share",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Preview File",
"description": "Preview and download file",
"loading": "Loading...",
"notAvailable": "Preview not available for this file type",
"downloadToView": "Use the download button to view this file",
@@ -1881,6 +1884,18 @@
"userr": "User"
}
},
"embedCode": {
"title": "Embed Image",
"description": "Use these codes to embed this image in forums, websites, or other platforms",
"tabs": {
"directLink": "Direct Link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Direct URL to the image file",
"htmlDescription": "Use this code to embed the image in HTML pages",
"bbcodeDescription": "Use this code to embed the image in forums that support BBCode"
},
"validation": {
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",
@@ -1896,4 +1911,4 @@
"nameRequired": "Name is required",
"required": "This field is required"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Mover",
"rename": "Renombrar",
"search": "Buscar",
"share": "Compartir"
"share": "Compartir",
"copied": "Copiado",
"copy": "Copiar"
},
"createShare": {
"title": "Crear Compartir",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Vista Previa del Archivo",
"description": "Vista previa y descarga de archivo",
"loading": "Cargando...",
"notAvailable": "Vista previa no disponible para este tipo de archivo.",
"downloadToView": "Use el botón de descarga para descargar el archivo.",
@@ -1930,5 +1933,17 @@
"passwordRequired": "Se requiere la contraseña",
"nameRequired": "El nombre es obligatorio",
"required": "Este campo es obligatorio"
},
"embedCode": {
"title": "Insertar imagen",
"description": "Utiliza estos códigos para insertar esta imagen en foros, sitios web u otras plataformas",
"tabs": {
"directLink": "Enlace directo",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL directa al archivo de imagen",
"htmlDescription": "Utiliza este código para insertar la imagen en páginas HTML",
"bbcodeDescription": "Utiliza este código para insertar la imagen en foros que admiten BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Déplacer",
"rename": "Renommer",
"search": "Rechercher",
"share": "Partager"
"share": "Partager",
"copied": "Copié",
"copy": "Copier"
},
"createShare": {
"title": "Créer un Partage",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Aperçu du Fichier",
"description": "Aperçu et téléchargement du fichier",
"loading": "Chargement...",
"notAvailable": "Aperçu non disponible pour ce type de fichier.",
"downloadToView": "Utilisez le bouton de téléchargement pour télécharger le fichier.",
@@ -1930,5 +1933,17 @@
"passwordRequired": "Le mot de passe est requis",
"nameRequired": "Nome é obrigatório",
"required": "Este campo é obrigatório"
},
"embedCode": {
"title": "Intégrer l'image",
"description": "Utilisez ces codes pour intégrer cette image dans des forums, sites web ou autres plateformes",
"tabs": {
"directLink": "Lien direct",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL directe vers le fichier image",
"htmlDescription": "Utilisez ce code pour intégrer l'image dans des pages HTML",
"bbcodeDescription": "Utilisez ce code pour intégrer l'image dans des forums prenant en charge BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "स्थानांतरित करें",
"rename": "नाम बदलें",
"search": "खोजें",
"share": "साझा करें"
"share": "साझा करें",
"copied": "कॉपी किया गया",
"copy": "कॉपी करें"
},
"createShare": {
"title": "साझाकरण बनाएं",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "फ़ाइल पूर्वावलोकन",
"description": "फ़ाइल पूर्वावलोकन और डाउनलोड",
"loading": "लोड हो रहा है...",
"notAvailable": "इस फ़ाइल प्रकार के लिए पूर्वावलोकन उपलब्ध नहीं है।",
"downloadToView": "फ़ाइल डाउनलोड करने के लिए डाउनलोड बटन का उपयोग करें।",
@@ -1930,5 +1933,17 @@
"passwordRequired": "पासवर्ड आवश्यक है",
"nameRequired": "नाम आवश्यक है",
"required": "यह फ़ील्ड आवश्यक है"
},
"embedCode": {
"title": "छवि एम्बेड करें",
"description": "इस छवि को मंचों, वेबसाइटों या अन्य प्लेटफार्मों में एम्बेड करने के लिए इन कोड का उपयोग करें",
"tabs": {
"directLink": "सीधा लिंक",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "छवि फ़ाइल का सीधा URL",
"htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Sposta",
"rename": "Rinomina",
"search": "Cerca",
"share": "Condividi"
"share": "Condividi",
"copied": "Copiato",
"copy": "Copia"
},
"createShare": {
"title": "Crea Condivisione",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Anteprima File",
"description": "Anteprima e download del file",
"loading": "Caricamento...",
"notAvailable": "Anteprima non disponibile per questo tipo di file.",
"downloadToView": "Utilizzare il pulsante di download per scaricare il file.",
@@ -1930,5 +1933,17 @@
"passwordMinLength": "La password deve contenere almeno 6 caratteri",
"nameRequired": "Il nome è obbligatorio",
"required": "Questo campo è obbligatorio"
},
"embedCode": {
"title": "Incorpora immagine",
"description": "Usa questi codici per incorporare questa immagine in forum, siti web o altre piattaforme",
"tabs": {
"directLink": "Link diretto",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL diretto al file immagine",
"htmlDescription": "Usa questo codice per incorporare l'immagine nelle pagine HTML",
"bbcodeDescription": "Usa questo codice per incorporare l'immagine nei forum che supportano BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "移動",
"rename": "名前を変更",
"search": "検索",
"share": "共有"
"share": "共有",
"copied": "コピーしました",
"copy": "コピー"
},
"createShare": {
"title": "共有を作成",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "ファイルプレビュー",
"description": "ファイルをプレビューしてダウンロード",
"loading": "読み込み中...",
"notAvailable": "このファイルタイプのプレビューは利用できません。",
"downloadToView": "ダウンロードボタンを使用してファイルをダウンロードしてください。",
@@ -1930,5 +1933,17 @@
"passwordRequired": "パスワードは必須です",
"nameRequired": "名前は必須です",
"required": "このフィールドは必須です"
},
"embedCode": {
"title": "画像を埋め込む",
"description": "これらのコードを使用して、この画像をフォーラム、ウェブサイト、またはその他のプラットフォームに埋め込みます",
"tabs": {
"directLink": "直接リンク",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "画像ファイルへの直接URL",
"htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
"bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "이동",
"rename": "이름 변경",
"search": "검색",
"share": "공유"
"share": "공유",
"copied": "복사됨",
"copy": "복사"
},
"createShare": {
"title": "공유 생성",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "파일 미리보기",
"description": "파일 미리보기 및 다운로드",
"loading": "로딩 중...",
"notAvailable": "이 파일 유형에 대한 미리보기를 사용할 수 없습니다.",
"downloadToView": "다운로드 버튼을 사용하여 파일을 다운로드하세요.",
@@ -1930,5 +1933,17 @@
"passwordRequired": "비밀번호는 필수입니다",
"nameRequired": "이름은 필수입니다",
"required": "이 필드는 필수입니다"
},
"embedCode": {
"title": "이미지 삽입",
"description": "이 코드를 사용하여 포럼, 웹사이트 또는 기타 플랫폼에 이 이미지를 삽입하세요",
"tabs": {
"directLink": "직접 링크",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "이미지 파일에 대한 직접 URL",
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
"bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Verplaatsen",
"rename": "Hernoemen",
"search": "Zoeken",
"share": "Delen"
"share": "Delen",
"copied": "Gekopieerd",
"copy": "Kopiëren"
},
"createShare": {
"title": "Delen Maken",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Bestandsvoorbeeld",
"description": "Bestand bekijken en downloaden",
"loading": "Laden...",
"notAvailable": "Voorbeeld niet beschikbaar voor dit bestandstype.",
"downloadToView": "Gebruik de downloadknop om het bestand te downloaden.",
@@ -1930,5 +1933,17 @@
"passwordMinLength": "Wachtwoord moet minimaal 6 tekens bevatten",
"nameRequired": "Naam is verplicht",
"required": "Dit veld is verplicht"
},
"embedCode": {
"title": "Afbeelding insluiten",
"description": "Gebruik deze codes om deze afbeelding in te sluiten in forums, websites of andere platforms",
"tabs": {
"directLink": "Directe link",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Directe URL naar het afbeeldingsbestand",
"htmlDescription": "Gebruik deze code om de afbeelding in te sluiten in HTML-pagina's",
"bbcodeDescription": "Gebruik deze code om de afbeelding in te sluiten in forums die BBCode ondersteunen"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Przenieś",
"rename": "Zmień nazwę",
"search": "Szukaj",
"share": "Udostępnij"
"share": "Udostępnij",
"copied": "Skopiowano",
"copy": "Kopiuj"
},
"createShare": {
"title": "Utwórz Udostępnienie",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Podgląd pliku",
"description": "Podgląd i pobieranie pliku",
"loading": "Ładowanie...",
"notAvailable": "Podgląd niedostępny dla tego typu pliku",
"downloadToView": "Użyj przycisku pobierania, aby wyświetlić ten plik",
@@ -1930,5 +1933,17 @@
"passwordMinLength": "Hasło musi mieć co najmniej 6 znaków",
"nameRequired": "Nazwa jest wymagana",
"required": "To pole jest wymagane"
},
"embedCode": {
"title": "Osadź obraz",
"description": "Użyj tych kodów, aby osadzić ten obraz na forach, stronach internetowych lub innych platformach",
"tabs": {
"directLink": "Link bezpośredni",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Bezpośredni adres URL pliku obrazu",
"htmlDescription": "Użyj tego kodu, aby osadzić obraz na stronach HTML",
"bbcodeDescription": "Użyj tego kodu, aby osadzić obraz na forach obsługujących BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Mover",
"rename": "Renomear",
"search": "Pesquisar",
"share": "Compartilhar"
"share": "Compartilhar",
"copied": "Copiado",
"copy": "Copiar"
},
"createShare": {
"title": "Criar compartilhamento",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Visualizar Arquivo",
"description": "Visualizar e baixar arquivo",
"loading": "Carregando...",
"notAvailable": "Preview não disponível para este tipo de arquivo.",
"downloadToView": "Use o botão de download para baixar o arquivo.",
@@ -1931,5 +1934,17 @@
"lastNameRequired": "O sobrenome é necessário",
"usernameLength": "O nome de usuário deve ter pelo menos 3 caracteres",
"usernameSpaces": "O nome de usuário não pode conter espaços"
},
"embedCode": {
"title": "Incorporar imagem",
"description": "Use estes códigos para incorporar esta imagem em fóruns, sites ou outras plataformas",
"tabs": {
"directLink": "Link direto",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "URL direto para o arquivo de imagem",
"htmlDescription": "Use este código para incorporar a imagem em páginas HTML",
"bbcodeDescription": "Use este código para incorporar a imagem em fóruns que suportam BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Переместить",
"rename": "Переименовать",
"search": "Поиск",
"share": "Поделиться"
"share": "Поделиться",
"copied": "Скопировано",
"copy": "Копировать"
},
"createShare": {
"title": "Создать общий доступ",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Предварительный просмотр файла",
"description": "Просмотр и загрузка файла",
"loading": "Загрузка...",
"notAvailable": "Предварительный просмотр недоступен для этого типа файла.",
"downloadToView": "Используйте кнопку загрузки для скачивания файла.",
@@ -1930,5 +1933,17 @@
"passwordRequired": "Требуется пароль",
"nameRequired": "Требуется имя",
"required": "Это поле обязательно"
},
"embedCode": {
"title": "Встроить изображение",
"description": "Используйте эти коды для встраивания этого изображения на форумах, веб-сайтах или других платформах",
"tabs": {
"directLink": "Прямая ссылка",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Прямой URL-адрес файла изображения",
"htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
"bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "Taşı",
"rename": "Yeniden Adlandır",
"search": "Ara",
"share": "Paylaş"
"share": "Paylaş",
"copied": "Kopyalandı",
"copy": "Kopyala"
},
"createShare": {
"title": "Paylaşım Oluştur",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "Dosya Önizleme",
"description": "Dosyayı önizleyin ve indirin",
"loading": "Yükleniyor...",
"notAvailable": "Bu dosya türü için önizleme mevcut değil.",
"downloadToView": "Dosyayı indirmek için indirme düğmesini kullanın.",
@@ -1930,5 +1933,17 @@
"passwordRequired": "Şifre gerekli",
"nameRequired": "İsim gereklidir",
"required": "Bu alan zorunludur"
},
"embedCode": {
"title": "Resmi Yerleştir",
"description": "Bu görüntüyü forumlara, web sitelerine veya diğer platformlara yerleştirmek için bu kodları kullanın",
"tabs": {
"directLink": "Doğrudan Bağlantı",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "Resim dosyasının doğrudan URL'si",
"htmlDescription": "Resmi HTML sayfalarına yerleştirmek için bu kodu kullanın",
"bbcodeDescription": "BBCode destekleyen forumlara resmi yerleştirmek için bu kodu kullanın"
}
}
}

View File

@@ -150,7 +150,9 @@
"move": "移动",
"rename": "重命名",
"search": "搜索",
"share": "分享"
"share": "分享",
"copied": "已复制",
"copy": "复制"
},
"createShare": {
"title": "创建分享",
@@ -302,6 +304,7 @@
},
"filePreview": {
"title": "文件预览",
"description": "预览和下载文件",
"loading": "加载中...",
"notAvailable": "此文件类型不支持预览。",
"downloadToView": "使用下载按钮下载文件。",
@@ -1930,5 +1933,17 @@
"passwordRequired": "密码为必填项",
"nameRequired": "名称为必填项",
"required": "此字段为必填项"
},
"embedCode": {
"title": "嵌入图片",
"description": "使用这些代码将此图片嵌入到论坛、网站或其他平台中",
"tabs": {
"directLink": "直接链接",
"html": "HTML",
"bbcode": "BBCode"
},
"directLinkDescription": "图片文件的直接URL",
"htmlDescription": "使用此代码将图片嵌入HTML页面",
"bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-web",
"version": "3.2.4-beta",
"version": "3.2.5-beta",
"description": "Frontend for Palmr",
"private": true,
"author": "Daniel Luiz Alves <daniel@kyantech.com.br>",

View File

@@ -900,16 +900,7 @@ export function ReceivedFilesModal({
</Dialog>
{previewFile && (
<ReverseShareFilePreviewModal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
file={{
id: previewFile.id,
name: previewFile.name,
objectName: previewFile.objectName,
extension: previewFile.extension,
}}
/>
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
)}
</>
);

View File

@@ -7,23 +7,11 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { deleteReverseShareFile } from "@/http/endpoints/reverse-shares";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
import { downloadReverseShareWithQueue } from "@/utils/download-queue-utils";
import { getFileIcon } from "@/utils/file-icons";
import { ReverseShareFilePreviewModal } from "./reverse-share-file-preview-modal";
interface ReverseShareFile {
id: string;
name: string;
description: string | null;
extension: string;
size: string;
objectName: string;
uploaderEmail: string | null;
uploaderName: string | null;
createdAt: string;
updatedAt: string;
}
interface ReceivedFilesSectionProps {
files: ReverseShareFile[];
onFileDeleted?: () => void;
@@ -159,16 +147,7 @@ export function ReceivedFilesSection({ files, onFileDeleted }: ReceivedFilesSect
</div>
{previewFile && (
<ReverseShareFilePreviewModal
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
file={{
id: previewFile.id,
name: previewFile.name,
objectName: previewFile.objectName,
extension: previewFile.extension,
}}
/>
<ReverseShareFilePreviewModal isOpen={!!previewFile} onClose={() => setPreviewFile(null)} file={previewFile} />
)}
</>
);

View File

@@ -1,26 +1,20 @@
"use client";
import { FilePreviewModal } from "@/components/modals/file-preview-modal";
import type { ReverseShareFile } from "@/http/endpoints/reverse-shares/types";
interface ReverseShareFilePreviewModalProps {
isOpen: boolean;
onClose: () => void;
file: {
id: string;
name: string;
objectName: string;
extension?: string;
} | null;
file: ReverseShareFile | null;
}
export function ReverseShareFilePreviewModal({ isOpen, onClose, file }: ReverseShareFilePreviewModalProps) {
if (!file) return null;
const adaptedFile = {
name: file.name,
objectName: file.objectName,
type: file.extension,
id: file.id,
...file,
description: file.description ?? undefined,
};
return <FilePreviewModal isOpen={isOpen} onClose={onClose} file={adaptedFile} isReverseShare={true} />;

View File

@@ -30,7 +30,7 @@ export function ReverseSharesSearch({
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<h2 className="text-xl font-semibold">{t("reverseShares.search.title")}</h2>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing} className="sm:w-auto">
<Button variant="outline" size="icon" onClick={onRefresh} disabled={isRefreshing}>
<IconRefresh className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
</Button>
<Button onClick={onCreateReverseShare} className="w-full sm:w-auto">

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const searchParams = req.nextUrl.searchParams;
const objectName = searchParams.get("objectName");
if (!objectName) {
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
}
// Forward all query params to backend
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/download-url?${queryString}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
},
});
const data = await apiRes.json();
return new NextResponse(JSON.stringify(data), {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}

View File

@@ -4,13 +4,22 @@ import { detectMimeTypeWithFallback } from "@/utils/mime-types";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ objectPath: string[] }> }) {
const { objectPath } = await params;
export async function GET(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const objectName = objectPath.join("/");
const searchParams = req.nextUrl.searchParams;
const objectName = searchParams.get("objectName");
if (!objectName) {
return new NextResponse(JSON.stringify({ error: "objectName parameter is required" }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
}
const queryString = searchParams.toString();
const url = `${API_BASE_URL}/files/${encodeURIComponent(objectName)}/download${queryString ? `?${queryString}` : ""}`;
const url = `${API_BASE_URL}/files/download?${queryString}`;
const apiRes = await fetch(url, {
method: "GET",

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
/**
* Short public embed endpoint: /e/{id}
* No authentication required
* Only works for media files (images, videos, audio)
*/
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!id) {
return new NextResponse(JSON.stringify({ error: "File ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const url = `${API_BASE_URL}/embed/${id}`;
try {
const apiRes = await fetch(url, {
method: "GET",
redirect: "manual",
});
if (!apiRes.ok) {
const errorText = await apiRes.text();
return new NextResponse(errorText, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
}
const blob = await apiRes.blob();
const contentType = apiRes.headers.get("content-type") || "application/octet-stream";
const contentDisposition = apiRes.headers.get("content-disposition");
const cacheControl = apiRes.headers.get("cache-control");
const res = new NextResponse(blob, {
status: apiRes.status,
headers: {
"Content-Type": contentType,
},
});
if (contentDisposition) {
res.headers.set("Content-Disposition", contentDisposition);
}
if (cacheControl) {
res.headers.set("Cache-Control", cacheControl);
} else {
res.headers.set("Cache-Control", "public, max-age=31536000");
}
return res;
} catch (error) {
console.error("Error proxying embed request:", error);
return new NextResponse(JSON.stringify({ error: "Failed to fetch file" }), {
status: 500,
headers: {
"Content-Type": "application/json",
},
});
}
}

View File

@@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface EmbedCodeDisplayProps {
imageUrl: string;
fileName: string;
fileId: string;
}
export function EmbedCodeDisplay({ imageUrl, fileName, fileId }: EmbedCodeDisplayProps) {
const t = useTranslations();
const [copiedType, setCopiedType] = useState<string | null>(null);
const [fullUrl, setFullUrl] = useState<string>("");
useEffect(() => {
if (typeof window !== "undefined") {
const origin = window.location.origin;
const embedUrl = `${origin}/e/${fileId}`;
setFullUrl(embedUrl);
}
}, [fileId]);
const directLink = fullUrl || imageUrl;
const htmlCode = `<img src="${directLink}" alt="${fileName}" />`;
const bbCode = `[img]${directLink}[/img]`;
const copyToClipboard = async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedType(type);
setTimeout(() => setCopiedType(null), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
return (
<Card>
<CardContent>
<div className="space-y-4">
<div>
<Label className="text-sm font-semibold">{t("embedCode.title")}</Label>
<p className="text-xs text-muted-foreground mt-1">{t("embedCode.description")}</p>
</div>
<Tabs defaultValue="direct" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="direct" className="cursor-pointer">
{t("embedCode.tabs.directLink")}
</TabsTrigger>
<TabsTrigger value="html" className="cursor-pointer">
{t("embedCode.tabs.html")}
</TabsTrigger>
<TabsTrigger value="bbcode" className="cursor-pointer">
{t("embedCode.tabs.bbcode")}
</TabsTrigger>
</TabsList>
<TabsContent value="direct" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={directLink}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button
size="default"
variant="outline"
onClick={() => copyToClipboard(directLink, "direct")}
className="shrink-0 h-full"
>
{copiedType === "direct" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.directLinkDescription")}</p>
</TabsContent>
<TabsContent value="html" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={htmlCode}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button variant="outline" onClick={() => copyToClipboard(htmlCode, "html")} className="shrink-0 h-full">
{copiedType === "html" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.htmlDescription")}</p>
</TabsContent>
<TabsContent value="bbcode" className="space-y-2">
<div className="flex gap-2">
<input
type="text"
readOnly
value={bbCode}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button variant="outline" onClick={() => copyToClipboard(bbCode, "bbcode")} className="shrink-0 h-full">
{copiedType === "bbcode" ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t("embedCode.bbcodeDescription")}</p>
</TabsContent>
</Tabs>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
interface MediaEmbedLinkProps {
fileId: string;
}
export function MediaEmbedLink({ fileId }: MediaEmbedLinkProps) {
const t = useTranslations();
const [copied, setCopied] = useState(false);
const [embedUrl, setEmbedUrl] = useState<string>("");
useEffect(() => {
if (typeof window !== "undefined") {
const origin = window.location.origin;
const url = `${origin}/e/${fileId}`;
setEmbedUrl(url);
}
}, [fileId]);
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(embedUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
}
};
return (
<Card>
<CardContent>
<div className="space-y-3">
<div>
<Label className="text-sm font-semibold">{t("embedCode.title")}</Label>
<p className="text-xs text-muted-foreground mt-1">{t("embedCode.directLinkDescription")}</p>
</div>
<div className="flex gap-2">
<input
type="text"
readOnly
value={embedUrl}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted/50 font-mono"
/>
<Button size="default" variant="outline" onClick={copyToClipboard} className="shrink-0 h-full">
{copied ? (
<>
<IconCheck className="h-4 w-4 mr-1" />
{t("common.copied")}
</>
) : (
<>
<IconCopy className="h-4 w-4 mr-1" />
{t("common.copy")}
</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -3,10 +3,20 @@
import { IconDownload } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { EmbedCodeDisplay } from "@/components/files/embed-code-display";
import { MediaEmbedLink } from "@/components/files/media-embed-link";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useFilePreview } from "@/hooks/use-file-preview";
import { getFileIcon } from "@/utils/file-icons";
import { getFileType } from "@/utils/file-types";
import { FilePreviewRenderer } from "./previews";
interface FilePreviewModalProps {
@@ -32,6 +42,10 @@ export function FilePreviewModal({
}: FilePreviewModalProps) {
const t = useTranslations();
const previewState = useFilePreview({ file, isOpen, isReverseShare, sharePassword });
const fileType = getFileType(file.name);
const isImage = fileType === "image";
const isVideo = fileType === "video";
const isAudio = fileType === "audio";
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -44,6 +58,7 @@ export function FilePreviewModal({
})()}
<span className="truncate">{file.name}</span>
</DialogTitle>
<DialogDescription className="sr-only">{t("filePreview.description")}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto">
<FilePreviewRenderer
@@ -59,6 +74,16 @@ export function FilePreviewModal({
description={file.description}
onDownload={previewState.handleDownload}
/>
{isImage && previewState.previewUrl && !previewState.isLoading && file.id && (
<div className="mt-4 mb-2">
<EmbedCodeDisplay imageUrl={previewState.previewUrl} fileName={file.name} fileId={file.id} />
</div>
)}
{(isVideo || isAudio) && !previewState.isLoading && file.id && (
<div className="mt-4 mb-2">
<MediaEmbedLink fileId={file.id} />
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>

View File

@@ -163,8 +163,7 @@ export function FilesGrid({
try {
loadingUrls.current.add(file.objectName);
const encodedObjectName = encodeURIComponent(file.objectName);
const response = await getDownloadUrl(encodedObjectName);
const response = await getDownloadUrl(file.objectName);
if (!componentMounted.current) break;

View File

@@ -187,8 +187,7 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
let url = downloadUrl;
if (!url) {
const encodedObjectName = encodeURIComponent(objectName);
const response = await getDownloadUrl(encodedObjectName);
const response = await getDownloadUrl(objectName);
url = response.data.url;
}

View File

@@ -181,12 +181,11 @@ export function useFilePreview({ file, isOpen, isReverseShare = false, sharePass
const response = await downloadReverseShareFile(file.id!);
url = response.data.url;
} else {
const encodedObjectName = encodeURIComponent(file.objectName);
const params: Record<string, string> = {};
if (sharePassword) params.password = sharePassword;
const response = await getDownloadUrl(
encodedObjectName,
file.objectName,
Object.keys(params).length > 0
? {
params: { ...params },

View File

@@ -80,7 +80,8 @@ export const getDownloadUrl = <TData = GetDownloadUrlResult>(
objectName: string,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.get(`/api/files/download/${objectName}`, options);
const encodedObjectName = encodeURIComponent(objectName);
return apiInstance.get(`/api/files/download-url?objectName=${encodedObjectName}`, options);
};
/**

View File

@@ -21,8 +21,7 @@ async function waitForDownloadReady(objectName: string, fileName: string): Promi
while (attempts < maxAttempts) {
try {
const encodedObjectName = encodeURIComponent(objectName);
const response = await getDownloadUrl(encodedObjectName);
const response = await getDownloadUrl(objectName);
if (response.status !== 202) {
return response.data.url;
@@ -98,13 +97,12 @@ export async function downloadFileWithQueue(
options.onStart?.(downloadId);
}
const encodedObjectName = encodeURIComponent(objectName);
// getDownloadUrl already handles encoding
const params: Record<string, string> = {};
if (sharePassword) params.password = sharePassword;
const response = await getDownloadUrl(
encodedObjectName,
objectName,
Object.keys(params).length > 0
? {
params: { ...params },
@@ -208,13 +206,12 @@ export async function downloadFileAsBlobWithQueue(
downloadUrl = response.data.url;
}
} else {
const encodedObjectName = encodeURIComponent(objectName);
// getDownloadUrl already handles encoding
const params: Record<string, string> = {};
if (sharePassword) params.password = sharePassword;
const response = await getDownloadUrl(
encodedObjectName,
objectName,
Object.keys(params).length > 0
? {
params: { ...params },

View File

@@ -1,6 +1,6 @@
{
"name": "palmr-monorepo",
"version": "3.2.4-beta",
"version": "3.2.5-beta",
"description": "Palmr monorepo with Husky configuration",
"private": true,
"packageManager": "pnpm@10.6.0",