fix: safe content rendering, media library server-side fetch, robots & sitemap

This commit is contained in:
Carlos (Almeira) 2026-06-18 14:05:57 -05:00
parent 45da8c1d46
commit 9680d8788f
9 changed files with 210 additions and 32 deletions

Binary file not shown.

View File

@ -46,6 +46,23 @@ if [ -f ".env.local" ]; then
cp .env.local "$DEPLOY_DIR/.env.production.example"
fi
# 7.5. Modificar package.json para evitar que Hostinger intente hacer build
echo "Configurando package.json para producción en Hostinger..."
node -e "
const fs = require('fs');
const path = require('path');
const pkgPath = path.join('$DEPLOY_DIR', 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
pkg.scripts = {
...pkg.scripts,
build: 'echo \"Application is already built standalone. Skipping build.\"',
start: 'NODE_ENV=production node server.js'
};
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
}
"
# 8. Comprimir en un archivo zip
ZIP_FILE="almeira-web-deploy.zip"
echo "Comprimiendo aplicación en $ZIP_FILE..."

View File

@ -6,6 +6,20 @@ import { Calendar, User, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { decodeHtmlEntities } from '@/lib/utils';
import DOMPurify from 'isomorphic-dompurify';
import { Metadata, ResolvingMetadata } from 'next';
function formatTextToHtml(text: string) {
if (!text) return '';
// Si ya contiene etiquetas estructurales de HTML (párrafos, saltos, divs, listas), lo devolvemos tal cual
if (/<\/?(?:p|br|div|h[1-6]|ul|ol|li|table)[^>]*>/i.test(text)) {
return text;
}
// Convertimos texto plano con saltos de línea a párrafos y <br/>, permitiendo que etiquetas inline como <strong> o <iframe> se mantengan.
return text.split(/\n\s*\n/)
.filter(p => p.trim() !== '')
.map(p => `<p>${p.replace(/\n/g, '<br />')}</p>`)
.join('');
}
async function fetchPostData(slugOrId: string) {
if (!slugOrId || typeof slugOrId !== 'string' || slugOrId.includes('.')) return null;
@ -36,6 +50,61 @@ async function fetchPostData(slugOrId: string) {
}
}
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> },
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params;
const post = await fetchPostData(slug);
if (!post) {
return {
title: 'No encontrado - Almeira Web',
description: 'La publicación solicitada no existe o ha sido eliminada.'
};
}
const title = decodeHtmlEntities(post.titulo);
const description = post.resumen_redes
? decodeHtmlEntities(post.resumen_redes)
: (post.contenido?.replace(/<[^>]*>?/gm, '').substring(0, 160) + '...');
const PROJECT_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID || '69b84df5003bd8957c5c';
const ENDPOINT = process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT || 'https://appwrite.almeira.com.co/v1';
let imagenUrl = post.og_image_url || null;
if (post.imagen_destacada_id) {
try {
const client = await createAdminClient();
const mediaRes = await client.databases.getDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', post.imagen_destacada_id);
if (mediaRes) {
imagenUrl = `${ENDPOINT}/storage/buckets/${mediaRes.bucket_id}/files/${mediaRes.file_id}/preview?project=${PROJECT_ID}&width=1200&height=630&quality=90`;
}
} catch(e) {}
}
const previousImages = (await parent).openGraph?.images || [];
const images = imagenUrl ? [imagenUrl, ...previousImages] : previousImages;
return {
title: title,
description: description,
openGraph: {
title: title,
description: description,
images: images,
type: 'article',
publishedTime: post.fecha_publicacion || post.$createdAt,
},
twitter: {
card: 'summary_large_image',
title: title,
description: description,
images: images,
}
};
}
export default async function NoticiaDetail({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await fetchPostData(slug);
@ -110,9 +179,9 @@ export default async function NoticiaDetail({ params }: { params: Promise<{ slug
<div
className="prose prose-invert prose-cyan max-w-none text-slate-300 leading-relaxed text-lg whitespace-pre-wrap break-words
prose-headings:font-outfit prose-headings:font-black prose-headings:text-white
prose-p:mb-6 prose-strong:text-cyan-400 prose-p:empty:before:content-['\\00a0']"
prose-strong:text-cyan-400 prose-p:empty:before:content-['\\00a0']"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(post.contenido, {
__html: DOMPurify.sanitize(formatTextToHtml(post.contenido), {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'style', 'class']
})

View File

@ -126,3 +126,40 @@ export async function uploadMediaDocumentServer(formData: FormData) {
return { success: false, error: error.message };
}
}
export async function listMediaServer(search?: string, offset: number = 0) {
try {
const client = getAdminClient();
const databases = new Databases(client);
const { Query } = require('node-appwrite');
const queries = [
Query.limit(20),
Query.offset(offset),
Query.orderDesc('$createdAt')
];
if (search) {
queries.push(Query.contains('titulo', search));
}
const res = await databases.listDocuments(DATABASE_ID, 'media', queries);
// Sanitizar para Next.js
return { success: true, data: res.documents.map(doc => ({ ...doc })) };
} catch (error: any) {
console.error('Error fetching media:', error);
return { success: false, data: [] };
}
}
export async function getMediaServer(documentId: string) {
try {
const client = getAdminClient();
const databases = new Databases(client);
const doc = await databases.getDocument(DATABASE_ID, 'media', documentId);
return { success: true, data: { ...doc } };
} catch (error: any) {
console.error('Error fetching media document:', error);
return { success: false, data: null };
}
}

14
src/app/robots.ts Normal file
View File

@ -0,0 +1,14 @@
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://almeira.com.co';
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/dashboard/', '/api/'],
},
sitemap: `${baseUrl}/sitemap.xml`,
};
}

