Crablog Initial Commit

This commit is contained in:
Andrey Tkachenko 2023-10-13 18:11:23 +04:00
parent 9ca9b36666
commit 0e8316588b
32 changed files with 2745 additions and 2585 deletions

View File

@ -16,6 +16,6 @@ jobs:
only:
- master
script:
- docker build -t kilerd/rubble:latest .
- docker build -t andreytkachenko/crablog:latest .
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- docker push kilerd/rubble:latest
- docker push andreytkachenko/crablog:latest

3158
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,46 +1,47 @@
[package]
name = "rubble"
name = "crablog"
description = "a slight blog system"
version = "0.3.3"
authors = ["Kilerd Chan <blove694@gmail.com>"]
version = "0.4.0"
authors = ["Andrey Tkachenko <andrey@aidev.ru>", "Kilerd Chan <blove694@gmail.com>"]
license = "MIT"
edition = "2018"
edition = "2021"
build = "build.rs"
[dependencies]
dotenv = "0.15.0"
openssl = "0.10.27"
# serde
serde = "1.0.104"
serde_derive = "1.0.104"
serde = "1.0"
serde_derive = "1.0"
r2d2 = "0.8.8"
diesel = { version = "1.4.3", features = ["postgres", "r2d2", "chrono"] }
diesel_derives = "1.4.1"
diesel_migrations = "1.4.0"
diesel = { version = "2.1.3", features = ["postgres", "postgres_backend", "chrono"] }
diesel_derives = "2.1.2"
diesel_migrations = "2.1.0"
tera = "1.0.2"
pulldown-cmark = { version = "0.6.1", default-features = false }
tera = "1.19.1"
pulldown-cmark = { version = "0.9.3", default-features = false }
chrono = { version = "0.4.10", features = ["serde"] }
chrono = { version = "0.4.31", features = ["serde"] }
rust-crypto = "0.2.36"
http = "0.2.0"
actix-web= "2.0.0"
actix-files = "0.2.1"
futures = "0.3.1"
http = "0.2.9"
futures = "0.3.28"
# log
log = "0.4.8"
pretty_env_logger = "0.4.0"
log = "0.4.20"
pretty_env_logger = "0.5.0"
time = "0.1"
rand = "0.7.3"
time = "0.3.29"
rand = "0.8.5"
rss = "1.9.0"
actix = "0.9.0"
jsonwebtoken = "7.0.1"
actix-cors = "0.2.0"
actix-rt = "1.0.0"
once_cell = "1.3.1"
derive_more = "0.99.2"
once_cell = "1.18.0"
jsonwebtoken = "8.3.0"
diesel-async = { version = "0.4.1", git = "https://github.com/weiznich/diesel_async.git", features = ["deadpool", "tokio-postgres", "postgres", "async-connection-wrapper"] }
axum = { version = "0.6.20", features = ["tokio"] }
tokio = { version = "1.33.0", features = ["rt", "macros"] }
anyhow = { version = "1.0.75", features = ["backtrace"] }
tower-http = { version = "0.4.4", features = ["fs", "cors", "normalize-path", "tokio", "trace", "auth"] }
thiserror = "1.0"

View File

@ -2,8 +2,8 @@ FROM clux/muslrust:stable as builder
WORKDIR /app
RUN USER=root cargo new rubble
WORKDIR /app/rubble
RUN USER=root cargo new crablog
WORKDIR /app/crablog
COPY Cargo.toml Cargo.lock ./
@ -11,25 +11,25 @@ RUN echo 'fn main() { println!("Dummy") }' > ./src/main.rs
RUN cargo build --release
RUN rm -r target/x86_64-unknown-linux-musl/release/.fingerprint/rubble-*
RUN rm -r target/x86_64-unknown-linux-musl/release/.fingerprint/crablog-*
COPY src src/
COPY migrations migrations/
COPY templates templates/
RUN cargo build --release --frozen --bin rubble
RUN cargo build --release --frozen --bin crablog
FROM alpine:latest
COPY --from=builder /app/rubble/migrations /application/migrations
COPY --from=builder /app/rubble/templates /application/templates
COPY --from=builder /app/rubble/target/x86_64-unknown-linux-musl/release/rubble /application/rubble
COPY --from=builder /app/crablog/migrations /application/migrations
COPY --from=builder /app/crablog/templates /application/templates
COPY --from=builder /app/crablog/target/x86_64-unknown-linux-musl/release/crablog /application/crablog
EXPOSE 8000
ENV DATABASE_URL postgres://root@postgres/rubble
ENV DATABASE_URL postgres://root@postgres/crablog
WORKDIR /application
CMD ["./rubble"]
CMD ["./crablog"]

View File

