added docker, ci/cd, threads, posts, profiles
This commit is contained in:
parent
8f2cb6eda6
commit
e036304b78
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
/target
|
||||
.git
|
||||
*.db
|
||||
*.db-*
|
||||
.env
|
||||
.env.*
|
||||
.dockerignore
|
||||
93
.gitea/workflows/ci.yml
Normal file
93
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,93 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Build
|
||||
run: cargo build
|
||||
|
||||
- name: Test
|
||||
run: cargo test
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.toasterdragon.com
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.toasterdragon.com/butter/sarmentine
|
||||
tags: |
|
||||
type=sha
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
name: Deploy to Rocky
|
||||
runs-on: ubuntu-latest
|
||||
needs: docker
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.2.0
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
username: butter
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
script: |
|
||||
cd /opt/sarmentine
|
||||
docker compose pull
|
||||
docker compose up -d --force-recreate
|
||||
docker image prune -f
|
||||
50
.sqlx/query-4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98.json
generated
Normal file
50
.sqlx/query-4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98.json
generated
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, body, author_id, thread_id, created_at, updated_at\n FROM posts WHERE thread_id = ? ORDER BY created_at ASC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "author_id",
|
||||
"ordinal": 2,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "thread_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4a1b2d9c1c66741913ff3a9ddf50d260d8bb01fb891509bf95561d72c3376f98"
|
||||
}
|
||||
50
.sqlx/query-6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879.json
generated
Normal file
50
.sqlx/query-6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879.json
generated
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "author_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6549001e2a522da8788d77d915ca372b07872cd763a48f3a035e7e5c5a70c879"
|
||||
}
|
||||
50
.sqlx/query-c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67.json
generated
Normal file
50
.sqlx/query-c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67.json
generated
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads ORDER BY updated_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "author_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 0
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c40c4444269dd206d0dc0618d8a322a76e5f77c663cdbb433d7985fb5528af67"
|
||||
}
|
||||
50
.sqlx/query-ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872.json
generated
Normal file
50
.sqlx/query-ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872.json
generated
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "SELECT id, title, body, author_id, created_at, updated_at\n FROM threads WHERE author_id = ? ORDER BY updated_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"ordinal": 2,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "author_id",
|
||||
"ordinal": 3,
|
||||
"type_info": "Int64"
|
||||
},
|
||||
{
|
||||
"name": "created_at",
|
||||
"ordinal": 4,
|
||||
"type_info": "Datetime"
|
||||
},
|
||||
{
|
||||
"name": "updated_at",
|
||||
"ordinal": 5,
|
||||
"type_info": "Datetime"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ce4cb40fa25111c00d2f4575ce0382de37f2b57bcfccc5f05b4a59ace6a60872"
|
||||
}
|
||||
1475
Cargo.lock
generated
1475
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,3 @@ serde = { version = "1", features = ["derive"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
cargo-generate-rpm = "0.20"
|
||||
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
FROM rust:1.85 AS builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends musl-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
ENV SQLX_OFFLINE=true
|
||||
RUN cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/sarmentine /usr/local/bin/sarmentine
|
||||
COPY --from=builder /app/templates /app/templates
|
||||
COPY --from=builder /app/static /app/static
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV SARMENTINE_BIND_ADDR=0.0.0.0:3000
|
||||
ENV SARMENTINE_STATIC_DIR=/app/static
|
||||
|
||||
CMD ["sarmentine"]
|
||||
8
migrations/0002_threads.sql
Normal file
8
migrations/0002_threads.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
8
migrations/0003_posts.sql
Normal file
8
migrations/0003_posts.sql
Normal file
@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
body TEXT NOT NULL,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@ -1,4 +1,4 @@
|
||||
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
|
||||
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||
|
||||
pub async fn connect(database_url: &str) -> SqlitePool {
|
||||
let pool = SqlitePoolOptions::new()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
@ -33,14 +33,18 @@ pub struct LoginTemplate {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn render<T: Template>(t: T) -> Response {
|
||||
pub fn render<T: Template>(t: T) -> Response {
|
||||
match t.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(_) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response(),
|
||||
Err(_) => (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"render error",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ~~ Form types
|
||||
// ~~ Form types
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegisterForm {
|
||||
@ -55,7 +59,7 @@ pub struct LoginForm {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
// ~~ Handlers
|
||||
// ~~ Handlers
|
||||
|
||||
pub async fn register_page() -> Response {
|
||||
render(RegisterTemplate {
|
||||
|
||||
@ -1 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod thread;
|
||||
|
||||
301
src/handlers/thread.rs
Normal file
301
src/handlers/thread.rs
Normal file
@ -0,0 +1,301 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Form, Path, State},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::SqlitePool;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::CurrentUser;
|
||||
use crate::handlers::auth::{get_current_user, render};
|
||||
use crate::models::{post::Post, thread::Thread, user::User};
|
||||
|
||||
// ~~ Templates
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "threads/list.html")]
|
||||
pub struct ThreadListTemplate {
|
||||
pub title: String,
|
||||
pub current_user: Option<CurrentUser>,
|
||||
pub threads: Vec<ThreadWithAuthor>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "threads/new.html")]
|
||||
pub struct NewThreadTemplate {
|
||||
pub title: String,
|
||||
pub current_user: Option<CurrentUser>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "threads/view.html")]
|
||||
pub struct ThreadViewTemplate {
|
||||
pub title: String,
|
||||
pub current_user: Option<CurrentUser>,
|
||||
pub thread: ThreadWithAuthor,
|
||||
pub posts: Vec<PostWithAuthor>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "profile.html")]
|
||||
pub struct ProfileTemplate {
|
||||
pub title: String,
|
||||
pub current_user: Option<CurrentUser>,
|
||||
pub profile_user: User,
|
||||
pub threads: Vec<ThreadWithAuthor>,
|
||||
}
|
||||
|
||||
// ~~ Data wrappers
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThreadWithAuthor {
|
||||
pub thread: Thread,
|
||||
pub author: User,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PostWithAuthor {
|
||||
pub post: Post,
|
||||
pub author: User,
|
||||
}
|
||||
|
||||
// ~~ Form types
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewThreadForm {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewPostForm {
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
// ~~ Handlers
|
||||
|
||||
pub async fn list_threads(State(pool): State<SqlitePool>, session: Session) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
let current_user = user.map(|u| CurrentUser {
|
||||
username: u.username,
|
||||
});
|
||||
|
||||
let threads = Thread::list_all(&pool).await.unwrap_or_default();
|
||||
|
||||
let mut threads_with_authors = Vec::new();
|
||||
for thread in threads {
|
||||
let author = User::find_by_id(&pool, thread.author_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
if let Some(a) = author {
|
||||
threads_with_authors.push(ThreadWithAuthor { thread, author: a });
|
||||
}
|
||||
}
|
||||
|
||||
render(ThreadListTemplate {
|
||||
title: "threads".into(),
|
||||
current_user,
|
||||
threads: threads_with_authors,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn new_thread_page(session: Session, State(pool): State<SqlitePool>) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
if user.is_none() {
|
||||
return Redirect::to("/auth/login").into_response();
|
||||
}
|
||||
|
||||
let current_user = user.map(|u| CurrentUser {
|
||||
username: u.username,
|
||||
});
|
||||
|
||||
render(NewThreadTemplate {
|
||||
title: "new thread".into(),
|
||||
current_user,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_thread(
|
||||
session: Session,
|
||||
State(pool): State<SqlitePool>,
|
||||
Form(form): Form<NewThreadForm>,
|
||||
) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
let current_user = user.clone().map(|u| CurrentUser {
|
||||
username: u.username,
|
||||
});
|
||||
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => return Redirect::to("/auth/login").into_response(),
|
||||
};
|
||||
|
||||
if form.title.trim().is_empty() || form.body.trim().is_empty() {
|
||||
return render(NewThreadTemplate {
|
||||
title: "new thread".into(),
|
||||
current_user,
|
||||
error: Some("title and body cannot be empty".into()),
|
||||
});
|
||||
}
|
||||
|
||||
let thread_id = Thread::insert(&pool, &form.title, &form.body, user.id)
|
||||
.await
|
||||
.expect("failed to create thread");
|
||||
|
||||
Redirect::to(&format!("/threads/{}", thread_id)).into_response()
|
||||
}
|
||||
|
||||
pub async fn view_thread(
|
||||
Path(id): Path<i64>,
|
||||
session: Session,
|
||||
State(pool): State<SqlitePool>,
|
||||
) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
let current_user = user.map(|u| CurrentUser {
|
||||
username: u.username,
|
||||
});
|
||||
|
||||
let thread = match Thread::find_by_id(&pool, id).await.ok().flatten() {
|
||||
Some(t) => t,
|
||||
None => return (axum::http::StatusCode::NOT_FOUND, "thread not found").into_response(),
|
||||
};
|
||||
|
||||
let author = match User::find_by_id(&pool, thread.author_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
Some(a) => a,
|
||||
None => {
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"author not found",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let posts = Post::by_thread(&pool, id).await.unwrap_or_default();
|
||||
|
||||
let mut posts_with_authors = Vec::new();
|
||||
for post in posts {
|
||||
let author = User::find_by_id(&pool, post.author_id).await.ok().flatten();
|
||||
if let Some(a) = author {
|
||||
posts_with_authors.push(PostWithAuthor { post, author: a });
|
||||
}
|
||||
}
|
||||
|
||||
render(ThreadViewTemplate {
|
||||
title: thread.title.clone(),
|
||||
current_user,
|
||||
thread: ThreadWithAuthor { thread, author },
|
||||
posts: posts_with_authors,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_post(
|
||||
Path(id): Path<i64>,
|
||||
session: Session,
|
||||
State(pool): State<SqlitePool>,
|
||||
Form(form): Form<NewPostForm>,
|
||||
) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
let current_user = user.clone().map(|u| CurrentUser {
|
||||
username: u.username,
|
||||
});
|
||||
|
||||
let user = match user {
|
||||
Some(u) => u,
|
||||
None => return Redirect::to("/auth/login").into_response(),
|
||||
};
|
||||
|
||||
let thread = match Thread::find_by_id(&pool, id).await.ok().flatten() {
|
||||
Some(t) => t,
|
||||
None => return Redirect::to("/threads").into_response(),
|
||||
};
|
||||
|
||||
if form.body.trim().is_empty() {
|
||||
let author = User::find_by_id(&pool, thread.author_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| User {
|
||||
id: 0,
|
||||
username: "unknown".into(),
|
||||
email: "".into(),
|
||||
password: "".into(),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
role: "member".into(),
|
||||
created_at: chrono::NaiveDateTime::MIN,
|
||||
});
|
||||
|
||||
let posts = Post::by_thread(&pool, id).await.unwrap_or_default();
|
||||
let mut posts_with_authors = Vec::new();
|
||||
for post in posts {
|
||||
let author = User::find_by_id(&pool, post.author_id).await.ok().flatten();
|
||||
if let Some(a) = author {
|
||||
posts_with_authors.push(PostWithAuthor { post, author: a });
|
||||
}
|
||||
}
|
||||
|
||||
return render(ThreadViewTemplate {
|
||||
title: thread.title.clone(),
|
||||
current_user,
|
||||
thread: ThreadWithAuthor { thread, author },
|
||||
posts: posts_with_authors,
|
||||
error: Some("reply cannot be empty".into()),
|
||||
});
|
||||
}
|
||||
|
||||
Post::insert(&pool, &form.body, user.id, thread.id)
|
||||
.await
|
||||
.expect("failed to create post");
|
||||
|
||||
Redirect::to(&format!("/threads/{}", thread.id)).into_response()
|
||||
}
|
||||
|
||||
pub async fn view_profile(
|
||||
Path(username): Path<String>,
|
||||
session: Session,
|
||||
State(pool): State<SqlitePool>,
|
||||
) -> Response {
|
||||
let user: Option<User> = get_current_user(&session, &pool).await;
|
||||
let current_user = user.map(|u| CurrentUser {
|
||||
username: u.username,
|
||||
});
|
||||
|
||||
let profile_user = match User::find_by_username(&pool, &username)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
Some(u) => u,
|
||||
None => return (axum::http::StatusCode::NOT_FOUND, "user not found").into_response(),
|
||||
};
|
||||
|
||||
let threads = Thread::by_author(&pool, profile_user.id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut threads_with_authors = Vec::new();
|
||||
for thread in threads {
|
||||
threads_with_authors.push(ThreadWithAuthor {
|
||||
thread,
|
||||
author: profile_user.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
render(ProfileTemplate {
|
||||
title: format!("profile: {}", profile_user.username),
|
||||
current_user,
|
||||
profile_user,
|
||||
threads: threads_with_authors,
|
||||
})
|
||||
}
|
||||
49
src/main.rs
49
src/main.rs
@ -4,19 +4,22 @@ mod models;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_sessions::{cookie::SameSite, SessionManagerLayer};
|
||||
use tower_sessions::{SessionManagerLayer, cookie::SameSite};
|
||||
use tower_sessions_sqlx_store::SqliteStore;
|
||||
|
||||
use handlers::auth::{
|
||||
get_current_user, login_page, login_submit, logout, register_page, register_submit,
|
||||
};
|
||||
use handlers::thread::{
|
||||
create_post, create_thread, list_threads, new_thread_page, view_profile, view_thread,
|
||||
};
|
||||
|
||||
pub struct CurrentUser {
|
||||
pub username: String,
|
||||
@ -29,12 +32,11 @@ struct IndexTemplate {
|
||||
current_user: Option<CurrentUser>,
|
||||
}
|
||||
|
||||
async fn index(
|
||||
State(pool): State<SqlitePool>,
|
||||
session: tower_sessions::Session,
|
||||
) -> Response {
|
||||
async fn index(State(pool): State<SqlitePool>, session: tower_sessions::Session) -> Response {
|
||||
let user: Option<models::user::User> = get_current_user(&session, &pool).await;
|
||||
let current_user = user.map(|u| CurrentUser { username: u.username });
|
||||
let current_user = user.map(|u| CurrentUser {
|
||||
username: u.username,
|
||||
});
|
||||
|
||||
let tmpl = IndexTemplate {
|
||||
title: "home".into(),
|
||||
@ -43,7 +45,11 @@ async fn index(
|
||||
|
||||
match tmpl.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(_) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response(),
|
||||
Err(_) => (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"render error",
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,7 +63,10 @@ async fn main() {
|
||||
let pool: SqlitePool = db::connect(&database_url).await;
|
||||
|
||||
let session_store = SqliteStore::new(pool.clone());
|
||||
session_store.migrate().await.expect("session store migration failed");
|
||||
session_store
|
||||
.migrate()
|
||||
.await
|
||||
.expect("session store migration failed");
|
||||
|
||||
let session_secret = std::env::var("SARMENTINE_SESSION_SECRET")
|
||||
.unwrap_or_else(|_| "change-me-in-production-use-a-long-random-string!!".into());
|
||||
@ -65,15 +74,21 @@ async fn main() {
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false)
|
||||
.with_same_site(SameSite::Lax)
|
||||
.with_signed(tower_sessions::cookie::Key::from(
|
||||
session_secret.as_bytes(),
|
||||
));
|
||||
.with_signed(tower_sessions::cookie::Key::from(session_secret.as_bytes()));
|
||||
|
||||
let static_dir = std::env::var("SARMENTINE_STATIC_DIR")
|
||||
.unwrap_or_else(|_| "static".into());
|
||||
let static_dir = std::env::var("SARMENTINE_STATIC_DIR").unwrap_or_else(|_| "static".into());
|
||||
|
||||
let bind_addr =
|
||||
std::env::var("SARMENTINE_BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3000".into());
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/threads", get(list_threads))
|
||||
.route("/threads/new", get(new_thread_page))
|
||||
.route("/threads", post(create_thread))
|
||||
.route("/threads/:id", get(view_thread))
|
||||
.route("/threads/:id/posts", post(create_post))
|
||||
.route("/profile/:username", get(view_profile))
|
||||
.route("/auth/register", get(register_page))
|
||||
.route("/auth/register", post(register_submit))
|
||||
.route("/auth/login", get(login_page))
|
||||
@ -83,10 +98,8 @@ async fn main() {
|
||||
.layer(session_layer)
|
||||
.with_state(pool);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
|
||||
.await
|
||||
.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind(&bind_addr).await.unwrap();
|
||||
|
||||
println!("listening on http://127.0.0.1:3000");
|
||||
println!("listening on http://{}", bind_addr);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
@ -1 +1,3 @@
|
||||
pub mod post;
|
||||
pub mod thread;
|
||||
pub mod user;
|
||||
|
||||
42
src/models/post.rs
Normal file
42
src/models/post.rs
Normal file
@ -0,0 +1,42 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
pub id: i64,
|
||||
pub body: String,
|
||||
pub author_id: i64,
|
||||
pub thread_id: i64,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
pub async fn by_thread(pool: &SqlitePool, thread_id: i64) -> sqlx::Result<Vec<Post>> {
|
||||
sqlx::query_as!(
|
||||
Post,
|
||||
r#"SELECT id, body, author_id, thread_id, created_at, updated_at
|
||||
FROM posts WHERE thread_id = ? ORDER BY created_at ASC"#,
|
||||
thread_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert(
|
||||
pool: &SqlitePool,
|
||||
body: &str,
|
||||
author_id: i64,
|
||||
thread_id: i64,
|
||||
) -> sqlx::Result<i64> {
|
||||
let result = sqlx::query("INSERT INTO posts (body, author_id, thread_id) VALUES (?, ?, ?)")
|
||||
.bind(body)
|
||||
.bind(author_id)
|
||||
.bind(thread_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
}
|
||||
63
src/models/thread.rs
Normal file
63
src/models/thread.rs
Normal file
@ -0,0 +1,63 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub author_id: i64,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
impl Thread {
|
||||
pub async fn list_all(pool: &SqlitePool) -> sqlx::Result<Vec<Thread>> {
|
||||
sqlx::query_as!(
|
||||
Thread,
|
||||
r#"SELECT id, title, body, author_id, created_at, updated_at
|
||||
FROM threads ORDER BY updated_at DESC"#
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_by_id(pool: &SqlitePool, id: i64) -> sqlx::Result<Option<Thread>> {
|
||||
sqlx::query_as!(
|
||||
Thread,
|
||||
r#"SELECT id, title, body, author_id, created_at, updated_at
|
||||
FROM threads WHERE id = ?"#,
|
||||
id
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn by_author(pool: &SqlitePool, author_id: i64) -> sqlx::Result<Vec<Thread>> {
|
||||
sqlx::query_as!(
|
||||
Thread,
|
||||
r#"SELECT id, title, body, author_id, created_at, updated_at
|
||||
FROM threads WHERE author_id = ? ORDER BY updated_at DESC"#,
|
||||
author_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert(
|
||||
pool: &SqlitePool,
|
||||
title: &str,
|
||||
body: &str,
|
||||
author_id: i64,
|
||||
) -> sqlx::Result<i64> {
|
||||
let result = sqlx::query("INSERT INTO threads (title, body, author_id) VALUES (?, ?, ?)")
|
||||
.bind(title)
|
||||
.bind(body)
|
||||
.bind(author_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
}
|
||||
@ -55,19 +55,13 @@ impl User {
|
||||
email: &str,
|
||||
password_hash: &str,
|
||||
) -> sqlx::Result<i64> {
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO users (username, email, password) VALUES (?, ?, ?)"
|
||||
)
|
||||
.bind(username)
|
||||
.bind(email)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let result = sqlx::query("INSERT INTO users (username, email, password) VALUES (?, ?, ?)")
|
||||
.bind(username)
|
||||
.bind(email)
|
||||
.bind(password_hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == "admin"
|
||||
}
|
||||
}
|
||||
|
||||
27
templates/profile.html
Normal file
27
templates/profile.html
Normal file
@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px; text-transform:uppercase;">profile: {{ profile_user.username }}</div>
|
||||
|
||||
<div class="post">
|
||||
<div class="post-title">{{ profile_user.username }}</div>
|
||||
{% if let Some(bio) = profile_user.bio %}
|
||||
<div class="post-body">{{ bio }}</div>
|
||||
{% endif %}
|
||||
<div class="post-body" style="color:#888; font-size:11px;">joined {{ profile_user.created_at }} · role: {{ profile_user.role }}</div>
|
||||
</div>
|
||||
|
||||
{% if !threads.is_empty() %}
|
||||
<div style="font-family:'Silkscreen',monospace; font-size:11px; color:#888; margin:16px 0 8px; text-transform:uppercase;">threads by {{ profile_user.username }}</div>
|
||||
{% for twa in threads %}
|
||||
<div class="post">
|
||||
<div class="post-title"><a href="/threads/{{ twa.thread.id }}">{{ twa.thread.title }}</a></div>
|
||||
<div class="post-body" style="color:#888; font-size:11px;">{{ twa.thread.created_at }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="post">
|
||||
<div class="post-body" style="color:#888;">no threads yet</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
23
templates/threads/list.html
Normal file
23
templates/threads/list.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px; text-transform:uppercase;">threads</div>
|
||||
|
||||
{% if let Some(user) = current_user %}
|
||||
<a href="/threads/new" class="nav-link" style="margin-bottom:16px; display:inline-block;">+ new thread</a>
|
||||
{% endif %}
|
||||
|
||||
{% if threads.is_empty() %}
|
||||
<div class="post">
|
||||
<div class="post-title">no threads yet</div>
|
||||
<div class="post-body">be the first to start a thread!</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for twa in threads %}
|
||||
<div class="post">
|
||||
<div class="post-title"><a href="/threads/{{ twa.thread.id }}">{{ twa.thread.title }}</a></div>
|
||||
<div class="post-body" style="color:#888; font-size:11px;">by <a href="/profile/{{ twa.author.username }}">{{ twa.author.username }}</a> · {{ twa.thread.created_at }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
23
templates/threads/new.html
Normal file
23
templates/threads/new.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px; text-transform:uppercase;">new thread</div>
|
||||
|
||||
{% if let Some(error) = error %}
|
||||
<div class="post" style="border-color:#ff6b6b;">
|
||||
<div class="post-body" style="color:#ff6b6b;">{{ error }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/threads">
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:block; font-size:11px; color:#888; margin-bottom:4px;">TITLE</label>
|
||||
<input type="text" name="title" required maxlength="200" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px;">
|
||||
</div>
|
||||
<div style="margin-bottom:8px;">
|
||||
<label style="display:block; font-size:11px; color:#888; margin-bottom:4px;">BODY</label>
|
||||
<textarea name="body" required rows="10" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px; resize:vertical;"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn" style="margin-top:8px;">create thread</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
44
templates/threads/view.html
Normal file
44
templates/threads/view.html
Normal file
@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div style="font-family:'Silkscreen',monospace; font-size:14px; color:#d4879c; margin-bottom:12px;">
|
||||
<a href="/threads" style="color:#888;">threads</a> / {{ thread.thread.title }}
|
||||
</div>
|
||||
|
||||
<div class="post">
|
||||
<div class="post-title">{{ thread.thread.title }}</div>
|
||||
<div class="post-body" style="color:#888; font-size:11px; margin-bottom:8px;">by <a href="/profile/{{ thread.author.username }}">{{ thread.author.username }}</a> · {{ thread.thread.created_at }}</div>
|
||||
<div class="post-body" style="white-space:pre-wrap;">{{ thread.thread.body }}</div>
|
||||
</div>
|
||||
|
||||
{% if let Some(error) = error %}
|
||||
<div class="post" style="border-color:#ff6b6b;">
|
||||
<div class="post-body" style="color:#ff6b6b;">{{ error }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if !posts.is_empty() %}
|
||||
<div style="font-family:'Silkscreen',monospace; font-size:11px; color:#888; margin:16px 0 8px; text-transform:uppercase;">replies</div>
|
||||
{% for pwa in posts %}
|
||||
<div class="post">
|
||||
<div class="post-body" style="color:#888; font-size:11px; margin-bottom:8px;">by <a href="/profile/{{ pwa.author.username }}">{{ pwa.author.username }}</a> · {{ pwa.post.created_at }}</div>
|
||||
<div class="post-body" style="white-space:pre-wrap;">{{ pwa.post.body }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(user) = current_user %}
|
||||
<div style="margin-top:16px;">
|
||||
<form method="post" action="/threads/{{ thread.thread.id }}/posts">
|
||||
<div style="margin-bottom:8px;">
|
||||
<textarea name="body" required rows="4" style="width:100%; padding:6px; background:#1a1a2e; border:1px solid #333; color:#e0e0e0; font-family:'VT323',monospace; font-size:14px; resize:vertical;"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">reply</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="margin-top:16px; color:#888; font-size:11px;">
|
||||
<a href="/auth/login">login</a> to reply
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user