added docker, ci/cd, threads, posts, profiles
Some checks failed
CI / Check (push) Failing after 1m16s
CI / Build & Push Docker Image (push) Has been skipped
CI / Deploy to Rocky (push) Has been skipped

This commit is contained in:
Butter 2026-05-04 13:26:33 -04:00
parent 8f2cb6eda6
commit e036304b78
24 changed files with 924 additions and 1506 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
/target
.git
*.db
*.db-*
.env
.env.*
.dockerignore

93
.gitea/workflows/ci.yml Normal file
View 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

View 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"
}

View 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"
}

View 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"
}

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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"]

View 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
);

View 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
);

View File

@ -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()

View File

@ -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 {

View File

@ -1 +1,2 @@
pub mod auth;
pub mod thread;

301
src/handlers/thread.rs Normal file
View 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,
})
}

View File

@ -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();
}

View File

@ -1 +1,3 @@
pub mod post;
pub mod thread;
pub mod user;

42
src/models/post.rs Normal file
View 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
View 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())
}
}

View File

@ -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
View 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 }} &middot; 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 %}

View 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> &middot; {{ twa.thread.created_at }}</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}

View 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 %}

View 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> &middot; {{ 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> &middot; {{ 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 %}