@ -1,6 +1,6 @@
<img align="right" width="128" height="128" src="/rubble.png">
<img align="right" width="128" height="128" src="/crablog.png">
# Rubble
# Crablob
a lightweight blog engine written by Rust.
@ -18,7 +18,7 @@ Cause this project is also the tentative staff I try to write something in Rust,
## Template
Project rubble highly depends on tera, a fast and effective template engine in Rust, which means that you can write your own template with tera syntax.
Project crablog highly depends on tera, a fast and effective template engine in Rust, which means that you can write your own template with tera syntax.
There are files in template folder as follow, which are the template for each page:
@ -31,7 +31,7 @@ There are files in template folder as follow, which are the template for each pa
Obviously you can learn how to write this template by the guide of official template folder, and how to use tera syntax in tera's official website.
## How to use it
After deploying rubble to your host, the first thing you need to do is login to the admin panel with url `http://yourdomain.com/admin`. And the default admin user and password is as follow:
After deploying crablog to your host, the first thing you need to do is login to the admin panel with url `http://yourdomain.com/admin`. And the default admin user and password is as follow:
- Username: `admin`
- Password: `password`
@ -39,26 +39,26 @@ after logging in, please modify the default password of admin. Then you can enjo
## Deploy using Docker
you can easily use Docker to create your own rubble application. And the latest version of it and each tagged version would be built as docker images storing in Docker Hub automatically. So you can easily pull those images by using `docker pull kilerd/rubble:latest`
you can easily use Docker to create your own crablog application. And the latest version of it and each tagged version would be built as docker images storing in Docker Hub automatically. So you can easily pull those images by using `docker pull andreytkachenko/crablog:latest`
Rubble uses PostgresQL as data storage, so before strating rubble application, you need to start your postgres service and link it to rubble.
Crablog uses PostgresQL as data storage, so before strating crablog application, you need to start your postgres service and link it to crablob.
Rubble image can accept some environment variable for setting up:
Crablog image can accept some environment variable for setting up:
- `DATABASE_URL` url of postgresQL
### Docker Stack
But we recommend to deploy rubble with Docker Swarm or Kubenetes. here is a simple file to create a whole rubble application with postgresQL`docker-compose.yml` :
But we recommend to deploy crablog with Docker Swarm or Kubenetes. here is a simple file to create a whole crablog application with postgresQL `docker-compose.yml` :
```yml
version: "3"
services:
rubble:
image: kilerd/rubble:latest
crablog:
image: andreytkachenko/crablog:latest
environment:
DATABASE_URL: postgres://root:password@postgres/rubble
DATABASE_URL: postgres://root:password@postgres/crablog
depends_on:
- postgres
networks:
@ -70,7 +70,7 @@ services:
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: password
POSTGRES_DB: rubble
POSTGRES_DB: crablog
networks:
- backend

3
build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=./migrations");
}

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -1,11 +1,11 @@
version: "3"
services:
rubble:
image: kilerd/rubble
crablog:
image: andreytkachenko/crablog
ports:
- "9999:8000"
environment:
DATABASE_URL: postgres://root:password@postgres/rubble
DATABASE_URL: postgres://root:password@postgres/crablog
ROCKET_SECRET_KEY: Bqgzqe3zIg2siAS6IBUmL9/50GOW1xDBpxXZgSpFbyM=
depends_on:
- postgres
@ -18,7 +18,7 @@ services:
environment:
POSTGRES_USER: root
POSTGRES_PASSWORD: password
POSTGRES_DB: rubble
POSTGRES_DB: crablog
networks:
- backend

View File

@ -1,3 +1,3 @@
-- Your SQL goes here
INSERT INTO setting ("name", "value")
VALUES ('title', 'rubble'), ('description', 'description of rubble application');
VALUES ('title', 'crablog'), ('description', 'description of crablog application');

View File

@ -1,21 +1,20 @@
use crate::pg_pool::{ManagedPgConn, Pool};
use r2d2::PooledConnection;
use crate::pg_pool::{DbConnection, Pool};
use std::sync::Arc;
use tera::{Context, Tera};
#[derive(Clone)]
pub struct RubbleData {
pub struct CrablogState {
pub pool: Pool,
pub tera: Arc<Tera>,
}
impl RubbleData {
pub fn postgres(&self) -> PooledConnection<ManagedPgConn> {
let pool = self.pool.clone();
pool.get().unwrap()
impl CrablogState {
pub async fn db(&self) -> DbConnection {
DbConnection::new(self.pool.get().await.unwrap())
}
pub fn render(&self, template_name: &str, data: &Context) -> String {
println!("{} {:?}", template_name, data);
self.tera.render(template_name, data).unwrap()
}
}

View File

@ -1,41 +1,96 @@
use actix_web::error::ResponseError;
use axum::{response::IntoResponse, Json};
use derive_more::Display;
use actix_web::HttpResponse;
use http::StatusCode;
use serde::Serialize;
use std::fmt::Debug;
use std::{borrow::Cow, fmt::Debug};
#[derive(Debug, Display)]
pub enum RubbleError<T> {
Unauthorized(T),
BadRequest(T),
pub enum ErrorKind {
Unauthorized,
BadRequest,
NotFound,
InternalError,
}
pub struct Error {
kind: ErrorKind,
value: Cow<'static, str>,
}
impl Error {
pub fn new(kind: ErrorKind, msg: impl Into<Cow<'static, str>>) -> Self {
Self {
kind,
value: msg.into(),
}
}
pub fn not_found(msg: impl Into<Cow<'static, str>>) -> Self {
Self {
kind: ErrorKind::NotFound,
value: msg.into(),
}
}
pub fn unauthorized(msg: impl Into<Cow<'static, str>>) -> Self {
Self {
kind: ErrorKind::Unauthorized,
value: msg.into(),
}
}
pub fn bad_request(msg: impl Into<Cow<'static, str>>) -> Self {
Self {
kind: ErrorKind::BadRequest,
value: msg.into(),
}
}
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let val = match self.kind {
ErrorKind::Unauthorized => (
StatusCode::UNAUTHORIZED,
Json(ErrorMsg {
message: self.value,
}),
),
ErrorKind::BadRequest => (
StatusCode::BAD_REQUEST,
Json(ErrorMsg {
message: self.value,
}),
),
ErrorKind::NotFound => (
StatusCode::NOT_FOUND,
Json(ErrorMsg {
message: self.value,
}),
),
ErrorKind::InternalError => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorMsg {
message: self.value,
}),
),
};
val.into_response()
}
}
impl<E: std::error::Error> From<E> for Error {
fn from(value: E) -> Self {
Self {
kind: ErrorKind::InternalError,
value: format!("Error: {}", value).into(),
}
}
}
#[derive(Serialize)]
struct ErrorMsg<T> {
message: T,
}
impl<T> ResponseError for RubbleError<T>
where
T: Debug + std::fmt::Display + Serialize,
{
fn status_code(&self) -> StatusCode {
match self {
RubbleError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
RubbleError::BadRequest(_) => StatusCode::BAD_REQUEST,
}
}
fn error_response(&self) -> HttpResponse {
match self {
RubbleError::Unauthorized(message) => {
HttpResponse::Unauthorized().json(&ErrorMsg { message })
}
RubbleError::BadRequest(message) => {
HttpResponse::BadRequest().json(&ErrorMsg { message })
}
}
}
}

View File

@ -1,25 +1,17 @@
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_derives;
#[macro_use]
extern crate diesel_migrations;
extern crate openssl;
#![feature(impl_trait_in_assoc_type)]
use std::sync::Arc;
use std::{net::SocketAddr, sync::Arc};
use actix_cors::Cors;
use actix_web::{
middleware::{Logger, NormalizePath},
web::{FormConfig, JsonConfig},
App, HttpServer,
use diesel_async::{
async_connection_wrapper::AsyncConnectionWrapper, pooled_connection::deadpool::Object,
AsyncPgConnection,
};
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv;
use once_cell::sync::Lazy;
use tera::Tera;
use dotenv::dotenv;
use crate::{data::RubbleData, pg_pool::database_pool_establish};
use crate::{data::CrablogState, pg_pool::database_pool_establish};
mod data;
mod error;
@ -29,45 +21,57 @@ mod routers;
mod schema;
mod utils;
embed_migrations!();
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations");
const TOKEN_KEY: Lazy<Vec<u8>> = Lazy::new(|| {
static TOKEN_KEY: Lazy<Vec<u8>> = Lazy::new(|| {
std::env::var("TOKEN_KEY")
.map(|token| Vec::from(token.as_bytes()))
.unwrap_or_else(|_| (0..32).into_iter().map(|_| rand::random::<u8>()).collect())
.unwrap_or_else(|_| (0..32).map(|_| rand::random::<u8>()).collect())
});
#[actix_rt::main]
async fn main() {
fn run_migrations(
connection: &mut impl MigrationHarness<diesel::pg::Pg>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
connection.run_pending_migrations(MIGRATIONS)?;
Ok(())
}
async fn run() -> anyhow::Result<()> {
dotenv().ok();
pretty_env_logger::init();
let database_url = std::env::var("DATABASE_URL").expect("database_url must be set");
let data = RubbleData {
pool: database_pool_establish(&database_url),
let data = CrablogState {
pool: database_pool_establish(&database_url).expect("cannot create pool"),
tera: Arc::new(Tera::new("templates/**/*.html").unwrap()),
};
embedded_migrations::run(&data.pool.get().expect("cannot get connection"))
.expect("panic on embedded database migration");
let async_connection = Object::take(data.pool.get().await.unwrap());
println!("rubble is listening on 127.0.0.1:8000");
let mut wrapper: AsyncConnectionWrapper<AsyncPgConnection> =
AsyncConnectionWrapper::from(async_connection);
HttpServer::new(move || {
App::new()
.app_data(data.clone())
.data(data.clone())
.data(JsonConfig::default().limit(256_000))
.data(FormConfig::default().limit(256_000))
.wrap(Logger::default())
.wrap(Cors::default())
.wrap(NormalizePath {})
.configure(routers::routes)
let _conn = tokio::task::spawn_blocking(move || {
run_migrations(&mut wrapper).expect("panic on embedded database migration");
wrapper
})
.bind(("0.0.0.0", 8000))
.unwrap()
.run()
.await?;
println!("crablog is listening on 0.0.0.0:8000");
let app = routers::routes().with_state(data);
let addr = SocketAddr::from(([0, 0, 0, 0], 8000));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap()
.unwrap();
Ok(())
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
run().await.unwrap()
}

View File

@ -1,11 +1,24 @@
use crate::{models::CRUD, schema::articles};
use crate::{
pg_pool::{DbConnection, DbError},
schema::articles,
};
use chrono::NaiveDateTime;
use diesel::{pg::PgConnection, prelude::*, result::Error};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use diesel::{sql_types::Integer, Insertable, Queryable};
use diesel::{Insertable, Queryable};
use serde::{Deserialize, Serialize};
#[derive(Queryable, Debug, Serialize)]
use futures::TryStreamExt;
pub struct All;
pub struct Published;
pub struct Pk(pub i32);
pub struct Url<'a>(pub &'a str);
use super::{Create, Delete, Query, QueryOne, Update, UpdateMany};
#[derive(Clone, Queryable, Debug, AsChangeset, Serialize, Identifiable)]
pub struct Article {
pub id: i32,
pub title: String,
@ -18,8 +31,8 @@ pub struct Article {
pub view: i32,
}
//
#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize)]
#[table_name = "articles"]
#[derive(Clone, Debug, Insertable, AsChangeset, Serialize, Deserialize)]
#[diesel(table_name = articles)]
pub struct NewArticle {
pub title: String,
pub body: String,
@ -33,52 +46,131 @@ pub struct NewArticle {
impl Article {
pub fn link(&self) -> String {
match self.url {
Some(ref to) if to.len() != 0 => format!("/{}", to),
Some(ref to) if !to.is_empty() => format!("/{}", to),
_ => format!("/archives/{}", self.id),
}
}
}
pub fn find_by_url(conn: &PgConnection, url: &str) -> Result<Self, Error> {
articles::table
impl QueryOne<Pk> for DbConnection {
type Item = Article;
type QueryOneFut<'a> = impl futures::Future<Output = Result<Self::Item, DbError>> + 'a;
fn query_one(&mut self, Pk(pk): Pk) -> Self::QueryOneFut<'_> {
async move { Ok(articles::table.find(pk).first::<Article>(self).await?) }
}
}
impl<'b> QueryOne<Url<'b>> for DbConnection {
type Item = Article;
type QueryOneFut<'a> = impl futures::Future<Output = Result<Self::Item, DbError>> + 'a
where 'b: 'a;
fn query_one(&mut self, Url(url): Url<'b>) -> Self::QueryOneFut<'_> {
async move {
Ok(articles::table
.filter(articles::url.eq(url))
.filter(articles::published.eq(true))
.first::<Article>(conn)
.first(self)
.await?)
}
pub fn increase_view(&self, conn: &PgConnection) {
diesel::sql_query(r#"UPDATE articles SET "view" = "view" + 1 where articles.id = $1"#)
.bind::<Integer, _>(self.id)
.execute(conn)
.expect("error on incr view");
}
}
impl CRUD<NewArticle, NewArticle, i32> for Article {
fn create(conn: &PgConnection, from: &NewArticle) -> Result<Self, Error> {
diesel::insert_into(articles::table)
.values(from)
.get_result(conn)
impl Query<All> for DbConnection {
type Item = Article;
type QueryFut<'a> = impl futures::Future<Output = Result<Self::QueryStream<'a>, DbError>> + 'a;
type QueryStream<'a> = impl futures::Stream<Item = Result<Article, DbError>> + 'a;
fn query(&mut self, _query: All) -> Self::QueryFut<'_> {
async move {
Ok(articles::table
.load_stream::<Article>(self)
.await?
.map_err(DbError::from))
}
}
}
fn read(conn: &PgConnection) -> Vec<Self> {
articles::table
.order(articles::publish_at.desc())
.load::<Self>(conn)
.expect("something wrong")
impl Query<Published> for DbConnection {
type Item = Article;
type QueryFut<'a> = impl futures::Future<Output = Result<Self::QueryStream<'a>, DbError>> + 'a;
type QueryStream<'a> = impl futures::Stream<Item = Result<Article, DbError>> + 'a;
fn query(&mut self, _query: Published) -> Self::QueryFut<'_> {
async move {
Ok(articles::table
.filter(articles::columns::published.eq(true))
.load_stream(self)
.await?
.map_err(DbError::from))
}
}
}
fn update(conn: &PgConnection, pk: i32, value: &NewArticle) -> Result<Self, Error> {
diesel::update(articles::table.find(pk))
.set(value)
.get_result(conn)
impl UpdateMany<NewArticle, Pk> for DbConnection {
type Item = Article;
type UpdateManyFut<'a> =
impl futures::Future<Output = Result<Self::UpdateManyStream<'a>, DbError>> + 'a;
type UpdateManyStream<'a> = impl futures::Stream<Item = Result<Article, DbError>> + 'a;
fn update_many(&mut self, Pk(id): Pk, model: NewArticle) -> Self::UpdateManyFut<'_> {
async move {
Ok(diesel::update(articles::table)
.filter(articles::columns::id.eq(id))
.set(model)
.load_stream(self)
.await?
.map_err(DbError::from))
}
}
}
fn delete(conn: &PgConnection, pk: i32) -> Result<usize, Error> {
diesel::delete(articles::table.filter(articles::id.eq(pk))).execute(conn)
impl Create<NewArticle> for DbConnection {
type Item = Article;
type CreateFut<'a> = impl futures::Future<Output = Result<Article, DbError>> + 'a;
fn create(&mut self, model: NewArticle) -> Self::CreateFut<'_> {
async move {
Ok(diesel::insert_into(articles::table)
.values(model)
.get_result(self)
.await?)
}
}
}
fn get_by_pk(conn: &PgConnection, pk: i32) -> Result<Self, Error> {
articles::table.find(pk).first::<Article>(conn)
impl Update<Article> for DbConnection {
type Item = Article;
type UpdateOneFut<'a> = impl futures::Future<Output = Result<Article, DbError>> + 'a;
fn update_one(&mut self, model: Article) -> Self::UpdateOneFut<'_> {
async move {
Ok(diesel::update(articles::table)
.filter(articles::columns::id.eq(model.id))
.set(model)
.get_result(self)
.await?)
}
}
}
impl Delete<Pk> for DbConnection {
type DeleteFut<'a> = impl futures::Future<Output = Result<usize, DbError>> + 'a;
fn delete(&mut self, Pk(pk): Pk) -> Self::DeleteFut<'_> {
async move {
Ok(diesel::delete(articles::table)
.filter(articles::columns::id.eq(pk))
.execute(self)
.await?)
}
}
}
@ -88,7 +180,7 @@ pub mod form {
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct NewArticleFrom {
pub struct NewArticleForm {
pub title: String,
pub body: String,
pub published: bool,
@ -98,8 +190,8 @@ pub mod form {
pub keywords: String,
}
impl From<NewArticleFrom> for NewArticle {
fn from(form: NewArticleFrom) -> Self {
impl From<NewArticleForm> for NewArticle {
fn from(form: NewArticleForm) -> Self {
Self {
title: form.title,
body: form.body,
@ -110,7 +202,7 @@ pub mod form {
keywords: if form.keywords.is_empty() {
vec![]
} else {
form.keywords.split(",").map(String::from).collect()
form.keywords.split(',').map(String::from).collect()
},
}
}
@ -124,33 +216,33 @@ pub mod view {
use std::ops::Deref;
#[derive(Debug, Serialize)]
pub struct ArticleView<'a> {
article: &'a Article,
pub struct ArticleView {
article: Article,
pub timestamp: i64,
pub markdown_content: String,
pub description: String,
}
impl<'a> Deref for ArticleView<'a> {
impl Deref for ArticleView {
type Target = Article;
fn deref(&self) -> &Self::Target {
self.article
&self.article
}
}
impl<'a> ArticleView<'a> {
pub fn from(article: &'a Article) -> ArticleView {
impl ArticleView {
pub fn from(article: Article) -> ArticleView {
let content_split: Vec<_> = article.body.split("<!--more-->").collect();
let description_parser = Parser::new(&content_split[0]);
let description_parser = Parser::new(content_split[0]);
let parser = Parser::new(&article.body);
let mut description_buf = String::new();
let mut content_buf = String::new();
html::push_html(&mut content_buf, parser);
html::push_html(&mut description_buf, description_parser);
ArticleView {
article,
timestamp: article.publish_at.timestamp(),
article,
markdown_content: content_buf,
description: description_buf,
}

View File

@ -1,27 +1,80 @@
use diesel::{pg::PgConnection, result::Error};
use futures::{Future, Stream};
use crate::pg_pool::Connection;
pub mod article;
pub mod setting;
pub mod token;
pub mod user;
pub trait CRUD<CreatedModel, UpdateModel, PK> {
fn create(conn: &PgConnection, from: &CreatedModel) -> Result<Self, Error>
where
Self: Sized;
fn read(conn: &PgConnection) -> Vec<Self>
pub trait QueryOne<Q>: Connection {
type Item;
type QueryOneFut<'a>: Future<Output = Result<Self::Item, Self::Error>> + 'a
where
Self: Sized;
Self: 'a,
Q: 'a;
fn update(conn: &PgConnection, pk: PK, value: &UpdateModel) -> Result<Self, Error>
where
Self: Sized;
fn delete(conn: &PgConnection, pk: PK) -> Result<usize, Error>
where
Self: Sized;
fn get_by_pk(conn: &PgConnection, pk: PK) -> Result<Self, Error>
where
Self: Sized;
fn query_one(&mut self, query: Q) -> Self::QueryOneFut<'_>;
}
pub trait Query<Q>: Connection {
type Item;
type QueryFut<'a>: Future<Output = Result<Self::QueryStream<'a>, Self::Error>> + 'a
where
Self: 'a,
Q: 'a;
type QueryStream<'a>: Stream<Item = Result<Self::Item, Self::Error>> + 'a
where
Self: 'a,
Q: 'a;
fn query(&mut self, query: Q) -> Self::QueryFut<'_>;
}
pub trait Update<U>: Connection {
type Item;
type UpdateOneFut<'a>: Future<Output = Result<Self::Item, Self::Error>> + 'a
where
Self: 'a,
U: 'a;
fn update_one(&mut self, model: U) -> Self::UpdateOneFut<'_>;
}
pub trait UpdateMany<U, Q>: Connection {
type Item;
type UpdateManyStream<'a>: Stream<Item = Result<Self::Item, Self::Error>> + 'a
where
Self: 'a,
U: 'a,
Q: 'a;
type UpdateManyFut<'a>: Future<Output = Result<Self::UpdateManyStream<'a>, Self::Error>> + 'a
where
Self: 'a,
U: 'a,
Q: 'a;
fn update_many(&mut self, query: Q, update: U) -> Self::UpdateManyFut<'_>;
}
pub trait Create<C>: Connection {
type Item;
type CreateFut<'a>: Future<Output = Result<Self::Item, Self::Error>> + 'a
where
Self: 'a,
C: 'a;
fn create(&mut self, model: C) -> Self::CreateFut<'_>;
}
pub trait Delete<Q>: Connection {
type DeleteFut<'a>: Future<Output = Result<usize, Self::Error>> + 'a
where
Self: 'a,
Q: 'a;
fn delete(&mut self, query: Q) -> Self::DeleteFut<'_>;
}

View File

@ -1,10 +1,22 @@
use crate::{models::CRUD, schema::setting};
use diesel::{pg::PgConnection, prelude::*, result::Error, AsChangeset, Insertable, Queryable};
use crate::{
pg_pool::{DbConnection, DbError},
schema::setting,
};
use diesel::{prelude::*, AsChangeset, Insertable, Queryable};
use diesel_async::RunQueryDsl;
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::{collections::HashMap, pin::pin};
use super::{Query, QueryOne, UpdateMany};
pub struct Map;
pub struct All;
pub struct Pk<'a>(pub &'a str);
#[derive(Queryable, Debug, Serialize, Deserialize, Insertable, AsChangeset)]
#[table_name = "setting"]
#[diesel(table_name = setting)]
pub struct Setting {
pub name: String,
pub value: Option<String>,
@ -17,27 +29,28 @@ pub struct SettingMap {
pub url: String,
pub analysis: String,
}
#[derive(Queryable, Debug, Serialize, Deserialize, AsChangeset)]
#[table_name = "setting"]
#[diesel(table_name = setting)]
pub struct UpdateSetting {
pub value: Option<String>,
}
impl Setting {
// TODO refactor this method
pub fn load(conn: &PgConnection) -> SettingMap {
let settings = setting::table.load::<Setting>(conn).unwrap();
impl QueryOne<Map> for DbConnection {
type Item = SettingMap;
type QueryOneFut<'a> = impl futures::Future<Output = Result<Self::Item, DbError>> + 'a;
fn query_one(&mut self, _: Map) -> Self::QueryOneFut<'_> {
async move {
let mut settings_map: HashMap<String, String> = HashMap::new();
let mut stream = pin!(setting::table.load_stream::<Setting>(self).await?);
for one_setting in settings {
settings_map.insert(
one_setting.name,
one_setting.value.unwrap_or("".to_string()),
);
while let Some(item) = stream.try_next().await? {
settings_map.insert(item.name, item.value.unwrap_or("".to_string()));
}
SettingMap {
Ok(SettingMap {
title: settings_map.get("title").unwrap_or(&"".to_string()).clone(),
description: settings_map
.get("description")
@ -49,30 +62,44 @@ impl Setting {
.get("analysis")
.unwrap_or(&"".to_string())
.clone(),
})
}
}
}
impl CRUD<(), UpdateSetting, String> for Setting {
fn create(_conn: &PgConnection, _from: &()) -> Result<Self, Error> {
unimplemented!()
impl Query<All> for DbConnection {
type Item = Setting;
type QueryFut<'a> = impl futures::Future<Output = Result<Self::QueryStream<'a>, DbError>> + 'a;
type QueryStream<'a> = impl futures::Stream<Item = Result<Self::Item, DbError>> + 'a;
fn query(&mut self, _query: All) -> Self::QueryFut<'_> {
async move {
Ok(setting::table
.load_stream::<Setting>(self)
.await?
.map_err(DbError::from))
}
}
}
fn read(_conn: &PgConnection) -> Vec<Self> {
unimplemented!()
}
impl<'b> UpdateMany<UpdateSetting, Pk<'b>> for DbConnection {
type Item = Setting;
fn update(conn: &PgConnection, pk: String, value: &UpdateSetting) -> Result<Self, Error> {
diesel::update(setting::table.find(&pk))
.set(value)
.get_result(conn)
}
type UpdateManyFut<'a> = impl futures::Future<Output = Result<Self::UpdateManyStream<'a>, DbError>> + 'a
where 'b: 'a;
type UpdateManyStream<'a> = impl futures::Stream<Item = Result<Self::Item, DbError>> + 'a
where 'b: 'a;
fn delete(_conn: &PgConnection, _pk: String) -> Result<usize, Error> {
unimplemented!()
}
fn get_by_pk(_conn: &PgConnection, _pk: String) -> Result<Self, Error> {
unimplemented!()
fn update_many(&mut self, Pk(pk): Pk<'b>, model: UpdateSetting) -> Self::UpdateManyFut<'_> {
async move {
Ok(
diesel::update(setting::table.filter(setting::columns::name.eq(pk)))
.set(model)
.load_stream(self)
.await?
.map_err(DbError::from),
)
}
}
}

View File

@ -1,16 +1,24 @@
use actix_web::{dev::Payload, FromRequest, HttpMessage, HttpRequest};
use chrono::NaiveDateTime;
use crypto::{digest::Digest, sha3::Sha3};
use diesel::{pg::PgConnection, prelude::*, result::Error, AsChangeset, Insertable, Queryable};
use futures::future::{ready, Ready};
use diesel::{prelude::*, AsChangeset, Insertable, Queryable};
use diesel_async::RunQueryDsl;
use futures::TryStreamExt;
use serde::Serialize;
use crate::{
data::RubbleData, error::RubbleError, models::CRUD, schema::users, utils::jwt::JWTClaims,
pg_pool::{DbConnection, DbError},
schema::users,
};
#[derive(Queryable, Debug, Serialize, Insertable, AsChangeset, Clone)]
#[table_name = "users"]
use super::{Query, QueryOne, Update};
pub struct All;
pub struct Pk(pub i32);
pub struct UserNameQuery<'a>(pub &'a str);
#[derive(Queryable, Debug, Serialize, Insertable, AsChangeset, Clone, Identifiable)]
#[diesel(table_name = users)]
pub struct User {
pub id: i32,
pub username: String,
@ -32,80 +40,91 @@ impl User {
hasher.input_str(password);
hasher.result_str()
}
}
pub fn find_by_username(conn: &PgConnection, username: &str) -> Result<Self, Error> {
users::table
.filter(users::username.eq(username.to_string()))
.first::<User>(conn)
impl<'b> QueryOne<UserNameQuery<'b>> for DbConnection {
type Item = User;
type QueryOneFut<'a> = impl futures::Future<Output = Result<User, DbError>> + 'a
where 'b: 'a;
fn query_one(&mut self, query: UserNameQuery<'b>) -> Self::QueryOneFut<'_> {
async move {
Ok(users::table
.filter(users::username.eq(query.0))
.first(self)
.await?)
}
}
}
impl CRUD<(), User, i32> for User {
fn create(_conn: &PgConnection, _from: &()) -> Result<Self, Error> {
unreachable!()
}
impl Update<User> for DbConnection {
type Item = User;
fn read(_conn: &PgConnection) -> Vec<Self> {
unreachable!()
}
type UpdateOneFut<'a> = impl futures::Future<Output = Result<User, DbError>> + 'a;
fn update(conn: &PgConnection, pk: i32, value: &User) -> Result<Self, Error> {
diesel::update(users::table.find(pk))
.set(value)
.get_result(conn)
fn update_one(&mut self, model: User) -> Self::UpdateOneFut<'_> {
async move {
Ok(diesel::update(users::table.find(model.id))
.set(model)
.get_result(self)
.await?)
}
fn delete(_conn: &PgConnection, _pk: i32) -> Result<usize, Error> {
unreachable!()
}
fn get_by_pk(conn: &PgConnection, pk: i32) -> Result<Self, Error> {
users::table.filter(users::id.eq(pk)).first::<User>(conn)
}
}
impl FromRequest for User {
type Config = ();
type Error = RubbleError<&'static str>;
type Future = Ready<Result<Self, Self::Error>>;
impl Query<All> for DbConnection {
type Item = User;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let data = req.app_data::<RubbleData>().expect("cannot get app data");
let authentication_cookie = req.cookie("Authorization");
type QueryFut<'a> = impl futures::Future<Output = Result<Self::QueryStream<'a>, DbError>> + 'a;
type QueryStream<'a> = impl futures::Stream<Item = Result<User, DbError>> + 'a;
let cookie_token = authentication_cookie.as_ref().map(|cookie| cookie.value());
let user = req
.headers()
.get("Authorization")
.map(|header| header.to_str())
.transpose()
.map(|header_token| header_token.or(cookie_token))
.map_err(|_| RubbleError::BadRequest("error on deserialize token"))
.and_then(|token| {
token.ok_or(RubbleError::Unauthorized("cannot get authentication token"))
})
.map(|jwt| jwt.splitn(2, ' ').collect::<Vec<&str>>())
.and_then(|tokens| {
if tokens.len() == 2 {
Ok(tokens[1])
} else {
Err(RubbleError::BadRequest("error on deserialize token"))
}
})
.and_then(|jwt| {
JWTClaims::decode(jwt.into())
.map_err(|_| RubbleError::Unauthorized("invalid jwt token"))
})
.and_then(|user_id| {
User::find_by_username(&data.postgres(), &user_id)
.map_err(|_| RubbleError::Unauthorized("error on get user"))
});
ready(user)
fn query(&mut self, _query: All) -> Self::QueryFut<'_> {
async move { Ok(users::table.load_stream(self).await?.map_err(DbError::from)) }
}
}
// impl FromRequest for User {
// type Error = Error<&'static str>;
// type Future = Ready<Result<Self, Self::Error>>;
// fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
// let data = req.app_data::<CrablogState>().expect("cannot get app data");
// let authentication_cookie = req.cookie("Authorization");
// let cookie_token = authentication_cookie.as_ref().map(|cookie| cookie.value());
// let user = req
// .headers()
// .get("Authorization")
// .map(|header| header.to_str())
// .transpose()
// .map(|header_token| header_token.or(cookie_token))
// .map_err(|_| Error::BadRequest("error on deserialize token"))
// .and_then(|token| {
// token.ok_or(Error::Unauthorized("cannot get authentication token"))
// })
// .map(|jwt| jwt.splitn(2, ' ').collect::<Vec<&str>>())
// .and_then(|tokens| {
// if tokens.len() == 2 {
// Ok(tokens[1])
// } else {
// Err(Error::BadRequest("error on deserialize token"))
// }
// })
// .and_then(|jwt| {
// JWTClaims::decode(jwt.into())
// .map_err(|_| Error::Unauthorized("invalid jwt token"))
// })
// .and_then(|user_id| {
// User::find_by_username(&mut data.postgres(), &user_id)
// .map_err(|_| Error::Unauthorized("error on get user"))
// });
// ready(user)
// }
// }
pub mod input {
use serde::{Deserialize, Serialize};

View File

@ -1,11 +1,64 @@
use diesel::{pg::PgConnection, r2d2::ConnectionManager};
use r2d2;
use std::ops::{Deref, DerefMut};
pub type ManagedPgConn = ConnectionManager<PgConnection>;
use diesel_async::{
pg::AsyncPgConnection,
pooled_connection::{
deadpool::{self, BuildError, Object},
AsyncDieselConnectionManager,
},
};
pub type Pool = r2d2::Pool<ManagedPgConn>;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("PoolError: {0}")]
PoolError(#[from] BuildError),
pub fn database_pool_establish(database_url: &str) -> Pool {
let manager = ConnectionManager::<PgConnection>::new(database_url);
r2d2::Pool::new(manager).expect("Failed to create pool.")
#[error("ResultError: {0}")]
ResultError(diesel::result::Error),
#[error("Record not found")]
NotFound,
}
impl From<diesel::result::Error> for DbError {
fn from(v: diesel::result::Error) -> Self {
match v {
diesel::result::Error::NotFound => Self::NotFound,
err => Self::ResultError(err),
}
}
}
pub trait Connection: DerefMut<Target = AsyncPgConnection> {
type Error: std::error::Error;
}
pub type Pool = deadpool::Pool<AsyncPgConnection>;
pub fn database_pool_establish<S: Into<String>>(durl: S) -> Result<Pool, DbError> {
Ok(Pool::builder(AsyncDieselConnectionManager::new(durl)).build()?)
}
pub struct DbConnection(Object<AsyncPgConnection>);
impl DbConnection {
pub(crate) fn new(conn: Object<AsyncPgConnection>) -> Self {
DbConnection(conn)
}
}
impl Deref for DbConnection {
type Target = AsyncPgConnection;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for DbConnection {
fn deref_mut(&mut self) -> &mut Self::Target {
self.0.as_mut()
}
}
impl Connection for DbConnection {
type Error = DbError;
}

View File

@ -1,20 +1,49 @@
use actix_web::{cookie::Cookie, get, post, web, web::Form, HttpResponse, Responder};
use axum::{
async_trait,
extract::{FromRequestParts, Path, State},
response::{Html, IntoResponse, Redirect},
routing::{get, post},
Form, Router,
};
use chrono::{NaiveDateTime, Utc};
use futures::stream::TryStreamExt;
use http::{request::Parts, StatusCode};
use serde::{Deserialize, Serialize};
use tera::Context;
use crate::{
data::RubbleData,
data::CrablogState,
error::Error,
models::{
article::{Article, NewArticle},
setting::{Setting, UpdateSetting},
user::User,
CRUD,
article::{self, form::NewArticleForm, Article, NewArticle},
setting::{self, Setting, SettingMap, UpdateSetting},
user::{self, UserNameQuery},
Create, Delete, Query, QueryOne, Update, UpdateMany,
},
routers::RubbleResponder,
pg_pool::DbError,
utils::jwt::JWTClaims,
};
pub struct User(pub user::User);
#[async_trait]
impl FromRequestParts<CrablogState> for User {
type Rejection = (StatusCode, String);
async fn from_request_parts(
_parts: &mut Parts,
_state: &CrablogState,
) -> Result<Self, Self::Rejection> {
Ok(Self(user::User {
id: 1,
username: "demo".into(),
password: "demo".into(),
create_at: chrono::Local::now().naive_local(),
last_login_at: chrono::Local::now().naive_local(),
}))
}
}
#[derive(Deserialize, Serialize)]
pub struct LoginForm {
pub username: String,
@ -26,84 +55,93 @@ pub struct NewPassword {
password: String,
}
#[get("")]
pub async fn redirect_to_admin_panel(user: Option<User>) -> impl Responder {
// pub async fn redirect_to_admin_panel(user: Option<User>) -> impl IntoResponse {
// if user.is_some() {
// Redirect::to("/admin/panel")
// } else {
// Redirect::to("/admin/login")
// }
// }
pub async fn admin_login(
user: Option<User>,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
if user.is_some() {
RubbleResponder::redirect("/admin/panel")
Ok(Redirect::to("/admin/panel").into_response())
} else {
RubbleResponder::redirect("/admin/login")
Ok(Html(data.render("admin/login.html", &Context::new())).into_response())
}
}
#[get("/login")]
pub async fn admin_login(user: Option<User>, data: web::Data<RubbleData>) -> impl Responder {
if user.is_some() {
RubbleResponder::redirect("/admin/panel")
} else {
RubbleResponder::html(data.render("admin/login.html", &Context::new()))
}
}
#[post("/login")]
pub async fn admin_authentication(
user: Form<LoginForm>,
data: web::Data<RubbleData>,
) -> impl Responder {
let fetched_user = User::find_by_username(&data.postgres(), &user.username);
State(data): State<CrablogState>,
Form(user): Form<LoginForm>,
) -> Result<impl IntoResponse, Error> {
let fetched_user = data
.db()
.await
.query_one(UserNameQuery(&user.username))
.await;
match fetched_user {
Ok(match fetched_user {
Ok(login_user) => {
if login_user.authenticated(&user.password) {
let jwt = JWTClaims::encode(&login_user);
let _jwt = JWTClaims::encode(&login_user);
HttpResponse::Found()
.header(http::header::LOCATION, "/admin/panel")
.cookie(
Cookie::build("Authorization", format!("JWT {}", jwt))
.path("/")
.max_age(60 * 60 * 24 * 7)
.finish(),
)
.finish()
([
(http::header::LOCATION, "/admin/panel"),
// (http::header::AUTHORIZATION, format!("JWT {}", jwt))
])
.into_response()
// Cookie::build("Authorization", )
// .path("/")
// .max_age(time::Duration::weeks(1))
// .finish(),
} else {
RubbleResponder::redirect("/admin/login")
Redirect::to("/admin/login").into_response()
}
}
Err(_) => RubbleResponder::redirect("/admin/login"),
}
Err(_) => Redirect::to("/admin/login").into_response(),
})
}
#[get("/panel")]
pub async fn admin_panel(user: User, data: web::Data<RubbleData>) -> impl Responder {
let articles = Article::read(&data.postgres());
let settings = Setting::load(&data.postgres());
pub async fn admin_panel(
User(user): User,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
let mut db = data.db().await;
let articles: Vec<Article> = db.query(article::All).await?.try_collect().await?;
let settings: SettingMap = db.query_one(setting::Map).await?;
let mut context = Context::new();
context.insert("setting", &settings);
context.insert("articles", &articles);
context.insert("admin", &user);
RubbleResponder::html(data.render("admin/panel.html", &context))
Ok(Html(data.render("admin/panel.html", &context)))
}
#[get("/{path}")]
pub async fn admin_show_page(
user: User,
path: web::Path<String>,
data: web::Data<RubbleData>,
) -> impl Responder {
let settings = Setting::load(&data.postgres());
User(user): User,
Path(path): Path<String>,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
let settings: SettingMap = data.db().await.query_one(setting::Map).await?;
let mut context = Context::new();
context.insert("setting", &settings);
context.insert("admin", &user);
RubbleResponder::html(data.render(&format!("admin/{}.html", path), &context))
Ok(Html(data.render(&format!("admin/{}.html", path), &context)))
}
#[get("/article/new")]
pub async fn article_creation(user: User, data: web::Data<RubbleData>) -> impl Responder {
let settings = Setting::load(&data.postgres());
pub async fn article_creation(
User(user): User,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
let settings: SettingMap = data.db().await.query_one(setting::Map).await?;
let mut context = Context::new();
let article = NewArticle {
@ -111,7 +149,7 @@ pub async fn article_creation(user: User, data: web::Data<RubbleData>) -> impl R
body: String::new(),
published: true,
user_id: user.id,
publish_at: Some(NaiveDateTime::from_timestamp(Utc::now().timestamp(), 0)),
publish_at: NaiveDateTime::from_timestamp_opt(Utc::now().timestamp(), 0),
url: None,
keywords: vec![],
};
@ -120,96 +158,113 @@ pub async fn article_creation(user: User, data: web::Data<RubbleData>) -> impl R
context.insert("setting", &settings);
context.insert("admin", &user);
RubbleResponder::html(data.render("admin/article_add.html", &context))
Ok(Html(data.render("admin/article_add.html", &context)))
}
#[get("/article/{article_id}")]
pub async fn article_edit(
user: User,
article_id: web::Path<i32>,
data: web::Data<RubbleData>,
) -> impl Responder {
let settings = Setting::load(&data.postgres());
User(user): User,
Path(article_id): Path<i32>,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
let mut db = data.db().await;
let settings: SettingMap = db.query_one(setting::Map).await?;
let article = match db.query_one(article::Pk(article_id)).await {
Ok(article) => article,
Err(DbError::NotFound) => return Ok(Redirect::to("/admin/panel").into_response()),
Err(err) => {
return Err(Error::new(
crate::error::ErrorKind::InternalError,
format!("Error: {}", err),
))
}
};
let result = Article::get_by_pk(&data.postgres(), article_id.into_inner());
match result {
Ok(article) => {
let mut context = Context::new();
context.insert("article", &article);
context.insert("setting", &settings);
context.insert("admin", &user);
RubbleResponder::html(data.render("admin/article_add.html", &context))
}
Err(_) => RubbleResponder::redirect("/admin/panel"),
}
Ok(Html(data.render("admin/article_add.html", &context)).into_response())
}
#[post("/article")]
pub async fn article_save(
_user: User,
article: Form<crate::models::article::form::NewArticleFrom>,
data: web::Data<RubbleData>,
) -> impl Responder {
let _article_title = article.title.clone();
State(data): State<CrablogState>,
Form(article): Form<NewArticleForm>,
) -> Result<impl IntoResponse, Error> {
let _res = data.db().await.create(article.into()).await?;
Article::create(&data.postgres(), &article.into_inner().into());
RubbleResponder::redirect("/admin/panel")
Ok(Redirect::to("/admin/panel"))
}
#[post("/article/{aid}")]
pub async fn article_update(
_user: User,
aid: web::Path<i32>,
article: Form<crate::models::article::form::NewArticleFrom>,
data: web::Data<RubbleData>,
) -> impl Responder {
Article::update(&data.postgres(), *aid, &article.into_inner().into());
Path(article_id): Path<i32>,
State(data): State<CrablogState>,
Form(article_form): Form<NewArticleForm>,
) -> Result<impl IntoResponse, Error> {
let _ = data
.db()
.await
.update_many(article::Pk(article_id), article_form.into())
.await;
RubbleResponder::redirect("/admin/panel")
Ok(Redirect::to("/admin/panel"))
}
#[post("/article/delete/{article_id}")]
pub async fn article_deletion(
_user: User,
article_id: web::Path<i32>,
data: web::Data<RubbleData>,
) -> impl Responder {
let i = article_id.into_inner();
Article::delete(&data.postgres(), i);
Path(article_id): Path<i32>,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
let _ = data.db().await.delete(article::Pk(article_id)).await?;
RubbleResponder::redirect("/admin/panel")
Ok(Redirect::to("/admin/panel"))
}
#[post("/password")]
pub async fn change_password(
mut user: User,
password: web::Form<NewPassword>,
data: web::Data<RubbleData>,
) -> impl Responder {
user.password = User::password_generate(&password.password).to_string();
User::update(&data.postgres(), user.id, &user);
RubbleResponder::redirect("/admin/panel")
User(mut user): User,
State(data): State<CrablogState>,
Form(password): Form<NewPassword>,
) -> Result<impl IntoResponse, Error> {
user.password = crate::models::user::User::password_generate(&password.password).to_string();
data.db().await.update_one(user).await?;
Ok(Redirect::to("/admin/panel"))
}
#[post("/setting")]
pub async fn change_setting(
_user: User,
setting: web::Form<Setting>,
data: web::Data<RubbleData>,
) -> impl Responder {
let update_setting = UpdateSetting {
State(data): State<CrablogState>,
Form(setting): Form<Setting>,
) -> Result<impl IntoResponse, Error> {
let _ = data
.db()
.await
.update_many(
setting::Pk(&setting.name),
UpdateSetting {
value: setting.value.clone(),
};
Setting::update(&data.postgres(), setting.name.clone(), &update_setting);
RubbleResponder::redirect("/admin/site-setting")
},
)
.await?;
Ok(Redirect::to("/admin/site-setting"))
}
#[cfg(test)]
mod test {
#[test]
fn test_normal() {
assert_eq!(1, 1);
}
pub fn router() -> Router<CrablogState> {
Router::new()
.route("/login", get(admin_login).post(admin_authentication))
.route("/setting", post(change_setting))
.route("/password", post(change_password))
.route("/article/delete/:article_id", post(article_deletion))
.route(
"/article/:article_id",
get(article_edit).post(article_update),
)
.route("/article", post(article_save))
.route("/article/new", get(article_creation))
.route("/panel", get(admin_panel))
.route("/{path}", get(admin_show_page))
}

View File

@ -1,57 +1,94 @@
use crate::{
data::RubbleData,
error::RubbleError,
models::{article::Article, user::User, CRUD},
routers::RubbleResponder,
use axum::{
extract::{Path, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use actix_web::{delete, get, post, put, web, Responder};
#[get("/articles")]
pub async fn get_all_article(_user: User, data: web::Data<RubbleData>) -> impl Responder {
RubbleResponder::json(Article::read(&data.postgres()))
use crate::{
data::CrablogState,
error::Error,
models::{
article::{self, Article, NewArticle},
Create, Delete, Query, QueryOne, UpdateMany,
},
routers::admin::User,
};
use futures::TryStreamExt;
pub async fn get_all_article(
_user: User,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
let articles: Vec<Article> = data
.db()
.await
.query(article::All)
.await?
.try_collect()
.await?;
Ok(Json(articles))
}
#[get("/articles/{id}")]
pub async fn get_article_by_id(
_user: User,
id: web::Path<i32>,
data: web::Data<RubbleData>,
) -> impl Responder {
Article::get_by_pk(&data.postgres(), *id)
.map(RubbleResponder::json)
.map_err(|_| RubbleResponder::not_found())
Path(id): Path<i32>,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
data.db()
.await
.query_one(article::Pk(id))
.await
.map(Json)
.map_err(|_| Error::not_found("article not found"))
}
#[post("/articles")]
pub async fn crate_article(
_user: User,
article: web::Json<crate::models::article::NewArticle>,
data: web::Data<RubbleData>,
) -> impl Responder {
Article::create(&data.postgres(), &article)
.map(RubbleResponder::json)
.map_err(|_| RubbleError::BadRequest("something wrong when creating article"))
State(data): State<CrablogState>,
Json(article): Json<NewArticle>,
) -> Result<impl IntoResponse, Error> {
Ok(Json(data.db().await.create(article).await?))
}
#[put("/articles/{id}")]
pub async fn update_article_by_id(
_user: User,
id: web::Path<i32>,
article: web::Json<crate::models::article::NewArticle>,
data: web::Data<RubbleData>,
) -> impl Responder {
Article::update(&data.postgres(), *id, &article)
.map(|data| RubbleResponder::json(data))
.map_err(|_| RubbleError::BadRequest("something wrong when updating article"))
Path(id): Path<i32>,
State(data): State<CrablogState>,
Json(article): Json<NewArticle>,
) -> Result<impl IntoResponse, Error> {
data.db()
.await
.update_many(article::Pk(id), article)
.await?
.try_next()
.await
.map(Json)
.map_err(|_| Error::not_found("article not found"))
}
#[delete("/articles/{id}")]
pub async fn delete_article_by_id(
_user: User,
id: web::Path<i32>,
data: web::Data<RubbleData>,
) -> impl Responder {
Article::delete(&data.postgres(), *id)
.map(|_| RubbleResponder::json("Ok"))
.map_err(|_| RubbleError::BadRequest("something wrong when deleting article"))
Path(id): Path<i32>,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
data.db()
.await
.delete(article::Pk(id))
.await
.map(Json)
.map_err(|_| Error::not_found("something wrong when deleting article"))
}
pub fn router() -> Router<CrablogState> {
Router::new()
.route("/", get(get_all_article).post(crate_article))
.route(
"/:id",
get(get_article_by_id)
.put(update_article_by_id)
.delete(delete_article_by_id),
)
}

View File

@ -1,21 +1,14 @@
use actix_web::web;
use axum::Router;
use crate::data::CrablogState;
pub mod article;
pub mod setting;
pub mod user;
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg
// user related
.service(user::admin_authentication)
.service(user::update_user_password)
// article related
.service(article::get_all_article)
.service(article::crate_article)
.service(article::get_article_by_id)
.service(article::update_article_by_id)
.service(article::delete_article_by_id)
// setting related
.service(setting::get_settings)
.service(setting::update_setting_by_key);
pub fn router() -> Router<CrablogState> {
Router::new()
.nest("/settings", setting::router())
.nest("/users", user::router())
.nest("/articles", article::router())
}

View File

@ -1,29 +1,54 @@
use crate::{
data::RubbleData,
error::RubbleError,
models::{
setting::{Setting, UpdateSetting},
user::User,
CRUD,
},
routers::RubbleResponder,
use axum::{
extract::{Path, State},
response::IntoResponse,
routing::{get, put},
Json, Router,
};
use actix_web::{get, put, web, Responder};
#[get("/settings")]
pub async fn get_settings(_user: User, data: web::Data<RubbleData>) -> impl Responder {
RubbleResponder::json(Setting::load(&data.postgres()))
use futures::TryStreamExt;
use crate::{
data::CrablogState,
error::Error,
models::{
setting::{self, UpdateSetting},
Query, UpdateMany,
},
routers::admin::User,
};
pub async fn get_settings(
_user: User,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
Ok(Json(
data.db()
.await
.query(setting::All)
.await?
.try_collect::<Vec<_>>()
.await?,
))
}
#[put("settings/{key}")]
pub async fn update_setting_by_key(
_user: User,
key: web::Path<String>,
value: web::Json<UpdateSetting>,
data: web::Data<RubbleData>,
) -> impl Responder {
let string = (*key).clone();
Setting::update(&data.postgres(), string, &value)
.map(RubbleResponder::json)
.map_err(|_| RubbleError::BadRequest("error on updating setting"))
Path(key): Path<String>,
State(data): State<CrablogState>,
Json(value): Json<UpdateSetting>,
) -> Result<impl IntoResponse, Error> {
Ok(Json(
data.db()
.await
.update_many(setting::Pk(&key), value)
.await?
.try_next()
.await?,
))
}
pub fn router() -> Router<CrablogState> {
Router::new()
.route("/", get(get_settings))
.route("/:key", put(update_setting_by_key))
}

View File

@ -1,86 +1,77 @@
//! /authentications routes
use crate::{
data::RubbleData,
error::RubbleError,
data::CrablogState,
error::Error,
models::{
token::Token,
user::{input::LoginForm, User},
CRUD,
user::{self, input::LoginForm, UserNameQuery},
QueryOne, Update,
},
routers::RubbleResponder,
routers::admin::User,
utils::jwt::JWTClaims,
};
use actix_web::{
post, put, web,
web::{Data, Json},
Responder,
use axum::{
extract::{Path, State},
response::IntoResponse,
routing::{post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
#[post("/user/token")]
pub async fn admin_authentication(
user: web::Json<LoginForm>,
data: web::Data<RubbleData>,
) -> impl Responder {
let fetched_user = User::find_by_username(&data.postgres(), &user.username);
State(data): State<CrablogState>,
Json(user): Json<LoginForm>,
) -> Result<impl IntoResponse, Error> {
let fetched_user = data
.db()
.await
.query_one(UserNameQuery(&user.username))
.await;
match fetched_user {
Ok(login_user) => {
if login_user.authenticated(&user.password) {
let string = JWTClaims::encode(&login_user);
Ok(RubbleResponder::json(Token { token: string }))
Ok(Json(Token { token: string }))
} else {
Err(RubbleError::Unauthorized("invalid password"))
Err(Error::unauthorized("invalid password"))
}
}
Err(_) => Err(RubbleError::Unauthorized("invalid username")),
Err(_) => Err(Error::unauthorized("invalid username")),
}
}
//
//#[get("")]
// pub fn get_all_users() -> impl Responder {
// unreachable!()
//}
//
//#[post("")]
// pub fn crate_user() -> impl Responder {
// unreachable!()
//}
//
//#[put("/{id}")]
// pub fn update_user_by_id() -> impl Responder {
// unreachable!()
//}
//
//#[delete("/{id}")]
// pub fn delete_user_by_id() -> impl Responder {
// unreachable!()
//}
//
#[derive(Serialize, Deserialize)]
pub struct UpdatedUserPassword {
pub password: String,
}
#[put("/users/{id}/password")]
pub async fn update_user_password(
_user: User,
id: web::Path<String>,
json: Json<UpdatedUserPassword>,
data: Data<RubbleData>,
) -> impl Responder {
Path(id): Path<String>,
State(data): State<CrablogState>,
Json(json): Json<UpdatedUserPassword>,
) -> Result<impl IntoResponse, Error> {
if json.password.eq("") {
return Err(RubbleError::BadRequest("password can not be empty"));
return Err(Error::bad_request("password can not be empty"));
}
let admin = User::find_by_username(&data.postgres(), &id);
admin
.map(|mut user| {
user.password = User::password_generate(&json.password).to_string();
User::update(&data.postgres(), user.id, &user);
RubbleResponder::json("OK")
})
.map_err(|_e| RubbleError::BadRequest("cannot get admin"))
let mut user = data
.db()
.await
.query_one(UserNameQuery(&id))
.await
.map_err(|_| Error::bad_request("cannot get admin"))?;
user.password = user::User::password_generate(&json.password).to_string();
data.db().await.update_one(user).await?;
Ok(Json("Ok"))
}
pub fn router() -> Router<CrablogState> {
Router::new()
.route("/token", post(admin_authentication))
.route("/:id/password", put(update_user_password))
}

View File

@ -1,84 +1,91 @@
use crate::{
data::RubbleData,
data::CrablogState,
error::Error,
models::{
article::{view::ArticleView, Article},
setting::Setting,
CRUD,
article::{self, view::ArticleView},
setting, Query, QueryOne, Update,
},
routers::RubbleResponder,
};
use actix_web::{get, web, Responder};
use axum::{
extract::{Path, State},
response::{Html, IntoResponse, Redirect},
routing::get,
Router,
};
use futures::TryStreamExt;
use tera::Context;
#[get("/")]
pub async fn homepage(data: web::Data<RubbleData>) -> impl Responder {
let vec: Vec<Article> = Article::read(&data.postgres());
let article_view: Vec<_> = vec
.iter()
.filter(|article| article.published == true)
.map(ArticleView::from)
.collect();
pub async fn homepage(State(data): State<CrablogState>) -> Result<impl IntoResponse, Error> {
let mut db = data.db().await;
let settings = Setting::load(&data.postgres());
let article_views: Vec<_> = db
.query(article::Published)
.await?
.map_ok(ArticleView::from)
.try_collect()
.await?;
let settings = db.query_one(setting::Map).await?;
let mut context = Context::new();
context.insert("setting", &settings);
context.insert("articles", &article_view);
context.insert("articles", &article_views);
RubbleResponder::html(data.render("homepage.html", &context))
Ok(Html(data.render("homepage.html", &context)))
}
#[get("archives/{archives_id}")]
pub async fn single_article(
archives_id: web::Path<i32>,
data: web::Data<RubbleData>,
) -> impl Responder {
let article = Article::get_by_pk(&data.postgres(), archives_id.into_inner());
Path(archives_id): Path<i32>,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
let mut db = data.db().await;
let mut article = db.query_one(article::Pk(archives_id)).await?;
if let Err(_e) = article {
return RubbleResponder::not_found();
}
let article1 = article.unwrap();
if let Some(ref to) = article1.url {
if to.len() != 0 {
return RubbleResponder::redirect(format!("/{}", to));
if let Some(ref to) = article.url {
if !to.is_empty() {
return Ok(Redirect::to(&format!("/{}", to)).into_response());
}
}
article1.increase_view(&data.postgres());
let view = ArticleView::from(&article1);
let view = ArticleView::from(article.clone());
let settings = Setting::load(&data.postgres());
article.view += 1;
db.update_one(article).await?;
let settings = db.query_one(setting::Map).await?;
let mut context = Context::new();
context.insert("setting", &settings);
context.insert("article", &view);
RubbleResponder::html(data.render("archives.html", &context))
Ok(Html(data.render("archives.html", &context)).into_response())
}
#[get("{url}")]
pub async fn get_article_by_url(
url: web::Path<String>,
data: web::Data<RubbleData>,
) -> impl Responder {
let article = Article::find_by_url(&data.postgres(), &url.into_inner());
Path(key): Path<String>,
State(data): State<CrablogState>,
) -> Result<impl IntoResponse, Error> {
let mut db = data.db().await;
let mut article = db.query_one(article::Url(&key)).await?;
if let Err(_e) = article {
return RubbleResponder::not_found();
}
let article1 = article.unwrap();
article1.increase_view(&data.postgres());
let view = ArticleView::from(article.clone());
let view = ArticleView::from(&article1);
article.view += 1;
db.update_one(article).await?;
let settings = Setting::load(&data.postgres());
let settings = db.query_one(setting::Map).await?;
let mut context = Context::new();
context.insert("setting", &settings);
context.insert("article", &view);
RubbleResponder::html(data.render("archives.html", &context))
Ok(Html(data.render("archives.html", &context)).into_response())
}
pub fn router() -> Router<CrablogState> {
Router::new()
.route("/", get(homepage))
.route("/:article", get(get_article_by_url))
.route("/archives/:archives_id", get(single_article))
}

View File

@ -1,84 +1,21 @@
use actix_web::{middleware::NormalizePath, web, HttpResponse};
use serde::{Deserialize, Serialize};
use axum::{routing::get, Router};
use tower_http::services::{ServeDir, ServeFile};
use crate::data::CrablogState;
pub mod admin;
pub mod api;
pub mod article;
pub mod rss;
#[derive(Deserialize, Serialize)]
pub struct JsonResponse<T> {
data: T,
}
pub fn routes() -> Router<CrablogState> {
let serve_dir = ServeDir::new("templates/resources")
.not_found_service(ServeFile::new("templates/not-found.html"));
#[derive(Deserialize, Serialize)]
pub struct ErrorResponse<T> {
message: T,
}
pub struct RubbleResponder;
impl RubbleResponder {
pub fn html(content: impl Into<String>) -> HttpResponse {
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(content.into())
}
pub fn json(data: impl Serialize) -> HttpResponse {
HttpResponse::Ok()
.header(
http::header::CONTENT_TYPE,
"application/json; charset=utf-8",
)
.json(JsonResponse { data })
}
pub fn text(content: impl Into<String>) -> HttpResponse {
HttpResponse::Ok().body(content.into())
}
pub fn redirect(to: impl Into<String>) -> HttpResponse {
HttpResponse::Found()
.header(http::header::LOCATION, to.into())
.finish()
}
pub fn redirect_permanently(to: impl Into<String>) -> HttpResponse {
HttpResponse::MovedPermanently()
.header(http::header::LOCATION, to.into())
.finish()
}
pub fn not_found() -> HttpResponse {
HttpResponse::NotFound().finish()
}
}
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/api").configure(api::routes))
.service(
web::scope("/admin")
.wrap(NormalizePath)
.service(admin::redirect_to_admin_panel)
.service(admin::admin_login)
.service(admin::admin_authentication)
.service(admin::admin_panel)
.service(admin::article_creation)
.service(admin::article_deletion)
.service(admin::article_edit)
.service(admin::article_save)
.service(admin::article_update)
.service(admin::change_password)
.service(admin::change_setting)
.service(admin::admin_show_page),
)
.service(article::homepage)
.service(article::single_article)
.service(actix_files::Files::new(
"/statics",
"./templates/resources/",
))
.service(rss::rss_)
.service(article::get_article_by_url);
Router::new()
.nest("/", article::router())
.nest("/api", api::router())
.nest("/admin", admin::router())
.route("/rss", get(rss::rss))
.nest_service("/statics", serve_dir)
}

View File

@ -1,35 +1,20 @@
use crate::{
data::RubbleData,
data::CrablogState,
error::Error,
models::{
article::{view::ArticleView, Article},
setting::Setting,
CRUD,
article::{self, view::ArticleView},
setting, Query, QueryOne,
},
};
use actix_web::{get, web, HttpResponse, Responder};
use rss::{Channel, ChannelBuilder, Item, ItemBuilder};
use axum::{extract::State, response::IntoResponse};
use futures::TryStreamExt;
use http::header;
use rss::{Channel, ChannelBuilder, ItemBuilder};
use std::collections::HashMap;
#[get("/rss")]
pub async fn rss_(data: web::Data<RubbleData>) -> impl Responder {
let articles = Article::read(&data.postgres());
let setting = Setting::load(&data.postgres());
let items: Vec<Item> = articles
.iter()
.filter(|article| article.published == true)
.map(ArticleView::from)
.map(|item| {
ItemBuilder::default()
.title(item.title.clone())
.link(format!("{}{}", setting.url, item.link()))
.description(item.description.clone())
.content(item.markdown_content.clone())
.pub_date(item.publish_at.to_string())
.build()
.unwrap()
})
.collect();
pub async fn rss(State(data): State<CrablogState>) -> Result<impl IntoResponse, Error> {
let mut db = data.db().await;
let setting = db.query_one(setting::Map).await?;
let mut namespaces: HashMap<String, String> = HashMap::new();
namespaces.insert(
@ -49,16 +34,38 @@ pub async fn rss_(data: web::Data<RubbleData>) -> impl Responder {
"http://search.yahoo.com/mrss/".to_string(),
);
let items = db
.query(article::Published)
.await?
.map_ok(ArticleView::from)
.map_ok(|item| {
ItemBuilder::default()
.title(Some(item.title.clone()))
.link(Some(format!("{}{}", setting.url, item.link())))
.description(Some(item.description.clone()))
.content(Some(item.markdown_content.clone()))
.pub_date(Some(item.publish_at.to_string()))
.build()
.unwrap()
})
.try_collect::<Vec<_>>()
.await?;
let channel: Channel = ChannelBuilder::default()
.title(setting.title)
.description(setting.description)
.generator("Rubble".to_string())
.generator(Some("Crablog".into()))
.link(setting.url.clone())
.items(items)
.namespaces(namespaces)
.build()
.unwrap();
HttpResponse::Ok()
.content_type("text/xml; charset=utf-8")
.body(channel.to_string())
Ok((
[(
header::CONTENT_TYPE,
header::HeaderValue::from_static("text/xml; charset=utf-8"),
)],
channel.to_string(),
))
}

View File

@ -1,3 +1,5 @@
use diesel::prelude::*;
table! {
articles (id) {
id -> Int4,

View File

@ -6,7 +6,6 @@ use jsonwebtoken::{
};
use serde::{Deserialize, Serialize};
use std::ops::Add;
use time::Duration;
#[derive(Debug, Serialize, Deserialize)]
pub struct JWTClaims {
@ -20,7 +19,8 @@ pub struct JWTClaims {
impl JWTClaims {
pub fn encode(user: &User) -> String {
let now: DateTime<Utc> = Utc::now();
let expire: DateTime<Utc> = Utc::now().add(Duration::days(7));
let expire: DateTime<Utc> = Utc::now().add(chrono::Duration::weeks(1));
let claims = JWTClaims {
iat: now.timestamp() as usize,
sub: String::from("LOGIN_TOKEN"),

View File

@ -1,5 +1,6 @@
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport"
@ -28,6 +29,7 @@
<link href="/statics/assets/css/dashboard.css" rel="stylesheet" />
<script src="/statics/assets/js/dashboard.js"></script>
</head>
<body class="">
<div class="page">
@ -75,8 +77,7 @@
<a href="/admin/site-setting" class="nav-link"><i class="fe fe-home"></i>Site Setting</a>
</li>
<li class="nav-item">
<a href="{{ setting.url }}/" class="nav-link" target="_blank"><i
class="fe fe-file-text"></i>
<a href="{{ setting.url }}/" class="nav-link" target="_blank"><i class="fe fe-file-text"></i>
Go to site
</a>
</li>
@ -92,11 +93,12 @@
<div class="container">
<div class="row align-items-center flex-row-reverse">
<div class="col-12 col-lg-auto mt-3 mt-lg-0 text-center">
Powered by Rubble.
Powered by Crablog.
</div>
</div>
</div>
</footer>
</div>
</body>
</html>

View File

@ -1,8 +1,10 @@
<!doctype html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta http-equiv="Content-Language" content="en" />
<meta name="msapplication-TileColor" content="#2d89ef">
@ -12,9 +14,10 @@
<meta name="mobile-web-app-capable" content="yes">
<meta name="HandheldFriendly" content="True">
<meta name="MobileOptimized" content="320">
<title>Login - Rubble</title>
<title>Login - Crablog</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,400,400i,500,500i,600,600i,700,700i&amp;subset=latin-ext">
<link rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,300i,400,400i,500,500i,600,600i,700,700i&amp;subset=latin-ext">
<script src="/statics/assets/js/require.min.js"></script>
<script>
requirejs.config({
@ -25,6 +28,7 @@
<link href="/statics/assets/css/dashboard.css" rel="stylesheet" />
<script src="/statics/assets/js/dashboard.js"></script>
</head>
<body class="">
<div class="page">
<div class="page-single">
@ -45,7 +49,8 @@
<label class="form-label">
Password
</label>
<input type="password" class="form-control" id="exampleInputPassword1" placeholder="Password" name="password">
<input type="password" class="form-control" id="exampleInputPassword1" placeholder="Password"
name="password">
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
@ -58,4 +63,5 @@
</div>
</div>
</body>
</html>

View File

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@ -7,18 +8,20 @@
<meta name="keywords" content="{% block keywords %}{% endblock keywords %}" />
<link rel="stylesheet" href="/statics/style.css">
<link rel="alternate" type="application/rss+xml" title="{{ setting.title }}" href="/rss" />
<title>{% block title %}Rubble{% endblock title %}</title>
<title>{% block title %}Crablog{% endblock title %}</title>
</head>
<body>
{% block body %}
{% endblock body %}
<footer>
<section class="container">
<p> 自豪地使用 <a href="https://github.com/Kilerd/rubble">Project Rubble</a> 运行。 </p>
<p> <a href="https://git.aidev.ru/andrey/crablog">Project Crablog</a> </p>
<div style="display:none">{{ setting.analysis | safe }}</div>
</section>
</footer>
<script src="/statics/prism.js" type="text/javascript"></script>
</body>
</html>

1
templates/not-found.html Normal file
View File

@ -0,0 +1 @@
qqqqq