feat: added file uploads to posts 44b87023
Steve · 2026-04-07 19:29 11 file(s) · +350 −12
.gitignore +1 −0
3 3
*.db
4 4
.env
5 5
.DS_Store
6 +
apps/posts/uploads
apps/posts/.env.example +1 −0
1 1
POSTS_PASSWORD=changeme
2 2
POSTS_DB_PATH=posts.sqlite
3 +
UPLOADS_DIR=uploads
3 4
COOKIE_SECURE=false
4 5
HOST=127.0.0.1
5 6
PORT=3000
apps/posts/Cargo.toml +1 −1
8 8
homepage = "https://github.com/stevedylandev/andromeda"
9 9
10 10
[dependencies]
11 -
axum = { workspace = true }
11 +
axum = { workspace = true, features = ["multipart"] }
12 12
tokio = { workspace = true }
13 13
serde = { workspace = true }
14 14
serde_json = { workspace = true }
apps/posts/docker-compose.yml +2 −0
8 8
    environment:
9 9
      - POSTS_PASSWORD=${POSTS_PASSWORD:-changeme}
10 10
      - POSTS_DB_PATH=/data/posts.sqlite
11 +
      - UPLOADS_DIR=/data/uploads
12 +
      - SITE_URL=${SITE_URL:-http://localhost:3000}
11 13
      - COOKIE_SECURE=false
12 14
      - HOST=0.0.0.0
13 15
      - PORT=${PORT:-3000}
apps/posts/src/db.rs +85 −0
61 61
    pub updated_at: String,
62 62
}
63 63
64 +
#[derive(Debug, Serialize, Deserialize, Clone)]
65 +
pub struct UploadedFile {
66 +
    pub id: i64,
67 +
    pub short_id: String,
68 +
    pub filename: String,
69 +
    pub original_name: String,
70 +
    pub content_type: String,
71 +
    pub size: i64,
72 +
    pub created_at: String,
73 +
}
74 +
64 75
pub fn init_db() -> Db {
65 76
    let path = std::env::var("POSTS_DB_PATH").unwrap_or_else(|_| "posts.sqlite".to_string());
66 77
    let conn = Connection::open(&path).expect("Failed to open database");
105 116
        CREATE TABLE IF NOT EXISTS settings (
106 117
            key   TEXT PRIMARY KEY,
107 118
            value TEXT NOT NULL
119 +
        );
120 +
121 +
        CREATE TABLE IF NOT EXISTS files (
122 +
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
123 +
            short_id      TEXT NOT NULL UNIQUE,
124 +
            filename      TEXT NOT NULL UNIQUE,
125 +
            original_name TEXT NOT NULL,
126 +
            content_type  TEXT NOT NULL DEFAULT 'application/octet-stream',
127 +
            size          INTEGER NOT NULL,
128 +
            created_at    TEXT NOT NULL DEFAULT (datetime('now'))
108 129
        );"
109 130
    )
110 131
    .expect("Failed to create tables");
505 526
    )?;
506 527
    Ok(())
