fixed invalid post title breaking links

This commit is contained in:
2026-05-09 05:59:56 +02:00
parent bd18b96846
commit 801df057e0
6 changed files with 23 additions and 16 deletions
+14 -8
View File
@@ -115,16 +115,22 @@ pub async fn create_post(
return Err(AppError::Unauthorized); return Err(AppError::Unauthorized);
} }
validate_slug(&payload.slug)?; let slug = slug::slugify(&payload.slug);
if slug.is_empty() {
return Err(AppError::BadRequest(
"Slug is empty after normalization (try ASCII letters/numbers)".to_string(),
));
}
validate_slug(&slug)?;
if let Some(ref old) = payload.old_slug { if let Some(ref old) = payload.old_slug {
validate_slug(old)?; validate_slug(old)?;
} }
let posts_dir = state.data_dir.join("posts"); let posts_dir = state.data_dir.join("posts");
let file_path = posts_dir.join(format!("{}.md", payload.slug)); let file_path = posts_dir.join(format!("{}.md", slug));
if let Some(ref old_slug) = payload.old_slug { if let Some(ref old_slug) = payload.old_slug {
if old_slug != &payload.slug { if old_slug != &slug {
let old_path = posts_dir.join(format!("{}.md", old_slug)); let old_path = posts_dir.join(format!("{}.md", old_slug));
if old_path.exists() { if old_path.exists() {
if file_path.exists() { if file_path.exists() {
@@ -134,11 +140,11 @@ pub async fn create_post(
} }
let _guard = state.post_lock.lock().await; let _guard = state.post_lock.lock().await;
fs::rename(&old_path, &file_path).map_err(|e| { fs::rename(&old_path, &file_path).map_err(|e| {
error!("Rename error from {} to {}: {}", old_slug, payload.slug, e); error!("Rename error from {} to {}: {}", old_slug, slug, e);
AppError::Internal("Rename error".to_string(), Some(e.to_string())) AppError::Internal("Rename error".to_string(), Some(e.to_string()))
})?; })?;
drop(_guard); drop(_guard);
info!("Renamed post from {} to {}", old_slug, payload.slug); info!("Renamed post from {} to {}", old_slug, slug);
} }
} }
} }
@@ -150,11 +156,11 @@ pub async fn create_post(
draft: payload.draft, draft: payload.draft,
}; };
let contents = serialize_post(&meta, &payload.content)?; let contents = serialize_post(&meta, &payload.content)?;
write_post_atomic(&state, &payload.slug, &contents).await?; write_post_atomic(&state, &slug, &contents).await?;
info!("Post saved: {}", payload.slug); info!("Post saved: {}", slug);
Ok(Json(PostDetail { Ok(Json(PostDetail {
slug: payload.slug, slug,
date: meta.date, date: meta.date,
summary: meta.summary, summary: meta.summary,
tags: meta.tags, tags: meta.tags,
+1 -1
View File
@@ -16,7 +16,7 @@ const formattedDate = new Date(date).toLocaleDateString('en-US', {
}); });
--- ---
<a href={`/posts/${slug}`} class="group block"> <a href={`/posts/${encodeURIComponent(slug)}`} class="group block">
<article class="glass p-5 md:p-8 transition-colors hover:bg-surface0/80 flex flex-col md:flex-row justify-between md:items-center gap-4 md:gap-6"> <article class="glass p-5 md:p-8 transition-colors hover:bg-surface0/80 flex flex-col md:flex-row justify-between md:items-center gap-4 md:gap-6">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2 text-xs text-subtext0"> <div class="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2 text-xs text-subtext0">
@@ -78,7 +78,7 @@ export default function Dashboard() {
<p className="text-xs text-subtext0" style={{ color: 'var(--subtext0)' }}>/posts/{post.slug}</p> <p className="text-xs text-subtext0" style={{ color: 'var(--subtext0)' }}>/posts/{post.slug}</p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<a href={`/admin/editor?edit=${post.slug}`} className="p-2 text-blue hover:bg-blue/10 rounded transition-colors" title="Edit"> <a href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`} className="p-2 text-blue hover:bg-blue/10 rounded transition-colors" title="Edit">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
</a> </a>
<button onClick={() => handleDelete(post.slug)} className="p-2 text-red hover:bg-red/10 rounded transition-colors" title="Delete"> <button onClick={() => handleDelete(post.slug)} className="p-2 text-red hover:bg-red/10 rounded transition-colors" title="Delete">
@@ -218,7 +218,7 @@ export default function Editor({ editSlug }: Props) {
.map(t => t.trim()) .map(t => t.trim())
.filter(Boolean); .filter(Boolean);
try { try {
await savePost({ const saved = await savePost({
slug, slug,
old_slug: originalSlug || null, old_slug: originalSlug || null,
date, date,
@@ -228,7 +228,8 @@ export default function Editor({ editSlug }: Props) {
content, content,
}); });
showAlertMsg('Post saved!', 'success'); showAlertMsg('Post saved!', 'success');
setOriginalSlug(slug); if (saved?.slug && saved.slug !== slug) setSlug(saved.slug);
setOriginalSlug(saved?.slug ?? slug);
} catch (e) { } catch (e) {
showAlertMsg(e instanceof ApiError ? `Error: ${e.message}` : 'Failed to connect to server.', 'error'); showAlertMsg(e instanceof ApiError ? `Error: ${e.message}` : 'Failed to connect to server.', 'error');
} }
@@ -274,7 +275,7 @@ export default function Editor({ editSlug }: Props) {
</button> </button>
{originalSlug && ( {originalSlug && (
<a <a
href={`/posts/${originalSlug}`} href={`/posts/${encodeURIComponent(originalSlug)}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="bg-blue text-crust font-bold py-3 px-8 rounded-lg hover:bg-sky transition-all transform hover:scale-105 whitespace-nowrap inline-flex items-center justify-center gap-2" className="bg-blue text-crust font-bold py-3 px-8 rounded-lg hover:bg-sky transition-all transform hover:scale-105 whitespace-nowrap inline-flex items-center justify-center gap-2"
+1 -1
View File
@@ -51,7 +51,7 @@ export const GET: APIRoute = async ({ site }) => {
const items = posts const items = posts
.filter(p => !p.draft) .filter(p => !p.draft)
.map(p => { .map(p => {
const url = `${origin}/posts/${p.slug}`; const url = `${origin}/posts/${encodeURIComponent(p.slug)}`;
const description = p.summary || p.excerpt || ''; const description = p.summary || p.excerpt || '';
const pubDate = new Date(p.date).toUTCString(); const pubDate = new Date(p.date).toUTCString();
const categories = p.tags.map(t => ` <category>${escapeXml(t)}</category>`).join('\n'); const categories = p.tags.map(t => ` <category>${escapeXml(t)}</category>`).join('\n');
+2 -2
View File
@@ -24,7 +24,7 @@ let html = '';
let error = ''; let error = '';
try { try {
const response = await fetch(`${API_URL}/api/posts/${slug}`); const response = await fetch(`${API_URL}/api/posts/${encodeURIComponent(slug ?? '')}`);
if (response.ok) { if (response.ok) {
post = await response.json(); post = await response.json();
html = renderMarkdown(post!.content); html = renderMarkdown(post!.content);
@@ -98,7 +98,7 @@ const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
</div> </div>
<a <a
id="edit-link" id="edit-link"
href={`/admin/editor?edit=${post.slug}`} href={`/admin/editor?edit=${encodeURIComponent(post.slug)}`}
class={`bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2 text-sm md:text-base self-start ${isAdmin ? '' : 'hidden'}`} class={`bg-surface0 hover:bg-surface1 text-blue px-3 py-1.5 md:px-4 md:py-2 rounded border border-surface1 transition-colors inline-flex items-center gap-2 text-sm md:text-base self-start ${isAdmin ? '' : 'hidden'}`}
> >
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-4 md:h-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-4 md:h-4"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>