feat: implement main logic

This commit is contained in:
thek4n 2026-06-03 14:15:55 +03:00
parent d8da31f8b6
commit e1fca0eb1e
18 changed files with 996 additions and 558 deletions

217
Cargo.lock generated
View File

@ -11,6 +11,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "askama"
version = "0.16.0"
@ -143,6 +193,15 @@ version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@ -184,12 +243,96 @@ dependencies = [
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -254,6 +397,16 @@ dependencies = [
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "glob"
version = "0.3.3"
@ -266,6 +419,18 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.4.1"
@ -380,6 +545,12 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@ -463,6 +634,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
@ -624,6 +801,17 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -662,6 +850,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
@ -686,7 +880,12 @@ dependencies = [
"askama",
"axum",
"chrono",
"clap",
"clap_complete",
"hex",
"mime",
"serde",
"sha2",
"tokio",
"toml",
]
@ -808,12 +1007,30 @@ dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"

View File

@ -5,6 +5,13 @@ edition = "2024"
authors = ["Vladislav Kan <thek4n@yandex.ru>"]
license = "MIT"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
[dependencies]
askama = "0.16.0"
serde = { version = "1.0", features = ["derive"] }
@ -12,3 +19,8 @@ toml = "0.8"
axum = "0.8"
tokio = { version = "1", features = ["full"] }
chrono = "0.4"
clap = { version = "4.5", features = ["derive"] }
clap_complete = "4.5"
sha2 = "0.10"
hex = "0.4"
mime = "0.3"

18
TODO.md
View File

@ -1,13 +1,17 @@
# ToDo list
* [ ] Сделать в embed шаблоны
* [ ] Сделать автоматическую локализацию по ip или заголовкам http `Accept-Language`
* [ ] Поправить стили
* [ ] alert copied
* [X] сделать путь до ключа
* [X] Сделать стили и засунуть их в embed (с уникальным хешем в имени)
* [X] Сделать js и засунуть их в embed (с уникальным хешем в имени)
* [ ] Добавить валидирование конфига
* [ ] Сделать default config чтобы выводился по аргументу cli
* [X] Сделать автоматическую локализацию по ip или заголовкам http `Accept-Language`
* [X] Сделать локализацию по параметру `/?lang=en`
* [X] Сделать авто выбор верстки в зависимости от User-Agent
* [ ] Сделать стили и засунуть их в embed (с уникальным хешем в имени)
* [ ] Сделать скрипты и засунуть их в embed (с уникальным хешем в имени)
* [ ] Отрефачить
* [ ] Сделать определение хоста по которому был запрос и засунуть его в
* [X] Отрефачить
* [X] Сделать определение хоста по которому был запрос и засунуть его в
аргумент `build_html_template()`
* [x] Сделать текущий год на клиенте или сервере (решить) (на сервере
предпочтительно, чтобы им нельзя было манипулировать на клиенте)
* [ ] Вынести конфиг в параметры
* [X] Вынести конфиг в параметры

View File

@ -1,64 +1,17 @@
const translations = {
en: document.querySelectorAll('[data-lang="en"]'),
ru: document.querySelectorAll('[data-lang="ru"]'),
};
function setLanguage(lang) {
document.documentElement.lang = lang;
localStorage.setItem("lang", lang);
const langToHide = lang === "en" ? "ru" : "en";
translations[langToHide].forEach((el) => (el.style.display = "none"));
translations[lang].forEach((el) => (el.style.display = ""));
document.getElementById(`lang-${lang}`).classList.add("active");
document.getElementById(`lang-${langToHide}`).classList.remove("active");
if (window.event) event.preventDefault();
}
function setTheme(theme) {
if (theme === "new") {
document.documentElement.setAttribute("data-theme", "new");
} else {
document.documentElement.removeAttribute("data-theme");
}
localStorage.setItem("theme", theme);
document
.getElementById("theme-old")
.classList.toggle("active", theme === "old");
document
.getElementById("theme-new")
.classList.toggle("active", theme === "new");
if (window.event) event.preventDefault();
}
function copyGPGKey() {
var e = window.event;
if (e) { e.preventDefault(); e.stopPropagation(); }
const keyText = document.getElementById('gpg-key').textContent;
navigator.clipboard.writeText(keyText).then(() => {
const btnEn = document.getElementById('copy-gpg');
const btnRu = document.getElementById('copy-gpg-ru');
const prevEn = btnEn.textContent;
const prevRu = btnRu.textContent;
btnEn.textContent = 'Copied!';
btnRu.textContent = 'Скопировано!';
setTimeout(() => {
btnEn.textContent = prevEn;
btnRu.textContent = prevRu;
}, 2000);
// const btnEn = document.getElementById('copy-gpg');
// const btnRu = document.getElementById('copy-gpg-ru');
// const prevEn = btnEn.textContent;
// const prevRu = btnRu.textContent;
// btnEn.textContent = 'Copied!';
// btnRu.textContent = 'Скопировано!';
// setTimeout(() => {
// btnEn.textContent = prevEn;
// btnRu.textContent = prevRu;
// }, 2000);
});
}
document.addEventListener("DOMContentLoaded", () => {
const savedLang = localStorage.getItem("lang") || "en";
const savedTheme = localStorage.getItem("theme") || "old";
setLanguage(savedLang);
setTheme(savedTheme);
});

View File

@ -1,10 +1,12 @@
[page]
title.en = "Hi, I'm Vlad"
title.ru = "Владислав Кан"
title.ua = "Владислав Кан"
meta_title = "thek4n"
meta_description = "thek4n - developer"
description.en = "Developer"
description.ru = "Разработчик"
description.ua = "Разработчик"
copyright_author = "Vladislav Kan <thek4n@yandex.ru>"
@ -20,6 +22,10 @@ title = "english"
key = "ru"
title = "русский"
[[languages]]
key = "ua"
title = "український"
# ---
@ -27,12 +33,29 @@ title = "русский"
[page.aboutme]
title.en = "About Me"
title.ru = "Обо Мне"
title.ua = "Про мене"
text.en = '''
About me text...
I am a Software Engineer 👨💻.
I'm working on developing of various IT products from embedded to web 🌐.
I enjoy reading different tech articles and books 📘.
'''
text.ru = '''
Обо мне текст...
Я инженер-программист 👨💻.
Я занимаюсь разработкой различных ИТ-продуктов, от embedded до веб-приложений 🌐.
Мне нравится читать различные технические статьи и книги 📘.
'''
text.ua = '''
Я інженер-програміст.
Я розробляю різні ІТ-продукти, від embedded до веб-додатків 🌐
Мені подобається читати Різні технічні статті та книги 📘
'''
@ -42,19 +65,22 @@ text.ru = '''
[page.gpg]
title.en = "GPG Public Key"
title.ru = "Публичный ключ GPG"
url = "/gpgkey.txt"
url_title = "thek4n.ru/gpgkey.txt"
title.ua = "Публічний ключ GPG"
showkey_title.en = "Show key"
showkey_title.ru = "Показать ключ"
showkey_title.ua = "Показати ключ"
availat_title.en = "Available at"
availat_title.ru = "Доступен по"
availat_title.ua = "Доступний по"
copy_title.en = "Show key"
copy_title.ru = "Показать ключ"
copy_title.en = "Copy key"
copy_title.ru = "Скопировать ключ"
copy_title.ua = "Скопіювати ключ"
copied_title.en = "Copied!"
copied_title.ru = "Скопировано!"
copied_title.ua = "Скопійовано!"
value = '''
-----BEGIN PGP PUBLIC KEY BLOCK-----
@ -116,6 +142,7 @@ Fs/cX/nWHveKYtN9iWsgHnYwm7hvg3A2k6JMwec3Yp0NmeJinfhJaihqxg==
[page.projects]
title.en = "Projects"
title.ru = "Проекты"
title.ua = "Проекти"
[[projects]]
title = "ip.thek4n.ru"
@ -124,6 +151,7 @@ source_url = "https://gitea.thek4n.ru/thek4n/ip.thek4n.ru"
source_url_title = "thek4n/ip.thek4n.ru"
description.en = "check my ip service"
description.ru = "сервис определения ip"
description.ua = "сервіс визначення ip"
[[projects]]
title = "paste.thek4n.ru"
@ -132,6 +160,7 @@ source_url = "https://gitea.thek4n.ru/thek4n/paste.thek4n.ru"
source_url_title = "thek4n/paste.thek4n.ru"
description.en = "copy/paste and url shortener service"
description.ru = "укоротитель ссылок"
description.ua = "укоротитель посилань"
[[projects]]
title = "thek4n.ru"
@ -140,6 +169,7 @@ source_url = "https://gitea.thek4n.ru/thek4n/thek4n.ru"
source_url_title = "thek4n/thek4n.ru"
description.en = "this site source"
description.ru = "исходники этого сайта"
description.ua = "джерела цього сайту"
# ---
@ -148,21 +178,25 @@ description.ru = "исходники этого сайта"
[page.contacts]
title.en = "Contacts"
title.ru = "Контакты"
title.ua = "Контакти"
[[contacts]]
site_name.en = "Telegram"
site_name.ru = "Телеграм"
site_name.ua = "Телеграм"
title = "@thek4n"
url = "https://t.me/thek4n"
[[contacts]]
site_name.en = "Github"
site_name.ru = "Github"
site_name.ua = "Github"
title = "github.com/thek4n"
url = "https://github.com/thek4n"
[[contacts]]
site_name.en = "Email"
site_name.ru = "Почта"
site_name.ua = "Пошта"
title = "thek4n@yandex.ru"
url = "mailto:thek4n@yandex.ru"

63
src/client/detector.rs Normal file
View File

@ -0,0 +1,63 @@
use axum::http::HeaderMap;
use axum::http::header::USER_AGENT;
#[derive(Debug, PartialEq)]
pub enum ClientType {
Browser,
CliLike,
Unknown,
}
pub fn detect_client_type(headers: &HeaderMap) -> ClientType {
let user_agent = headers
.get(USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let user_agent_lower = user_agent.to_lowercase();
let accept = headers
.get("accept")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let accept_lower = accept.to_lowercase();
let is_likely_cli = accept_lower.is_empty()
|| accept_lower == "*/*"
|| accept_lower == "application/json"
|| accept_lower == "text/plain";
let is_likely_browser = accept_lower.contains("text/html")
&& (accept_lower.contains("application/xhtml+xml")
|| accept_lower.contains("application/xml")
|| accept_lower.contains("*/*"));
if user_agent_lower.contains("curl")
|| user_agent_lower.contains("wget")
|| user_agent_lower.contains("httpie")
|| user_agent_lower.contains("python-requests")
|| user_agent_lower.contains("go-http-client")
|| (is_likely_cli && !user_agent_lower.contains("mozilla/"))
{
return ClientType::CliLike;
}
if user_agent_lower.contains("mozilla/")
&& (user_agent_lower.contains("chrome")
|| user_agent_lower.contains("safari")
|| user_agent_lower.contains("firefox")
|| user_agent_lower.contains("edg"))
&& is_likely_browser
{
return ClientType::Browser;
}
if is_likely_browser {
ClientType::Browser
} else if is_likely_cli {
ClientType::CliLike
} else {
ClientType::Unknown
}
}

4
src/client/mod.rs Normal file
View File

@ -0,0 +1,4 @@
mod detector;
pub use detector::ClientType;
pub use detector::detect_client_type;

12
src/config/mod.rs Normal file
View File

@ -0,0 +1,12 @@
mod models;
pub use models::*;
use std::fs;
use std::path::PathBuf;
pub fn load_config(path: PathBuf) -> Result<Config, Box<dyn std::error::Error>> {
let contents = fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}

70
src/config/models.rs Normal file
View File

@ -0,0 +1,70 @@
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub page: PageConfig,
pub languages: Vec<LanguageConfig>,
pub projects: Option<Vec<ProjectConfig>>,
pub contacts: Option<Vec<ContactConfig>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PageConfig {
pub title: HashMap<String, String>,
pub meta_title: String,
pub meta_description: String,
pub description: HashMap<String, String>,
pub copyright_author: String,
pub aboutme: Option<AboutMeConfig>,
pub gpg: Option<GPGConfig>,
pub projects: Option<ProjectsSectionConfig>,
pub contacts: Option<ContactsSectionConfig>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AboutMeConfig {
pub title: HashMap<String, String>,
pub text: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct GPGConfig {
pub title: HashMap<String, String>,
pub showkey_title: HashMap<String, String>,
pub availat_title: HashMap<String, String>,
pub copy_title: HashMap<String, String>,
pub value: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ProjectsSectionConfig {
pub title: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ContactsSectionConfig {
pub title: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ProjectConfig {
pub title: String,
pub url: String,
pub source_url: String,
pub source_url_title: String,
pub description: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ContactConfig {
pub site_name: HashMap<String, String>,
pub title: String,
pub url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct LanguageConfig {
pub key: String,
pub title: String,
}

3
src/handlers/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod root;
pub use root::{gpg_handler, root_handler};

105
src/handlers/root.rs Normal file
View File

@ -0,0 +1,105 @@
use askama::Template;
use axum::{
extract::{Query, State},
http::{HeaderMap, Request},
response::{Html, IntoResponse},
};
use chrono::Datelike;
use serde::Deserialize;
use crate::client::ClientType;
use crate::client::detect_client_type;
use crate::config::load_config;
use crate::language::detect_user_language;
use crate::templates::{build_html_template, build_text_template};
use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct RootParams {
pub lang: Option<String>,
}
fn get_base_url(request: &Request<axum::body::Body>) -> String {
let protocol = request
.headers()
.get("x-forwarded-proto")
.and_then(|p| p.to_str().ok())
.or_else(|| request.uri().scheme_str())
.unwrap_or("http");
let host = request
.headers()
.get("x-forwarded-host")
.and_then(|h| h.to_str().ok())
.or_else(|| request.headers().get("host").and_then(|h| h.to_str().ok()))
.unwrap_or("unknown");
format!("{}://{}", protocol, host)
}
pub async fn root_handler(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<RootParams>,
request: Request<axum::body::Body>,
) -> impl IntoResponse {
let config = match load_config(state.config_path.clone()) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("Failed to load config: {}", e);
return "Internal Server Error".into_response();
}
};
let accept_language = headers
.get(axum::http::header::ACCEPT_LANGUAGE)
.and_then(|v| v.to_str().ok());
let lang = detect_user_language(params.lang, accept_language, &config.languages);
let year = chrono::Utc::now().year().to_string();
let base_url = get_base_url(&request);
match detect_client_type(&headers) {
ClientType::Browser => {
match build_html_template(
&config,
&lang,
&base_url,
&year,
&state.css_url,
&state.script_js_url,
)
.render()
{
Ok(html) => Html(html).into_response(),
Err(e) => {
eprintln!("Failed to render HTML template: {}", e);
"Internal Server Error".into_response()
}
}
}
ClientType::CliLike | ClientType::Unknown => {
let text = build_text_template(&config, &lang, &base_url, &year)
.render()
.unwrap_or_else(|e| {
eprintln!("Failed to render text template: {}", e);
"Internal Server Error".to_string()
});
format!("{}\n", text).into_response()
}
}
}
pub async fn gpg_handler(State(state): State<AppState>) -> impl IntoResponse {
let config = match load_config(state.config_path.clone()) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("Failed to load config: {}", e);
return "Internal Server Error".into_response();
}
};
config.page.gpg.unwrap().value.into_response()
}

67
src/language/detector.rs Normal file
View File

@ -0,0 +1,67 @@
use crate::config::LanguageConfig;
#[derive(Debug, PartialEq, Clone)]
struct LanguageWeight {
code: String,
weight: f32,
}
pub fn detect_user_language(
query_lang: Option<String>,
accept_language_header: Option<&str>,
supported_languages: &[LanguageConfig],
) -> String {
if let Some(lang) = query_lang {
if supported_languages.iter().any(|l| l.key == lang) {
return lang;
}
}
if let Some(accept_lang) = accept_language_header {
let languages = parse_accept_language(accept_lang);
for lang_weight in languages {
if supported_languages
.iter()
.any(|l| l.key == lang_weight.code)
{
return lang_weight.code;
}
}
}
supported_languages
.first()
.map(|l| l.key.clone())
.unwrap_or_else(|| "en".to_string())
}
fn parse_accept_language(header: &str) -> Vec<LanguageWeight> {
let mut languages: Vec<LanguageWeight> = Vec::new();
for part in header.split(',') {
let trimmed = part.trim();
let parts: Vec<&str> = trimmed.split(';').collect();
let lang_code = parts[0];
let main_lang = lang_code.split('-').next().unwrap_or(lang_code);
let weight = if parts.len() > 1 {
parts[1]
.trim()
.strip_prefix("q=")
.and_then(|q| q.parse::<f32>().ok())
.unwrap_or(1.0)
} else {
1.0
};
languages.push(LanguageWeight {
code: main_lang.to_string(),
weight,
});
}
languages.sort_by(|a, b| b.weight.partial_cmp(&a.weight).unwrap());
languages
}

3
src/language/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod detector;
pub use detector::detect_user_language;

View File

@ -1,477 +1,134 @@
use askama::Template;
use axum::{
Router,
extract::Query,
http::HeaderMap,
http::header::{ACCEPT, USER_AGENT},
response::{Html, IntoResponse},
routing::get,
};
use chrono::Datelike;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::net::SocketAddr;
mod client;
mod config;
mod handlers;
mod language;
mod templates;
#[derive(Debug, Deserialize)]
struct Config {
page: PageConfig,
languages: Vec<LanguageConfig>,
projects: Option<Vec<ProjectConfig>>,
contacts: Option<Vec<ContactConfig>>,
}
use axum::Router;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::get;
use clap::Parser;
use handlers::{gpg_handler, root_handler};
use sha2::{Digest, Sha256};
use std::net::ToSocketAddrs;
use std::ops::RangeInclusive;
#[derive(Debug, Deserialize)]
struct PageConfig {
title: HashMap<String, String>, // теперь HashMap
meta_title: String,
meta_description: String,
description: HashMap<String, String>, // теперь HashMap
copyright_author: String,
aboutme: Option<AboutMeConfig>,
gpg: Option<GPGConfig>,
projects: Option<ProjectsSectionConfig>,
contacts: Option<ContactsSectionConfig>,
}
const STATIC_JS: &'static str = include_str!("../assets/script.js");
const STATIC_CSS: &'static str = include_str!("../assets/style.css");
#[derive(Debug, Deserialize)]
struct AboutMeConfig {
title: HashMap<String, String>,
text: HashMap<String, String>,
}
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Path to configuration file
#[arg(short, long)]
config: std::path::PathBuf,
#[derive(Debug, Deserialize)]
struct GPGConfig {
title: HashMap<String, String>,
url: String,
url_title: String,
showkey_title: HashMap<String, String>,
availat_title: HashMap<String, String>,
copy_title: HashMap<String, String>,
value: String,
}
/// Network port to use
#[arg(short, long, value_parser = port_in_range, value_name = "PORT", default_value = "8080")]
port: u16,
#[derive(Debug, Deserialize)]
struct ProjectsSectionConfig {
title: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct ContactsSectionConfig {
title: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct ProjectConfig {
title: String,
url: String,
source_url: String,
source_url_title: String,
description: HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
struct ContactConfig {
site_name: HashMap<String, String>,
title: String,
url: String,
}
#[derive(Debug, Deserialize)]
struct LanguageConfig {
key: String,
title: String,
}
// Template structures matching the HTML
#[derive(Template)]
#[template(path = "html_index.jinja2")]
struct HtmlPageTemplate<'a> {
title: &'a str,
description: &'a str,
copyright_author: &'a str,
base_url: &'a str,
meta: PageMeta<'a>,
year: &'a str,
aboutme: AboutMeSection<'a>,
gpg_section: GPGSection<'a>,
contacts_section: ContactsSection<'a>,
projects_section: ProjectsSection<'a>,
languages: Vec<Language<'a>>,
}
#[derive(Template)]
#[template(path = "text_index.jinja2", escape = "none")]
struct TextPageTemplate<'a> {
title: &'a str,
description: &'a str,
copyright_author: &'a str,
base_url: &'a str,
year: &'a str,
aboutme_section: AboutMeSection<'a>,
gpg_section: GPGSection<'a>,
contacts_section: ContactsSection<'a>,
projects_section: ProjectsSection<'a>,
}
#[derive(Clone, Copy)]
struct PageMeta<'a> {
title: &'a str,
description: &'a str,
}
#[derive(Clone, Copy)]
struct AboutMeSection<'a> {
title: &'a str,
text: &'a str,
}
#[derive(Clone, Copy)]
struct GPGSection<'a> {
title: &'a str,
availat_title: &'a str,
url: &'a str,
url_title: &'a str,
showkey_title: &'a str,
copy_title: &'a str,
value: &'a str,
/// Host to listen
#[arg(long, default_value = "127.0.0.1")]
host: String,
}
#[derive(Clone)]
struct ContactsSection<'a> {
title: &'a str,
contacts: Vec<Contact<'a>>,
pub struct AppState {
config_path: std::path::PathBuf,
css_content: &'static str,
js_content: &'static str,
css_url: String,
script_js_url: String,
}
#[derive(Clone)]
struct Contact<'a> {
site_name: &'a str,
url: &'a str,
title: &'a str,
}
const PORT_RANGE: RangeInclusive<usize> = 1..=65535;
#[derive(Clone)]
struct ProjectsSection<'a> {
title: &'a str,
projects: Vec<Project<'a>>,
}
fn port_in_range(s: &str) -> Result<u16, String> {
let port: usize = s
.parse()
.map_err(|_| format!("`{s}` isn't a port number"))?;
#[derive(Clone)]
struct Project<'a> {
title: &'a str,
url: &'a str,
source_url: &'a str,
source_url_title: &'a str,
description: &'a str,
}
#[derive(Clone)]
struct Language<'a> {
key: &'a str,
current: bool,
value: &'a str,
}
fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
let contents = fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
fn build_aboutme_section<'a>(config: &'a Config, lang_key: &'a str) -> AboutMeSection<'a> {
AboutMeSection {
title: config
.page
.aboutme
.as_ref()
.and_then(|a| a.title.get(lang_key))
.map(|s| s.as_str())
.unwrap_or(""),
text: config
.page
.aboutme
.as_ref()
.and_then(|a| a.text.get(lang_key))
.map(|s| s.as_str())
.unwrap_or(""),
}
}
fn build_gpg_section<'a>(config: &'a Config, lang_key: &'a str) -> GPGSection<'a> {
let gpg_config = config.page.gpg.as_ref().unwrap();
GPGSection {
title: gpg_config
.title
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
availat_title: gpg_config
.availat_title
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
url: &gpg_config.url,
url_title: &gpg_config.url_title,
showkey_title: gpg_config
.showkey_title
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
copy_title: gpg_config
.copy_title
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
value: &gpg_config.value,
}
}
fn build_contacts_section<'a>(config: &'a Config, lang_key: &'a str) -> ContactsSection<'a> {
let contacts_config = config.page.contacts.as_ref().unwrap();
let contacts: Vec<Contact<'a>> = config
.contacts
.as_ref()
.unwrap()
.iter()
.map(|contact| Contact {
site_name: contact
.site_name
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
url: &contact.url,
title: &contact.title,
})
.collect();
ContactsSection {
title: contacts_config
.title
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
contacts,
}
}
fn build_projects_section<'a>(config: &'a Config, lang_key: &'a str) -> ProjectsSection<'a> {
let projects_config = config.page.projects.as_ref().unwrap();
let projects: Vec<Project<'a>> = config
.projects
.as_ref()
.unwrap()
.iter()
.map(|project| Project {
title: &project.title,
url: &project.url,
source_url: &project.source_url,
source_url_title: &project.source_url_title,
description: project
.description
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
})
.collect();
ProjectsSection {
title: projects_config
.title
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
projects,
}
}
fn build_languages<'a>(config: &'a Config, lang_key: &'a str) -> Vec<Language<'a>> {
config
.languages
.iter()
.map(|lang| Language {
key: &lang.key,
current: lang.key == lang_key,
value: &lang.title,
})
.collect()
}
fn build_html_template<'a>(
config: &'a Config,
lang_key: &'a str,
base_url: &'a str,
year: &'a str,
) -> HtmlPageTemplate<'a> {
let meta = PageMeta {
title: &config.page.meta_title,
description: &config.page.meta_description,
};
HtmlPageTemplate {
title: config
.page
.title
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
description: config
.page
.description
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
copyright_author: &config.page.copyright_author,
base_url,
meta,
year,
aboutme: build_aboutme_section(config, lang_key),
gpg_section: build_gpg_section(config, lang_key),
contacts_section: build_contacts_section(config, lang_key),
projects_section: build_projects_section(config, lang_key),
languages: build_languages(config, lang_key),
}
}
fn build_text_template<'a>(
config: &'a Config,
lang_key: &'a str,
base_url: &'a str,
year: &'a str,
) -> TextPageTemplate<'a> {
TextPageTemplate {
title: config
.page
.title
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
description: config
.page
.description
.get(lang_key)
.map(|s| s.as_str())
.unwrap_or(""),
copyright_author: &config.page.copyright_author,
base_url,
year,
aboutme_section: build_aboutme_section(config, lang_key),
gpg_section: build_gpg_section(config, lang_key),
contacts_section: build_contacts_section(config, lang_key),
projects_section: build_projects_section(config, lang_key),
}
}
#[derive(Debug, PartialEq)]
enum ClientType {
Browser,
CliLike, // curl, wget, httpie и т.д.
Unknown,
}
fn detect_client_type(headers: &HeaderMap) -> ClientType {
let user_agent = headers
.get(USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let user_agent_lower = user_agent.to_lowercase();
// Проверка Accept header
let accept = headers
.get(ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let accept_lower = accept.to_lowercase();
// CLI утилиты обычно имеют простой Accept или не имеют его вовсе
let is_likely_cli = accept_lower.is_empty()
|| accept_lower == "*/*"
|| accept_lower == "application/json"
|| accept_lower == "text/plain";
// Браузеры всегда запрашивают text/html в первую очередь
let is_likely_browser = accept_lower.contains("text/html")
&& (accept_lower.contains("application/xhtml+xml")
|| accept_lower.contains("application/xml")
|| accept_lower.contains("*/*"));
// Комбинированная проверка: User-Agent + Accept
if user_agent_lower.contains("curl")
|| user_agent_lower.contains("wget")
|| user_agent_lower.contains("httpie")
|| user_agent_lower.contains("python-requests")
|| user_agent_lower.contains("go-http-client")
|| (is_likely_cli && !user_agent_lower.contains("mozilla/"))
{
return ClientType::CliLike;
}
if user_agent_lower.contains("mozilla/")
&& (user_agent_lower.contains("chrome")
|| user_agent_lower.contains("safari")
|| user_agent_lower.contains("firefox")
|| user_agent_lower.contains("edg"))
&& is_likely_browser
{
return ClientType::Browser;
}
// Fallback с использованием только Accept header
if is_likely_browser {
ClientType::Browser
} else if is_likely_cli {
ClientType::CliLike
if PORT_RANGE.contains(&port) {
Ok(port as u16)
} else {
ClientType::Unknown
}
}
#[derive(Debug, Deserialize)]
pub struct RootParams {
pub lang: Option<String>,
}
async fn root_handler(headers: HeaderMap, Query(params): Query<RootParams>) -> impl IntoResponse {
let query_lang = params.lang.unwrap_or("en".to_string());
let config = load_config("docs/config_example.toml").unwrap();
let lang_exists = config
.languages
.iter()
.any(|lang_config| lang_config.key == query_lang);
let lang = if lang_exists {
query_lang.as_str()
} else {
"en"
};
let year = chrono::Utc::now().year().to_string();
match detect_client_type(&headers) {
ClientType::Browser => Html(
build_html_template(&config, lang, "https://thek4n.ru", year.as_str())
.render()
.unwrap(),
)
.into_response(),
ClientType::CliLike | ClientType::Unknown => format!(
"{}\n",
build_text_template(&config, lang, "https://thek4n.ru", year.as_str())
)
.into_response(),
Err(format!(
"port not in range {}-{}",
PORT_RANGE.start(),
PORT_RANGE.end()
))
}
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(root_handler));
let args = Args::parse();
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let css_hash = calculate_sha256(STATIC_CSS);
let js_hash = calculate_sha256(STATIC_JS);
axum::serve(tokio::net::TcpListener::bind(addr).await.unwrap(), app)
let css_path = format!("/style-{css_hash}.js");
let js_path = format!("/script-{js_hash}.js");
let state = AppState {
config_path: args.config,
css_content: STATIC_CSS,
js_content: STATIC_JS,
css_url: css_path.clone(),
script_js_url: js_path.clone(),
};
let app = Router::new()
.route("/", get(root_handler))
.route("/gpgkey.txt", get(gpg_handler))
.route(css_path.as_str(), get(static_css_handler))
.route(js_path.as_str(), get(static_js_handler))
.with_state(state);
let addr = format!("{}:{}", args.host, args.port)
.to_socket_addrs()
.expect("Fail to get address")
.next()
.ok_or("failed to resolve address")
.expect("");
println!("Server running on http://{}", addr);
axum::serve(
tokio::net::TcpListener::bind(addr)
.await
.unwrap();
.expect("Failed to bind to address"),
app,
)
.await
.expect("Server failed to start");
}
async fn static_css_handler(State(state): State<AppState>) -> impl IntoResponse {
let headers = [
(axum::http::header::CONTENT_TYPE, mime::CSS.as_ref()),
(axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
];
(headers, state.css_content).into_response()
}
async fn static_js_handler(State(state): State<AppState>) -> impl IntoResponse {
let headers = [
(
axum::http::header::CONTENT_TYPE,
mime::APPLICATION_JAVASCRIPT.as_ref(),
),
(axum::http::header::CACHE_CONTROL, "public, max-age=86400"),
];
(headers, state.js_content).into_response()
}
fn calculate_sha256(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
hex::encode(result)[..6].to_string()
}

236
src/templates/builders.rs Normal file
View File

@ -0,0 +1,236 @@
use crate::config::Config;
use askama::Template;
use std::collections::HashMap;
#[derive(Template)]
#[template(path = "html_index.jinja2")]
pub struct HtmlPageTemplate<'a> {
pub title: &'a str,
pub description: &'a str,
pub copyright_author: &'a str,
pub base_url: &'a str,
pub meta: PageMeta<'a>,
pub year: &'a str,
pub aboutme: AboutMeSection<'a>,
pub gpg_section: GPGSection<'a>,
pub contacts_section: ContactsSection<'a>,
pub projects_section: ProjectsSection<'a>,
pub languages: Vec<Language<'a>>,
}
#[derive(Template)]
#[template(path = "text_index.jinja2", escape = "none")]
pub struct TextPageTemplate<'a> {
pub title: &'a str,
pub description: &'a str,
pub copyright_author: &'a str,
pub base_url: &'a str,
pub year: &'a str,
pub aboutme_section: AboutMeSection<'a>,
pub gpg_section: GPGSection<'a>,
pub contacts_section: ContactsSection<'a>,
pub projects_section: ProjectsSection<'a>,
}
#[derive(Clone, Copy)]
pub struct PageMeta<'a> {
pub title: &'a str,
pub description: &'a str,
pub css_url: &'a str,
pub script_js_url: &'a str,
}
#[derive(Clone, Copy)]
pub struct AboutMeSection<'a> {
pub title: &'a str,
pub text: &'a str,
}
#[derive(Clone, Copy)]
pub struct GPGSection<'a> {
pub title: &'a str,
pub availat_title: &'a str,
pub showkey_title: &'a str,
pub copy_title: &'a str,
pub value: &'a str,
}
#[derive(Clone)]
pub struct ContactsSection<'a> {
pub title: &'a str,
pub contacts: Vec<Contact<'a>>,
}
#[derive(Clone)]
pub struct Contact<'a> {
pub site_name: &'a str,
pub url: &'a str,
pub title: &'a str,
}
#[derive(Clone)]
pub struct ProjectsSection<'a> {
pub title: &'a str,
pub projects: Vec<Project<'a>>,
}
#[derive(Clone)]
pub struct Project<'a> {
pub title: &'a str,
pub url: &'a str,
pub source_url: &'a str,
pub source_url_title: &'a str,
pub description: &'a str,
}
#[derive(Clone)]
pub struct Language<'a> {
pub key: &'a str,
pub current: bool,
pub value: &'a str,
}
// Вспомогательная функция для безопасного получения значения из HashMap
fn get_localized_string<'a>(map: &'a HashMap<String, String>, key: &str) -> &'a str {
map.get(key).map(|s| s.as_str()).unwrap_or("")
}
pub fn build_html_template<'a>(
config: &'a Config,
lang_key: &'a str,
base_url: &'a str,
year: &'a str,
css_url: &'a str,
script_js_url: &'a str,
) -> HtmlPageTemplate<'a> {
let meta = PageMeta {
title: &config.page.meta_title,
description: &config.page.meta_description,
css_url,
script_js_url,
};
HtmlPageTemplate {
title: get_localized_string(&config.page.title, lang_key),
description: get_localized_string(&config.page.description, lang_key),
copyright_author: &config.page.copyright_author,
base_url,
meta,
year,
aboutme: build_aboutme_section(config, lang_key),
gpg_section: build_gpg_section(config, lang_key),
contacts_section: build_contacts_section(config, lang_key),
projects_section: build_projects_section(config, lang_key),
languages: build_languages(config, lang_key),
}
}
pub fn build_text_template<'a>(
config: &'a Config,
lang_key: &'a str,
base_url: &'a str,
year: &'a str,
) -> TextPageTemplate<'a> {
TextPageTemplate {
title: get_localized_string(&config.page.title, lang_key),
description: get_localized_string(&config.page.description, lang_key),
copyright_author: &config.page.copyright_author,
base_url,
year,
aboutme_section: build_aboutme_section(config, lang_key),
gpg_section: build_gpg_section(config, lang_key),
contacts_section: build_contacts_section(config, lang_key),
projects_section: build_projects_section(config, lang_key),
}
}
fn build_aboutme_section<'a>(config: &'a Config, lang_key: &'a str) -> AboutMeSection<'a> {
AboutMeSection {
title: config
.page
.aboutme
.as_ref()
.map(|a| get_localized_string(&a.title, lang_key))
.unwrap_or(""),
text: config
.page
.aboutme
.as_ref()
.map(|a| get_localized_string(&a.text, lang_key))
.unwrap_or(""),
}
}
fn build_gpg_section<'a>(config: &'a Config, lang_key: &'a str) -> GPGSection<'a> {
let gpg_config = config.page.gpg.as_ref().expect("GPG config is required");
GPGSection {
title: get_localized_string(&gpg_config.title, lang_key),
availat_title: get_localized_string(&gpg_config.availat_title, lang_key),
showkey_title: get_localized_string(&gpg_config.showkey_title, lang_key),
copy_title: get_localized_string(&gpg_config.copy_title, lang_key),
value: &gpg_config.value,
}
}
fn build_contacts_section<'a>(config: &'a Config, lang_key: &'a str) -> ContactsSection<'a> {
let contacts_config = config
.page
.contacts
.as_ref()
.expect("Contacts config is required");
let contacts = config
.contacts
.as_ref()
.expect("Contacts list is required")
.iter()
.map(|contact| Contact {
site_name: get_localized_string(&contact.site_name, lang_key),
url: &contact.url,
title: &contact.title,
})
.collect();
ContactsSection {
title: get_localized_string(&contacts_config.title, lang_key),
contacts,
}
}
fn build_projects_section<'a>(config: &'a Config, lang_key: &'a str) -> ProjectsSection<'a> {
let projects_config = config
.page
.projects
.as_ref()
.expect("Projects config is required");
let projects = config
.projects
.as_ref()
.expect("Projects list is required")
.iter()
.map(|project| Project {
title: &project.title,
url: &project.url,
source_url: &project.source_url,
source_url_title: &project.source_url_title,
description: get_localized_string(&project.description, lang_key),
})
.collect();
ProjectsSection {
title: get_localized_string(&projects_config.title, lang_key),
projects,
}
}
fn build_languages<'a>(config: &'a Config, lang_key: &'a str) -> Vec<Language<'a>> {
config
.languages
.iter()
.map(|lang| Language {
key: &lang.key,
current: lang.key == lang_key,
value: &lang.title,
})
.collect()
}