41
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,41 @@
import { MetadataRoute } from 'next';
import { createAdminClient } from '@/lib/appwrite.server';
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { Query } from 'node-appwrite';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://almeira.com.co';
// Static routes
const routes = ['', '/clasificados', '/eventos', '/tv', '/radio'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
}));
// Dynamic posts
let postRoutes: MetadataRoute.Sitemap = [];
try {
const client = await createAdminClient();
const dbId = APPWRITE_ALMEIRA_CONFIG.databaseId;
// Fetch all published posts
const postsRes = await client.databases.listDocuments(dbId, 'posts', [
Query.equal('estado', 'Publicado'),
Query.limit(1000), // Get up to 1000 posts. For more, pagination is needed.
Query.orderDesc('fecha_publicacion')
]);
postRoutes = postsRes.documents.map((post) => ({
url: `${baseUrl}/${post.slug || post.$id}`,
lastModified: new Date(post.$updatedAt || post.fecha_publicacion || post.$createdAt),
changeFrequency: 'weekly' as const,
priority: 0.7,
}));
} catch (e) {
console.error('Error generating sitemap for posts:', e);
}
return [...routes, ...postRoutes];
}

View File

@ -118,7 +118,7 @@ export const EntityForm = ({
// Ensure featured image ID is set if present
if (featuredImageId) {
formData.set('imagen_destacada_id', featuredImageId);
formData.set('logotipo', featuredImageId);
}
startTransition(async () => {
@ -127,7 +127,7 @@ export const EntityForm = ({
};
// Organize fields into Main Content and Sidebar Metadata
const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'imagen_destacada_id', 'categoria_id', 'tipo_publicacion_id'];
const sidebarFieldNames = ['estado', 'fecha_publicacion', 'fecha_finalizacion', 'slug', 'logotipo', 'categoria_id', 'tipo_publicacion_id'];
const mainFields = fields.filter(f => !sidebarFieldNames.includes(f.name));
const sidebarFields = fields.filter(f => sidebarFieldNames.includes(f.name));
@ -136,8 +136,9 @@ export const EntityForm = ({
// Set smart defaults for new items
if (!initialData) {
if (field.name === 'estado') return 'borrador';
if (field.name === 'fecha_publicacion' || field.type === 'date') {
if (field.name === 'logotipo') {
return featuredImageId;
} if (field.name === 'fecha_publicacion' || field.type === 'date') {
// Get local ISO string for datetime-local input
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);

View File

@ -4,8 +4,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ImageIcon, X, Search, CheckCircle2, UploadCloud, Loader2 } from 'lucide-react';
import { databases, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
import { Query } from 'appwrite';
import { uploadMediaDocumentServer } from '@/app/actions/appwrite';
import { uploadMediaDocumentServer, listMediaServer, getMediaServer } from '@/app/actions/appwrite';
/** Utility to generate frontend Appwrite image previews */
export function getAppwriteImageUrl(fileId: string, bucketId?: string, width = 400, height = 250) {
@ -52,25 +51,18 @@ export const MediaGridSelector = ({
setIsLoading(true);
try {
const currentOffset = more ? mediaItems.length : 0;
const queries = [
Query.limit(20),
Query.offset(currentOffset),
Query.orderDesc('$createdAt')
];
if (search) {
queries.push(Query.contains('titulo', search));
}
const res = await databases.listDocuments(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', queries);
const res = await listMediaServer(search, currentOffset);
if (more) {
setMediaItems(prev => [...prev, ...res.documents]);
if (res.success && res.data) {
if (more) {
setMediaItems(prev => [...prev, ...res.data]);
} else {
setMediaItems(res.data);
}
setHasMore(res.data.length === 20);
} else {
setMediaItems(res.documents);
setHasMore(false);
}
setHasMore(res.documents.length === 20);
} catch (error) {
console.error('Error fetching media:', error);
} finally {
@ -92,13 +84,14 @@ export const MediaGridSelector = ({
useEffect(() => {
// If we have a value but haven't loaded its document, fetch just that one
if (value && mode === 'field' && !mediaItems.some(m => m.$id === value)) {
databases.getDocument(APPWRITE_ALMEIRA_CONFIG.databaseId, 'media', value)
.then(doc => {
setMediaItems(prev => {
// Protect against race conditions duplicating the doc
if (prev.some(m => m.$id === value)) return prev;
return [...prev, doc];
});
getMediaServer(value)
.then(res => {
if (res.success && res.data) {
setMediaItems(prev => {
if (prev.some(m => m.$id === value)) return prev;
return [...prev, res.data];
});
}
}).catch(err => console.error("Error resolver media thumbnail:", err));
}
}, [value, mode]);
@ -306,7 +299,10 @@ export const MediaGridSelector = ({
</>
)}
</div>
<input type="hidden" name="imagen_destacada_id" value={value || ''} />
{/* Sólo incluir hidden input en modo campo, para evitar conflictos cuando se usa como modal puro */}
{mode === 'field' && label !== 'Logotipo Corporativo' && (
<input type="hidden" name="imagen_destacada_id" value={value || ''} />
)}
{renderModal()}
</div>
);

3
test_html.js Normal file
View File

@ -0,0 +1,3 @@
const DOMPurify = require('isomorphic-dompurify');
const txt = "Line 1\nLine 2\n\nLine 3\n<strong>Bold</strong> text";
console.log(DOMPurify.sanitize(txt));