507 528
}
529 +
530 +
// --- File CRUD ---
531 +
532 +
fn row_to_file(row: &rusqlite::Row) -> rusqlite::Result<UploadedFile> {
533 +
    Ok(UploadedFile {
534 +
        id: row.get(0)?,
535 +
        short_id: row.get(1)?,
536 +
        filename: row.get(2)?,
537 +
        original_name: row.get(3)?,
538 +
        content_type: row.get(4)?,
539 +
        size: row.get(5)?,
540 +
        created_at: row.get(6)?,
541 +
    })
542 +
}
543 +
544 +
const FILE_COLS: &str = "id, short_id, filename, original_name, content_type, size, created_at";
545 +
546 +
pub fn create_file(
547 +
    db: &Db,
548 +
    filename: &str,
549 +
    original_name: &str,
550 +
    content_type: &str,
551 +
    size: i64,
552 +
) -> Result<UploadedFile, DbError> {
553 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
554 +
    let short_id = nanoid!(10);
555 +
    conn.execute(
556 +
        "INSERT INTO files (short_id, filename, original_name, content_type, size) VALUES (?1, ?2, ?3, ?4, ?5)",
557 +
        params![short_id, filename, original_name, content_type, size],
558 +
    )?;
559 +
    let id = conn.last_insert_rowid();
560 +
    let file = conn.query_row(
561 +
        &format!("SELECT {} FROM files WHERE id = ?1", FILE_COLS),
562 +
        params![id],
563 +
        row_to_file,
564 +
    )?;
565 +
    Ok(file)
566 +
}
567 +
568 +
pub fn get_all_files(db: &Db) -> Result<Vec<UploadedFile>, DbError> {
569 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
570 +
    let mut stmt = conn.prepare(
571 +
        &format!("SELECT {} FROM files ORDER BY id DESC", FILE_COLS),
572 +
    )?;
573 +
    let files = stmt
574 +
        .query_map([], row_to_file)?
575 +
        .collect::<Result<Vec<_>, _>>()?;
576 +
    Ok(files)
577 +
}
578 +
579 +
pub fn delete_file(db: &Db, short_id: &str) -> Result<Option<UploadedFile>, DbError> {
580 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
581 +
    let file = match conn.query_row(
582 +
        &format!("SELECT {} FROM files WHERE short_id = ?1", FILE_COLS),
583 +
        params![short_id],
584 +
        row_to_file,
585 +
    ) {
586 +
        Ok(f) => f,
587 +
        Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
588 +
        Err(e) => return Err(DbError::Sqlite(e)),
589 +
    };
590 +
    conn.execute("DELETE FROM files WHERE short_id = ?1", params![short_id])?;
591 +
    Ok(Some(file))
592 +
}
apps/posts/src/server.rs +174 −6
1 1
use askama::Template;
2 2
use askama_web::WebTemplate;
3 3
use axum::{
4 -
    extract::{Form, Path, Query, State},
4 +
    extract::{DefaultBodyLimit, Form, Multipart, Path, Query, State},
5 5
    http::{HeaderValue, StatusCode, Uri},
6 6
    response::{Html, IntoResponse, Redirect, Response},
7 7
    routing::{get, post},
12 12
use std::sync::Arc;
13 13
14 14
use crate::auth;
15 -
use crate::db::{self, Db, Page, Post};
15 +
use crate::db::{self, Db, Page, Post, UploadedFile};
16 16
17 17
#[derive(Clone)]
18 18
pub struct AppState {
19 19
    pub db: Db,
20 20
    pub app_password: String,
21 21
    pub cookie_secure: bool,
22 +
    pub uploads_dir: String,
23 +
    pub site_url: String,
22 24
}
23 25
24 26
#[derive(Embed)]
107 109
    success: bool,
108 110
}
109 111
112 +
#[derive(Template)]
113 +
#[template(path = "admin_files.html")]
114 +
struct AdminFilesTemplate {
115 +
    files: Vec<UploadedFile>,
116 +
    site_url: String,
117 +
    error: Option<String>,
118 +
    success: bool,
119 +
}
120 +
110 121
// --- Query/Form structs ---
111 122
112 123
#[derive(serde::Deserialize, Default)]
160 171
                "slug" => attrs.slug = value,
161 172
                "alias" => attrs.alias = value,
162 173
                "published_date" => attrs.published_date = value,
163 -
                "meta_description" => attrs.meta_description = value,
174 +
                "description" | "meta_description" => attrs.meta_description = value,
164 175
                "meta_image" => attrs.meta_image = value,
165 176
                "lang" => attrs.lang = value,
166 177
                "tags" => attrs.tags = value,
197 208
        "js" => "application/javascript",
198 209
        "html" => "text/html",
199 210
        "png" => "image/png",
211 +
        "jpg" | "jpeg" => "image/jpeg",
212 +
        "gif" => "image/gif",
213 +
        "webp" => "image/webp",
200 214
        "ico" => "image/x-icon",
201 215
        "svg" => "image/svg+xml",
202 216
        "woff" | "woff2" => "font/woff2",
203 217
        "ttf" => "font/ttf",
204 218
        "otf" => "font/otf",
205 219
        "json" | "webmanifest" => "application/json",
220 +
        "pdf" => "application/pdf",
221 +
        "mp4" => "video/mp4",
222 +
        "webm" => "video/webm",
206 223
        _ => "application/octet-stream",
207 224
    }
208 225
}
745 762
    Redirect::to("/admin/settings?success=true").into_response()