3
src/templates/mod.rs Normal file
View File

@ -0,0 +1,3 @@
mod builders;
pub use builders::{build_html_template, build_text_template};

View File

@ -6,7 +6,7 @@
<title>{{ meta.title }}</title>
<meta name="description" content="{{ meta.description }}">
<link rel="icon" href="favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="{{ meta.css_url }}">
</head>
<body>
<main>
@ -39,28 +39,6 @@
<p>{{ aboutme.text }}</p>
</section>
<section>
<h2>{{ gpg_section.title }}</h2>
<p>
<span>{{ gpg_section.availat_title }}</span>
<a href="{{ gpg_section.url }}" target="_blank" rel="noopener noreferrer" >{{ gpg_section.url_title }}</a>
</p>
<details class="gpg-details">
<summary>
<span class="summary-row">
{{ gpg_section.showkey_title }}
<a id="copy-gpg" class="copy-link" href="#" onclick="copyGPGKey()">{{ gpg_section.copy_title }}</a>
</span>
<code>
{{ gpg_section.value|linebreaksbr }}
</code>
</summary>
</details>
</section>
<section>
<h2>{{ contacts_section.title }}</h2>
{% for contact in contacts_section.contacts %}
@ -72,11 +50,30 @@
{% endfor %}
</section>
<section>
<h2>{{ gpg_section.title }}</h2>
<p>
<span>{{ gpg_section.availat_title }}</span>
<a href="{{ base_url }}/gpgkey.txt" target="_blank" rel="noopener noreferrer" >{{ base_url }}/gpgkey.txt</a>
</p>
<details class="gpg-details">
<summary>
<span class="summary-row">
{{ gpg_section.showkey_title }}
<a id="copy-gpg" class="copy-link" href="#" onclick="copyGPGKey()">{{ gpg_section.copy_title }}</a>
</span>
</summary>
<pre id="gpg-key">{{ gpg_section.value|linebreaksbr }}</pre>
</details>
</section>
<footer>
<p>© {{ year }} {{ copyright_author }}</p>
</footer>
</main>
<script src="script.js"></script>
<script src="{{ meta.script_js_url }}"></script>
</body>
</html>

View File

@ -7,18 +7,16 @@
{%- endfor %}
{{ contacts_section.title }}:
{%- for contact in contacts_section.contacts %}
> {{ contact.site_name }}: {{ contact.title }}
{%- endfor %}
{{ gpg_section.title }}: {{ base_url }}{{ gpg_section.url }}
{{ aboutme_section.title }}:
{{ aboutme_section.text }}
{{ contacts_section.title }}:
{%- for contact in contacts_section.contacts %}
- {{ contact.site_name }}: {{ contact.title }}
{%- endfor %}
{{ gpg_section.title }}: {{ base_url }}/gpgkey.txt
© {{ year }} {{ copyright_author }}