Initial commit

This commit is contained in:
august kline 2024-11-13 21:41:12 -05:00
commit a8c0bb1641
43 changed files with 20461 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
/index
.env

3339
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

51
Cargo.toml Normal file
View File

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

56
build.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 116 KiB

13695
src/client/assets/js/editor.js Normal file

File diff suppressed because one or more lines are too long

745
src/html.rs Normal file
View File

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

16
src/main.rs Normal file
View File

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

43
src/server/middleware.rs Normal file
View File

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

347
src/server/mod.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

View File

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

384
src/server/routes/mod.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

5
src/templates/admin.html Normal file
View File

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

View File

@ -0,0 +1 @@
<time></time>

View File

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

View File

@ -0,0 +1,2 @@
<h1>Blog</h1>
<ul class="blog-roll"></ul>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
<div class="login-status">
<span>Incorrect username or password. Please try again.</span>
</div>

23
src/templates/login.html Normal file
View File

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

View File

@ -0,0 +1,7 @@
<main>
<div id="editorjs"></div>
</main>
<script id="editor"></script>
<script src="/assets/js/editor.js"></script>

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
</head>
<body>
<site-header></site-header>
<main>
<h1></h1>
<ul></ul>
</main>
</body>
</html>

View File

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