746 763
}
747 764
765 +
// --- Admin file handlers ---
766 +
767 +
async fn admin_files(
768 +
    _session: auth::AuthSession,
769 +
    State(state): State<Arc<AppState>>,
770 +
    Query(q): Query<FlashQuery>,
771 +
) -> Response {
772 +
    match db::get_all_files(&state.db) {
773 +
        Ok(files) => WebTemplate(AdminFilesTemplate {
774 +
            files,
775 +
            site_url: state.site_url.clone(),
776 +
            error: q.error,
777 +
            success: q.success,
778 +
        })
779 +
        .into_response(),
780 +
        Err(e) => {
781 +
            tracing::error!("Failed to list files: {}", e);
782 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
783 +
        }
784 +
    }
785 +
}
786 +
787 +
async fn admin_upload_file(
788 +
    _session: auth::AuthSession,
789 +
    State(state): State<Arc<AppState>>,
790 +
    mut multipart: Multipart,
791 +
) -> Response {
792 +
    let mut file_data: Option<(String, String, Vec<u8>)> = None;
793 +
794 +
    while let Ok(Some(field)) = multipart.next_field().await {
795 +
        if field.name() == Some("file") {
796 +
            let original_name = field
797 +
                .file_name()
798 +
                .unwrap_or("upload")
799 +
                .to_string();
800 +
            let content_type = field
801 +
                .content_type()
802 +
                .unwrap_or("application/octet-stream")
803 +
                .to_string();
804 +
            match field.bytes().await {
805 +
                Ok(bytes) => {
806 +
                    file_data = Some((original_name, content_type, bytes.to_vec()));
807 +
                }
808 +
                Err(e) => {
809 +
                    tracing::error!("Failed to read upload: {}", e);
810 +
                    return Redirect::to("/admin/files?error=Failed+to+read+upload").into_response();
811 +
                }
812 +
            }
813 +
        }
814 +
    }
815 +
816 +
    let (original_name, content_type, bytes) = match file_data {
817 +
        Some(d) => d,
818 +
        None => return Redirect::to("/admin/files?error=No+file+provided").into_response(),
819 +
    };
820 +
821 +
    let max_size: usize = 10 * 1024 * 1024;
822 +
    if bytes.len() > max_size {
823 +
        return Redirect::to("/admin/files?error=File+exceeds+10MB+limit").into_response();
824 +
    }
825 +
826 +
    let ext = original_name
827 +
        .rsplit('.')
828 +
        .next()
829 +
        .filter(|e| !e.is_empty() && *e != original_name)
830 +
        .unwrap_or("");
831 +
    let id = nanoid::nanoid!(10);
832 +
    let stored_name = if ext.is_empty() {
833 +
        id
834 +
    } else {
835 +
        format!("{}.{}", id, ext)
836 +
    };
837 +
838 +
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name);
839 +
    if let Err(e) = tokio::fs::write(&path, &bytes).await {
840 +
        tracing::error!("Failed to write file: {}", e);
841 +
        return Redirect::to("/admin/files?error=Failed+to+save+file").into_response();
842 +
    }
843 +
844 +
    match db::create_file(&state.db, &stored_name, &original_name, &content_type, bytes.len() as i64) {
845 +
        Ok(_) => Redirect::to("/admin/files?success=true").into_response(),
846 +
        Err(e) => {
847 +
            tracing::error!("Failed to record file: {}", e);
848 +
            let _ = tokio::fs::remove_file(&path).await;
849 +
            Redirect::to("/admin/files?error=Failed+to+record+file").into_response()
850 +
        }
851 +
    }
852 +
}
853 +
854 +
async fn admin_delete_file(
855 +
    _session: auth::AuthSession,
856 +
    State(state): State<Arc<AppState>>,
857 +
    Path(short_id): Path<String>,
858 +
) -> Response {
859 +
    match db::delete_file(&state.db, &short_id) {
860 +
        Ok(Some(file)) => {
861 +
            let path = std::path::PathBuf::from(&state.uploads_dir).join(&file.filename);
862 +
            if let Err(e) = tokio::fs::remove_file(&path).await {
863 +
                tracing::warn!("Failed to delete file from disk: {}", e);
864 +
            }
865 +
            Redirect::to("/admin/files").into_response()
866 +
        }
867 +
        Ok(None) => Redirect::to("/admin/files").into_response(),
868 +
        Err(e) => {
869 +
            tracing::error!("Failed to delete file: {}", e);
870 +
            Redirect::to("/admin/files").into_response()
871 +
        }
872 +
    }
873 +
}
874 +
875 +
async fn serve_uploaded_file(
876 +
    State(state): State<Arc<AppState>>,
877 +
    Path(filename): Path<String>,
878 +
) -> Response {
879 +
    if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
880 +
        return StatusCode::NOT_FOUND.into_response();
881 +
    }
