ui redesign, markdown fix + metadata and auth header
This commit is contained in:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user