ui redesign, markdown fix + metadata and auth header
This commit is contained in:
+14
-2
@@ -1,8 +1,20 @@
|
||||
# Backend Configuration
|
||||
PORT=3000
|
||||
ADMIN_TOKEN=your_secure_random_token_here
|
||||
|
||||
# REQUIRED. Long random string. Generate with `openssl rand -hex 32`.
|
||||
# Used as the admin token; stored as an HttpOnly cookie after login.
|
||||
ADMIN_TOKEN=
|
||||
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# Cookie hardening. Default is true; set to false for local HTTP development
|
||||
# only. In production with HTTPS, leave this true.
|
||||
COOKIE_SECURE=true
|
||||
|
||||
# CORS allow-list. Leave empty unless you expose the backend directly to
|
||||
# browsers. With the default Astro proxy setup, no CORS is needed.
|
||||
FRONTEND_ORIGIN=
|
||||
|
||||
# Frontend Configuration
|
||||
# URL of the backend API accessible from the frontend container
|
||||
# URL of the backend API accessible from the frontend container.
|
||||
PUBLIC_API_URL=http://backend:3000
|
||||
|
||||
+7
-10
@@ -8,10 +8,8 @@ Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*
|
||||
!.env.example
|
||||
docker-compose.override.yml
|
||||
|
||||
# Backend (Rust)
|
||||
@@ -27,9 +25,8 @@ frontend/yarn-debug.log*
|
||||
frontend/yarn-error.log*
|
||||
frontend/.pnpm-debug.log*
|
||||
|
||||
# Data (Persistent storage)
|
||||
# We might want to keep the directory structure but ignore the actual content
|
||||
# data/posts/*
|
||||
# data/uploads/*
|
||||
# !data/posts/hello-world.md
|
||||
# !data/posts/another-post.md
|
||||
# Persistent runtime data — your blog's content lives here on the deployed
|
||||
# host, not in the repo. The two seed posts under data/posts/ ship with the
|
||||
# initial commit; new files created by the editor stay local.
|
||||
data/uploads/
|
||||
data/config.json
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Narlblog
|
||||
|
||||
A single-author blog. Rust/Axum API backed by markdown files on disk; Astro/React frontend with a glass-effect Catppuccin theme. KaTeX math, GFM tables, server-side syntax highlighting, draft posts, RSS feed.
|
||||
|
||||
```
|
||||
backend/ Rust + Axum API (filesystem-backed)
|
||||
frontend/ Astro + React, SSRs pages and proxies /api/*
|
||||
data/ Runtime data — posts, uploads, config.json (host volume)
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
# Generate a strong admin token:
|
||||
echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env
|
||||
|
||||
docker compose up --build
|
||||
# → http://localhost:4321
|
||||
# → log in at /admin/login with the token from .env
|
||||
```
|
||||
|
||||
Make sure the `data/` host directory is owned by UID 1000 (the backend container's app user):
|
||||
|
||||
```sh
|
||||
sudo chown -R 1000:1000 data/
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
| ----------------- | -------- | ----------------- | --------------------------------------------------------------------------- |
|
||||
| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. |
|
||||
| `PORT` | no | `3000` | Backend port. |
|
||||
| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. |
|
||||
| `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. |
|
||||
| `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. |
|
||||
| `RUST_LOG` | no | `info` | tracing-subscriber filter. |
|
||||
| `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. |
|
||||
|
||||
## Local development
|
||||
|
||||
Backend:
|
||||
|
||||
```sh
|
||||
cd backend
|
||||
ADMIN_TOKEN=devtoken COOKIE_SECURE=false DATA_DIR=../data cargo run
|
||||
```
|
||||
|
||||
Frontend (separate terminal, Node ≥ 22.12):
|
||||
|
||||
```sh
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# → http://localhost:4321 (proxies to PUBLIC_API_URL, default http://backend:3000)
|
||||
```
|
||||
|
||||
For a fully local stack, set `PUBLIC_API_URL=http://localhost:3000` in `frontend/.env`.
|
||||
|
||||
## Authoring posts
|
||||
|
||||
Posts are markdown files at `data/posts/<slug>.md` with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
date: 2026-05-09
|
||||
summary: Optional summary; falls back to auto-extracted excerpt.
|
||||
tags:
|
||||
- rust
|
||||
- astro
|
||||
draft: false
|
||||
---
|
||||
# My post
|
||||
|
||||
Body in **markdown**, with $LaTeX$ via `$…$` / `$$…$$`, GFM tables, and fenced code blocks (```rust, ```ts, …).
|
||||
```
|
||||
|
||||
Posts with `draft: true` are hidden from the public list and 404 when accessed by anyone without an admin session. Posts are sorted by `date` descending on the frontpage.
|
||||
|
||||
The web editor at `/admin/editor` writes the same format and updates atomically (write to `.tmp`, rename over target).
|
||||
|
||||
## Uploads
|
||||
|
||||
Allowlisted extensions: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav, ogg, mp4, webm, mov. Magic bytes are checked against the extension. SVG and HTML are intentionally rejected — `/uploads/*` is served as-is, so any active content there would be XSS.
|
||||
|
||||
Max upload size: 50 MB.
|
||||
|
||||
## Backups
|
||||
|
||||
The deployed `data/` directory is the entire blog. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in.
|
||||
|
||||
```sh
|
||||
rsync -av data/ backup-host:/path/to/narlblog-data/
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend**: axum 0.8, serde_yaml frontmatter, subtle constant-time auth, infer for upload sniffing
|
||||
- **Frontend**: Astro 6, React 19, Tailwind 4, marked + marked-katex-extension + marked-highlight + DOMPurify
|
||||
- **Editor**: CodeMirror 6 with vim mode and asset autocomplete
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
.git/
|
||||
*.md
|
||||
Generated
+92
@@ -104,9 +104,12 @@ dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"infer",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"slug",
|
||||
"subtle",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
@@ -125,6 +128,12 @@ version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -141,6 +150,17 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfb"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -188,6 +208,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -204,6 +230,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -252,6 +284,12 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -363,6 +401,25 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infer"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -664,6 +721,19 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
@@ -727,6 +797,12 @@ version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
@@ -922,6 +998,22 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
|
||||
@@ -7,9 +7,12 @@ edition = "2024"
|
||||
axum = { version = "0.8.8", features = ["multipart", "macros"] }
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
dotenvy = "0.15.7"
|
||||
infer = "0.19.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
serde_yaml = "0.9.34"
|
||||
slug = "0.1.6"
|
||||
subtle = "2.6.1"
|
||||
tokio = { version = "1.50.0", features = ["full"] }
|
||||
tower-http = { version = "0.6.8", features = ["cors", "fs"] }
|
||||
tracing = "0.1.44"
|
||||
|
||||
+18
-2
@@ -1,14 +1,30 @@
|
||||
FROM rust:latest as builder
|
||||
FROM rust:1.83-slim AS builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY . .
|
||||
|
||||
# Cache deps as their own layer.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
RUN mkdir -p src \
|
||||
&& echo "fn main() {}" > src/main.rs \
|
||||
&& cargo build --release \
|
||||
&& rm -rf src target/release/deps/backend* target/release/backend*
|
||||
|
||||
# Now build the real source.
|
||||
COPY src ./src
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd --system --gid 1000 app \
|
||||
&& useradd --system --uid 1000 --gid app --home-dir /app app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /usr/src/app/target/release/backend /usr/local/bin/backend
|
||||
|
||||
USER app:app
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["backend"]
|
||||
|
||||
+40
-3
@@ -1,11 +1,48 @@
|
||||
use crate::error::AppError;
|
||||
use axum::http::HeaderMap;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tracing::warn;
|
||||
|
||||
const COOKIE_NAME: &str = "admin";
|
||||
|
||||
fn extract_token(headers: &HeaderMap) -> Option<String> {
|
||||
if let Some(auth) = headers.get("Authorization").and_then(|h| h.to_str().ok()) {
|
||||
if let Some(token) = auth.strip_prefix("Bearer ") {
|
||||
return Some(token.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(cookie_header) = headers.get("Cookie").and_then(|h| h.to_str().ok()) {
|
||||
for part in cookie_header.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(value) = part.strip_prefix(&format!("{}=", COOKIE_NAME)) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn token_matches(provided: &str, expected: &str) -> bool {
|
||||
let a = provided.as_bytes();
|
||||
let b = expected.as_bytes();
|
||||
if a.len() != b.len() {
|
||||
// Still do a constant-time compare to make timing uniform on the same-length path.
|
||||
let _ = a.ct_eq(a);
|
||||
return false;
|
||||
}
|
||||
a.ct_eq(b).into()
|
||||
}
|
||||
|
||||
pub fn is_authed(headers: &HeaderMap, admin_token: &str) -> bool {
|
||||
match extract_token(headers) {
|
||||
Some(t) => token_matches(&t, admin_token),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_auth(headers: &HeaderMap, admin_token: &str) -> Result<(), AppError> {
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
if auth_header != Some(&format!("Bearer {}", admin_token)) {
|
||||
warn!("Unauthorized access attempt detected");
|
||||
if !is_authed(headers, admin_token) {
|
||||
warn!("Unauthorized access attempt");
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+14
-6
@@ -3,6 +3,7 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use tracing::error;
|
||||
|
||||
use crate::models::ErrorResponse;
|
||||
|
||||
@@ -10,21 +11,28 @@ pub enum AppError {
|
||||
Unauthorized,
|
||||
NotFound(String),
|
||||
BadRequest(String),
|
||||
/// (public_message, internal_details) — details are logged but not returned.
|
||||
Internal(String, Option<String>),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message, details) = match self {
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string(), None),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg, None),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg, None),
|
||||
AppError::Internal(msg, details) => (StatusCode::INTERNAL_SERVER_ERROR, msg, details),
|
||||
let (status, error_message) = match self {
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
|
||||
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||
AppError::Internal(msg, details) => {
|
||||
if let Some(d) = details {
|
||||
error!("Internal error: {} — {}", msg, d);
|
||||
} else {
|
||||
error!("Internal error: {}", msg);
|
||||
}
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
let body = Json(ErrorResponse {
|
||||
error: error_message,
|
||||
details,
|
||||
});
|
||||
|
||||
(status, body).into_response()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{AppendHeaders, IntoResponse},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{AppState, error::AppError};
|
||||
|
||||
const COOKIE_TOKEN: &str = "admin";
|
||||
const COOKIE_FLAG: &str = "admin_session";
|
||||
const MAX_AGE_SECS: i64 = 60 * 60 * 24 * 30; // 30 days
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
fn cookie_attrs(secure: bool) -> String {
|
||||
let secure_part = if secure { "; Secure" } else { "" };
|
||||
format!(
|
||||
"; HttpOnly{}; SameSite=Strict; Path=/; Max-Age={}",
|
||||
secure_part, MAX_AGE_SECS
|
||||
)
|
||||
}
|
||||
|
||||
fn flag_cookie_attrs(secure: bool) -> String {
|
||||
let secure_part = if secure { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{}; SameSite=Strict; Path=/; Max-Age={}",
|
||||
secure_part, MAX_AGE_SECS
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let provided = payload.token.as_bytes();
|
||||
let expected = state.admin_token.as_bytes();
|
||||
let ok = if provided.len() == expected.len() {
|
||||
provided.ct_eq(expected).into()
|
||||
} else {
|
||||
// Run constant-time compare anyway to flatten timing.
|
||||
let _: bool = provided.ct_eq(provided).into();
|
||||
false
|
||||
};
|
||||
|
||||
if !ok {
|
||||
warn!("Failed login attempt");
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
info!("Admin logged in");
|
||||
let token_cookie = format!(
|
||||
"{}={}{}",
|
||||
COOKIE_TOKEN,
|
||||
state.admin_token,
|
||||
cookie_attrs(state.cookie_secure)
|
||||
);
|
||||
let flag_cookie = format!(
|
||||
"{}=1{}",
|
||||
COOKIE_FLAG,
|
||||
flag_cookie_attrs(state.cookie_secure)
|
||||
);
|
||||
|
||||
let headers = AppendHeaders([
|
||||
(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&token_cookie)
|
||||
.map_err(|e| AppError::Internal("Cookie".to_string(), Some(e.to_string())))?,
|
||||
),
|
||||
(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&flag_cookie)
|
||||
.map_err(|e| AppError::Internal("Cookie".to_string(), Some(e.to_string())))?,
|
||||
),
|
||||
]);
|
||||
|
||||
Ok((StatusCode::NO_CONTENT, headers))
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let secure_part = if state.cookie_secure { "; Secure" } else { "" };
|
||||
let token_cookie = format!(
|
||||
"{}=; HttpOnly{}; SameSite=Strict; Path=/; Max-Age=0",
|
||||
COOKIE_TOKEN, secure_part
|
||||
);
|
||||
let flag_cookie = format!(
|
||||
"{}={}; SameSite=Strict; Path=/; Max-Age=0",
|
||||
COOKIE_FLAG, secure_part
|
||||
);
|
||||
let headers = AppendHeaders([
|
||||
(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&token_cookie).unwrap(),
|
||||
),
|
||||
(
|
||||
axum::http::header::SET_COOKIE,
|
||||
HeaderValue::from_str(&flag_cookie).unwrap(),
|
||||
),
|
||||
]);
|
||||
(StatusCode::NO_CONTENT, headers)
|
||||
}
|
||||
|
||||
pub async fn me(State(state): State<Arc<AppState>>, headers: HeaderMap) -> StatusCode {
|
||||
if crate::auth::is_authed(&headers, &state.admin_token) {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::UNAUTHORIZED
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod posts;
|
||||
pub mod upload;
|
||||
|
||||
+182
-105
@@ -2,79 +2,164 @@ use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::{fs, sync::Arc};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::check_auth,
|
||||
auth::is_authed,
|
||||
error::AppError,
|
||||
models::{CreatePostRequest, PostDetail, PostInfo},
|
||||
models::{CreatePostRequest, PostDetail, PostInfo, PostMeta},
|
||||
};
|
||||
|
||||
const WORDS_PER_MINUTE: u32 = 200;
|
||||
|
||||
fn validate_slug(s: &str) -> Result<(), AppError> {
|
||||
if s.is_empty()
|
||||
|| s.contains('/')
|
||||
|| s.contains('\\')
|
||||
|| s.contains("..")
|
||||
|| s.contains('\0')
|
||||
{
|
||||
return Err(AppError::BadRequest("Invalid slug".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
|
||||
let raw = raw.strip_prefix("---\n").or_else(|| raw.strip_prefix("---\r\n"))?;
|
||||
let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?;
|
||||
let yaml = &raw[..end_marker];
|
||||
let body_start = end_marker
|
||||
+ raw[end_marker..]
|
||||
.find("---\n")
|
||||
.or_else(|| raw[end_marker..].find("---\r\n"))?
|
||||
+ "---\n".len();
|
||||
let body = raw[body_start..].trim_start_matches('\n').trim_start_matches('\r');
|
||||
Some((yaml, body))
|
||||
}
|
||||
|
||||
fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> {
|
||||
let (yaml, body) = split_frontmatter(raw).ok_or_else(|| {
|
||||
AppError::Internal(
|
||||
"Missing frontmatter".to_string(),
|
||||
Some("post is missing the YAML --- block".to_string()),
|
||||
)
|
||||
})?;
|
||||
let meta: PostMeta = serde_yaml::from_str(yaml).map_err(|e| {
|
||||
AppError::Internal(
|
||||
"Invalid frontmatter".to_string(),
|
||||
Some(format!("YAML parse error: {}", e)),
|
||||
)
|
||||
})?;
|
||||
Ok((meta, body.to_string()))
|
||||
}
|
||||
|
||||
fn serialize_post(meta: &PostMeta, body: &str) -> Result<String, AppError> {
|
||||
let yaml = serde_yaml::to_string(meta).map_err(|e| {
|
||||
AppError::Internal(
|
||||
"Serialization error".to_string(),
|
||||
Some(e.to_string()),
|
||||
)
|
||||
})?;
|
||||
Ok(format!("---\n{}---\n{}", yaml, body))
|
||||
}
|
||||
|
||||
fn reading_time(body: &str) -> u32 {
|
||||
let words = body.split_whitespace().count() as u32;
|
||||
(words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1)
|
||||
}
|
||||
|
||||
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
|
||||
if let Some(s) = meta.summary.as_ref() {
|
||||
if !s.trim().is_empty() {
|
||||
return s.trim().to_string();
|
||||
}
|
||||
}
|
||||
let plain = body
|
||||
.replace(['#', '*', '_', '`'], "")
|
||||
.replace('\n', " ");
|
||||
let mut out: String = plain.chars().take(200).collect();
|
||||
if plain.chars().count() > 200 {
|
||||
out.push_str("...");
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
async fn write_post_atomic(
|
||||
state: &AppState,
|
||||
slug: &str,
|
||||
contents: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let _guard = state.post_lock.lock().await;
|
||||
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug));
|
||||
let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug));
|
||||
fs::write(&tmp_path, contents).map_err(|e| {
|
||||
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
fs::rename(&tmp_path, &final_path).map_err(|e| {
|
||||
let _ = fs::remove_file(&tmp_path);
|
||||
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreatePostRequest>,
|
||||
) -> Result<Json<PostDetail>, AppError> {
|
||||
check_auth(&headers, &state.admin_token)?;
|
||||
|
||||
if payload.slug.contains('/') || payload.slug.contains('\\') || payload.slug.contains("..") {
|
||||
return Err(AppError::BadRequest("Invalid slug".to_string()));
|
||||
if !is_authed(&headers, &state.admin_token) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let file_path = state
|
||||
.data_dir
|
||||
.join("posts")
|
||||
.join(format!("{}.md", payload.slug));
|
||||
validate_slug(&payload.slug)?;
|
||||
if let Some(ref old) = payload.old_slug {
|
||||
validate_slug(old)?;
|
||||
}
|
||||
|
||||
let posts_dir = state.data_dir.join("posts");
|
||||
let file_path = posts_dir.join(format!("{}.md", payload.slug));
|
||||
|
||||
// Handle renaming
|
||||
if let Some(ref old_slug) = payload.old_slug {
|
||||
if old_slug != &payload.slug {
|
||||
let old_file_path = state
|
||||
.data_dir
|
||||
.join("posts")
|
||||
.join(format!("{}.md", old_slug));
|
||||
if old_file_path.exists() {
|
||||
// If new path already exists and it's different from old path, error out
|
||||
let old_path = posts_dir.join(format!("{}.md", old_slug));
|
||||
if old_path.exists() {
|
||||
if file_path.exists() {
|
||||
return Err(AppError::BadRequest(
|
||||
"A post with this new title already exists".to_string(),
|
||||
));
|
||||
}
|
||||
if let Err(e) = fs::rename(&old_file_path, &file_path) {
|
||||
let _guard = state.post_lock.lock().await;
|
||||
fs::rename(&old_path, &file_path).map_err(|e| {
|
||||
error!("Rename error from {} to {}: {}", old_slug, payload.slug, e);
|
||||
return Err(AppError::Internal(
|
||||
"Rename error".to_string(),
|
||||
Some(e.to_string()),
|
||||
));
|
||||
}
|
||||
AppError::Internal("Rename error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
drop(_guard);
|
||||
info!("Renamed post from {} to {}", old_slug, payload.slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut file_content = String::new();
|
||||
if let Some(ref summary) = payload.summary {
|
||||
if !summary.trim().is_empty() {
|
||||
file_content.push_str("---\nsummary: ");
|
||||
file_content.push_str(&summary.replace('\n', " "));
|
||||
file_content.push_str("\n---\n");
|
||||
}
|
||||
}
|
||||
file_content.push_str(&payload.content);
|
||||
let meta = PostMeta {
|
||||
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()),
|
||||
summary: payload.summary.filter(|s| !s.trim().is_empty()),
|
||||
tags: payload.tags,
|
||||
draft: payload.draft,
|
||||
};
|
||||
let contents = serialize_post(&meta, &payload.content)?;
|
||||
write_post_atomic(&state, &payload.slug, &contents).await?;
|
||||
|
||||
fs::write(&file_path, &file_content).map_err(|e| {
|
||||
error!("Write error for post {}: {}", payload.slug, e);
|
||||
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
|
||||
info!("Post created/updated: {}", payload.slug);
|
||||
info!("Post saved: {}", payload.slug);
|
||||
Ok(Json(PostDetail {
|
||||
slug: payload.slug,
|
||||
summary: payload.summary,
|
||||
date: meta.date,
|
||||
summary: meta.summary,
|
||||
tags: meta.tags,
|
||||
draft: meta.draft,
|
||||
reading_time: reading_time(&payload.content),
|
||||
content: payload.content,
|
||||
}))
|
||||
}
|
||||
@@ -84,10 +169,13 @@ pub async fn delete_post(
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
check_auth(&headers, &state.admin_token)?;
|
||||
if !is_authed(&headers, &state.admin_token) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
validate_slug(&slug)?;
|
||||
|
||||
let _guard = state.post_lock.lock().await;
|
||||
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
|
||||
info!("Attempting to delete post at: {:?}", file_path);
|
||||
|
||||
if !file_path.exists() {
|
||||
warn!("Post not found for deletion: {}", slug);
|
||||
@@ -103,87 +191,76 @@ pub async fn delete_post(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn list_posts(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
pub async fn list_posts(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Json<Vec<PostInfo>> {
|
||||
let admin = is_authed(&headers, &state.admin_token);
|
||||
let posts_dir = state.data_dir.join("posts");
|
||||
let mut posts = Vec::new();
|
||||
let mut posts: Vec<PostInfo> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(posts_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||
if let Some(slug) = path.file_stem().and_then(|s| s.to_str()) {
|
||||
let mut excerpt = String::new();
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if content.starts_with("---\n") {
|
||||
let parts: Vec<&str> = content.splitn(3, "---\n").collect();
|
||||
if parts.len() == 3 {
|
||||
let frontmatter = parts[1];
|
||||
for line in frontmatter.lines() {
|
||||
if line.starts_with("summary: ") {
|
||||
excerpt = line.trim_start_matches("summary: ").to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if excerpt.is_empty() {
|
||||
let body = parts[2];
|
||||
let clean_content = body.replace("#", "").replace("\n", " ");
|
||||
excerpt = clean_content.chars().take(200).collect::<String>();
|
||||
if clean_content.len() > 200 {
|
||||
excerpt.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let clean_content = content.replace("#", "").replace("\n", " ");
|
||||
excerpt = clean_content.chars().take(200).collect::<String>();
|
||||
if clean_content.len() > 200 {
|
||||
excerpt.push_str("...");
|
||||
}
|
||||
}
|
||||
}
|
||||
posts.push(PostInfo {
|
||||
slug: slug.to_string(),
|
||||
excerpt: excerpt.trim().to_string(),
|
||||
});
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if slug.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let Ok(raw) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok((meta, body)) = parse_post(&raw) else {
|
||||
warn!("Skipping post with bad frontmatter: {}", slug);
|
||||
continue;
|
||||
};
|
||||
if meta.draft && !admin {
|
||||
continue;
|
||||
}
|
||||
posts.push(PostInfo {
|
||||
slug: slug.to_string(),
|
||||
date: meta.date,
|
||||
summary: meta.summary.clone(),
|
||||
tags: meta.tags.clone(),
|
||||
draft: meta.draft,
|
||||
reading_time: reading_time(&body),
|
||||
excerpt: excerpt_from(&meta, &body),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug)));
|
||||
Json(posts)
|
||||
}
|
||||
|
||||
pub async fn get_post(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(slug): Path<String>,
|
||||
) -> Result<Json<PostDetail>, AppError> {
|
||||
validate_slug(&slug)?;
|
||||
let admin = is_authed(&headers, &state.admin_token);
|
||||
let file_path = state.data_dir.join("posts").join(format!("{}.md", slug));
|
||||
|
||||
match fs::read_to_string(&file_path) {
|
||||
Ok(raw_content) => {
|
||||
let mut summary = None;
|
||||
let mut content = raw_content.clone();
|
||||
let raw = fs::read_to_string(&file_path)
|
||||
.map_err(|_| AppError::NotFound("Post not found".to_string()))?;
|
||||
let (meta, body) = parse_post(&raw)?;
|
||||
|
||||
if raw_content.starts_with("---\n") {
|
||||
let parts: Vec<&str> = raw_content.splitn(3, "---\n").collect();
|
||||
if parts.len() == 3 {
|
||||
let frontmatter = parts[1];
|
||||
for line in frontmatter.lines() {
|
||||
if line.starts_with("summary: ") {
|
||||
summary = Some(line.trim_start_matches("summary: ").to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
content = parts[2].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(PostDetail {
|
||||
slug,
|
||||
summary,
|
||||
content,
|
||||
}))
|
||||
}
|
||||
Err(_) => Err(AppError::NotFound("Post not found".to_string())),
|
||||
if meta.draft && !admin {
|
||||
return Err(AppError::NotFound("Post not found".to_string()));
|
||||
}
|
||||
|
||||
Ok(Json(PostDetail {
|
||||
slug,
|
||||
date: meta.date,
|
||||
summary: meta.summary,
|
||||
tags: meta.tags,
|
||||
draft: meta.draft,
|
||||
reading_time: reading_time(&body),
|
||||
content: body,
|
||||
}))
|
||||
}
|
||||
|
||||
+113
-38
@@ -19,22 +19,72 @@ pub struct UploadQuery {
|
||||
pub replace: Option<bool>,
|
||||
}
|
||||
|
||||
/// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent —
|
||||
/// /uploads/* is served as-is, so any active content there is XSS waiting to happen.
|
||||
const ALLOWED_EXTS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "webp", "gif", "avif",
|
||||
"pdf", "txt", "md",
|
||||
"mp3", "wav", "ogg",
|
||||
"mp4", "webm", "mov",
|
||||
];
|
||||
|
||||
fn validate_filename(name: &str) -> Result<(), AppError> {
|
||||
if name.is_empty()
|
||||
|| name.contains('/')
|
||||
|| name.contains('\\')
|
||||
|| name.contains("..")
|
||||
|| name.contains('\0')
|
||||
|| name.starts_with('.')
|
||||
{
|
||||
return Err(AppError::BadRequest("Invalid filename".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_mime_matches_ext(bytes: &[u8], ext: &str) -> bool {
|
||||
let Some(kind) = infer::get(bytes) else {
|
||||
// Plain text formats (txt, md) won't be detected by magic bytes.
|
||||
return matches!(ext, "txt" | "md");
|
||||
};
|
||||
let mime = kind.mime_type();
|
||||
match ext {
|
||||
"jpg" | "jpeg" => mime == "image/jpeg",
|
||||
"png" => mime == "image/png",
|
||||
"webp" => mime == "image/webp",
|
||||
"gif" => mime == "image/gif",
|
||||
"avif" => mime == "image/avif",
|
||||
"pdf" => mime == "application/pdf",
|
||||
"mp3" => mime == "audio/mpeg",
|
||||
"wav" => mime == "audio/x-wav" || mime == "audio/wav",
|
||||
"ogg" => mime == "audio/ogg" || mime == "video/ogg",
|
||||
"mp4" => mime == "video/mp4",
|
||||
"webm" => mime == "video/webm",
|
||||
"mov" => mime == "video/quicktime",
|
||||
"txt" | "md" => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_upload(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(filename): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
check_auth(&headers, &state.admin_token)?;
|
||||
validate_filename(&filename)?;
|
||||
|
||||
let file_path = state.data_dir.join("uploads").join(&filename);
|
||||
let uploads_dir = state.data_dir.join("uploads");
|
||||
let file_path = uploads_dir.join(&filename);
|
||||
|
||||
// Security check to prevent directory traversal
|
||||
if file_path.parent() != Some(&state.data_dir.join("uploads")) {
|
||||
return Err(AppError::BadRequest("Invalid filename".to_string()));
|
||||
}
|
||||
|
||||
if file_path.exists() {
|
||||
fs::remove_file(file_path).map_err(|e| {
|
||||
let canonical_dir = uploads_dir.canonicalize().map_err(|e| {
|
||||
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
if let Ok(canonical_file) = file_path.canonicalize() {
|
||||
if !canonical_file.starts_with(&canonical_dir) {
|
||||
warn!("Refused delete outside uploads dir: {}", filename);
|
||||
return Err(AppError::BadRequest("Invalid filename".to_string()));
|
||||
}
|
||||
fs::remove_file(canonical_file).map_err(|e| {
|
||||
error!("Delete error for file {}: {}", filename, e);
|
||||
AppError::Internal("Delete error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
@@ -82,57 +132,82 @@ pub async fn upload_file(
|
||||
info!("Upload requested");
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let file_name = match field.file_name() {
|
||||
let original_name = match field.file_name() {
|
||||
Some(name) => name.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
info!("Processing upload for: {}", file_name);
|
||||
let slugified_name = slug::slugify(&file_name);
|
||||
info!("Processing upload for: {}", original_name);
|
||||
|
||||
let extension = std::path::Path::new(&file_name)
|
||||
let extension = std::path::Path::new(&original_name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
let final_name = if !extension.is_empty() {
|
||||
format!("{}.{}", slugified_name, extension)
|
||||
} else {
|
||||
slugified_name
|
||||
};
|
||||
if !ALLOWED_EXTS.contains(&extension.as_str()) {
|
||||
warn!("Upload rejected: extension '{}' not allowed", extension);
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"File type '.{}' not allowed",
|
||||
extension
|
||||
)));
|
||||
}
|
||||
|
||||
let stem = std::path::Path::new(&original_name)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("file");
|
||||
let slugified = slug::slugify(stem);
|
||||
let final_name = format!("{}.{}", slugified, extension);
|
||||
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
error!("Failed to read multipart bytes: {}", e);
|
||||
AppError::BadRequest(format!("Read error: {}", e))
|
||||
})?;
|
||||
|
||||
if !check_mime_matches_ext(&data, &extension) {
|
||||
warn!(
|
||||
"Upload rejected: magic bytes don't match extension '{}'",
|
||||
extension
|
||||
);
|
||||
return Err(AppError::BadRequest(
|
||||
"File contents don't match extension".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let uploads_dir = state.data_dir.join("uploads");
|
||||
let file_path = uploads_dir.join(&final_name);
|
||||
let target_path = uploads_dir.join(&final_name);
|
||||
|
||||
let final_path = if file_path.exists() && !query.replace.unwrap_or(false) {
|
||||
let final_path = if target_path.exists() && !query.replace.unwrap_or(false) {
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
uploads_dir.join(format!("{}_{}", timestamp, final_name))
|
||||
} else {
|
||||
file_path
|
||||
target_path
|
||||
};
|
||||
|
||||
// Final containment check.
|
||||
let canonical_dir = uploads_dir.canonicalize().map_err(|e| {
|
||||
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
if let Some(parent) = final_path.parent() {
|
||||
let canonical_parent = parent.canonicalize().map_err(|e| {
|
||||
AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
if canonical_parent != canonical_dir {
|
||||
return Err(AppError::BadRequest("Invalid filename".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let final_name_str = final_path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&final_name)
|
||||
.to_string();
|
||||
|
||||
let data = match field.bytes().await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
error!("Failed to read multipart bytes: {}", e);
|
||||
return Err(AppError::BadRequest(format!("Read error: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = fs::write(&final_path, &data) {
|
||||
fs::write(&final_path, &data).map_err(|e| {
|
||||
error!("Failed to write file to {:?}: {}", final_path, e);
|
||||
return Err(AppError::Internal(
|
||||
"Write error".to_string(),
|
||||
Some(e.to_string()),
|
||||
));
|
||||
}
|
||||
AppError::Internal("Write error".to_string(), Some(e.to_string()))
|
||||
})?;
|
||||
|
||||
info!("File uploaded successfully to {:?}", final_path);
|
||||
return Ok(Json(UploadResponse {
|
||||
|
||||
+45
-9
@@ -6,19 +6,22 @@ pub mod models;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
http::{HeaderValue, header},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use std::{env, fs, path::PathBuf, sync::Arc};
|
||||
use tokio::sync::Mutex;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
cors::{AllowOrigin, CorsLayer},
|
||||
services::ServeDir,
|
||||
};
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub admin_token: String,
|
||||
pub data_dir: PathBuf,
|
||||
pub cookie_secure: bool,
|
||||
pub post_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -27,13 +30,23 @@ async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let port = env::var("PORT").unwrap_or_else(|_| "3000".to_string());
|
||||
let admin_token = env::var("ADMIN_TOKEN").unwrap_or_else(|_| "secret".to_string());
|
||||
let admin_token = env::var("ADMIN_TOKEN")
|
||||
.ok()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.expect("ADMIN_TOKEN must be set to a non-empty value");
|
||||
if admin_token.len() < 16 {
|
||||
warn!(
|
||||
"ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production."
|
||||
);
|
||||
}
|
||||
let data_dir_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string());
|
||||
let data_dir = PathBuf::from(data_dir_str);
|
||||
let cookie_secure = env::var("COOKIE_SECURE")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true);
|
||||
|
||||
info!("Initializing backend with data dir: {:?}", data_dir);
|
||||
|
||||
// Ensure directories exist
|
||||
let posts_dir = data_dir.join("posts");
|
||||
let uploads_dir = data_dir.join("uploads");
|
||||
if let Err(e) = fs::create_dir_all(&posts_dir) {
|
||||
@@ -46,14 +59,36 @@ async fn main() {
|
||||
let state = Arc::new(AppState {
|
||||
admin_token,
|
||||
data_dir,
|
||||
cookie_secure,
|
||||
post_lock: Mutex::new(()),
|
||||
});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
// CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of
|
||||
// the frontend if you ever expose the backend directly to browsers.
|
||||
// Normal deployments hit the backend through the Astro proxy, which is
|
||||
// server-to-server and not subject to CORS.
|
||||
let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) {
|
||||
Some(origin) => {
|
||||
let value = HeaderValue::from_str(&origin)
|
||||
.expect("FRONTEND_ORIGIN must be a valid origin URL");
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::exact(value))
|
||||
.allow_methods([
|
||||
axum::http::Method::GET,
|
||||
axum::http::Method::POST,
|
||||
axum::http::Method::DELETE,
|
||||
axum::http::Method::OPTIONS,
|
||||
])
|
||||
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
|
||||
.allow_credentials(true)
|
||||
}
|
||||
None => CorsLayer::new(),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/auth/login", post(handlers::auth::login))
|
||||
.route("/api/auth/logout", post(handlers::auth::logout))
|
||||
.route("/api/auth/me", get(handlers::auth::me))
|
||||
.route(
|
||||
"/api/config",
|
||||
get(handlers::config::get_config).post(handlers::config::update_config),
|
||||
@@ -72,6 +107,7 @@ async fn main() {
|
||||
delete(handlers::upload::delete_upload),
|
||||
)
|
||||
.route("/api/upload", post(handlers::upload::upload_file))
|
||||
.route("/healthz", get(|| async { "ok" }))
|
||||
.nest_service("/uploads", ServeDir::new(uploads_dir))
|
||||
.layer(DefaultBodyLimit::max(50 * 1024 * 1024))
|
||||
.layer(cors)
|
||||
|
||||
+31
-2
@@ -1,3 +1,4 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
@@ -28,32 +29,60 @@ impl Default for SiteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Default)]
|
||||
pub struct PostMeta {
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub draft: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PostInfo {
|
||||
pub slug: String,
|
||||
pub date: NaiveDate,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub draft: bool,
|
||||
pub reading_time: u32,
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PostDetail {
|
||||
pub slug: String,
|
||||
pub date: NaiveDate,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub draft: bool,
|
||||
pub reading_time: u32,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePostRequest {
|
||||
pub slug: String,
|
||||
#[serde(default)]
|
||||
pub old_slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub date: Option<NaiveDate>,
|
||||
#[serde(default)]
|
||||
pub summary: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub draft: bool,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
---
|
||||
date: 2026-05-09
|
||||
summary: Markdown smoke test — bold, italic, links, blockquotes, fenced code.
|
||||
tags:
|
||||
- meta
|
||||
draft: false
|
||||
---
|
||||
# My Second Blog Post
|
||||
|
||||
Adding some more content to test the layout and the glassy look!
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
---
|
||||
date: 2026-05-09
|
||||
summary: First post — modern stack, glassy aesthetic, Catppuccin theme.
|
||||
tags:
|
||||
- meta
|
||||
- intro
|
||||
draft: false
|
||||
---
|
||||
# Welcome to Narlblog
|
||||
|
||||
This is my very first blog post! Built with a modern, glassy aesthetic and the beautiful Catppuccin color palette.
|
||||
|
||||
+26
-5
@@ -1,19 +1,34 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
expose:
|
||||
- "3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- PORT=3000
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:-default_insecure_token}
|
||||
# No fallback — fail fast if ADMIN_TOKEN isn't set in your .env.
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set in .env}
|
||||
- DATA_DIR=/app/data
|
||||
- COOKIE_SECURE=${COOKIE_SECURE:-true}
|
||||
- FRONTEND_ORIGIN=${FRONTEND_ORIGIN:-}
|
||||
- RUST_LOG=${RUST_LOG:-info}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal_net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -24,10 +39,16 @@ services:
|
||||
environment:
|
||||
- PUBLIC_API_URL=http://backend:3000
|
||||
depends_on:
|
||||
- backend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal_net
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
internal_net:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.git/
|
||||
*.log
|
||||
+6
-4
@@ -1,19 +1,21 @@
|
||||
FROM node:latest AS builder
|
||||
FROM node:22.12-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:latest
|
||||
FROM node:22.12-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
USER node
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
|
||||
@@ -9,9 +9,6 @@ import node from '@astrojs/node';
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
integrations: [react()],
|
||||
security: {
|
||||
checkOrigin: false
|
||||
},
|
||||
image: {
|
||||
service: { entrypoint: 'astro/assets/services/noop' }
|
||||
},
|
||||
|
||||
Generated
+1540
-5
File diff suppressed because it is too large
Load Diff
@@ -21,22 +21,30 @@
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.5",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"astro": "^6.0.8",
|
||||
"codemirror": "^6.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"isomorphic-dompurify": "^2.18.0",
|
||||
"katex": "^0.16.44",
|
||||
"marked": "^17.0.5",
|
||||
"marked-gfm-heading-id": "^4.1.2",
|
||||
"marked-highlight": "^2.2.2",
|
||||
"marked-katex-extension": "^5.1.5",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3"
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,53 @@
|
||||
---
|
||||
interface Props {
|
||||
slug: string;
|
||||
date: string;
|
||||
excerpt?: string;
|
||||
tags?: string[];
|
||||
draft?: boolean;
|
||||
readingTime: number;
|
||||
formatSlug: (slug: string) => string;
|
||||
}
|
||||
|
||||
const { slug, excerpt, formatSlug } = Astro.props;
|
||||
const { slug, date, excerpt, tags = [], draft = false, readingTime, formatSlug } = Astro.props;
|
||||
|
||||
const formattedDate = new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric'
|
||||
});
|
||||
---
|
||||
|
||||
<a href={`/posts/${slug}`} class="group block">
|
||||
<article class="glass p-5 md:p-8 transition-all hover:scale-[1.01] hover:bg-surface0/80 active:scale-[0.99] flex flex-col md:flex-row justify-between md:items-center gap-4 md:gap-6">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl md:text-3xl font-bold text-lavender group-hover:text-mauve transition-colors mb-2 md:mb-3">
|
||||
<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 flex-wrap items-center gap-x-3 gap-y-1 mb-2 text-xs text-subtext0">
|
||||
<time datetime={date}>{formattedDate}</time>
|
||||
<span class="opacity-50">·</span>
|
||||
<span>{readingTime} min read</span>
|
||||
{draft && (
|
||||
<>
|
||||
<span class="opacity-50">·</span>
|
||||
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-semibold text-lavender group-hover:text-mauve transition-colors mb-2">
|
||||
{formatSlug(slug)}
|
||||
</h2>
|
||||
<p class="text-text text-sm md:text-base leading-relaxed line-clamp-3" style="color: var(--text) !important;">
|
||||
<p class="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-2">
|
||||
{excerpt || `Read more about ${formatSlug(slug)}...`}
|
||||
</p>
|
||||
{tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
{tags.map(tag => (
|
||||
<span class="text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full bg-surface0 text-subtext0 border border-surface1">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="text-mauve opacity-0 group-hover:opacity-100 transition-opacity self-end md:self-auto shrink-0 hidden md:block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-8 md:h-8"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import hljs from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
function renderMath(element: HTMLElement) {
|
||||
const delimiters = [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
];
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
|
||||
for (const child of Array.from(el.childNodes)) walk(child);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
for (const { left, right, display } of delimiters) {
|
||||
const idx = text.indexOf(left);
|
||||
if (idx === -1) continue;
|
||||
const end = text.indexOf(right, idx + left.length);
|
||||
if (end === -1) continue;
|
||||
const tex = text.slice(idx + left.length, end);
|
||||
try {
|
||||
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = rendered;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, end + right.length);
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
} catch { /* skip invalid tex */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) walk(element);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export default function PostEnhancer({ containerId }: Props) {
|
||||
useEffect(() => {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
renderMath(el);
|
||||
el.querySelectorAll<HTMLElement>('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}, [containerId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const THEMES = [
|
||||
{ value: 'mocha', label: 'Mocha' },
|
||||
@@ -19,10 +19,23 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
|
||||
}
|
||||
return defaultTheme;
|
||||
});
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const isFirst = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.className = theme;
|
||||
const html = document.documentElement;
|
||||
THEMES.forEach(t => html.classList.remove(t.value));
|
||||
html.classList.add(theme);
|
||||
localStorage.setItem('user-theme', theme);
|
||||
|
||||
if (isFirst.current) {
|
||||
isFirst.current = false;
|
||||
return;
|
||||
}
|
||||
const label = THEMES.find(t => t.value === theme)?.label ?? theme;
|
||||
setToast(`Theme: ${label}`);
|
||||
const id = setTimeout(() => setToast(null), 1200);
|
||||
return () => clearTimeout(id);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
@@ -30,15 +43,17 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
className="appearance-none bg-surface0/50 text-text border border-surface1 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-mauve transition-all cursor-pointer hover:bg-surface0 pr-8 shadow-sm"
|
||||
aria-label="Theme"
|
||||
className="appearance-none bg-surface0/50 text-text border border-surface1 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-mauve transition-colors cursor-pointer hover:bg-surface0 pr-8 shadow-sm"
|
||||
>
|
||||
{THEMES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-subtext0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-9"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
{toast && <div className="toast" role="status">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function AssetManager({ mode = 'manage', onSelect }: Props) {
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{assets.map(asset => (
|
||||
<div key={asset.name} className="group relative aspect-square bg-crust rounded-xl overflow-hidden border border-white/5 transition-all hover:scale-105 shadow-lg flex flex-col">
|
||||
<div key={asset.name} className="group relative aspect-square bg-crust rounded-xl overflow-hidden border border-white/5 hover:border-mauve/40 transition-colors shadow-lg flex flex-col">
|
||||
<div className="flex-1 overflow-hidden bg-surface0/20 relative cursor-pointer">
|
||||
{isImage(asset.name) ? (
|
||||
<img src={asset.url} className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt={asset.name} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, type ReactElement } from 'react';
|
||||
import { useAuth } from '../../../stores/auth';
|
||||
import { getPosts, deletePost, ApiError } from '../../../lib/api';
|
||||
import type { Post } from '../../../lib/types';
|
||||
@@ -35,7 +35,6 @@ export default function Dashboard() {
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -94,7 +93,7 @@ export default function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS: Record<string, JSX.Element> = {
|
||||
const ICONS: Record<string, ReactElement> = {
|
||||
write: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
|
||||
assets: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>,
|
||||
settings: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>,
|
||||
|
||||
@@ -7,9 +7,7 @@ import { defaultKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import { renderMarkdown } from '../../../lib/markdown';
|
||||
import { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api';
|
||||
import type { Asset } from '../../../lib/types';
|
||||
import AssetManager from './AssetManager';
|
||||
@@ -58,43 +56,6 @@ const narlblogTheme = EditorView.theme({
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
function renderMathInElement(element: HTMLElement) {
|
||||
const delimiters = [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
];
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
|
||||
for (const child of Array.from(el.childNodes)) walk(child);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
for (const { left, right, display } of delimiters) {
|
||||
const idx = text.indexOf(left);
|
||||
if (idx === -1) continue;
|
||||
const end = text.indexOf(right, idx + left.length);
|
||||
if (end === -1) continue;
|
||||
const tex = text.slice(idx + left.length, end);
|
||||
try {
|
||||
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = rendered;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, end + right.length);
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
} catch { /* skip */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < 3; i++) walk(element);
|
||||
}
|
||||
|
||||
// Compartment for hot-swapping vim mode without recreating the editor
|
||||
const vimCompartment = new Compartment();
|
||||
|
||||
@@ -104,8 +65,12 @@ export default function Editor({ editSlug }: Props) {
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const updatePreviewRef = useRef<() => void>(() => {});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [slug, setSlug] = useState(editSlug || '');
|
||||
const [date, setDate] = useState(today);
|
||||
const [summary, setSummary] = useState('');
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
const [draft, setDraft] = useState(false);
|
||||
const [originalSlug, setOriginalSlug] = useState(editSlug || '');
|
||||
const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -126,19 +91,7 @@ export default function Editor({ editSlug }: Props) {
|
||||
const updatePreview = useCallback(() => {
|
||||
if (!showPreview || !viewRef.current || !previewRef.current) return;
|
||||
const content = viewRef.current.state.doc.toString();
|
||||
const result = marked.parse(content);
|
||||
if (typeof result === 'string') {
|
||||
previewRef.current.innerHTML = result;
|
||||
} else {
|
||||
result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; });
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (!previewRef.current) return;
|
||||
renderMathInElement(previewRef.current);
|
||||
previewRef.current.querySelectorAll<HTMLElement>('pre code').forEach(block => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
});
|
||||
previewRef.current.innerHTML = renderMarkdown(content);
|
||||
}, [showPreview]);
|
||||
|
||||
useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]);
|
||||
@@ -194,6 +147,9 @@ export default function Editor({ editSlug }: Props) {
|
||||
if (!editSlug) return;
|
||||
getPost(editSlug).then(post => {
|
||||
if (post.summary) setSummary(post.summary);
|
||||
if (post.date) setDate(post.date);
|
||||
if (post.tags?.length) setTagsInput(post.tags.join(', '));
|
||||
setDraft(!!post.draft);
|
||||
if (post.content && viewRef.current) {
|
||||
viewRef.current.dispatch({
|
||||
changes: { from: 0, to: viewRef.current.state.doc.length, insert: post.content },
|
||||
@@ -257,11 +213,18 @@ export default function Editor({ editSlug }: Props) {
|
||||
showAlertMsg('Title and content are required.', 'error');
|
||||
return;
|
||||
}
|
||||
const tags = tagsInput
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
await savePost({
|
||||
slug,
|
||||
old_slug: originalSlug || null,
|
||||
date,
|
||||
summary: summary || null,
|
||||
tags,
|
||||
draft,
|
||||
content,
|
||||
});
|
||||
showAlertMsg('Post saved!', 'success');
|
||||
@@ -323,17 +286,51 @@ export default function Editor({ editSlug }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Post Title (URL identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value)}
|
||||
required
|
||||
placeholder="my-awesome-post"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono"
|
||||
/>
|
||||
{/* Slug + Date */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_180px] gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Post Title (URL identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value)}
|
||||
required
|
||||
placeholder="my-awesome-post"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags + Draft */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4 md:items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tagsInput}
|
||||
onChange={e => setTagsInput(e.target.value)}
|
||||
placeholder="rust, astro, design"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 px-4 py-3 bg-crust border border-surface1 rounded-lg cursor-pointer hover:border-peach/40 transition-colors select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft}
|
||||
onChange={e => setDraft(e.target.checked)}
|
||||
className="accent-peach"
|
||||
/>
|
||||
<span className="text-sm font-medium text-subtext1">Draft</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { login, ApiError } from '../../../lib/api';
|
||||
import { useAuth } from '../../../stores/auth';
|
||||
|
||||
export default function Login() {
|
||||
const [value, setValue] = useState('');
|
||||
const setToken = useAuth(s => s.setToken);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const setLoggedIn = useAuth(s => s.setLoggedIn);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (value.trim()) {
|
||||
setToken(value.trim());
|
||||
const token = value.trim();
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(token);
|
||||
setLoggedIn(true);
|
||||
window.location.href = '/admin';
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
setError('Invalid token.');
|
||||
} else {
|
||||
setError('Login failed. Try again.');
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +42,22 @@ export default function Login() {
|
||||
required
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors">
|
||||
Login
|
||||
{error && (
|
||||
<p className="text-sm text-red bg-red/10 border border-red/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,29 @@ const { title, wide = false } = Astro.props;
|
||||
---
|
||||
|
||||
<Layout title={title} wide={wide}>
|
||||
<div class="glass p-6 md:p-12 mb-12" id="admin-content" style="display: none;">
|
||||
<Fragment slot="head">
|
||||
<script is:inline>
|
||||
(function () {
|
||||
try {
|
||||
var authed = document.cookie.split(';').some(function (c) {
|
||||
return c.trim().indexOf('admin_session=1') === 0;
|
||||
});
|
||||
if (!authed) {
|
||||
window.location.replace('/admin/login');
|
||||
return;
|
||||
}
|
||||
document.documentElement.classList.add('admin-authed');
|
||||
} catch (e) {
|
||||
window.location.replace('/admin/login');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</Fragment>
|
||||
|
||||
<div class="glass p-6 md:p-12 mb-12" id="admin-content">
|
||||
<header class="mb-8 md:mb-12 border-b border-white/5 pb-8 md:pb-12 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<a id="back-link" href="/admin" class="text-blue hover:text-sky transition-colors mb-4 md:mb-8 inline-flex items-center gap-2 group text-sm md:text-base" style="color: var(--blue) !important;">
|
||||
<a id="back-link" href="/admin" class="text-blue hover:text-sky transition-colors mb-4 md:mb-8 inline-flex items-center gap-2 group text-sm md:text-base">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-5 md:h-5 transition-transform group-hover:-translate-x-1"><path d="m15 18-6-6 6-6"/></svg>
|
||||
Back
|
||||
</a>
|
||||
@@ -32,20 +51,12 @@ const { title, wide = false } = Astro.props;
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (!token) {
|
||||
window.location.href = '/admin/login';
|
||||
} else {
|
||||
const content = document.getElementById('admin-content');
|
||||
if (content) content.style.display = 'block';
|
||||
|
||||
const backLink = document.getElementById('back-link');
|
||||
if (backLink && document.referrer && document.referrer.includes(window.location.host) && !document.referrer.includes('/admin/login')) {
|
||||
backLink.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
window.history.back();
|
||||
});
|
||||
}
|
||||
const backLink = document.getElementById('back-link');
|
||||
if (backLink && document.referrer && document.referrer.includes(window.location.host) && !document.referrer.includes('/admin/login')) {
|
||||
backLink.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
window.history.back();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'highlight.js/styles/atom-one-dark.css';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/jetbrains-mono';
|
||||
import ThemeSwitcher from '../components/react/ThemeSwitcher';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
wide?: boolean;
|
||||
description?: string;
|
||||
image?: string;
|
||||
type?: 'website' | 'article';
|
||||
}
|
||||
|
||||
const { title, wide = false } = Astro.props;
|
||||
const { title, wide = false, description, image, type = 'website' } = Astro.props;
|
||||
|
||||
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
|
||||
|
||||
@@ -29,28 +36,42 @@ try {
|
||||
console.error("Failed to fetch config:", e);
|
||||
}
|
||||
|
||||
const fullTitle = `${title} | ${siteConfig.title}`;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" href={siteConfig.favicon} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title} | {siteConfig.title}</title>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" href={siteConfig.favicon} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{fullTitle}</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
{description && <meta property="og:description" content={description} />}
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={siteConfig.title} />
|
||||
<meta property="og:url" content={Astro.url.href} />
|
||||
{image && <meta property="og:image" content={image} />}
|
||||
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
{description && <meta name="twitter:description" content={description} />}
|
||||
{image && <meta name="twitter:image" content={image} />}
|
||||
<link rel="alternate" type="application/rss+xml" title={siteConfig.title} href="/feed.xml" />
|
||||
{siteConfig.custom_css && <style set:html={siteConfig.custom_css} />}
|
||||
<script is:inline define:vars={{ defaultTheme: siteConfig.theme }}>
|
||||
const savedTheme = localStorage.getItem('user-theme') || defaultTheme;
|
||||
document.documentElement.className = savedTheme;
|
||||
document.documentElement.classList.add(savedTheme);
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-base text-text selection:bg-surface2 selection:text-text">
|
||||
<!-- Dynamic Mesh Gradient Background -->
|
||||
<div class="fixed inset-0 z-[-1] overflow-hidden bg-base text-text">
|
||||
<div class="absolute top-[-10%] left-[-10%] w-[60%] h-[50%] rounded-full bg-mauve/15 blur-[120px] opacity-70 animate-pulse" style="animation-duration: 10s;"></div>
|
||||
<div class="absolute bottom-[-10%] right-[-10%] w-[60%] h-[60%] rounded-full bg-blue/15 blur-[120px] opacity-60 animate-pulse" style="animation-duration: 15s;"></div>
|
||||
<div class="absolute top-[30%] right-[10%] w-[40%] h-[40%] rounded-full bg-teal/10 blur-[100px] opacity-50 animate-pulse" style="animation-duration: 12s;"></div>
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class="bg-base text-text selection:bg-surface2 selection:text-text">
|
||||
<!-- Static Mesh Gradient Background -->
|
||||
<div class="fixed inset-0 z-[-1] overflow-hidden bg-base pointer-events-none">
|
||||
<div class="absolute top-[-10%] left-[-10%] w-[55%] h-[45%] rounded-full bg-mauve/10 blur-[110px] opacity-60"></div>
|
||||
<div class="absolute bottom-[-10%] right-[-10%] w-[55%] h-[55%] rounded-full bg-blue/10 blur-[110px] opacity-50"></div>
|
||||
<div class="absolute top-[30%] right-[10%] w-[35%] h-[35%] rounded-full bg-teal/8 blur-[90px] opacity-40"></div>
|
||||
</div>
|
||||
|
||||
<nav class="max-w-6xl mx-auto px-4 md:px-6 py-4 md:py-8">
|
||||
@@ -70,14 +91,17 @@ try {
|
||||
</nav>
|
||||
|
||||
<main class={`mx-auto px-4 md:px-6 py-4 md:py-8 ${wide ? 'max-w-[95vw]' : 'max-w-6xl'}`}>
|
||||
<slot />
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="max-w-6xl mx-auto px-4 md:px-6 py-8 md:py-12 text-center text-xs md:text-sm text-subtext1 border-t border-white/5 mt-8 md:mt-12">
|
||||
<p class="mb-2">{siteConfig.footer}</p>
|
||||
<div class="text-xs text-subtext0 mb-2">
|
||||
<a href="/feed.xml" class="hover:text-mauve transition-colors">RSS</a>
|
||||
</div>
|
||||
<div class="text-subtext0 opacity-50">
|
||||
© {new Date().getFullYear()} {siteConfig.title}
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+20
-13
@@ -1,25 +1,19 @@
|
||||
import type { Post, SiteConfig, Asset } from './types';
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('admin_token');
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
||||
}
|
||||
|
||||
const res = await fetch(`/api${path}`, { ...options, headers });
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
@@ -37,11 +31,24 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const login = (token: string) =>
|
||||
apiFetch<void>('/auth/login', { method: 'POST', body: JSON.stringify({ token }) });
|
||||
export const logout = () => apiFetch<void>('/auth/logout', { method: 'POST' });
|
||||
export const checkSession = () => apiFetch<void>('/auth/me');
|
||||
|
||||
// Posts
|
||||
export const getPosts = () => apiFetch<Post[]>('/posts');
|
||||
export const getPost = (slug: string) => apiFetch<Post>(`/posts/${encodeURIComponent(slug)}`);
|
||||
export const savePost = (data: { slug: string; old_slug?: string | null; summary?: string | null; content: string }) =>
|
||||
apiFetch<Post>('/posts', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const savePost = (data: {
|
||||
slug: string;
|
||||
old_slug?: string | null;
|
||||
date?: string;
|
||||
summary?: string | null;
|
||||
tags?: string[];
|
||||
draft?: boolean;
|
||||
content: string;
|
||||
}) => apiFetch<Post>('/posts', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const deletePost = (slug: string) =>
|
||||
apiFetch<void>(`/posts/${encodeURIComponent(slug)}`, { method: 'DELETE' });
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Marked } from 'marked';
|
||||
import markedKatex from 'marked-katex-extension';
|
||||
import { markedHighlight } from 'marked-highlight';
|
||||
import { gfmHeadingId } from 'marked-gfm-heading-id';
|
||||
import hljs from 'highlight.js';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
const renderer = new Marked()
|
||||
.setOptions({ gfm: true, breaks: false })
|
||||
.use(gfmHeadingId())
|
||||
.use(
|
||||
markedHighlight({
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
|
||||
return hljs.highlight(code, { language }).value;
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use(markedKatex({ throwOnError: false, nonStandard: true }));
|
||||
|
||||
const KATEX_TAGS = [
|
||||
'math', 'annotation', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'mtext',
|
||||
'msup', 'msub', 'msubsup', 'mfrac', 'msqrt', 'mroot', 'mover', 'munder',
|
||||
'munderover', 'mtable', 'mtr', 'mtd', 'mspace', 'mstyle', 'mphantom',
|
||||
'mpadded', 'menclose',
|
||||
];
|
||||
|
||||
export function renderMarkdown(src: string): string {
|
||||
const html = renderer.parse(src, { async: false }) as string;
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: KATEX_TAGS,
|
||||
ADD_ATTR: ['aria-hidden', 'style', 'id', 'class', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel'],
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
export interface Post {
|
||||
slug: string;
|
||||
date: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
excerpt?: string;
|
||||
tags: string[];
|
||||
draft: boolean;
|
||||
reading_time: number;
|
||||
}
|
||||
|
||||
export interface SiteConfig {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Not found" description="The page you're looking for doesn't exist.">
|
||||
<div class="glass p-8 md:p-16 text-center max-w-2xl mx-auto mt-8 md:mt-16">
|
||||
<p class="text-7xl md:text-8xl font-extrabold bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal mb-4">
|
||||
404
|
||||
</p>
|
||||
<h1 class="text-2xl md:text-3xl font-semibold text-text mb-3">Page not found</h1>
|
||||
<p class="text-subtext1 mb-8">
|
||||
The page you're looking for has moved, been deleted, or never existed.
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-2 bg-mauve text-crust font-semibold px-5 py-2.5 rounded-lg hover:bg-pink transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</Layout>
|
||||
@@ -1,57 +1,45 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
const FORBIDDEN_HEADERS = new Set([
|
||||
'host', 'connection', 'content-length', 'transfer-encoding', 'origin', 'referer',
|
||||
]);
|
||||
|
||||
export const ALL: APIRoute = async ({ request, params }) => {
|
||||
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
|
||||
const path = params.path;
|
||||
|
||||
|
||||
if (!path) {
|
||||
return new Response(JSON.stringify({ error: 'Missing path' }), { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure path is properly encoded
|
||||
const url = new URL(`${API_URL}/api/${path}`);
|
||||
const requestUrl = new URL(request.url);
|
||||
url.search = requestUrl.search;
|
||||
|
||||
const headers = new Headers();
|
||||
// Filter headers to avoid conflicts.
|
||||
const forbiddenHeaders = ['host', 'connection', 'content-length', 'transfer-encoding', 'origin', 'referer'];
|
||||
|
||||
request.headers.forEach((value, key) => {
|
||||
if (!forbiddenHeaders.includes(key.toLowerCase())) {
|
||||
if (!FORBIDDEN_HEADERS.has(key.toLowerCase())) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n[Proxy Req] ${request.method} -> ${url.toString()}`);
|
||||
console.log(`[Proxy Req] Auth Header Present:`, headers.has('authorization'));
|
||||
console.log(`[Proxy Req] Content-Type:`, headers.get('content-type'));
|
||||
|
||||
try {
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers: headers,
|
||||
headers,
|
||||
};
|
||||
|
||||
// Safely handle body for mutating requests
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
// Clone the request to safely access the body stream
|
||||
const reqClone = request.clone();
|
||||
|
||||
// For DELETE requests, check if a body actually exists before attaching it
|
||||
// Some fetch implementations fail if a body is provided for DELETE
|
||||
if (request.method !== 'DELETE' || reqClone.body) {
|
||||
fetchOptions.body = reqClone.body;
|
||||
// Required by Node.js fetch when body is a ReadableStream
|
||||
// @ts-ignore
|
||||
fetchOptions.duplex = 'half';
|
||||
}
|
||||
const reqClone = request.clone();
|
||||
if (request.method !== 'DELETE' || reqClone.body) {
|
||||
fetchOptions.body = reqClone.body;
|
||||
// @ts-ignore — required by Node fetch when body is a stream
|
||||
fetchOptions.duplex = 'half';
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), fetchOptions);
|
||||
|
||||
console.log(`[Proxy Res] Backend returned ${response.status}`);
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders.set(key, value);
|
||||
@@ -59,16 +47,13 @@ export const ALL: APIRoute = async ({ request, params }) => {
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: responseHeaders
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Proxy Error] ${request.method} ${url}:`, e);
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Proxy connection failed',
|
||||
details: e instanceof Error ? e.message : String(e)
|
||||
}), {
|
||||
console.error(`[Proxy] ${request.method} ${url} failed:`, e);
|
||||
return new Response(JSON.stringify({ error: 'Proxy connection failed' }), {
|
||||
status: 502,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
interface PostInfo {
|
||||
slug: string;
|
||||
date: string;
|
||||
summary?: string;
|
||||
excerpt?: string;
|
||||
tags: string[];
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
interface SiteConfig {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/[<>&'"]/g, c => ({
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
}[c]!));
|
||||
}
|
||||
|
||||
function formatSlug(slug: string): string {
|
||||
return slug
|
||||
.split('-')
|
||||
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
const API_URL = process.env.PUBLIC_API_URL || 'http://backend:3000';
|
||||
const origin = site?.toString().replace(/\/$/, '') || '';
|
||||
|
||||
let posts: PostInfo[] = [];
|
||||
let config: SiteConfig = { title: 'Narlblog', subtitle: 'A clean, modern blog' };
|
||||
try {
|
||||
const [pr, cr] = await Promise.all([
|
||||
fetch(`${API_URL}/api/posts`),
|
||||
fetch(`${API_URL}/api/config`),
|
||||
]);
|
||||
if (pr.ok) posts = await pr.json();
|
||||
if (cr.ok) config = await cr.json();
|
||||
} catch (e) {
|
||||
console.error('feed.xml backend fetch failed', e);
|
||||
}
|
||||
|
||||
const items = posts
|
||||
.filter(p => !p.draft)
|
||||
.map(p => {
|
||||
const url = `${origin}/posts/${p.slug}`;
|
||||
const description = p.summary || p.excerpt || '';
|
||||
const pubDate = new Date(p.date).toUTCString();
|
||||
const categories = p.tags.map(t => ` <category>${escapeXml(t)}</category>`).join('\n');
|
||||
return ` <item>
|
||||
<title>${escapeXml(formatSlug(p.slug))}</title>
|
||||
<link>${escapeXml(url)}</link>
|
||||
<guid isPermaLink="true">${escapeXml(url)}</guid>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<description>${escapeXml(description)}</description>
|
||||
${categories}
|
||||
</item>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escapeXml(config.title)}</title>
|
||||
<link>${escapeXml(origin)}</link>
|
||||
<description>${escapeXml(config.subtitle)}</description>
|
||||
<language>en</language>
|
||||
<atom:link href="${escapeXml(origin)}/feed.xml" rel="self" type="application/rss+xml" />
|
||||
${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=600',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -6,7 +6,11 @@ const API_URL = process.env.PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
interface Post {
|
||||
slug: string;
|
||||
date: string;
|
||||
excerpt?: string;
|
||||
tags: string[];
|
||||
draft: boolean;
|
||||
reading_time: number;
|
||||
}
|
||||
|
||||
let posts: Post[] = [];
|
||||
@@ -46,7 +50,7 @@ function formatSlug(slug: string) {
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title="Home">
|
||||
<Layout title="Home" description={siteConfig.welcome_subtitle}>
|
||||
<div class="space-y-6 md:space-y-8">
|
||||
<section class="text-center py-6 md:py-12">
|
||||
<h1 class="text-3xl md:text-5xl font-extrabold mb-3 md:mb-4 pb-2 md:pb-4 leading-tight bg-clip-text text-transparent bg-gradient-to-r from-mauve via-blue to-teal">
|
||||
@@ -71,10 +75,14 @@ function formatSlug(slug: string) {
|
||||
)}
|
||||
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
slug={post.slug}
|
||||
excerpt={post.excerpt}
|
||||
formatSlug={formatSlug}
|
||||
<PostCard
|
||||
slug={post.slug}
|
||||
date={post.date}
|
||||
excerpt={post.excerpt}
|
||||
tags={post.tags}
|
||||
draft={post.draft}
|
||||
readingTime={post.reading_time}
|
||||
formatSlug={formatSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import PostEnhancer from '../../components/react/PostEnhancer';
|
||||
import { marked } from 'marked';
|
||||
import { renderMarkdown } from '../../lib/markdown';
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const API_URL = (typeof process !== 'undefined' ? process.env.PUBLIC_API_URL : import.meta.env.PUBLIC_API_URL) || 'http://localhost:3000';
|
||||
|
||||
interface PostDetail {
|
||||
slug: string;
|
||||
date: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
tags: string[];
|
||||
draft: boolean;
|
||||
reading_time: number;
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
let post: PostDetail | null = null;
|
||||
@@ -19,7 +27,7 @@ try {
|
||||
const response = await fetch(`${API_URL}/api/posts/${slug}`);
|
||||
if (response.ok) {
|
||||
post = await response.json();
|
||||
html = await marked.parse(post!.content);
|
||||
html = renderMarkdown(post!.content);
|
||||
} else {
|
||||
error = 'Post not found';
|
||||
}
|
||||
@@ -34,10 +42,14 @@ function formatSlug(s: string) {
|
||||
return s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
|
||||
const isAdmin = Astro.cookies.has('admin_token');
|
||||
const isAdmin = Astro.cookies.get('admin_session')?.value === '1';
|
||||
---
|
||||
|
||||
<Layout title={post ? formatSlug(post.slug) : 'Post'}>
|
||||
<Layout
|
||||
title={post ? formatSlug(post.slug) : 'Post'}
|
||||
description={post?.summary}
|
||||
type="article"
|
||||
>
|
||||
<article class="glass p-6 md:p-12 mb-8 md:mb-12 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{error && (
|
||||
<div class="text-red text-center py-12">
|
||||
@@ -58,30 +70,45 @@ const isAdmin = Astro.cookies.has('admin_token');
|
||||
Back to list
|
||||
</a>
|
||||
<div class="flex flex-col md:flex-row md:justify-between md:items-start mt-2 md:mt-4 gap-4">
|
||||
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve">
|
||||
{formatSlug(post.slug)}
|
||||
</h1>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl md:text-5xl font-extrabold text-mauve mb-3">
|
||||
{formatSlug(post.slug)}
|
||||
</h1>
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-subtext0">
|
||||
<time datetime={post.date}>{formatDate(post.date)}</time>
|
||||
<span class="opacity-50">·</span>
|
||||
<span>{post.reading_time} min read</span>
|
||||
{post.draft && (
|
||||
<>
|
||||
<span class="opacity-50">·</span>
|
||||
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
|
||||
</>
|
||||
)}
|
||||
{post.tags?.length > 0 && (
|
||||
<>
|
||||
<span class="opacity-50">·</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{post.tags.map(tag => (
|
||||
<span class="text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full bg-surface0 text-subtext0 border border-surface1">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
id="edit-link"
|
||||
href={`/admin/editor?edit=${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 hidden"
|
||||
style="color: var(--blue);"
|
||||
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>
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="post-content" class="prose max-w-none" set:html={html} />
|
||||
<PostEnhancer client:only="react" containerId="post-content" />
|
||||
<div id="post-content" class="prose" set:html={html} />
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
if (localStorage.getItem('admin_token')) {
|
||||
const el = document.getElementById('edit-link');
|
||||
if (el) el.classList.remove('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
+18
-11
@@ -1,19 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
setToken: (token: string) => void;
|
||||
logout: () => void;
|
||||
loggedIn: boolean;
|
||||
setLoggedIn: (v: boolean) => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
function readSessionCookie(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return document.cookie.split(';').some(c => c.trim().startsWith('admin_session=1'));
|
||||
}
|
||||
|
||||
export const useAuth = create<AuthState>((set) => ({
|
||||
token: typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null,
|
||||
setToken: (token: string) => {
|
||||
localStorage.setItem('admin_token', token);
|
||||
set({ token });
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('admin_token');
|
||||
set({ token: null });
|
||||
loggedIn: readSessionCookie(),
|
||||
setLoggedIn: (v: boolean) => set({ loggedIn: v }),
|
||||
logout: async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch { /* still clear local state */ }
|
||||
set({ loggedIn: false });
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/admin/login';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
+235
-65
@@ -1,8 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* NARLBLOG PROFESSIONAL THEME ENGINE
|
||||
* All UI components are automatically linked to these tokens.
|
||||
/*
|
||||
* NARLBLOG THEME ENGINE
|
||||
* All UI components automatically pick up these tokens.
|
||||
*/
|
||||
|
||||
@theme {
|
||||
@@ -32,6 +32,9 @@
|
||||
--color-pink: var(--pink);
|
||||
--color-flamingo: var(--flamingo);
|
||||
--color-rosewater: var(--rosewater);
|
||||
|
||||
--font-sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
:root, .mocha {
|
||||
@@ -70,12 +73,12 @@
|
||||
--flamingo: #eebebe; --rosewater: #f2d5cf;
|
||||
}
|
||||
|
||||
/* Redesigned light themes for better contrast */
|
||||
/* Light themes — darkened secondary text for WCAG AA against base. */
|
||||
.latte {
|
||||
--crust: #dce0e8; --mantle: #e6e9ef; --base: #eff1f5;
|
||||
--surface0: #ccd0da; --surface1: #bcc0cc; --surface2: #acb0be;
|
||||
--overlay0: #9ca0b0; --overlay1: #8c8fa1; --overlay2: #7c7f93;
|
||||
--text: #1e1e2e; --subtext0: #4c4f69; --subtext1: #5c5f77;
|
||||
--overlay0: #7c7f93; --overlay1: #6c6f85; --overlay2: #5c5f77;
|
||||
--text: #1e1e2e; --subtext0: #3c3f59; --subtext1: #4c4f69;
|
||||
--blue: #1e66f5; --lavender: #7287fd; --sapphire: #209fb5;
|
||||
--sky: #04a5e5; --teal: #179299; --green: #40a02b;
|
||||
--yellow: #df8e1d; --peach: #fe640b; --maroon: #e64553;
|
||||
@@ -88,77 +91,244 @@
|
||||
--surface0: #cbd5e1; --surface1: #94a3b8; --surface2: #64748b;
|
||||
--overlay0: #475569; --overlay1: #334155; --overlay2: #1e293b;
|
||||
--text: #0f172a; --subtext0: #1e293b; --subtext1: #334155;
|
||||
--blue: #5cdbdf; --lavender: #8ab4f8; --sapphire: #38bdf8;
|
||||
--sky: #0ea5e9; --teal: #2dd4bf; --green: #34d399;
|
||||
--yellow: #fcd34d; --peach: #fbbf24; --maroon: #f43f5e;
|
||||
--red: #ef4444; --mauve: #f0498b; --pink: #ec4899;
|
||||
--flamingo: #f472b6; --rosewater: #fda4af;
|
||||
--blue: #0284c7; --lavender: #6366f1; --sapphire: #0ea5e9;
|
||||
--sky: #0284c7; --teal: #0d9488; --green: #16a34a;
|
||||
--yellow: #ca8a04; --peach: #ea580c; --maroon: #be123c;
|
||||
--red: #dc2626; --mauve: #9333ea; --pink: #db2777;
|
||||
--flamingo: #be185d; --rosewater: #b91c1c;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--base);
|
||||
color: var(--text) !important;
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Professional Typography */
|
||||
.prose { color: var(--text) !important; }
|
||||
.prose h1 { @apply text-3xl md:text-4xl font-bold mb-4 md:mb-6; color: var(--mauve) !important; }
|
||||
.prose h2 { @apply text-2xl md:text-3xl font-semibold mb-3 md:mb-4 mt-6 md:mt-8; color: var(--lavender) !important; }
|
||||
.prose h3 { @apply text-xl md:text-2xl font-medium mb-2 md:mb-3 mt-4 md:mt-6; color: var(--blue) !important; }
|
||||
.prose p { @apply mb-3 md:mb-4 leading-relaxed text-sm md:text-base; color: var(--text) !important; }
|
||||
.prose blockquote { @apply border-l-4 border-surface2 pl-4 italic my-4 md:my-6; color: var(--subtext0) !important; }
|
||||
.prose pre { @apply p-3 md:p-4 rounded-xl overflow-x-auto border border-white/5 my-4 md:my-6 text-xs md:text-sm; background-color: var(--crust) !important; }
|
||||
.prose code { @apply bg-surface0 px-1.5 py-0.5 rounded text-xs md:text-sm font-mono; color: var(--peach) !important; }
|
||||
.prose pre code { background-color: transparent !important; padding: 0 !important; border-radius: 0 !important; color: inherit !important; }
|
||||
.prose img { @apply max-w-full h-auto rounded-xl shadow-lg border border-white/5 my-6 md:my-8; }
|
||||
.prose a { color: var(--blue) !important; text-decoration: underline; text-underline-offset: 2px; transition: color 0.2s; }
|
||||
.prose a:hover { color: var(--sky) !important; }
|
||||
.prose ul { @apply list-disc pl-6 mb-4 space-y-1 text-sm md:text-base; color: var(--text) !important; }
|
||||
.prose ol { @apply list-decimal pl-6 mb-4 space-y-1 text-sm md:text-base; color: var(--text) !important; }
|
||||
.prose li { @apply leading-relaxed; color: var(--text) !important; }
|
||||
.prose li > ul, .prose li > ol { @apply mt-1 mb-0; }
|
||||
.prose ul ul { list-style-type: circle; }
|
||||
.prose ul ul ul { list-style-type: square; }
|
||||
.prose hr { @apply my-8 border-0; border-top: 1px solid var(--surface2); }
|
||||
.prose h4 { @apply text-lg md:text-xl font-medium mb-2 mt-4; color: var(--sapphire) !important; }
|
||||
.prose h5 { @apply text-base md:text-lg font-medium mb-1 mt-3; color: var(--teal) !important; }
|
||||
.prose h6 { @apply text-sm md:text-base font-medium mb-1 mt-3; color: var(--subtext1) !important; }
|
||||
.prose strong { color: var(--text) !important; font-weight: 700; }
|
||||
.prose em { color: var(--subtext1) !important; }
|
||||
.prose del { color: var(--overlay1) !important; text-decoration: line-through; }
|
||||
.prose li input[type="checkbox"] { @apply mr-2 accent-blue; vertical-align: middle; }
|
||||
.prose li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.5rem; }
|
||||
code, pre, kbd, samp {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Professional Table Styles */
|
||||
.prose table { @apply w-full mb-8 border-collapse overflow-hidden rounded-xl; border: 1px solid var(--surface1); }
|
||||
.prose thead { @apply bg-surface0/50; }
|
||||
.prose th { @apply px-4 py-3 text-left font-bold text-sm md:text-base border-b border-surface1; color: var(--mauve) !important; }
|
||||
.prose td { @apply px-4 py-2 border-b border-surface1 text-sm md:text-base; color: var(--text) !important; }
|
||||
.prose tr:last-child td { @apply border-b-0; }
|
||||
.prose tr:nth-child(even) { @apply bg-surface0/20; }
|
||||
|
||||
/* Dynamic UI Components */
|
||||
.glass {
|
||||
/* Prose — readable column, calm hierarchy */
|
||||
.prose {
|
||||
color: var(--text);
|
||||
max-width: 70ch;
|
||||
line-height: 1.7;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.prose h1 {
|
||||
font-size: clamp(1.75rem, 1.4rem + 1.5vw, 2.5rem);
|
||||
font-weight: 700;
|
||||
color: var(--mauve);
|
||||
margin: 0 0 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose h2 {
|
||||
font-size: clamp(1.4rem, 1.2rem + 0.8vw, 1.875rem);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 2.5rem 0 1rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--surface2) 60%, transparent);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.prose h3 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 2rem 0 0.75rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.prose h4 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
}
|
||||
.prose h5 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--subtext1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
}
|
||||
.prose h6 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--subtext0);
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
.prose p {
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--mauve);
|
||||
padding: 0.25rem 0 0.25rem 1.1rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--subtext1);
|
||||
font-style: italic;
|
||||
}
|
||||
.prose pre {
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 0.75rem;
|
||||
overflow-x: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--surface1) 70%, transparent);
|
||||
margin: 1.5rem 0;
|
||||
background-color: var(--crust);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.prose code {
|
||||
background-color: color-mix(in srgb, var(--surface0) 80%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid color-mix(in srgb, var(--text) 15%, transparent);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.9em;
|
||||
color: var(--peach);
|
||||
}
|
||||
.prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.75rem;
|
||||
margin: 1.75rem 0;
|
||||
border: 1px solid color-mix(in srgb, var(--surface1) 50%, transparent);
|
||||
}
|
||||
.prose a {
|
||||
color: var(--blue);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
text-decoration-thickness: 1px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.prose a:hover {
|
||||
color: var(--sky);
|
||||
}
|
||||
.prose ul, .prose ol {
|
||||
margin: 0 0 1.1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.prose ul { list-style: disc; }
|
||||
.prose ol { list-style: decimal; }
|
||||
.prose ul ul { list-style: circle; }
|
||||
.prose ul ul ul { list-style: square; }
|
||||
.prose li { margin: 0.25rem 0; }
|
||||
.prose hr {
|
||||
margin: 2.5rem 0;
|
||||
border: 0;
|
||||
border-top: 1px solid color-mix(in srgb, var(--surface2) 70%, transparent);
|
||||
}
|
||||
.prose strong { color: var(--text); font-weight: 700; }
|
||||
.prose em { color: inherit; font-style: italic; }
|
||||
.prose del { color: var(--overlay1); text-decoration: line-through; }
|
||||
.prose li input[type="checkbox"] { margin-right: 0.5rem; accent-color: var(--blue); vertical-align: middle; }
|
||||
.prose li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.25rem; }
|
||||
|
||||
/* GFM tables */
|
||||
.prose table {
|
||||
width: 100%;
|
||||
margin: 1.75rem 0;
|
||||
border-collapse: collapse;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--surface1);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.prose thead { background-color: color-mix(in srgb, var(--surface0) 60%, transparent); }
|
||||
.prose th {
|
||||
padding: 0.6rem 0.9rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--surface1);
|
||||
}
|
||||
.prose td {
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--surface1) 60%, transparent);
|
||||
}
|
||||
.prose tr:last-child td { border-bottom: 0; }
|
||||
.prose tr:nth-child(even) td { background-color: color-mix(in srgb, var(--surface0) 25%, transparent); }
|
||||
|
||||
/* Glass surface */
|
||||
.glass {
|
||||
background-color: color-mix(in srgb, var(--surface0) 75%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
|
||||
box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.cm-s-narlblog.CodeMirror {
|
||||
background: var(--crust) !important;
|
||||
color: var(--text) !important;
|
||||
border: 1px solid var(--surface1);
|
||||
/* Don't double-blur nested glass surfaces */
|
||||
.glass .glass {
|
||||
background-color: color-mix(in srgb, var(--surface0) 50%, transparent);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.cm-s-narlblog .cm-header { color: var(--mauve) !important; }
|
||||
.cm-s-narlblog .cm-string { color: var(--green) !important; }
|
||||
.cm-s-narlblog .cm-keyword { color: var(--mauve) !important; font-weight: bold; }
|
||||
.cm-s-narlblog .CodeMirror-cursor { border-left-color: var(--text) !important; }
|
||||
|
||||
.hljs { color: var(--text) !important; background: transparent !important; }
|
||||
.hljs-keyword, .hljs-selector-tag { color: var(--mauve) !important; font-weight: bold; }
|
||||
.hljs-string { color: var(--green) !important; }
|
||||
.hljs-comment { color: var(--subtext0) !important; font-style: italic; }
|
||||
/* hljs token colors — driven by theme tokens */
|
||||
.hljs { color: var(--text); background: transparent; }
|
||||
.hljs-keyword, .hljs-selector-tag, .hljs-built_in { color: var(--mauve); font-weight: 600; }
|
||||
.hljs-string, .hljs-attr { color: var(--green); }
|
||||
.hljs-number, .hljs-literal { color: var(--peach); }
|
||||
.hljs-comment, .hljs-quote { color: var(--overlay1); font-style: italic; }
|
||||
.hljs-title, .hljs-section, .hljs-name { color: var(--blue); }
|
||||
.hljs-type, .hljs-class .hljs-title { color: var(--yellow); }
|
||||
.hljs-variable, .hljs-template-variable { color: var(--red); }
|
||||
|
||||
/* KaTeX inherits prose color */
|
||||
.katex { color: var(--text); }
|
||||
|
||||
/* Admin auth gate — set by inline head script before paint */
|
||||
html:not(.admin-authed) #admin-content { display: none; }
|
||||
.admin-authed #admin-content { display: block; }
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--surface0) 50%, transparent) 0%,
|
||||
color-mix(in srgb, var(--surface1) 50%, transparent) 50%,
|
||||
color-mix(in srgb, var(--surface0) 50%, transparent) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface0);
|
||||
border: 1px solid var(--surface1);
|
||||
color: var(--text);
|
||||
padding: 0.65rem 1.1rem;
|
||||
border-radius: 0.6rem;
|
||||
box-shadow: 0 8px 24px -8px rgba(0,0,0,0.4);
|
||||
font-size: 0.85rem;
|
||||
z-index: 200;
|
||||
animation: toast-in 0.2s ease;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translate(-50%, 8px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user