fix: safe content rendering, media library server-side fetch, robots & sitemap
This commit is contained in:
parent
45da8c1d46
commit
9680d8788f
Binary file not shown.
@ -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..."
|
||||
|
||||
@ -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']
|
||||
})
|
||||
|
||||
@ -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
14
src/app/robots.ts
Normal 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
41
src/app/sitemap.ts
Normal 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];
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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
3
test_html.js
Normal 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));
|
||||
Loading…
Reference in New Issue
Block a user