Admin panel for services

Store list of services in the database.
Add service covers.

Still needs some work.
Authorization probably should be implemented in middleware.
This commit is contained in:
Ivan R. 2024-11-06 16:23:29 +05:00
parent 2e25aee255
commit 4f8f045d52
Signed by: lumin
GPG key ID: 9B2CA5D12844D4D0
21 changed files with 584 additions and 74 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
/target
/uploads
/config.local.toml

View file

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

View file

@ -1,5 +1,6 @@
url = "http://localhost:4000"
port = 4000
uploads_dir = "uploads"
[database]
url = "postgres://comfycamp:simple-password@localhost/comfycamp"

View file

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

View file

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

116
src/internal/db/services.rs Normal file
View file

@ -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<String>,
pub cover_id: Option<Uuid>,
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<String>,
pub cover_id: Option<Uuid>,
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<Vec<Service>> {
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<Service> {
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(())
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Arc<AppState>>,
cookies: CookieJar,
) -> Result<impl IntoResponse, ControllerError> {
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)
}

View file

@ -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,
};

View file

@ -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<Arc<AppState>>,
cookies: CookieJar,
) -> Result<impl IntoResponse, ControllerError> {
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<Arc<AppState>>,
cookies: CookieJar,
) -> Result<impl IntoResponse, ControllerError> {
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<Arc<AppState>>,
cookies: CookieJar,
Form(svc): Form<services::NewService>,
) -> Result<impl IntoResponse, ControllerError> {
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<Arc<AppState>>,
cookies: CookieJar,
Path(slug): Path<String>,
) -> Result<impl IntoResponse, ControllerError> {
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<Arc<AppState>>,
cookies: CookieJar,
Form(svc): Form<services::NewService>,
) -> Result<impl IntoResponse, ControllerError> {
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<Arc<AppState>>,
cookies: CookieJar,
Path(slug): Path<String>,
) -> Result<impl IntoResponse, ControllerError> {
get_admin_user(&state.db, cookies).await?;
services::delete_service(&state.db, &slug).await?;
Ok(Redirect::to("/admin/services"))
}

View file

@ -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<Arc<super::AppState>>,
) -> Result<Html<String>, 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)
}

View file

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

View file

@ -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<String, Value>| -> Result<Value, Error> {
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
};
}

View file

@ -0,0 +1,7 @@
{% extends "layout.html" %}
{% block content %}
<h1>Админка</h1>
<a href="/admin/services">Сервисы</a>
{% endblock %}

View file

@ -0,0 +1,114 @@
{% extends "layout.html" %}
{% block head %}
<link rel="stylesheet" href="/assets/css/admin.css" />
<script src="/assets/js/admin.js"></script>
{% endblock %}
{% block content %}
<h1>
{% if svc %}
{{ svc.name }}
{% else %}
Новый сервис
{% endif %}
</h1>
{% if svc %}
<form method="post" action="/admin/services/{{ svc.slug }}/delete">
<button type="submit">Удалить</button>
</form>
{% endif %}
<form method="post" onsubmit="submitForm(event)">
<div class="form-row">
<label for="service-slug">Slug</label>
<input
type="text"
id="service-slug"
name="slug"
required
value="{% if svc %}{{ svc.slug }}{% endif %}"
/>
</div>
<div class="form-row">
<label for="service-name">Название</label>
<input
type="text"
id="service-name"
name="name"
required
value="{% if svc %}{{ svc.name }}{% endif %}"
/>
</div>
<div class="form-row">
<label for="service-url">URL</label>
<input
type="text"
id="service-url"
name="url"
required
value="{% if svc %}{{ svc.url }}{% endif %}"
/>
</div>
<div class="form-row">
<label for="service-url-checkbox">URL активна?</label>
<input
type="checkbox"
id="service-url-checkbox"
name="is_url_active"
value="true"
{% if svc and svc.is_url_active %}checked{% endif %}
/>
</div>
<div class="form-row">
<label for="service-tor-url">Tor URL</label>
<input
type="text"
id="service-tor-url"
name="tor_url"
value="{% if svc %}{{ svc.tor_url }}{% endif %}"
/>
</div>
<div class="form-row">
<label for="service-cover">Обложка</label>
<input type="file" id="service-cover" accept="image/*" onchange="onFileSelection(event)" />
<input
id="service-cover-id"
name="cover_id"
value="{% if svc %}{{ svc.cover_id }}{% endif %}"
{% if not svc or not svc.cover_id %}
type="hidden"
disabled
{% endif %}
readonly
/>
<button
id="clear-cover-button"
onclick="clearCover()"
type="button"
{% if not svc or not svc.cover_id %}
style="display: none"
{% endif %}
>
Убрать обложку
</button>
</div>
<div class="form-row">
<label for="service-short-description">Короткое описание</label>
<textarea
id="service-short-description"
name="short_description"
required
>{% if svc %}{{ svc.short_description }}{% endif %}</textarea>
</div>
<button class="submit-button" type="submit">Отправить</button>
</form>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends "layout.html" %}
{% block content %}
<h1>Сервисы</h1>
<a href="/admin/services/new">Новый сервис</a>
<ul>
{% for service in services %}
<li>
<a href="/admin/services/{{ service.slug }}">{{ service.name }}</a>
</li>
{% endfor %}
</ul>
{% endblock %}