882 +
883 +
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&filename);
884 +
    match tokio::fs::read(&path).await {
885 +
        Ok(bytes) => {
886 +
            let mime = mime_from_path(&filename);
887 +
            (
888 +
                StatusCode::OK,
889 +
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
890 +
                bytes,
891 +
            )
892 +
                .into_response()
893 +
        }
894 +
        Err(_) => StatusCode::NOT_FOUND.into_response(),
895 +
    }
896 +
}
897 +
748 898
// --- RSS feed handler ---
749 899
750 900
fn xml_escape(s: &str) -> String {
761 911
        .ok()
762 912
        .flatten()
763 913
        .unwrap_or_default();
764 -
    let site_url = std::env::var("SITE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
765 -
    let site_url = site_url.trim_end_matches('/');
914 +
    let site_url = &state.site_url;
766 915
767 916
    let posts = match db::get_published_posts(&state.db) {
768 917
        Ok(posts) => posts,
861 1010
        .map(|v| v == "true")
862 1011
        .unwrap_or(false);
863 1012
1013 +
    let uploads_dir = std::env::var("UPLOADS_DIR").unwrap_or_else(|_| "uploads".to_string());
1014 +
    tokio::fs::create_dir_all(&uploads_dir)
1015 +
        .await
1016 +
        .expect("Failed to create uploads directory");
1017 +
1018 +
    let site_url = std::env::var("SITE_URL")
1019 +
        .unwrap_or_else(|_| "http://localhost:3000".to_string())
1020 +
        .trim_end_matches('/')
1021 +
        .to_string();
1022 +
864 1023
    let state = Arc::new(AppState {
865 1024
        db,
866 1025
        app_password,
867 1026
        cookie_secure,
1027 +
        uploads_dir,
1028 +
        site_url,
868 1029
    });
869 1030
870 1031
    let app = Router::new()
893 1054
        .route("/admin/pages/{id}/delete", post(admin_delete_page))
894 1055
        // Admin settings
895 1056
        .route("/admin/settings", get(admin_get_settings).post(admin_post_settings))
1057 +
        // Admin files
1058 +
        .route("/admin/files", get(admin_files))
1059 +
        .route("/admin/files/upload", post(admin_upload_file))
1060 +
        .route("/admin/files/{id}/delete", post(admin_delete_file))
1061 +
        // Public files
1062 +
        .route("/files/{filename}", get(serve_uploaded_file))
896 1063
        // Static assets
897 1064
        .route("/static/{*path}", get(serve_static))
898 1065
        // Fallback
899 1066
        .fallback(get(fallback_handler))
900 -
        .with_state(state);
1067 +
        .with_state(state)
1068 +
        .layer(DefaultBodyLimit::max(11 * 1024 * 1024));
901 1069
902 1070
    let addr = format!("{}:{}", host, port);
903 1071
    tracing::info!("Listening on http://{}", addr);
apps/posts/static/styles.css +29 −1
41 41
  min-height: 100vh;
42 42
  max-width: 700px;
43 43
  margin: auto;
44 -
  padding: 0 1rem;
44 +
  padding: 0 1rem 4rem;
45 45
}
46 46
47 47
@media (max-width: 480px) {
221 221
222 222
.post-title {
223 223
  font-size: 16px;
224 +
}
225 +
226 +
.post-description {
227 +
  font-style: italic;
228 +
  opacity: 0.7;
224 229
}
225 230
226 231
.post-date {
585 590
  color: white;
586 591
  line-height: 1;
587 592
}
593 +
594 +
/* File upload input */
595 +
596 +
input[type="file"] {
597 +
  background: #121113;
598 +
  color: #ffffff;
599 +
  border: 1px solid white;
600 +
  padding: 0.4rem 0.75rem;
601 +
  font-size: 14px;
602 +
  width: 100%;
603 +
  cursor: pointer;
604 +
}
605 +
606 +
input[type="file"]::file-selector-button {
607 +
  background: #121113;
608 +
  color: #ffffff;
609 +
  border: 1px solid #555;
610 +
  padding: 0.25rem 0.5rem;
611 +
  cursor: pointer;
612 +
  font-family: "Commit Mono", monospace;
613 +
  font-size: 12px;
614 +
  margin-right: 0.5rem;
615 +
}
apps/posts/templates/admin_base.html +2 −1
10 10
</head>
11 11
<body>
12 12
  <header class="header">
13 -
    <a href="/admin" class="logo">Admin</a>
13 +
    <a href="/admin" class="logo">POSTS</a>
14 14
    <nav class="links">
15 15
      <a href="/admin">posts</a>
16 16
      <a href="/admin/pages">pages</a>
17 +
      <a href="/admin/files">files</a>
17 18
      <a href="/admin/settings">settings</a>
18 19
      <a href="/" target="_blank">view site</a>
19 20
      <a href="/admin/logout">logout</a>
apps/posts/templates/admin_files.html (added) +49 −0
1 +
{% extends "admin_base.html" %}
2 +
{% block title %}Admin — Files{% endblock %}
3 +
{% block content %}
4 +
  <div class="admin-toolbar">
5 +
    <h2>Files</h2>
6 +
  </div>
7 +
  {% if let Some(err) = error %}
8 +
    <p class="error">{{ err }}</p>
9 +
  {% endif %}
10 +
  {% if success %}
11 +
    <p class="success">File uploaded.</p>
12 +
  {% endif %}
13 +
  <form method="POST" action="/admin/files/upload" enctype="multipart/form-data" class="form">
14 +
    <label for="file">upload file (max 10MB)</label>
15 +
    <input type="file" id="file" name="file" required>
16 +
    <button type="submit">upload</button>
17 +
  </form>
18 +
  {% if files.is_empty() %}
19 +
    <p class="empty">no files yet</p>
20 +
  {% else %}
21 +
    <div class="admin-list">
22 +
      {% for file in files %}
23 +
        <div class="admin-list-item">
24 +
          <div class="admin-list-info">
25 +
            <span class="admin-list-title">{{ file.original_name }}</span>
26 +
            <div class="admin-list-meta">
27 +
              <span class="admin-list-date">{{ file.content_type }}</span>
28 +
              <span class="admin-list-date">{{ file.size|filesizeformat }}</span>
29 +
              <span class="admin-list-date">{{ file.created_at }}</span>
30 +
            </div>
31 +
          </div>
32 +
          <div class="admin-list-actions">
33 +
            <button type="button" class="link-button"
34 +
              onclick="navigator.clipboard.writeText('{{ site_url }}/files/{{ file.filename }}');this.textContent='copied!'">
35 +
              copy url
36 +
            </button>
37 +
            <button type="button" class="link-button"
38 +
              onclick="navigator.clipboard.writeText('![{{ file.original_name }}]({{ site_url }}/files/{{ file.filename }})');this.textContent='copied!'">
39 +
              copy md
40 +
            </button>
41 +
            <form method="POST" action="/admin/files/{{ file.short_id }}/delete" class="inline-form">
42 +
              <button type="submit" class="link-button danger" onclick="return confirm('Delete this file?')">delete</button>
43 +
            </form>
44 +
          </div>
45 +
        </div>
46 +
      {% endfor %}
47 +
    </div>
48 +
  {% endif %}
49 +
{% endblock %}
apps/posts/templates/admin_post_form.html +3 −3
26 26
meta_image: {{ p.meta_image.as_deref().unwrap_or_default() }}
27 27
{%- endif %}
28 28
{%- if p.meta_description.is_some() %}
29 -
meta_description: {{ p.meta_description.as_deref().unwrap_or_default() }}
29 +
description: {{ p.meta_description.as_deref().unwrap_or_default() }}
30 30
{%- endif %}</textarea>
31 31
        <details class="available-fields">
32 32
          <summary>available fields</summary>
38 38
            <span>tags: rust, web, tutorial</span>
39 39
            <span>alias: /old/path</span>
40 40
            <span>meta_image: https://example.com/image.jpg</span>
41 -
            <span>meta_description: A short summary of the post</span>
41 +
            <span>description: A short summary of the post</span>
42 42
          </div>
43 43
        </details>
44 44
        <label for="content">content</label>
61 61
            <span>tags: rust, web, tutorial</span>
62 62
            <span>alias: /old/path</span>
63 63
            <span>meta_image: https://example.com/image.jpg</span>
64 -
            <span>meta_description: A short summary of the post</span>
64 +
            <span>description: A short summary of the post</span>
65 65
          </div>
66 66
        </details>
67 67
        <label for="content">content</label>
apps/posts/templates/post.html +3 −0
18 18
{% block content %}
19 19
  <div class="post-header">
20 20
    <h1>{{ post.title }}</h1>
21 +
    {% if post.meta_description.is_some() %}
22 +
      <p class="post-description">{{ post.meta_description.as_deref().unwrap_or_default() }}</p>
23 +
    {% endif %}
21 24
    {% if post.published_date.is_some() %}
22 25
      <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time>
23 26
    {% endif %}