diff --git a/.gitignore b/.gitignore index 6562de5..fc74b0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target +/uploads /config.local.toml diff --git a/Cargo.toml b/Cargo.toml index ccc8afe..e8d9f22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] tokio = { version = "1.41.0", features = ["full"] } -axum = { version = "0.7.7", features = [ "macros", "multipart", "json" ] } +axum = { version = "0.7.7", features = [ "macros", "multipart", "json", "form" ] } axum-extra = { version = "0.9.4", features = [ "cookie" ] } tera = "1.20.0" lazy_static = "1.5.0" diff --git a/config.default.toml b/config.default.toml index 741705b..f6ff10d 100644 --- a/config.default.toml +++ b/config.default.toml @@ -1,5 +1,6 @@ url = "http://localhost:4000" port = 4000 +uploads_dir = "uploads" [database] url = "postgres://comfycamp:simple-password@localhost/comfycamp" diff --git a/src/internal/db/migrations/0003_services.sql b/src/internal/db/migrations/0003_services.sql new file mode 100644 index 0000000..b515cf6 --- /dev/null +++ b/src/internal/db/migrations/0003_services.sql @@ -0,0 +1,12 @@ +CREATE TABLE services( + slug VARCHAR(20) NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + is_url_active BOOLEAN NOT NULL DEFAULT TRUE, + tor_url TEXT, + cover_id UUID + REFERENCES images(id) + ON DELETE SET NULL, + short_description TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); diff --git a/src/internal/db/mod.rs b/src/internal/db/mod.rs index ecf4863..53ec4a8 100644 --- a/src/internal/db/mod.rs +++ b/src/internal/db/mod.rs @@ -1,4 +1,5 @@ pub mod images; +pub mod services; pub mod users; pub async fn apply_migrations(pool: &sqlx::PgPool) -> Result<(), sqlx::migrate::MigrateError> { diff --git a/src/internal/db/services.rs b/src/internal/db/services.rs new file mode 100644 index 0000000..36727ea --- /dev/null +++ b/src/internal/db/services.rs @@ -0,0 +1,116 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct NewService { + pub slug: String, + pub name: String, + pub url: String, + pub is_url_active: bool, + pub tor_url: Option, + pub cover_id: Option, + pub short_description: String, +} + +#[derive(FromRow, Serialize)] +pub struct Service { + pub slug: String, + pub name: String, + pub url: String, + pub is_url_active: bool, + pub tor_url: Option, + pub cover_id: Option, + pub short_description: String, +} + +pub async fn create_service(pool: &PgPool, svc: &NewService) -> Result<()> { + sqlx::query( + " + INSERT INTO services(slug, name, url, is_url_active, tor_url, cover_id, short_description) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ", + ) + .bind(&svc.slug) + .bind(&svc.name) + .bind(&svc.url) + .bind(svc.is_url_active) + .bind(&svc.tor_url) + .bind(svc.cover_id) + .bind(&svc.short_description) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn get_services(pool: &PgPool) -> Result> { + let services = sqlx::query_as::<_, Service>( + " + SELECT * + FROM services + ORDER BY sort_order + ", + ) + .fetch_all(pool) + .await?; + + Ok(services) +} + +pub async fn get_service(pool: &PgPool, slug: &str) -> Result { + let service = sqlx::query_as::<_, Service>( + " + SELECT * + FROM services + WHERE slug = $1 + ", + ) + .bind(slug) + .fetch_one(pool) + .await?; + + Ok(service) +} + +pub async fn update_service(pool: &PgPool, svc: &NewService) -> Result<()> { + sqlx::query( + " + UPDATE services SET + name = $1, + url = $2, + is_url_active = $3, + tor_url = $4, + cover_id = $5, + short_description = $6 + WHERE slug = $7 + ", + ) + .bind(&svc.name) + .bind(&svc.url) + .bind(svc.is_url_active) + .bind(&svc.tor_url) + .bind(svc.cover_id) + .bind(&svc.short_description) + .bind(&svc.slug) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete_service(pool: &PgPool, slug: &str) -> Result<()> { + sqlx::query( + " + DELETE FROM services + WHERE slug = $1 + RETURNING 1 + ", + ) + .bind(slug) + .fetch_one(pool) + .await?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 43a962f..6bc4879 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use axum::{ + extract::DefaultBodyLimit, routing::{get, post}, Router, }; @@ -45,8 +46,23 @@ async fn main() { "/api/images/:image_id", get(controllers::get_image).delete(controllers::delete_image), ) + .route("/admin", get(controllers::admin_page)) + .route("/admin/services", get(controllers::list_services)) + .route( + "/admin/services/new", + get(controllers::new_service).post(controllers::create_service), + ) + .route( + "/admin/services/:slug", + get(controllers::edit_service).post(controllers::update_service), + ) + .route( + "/admin/services/:slug/delete", + post(controllers::delete_service), + ) .nest_service("/assets", ServeDir::new("src/web/assets")) - .with_state(shared_state.clone()); + .with_state(shared_state.clone()) + .layer(DefaultBodyLimit::max(1024 * 1024 * 100)); let addr = format!("0.0.0.0:{}", CONFIG.port); let listener = tokio::net::TcpListener::bind(&addr) diff --git a/src/web/assets/css/admin.css b/src/web/assets/css/admin.css new file mode 100644 index 0000000..4c42a4c --- /dev/null +++ b/src/web/assets/css/admin.css @@ -0,0 +1,52 @@ +input, +textarea { + display: block; + font-family: inherit; + font-size: inherit; + width: 100%; +} + +label { + font-family: inherit; + font-size: inherit; +} + +input[type=checkbox] { + display: inline; + width: auto; + cursor: pointer; + accent-color: var(--accent); + width: 16px; + height: 16px; + outline: none; +} + +input, +textarea { + background-color: var(--input-bg); + border: 2px solid var(--input-border); + border-radius: 4px; + padding: 4px 6px; + color: white; + outline: 1px solid transparent; + margin-top: 4px; +} + +input:focus, +textarea:focus { + outline-color: var(--input-border); +} + +.form-row { + margin-top: 8px; +} + +.submit-button { + width: 100%; + margin-top: 8px; + background-color: var(--input-bg); + border: 2px solid var(--input-border); + border-radius: 4px; + padding: 4px 6px; + color: white; +} diff --git a/src/web/assets/css/home.css b/src/web/assets/css/home.css index a21693d..d4c2768 100644 --- a/src/web/assets/css/home.css +++ b/src/web/assets/css/home.css @@ -16,6 +16,19 @@ margin-bottom: 0; } +.service .image { + width: 100%; + height: 260px; + position: relative; + overflow: hidden; + margin-bottom: 8px; +} + +.service .image img { + object-fit: cover; + object-position: 50% 50%; +} + .service .tag { font-size: 12px; background-color: #333; @@ -23,13 +36,22 @@ border-radius: 6px; } +.service .links { + display: flex; + gap: 8px; +} + .service .link { color: var(--accent); display: flex; - gap: 8px; + gap: 4px; align-items: center; } +.service .link img { + opacity: 0.4; +} + .service svg { width: 16px; height: 16px; diff --git a/src/web/assets/js/admin.js b/src/web/assets/js/admin.js new file mode 100644 index 0000000..2ec506e --- /dev/null +++ b/src/web/assets/js/admin.js @@ -0,0 +1,48 @@ +"use strict"; + +/** + * Upload the first file and update service cover id field. + */ +async function onFileSelection(e) { + let formData = new FormData(); + formData.append("image", event.target.files[0]); + + const response = await fetch("/api/images", { + method: "POST", + body: formData, + }); + const data = await response.json(); + + const coverIdInput = document.getElementById("service-cover-id"); + coverIdInput.value = data.ids[0]; + coverIdInput.type = "text"; + coverIdInput.disabled = false; + + const button = document.getElementById("clear-cover-button"); + button.style.display = "block"; +} + +function clearCover() { + const coverIdInput = document.getElementById("service-cover-id"); + coverIdInput.value = ""; + coverIdInput.type = "hidden"; + coverIdInput.disabled = true; + + const button = document.getElementById("clear-cover-button"); + button.style.display = "none"; +} + +async function submitForm(e) { + e.preventDefault(); + + if (e.target.tor_url.value === "") { + e.target.tor_url.disabled = true; + } + + if (!e.target.is_url_active.checked) { + e.target.is_url_active.value = "false"; + e.target.is_url_active.checked = true; + } + + e.target.submit(); +} diff --git a/src/web/controllers/admin/index.rs b/src/web/controllers/admin/index.rs new file mode 100644 index 0000000..406bb69 --- /dev/null +++ b/src/web/controllers/admin/index.rs @@ -0,0 +1,18 @@ +use axum::{extract::State, response::IntoResponse}; +use axum_extra::extract::cookie::CookieJar; +use std::sync::Arc; + +use crate::web::controllers::{get_admin_user, AppState, ControllerError}; +use crate::web::templates; + +pub async fn admin_page( + State(state): State>, + cookies: CookieJar, +) -> Result { + let user = get_admin_user(&state.db, cookies).await?; + let mut ctx = tera::Context::new(); + ctx.insert("user", &user); + + let resp = templates::render("admin/index.html", &ctx); + Ok(resp) +} diff --git a/src/web/controllers/admin/mod.rs b/src/web/controllers/admin/mod.rs new file mode 100644 index 0000000..aeadb8b --- /dev/null +++ b/src/web/controllers/admin/mod.rs @@ -0,0 +1,7 @@ +mod index; +mod services; + +pub use index::admin_page; +pub use services::{ + create_service, delete_service, edit_service, list_services, new_service, update_service, +}; diff --git a/src/web/controllers/admin/services.rs b/src/web/controllers/admin/services.rs new file mode 100644 index 0000000..2e2f1d4 --- /dev/null +++ b/src/web/controllers/admin/services.rs @@ -0,0 +1,90 @@ +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Redirect}, + Form, +}; +use axum_extra::extract::cookie::CookieJar; +use std::sync::Arc; + +use crate::internal::db::services; +use crate::web::controllers::{get_admin_user, AppState, ControllerError}; +use crate::web::templates; + +pub async fn list_services( + State(state): State>, + cookies: CookieJar, +) -> Result { + let user = get_admin_user(&state.db, cookies).await?; + let mut ctx = tera::Context::new(); + ctx.insert("user", &user); + + let service_list = services::get_services(&state.db).await?; + ctx.insert("services", &service_list); + + let resp = templates::render("admin/services/list.html", &ctx); + Ok(resp) +} + +pub async fn new_service( + State(state): State>, + cookies: CookieJar, +) -> Result { + let user = get_admin_user(&state.db, cookies).await?; + let mut ctx = tera::Context::new(); + ctx.insert("user", &user); + + let resp = templates::render("admin/services/form.html", &ctx); + Ok(resp) +} + +pub async fn create_service( + State(state): State>, + cookies: CookieJar, + Form(svc): Form, +) -> Result { + get_admin_user(&state.db, cookies).await?; + + services::create_service(&state.db, &svc).await?; + + Ok(Redirect::to("/admin/services")) +} + +pub async fn edit_service( + State(state): State>, + cookies: CookieJar, + Path(slug): Path, +) -> Result { + let user = get_admin_user(&state.db, cookies).await?; + let mut ctx = tera::Context::new(); + ctx.insert("user", &user); + + let svc = services::get_service(&state.db, &slug).await?; + ctx.insert("svc", &svc); + + let resp = templates::render("admin/services/form.html", &ctx); + Ok(resp) +} + +pub async fn update_service( + State(state): State>, + cookies: CookieJar, + Form(svc): Form, +) -> Result { + get_admin_user(&state.db, cookies).await?; + + services::update_service(&state.db, &svc).await?; + + Ok(Redirect::to(&format!("/admin/services/{}", svc.slug))) +} + +pub async fn delete_service( + State(state): State>, + cookies: CookieJar, + Path(slug): Path, +) -> Result { + get_admin_user(&state.db, cookies).await?; + + services::delete_service(&state.db, &slug).await?; + + Ok(Redirect::to("/admin/services")) +} diff --git a/src/web/controllers/home.rs b/src/web/controllers/home.rs index b891fa4..6d37127 100644 --- a/src/web/controllers/home.rs +++ b/src/web/controllers/home.rs @@ -2,13 +2,18 @@ use axum::{extract::State, response::Html}; use axum_extra::extract::CookieJar; use std::sync::Arc; +use crate::internal::db::services; use crate::web::templates; pub async fn home( cookies: CookieJar, State(state): State>, ) -> Result, super::ControllerError> { - let ctx = super::get_context(&state.db, cookies).await?; + let mut ctx = super::get_context(&state.db, cookies).await?; + + let service_list = services::get_services(&state.db).await?; + ctx.insert("services", &service_list); + let resp = templates::render("home.html", &ctx); Ok(resp) } diff --git a/src/web/controllers/mod.rs b/src/web/controllers/mod.rs index b1cd69f..06e8472 100644 --- a/src/web/controllers/mod.rs +++ b/src/web/controllers/mod.rs @@ -1,3 +1,4 @@ +mod admin; mod auth; mod ctx; mod error; @@ -5,6 +6,7 @@ mod home; mod images; mod openid; +pub use admin::*; pub use auth::*; pub use home::*; pub use images::{delete_image, get_image, upload_image}; diff --git a/src/web/templates.rs b/src/web/templates.rs index 74d010e..5718b12 100644 --- a/src/web/templates.rs +++ b/src/web/templates.rs @@ -1,6 +1,8 @@ use axum::response::Html; use lazy_static::lazy_static; -use tera::Tera; +use std::collections::HashMap; +use tera::{Error, Tera, Value}; +use url::Url; lazy_static! { pub static ref TEMPLATES: Tera = { @@ -12,6 +14,25 @@ lazy_static! { } }; tera.autoescape_on(vec![".html"]); + tera.register_filter( + "domain", + |v: &Value, _h: &HashMap| -> Result { + let s = match v { + Value::String(s) => s, + _ => &v.to_string(), + }; + + let url = match Url::parse(s) { + Ok(url) => url, + Err(err) => return Err(Error::msg(err)), + }; + + match url.domain() { + Some(domain) => Ok(Value::String(domain.to_string())), + None => Err(Error::msg("domain is undefined")), + } + }, + ); tera }; } diff --git a/src/web/templates/admin/index.html b/src/web/templates/admin/index.html new file mode 100644 index 0000000..ffb1d16 --- /dev/null +++ b/src/web/templates/admin/index.html @@ -0,0 +1,7 @@ +{% extends "layout.html" %} + +{% block content %} +

Админка

+ + Сервисы +{% endblock %} diff --git a/src/web/templates/admin/services/form.html b/src/web/templates/admin/services/form.html new file mode 100644 index 0000000..8446ea2 --- /dev/null +++ b/src/web/templates/admin/services/form.html @@ -0,0 +1,114 @@ +{% extends "layout.html" %} + +{% block head %} + + +{% endblock %} + +{% block content %} +

+ {% if svc %} + {{ svc.name }} + {% else %} + Новый сервис + {% endif %} +

+ + {% if svc %} +
+ +
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+ +
+ + +
+ + +
+{% endblock %} diff --git a/src/web/templates/admin/services/list.html b/src/web/templates/admin/services/list.html new file mode 100644 index 0000000..adb8cdf --- /dev/null +++ b/src/web/templates/admin/services/list.html @@ -0,0 +1,15 @@ +{% extends "layout.html" %} + +{% block content %} +

Сервисы

+ + Новый сервис + + +{% endblock %} diff --git a/src/web/templates/home.html b/src/web/templates/home.html index 762e2eb..e3ba2c3 100644 --- a/src/web/templates/home.html +++ b/src/web/templates/home.html @@ -23,75 +23,34 @@

- Иван, администратор comfycamp.space

-
-
-

Mastodon

-
- - m.comfycamp.space - -

- Микроблоги с поддержкой fediverse. -

-
+ {% for svc in services %} +
+
+

{{ svc.name }}

+
+ {% if svc.cover_id %} +
+ +
+ {% endif %} + - -
-
-

Matrix

-
- - matrix.comfycamp.space - -

- Современный протокол для общения. -

-
- -
-
-

Forgejo

-
- - git.comfycamp.space - -

- Хостинг для git-проектов. -

-
- -
-
-

Nextcloud

-
- - nc.comfycamp.space - -

- Облако, календарь, задачи. -

-
- -
-
-

XMPP

-
- - xmpp.comfycamp.space - -

- Проверенный временем протокол для общения. -

-
+ {% endfor %} {% endblock %} diff --git a/src/web/templates/layout.html b/src/web/templates/layout.html index fb0e945..2468f37 100644 --- a/src/web/templates/layout.html +++ b/src/web/templates/layout.html @@ -16,6 +16,9 @@