View file

@ -23,75 +23,34 @@
<p><i>- Иван, администратор comfycamp.space</i></p>
<div class="service">
<div class="title">
<h3>Mastodon</h3>
</div>
<a class="link" href="https://m.comfycamp.space" target="_blank">
m.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
</a>
<p>
Микроблоги с поддержкой fediverse.
</p>
</div>
{% for svc in services %}
<div class="service">
<div class="title">
<h3>{{ svc.name }}</h3>
</div>
{% if svc.cover_id %}
<div class="image">
<img src="/api/images/{{ svc.cover_id }}" />
</div>
{% endif %}
<div class="links">
{% if svc.is_url_active %}
<a class="link" href="{{ svc.url }}" target="_blank">
{{ svc.url | domain }} <img src="/assets/icons/external.svg" width="16" height="16" alt="" />
</a>
{% else %}
<a class="link">
{{ svc.url }}
</a>
{% endif %}
<div class="service">
<div class="title">
<h3>Peertube</h3>
{% if svc.tor_url %}
<a class="link" href="{{ svc.tor_url }}" target="_blank">
Onion <img src="/assets/icons/external.svg" width="16" height="16" alt="" />
</a>
{% endif %}
</div>
<p>{{ svc.short_description }}</p>
</div>
<a class="link" href="https://v.comfycamp.space" target="_blank">
v.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
</a>
<p>
Видеохостинг, альтернатива YouTube.
</p>
</div>
<div class="service">
<div class="title">
<h3>Matrix</h3>
</div>
<a class="link">
matrix.comfycamp.space
</a>
<p>
Современный протокол для общения.
</p>
</div>
<div class="service">
<div class="title">
<h3>Forgejo</h3>
</div>
<a class="link" href="https://git.comfycamp.space" target="_blank">
git.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
</a>
<p>
Хостинг для git-проектов.
</p>
</div>
<div class="service">
<div class="title">
<h3>Nextcloud</h3>
</div>
<a class="link" href="https://nc.comfycamp.space" target="_blank">
nc.comfycamp.space <img src="/assets/icons/external.svg" width="20" height="20" alt="" />
</a>
<p>
Облако, календарь, задачи.
</p>
</div>
<div class="service">
<div class="title">
<h3>XMPP</h3>
</div>
<a class="link">
xmpp.comfycamp.space
</a>
<p>
Проверенный временем протокол для общения.
</p>
</div>
{% endfor %}
{% endblock %}

View file

@ -16,6 +16,9 @@
<body>
<nav>
<a href="/">Главная</a>
{% if user and user.is_admin %}
<a href="/admin">Админка</a>
{% endif %}
<div class="space"></div>
{% if user %}
Привет, {{ user.display_name }}!