Initial commit
This commit is contained in:
commit
a8c0bb1641
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
/index
|
||||
.env
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,51 @@
|
|||
[package]
|
||||
name = "evie"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
blogdb = { git = "https://git.augustkline.com/august/blogdb" }
|
||||
anyhow = "1.0.89"
|
||||
argon2 = "0.5.3"
|
||||
axum = { version = "0.7.7", features = ["macros", "multipart"] }
|
||||
axum-extra = { version = "0.9.4", features = [
|
||||
"cookie",
|
||||
"cookie-private",
|
||||
"form",
|
||||
"multipart",
|
||||
"query",
|
||||
] }
|
||||
serde = { version = "1.0.210", features = ["derive"] }
|
||||
sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio", "macros"] }
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
tower-http = { version = "0.6.1", features = [
|
||||
"cors",
|
||||
"fs",
|
||||
"limit",
|
||||
"normalize-path",
|
||||
"trace",
|
||||
] }
|
||||
tracing = "0.1.40"
|
||||
futures = "0.3.31"
|
||||
tower = "0.5.1"
|
||||
http-body = "1.0.1"
|
||||
lol_html = "2.0.0"
|
||||
tokio-util = { version = "0.7.12", features = ["io"] }
|
||||
glob = "0.3.1"
|
||||
mime_guess = "2.0.5"
|
||||
constcat = "0.5.1"
|
||||
serde_json = "1.0.132"
|
||||
tantivy = "0.22.0"
|
||||
tracing-subscriber = { version = "0.3.18", features = [
|
||||
"env-filter",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"std",
|
||||
] }
|
||||
phf = "0.11.2"
|
||||
|
||||
[build-dependencies]
|
||||
dotenvy = "0.15.7"
|
||||
glob = "0.3.1"
|
||||
phf = "0.11.2"
|
||||
phf_codegen = "0.11.2"
|
|
@ -0,0 +1,56 @@
|
|||
use std::{
|
||||
env,
|
||||
fs::File,
|
||||
io::Write,
|
||||
io::{BufWriter, Read},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use dotenvy::{dotenv, dotenv_iter};
|
||||
use glob::glob;
|
||||
use phf_codegen::Map;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed={}", dotenv().unwrap().display());
|
||||
for item in dotenv_iter().unwrap() {
|
||||
let (key, value) = item.unwrap();
|
||||
println!("cargo:rustc-env={key}={value}");
|
||||
}
|
||||
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("codegen.rs");
|
||||
let mut file = BufWriter::new(File::create(&path).unwrap());
|
||||
let template_dir = [&env::var("CARGO_MANIFEST_DIR").unwrap(), "/src/templates"].concat();
|
||||
println!("cargo:rerun-if-changed={}", template_dir);
|
||||
let pattern = [&template_dir, "/**/*.html"].concat();
|
||||
let mut map: Map<String> = phf_codegen::Map::new();
|
||||
|
||||
let paths = glob(&pattern).unwrap();
|
||||
for path in paths {
|
||||
match path {
|
||||
Ok(path) => {
|
||||
let slug_path = path.clone();
|
||||
let slug: String = slug_path
|
||||
.strip_prefix(&template_dir)
|
||||
.unwrap()
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.strip_suffix(".html")
|
||||
.unwrap()
|
||||
.into(); // eek
|
||||
let mut file = File::open(path.clone()).unwrap();
|
||||
let mut content = "r####\"".to_string();
|
||||
file.read_to_string(&mut content).unwrap();
|
||||
content.push_str("\"####");
|
||||
println!("cargo:warning={}\n{}", &slug, &content);
|
||||
map.entry(slug, &content);
|
||||
}
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
}
|
||||
writeln!(
|
||||
&mut file,
|
||||
"static TEMPLATES: phf::Map<&'static str, &'static str> = {};",
|
||||
map.build()
|
||||
)
|
||||
.unwrap();
|
||||
}
|
|
@ -0,0 +1,256 @@
|
|||
main {
|
||||
--page-margin: calc(0.333 * 100vi / 2);
|
||||
--page-content-size: calc(100% - (var(--page-margin) * 2));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--default-padding);
|
||||
margin-block-end: 40svb;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 60rem) {
|
||||
main {
|
||||
block-size: calc(100svb - 8rem
|
||||
/*the most magic number of all...*/
|
||||
);
|
||||
margin-block-end: unset;
|
||||
max-inline-size: var(--page-content-size);
|
||||
}
|
||||
|
||||
.blog-admin {
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
.admin-widget-user {
|
||||
flex-direction: row !important;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.admin-widget ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
flex: 1;
|
||||
min-block-size: 0;
|
||||
block-size: 100%;
|
||||
overflow: auto;
|
||||
max-block-size: 100%;
|
||||
margin-block: 0;
|
||||
gap: var(--default-padding);
|
||||
|
||||
li {
|
||||
@media screen and (min-width: 60rem) {
|
||||
min-block-size: 6rem;
|
||||
flex: 0 1 6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-widget-user {
|
||||
padding: var(--default-padding);
|
||||
flex-direction: column;
|
||||
gap: var(--default-padding);
|
||||
|
||||
&>* {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
#user-info {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
transition: opacity 0.3s ease;
|
||||
transform: translate(calc(var(--default-padding) * -1), calc(var(--default-padding) * -1));
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
text-wrap: nowrap;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
background: var(--color-bg);
|
||||
|
||||
.close {
|
||||
aspect-ratio: 1;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&:target {
|
||||
z-index: 100;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
form {
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.user-info-form {
|
||||
display: contents;
|
||||
|
||||
&>* {
|
||||
padding-block: 0;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&>*:not(#user-info) {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:has(#user-info:target) {
|
||||
&>*:not(#user-info) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-widget:not(.admin-widget-user) {
|
||||
min-block-size: 0;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.blog-admin {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: var(--default-padding);
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
min-block-size: 0;
|
||||
flex: 1;
|
||||
justify-content: stretch;
|
||||
|
||||
ul {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
flex-direction: column;
|
||||
gap: var(--default-padding);
|
||||
box-sizing: border-box;
|
||||
min-block-size: 0;
|
||||
flex: 0 1 100%;
|
||||
|
||||
|
||||
.empty-message {
|
||||
@media screen and (min-width: 60rem) {
|
||||
margin-block-start: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.form-action {
|
||||
transition: all 0.3s ease;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
border-block-start: var(--border);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
gap: var(--default-padding);
|
||||
box-sizing: content-box;
|
||||
justify-content: end;
|
||||
max-block-size: 0;
|
||||
padding: 0 var(--default-padding);
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
&:has(:checked) {
|
||||
.form-actions {
|
||||
border-block: var(--border);
|
||||
transition: all 0.3s ease;
|
||||
max-block-size: calc(var(--default-padding) * 2);
|
||||
padding-block: var(--default-padding);
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
& {
|
||||
max-block-size: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.form-action {
|
||||
transition: all 0.3s ease;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
min-inline-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.admin-widget {
|
||||
position: relative;
|
||||
min-block-size: 0;
|
||||
border: var(--border);
|
||||
display: flex;
|
||||
|
||||
&>*:not(form, a) {
|
||||
padding-inline: var(--default-padding);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
display: flex;
|
||||
min-block-size: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
li {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(2 * var(--default-padding)) var(--default-padding);
|
||||
border-inline-start: 0px solid black;
|
||||
}
|
||||
|
||||
.widget-entry {
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:has(.entry-content:hover),
|
||||
&:has(.entry-content:focus) {
|
||||
border-inline-start-width: 3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.entry-content {
|
||||
&>* {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a.entry-content {
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
inline-size: 90%;
|
||||
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: translateX(1ch);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.blog-roll-entry {
|
||||
margin-block: 1rem;
|
||||
|
||||
a {
|
||||
border: var(--border);
|
||||
background: var(--color-bg-accent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
outline: none;
|
||||
|
||||
--content-padding: 1rem;
|
||||
|
||||
.entry-content {
|
||||
padding: var(--content-padding);
|
||||
transition: padding-inline-start 0.3s ease, box-shadow 0.3s ease;
|
||||
|
||||
* {
|
||||
margin-block: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
.entry-content {
|
||||
padding-inline-start: calc(var(--content-padding) * 2);
|
||||
box-shadow: inset 3px 0px 0px var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
border-inline: none;
|
||||
border-block-start: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
main {
|
||||
@media screen and (min-width: 60rem) {
|
||||
min-block-size: calc(100svb - 8rem
|
||||
/*the most magic number of all...*/
|
||||
) !important;
|
||||
}
|
||||
|
||||
margin-block-end: 0;
|
||||
|
||||
border: solid 1px var(--color-text);
|
||||
padding: 2rem;
|
||||
min-block-size: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
h1 {
|
||||
font-size: calc(var(--font-size) * 3.33);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(var(--font-size) * 2.66);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(var(--font-size) * 2);
|
||||
}
|
||||
|
||||
:is(h4, h5, h6) {
|
||||
font-size: calc(var(--font-size) * 1.33);
|
||||
}
|
||||
|
||||
:is(h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6) {
|
||||
margin-block: 2rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-block:not(:first-child) {
|
||||
|
||||
*:is(h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6) {
|
||||
margin-block: 0rem 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-header {
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
font-family: var(--font-family-display) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-delimiter {
|
||||
width: 100%;
|
||||
margin-block: 1rem;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.cdx-input {
|
||||
border-radius: 0 !important;
|
||||
border: var(--border) !important;
|
||||
background-color: var(--color-bg) !important;
|
||||
}
|
||||
|
||||
.ce-delimiter::before {
|
||||
display: block;
|
||||
content: "" !important;
|
||||
height: 1px !important;
|
||||
inline-size: 100%;
|
||||
position: absolute;
|
||||
background: var(--color-text);
|
||||
color: green
|
||||
}
|
||||
|
||||
.image-tool__image_preloader::after {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
height: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
@media all and (max-width: 650px) {
|
||||
.ce-toolbar__actions {
|
||||
|
||||
&>*:nth-child(1),
|
||||
&>*:nth-child(2) {
|
||||
border-radius: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cdx-warning {
|
||||
&::before {
|
||||
display: none !important;
|
||||
inline-size: 0 !important;
|
||||
}
|
||||
|
||||
.cdx-warning__message {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cdx-warning__title {
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cdx-input[data-placeholder]::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.cdx-input {
|
||||
box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
font-size: calc(var(--font-size) * 1.3);
|
||||
border: 1px solid var(--color-text);
|
||||
border-inline-start: 4px solid var(--color-text);
|
||||
padding: 1rem 2rem !important;
|
||||
max-inline-size: 100%;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.codex-editor__redactor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--default-padding);
|
||||
}
|
||||
|
||||
.ce-block__content {
|
||||
word-wrap: break-word;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.ce-block {
|
||||
margin-block: 0;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.cdx-block {
|
||||
margin-block: 0;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.codex-editor--narrow {
|
||||
/* margin-inline-end: -50px !important; */
|
||||
}
|
||||
|
||||
.codex-editor {
|
||||
&>*:first-child {
|
||||
|
||||
& h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ce-popover--opened>.ce-popover__container {
|
||||
border: solid 1px var(--color-text) !important;
|
||||
border-radius: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ce-paragraph {
|
||||
font-size: calc(var(--font-size) * 1.3);
|
||||
/* margin-block: calc(var(--font-size) * 2.6); */
|
||||
line-height: calc(var(--font-size) * 2);
|
||||
}
|
||||
|
||||
.cdx-block {
|
||||
&>span {
|
||||
background-color: var(--color-selection) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-block--selected .ce-block__content {
|
||||
background-color: var(--color-selection) !important;
|
||||
}
|
||||
|
||||
.embed-tool__caption {
|
||||
display: none;
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 50rem) {
|
||||
body {
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
body>div {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
all: unset;
|
||||
max-block-size: 100%;
|
||||
min-block-size: 25%;
|
||||
min-inline-size: 50%;
|
||||
max-inline-size: 100%;
|
||||
background: url("/assets/images/login.jpg");
|
||||
filter: invert();
|
||||
background-size: cover;
|
||||
mask-size: 100%;
|
||||
mask-image: linear-gradient(rgb(0 0 0 / 100%), transparent);
|
||||
|
||||
@media screen and (min-width: 50rem) {
|
||||
mask-image: linear-gradient(90deg, rgb(0 0 0 / 100%), transparent);
|
||||
}
|
||||
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* block-size: 100%; */
|
||||
max-block-size: 100%;
|
||||
|
||||
&>:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: var(--border);
|
||||
padding: 1rem;
|
||||
align-items: center;
|
||||
|
||||
h1 {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label:has(input:not([type="checkbox"])) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
padding: 0.5ch;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-inline-size: 40ch;
|
||||
gap: 1rem;
|
||||
}
|
|
@ -0,0 +1,432 @@
|
|||
@font-face {
|
||||
src: url("/assets/fonts/Redaction-Regular.woff2");
|
||||
font-family: Redaction;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
src: url("/assets/fonts/Atkinson-Hyperlegible-Regular.woff2");
|
||||
font-family: Atkinson-Hyperlegible;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg: #faf9f6;
|
||||
--color-bg-accent: #fffefb;
|
||||
--color-text: #000000;
|
||||
--color-accent: oklch(70.92% 0.1619 310);
|
||||
--color-selection: color-mix(in srgb, var(--color-bg) 85%, var(--color-accent) 15%);
|
||||
--font-family-display: Redaction;
|
||||
--font-family-text: Atkinson-Hyperlegible;
|
||||
--border: 1px solid var(--color-text);
|
||||
|
||||
--default-padding: 1rem;
|
||||
|
||||
--transition-timing: 0.3s ease;
|
||||
|
||||
--header-size: 6rem;
|
||||
|
||||
--font-size: 1rem;
|
||||
font-size: var(--font-size);
|
||||
line-height: 1.15;
|
||||
/* 1. Correct the line height in all browsers. */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
/* 2. Prevent adjustments of font size after orientation changes in iOS. */
|
||||
tab-size: 4;
|
||||
/* 3. Use a more readable tab size (opinionated). */
|
||||
color: var(--color-text);
|
||||
|
||||
scroll-behavior: smooth;
|
||||
|
||||
*::selection {
|
||||
background-color: var(--color-selection);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-family-text);
|
||||
}
|
||||
|
||||
p,
|
||||
li,
|
||||
button,
|
||||
.button,
|
||||
input,
|
||||
label,
|
||||
a,
|
||||
blockquote,
|
||||
aside,
|
||||
ol,
|
||||
ul {
|
||||
font-size: calc(var(--font-size) * 1.33);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-family-display);
|
||||
}
|
||||
|
||||
aside,
|
||||
blockquote {
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
blockquote,
|
||||
aside {
|
||||
padding: var(--default-padding) calc(var(--default-padding) * 2);
|
||||
max-inline-size: 100%;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
&::before {
|
||||
content: '“';
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '”';
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
appearance: none;
|
||||
list-style: none;
|
||||
padding-inline: unset;
|
||||
}
|
||||
|
||||
a {
|
||||
color: unset;
|
||||
text-decoration: unset;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--color-bg-accent);
|
||||
outline: none;
|
||||
border: var(--border);
|
||||
transition: box-shadow var(--transition-timing);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input:is([type="text"], [type="password"], [type="search"]) {
|
||||
padding: 0.5ch 1ch;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
box-shadow: inset 3px 0px 0px var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
min-inline-size: 1ch;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
border: 0px solid var(--color-bg);
|
||||
outline: 0px solid var(--color-text);
|
||||
|
||||
&:not(:checked)::before {
|
||||
outline: 3px solid var(--color-text);
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
outline: 3px solid var(--color-text);
|
||||
border: 3px solid var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
background: var(--color-bg);
|
||||
outline: 1px solid var(--color-text);
|
||||
box-shadow: inset 0px 0px 0px var(--color-text), inset 0px 0px 0px var(--color-text);
|
||||
}
|
||||
|
||||
&,
|
||||
&::before {
|
||||
transition: box-shadow var(--transition-timing), background var(--transition-timing), outline var(--transition-timing);
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
box-shadow: inset 1px 1px 0px var(--color-text), inset -1px -1px 0px var(--color-text);
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
background: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
appearance: unset;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
border: var(--border);
|
||||
transition: box-shadow var(--transition-timing);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: 0px 2px 0px var(--color-text);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow:
|
||||
0px 1px 0px var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
|
||||
.animated-link-underline {
|
||||
background-size: 100% 1px;
|
||||
}
|
||||
|
||||
.animated-link,
|
||||
.animated-link-underline {
|
||||
text-decoration: unset;
|
||||
background-size: 100% 0%;
|
||||
background-position: left bottom;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
transition: background-size var(--transition-timing), background-image var(--transition-timing), color var(--transition-timing);
|
||||
background-image: linear-gradient(var(--color-text), var(--color-text));
|
||||
|
||||
color: var(--color-text);
|
||||
|
||||
* {
|
||||
transition: color var(--transition-timing);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
color: var(--color-bg);
|
||||
|
||||
* {
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
box-sizing: content-box;
|
||||
flex-direction: column;
|
||||
border-block-end: var(--border);
|
||||
max-inline-size: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: var(--default-padding);
|
||||
position: sticky;
|
||||
z-index: 100;
|
||||
top: 0;
|
||||
background: var(--color-bg);
|
||||
|
||||
&>:first-child {
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-block: 0.25ch;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
max-inline-size: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
a {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-family-display);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
gap: var(--default-padding);
|
||||
|
||||
font-size: calc(var(--font-size) * 1.8);
|
||||
}
|
||||
|
||||
|
||||
form {
|
||||
min-inline-size: min(100%, 10ch);
|
||||
flex: 0;
|
||||
align-items: center;
|
||||
border: var(--border);
|
||||
background: var(--color-bg-accent);
|
||||
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
|
||||
padding-inline-end: 0.25rem;
|
||||
}
|
||||
|
||||
input {
|
||||
min-inline-size: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
transition: flex var(--transition-timing),
|
||||
padding-inline var(--transition-timing);
|
||||
|
||||
input[type="search"]::-webkit-search-decoration,
|
||||
input[type="search"]::-webkit-search-cancel-button,
|
||||
input[type="search"]::-webkit-search-results-button,
|
||||
input[type="search"]::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&>* {
|
||||
box-sizing: content-box;
|
||||
background: transparent;
|
||||
margin-inline: 0;
|
||||
margin-block: 0;
|
||||
padding-block: 0;
|
||||
block-size: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
main {
|
||||
margin-block: var(--default-padding) 40svb;
|
||||
margin-inline: auto;
|
||||
max-inline-size: min(60ch, 80%);
|
||||
padding-block: var(--default-padding);
|
||||
gap: var(--default-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&>:first-child {
|
||||
margin-block-start: 0;
|
||||
|
||||
&:is(a) {
|
||||
&+* {
|
||||
margin-block-start: 0rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&>* {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
&>* {
|
||||
|
||||
&:is(h1) {
|
||||
font-size: calc(var(--font-size) * 3.33);
|
||||
}
|
||||
|
||||
&:is(h2) {
|
||||
font-size: calc(var(--font-size) * 2.66);
|
||||
}
|
||||
|
||||
&:is(h3) {
|
||||
font-size: calc(var(--font-size) * 2);
|
||||
}
|
||||
|
||||
&:is(h4, h5, h6) {
|
||||
font-size: calc(var(--font-size) * 1.33);
|
||||
}
|
||||
|
||||
&:is(h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6) {
|
||||
margin-block: 2rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
min-inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
border: var(--border);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* anything wider than mobile */
|
||||
@media all and (min-width: 60rem) {
|
||||
header {
|
||||
box-sizing: border-box;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
nav {
|
||||
justify-content: flex-end;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
margin-block: var(--default-padding);
|
||||
}
|
||||
}
|
||||
|
||||
/* tablet */
|
||||
@media all and (min-width: 60rem) and (max-width: 80rem) {}
|
||||
|
||||
/* desktop */
|
||||
@media all and (min-width: 80rem) {}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
After Width: | Height: | Size: 116 KiB |
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,745 @@
|
|||
use std::{env, vec};
|
||||
|
||||
use blogdb::posts::Post;
|
||||
use blogdb::BlogDb;
|
||||
use lol_html::html_content::ContentType;
|
||||
use lol_html::{comments, element, rewrite_str, RewriteStrSettings};
|
||||
|
||||
use crate::routes::api::editor::Blocks;
|
||||
use crate::routes::SearchResponse;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
|
||||
|
||||
/// Gets template html string from `src/templates`
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// // `src/templates/blog-roll.html`
|
||||
/// //
|
||||
/// // <h1>Blog</h1>
|
||||
/// // <ul></ul>
|
||||
///
|
||||
/// let template = template!("blog-roll");
|
||||
/// assert_eq!(template, "<h1>Blog</h1>\n<ul></ul>");
|
||||
/// ```
|
||||
macro_rules! template {
|
||||
($t:tt) => {
|
||||
TEMPLATES.get($t).unwrap_or(&"")
|
||||
};
|
||||
}
|
||||
|
||||
/// Makes a page with default settings and content inserted into <main></main>
|
||||
/// (or replacing <main></main> if content has a <main> tag)
|
||||
pub(crate) fn make_page<S>(content: S, settings: PageSettings) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
rewrite_str(
|
||||
template!("default"),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![
|
||||
element!("head", |head| {
|
||||
head.prepend(
|
||||
&["<title>", &settings.title, "</title>"].concat(),
|
||||
ContentType::Html,
|
||||
);
|
||||
if let Some(stylesheets) = &settings.stylesheets {
|
||||
for url in stylesheets {
|
||||
head.append(
|
||||
&[r#"<link rel="stylesheet" href=""#, url, r#"" />"#].concat(),
|
||||
ContentType::Html,
|
||||
)
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
element!("body", |body| {
|
||||
body.prepend("<script>0</script>", ContentType::Html);
|
||||
Ok(())
|
||||
}),
|
||||
element!("main", |main| {
|
||||
if content.as_ref().contains("<main>") {
|
||||
main.replace(content.as_ref(), ContentType::Html);
|
||||
} else {
|
||||
main.set_inner_content(content.as_ref(), ContentType::Html);
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
element!("site-header", |site_header| {
|
||||
if settings.site_header {
|
||||
site_header.replace(template!("site-header"), ContentType::Html);
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
element!("br", |br| {
|
||||
br.remove();
|
||||
Ok(())
|
||||
}),
|
||||
comments!("*", |comments| {
|
||||
comments.remove();
|
||||
Ok(())
|
||||
}),
|
||||
],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn rewrite_el<S, T, R>(input: S, name: T, with: R) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
R: AsRef<str>,
|
||||
{
|
||||
rewrite_str(
|
||||
input.as_ref(),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!(name.as_ref(), |el| {
|
||||
el.replace(with.as_ref(), ContentType::Html);
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn remove_el<S, T>(input: S, name: T) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
rewrite_str(
|
||||
input.as_ref(),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!(name.as_ref(), |el| {
|
||||
el.remove();
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn remove_comments<S: AsRef<str>>(input: S) -> String {
|
||||
rewrite_str(
|
||||
input.as_ref(),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![comments!("*", |co| {
|
||||
co.remove();
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn homepage_editor<S: AsRef<str>>(homepage_content: S) -> String {
|
||||
editor(homepage_content, "/api/editor/update/home")
|
||||
}
|
||||
|
||||
pub(crate) fn about_editor<S: AsRef<str>>(about_content: S) -> String {
|
||||
editor(about_content, "/api/editor/update/about")
|
||||
}
|
||||
|
||||
pub(crate) fn draft_editor<S, T>(post_content: S, post_id: T) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
editor(
|
||||
post_content,
|
||||
["/api/editor/update/", post_id.as_ref()].concat(),
|
||||
)
|
||||
}
|
||||
|
||||
fn editor<S, T>(content: S, endpoint: T) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
let content = content.as_ref();
|
||||
let data = if !content.is_empty() && content.starts_with("[") && content.ends_with("]") {
|
||||
content
|
||||
} else {
|
||||
"[]"
|
||||
};
|
||||
let settings = [
|
||||
"const data={blocks:",
|
||||
data,
|
||||
"};const onChange=()=>{editor.save().then(async(savedData)=>{const endpoint = \"",endpoint.as_ref(),"\"; const response = await fetch(endpoint, { method: \"POST\", mode: \"same-origin\", headers: {\"Content-Type\": \"application/json\"}, body: JSON.stringify(savedData.blocks) });})};"
|
||||
].concat();
|
||||
let html = rewrite_str(
|
||||
template!("post-editor"),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("#editor", move |script| {
|
||||
script.prepend(&settings, ContentType::Html);
|
||||
Ok(())
|
||||
})],
|
||||
strict: false,
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
make_page(
|
||||
html,
|
||||
PageSettings::new("Editor", Some(vec!["/assets/css/editor.css"]), true),
|
||||
)
|
||||
}
|
||||
|
||||
/// Rewrites `<login-status></login-status>` with html from template, and sets the `next` hidden
|
||||
/// input element to the next location
|
||||
pub(crate) fn login_status(next: &str, success: bool) -> String {
|
||||
let status = if success {
|
||||
""
|
||||
} else {
|
||||
template!("login-status")
|
||||
};
|
||||
|
||||
let next = next.strip_prefix("/").unwrap_or(next);
|
||||
|
||||
let html = rewrite_el(template!("login"), "login-status", status);
|
||||
|
||||
let html = rewrite_str(
|
||||
&html,
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!(r#"input[name="next"]"#, |input| {
|
||||
input.set_attribute("value", next).unwrap_or_default();
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
let html = rewrite_str(
|
||||
&html,
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!(r#"input[name="next"]"#, |input| {
|
||||
input.set_attribute("value", next).unwrap_or_default();
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
make_page(
|
||||
html,
|
||||
PageSettings::new("Login", Some(vec!["/assets/css/login.css"]), false),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_page(session_id: i64, db: &BlogDb) -> String {
|
||||
let content = admin_widgets(template!("admin"), session_id, db).await;
|
||||
make_page(
|
||||
&content,
|
||||
PageSettings::new("Admin", Some(vec!["/assets/css/admin.css"]), true),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_widgets(input: &str, session_id: i64, db: &BlogDb) -> String {
|
||||
let posts_html = admin_entries(EntryType::Post, session_id, db).await;
|
||||
let drafts_html = admin_entries(EntryType::Draft, session_id, db).await;
|
||||
let user_html = template!("admin-widgets/user");
|
||||
|
||||
rewrite_str(
|
||||
input,
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("admin-widget", move |a| {
|
||||
match a.get_attribute("type") {
|
||||
Some(attr) => {
|
||||
match attr.as_str() {
|
||||
"drafts" => a.replace(&drafts_html, ContentType::Html),
|
||||
"posts" => a.replace(&posts_html, ContentType::Html),
|
||||
"user" => a.replace(user_html, ContentType::Html),
|
||||
_ => a.remove(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => todo!(),
|
||||
}
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
enum EntryType {
|
||||
Post,
|
||||
Draft,
|
||||
}
|
||||
|
||||
async fn admin_entries(entry_type: EntryType, session_id: i64, db: &BlogDb) -> String {
|
||||
let (list_template, entry_template) = match entry_type {
|
||||
EntryType::Post => (
|
||||
template!("admin-widgets/posts"),
|
||||
template!("admin-widgets/post"),
|
||||
),
|
||||
EntryType::Draft => (
|
||||
template!("admin-widgets/drafts"),
|
||||
template!("admin-widgets/draft"),
|
||||
),
|
||||
};
|
||||
let is_empty: bool;
|
||||
let entries_html = match entry_type {
|
||||
EntryType::Post => {
|
||||
let mut entries = db.get_posts().await.unwrap_or(vec![]);
|
||||
entries.reverse();
|
||||
let mut entry_list_html = String::new();
|
||||
is_empty = entries.is_empty();
|
||||
if !is_empty {
|
||||
for entry in entries {
|
||||
let href = ["/blog/", &entry.id].concat();
|
||||
let mut entry_html = admin_entry(
|
||||
entry_template,
|
||||
&entry.id,
|
||||
&entry.title,
|
||||
&href,
|
||||
&entry.content,
|
||||
);
|
||||
entry_html = rewrite_str(
|
||||
&entry_html,
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("time", |time| {
|
||||
time.set_inner_content(&entry.date, ContentType::Text);
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
entry_list_html.push_str(&entry_html);
|
||||
}
|
||||
}
|
||||
entry_list_html
|
||||
}
|
||||
EntryType::Draft => {
|
||||
let mut entries = db.get_drafts(session_id).await.unwrap_or(vec![]);
|
||||
entries.reverse();
|
||||
let mut entry_list_html = String::new();
|
||||
is_empty = entries.is_empty();
|
||||
if !is_empty {
|
||||
for entry in entries {
|
||||
let href = ["/editor/", &entry.id].concat();
|
||||
let entry_html = admin_entry(
|
||||
entry_template,
|
||||
&entry.id,
|
||||
&entry.title,
|
||||
&href,
|
||||
&entry.content,
|
||||
);
|
||||
entry_list_html.push_str(&entry_html);
|
||||
}
|
||||
}
|
||||
entry_list_html
|
||||
}
|
||||
};
|
||||
let final_html = rewrite_str(
|
||||
list_template,
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("ul", |ul| {
|
||||
if is_empty {
|
||||
match entry_type {
|
||||
EntryType::Post => ul.replace("<h2 class=\"empty-message\">No Published Posts</h2>", ContentType::Html),
|
||||
EntryType::Draft => ul.replace("<div class=\"entry-content empty-message\"><h2>No Drafts</h2><p>Start a <a href=\"/editor/new\" class=\"button\">New Draft</a></p></div>", ContentType::Html)
|
||||
}
|
||||
} else {
|
||||
ul.set_inner_content(&entries_html, ContentType::Html);
|
||||
}
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
final_html
|
||||
}
|
||||
|
||||
fn admin_entry(entry_template: &str, id: &str, title: &str, href: &str, content: &str) -> String {
|
||||
rewrite_str(
|
||||
entry_template,
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![
|
||||
element!("a.entry-content", |a| {
|
||||
a.set_attribute("href", href).unwrap_or_default();
|
||||
Ok(())
|
||||
}),
|
||||
element!("h2", |h2| {
|
||||
h2.set_inner_content(title, ContentType::Html);
|
||||
Ok(())
|
||||
}),
|
||||
element!("p", |p| {
|
||||
let post_content: Blocks = serde_json::from_str(content).unwrap_or_default();
|
||||
let content = rewrite_str(
|
||||
&post_content.desc(),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("*", |all| {
|
||||
all.remove_and_keep_content();
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
p.set_inner_content(&content, ContentType::Text);
|
||||
Ok(())
|
||||
}),
|
||||
element!("input", |input| {
|
||||
input.set_attribute("value", id).unwrap_or_default();
|
||||
Ok(())
|
||||
}),
|
||||
],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn about_page(db: &BlogDb) -> String {
|
||||
let about = db.get_about().await;
|
||||
let about_content: Blocks = serde_json::from_str(&about).unwrap_or_default();
|
||||
let about_html = remove_comments(about_content.to_html());
|
||||
make_page(&about_html, PageSettings::title("About"))
|
||||
}
|
||||
|
||||
pub(crate) async fn home_page(db: &BlogDb) -> String {
|
||||
let home = db.get_homepage().await;
|
||||
let home_content: Blocks = serde_json::from_str(&home).unwrap_or_default();
|
||||
let home_html = home_content.to_html();
|
||||
make_page(&home_html, PageSettings::title("Evie Ippolito"))
|
||||
}
|
||||
|
||||
fn meta<S, T, R, I>(slug: S, title: T, description: R, image: I) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
R: AsRef<str>,
|
||||
I: AsRef<str>,
|
||||
{
|
||||
rewrite_str(
|
||||
template!("head/meta"),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("meta", |meta| {
|
||||
if let Some(attr) = meta.get_attribute("property") {
|
||||
let content = match attr.as_str() {
|
||||
"og:url" => &[env!("DOMAIN"), slug.as_ref()].concat(),
|
||||
"og:type" => "article",
|
||||
"og:title" => title.as_ref(),
|
||||
"og:description" => description.as_ref(),
|
||||
"og:image" => {
|
||||
let image = image.as_ref();
|
||||
if !image.is_empty() {
|
||||
image
|
||||
} else {
|
||||
meta.remove();
|
||||
""
|
||||
}
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
let _ = meta.set_attribute("content", content);
|
||||
}
|
||||
Ok(())
|
||||
})],
|
||||
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Creates a blog page from a post's content
|
||||
pub(crate) async fn blog_page<S>(id: S, db: &BlogDb) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let template = template!("blog-page");
|
||||
let html = make_page(template, PageSettings::title("Blog"));
|
||||
|
||||
let post = db.get_post(id).await.unwrap_or_default();
|
||||
let head = head::<_, _, [String; 0]>(post.title, []);
|
||||
let post_content: Blocks = serde_json::from_str(&post.content).unwrap_or_default();
|
||||
let post_html: String = post_content.to_html();
|
||||
let image = if let Some(url) = post_content.image() {
|
||||
&[url].concat()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let meta = meta(
|
||||
["/blog/", &post.id].concat(),
|
||||
post_content.title(),
|
||||
post_content.desc(),
|
||||
image,
|
||||
);
|
||||
|
||||
let html = rewrite_str(
|
||||
&html,
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("time", |time| {
|
||||
time.after(&post_html, ContentType::Html);
|
||||
time.replace(
|
||||
&[
|
||||
r#"<a class="animated-link" style="align-self: flex-start; margin-block-end: 0" href="https://"#,
|
||||
env!("DOMAIN"),
|
||||
"/blog/",
|
||||
&post.id,
|
||||
r#""><time>"#,
|
||||
&post.date,
|
||||
"</time></a>",
|
||||
]
|
||||
.concat(),
|
||||
ContentType::Html,
|
||||
);
|
||||
Ok(())
|
||||
})],
|
||||
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
html
|
||||
}
|
||||
|
||||
pub(crate) async fn blog_roll(db: &BlogDb) -> String {
|
||||
let mut posts = match db.get_posts().await {
|
||||
Ok(posts) => posts,
|
||||
Err(_) => return "".to_string(),
|
||||
};
|
||||
|
||||
posts.reverse();
|
||||
|
||||
let mut post_entries = String::new();
|
||||
|
||||
for post in posts.iter() {
|
||||
let post_entry = post_entry(post);
|
||||
post_entries.push_str(&post_entry);
|
||||
}
|
||||
|
||||
let blog_roll_html = rewrite_str(
|
||||
template!("blog-roll"),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("ul", move |ul| {
|
||||
ul.set_inner_content(&post_entries, ContentType::Html);
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
|
||||
make_page(
|
||||
blog_roll_html,
|
||||
PageSettings::new("Blog", Some(vec!["/assets/css/blog.css"]), true),
|
||||
)
|
||||
}
|
||||
|
||||
fn post_entry(post: &Post) -> String {
|
||||
rewrite_str(
|
||||
template!("blog-roll-entry"),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![
|
||||
element!("a", |a| {
|
||||
a.set_attribute("href", &["/blog/", &post.id].concat())
|
||||
.unwrap_or_default();
|
||||
Ok(())
|
||||
}),
|
||||
element!("img", |img| {
|
||||
let post_content: Blocks =
|
||||
serde_json::from_str(&post.content).unwrap_or_default();
|
||||
match post_content.image() {
|
||||
Some(src) => {
|
||||
let _ = img.set_attribute("src", src);
|
||||
}
|
||||
None => img.remove(),
|
||||
}
|
||||
Ok(())
|
||||
}),
|
||||
element!("h2", |h2| {
|
||||
h2.set_inner_content(&post.title, ContentType::Text);
|
||||
Ok(())
|
||||
}),
|
||||
element!("time", |time| {
|
||||
time.set_inner_content(&post.date, ContentType::Text);
|
||||
Ok(())
|
||||
}),
|
||||
element!("p", |p| {
|
||||
let post_content: Blocks =
|
||||
serde_json::from_str(&post.content).unwrap_or_default();
|
||||
let content = remove_el(post_content.desc(), "br");
|
||||
p.set_inner_content(&content, ContentType::Html);
|
||||
Ok(())
|
||||
}),
|
||||
],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn search_page_no_query() -> String {
|
||||
let search_page_html = make_page(template!("search-page"), PageSettings::title("Search"));
|
||||
rewrite_str(
|
||||
&search_page_html,
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("ul", move |ul| {
|
||||
ul.remove();
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) async fn search_page(
|
||||
query: String,
|
||||
search_response: SearchResponse,
|
||||
db: &BlogDb,
|
||||
) -> String {
|
||||
let mut entries = String::new();
|
||||
let search_page_html = if !search_response.is_empty() {
|
||||
for entry in search_response {
|
||||
let post = match db.get_post(&entry.id[0]).await {
|
||||
Ok(post) => post,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let entry_html = post_entry(&post);
|
||||
entries.push_str(&entry_html);
|
||||
}
|
||||
[
|
||||
"<h1>Results for “",
|
||||
&query,
|
||||
"”</h1>",
|
||||
"<ul>",
|
||||
&entries,
|
||||
"</ul>",
|
||||
]
|
||||
.concat()
|
||||
} else {
|
||||
["<h1>No results for “", &query, "”</h1>"].concat()
|
||||
};
|
||||
make_page(
|
||||
&search_page_html,
|
||||
PageSettings::new(
|
||||
["Results for “", &query, "”"].concat(),
|
||||
Some(vec!["/assets/css/blog.css"]),
|
||||
true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a <link rel="stylesheet" href="style_url"/> for each given style_url
|
||||
fn stylesheets<S, I>(style_urls: I) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
I: IntoIterator<Item = S>,
|
||||
{
|
||||
style_urls
|
||||
.into_iter()
|
||||
.map(|url| [r#"<link href="#, url.as_ref(), r#" rel="stylesheet"/>"#].concat())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
fn head<S, T, I>(title: S, style_urls: I) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
T: AsRef<str>,
|
||||
I: IntoIterator<Item = T>,
|
||||
{
|
||||
let content = rewrite_str(
|
||||
template!("head/content"),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("title", move |t| {
|
||||
t.set_inner_content(title.as_ref(), ContentType::Html);
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let links_html = stylesheets(style_urls);
|
||||
[content, links_html].concat()
|
||||
}
|
||||
|
||||
pub(crate) struct PageSettings {
|
||||
title: String,
|
||||
stylesheets: Option<Vec<String>>,
|
||||
site_header: bool,
|
||||
}
|
||||
|
||||
impl PageSettings {
|
||||
pub(crate) fn title<S>(title: S) -> Self
|
||||
where
|
||||
S: ToString,
|
||||
{
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
stylesheets: None,
|
||||
site_header: true,
|
||||
}
|
||||
}
|
||||
pub(crate) fn new<S, T>(title: S, stylesheets: Option<Vec<T>>, site_header: bool) -> Self
|
||||
where
|
||||
S: ToString,
|
||||
T: ToString,
|
||||
{
|
||||
let stylesheets =
|
||||
stylesheets.map(|s| s.iter().map(|t| t.to_string()).collect::<Vec<String>>());
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
stylesheets,
|
||||
site_header,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn make_404<S>(message: S) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
make_page(message, PageSettings::title("Not found"))
|
||||
}
|
||||
|
||||
pub(crate) fn animate_anchors<S>(input: S) -> String
|
||||
where
|
||||
S: AsRef<str>,
|
||||
{
|
||||
rewrite_str(
|
||||
input.as_ref(),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("a", move |t| {
|
||||
match t.get_attribute("class") {
|
||||
Some(class) => t
|
||||
.set_attribute("class", &[&class, "animated-link-underline"].concat())
|
||||
.unwrap_or_default(),
|
||||
None => {
|
||||
t.set_attribute("class", "animated-link-underline")
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize<S: AsRef<str>>(input: S) -> String {
|
||||
rewrite_str(
|
||||
input.as_ref(),
|
||||
RewriteStrSettings {
|
||||
element_content_handlers: vec![element!("*", move |all| {
|
||||
all.remove_and_keep_content();
|
||||
Ok(())
|
||||
})],
|
||||
..RewriteStrSettings::new()
|
||||
},
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
mod html;
|
||||
mod server;
|
||||
|
||||
use std::env;
|
||||
|
||||
use server::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let port: Option<u16> = match env::args().nth(1) {
|
||||
Some(value) => Some(value.parse()?),
|
||||
None => None,
|
||||
};
|
||||
Blog::new().await?.serve(port).await;
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use axum_extra::extract::PrivateCookieJar;
|
||||
|
||||
use crate::BlogState;
|
||||
|
||||
/// Middleware layer requiring user to log in to access route
|
||||
pub(crate) async fn logged_in(
|
||||
jar: PrivateCookieJar,
|
||||
State(state): State<BlogState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let next_uri = request.uri();
|
||||
let redirect_to_login = || -> Response {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
LOCATION,
|
||||
HeaderValue::from_str(&["/login", &next_uri.to_string()].concat()).unwrap(),
|
||||
);
|
||||
(StatusCode::FOUND, headers).into_response()
|
||||
};
|
||||
let db = state.db();
|
||||
let session_id = match jar.get("id") {
|
||||
Some(id) => match id.value_trimmed().parse::<i64>() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return redirect_to_login();
|
||||
}
|
||||
},
|
||||
None => {
|
||||
return redirect_to_login();
|
||||
}
|
||||
};
|
||||
if let Err(_error) = db.check_session(session_id).await {
|
||||
return redirect_to_login();
|
||||
}
|
||||
next.run(request).await
|
||||
}
|
|
@ -0,0 +1,347 @@
|
|||
mod middleware;
|
||||
pub(crate) mod routes;
|
||||
|
||||
use routes::{api::editor::Blocks, make_router};
|
||||
use tantivy::{
|
||||
directory::MmapDirectory,
|
||||
doc,
|
||||
schema::{Schema, STORED, STRING, TEXT},
|
||||
Index, IndexWriter, TantivyDocument, Term,
|
||||
};
|
||||
use tower::Layer;
|
||||
use tower_http::normalize_path::NormalizePathLayer;
|
||||
|
||||
use std::{
|
||||
env,
|
||||
fs::{self, File},
|
||||
io::{Read, Write},
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use axum::{
|
||||
extract::{FromRef, Request},
|
||||
Router, ServiceExt,
|
||||
};
|
||||
use axum_extra::extract::cookie::Key;
|
||||
use blogdb::BlogDb;
|
||||
use glob::glob;
|
||||
|
||||
use crate::html;
|
||||
|
||||
const DB_URL: &str = "sqlite://evie.db";
|
||||
|
||||
pub struct Blog {
|
||||
app: Router,
|
||||
}
|
||||
|
||||
impl Blog {
|
||||
pub async fn new() -> anyhow::Result<Self> {
|
||||
let state = BlogState::new().await?;
|
||||
let app = make_router(state).await;
|
||||
Ok(Self { app })
|
||||
}
|
||||
|
||||
pub async fn serve(self, port: Option<u16>) {
|
||||
let port = port.unwrap_or(3000);
|
||||
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.await
|
||||
.unwrap();
|
||||
axum::serve(
|
||||
listener,
|
||||
ServiceExt::<Request>::into_make_service(
|
||||
NormalizePathLayer::trim_trailing_slash().layer(self.app),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlogState(Arc<BlogStateInner>);
|
||||
|
||||
impl BlogState {
|
||||
async fn new() -> anyhow::Result<Self> {
|
||||
Ok(BlogState(Arc::new(BlogStateInner::new().await?)))
|
||||
}
|
||||
|
||||
fn db(&self) -> &BlogDb {
|
||||
&self.0.db
|
||||
}
|
||||
|
||||
fn domain(&self) -> String {
|
||||
self.0.domain.clone()
|
||||
}
|
||||
|
||||
fn index(&self) -> &Index {
|
||||
&self.0.index
|
||||
}
|
||||
|
||||
fn index_add_document(&self, document: TantivyDocument) {
|
||||
let _ = self.0.index_writer.lock().unwrap().add_document(document);
|
||||
}
|
||||
|
||||
fn index_delete(&self, term: Term) {
|
||||
let _ = self.0.index_writer.lock().unwrap().delete_term(term);
|
||||
}
|
||||
|
||||
fn index_commit(&self) {
|
||||
let _ = self.0.index_writer.lock().unwrap().commit();
|
||||
}
|
||||
}
|
||||
|
||||
// this impl tells `SignedCookieJar` how to access the key from our state
|
||||
impl FromRef<BlogState> for Key {
|
||||
fn from_ref(state: &BlogState) -> Self {
|
||||
state.0.key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct BlogStateInner {
|
||||
db: BlogDb,
|
||||
key: Key,
|
||||
domain: String,
|
||||
index: Index,
|
||||
index_writer: Mutex<IndexWriter>,
|
||||
}
|
||||
|
||||
impl BlogStateInner {
|
||||
async fn new() -> anyhow::Result<Self> {
|
||||
let username = env!("INIT_USER_NAME").to_string();
|
||||
let password = env!("INIT_USER_PASSWORD").to_string();
|
||||
let domain = env!("DOMAIN").to_string();
|
||||
let url_as_path = DB_URL.strip_prefix("sqlite://").unwrap_or_default();
|
||||
if File::open(url_as_path).is_err() {
|
||||
let _ = File::create_new(url_as_path);
|
||||
}
|
||||
let db = BlogDb::new(&username, password.clone(), DB_URL).await?;
|
||||
Self::add_initial_assets(&db).await?;
|
||||
Self::add_initial_pages(&db).await?;
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
Self::add_posts(&db).await?;
|
||||
Self::add_drafts(&username, password, &db).await?;
|
||||
}
|
||||
let key = Self::key()?;
|
||||
let (index, index_writer) = Self::generate_index(&db).await?;
|
||||
Ok(Self {
|
||||
db,
|
||||
key,
|
||||
domain,
|
||||
index,
|
||||
index_writer: Mutex::new(index_writer),
|
||||
})
|
||||
}
|
||||
|
||||
async fn add_initial_pages(db: &BlogDb) -> anyhow::Result<()> {
|
||||
let pages = vec![
|
||||
("index".to_string(), html::home_page(db).await),
|
||||
("about".to_string(), html::about_page(db).await),
|
||||
];
|
||||
db.add_pages(pages).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const ASSETS_DIR: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/client/assets");
|
||||
|
||||
async fn add_initial_assets(db: &BlogDb) -> anyhow::Result<()> {
|
||||
let pattern = [Self::ASSETS_DIR, "/*/*"].concat();
|
||||
let paths = glob(&pattern).unwrap();
|
||||
let mut assets = vec![];
|
||||
for path in paths.into_iter() {
|
||||
match path {
|
||||
Ok(path) => {
|
||||
let slug_path = path.clone();
|
||||
let slug: String = slug_path
|
||||
.strip_prefix(Self::ASSETS_DIR)?
|
||||
.as_os_str()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.into(); // eek
|
||||
let mut file = File::open(path.clone())?;
|
||||
let mut data = vec![];
|
||||
file.read_to_end(&mut data).unwrap();
|
||||
assets.push((slug, data));
|
||||
}
|
||||
Err(_) => todo!(),
|
||||
}
|
||||
}
|
||||
db.add_assets(assets).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const INDEX_PATH: &'static str = concat!(env!("CARGO_MANIFEST_DIR"), "/index");
|
||||
const MEMORY_BUDGET: usize = 50_000_000;
|
||||
|
||||
async fn generate_index(db: &BlogDb) -> anyhow::Result<(Index, IndexWriter)> {
|
||||
let mut schema_builder = Schema::builder();
|
||||
let id = schema_builder.add_text_field("id", STRING | STORED);
|
||||
let title = schema_builder.add_text_field("title", TEXT | STORED);
|
||||
let body = schema_builder.add_text_field("body", TEXT | STORED);
|
||||
let schema = schema_builder.build();
|
||||
let _ = fs::remove_dir_all(Self::INDEX_PATH);
|
||||
let _ = fs::create_dir_all(Self::INDEX_PATH);
|
||||
let dir = MmapDirectory::open(Self::INDEX_PATH).unwrap();
|
||||
let index = Index::open_or_create(dir, schema.clone())?;
|
||||
let mut index_writer: IndexWriter = index.writer(Self::MEMORY_BUDGET)?;
|
||||
let posts = db.get_posts().await?;
|
||||
for post in posts.into_iter() {
|
||||
let content: Blocks = serde_json::from_str(&post.content).unwrap_or_default();
|
||||
index_writer.add_document(
|
||||
doc!(id => post.id, title => post.title, body => content.to_plaintext()),
|
||||
)?;
|
||||
}
|
||||
index_writer.commit()?;
|
||||
Ok((index, index_writer))
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
fn key() -> anyhow::Result<Key> {
|
||||
let key = Key::try_generate().ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn key() -> anyhow::Result<Key> {
|
||||
let key_path = PathBuf::from_str("./key")?;
|
||||
let key_file = File::options().read(true).write(true).open(&key_path);
|
||||
let key = match key_file {
|
||||
Ok(mut file) => {
|
||||
let mut bytes = vec![];
|
||||
file.read_to_end(&mut bytes)?;
|
||||
match Key::try_from(bytes.as_slice()) {
|
||||
Ok(key) => key,
|
||||
Err(error) => {
|
||||
println!("need to make a new key, couldn't get from file cause {error:?}");
|
||||
let key = Key::try_generate()
|
||||
.ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
|
||||
file.write_all(key.master())?;
|
||||
println!("made one");
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
println!("need to make a new key, couldn't open the key file cause {error:?}");
|
||||
let key = Key::try_generate()
|
||||
.ok_or(anyhow!("Couldn't generate key for cookie signing"))?;
|
||||
|
||||
let mut key_file = File::create(&key_path)?;
|
||||
key_file.write_all(key.master())?;
|
||||
println!("made one");
|
||||
key
|
||||
}
|
||||
};
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
async fn add_posts(db: &BlogDb) -> anyhow::Result<()> {
|
||||
let content = r#"
|
||||
[
|
||||
{
|
||||
"id": "26lQiP26Dd",
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Example Post",
|
||||
"level": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PnWeuvNW6O",
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "This is a subheading",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xSV-1St5Nh",
|
||||
"type": "warning",
|
||||
"data": {
|
||||
"title": "This is a warning...",
|
||||
"message": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rfnEtkKIjP",
|
||||
"type": "quote",
|
||||
"data": {
|
||||
"text": "this is a quote<br>",
|
||||
"caption": "",
|
||||
"alignment": "left"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6lQRMa2guf",
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "This is some content<br>"
|
||||
}
|
||||
}
|
||||
]
|
||||
"#;
|
||||
if db.get_posts().await?.is_empty() {
|
||||
db.add_post("Example Post", "", content).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
async fn add_drafts(username: &str, password: String, db: &BlogDb) -> anyhow::Result<()> {
|
||||
let content = r#"
|
||||
[
|
||||
{
|
||||
"id": "26lQiP26Dd",
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "Example Draft",
|
||||
"level": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "PnWeuvNW6O",
|
||||
"type": "header",
|
||||
"data": {
|
||||
"text": "This is a subheading",
|
||||
"level": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "xSV-1St5Nh",
|
||||
"type": "warning",
|
||||
"data": {
|
||||
"title": "This is a warning...",
|
||||
"message": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rfnEtkKIjP",
|
||||
"type": "quote",
|
||||
"data": {
|
||||
"text": "this is a quote<br>",
|
||||
"caption": "",
|
||||
"alignment": "left"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "6lQRMa2guf",
|
||||
"type": "paragraph",
|
||||
"data": {
|
||||
"text": "This is some content<br>"
|
||||
}
|
||||
}
|
||||
]
|
||||
"#;
|
||||
let session_id = db.new_session(username, password, "+1 year").await?;
|
||||
if db.get_drafts(session_id).await?.is_empty() {
|
||||
db.add_draft("Example Draft", "", content).await?;
|
||||
}
|
||||
db.end_session(session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::post,
|
||||
Form, Router,
|
||||
};
|
||||
use axum_extra::extract::{cookie::Cookie, PrivateCookieJar};
|
||||
use blogdb::BlogDb;
|
||||
use serde::Deserialize;
|
||||
use serde_json::to_string;
|
||||
|
||||
use crate::{html, BlogState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginCredentials {
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
remember: Option<String>,
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn auth(State(state): State<BlogState>) -> Router {
|
||||
Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn login(
|
||||
mut jar: PrivateCookieJar,
|
||||
State(state): State<BlogState>,
|
||||
Form(body): Form<LoginCredentials>,
|
||||
) -> Response {
|
||||
let db = &state.db();
|
||||
let next = match body.next.unwrap_or("/".to_string()).as_str() {
|
||||
"/" => "/".to_string(),
|
||||
next => ["/", next].concat(),
|
||||
};
|
||||
let username = match body.username {
|
||||
Some(username) => username,
|
||||
None => return bad_login(&next).await,
|
||||
};
|
||||
let password = match body.password {
|
||||
Some(password) => password,
|
||||
None => return bad_login(&next).await,
|
||||
};
|
||||
let expiration = if body.remember.is_some() {
|
||||
"+1 year"
|
||||
} else {
|
||||
"+0 day"
|
||||
};
|
||||
match db.new_session(username, password, expiration).await {
|
||||
Ok(id) => {
|
||||
jar = jar.add(
|
||||
Cookie::build(("id", id.to_string()))
|
||||
.domain(state.domain())
|
||||
.secure(true)
|
||||
.http_only(true)
|
||||
.path("/"),
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(LOCATION, HeaderValue::from_str(&next).unwrap());
|
||||
(StatusCode::FOUND, headers, jar).into_response()
|
||||
}
|
||||
Err(_) => bad_login(&next).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn bad_login(next: &str) -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Html(html::login_status(next, false)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn logout(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
|
||||
let db = &state.db();
|
||||
let session_id: i64 = if let Some(cookie) = jar.get("id") {
|
||||
match cookie.value().parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
}
|
||||
} else {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
};
|
||||
match db.end_session(session_id).await {
|
||||
Ok(_) => {
|
||||
let jar = jar.remove("id");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(LOCATION, HeaderValue::from_str("/").unwrap());
|
||||
(StatusCode::FOUND, headers, jar).into_response()
|
||||
}
|
||||
Err(_) => StatusCode::BAD_REQUEST.into_response(),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,267 @@
|
|||
use axum::{
|
||||
extract::{Json, Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use axum_extra::extract::PrivateCookieJar;
|
||||
use blogdb::BlogDb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
html::{self, about_page, home_page, remove_el},
|
||||
BlogState,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
#[allow(non_camel_case_types)]
|
||||
pub(crate) enum Block {
|
||||
paragraph {
|
||||
text: String,
|
||||
},
|
||||
header {
|
||||
text: String,
|
||||
level: usize,
|
||||
},
|
||||
list {
|
||||
style: ListStyle,
|
||||
items: Vec<String>,
|
||||
},
|
||||
warning {
|
||||
title: String,
|
||||
},
|
||||
quote {
|
||||
text: String,
|
||||
},
|
||||
embed {
|
||||
service: String,
|
||||
source: String,
|
||||
embed: String,
|
||||
width: usize,
|
||||
height: usize,
|
||||
},
|
||||
image {
|
||||
file: ImageSource,
|
||||
#[serde(default)]
|
||||
caption: String,
|
||||
},
|
||||
delimiter {},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[allow(non_camel_case_types)]
|
||||
pub struct ImageSource {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[allow(non_camel_case_types)]
|
||||
pub enum ListStyle {
|
||||
ordered,
|
||||
unordered,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
fn to_html(&self) -> String {
|
||||
let text = match self {
|
||||
Block::paragraph { text } => ["<p>", text, "</p>"].concat(),
|
||||
Block::header { text, level } => {
|
||||
let level = level.to_string();
|
||||
["<h", &level, ">", text, "</h", &level, ">"].concat()
|
||||
}
|
||||
Block::list { style, items } => match style {
|
||||
ListStyle::ordered => ["<ol><li>", &items.join("</li><li>"), "</li></ol>"].concat(),
|
||||
ListStyle::unordered => {
|
||||
["<ul><li>", &items.join("</li><li>"), "</li></ul>"].concat()
|
||||
}
|
||||
},
|
||||
Block::warning { title } => ["<aside>", title, "</aside>"].concat(),
|
||||
Block::quote { text } => ["<blockquote>", text, "</blockquote>"].concat(),
|
||||
Block::embed {
|
||||
embed,
|
||||
width,
|
||||
height,
|
||||
..
|
||||
} => [
|
||||
"<iframe width=\"",
|
||||
&width.to_string(),
|
||||
"\" height=\"",
|
||||
&height.to_string(),
|
||||
"\" src=\"",
|
||||
embed,
|
||||
"\"></iframe>",
|
||||
]
|
||||
.concat(),
|
||||
Block::image { file, caption } => {
|
||||
["<img src=\"", &file.url, "\" alt=\"", caption, "\"/>"].concat()
|
||||
}
|
||||
Block::delimiter {} => "<div style=\"inline-size: 100%; block-size: 1px; background: var(--color-text)\"></div>".to_string(),
|
||||
|
||||
};
|
||||
html::animate_anchors(html::remove_el(&text, "br"))
|
||||
}
|
||||
|
||||
fn to_plaintext(&self) -> String {
|
||||
let text = match self {
|
||||
Block::paragraph { text } => text,
|
||||
Block::header { text, level: _ } => text,
|
||||
Block::list { style: _, items } => &items.join("\n"),
|
||||
Block::warning { title } => title,
|
||||
Block::quote { text } => text,
|
||||
_ => &"".to_string(),
|
||||
};
|
||||
let text = [text, "\n"].concat();
|
||||
remove_el(&text, "br")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn editor(State(state): State<BlogState>) -> Router {
|
||||
Router::new()
|
||||
.route("/update/:id", post(update))
|
||||
.route("/update/about", post(update_about))
|
||||
.route("/update/home", post(update_homepage))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
pub(crate) async fn check_id(
|
||||
jar: PrivateCookieJar,
|
||||
db: &BlogDb,
|
||||
) -> Result<(i64, String), Response> {
|
||||
match jar.get("id") {
|
||||
Some(id) => match id.value_trimmed().parse::<i64>() {
|
||||
Ok(id) => match db.check_session(id).await {
|
||||
Ok(user) => Ok((id, user)),
|
||||
Err(_) => Err(StatusCode::UNAUTHORIZED.into_response()),
|
||||
},
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST.into_response()),
|
||||
},
|
||||
None => Err(StatusCode::UNAUTHORIZED.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update(
|
||||
jar: PrivateCookieJar,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<BlogState>,
|
||||
Json(content): Json<Blocks>,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
|
||||
if let Err(response) = check_id(jar, db).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
let title = content.title();
|
||||
let _ = db.update_draft_title(&id, title).await;
|
||||
|
||||
let _ = db
|
||||
.update_draft_content(&id, serde_json::to_string(&content).unwrap())
|
||||
.await;
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
async fn update_about(
|
||||
jar: PrivateCookieJar,
|
||||
State(state): State<BlogState>,
|
||||
Json(content): Json<Blocks>,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
|
||||
let (id, user) = match check_id(jar, db).await {
|
||||
Ok(info) => info,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
let _ = db
|
||||
.update_user_about(&user, serde_json::to_string(&content).unwrap(), id)
|
||||
.await;
|
||||
|
||||
let _ = db.update_page("about", about_page(db).await).await;
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
async fn update_homepage(
|
||||
jar: PrivateCookieJar,
|
||||
State(state): State<BlogState>,
|
||||
Json(content): Json<Blocks>,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
|
||||
let (id, user) = match check_id(jar, db).await {
|
||||
Ok(info) => info,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
let _ = db
|
||||
.update_user_homepage(&user, serde_json::to_string(&content).unwrap(), id)
|
||||
.await;
|
||||
|
||||
let _ = db.update_page("index", home_page(db).await).await;
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Default)]
|
||||
pub(crate) struct Blocks(Vec<Block>);
|
||||
|
||||
impl Blocks {
|
||||
pub(crate) fn to_html(&self) -> String {
|
||||
let mut html = String::new();
|
||||
for block in self.0.iter() {
|
||||
html.push_str(&block.to_html());
|
||||
}
|
||||
html
|
||||
}
|
||||
pub(crate) fn to_plaintext(&self) -> String {
|
||||
let mut text = String::new();
|
||||
for block in self.0.iter() {
|
||||
text.push_str(&block.to_plaintext());
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub(crate) fn first(&self) -> Option<&Block> {
|
||||
self.0.first()
|
||||
}
|
||||
|
||||
pub(crate) fn title(&self) -> &str {
|
||||
if let Some(Block::header { text, level: 1 }) = self.first() {
|
||||
text
|
||||
} else {
|
||||
"Untitled"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn desc(&self) -> String {
|
||||
html::sanitize(
|
||||
self.0
|
||||
.iter()
|
||||
.find(|block| match block {
|
||||
Block::paragraph { text: _ } => true,
|
||||
Block::header { text: _, level } => *level > 1,
|
||||
Block::warning { title: _ } => true,
|
||||
Block::quote { text: _ } => true,
|
||||
_ => false,
|
||||
})
|
||||
.map(|block| match block {
|
||||
Block::paragraph { text } => text,
|
||||
Block::header { text, level: _ } => text,
|
||||
Block::warning { title } => title,
|
||||
Block::quote { text } => text,
|
||||
_ => "...",
|
||||
})
|
||||
.unwrap_or("No description"),
|
||||
)
|
||||
}
|
||||
pub(crate) fn image(&self) -> Option<&str> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|block| matches!(block, Block::image { .. }))
|
||||
.map(|block| match block {
|
||||
Block::image { file, .. } => Some(file.url.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
handler::HandlerWithoutStateExt,
|
||||
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use axum_extra::extract::{Form, PrivateCookieJar, Query};
|
||||
|
||||
use serde::Deserialize;
|
||||
use tantivy::{
|
||||
doc,
|
||||
schema::{Schema, STORED, TEXT},
|
||||
Term,
|
||||
};
|
||||
|
||||
use crate::BlogState;
|
||||
|
||||
mod auth;
|
||||
pub(crate) mod editor;
|
||||
use auth::auth;
|
||||
use editor::{check_id, editor, Blocks};
|
||||
|
||||
pub(super) fn api(state: BlogState) -> Router {
|
||||
Router::new()
|
||||
.route("/posts/:path", post(posts))
|
||||
.route("/user/update", post(user_update))
|
||||
.route("/drafts/:path", post(drafts))
|
||||
.route("/search", get(search))
|
||||
.with_state(state.clone())
|
||||
.nest("/auth", auth(State(state.clone())))
|
||||
.nest("/editor", editor(State(state.clone())))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Entries {
|
||||
#[serde(default)]
|
||||
item: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(non_camel_case_types)]
|
||||
enum PostsEndpoints {
|
||||
delete,
|
||||
unpublish,
|
||||
}
|
||||
|
||||
async fn posts(
|
||||
Path(path): Path<PostsEndpoints>,
|
||||
State(state): State<BlogState>,
|
||||
Form(data): Form<Entries>,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
let mut schema_builder = Schema::builder();
|
||||
let id = schema_builder.add_text_field("id", TEXT | STORED);
|
||||
match path {
|
||||
PostsEndpoints::delete => {
|
||||
for post_id in data.item.iter() {
|
||||
let _ = db.delete_post(post_id).await;
|
||||
let term = Term::from_field_text(id, post_id);
|
||||
state.index_delete(term);
|
||||
}
|
||||
}
|
||||
PostsEndpoints::unpublish => {
|
||||
for post_id in data.item.iter() {
|
||||
let _ = db.edit_post(post_id).await;
|
||||
let _ = db.delete_post(post_id).await;
|
||||
let term = Term::from_field_text(id, post_id);
|
||||
state.index_delete(term);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.index_commit();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
|
||||
(StatusCode::FOUND, headers).into_response()
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(non_camel_case_types)]
|
||||
enum DraftsEndpoints {
|
||||
delete,
|
||||
publish,
|
||||
}
|
||||
|
||||
async fn drafts(
|
||||
Path(path): Path<DraftsEndpoints>,
|
||||
State(state): State<BlogState>,
|
||||
Form(data): Form<Entries>,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
match path {
|
||||
DraftsEndpoints::delete => {
|
||||
for id in data.item.iter() {
|
||||
let _result = db.delete_draft(id).await;
|
||||
}
|
||||
}
|
||||
DraftsEndpoints::publish => {
|
||||
for id in data.item.iter() {
|
||||
match db.publish_draft(id).await {
|
||||
Ok(post) => {
|
||||
let mut schema_builder = Schema::builder();
|
||||
let id = schema_builder.add_text_field("id", TEXT | STORED);
|
||||
let body = schema_builder.add_text_field("body", TEXT | STORED);
|
||||
let title = schema_builder.add_text_field("title", TEXT | STORED);
|
||||
let content: Blocks =
|
||||
serde_json::from_str(&post.content).unwrap_or_default();
|
||||
state.index_add_document(doc!(id => post.id, body => content.to_plaintext(), title => post.title));
|
||||
}
|
||||
Err(_) => continue,
|
||||
};
|
||||
}
|
||||
state.index_commit();
|
||||
}
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
|
||||
(StatusCode::FOUND, headers).into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchQuery {
|
||||
#[serde(default)]
|
||||
query: Vec<String>,
|
||||
}
|
||||
|
||||
async fn search(Query(query): Query<SearchQuery>) -> Response {
|
||||
let query = query
|
||||
.query
|
||||
.join(" ")
|
||||
.split(" ")
|
||||
.collect::<Vec<&str>>()
|
||||
.join("-");
|
||||
let url = ["/search/", &query].concat();
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(LOCATION, HeaderValue::from_str(&url).unwrap());
|
||||
(StatusCode::FOUND, headers).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateUserInfo {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn user_update(
|
||||
jar: PrivateCookieJar,
|
||||
State(state): State<BlogState>,
|
||||
Form(info): Form<UpdateUserInfo>,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
let (id, user) = match check_id(jar, db).await {
|
||||
Ok(creds) => creds,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
if info.username != user {
|
||||
let _ = db.update_user_name(&user, &info.username, id).await;
|
||||
let _ = db.update_password(user, info.password, id).await;
|
||||
} else {
|
||||
let _ = db.update_password(user, info.password, id).await;
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
|
||||
(StatusCode::FOUND, headers).into_response()
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum_extra::extract::PrivateCookieJar;
|
||||
|
||||
use crate::{html, BlogState};
|
||||
|
||||
pub(super) fn login(State(state): State<BlogState>) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(login_page))
|
||||
.route("/*next", get(login_next))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn login_next(
|
||||
jar: PrivateCookieJar,
|
||||
Path(path): Path<String>,
|
||||
State(state): State<BlogState>,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
match jar.get("id") {
|
||||
Some(id) => match id.value_trimmed().parse::<i64>() {
|
||||
Ok(id) => {
|
||||
if db.check_session(id).await.is_ok() {
|
||||
return go_next(path).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
{};
|
||||
}
|
||||
},
|
||||
None => {
|
||||
{};
|
||||
}
|
||||
}
|
||||
Html(html::login_status(&path, true)).into_response()
|
||||
}
|
||||
|
||||
async fn login_page(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
|
||||
let db = state.db();
|
||||
match jar.get("id") {
|
||||
Some(id) => match id.value_trimmed().parse::<i64>() {
|
||||
Ok(id) => {
|
||||
if db.check_session(id).await.is_ok() {
|
||||
return go_next("/".to_string()).await;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
{};
|
||||
}
|
||||
},
|
||||
None => {
|
||||
{};
|
||||
}
|
||||
}
|
||||
Html(html::login_status("/", true)).into_response()
|
||||
}
|
||||
|
||||
async fn go_next(path: String) -> Response {
|
||||
let next = path.as_str();
|
||||
let mut headers = HeaderMap::new();
|
||||
let value = match HeaderValue::from_str(next) {
|
||||
Ok(value) => value,
|
||||
Err(_error) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(LOCATION, "/".parse().unwrap());
|
||||
return (StatusCode::FOUND, headers).into_response();
|
||||
}
|
||||
};
|
||||
headers.append(LOCATION, value);
|
||||
(StatusCode::FOUND, headers).into_response()
|
||||
}
|
|
@ -0,0 +1,384 @@
|
|||
pub(crate) mod api;
|
||||
mod login;
|
||||
use std::io::Cursor;
|
||||
|
||||
use api::{api, editor::check_id};
|
||||
use axum_extra::{extract::PrivateCookieJar, response::Html};
|
||||
use login::login;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{rejection::PathRejection, DefaultBodyLimit, Multipart, Path, State},
|
||||
http::{
|
||||
header::{CACHE_CONTROL, CONTENT_TYPE, EXPIRES, LOCATION, PRAGMA},
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
},
|
||||
middleware::from_fn_with_state,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tantivy::{
|
||||
collector::TopDocs,
|
||||
query::QueryParser,
|
||||
schema::{Schema, STRING, TEXT},
|
||||
DocAddress, Document, Score, TantivyDocument,
|
||||
};
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::{
|
||||
html::{
|
||||
self, about_editor, admin_page, draft_editor, homepage_editor, search_page,
|
||||
search_page_no_query,
|
||||
},
|
||||
BlogState,
|
||||
};
|
||||
|
||||
use super::middleware::logged_in;
|
||||
|
||||
pub(super) async fn make_router(state: BlogState) -> Router {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
// axum logs rejections from built-in extractors with the `axum::rejection`
|
||||
// target, at `TRACE` level. `axum::rejection=trace` enables showing those events
|
||||
format!(
|
||||
"{}=debug,tower_http=debug,axum::rejection=trace",
|
||||
env!("CARGO_CRATE_NAME")
|
||||
)
|
||||
.into()
|
||||
}),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
let router = Router::new()
|
||||
.merge(protected_routes(state.clone()).await)
|
||||
.merge(blog(State(state.clone())))
|
||||
.route("/assets/*asset", get(asset).with_state(state.clone()))
|
||||
.nest("/api", api(state.clone()))
|
||||
.nest("/login", login(State(state.clone())))
|
||||
.route("/", get(pages).with_state(state.clone()))
|
||||
.route("/*path", get(pages).with_state(state.clone()))
|
||||
.route("/search", get(search_empty))
|
||||
.route("/search/*query", get(search).with_state(state.clone()))
|
||||
.fallback(get(handle_404))
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestBodyLimitLayer::new(250 * 1024 * 1024));
|
||||
#[cfg(debug_assertions)]
|
||||
let router = router.layer(TraceLayer::new_for_http());
|
||||
router
|
||||
}
|
||||
|
||||
async fn protected_routes(state: BlogState) -> Router {
|
||||
Router::new()
|
||||
.route("/admin", get(admin).with_state(state.clone()))
|
||||
.route("/editor/:id", get(editor).with_state(state.clone()))
|
||||
.route(
|
||||
"/editor/uploadFile",
|
||||
post(editor_upload_file).with_state(state.clone()),
|
||||
)
|
||||
.route(
|
||||
"/editor/fetchUrl",
|
||||
post(editor_fetch_url).with_state(state.clone()),
|
||||
)
|
||||
.route_layer(from_fn_with_state(state.clone(), logged_in))
|
||||
}
|
||||
|
||||
async fn pages(
|
||||
path: Result<Path<String>, PathRejection>,
|
||||
State(state): State<BlogState>,
|
||||
) -> Response {
|
||||
match path {
|
||||
Ok(path) => match state.db().get_page(path.0).await {
|
||||
Ok(page) => Html(page).into_response(),
|
||||
Err(_) => four_oh_four(),
|
||||
},
|
||||
Err(error) => match error {
|
||||
PathRejection::FailedToDeserializePathParams(_) => {
|
||||
match state.db().get_page("/").await {
|
||||
Ok(page) => Html(page).into_response(),
|
||||
Err(_) => four_oh_four(),
|
||||
}
|
||||
}
|
||||
_ => four_oh_four(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn blog(State(state): State<BlogState>) -> Router {
|
||||
Router::new()
|
||||
.route("/blog", get(blog_root))
|
||||
.route("/blog/*path", get(blog_post))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn blog_root(State(state): State<BlogState>) -> Response {
|
||||
let db = state.db();
|
||||
let page = html::blog_roll(db).await;
|
||||
Html(page).into_response()
|
||||
}
|
||||
|
||||
async fn blog_post(Path(id): Path<String>, State(state): State<BlogState>) -> Response {
|
||||
let db = state.db();
|
||||
match db.get_post(id).await {
|
||||
Ok(post) => {
|
||||
let html = html::blog_page(post.id, db).await;
|
||||
Html(html).into_response()
|
||||
}
|
||||
Err(_) => four_oh_four(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn four_oh_four() -> Response {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(html::make_404("Page not found!")),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub(crate) async fn editor(
|
||||
jar: PrivateCookieJar,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<BlogState>,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
match id.as_str() {
|
||||
"new" => match db.add_draft("Untitled", "", "").await {
|
||||
Ok(draft) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
LOCATION,
|
||||
HeaderValue::from_str(&format!("/editor/{}", draft.id)).unwrap(),
|
||||
);
|
||||
(StatusCode::FOUND, headers).into_response()
|
||||
}
|
||||
Err(_) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(LOCATION, HeaderValue::from_str("/admin").unwrap());
|
||||
(StatusCode::FOUND, headers).into_response()
|
||||
}
|
||||
},
|
||||
"about" => {
|
||||
let (_id, user) = match check_id(jar, db).await {
|
||||
Ok(info) => info,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let about_content = db.get_user_info(user).await.unwrap().about;
|
||||
let page = about_editor(&about_content);
|
||||
Html(page).into_response()
|
||||
}
|
||||
"home" => {
|
||||
let (_id, user) = match check_id(jar, db).await {
|
||||
Ok(info) => info,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let home_content = db.get_user_info(user).await.unwrap().home;
|
||||
let page = homepage_editor(&home_content);
|
||||
Html(page).into_response()
|
||||
}
|
||||
_ => {
|
||||
let draft = match db.edit_post(id).await {
|
||||
Ok(draft) => draft,
|
||||
Err(_) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(LOCATION, HeaderValue::from_str("/editor/new").unwrap());
|
||||
return (StatusCode::FOUND, headers).into_response();
|
||||
}
|
||||
};
|
||||
let page = draft_editor(&draft.content, &draft.id.to_string());
|
||||
Html(page).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UploadResult {
|
||||
success: usize,
|
||||
file: Option<UrlUpload>,
|
||||
}
|
||||
impl UploadResult {
|
||||
fn success(url: String) -> Self {
|
||||
Self {
|
||||
success: 1,
|
||||
file: Some(UrlUpload { url }),
|
||||
}
|
||||
}
|
||||
fn failure() -> Self {
|
||||
Self {
|
||||
success: 0,
|
||||
file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn editor_upload_file(
|
||||
State(state): State<BlogState>,
|
||||
mut fields: Multipart,
|
||||
) -> Response {
|
||||
let db = state.db();
|
||||
let field = match fields.next_field().await {
|
||||
Ok(option) => match option {
|
||||
Some(field) => field,
|
||||
None => {
|
||||
return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response()
|
||||
}
|
||||
},
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response(),
|
||||
};
|
||||
let name = match field.file_name() {
|
||||
Some(name) => name.to_string(),
|
||||
None => "".to_string(),
|
||||
};
|
||||
let data = match field.bytes().await {
|
||||
Ok(data) => data,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, Json(UploadResult::failure())).into_response(),
|
||||
};
|
||||
|
||||
match db.add_asset(name, data).await {
|
||||
Ok(asset) => {
|
||||
let slug = ["/assets/", &asset.slug].concat();
|
||||
Json(UploadResult::success(slug)).into_response()
|
||||
}
|
||||
Err(_error) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(UploadResult::failure()),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct UrlUpload {
|
||||
url: String,
|
||||
}
|
||||
pub(crate) async fn editor_fetch_url(
|
||||
// jar: PrivateCookieJar,
|
||||
State(state): State<BlogState>,
|
||||
Json(UrlUpload { url: _ }): Json<UrlUpload>,
|
||||
) -> Response {
|
||||
let _db = state.db();
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(r#"{"success": 0}"#)).into_response()
|
||||
}
|
||||
|
||||
async fn asset(Path(slug): Path<String>, State(state): State<BlogState>) -> Response {
|
||||
let db = state.db();
|
||||
match db.get_asset(slug).await {
|
||||
Ok(asset) => {
|
||||
async {
|
||||
// i still don't quite know how to do async rust
|
||||
let stream = ReaderStream::new(Cursor::new(asset.data));
|
||||
let body = Body::from_stream(stream);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(CONTENT_TYPE, HeaderValue::from_str(&asset.mime).unwrap());
|
||||
(headers, body).into_response()
|
||||
}
|
||||
.await
|
||||
}
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_404() -> Response {
|
||||
four_oh_four()
|
||||
}
|
||||
|
||||
async fn admin(jar: PrivateCookieJar, State(state): State<BlogState>) -> Response {
|
||||
let db = state.db();
|
||||
// this can be unwrapped because our login middleware
|
||||
// ensures the user has a valid id
|
||||
let session_id = jar
|
||||
.get("id")
|
||||
.unwrap()
|
||||
.value_trimmed()
|
||||
.parse()
|
||||
.unwrap_or_default();
|
||||
let page = admin_page(session_id, db).await;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_str("no-cache, no-store, must-revalidate").unwrap(),
|
||||
);
|
||||
headers.append(PRAGMA, HeaderValue::from_str("no-cache").unwrap());
|
||||
headers.append(EXPIRES, HeaderValue::from_str("0").unwrap());
|
||||
(headers, Html(page)).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug)]
|
||||
pub(crate) struct SearchResponse(Vec<SearchResponseEntry>);
|
||||
impl SearchResponse {
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoIterator for SearchResponse {
|
||||
type IntoIter = <Vec<SearchResponseEntry> as IntoIterator>::IntoIter;
|
||||
type Item = SearchResponseEntry;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub(crate) struct SearchResponseEntry {
|
||||
// idk why tantivy makes these fields sequences
|
||||
body: Vec<String>,
|
||||
pub(crate) id: Vec<String>,
|
||||
title: Vec<String>,
|
||||
}
|
||||
|
||||
async fn search_empty() -> Response {
|
||||
Html(search_page_no_query()).into_response()
|
||||
}
|
||||
|
||||
async fn search(Path(query): Path<String>, State(state): State<BlogState>) -> Response {
|
||||
let query = query.split("-").collect::<Vec<&str>>().join(" ");
|
||||
|
||||
let closure_query = query.clone();
|
||||
|
||||
let closure_state = state.clone();
|
||||
let response = tokio::task::spawn_blocking(move || {
|
||||
let index = closure_state.index();
|
||||
let reader = index.reader().unwrap();
|
||||
let searcher = reader.searcher();
|
||||
let mut schema_builder = Schema::builder();
|
||||
let (id, title, body) = (
|
||||
schema_builder.add_text_field("id", STRING),
|
||||
schema_builder.add_text_field("title", TEXT),
|
||||
schema_builder.add_text_field("body", TEXT),
|
||||
);
|
||||
let schema = schema_builder.build();
|
||||
let query_parser = QueryParser::for_index(index, vec![id, title, body]);
|
||||
let query = query_parser.parse_query(&closure_query).unwrap();
|
||||
let top_docs: Vec<(Score, DocAddress)> =
|
||||
searcher.search(&query, &TopDocs::with_limit(100)).unwrap();
|
||||
let mut json = String::new();
|
||||
json.push('[');
|
||||
let mut docs_iter = top_docs.iter().peekable();
|
||||
while let Some((_score, doc_address)) = docs_iter.next() {
|
||||
let retrieved = searcher
|
||||
.doc::<TantivyDocument>(*doc_address)
|
||||
.unwrap_or_default();
|
||||
json.push_str(&retrieved.to_json(&schema));
|
||||
if docs_iter.peek().is_some() {
|
||||
json.push(',');
|
||||
}
|
||||
}
|
||||
json.push(']');
|
||||
// TODO: Deserialize directly to SearchResponseEntry
|
||||
let result = serde_json::from_str::<SearchResponse>(&json);
|
||||
result.unwrap_or_default()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let db = state.db();
|
||||
let html = search_page(query, response, db).await;
|
||||
Html(html).into_response()
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
<li class="widget-entry">
|
||||
<a class="entry-content" href="">
|
||||
<h2></h2>
|
||||
<p></p>
|
||||
</a>
|
||||
<input type="checkbox" name="item" value="">
|
||||
</li>
|
|
@ -0,0 +1,14 @@
|
|||
<div class="admin-widget admin-widget-drafts">
|
||||
<div class="widget-header">
|
||||
<h1>Drafts</h1>
|
||||
<a class="button" href="/editor/new">New Draft</a>
|
||||
</div>
|
||||
<form method="post">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="form-action" formaction="/api/drafts/delete">Delete drafts</button>
|
||||
<button type="submit" class="form-action" formaction="/api/drafts/publish">Publish drafts</button>
|
||||
</div>
|
||||
<ul tabindex="-1">
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,8 @@
|
|||
<li class="widget-entry">
|
||||
<a class="entry-content" href="">
|
||||
<time></time>
|
||||
<h2></h2>
|
||||
<p></p>
|
||||
</a>
|
||||
<input type="checkbox" name="item" value="">
|
||||
</li>
|
|
@ -0,0 +1,15 @@
|
|||
<div class="admin-widget admin-widget-posts">
|
||||
<div class="widget-header">
|
||||
<h1>Published Posts</h1>
|
||||
</div>
|
||||
<form method="post">
|
||||
<div class="form-actions">
|
||||
<button class="form-action" type="submit" formaction="/api/posts/delete">Delete posts
|
||||
</button>
|
||||
<button class=" form-action" type="submit" formaction="/api/posts/unpublish">Unpublish Posts
|
||||
</button>
|
||||
</div>
|
||||
<ul tabindex="-1">
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,16 @@
|
|||
<div class="admin-widget admin-widget-user">
|
||||
<form action="#user-info" method="get"><button>Update User Info</button></form>
|
||||
<a class="button" href="/editor/home">Update Home Page</a>
|
||||
<a class="button" href="/editor/about">Update About Page</a>
|
||||
<form action="/api/auth/logout" method="post"><button type="submit">Logout</button></form>
|
||||
<div id="user-info" class="admin-widget-user">
|
||||
<form action="#" method="get"><button aria-label="close">✕</button></form>
|
||||
<form action="/api/user/update" method="post" class="user-info-form">
|
||||
<input type="text" name="username" placeholder="New Username" autocomplete="off" aria-label="New Username"
|
||||
required />
|
||||
<input type="password" name="password" placeholder="New Password" autocomplete="new-password"
|
||||
aria-label="New Password" required />
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,5 @@
|
|||
<admin-widget type="user"></admin-widget>
|
||||
<div class="blog-admin">
|
||||
<admin-widget type="drafts"></admin-widget>
|
||||
<admin-widget type="posts"></admin-widget>
|
||||
</div>
|
|
@ -0,0 +1 @@
|
|||
<time></time>
|
|
@ -0,0 +1,10 @@
|
|||
<li class="blog-roll-entry">
|
||||
<a href="">
|
||||
<img src="" />
|
||||
<div class="entry-content">
|
||||
<time></time>
|
||||
<h2></h2>
|
||||
<p></p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
|
@ -0,0 +1,2 @@
|
|||
<h1>Blog</h1>
|
||||
<ul class="blog-roll"></ul>
|
|
@ -0,0 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/assets/css/style.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<site-header></site-header>
|
||||
<main>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -0,0 +1,4 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title></title>
|
||||
<link href="/assets/css/style.css" rel="stylesheet">
|
|
@ -0,0 +1,5 @@
|
|||
<meta property="og:url" content="" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="" />
|
||||
<meta property="og:description" content="" />
|
||||
<meta property="og:image" content="" />
|
|
@ -0,0 +1,3 @@
|
|||
<div class="login-status">
|
||||
<span>Incorrect username or password. Please try again.</span>
|
||||
</div>
|
|
@ -0,0 +1,23 @@
|
|||
<div></div>
|
||||
<main>
|
||||
<div class="login-area">
|
||||
<h1>Log In</h1>
|
||||
<login-status></login-status>
|
||||
<form method="POST" action="/api/auth/login">
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="username" autocomplete="off" required>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" required>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="remember">
|
||||
Remember me
|
||||
</label>
|
||||
<input type="hidden" name="next" value="">
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
|
@ -0,0 +1,7 @@
|
|||
<main>
|
||||
<div id="editorjs"></div>
|
||||
</main>
|
||||
|
||||
<script id="editor"></script>
|
||||
|
||||
<script src="/assets/js/editor.js"></script>
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<site-header></site-header>
|
||||
<main>
|
||||
<h1></h1>
|
||||
<ul></ul>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -0,0 +1,19 @@
|
|||
<header>
|
||||
<a class="animated-link" href="/">
|
||||
<h1>Evie Ippolito</h1>
|
||||
</a>
|
||||
<nav>
|
||||
<a href="/blog" class="animated-link">blog</a>
|
||||
<a href="/about" class="animated-link">about</a>
|
||||
<form action="/api/search" method="get" role="search">
|
||||
<input type="search" name="query" tabindex="0" autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
required dir="auto" />
|
||||
<button type="submit" aria-label="search" tabindex="-1">
|
||||
<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" height="1.5rem" width="1.5rem">
|
||||
<path
|
||||
d="M8.5 14a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Zm4.936-1.27 4.418 4.416-.708.708-4.417-4.418a6.5 6.5 0 1 1 .707-.707Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
Loading…
Reference in New Issue