├── .gitignore ├── .rustfmt.toml ├── Cargo.toml ├── LICENSE ├── README.md ├── backend ├── .gitignore ├── Cargo.toml ├── README.md ├── build.rs ├── src │ ├── config │ │ ├── config_loader.rs │ │ ├── example │ │ │ └── config.json │ │ └── mod.rs │ ├── db │ │ ├── management.rs │ │ ├── mod.rs │ │ ├── model.rs │ │ ├── post.rs │ │ ├── query_builder.rs │ │ ├── tag.rs │ │ └── user.rs │ ├── facade │ │ ├── asset.rs │ │ ├── export.rs │ │ ├── git.rs │ │ ├── image.rs │ │ ├── index.rs │ │ ├── management.rs │ │ ├── mod.rs │ │ ├── post.rs │ │ ├── tag.rs │ │ └── user.rs │ ├── image │ │ ├── asset.rs │ │ ├── image.rs │ │ ├── mod.rs │ │ └── number_image.rs │ ├── lib.rs │ ├── main.rs │ ├── resource │ │ ├── icon │ │ │ ├── 1-0.png │ │ │ ├── 1-1.png │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ ├── 1-4.png │ │ │ ├── 1-5.png │ │ │ ├── 1-6.png │ │ │ ├── 1-7.png │ │ │ ├── 1-8.png │ │ │ ├── 1-9.png │ │ │ ├── 2-0.png │ │ │ ├── 2-1.png │ │ │ ├── 2-2.png │ │ │ ├── 2-3.png │ │ │ ├── 2-4.png │ │ │ ├── 2-5.png │ │ │ ├── 2-6.png │ │ │ ├── 2-7.png │ │ │ ├── 2-8.png │ │ │ ├── 2-9.png │ │ │ ├── 3-0.png │ │ │ ├── 3-1.png │ │ │ ├── 3-2.png │ │ │ ├── 3-3.png │ │ │ ├── 3-4.png │ │ │ ├── 3-5.png │ │ │ ├── 3-6.png │ │ │ ├── 3-7.png │ │ │ ├── 3-8.png │ │ │ ├── 3-9.png │ │ │ ├── 4-0.png │ │ │ ├── 4-1.png │ │ │ ├── 4-2.png │ │ │ ├── 4-3.png │ │ │ ├── 4-4.png │ │ │ ├── 4-5.png │ │ │ ├── 4-6.png │ │ │ ├── 4-7.png │ │ │ ├── 4-8.png │ │ │ └── 4-9.png │ │ ├── page │ │ │ ├── export-template.html │ │ │ ├── git-pages-detail.html │ │ │ ├── git-pages-init.html │ │ │ ├── index.html │ │ │ ├── login.html │ │ │ └── settings.html │ │ ├── sql │ │ │ ├── ddl.sql │ │ │ └── dml.sql │ │ └── static-site │ │ │ └── template │ │ │ ├── hugo.txt │ │ │ └── post_detail.html │ ├── service │ │ ├── asset.rs │ │ ├── asset_list.rs │ │ ├── export.rs │ │ ├── git │ │ │ ├── git.rs │ │ │ ├── mod.rs │ │ │ └── pull.rs │ │ ├── image.rs │ │ ├── mod.rs │ │ ├── server.rs │ │ └── status.rs │ └── util │ │ ├── common.rs │ │ ├── crypt.rs │ │ ├── io.rs │ │ ├── mod.rs │ │ ├── num.rs │ │ ├── result.rs │ │ ├── snowflake.rs │ │ └── val.rs └── upload │ ├── 0 │ └── 424410880.jpg │ └── 6 │ └── 92189696.jpg ├── common ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── src │ ├── dto │ ├── git.rs │ ├── management.rs │ ├── mod.rs │ ├── post.rs │ ├── tag.rs │ └── user.rs │ ├── lib.rs │ ├── result.rs │ ├── util │ ├── mod.rs │ └── time.rs │ └── val.rs ├── frontend ├── .gitignore ├── Cargo.toml ├── asset │ ├── bulma.min.css │ ├── codemirror.min.css │ ├── common.js │ ├── editor.html │ ├── editor.js │ ├── favicon.ico │ ├── fontawesome.min.css │ ├── index.scss │ ├── logo.png │ ├── regular.min.css │ ├── show.js │ ├── solid.min.css │ ├── toastui-editor-all.min.js │ ├── toastui-editor.min.css │ └── webfonts │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.ttf │ │ ├── fa-regular-400.woff2 │ │ ├── fa-solid-900.ttf │ │ └── fa-solid-900.woff2 ├── dist │ ├── bulma.min-82aac43507618108.css │ ├── codemirror.min.css │ ├── common.js │ ├── editor.html │ ├── favicon.ico │ ├── fontawesome.min-5e9e696c59c57e83.css │ ├── index-ec4ef66164c33b9c.css │ ├── index.html │ ├── logo.png │ ├── regular.min-a0c258fb7c5f655d.css │ ├── snippets │ │ └── blog-frontend-a0e7f15f5414ab99 │ │ │ └── asset │ │ │ ├── editor.js │ │ │ └── show.js │ ├── solid.min-70c2e5caa950974d.css │ ├── toastui-editor-all.min.js │ ├── toastui-editor.min.css │ └── webfonts │ │ ├── fa-brands-400.ttf │ │ ├── fa-brands-400.woff2 │ │ ├── fa-regular-400.ttf │ │ ├── fa-regular-400.woff2 │ │ ├── fa-solid-900.ttf │ │ └── fa-solid-900.woff2 ├── index.html ├── resource │ └── i18n │ │ ├── en-US.txt │ │ └── zh-CN.txt └── src │ ├── app.rs │ ├── component │ ├── mod.rs │ ├── posts_list.rs │ └── unauthorized.rs │ ├── i18n.rs │ ├── lib.rs │ ├── main.rs │ ├── page │ ├── git │ │ ├── mod.rs │ │ └── pages.rs │ ├── mod.rs │ ├── post │ │ ├── compose.rs │ │ ├── detail.rs │ │ ├── list.rs │ │ ├── list.rs.bak │ │ ├── list_by_tag.rs │ │ └── mod.rs │ └── tag │ │ ├── list.rs │ │ └── mod.rs │ └── router.rs ├── manual ├── how-to-use-en.md ├── how-to-use-zh.md ├── screenshot1.jpg ├── screenshot2.jpg ├── screenshot_en-US.jpg └── screenshots.md └── scripts ├── build.bat ├── build.sh ├── release.bat └── release.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | bin/ 13 | pkg/ 14 | wasm-pack.log 15 | .idea 16 | backend/data 17 | *.iml 18 | backend/upload 19 | blog.dat* 20 | export/ 21 | **/asset/ 22 | **/dist/ -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | fn_single_line = true 3 | imports_granularity = "Crate" 4 | match_block_trailing_comma = true 5 | newline_style = "Native" -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "backend", 4 | "common", 5 | "frontend", 6 | ] 7 | 8 | [profile.release] 9 | # less code to include into binary 10 | panic = 'abort' 11 | # optimization over all codebase ( better optimization, slower build ) 12 | codegen-units = 1 13 | # optimization for size ( more aggressive ) 14 | opt-level = 3 15 | # opt-level = 'z' 16 | # optimization for size 17 | # opt-level = 's' 18 | # link time optimization using using whole-program analysis 19 | lto = true 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 截图/Screenshot 2 | **Home page** 3 | ![BlogListPage](manual/screenshot1.jpg) 4 | 5 | [>>更多截图/More screenshots](manual/screenshots.md) 6 | 7 | ## 用户使用手册/User manual 8 | [用户使用手册](manual/how-to-use-zh.md) 9 | [User manual](manual/how-to-use-en.md) 10 | 11 | ## 自带服务端的博客系统 12 | 13 | 当前版本:`0.5.5` 14 | 15 | ## 发布博客的3种方式 16 | 1. 使用本工具自带的Http Server 17 | 2. 导出到`Hugo` 18 | 3. 推送到支持`Git pages`的服务商,比如:[GitHub Pages](https://pages.github.com/) (即将推出) 19 | 20 | ## 亮点 21 | 1. 单文件(5 Mb)跨平台可执行文件 22 | 2. 两种工作模式:1、带博客后台的创作模式,2、纯文本文件服务器模式(使用命令行`-m`参数) 23 | 3. 自带 HTTP 服务(支持 HTTPS,使用`-p`更换端口,默认是:80) 24 | 4. 所有嵌入静态资源均通过`gzip`压缩,优化网络传输 25 | 5. 嵌入`Markdown`编辑器:[tui.editor](https://github.com/nhn/tui.editor) 26 | 6. 导出`Hugo`数据,可以把软件当作一个静态网站的管理后端。 27 | 7. 支持 **i18n** 28 | 29 | ## A singleton self-serve Blog written in Rust (Warp + Yew) 30 | 31 | Current version: `0.5.5` 32 | 33 | ## Deploy posts in 3 ways 34 | 1. Use embedding http server of this tool directly 35 | 2. Export posts to `Hugo` or other static site generator 36 | 3. Push to any `Git pages` provider, like: [GitHub Pages](https://pages.github.com/) (Coming soon) 37 | 38 | ## Features 39 | 1. Single executable file (5Mb), support `Windows`, `Linux`, `macOS` 40 | 2. Two serve mode. One with `Blog backend`, another one is static file service ( Specified by `-m` command line argument ) 41 | 3. Self-hosting (`TLS` supported, port can be changed via command-line argument `-p`, default is 80) 42 | 4. All static resources were gzipped for bandwidth optimization 43 | 5. Embed `Markdown` editor with [tui.editor](https://github.com/nhn/tui.editor) 44 | 6. Export posts for `Hugo`, you can simply use this as a static site management tool. 45 | 7. **i18n** supported. 46 | 47 | --- 48 | 49 | Thanks to JetBrains for supporting this project with a free open source license for their amazing IDE **IntelliJ IDEA**. 50 | 51 | [![IntelliJ IDEA](https://resources.jetbrains.com/storage/products/company/brand/logos/IntelliJ_IDEA_icon.svg)](https://www.jetbrains.com/) 52 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | 13 | #Added by cargo 14 | 15 | /target 16 | .idea 17 | asset/ -------------------------------------------------------------------------------- /backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "blog-backend" 3 | version = "0.5.6" 4 | authors = ["Songday "] 5 | edition = "2021" 6 | 7 | [lib] 8 | name = "blog_backend" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [dependencies] 13 | blog-common = { path = "../common" } 14 | 15 | ahash = "0.8" 16 | argon2 = "0.4" 17 | base64 = "0.13" 18 | bytes = "1" 19 | # chrono = { version = "0.4", features = ["serde"] } 20 | clap = { version = "4", features = ["derive"] } 21 | comrak = "0.15" 22 | # ctrlc = { version = "3.0", features = ["termination"] } 23 | # crc = "^1.0.0" 24 | futures = "0.3" 25 | git2 = "0.15" 26 | hyper = "0.14" 27 | image = { version = "0.24", features = ["jpeg", "png", "gif"] } 28 | lazy_static = "1.4" 29 | lazy-static-include = "3" 30 | log = "0.4" 31 | once_cell = "1.16" 32 | parking_lot = "0.12" 33 | password-hash = { version = "0.4", features = ["rand_core"] } 34 | # percent-encoding = "2.1" 35 | pretty_env_logger = "0.4" 36 | # pulldown-cmark = "0.9" 37 | rand = "0.8" 38 | regex = "1.7" 39 | reqwest = "0.11" 40 | # subtle = "2" 41 | serde = { version = "1", features = ["derive"] } 42 | serde_json = "1" 43 | sled = "0.34" 44 | sqlx = { version = "0.6", default-features = false, features = [ "runtime-tokio-rustls", "macros", "sqlite"], optional = false } 45 | #scrypt = { version = "0.6", default-features = false } 46 | tera = "1.17" 47 | # time = { version = "0.3", features = ["serde"] } 48 | tokio = { version = "1", features = ["fs", "io-util", "macros", "rt", "rt-multi-thread", "signal", "time"] } 49 | uuid = { version = "1", features = ["v5"] } 50 | urlencoding = "2" 51 | v_htmlescape = "0.15" 52 | warp = {version="0.3",features=["tls"]} 53 | zip = { version = "0.6", default-features = false } 54 | 55 | # https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#platform-specific-dependencies 56 | # https://doc.rust-lang.org/reference/conditional-compilation.html 57 | [target.'cfg(target_env = "gnu")'.dependencies] 58 | [target.'cfg(target_env = "musl")'.dependencies] 59 | openssl = { version = "0.10", features = ["vendored"] } 60 | 61 | [build-dependencies] 62 | flate2 = "1.0" 63 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # blog 2 | A blog backend 3 | -------------------------------------------------------------------------------- /backend/build.rs: -------------------------------------------------------------------------------- 1 | use core::result::Result; 2 | use std::{ 3 | error::Error, 4 | fs::{self, File}, 5 | io::Write, 6 | path::{Path, PathBuf}, 7 | }; 8 | 9 | use flate2::{write::GzEncoder, Compression}; 10 | 11 | fn walk_assets(path: impl AsRef) -> Result, std::io::Error> { 12 | let mut files: Vec = Vec::new(); 13 | if let Ok(entries) = fs::read_dir(path) { 14 | for entry in entries { 15 | if let Ok(entry) = entry { 16 | let file_type = entry.file_type()?; 17 | if file_type.is_dir() { 18 | let mut other_files = walk_assets(entry.path())?; 19 | files.append(&mut other_files); 20 | } else if file_type.is_file() { 21 | let path = entry.path(); 22 | let ext = path.extension(); 23 | if ext.is_none() { 24 | continue; 25 | } 26 | let ext = ext.unwrap().to_os_string().into_string().unwrap(); 27 | if ext.find("gz").is_some() { 28 | continue; 29 | } 30 | files.push(entry.path()); 31 | // files.push(entry.file_name().to_os_string().into_string().unwrap()); 32 | } 33 | } 34 | } 35 | } 36 | Ok(files) 37 | } 38 | 39 | fn gz_files(raw_asset_files: Vec) -> Result, std::io::Error> { 40 | let mut gz_files: Vec = Vec::new(); 41 | 42 | for asset_file in raw_asset_files.iter() { 43 | let cache: Vec = Vec::with_capacity(65535); 44 | let mut e = GzEncoder::new(cache, Compression::default()); 45 | let b = fs::read(asset_file)?; 46 | e.write_all(b.as_slice()); 47 | let compressed_bytes = e.finish()?; 48 | let mut extension = asset_file.extension().unwrap().to_os_string().into_string().unwrap(); 49 | extension.push_str(".gz"); 50 | let gz_file = asset_file.with_extension(extension.as_str()); 51 | fs::write(gz_file.as_path(), compressed_bytes.as_slice())?; 52 | gz_files.push(gz_file); 53 | } 54 | Ok(gz_files) 55 | } 56 | 57 | fn get_content_type(filename: String) -> String { 58 | if filename.rfind(".css").is_some() { 59 | String::from("text/css") 60 | } else if filename.rfind(".js").is_some() { 61 | String::from("text/javascript") 62 | } else if filename.rfind(".html").is_some() { 63 | String::from("text/html; charset=utf-8") 64 | } else if filename.rfind(".wasm").is_some() { 65 | String::from("application/wasm") 66 | } else { 67 | String::new() 68 | } 69 | } 70 | 71 | fn main() -> Result<(), Box> { 72 | println!("cargo:rerun-if-changed=build.rs,resource/asset/index.html"); 73 | 74 | // embed all static resource asset files 75 | let asset_root = Path::new("src").join("resource").join("asset"); 76 | let asset_root = format!("{}/", asset_root.display()); 77 | let asset_root = asset_root.as_str(); 78 | let all_static_asset_files = walk_assets(asset_root)?; 79 | let gz_files = gz_files(all_static_asset_files)?; 80 | let mut service_asset_file = File::create(Path::new("src").join("service").join("asset_list.rs"))?; 81 | writeln!(&mut service_asset_file, r##"["##,)?; 82 | for f in gz_files.iter() { 83 | writeln!( 84 | &mut service_asset_file, 85 | r##"("{name}", include_bytes!(r#"{file_path}"#), "{mime}"),"##, 86 | name = format!("{}", f.display()) 87 | .replace(asset_root, "") 88 | .replace(".gz", "") 89 | .replace("\\", "/"), 90 | file_path = format!("{}", f.display()).replace("src", ".."), 91 | mime = get_content_type(format!("{}", f.display())), 92 | )?; 93 | } 94 | writeln!(&mut service_asset_file, r##"]"##,)?; 95 | 96 | // embed images for validate image 97 | let dest_path = Path::new("src").join("image").join("number_image.rs"); 98 | const GROUP_AMOUNT: u8 = 4; 99 | 100 | let mut groups = String::with_capacity(512); 101 | let mut number_images = String::with_capacity(2048); 102 | 103 | groups.push_str(&format!( 104 | "pub const NUMBER_IMAGE_GROUPS: [[NumberImage; 10]; {}] = [\n", 105 | GROUP_AMOUNT 106 | )); 107 | for group in 1..(GROUP_AMOUNT + 1) { 108 | let group_name = &format!("GROUP{}_NUMBERS", group); 109 | groups.push_str(group_name); 110 | groups.push_str(",\n"); 111 | 112 | number_images.push_str(&format!("pub const {}: [NumberImage; 10] = [\n", group_name)); 113 | for i in 0..10 { 114 | number_images.push_str(" NumberImage {\n"); 115 | number_images.push_str(&format!( 116 | " data: include_bytes!(\"../resource/icon/{}-{}.png\"),\n", 117 | group, i 118 | )); 119 | number_images.push_str(" },\n"); 120 | } 121 | number_images.push_str("];\n"); 122 | } 123 | groups.push_str("];\n"); 124 | 125 | let mut all = String::with_capacity(groups.len() + number_images.len()); 126 | all.push_str(&groups); 127 | all.push_str(&number_images); 128 | fs::write(&dest_path, &all).unwrap(); 129 | 130 | Ok(()) 131 | } 132 | -------------------------------------------------------------------------------- /backend/src/config/config_loader.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_json::*; 4 | use std::fs; 5 | pub fn load_config(args: &mut Args) -> Result<()> { 6 | let data = fs::read_to_string(args.config.as_ref().unwrap()).unwrap(); 7 | let v: Args = serde_json::from_str(data.as_str())?; 8 | *args = v; 9 | // let b = Box::leak(Box::new(v)); 10 | // args = b; 11 | Ok(()) 12 | } 13 | /// Simple blog backend 14 | #[derive(Parser, Serialize, Deserialize)] 15 | #[clap(name = "Songday blog backend", author, version, about, long_about = None)] 16 | pub struct Args { 17 | #[clap(long, value_parser)] 18 | /// Specify config path, e.g.: ./config.json 19 | pub config: Option, 20 | /// Specify run mode: 'static' is for static file serve, 'blog' is blog warp server mode 21 | #[clap(long, value_parser)] 22 | pub mode: Option, 23 | 24 | /// HTTP Server Settings 25 | /// Specify http listening address, e.g.: 0.0.0.0 or [::] or 127.0.0.1 or other particular ip, default is '127.0.0.1' 26 | #[clap(long, default_value = "127.0.0.1", value_parser)] 27 | pub ip: String, 28 | 29 | /// Specify listening port, default value is '80' 30 | #[clap(long, default_value_t = 80, value_parser)] 31 | pub port: u16, 32 | 33 | /// Enable HTTPS Server 34 | #[clap(long, value_parser)] 35 | pub https_enabled: bool, 36 | 37 | /// Cert file path, needed by https 38 | #[clap(long, value_parser)] 39 | pub cert_path: Option, 40 | 41 | /// Key file path, needed by https 42 | #[clap(long, value_parser)] 43 | pub key_path: Option, 44 | 45 | /// Specify HTTPS listening port, default value is '443' 46 | #[clap(long, value_parser, default_value_t = 443)] 47 | pub https_port: u16, 48 | 49 | /// Enable HSTS Redirect Server 50 | #[clap(long, value_parser)] 51 | pub hsts_enabled: bool, 52 | 53 | /// Hostname for CORS 54 | #[clap(long, value_parser)] 55 | pub cors_host: Option, 56 | } 57 | -------------------------------------------------------------------------------- /backend/src/config/example/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mode":"blog", 3 | "ip":"0.0.0.0", 4 | "port":80, 5 | "https_enabled":false, 6 | "https_port":443, 7 | "cert_path":"./cert.crt", 8 | "key_path":"./key.key", 9 | "hsts_enabled":false, 10 | "cors_host":"https://localhost" 11 | } -------------------------------------------------------------------------------- /backend/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config_loader; -------------------------------------------------------------------------------- /backend/src/db/management.rs: -------------------------------------------------------------------------------- 1 | use blog_common::util::time; 2 | use blog_common::{dto::user::UserInfo, result::Error}; 3 | use sqlx::{Row, Sqlite}; 4 | 5 | use crate::{ 6 | db::{ 7 | self, 8 | model::{Setting, User}, 9 | DATA_SOURCE, 10 | }, 11 | service::status, 12 | util::{crypt, result::Result}, 13 | }; 14 | 15 | pub async fn has_admin_password() -> Result { 16 | let row = sqlx::query("SELECT COUNT(id) FROM settings WHERE item='admin_password'") 17 | .fetch_one(db::get_sqlite()) 18 | .await?; 19 | let total: i64 = row.get(0); 20 | dbg!(total); 21 | return Ok(total > 0); 22 | } 23 | 24 | pub async fn admin_login(token: &str, password: &str) -> Result { 25 | let d = get_setting("admin_password").await?; 26 | 27 | if let Some(settings) = d { 28 | if crypt::verify_password(password, &settings.content)? { 29 | status::user_online(token, UserInfo { id: 1 }); 30 | return Ok(true); 31 | } 32 | } 33 | return Ok(false); 34 | } 35 | 36 | pub async fn update_setting(setting: Setting) -> Result<()> { 37 | // db::sled_save(&DATA_SOURCE.get().unwrap().management, "settings", &setting).await?; 38 | let content = if setting.item.eq("admin_password") { 39 | if setting.content.is_empty() { 40 | String::new() 41 | } else { 42 | crypt::encrypt_password(&setting.content)? 43 | } 44 | } else { 45 | setting.content 46 | }; 47 | 48 | let now = time::unix_epoch_sec() as i64; 49 | 50 | let r = sqlx::query("UPDATE settings SET content=?,updated_at=? WHERE item=?") 51 | .bind(&content) 52 | .bind(now) 53 | .bind(&setting.item) 54 | .execute(db::get_sqlite()) 55 | .await?; 56 | 57 | if r.rows_affected() < 1 { 58 | sqlx::query("INSERT INTO settings(item,content,created_at,updated_at)VALUES(?,?,?,?)") 59 | .bind(&setting.item) 60 | .bind(&content) 61 | .bind(now) 62 | .bind(now) 63 | .execute(db::get_sqlite()) 64 | .await?; 65 | } 66 | Ok(()) 67 | } 68 | 69 | pub async fn get_setting(item: &str) -> Result> { 70 | let r = sqlx::query_as::("SELECT * FROM settings WHERE item=?") 71 | .bind(item) 72 | .fetch_optional(super::get_sqlite()) 73 | .await?; 74 | Ok(r) 75 | } 76 | -------------------------------------------------------------------------------- /backend/src/db/model.rs: -------------------------------------------------------------------------------- 1 | use core::fmt::Display; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use blog_common::dto::{post::PostDetail, user::UserInfo}; 6 | use sqlx::{ 7 | database::{HasArguments, HasValueRef}, 8 | encode::IsNull, 9 | error::BoxDynError, 10 | }; 11 | 12 | #[derive(Serialize, Deserialize, Debug, Clone, sqlx::FromRow)] 13 | pub struct User { 14 | pub id: i64, 15 | pub email: String, 16 | pub password: String, 17 | pub created_at: i64, 18 | } 19 | 20 | impl Into for &User { 21 | fn into(self) -> UserInfo { 22 | UserInfo { id: self.id } 23 | } 24 | } 25 | 26 | #[derive(Serialize, Deserialize, Debug, sqlx::FromRow)] 27 | pub struct Post { 28 | /* 29 | https://docs.rs/sqlx/0.4.0-beta.1/sqlx/prelude/trait.Type.html 30 | 31 | 这里想自己实现`id`的`u64`,因为`sqlx`只实现了`i64`,然后根据文档,自己实现`Encode`和`Encode` 32 | https://docs.rs/sqlx/0.4.0-beta.1/sqlx/prelude/trait.Encode.html#impl-Encode%3C%27q%2C%20Sqlite%3E-for-i64 33 | https://docs.rs/sqlx/0.4.0-beta.1/sqlx/prelude/trait.Decode.html?search=#impl-Decode%3C%27r%2C%20Sqlite%3E-for-i64 34 | 但是实现`Decode`的方法的时候,里面的对象都是私有的,走进死胡同了 35 | 后来想了下,为什么只有`MySQL`实现了`u64`,原因是只有`MySQL`支持`unsigned bigint`类型的字段 36 | 37 | 参考了其它: 38 | https://docs.rs/sqlx/0.4.0-beta.1/sqlx/trait.TypeInfo.html 39 | https://docs.rs/sqlx/0.4.0-beta.1/sqlx/sqlite/struct.SqliteTypeInfo.html 40 | https://docs.rs/sqlx/0.4.0-beta.1/sqlx/prelude/trait.Type.html#impl-Type%3CSqlite%3E-for-i64 41 | https://docs.rs/sqlx/0.4.0-beta.1/sqlx/struct.Pool.html 42 | */ 43 | pub id: i64, 44 | pub title: String, 45 | pub title_image: String, 46 | pub markdown_content: String, 47 | pub rendered_content: String, 48 | pub created_at: i64, 49 | pub updated_at: Option, 50 | } 51 | 52 | impl Into for &Post { 53 | fn into(self) -> PostDetail { 54 | PostDetail { 55 | id: self.id, 56 | title: self.title.clone(), 57 | title_image: self.title_image.clone(), 58 | content: self.rendered_content.clone(), 59 | tags: None, 60 | created_at: self.created_at as u64, 61 | updated_at: self.updated_at.map(|t| t as u64), 62 | editable: false, 63 | } 64 | } 65 | } 66 | 67 | #[derive(Deserialize, Serialize, sqlx::FromRow)] 68 | pub struct Tag { 69 | pub id: i64, 70 | pub name: String, 71 | } 72 | 73 | #[derive(Deserialize, Serialize, sqlx::FromRow)] 74 | pub struct TagUsage { 75 | pub id: i64, 76 | pub post_id: i64, 77 | pub tag_id: i64, 78 | } 79 | 80 | #[derive(Clone, Default, Debug, Serialize, sqlx::FromRow)] 81 | pub struct Setting { 82 | pub item: String, 83 | pub content: String, 84 | // pub settings: blog_common::dto::management::Settings, 85 | } 86 | 87 | // impl std::ops::Deref for Settings { 88 | // type Target = blog_common::dto::management::Settings; 89 | // fn deref(&self) -> &Self::Target { 90 | // &self.settings 91 | // } 92 | // } 93 | 94 | impl From for Setting { 95 | fn from(settings: blog_common::dto::management::Setting) -> Self { 96 | Self { 97 | item: settings.item, 98 | content: settings.content, 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /backend/src/db/query_builder.rs: -------------------------------------------------------------------------------- 1 | // https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=ad7391366c8408d8cb0097f1f867808f 2 | 3 | use sqlx::arguments::Arguments; 4 | use sqlx::{encode::Encode, Database, Type}; 5 | 6 | 7 | /// As of right now, you can only use this with the `query` function because 8 | /// it's the only function that has a `bind_all` method to pass the arguments struct. 9 | /// 10 | /// ``` 11 | /// let (query, arguments) = QueryBuilder::new() 12 | /// .append("SELECT * FROM someTable") 13 | /// .condition("ID = ?") 14 | /// .bind("123abc") 15 | /// .append("ORDER BY id") 16 | /// .into_query_and_arguments(); 17 | /// 18 | /// let mut cursor = sqlx::query(&query) 19 | /// .bind_all(arguments) 20 | /// .fetch(&mut connection); 21 | /// ``` 22 | #[derive(Debug, Clone)] 23 | pub struct QueryBuilder { 24 | query: String, 25 | arguments: DB::Arguments, 26 | has_where_statement: bool, 27 | } 28 | 29 | impl QueryBuilder { 30 | pub fn new() -> Self { 31 | Self { 32 | query: String::new(), 33 | arguments: DB::Arguments::default(), 34 | has_where_statement: false, 35 | } 36 | } 37 | 38 | pub fn into_query_and_arguments(self) -> (String, DB::Arguments) { 39 | (self.query, self.arguments) 40 | } 41 | 42 | pub fn append(mut self, text: &str) -> Self { 43 | self.query.push_str(text); 44 | self.query.push(' '); 45 | self 46 | } 47 | 48 | /// Adds a WHERE clause or appends to an existing WHERE with AND 49 | pub fn condition(mut self, condition: &str) -> Self { 50 | if self.has_where_statement { 51 | self.query.push_str("AND "); 52 | } else { 53 | self.query.push_str("WHERE "); 54 | self.has_where_statement = true; 55 | } 56 | 57 | self.query.push_str(condition); 58 | self.query.push(' '); 59 | 60 | self 61 | } 62 | 63 | pub fn multi_condition(mut self, condition: &str, values: &[T]) -> Self 64 | where T: Type + Encode 65 | { 66 | let mut full_condition = String::from("("); 67 | for (i, value) in values.iter().enumerate() { 68 | if i > 0 { 69 | full_condition.push_str("OR ") 70 | } 71 | full_condition.push_str(condition); 72 | full_condition.push(' '); 73 | self.arguments.add(value); 74 | } 75 | full_condition.push_str(")"); 76 | 77 | self.condition(&full_condition) 78 | } 79 | 80 | pub fn bind(mut self, value: T) -> Self 81 | where 82 | T: Type + Encode, 83 | { 84 | self.arguments.add(value); 85 | self 86 | } 87 | 88 | pub fn bind_slice(mut self, values: &[T]) -> Self 89 | where T: Type + Encode 90 | { 91 | for value in values { 92 | self.arguments.add(value); 93 | } 94 | 95 | self 96 | } 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::*; 102 | 103 | #[test] 104 | fn simple_query() -> anyhow::Result<()> { 105 | let expected = "\ 106 | SELECT * FROM someTable \ 107 | WHERE ID = ? \ 108 | ORDER BY id \ 109 | "; 110 | 111 | let (actual, _) = QueryBuilder::new() 112 | .append("SELECT * FROM someTable") 113 | .condition("ID = ?") 114 | .bind("123abc") 115 | .append("ORDER BY id") 116 | .into_query_and_arguments(); 117 | 118 | assert_eq!(expected, actual); 119 | 120 | Ok(()) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /backend/src/db/tag.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | hash::Hasher, 4 | io::{Cursor, ErrorKind, SeekFrom}, 5 | mem::size_of, 6 | path::{Path, PathBuf}, 7 | sync::Arc, 8 | vec::Vec, 9 | }; 10 | 11 | use ahash::AHasher; 12 | use blog_common::{dto::tag::TagUsageAmount, result::Error, util::time}; 13 | use bytes::{Buf, Bytes, BytesMut}; 14 | use parking_lot::RwLock; 15 | use sqlx::{Row, Sqlite}; 16 | use tokio::{ 17 | fs::{remove_file, rename, File, OpenOptions}, 18 | io::{self, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufReader, BufWriter}, 19 | }; 20 | 21 | use crate::{ 22 | db::{self, model::Tag, DATA_SOURCE}, 23 | util::{common, crypt, result::Result, snowflake}, 24 | }; 25 | 26 | pub async fn top() -> Result> { 27 | let tags = sqlx::query("SELECT t.id,t.name,u.amount FROM tags t INNER JOIN (SELECT tag_id, COUNT(tag_id) AS amount FROM tags_usage GROUP BY tag_id) u ON t.id=u.tag_id ORDER BY u.amount DESC") 28 | .fetch_all(&DATA_SOURCE.get().unwrap().sqlite) 29 | .await?; 30 | let name_list = tags 31 | .iter() 32 | .map(|i| TagUsageAmount { 33 | id: i.get(0), 34 | name: i.get(1), 35 | amount: i.get(2), 36 | }) 37 | .collect::>(); 38 | Ok(name_list) 39 | } 40 | 41 | pub async fn list() -> Result> { 42 | let tag_list = sqlx::query_as::("SELECT id,name FROM tags ORDER BY created_at DESC") 43 | .fetch_all(&DATA_SOURCE.get().unwrap().sqlite) 44 | .await?; 45 | let name_list = tag_list.iter().map(|i| i.name.clone()).collect::>(); 46 | Ok(name_list) 47 | } 48 | 49 | pub async fn get_names(id_array: Vec) -> Result> { 50 | if id_array.is_empty() { 51 | return Ok(vec![]); 52 | } 53 | let mut sql = String::with_capacity(256); 54 | sql.push_str("SELECT name from tags WHERE id IN ("); 55 | for id in id_array.iter() { 56 | sql.push_str(id.to_string().as_str()); 57 | sql.push(','); 58 | } 59 | sql.push('-'); 60 | let sql = sql.replace(",-", ") ORDER BY created_at DESC"); 61 | let tag_list = sqlx::query_as::(sql.as_str()) 62 | .fetch_all(&DATA_SOURCE.get().unwrap().sqlite) 63 | .await?; 64 | let name_list = tag_list.iter().map(|i| i.name.clone()).collect::>(); 65 | Ok(name_list) 66 | } 67 | 68 | pub(super) async fn record_usage(post_id: i64, tags: &Vec) -> Result<()> { 69 | // query id list by name list 70 | let mut sql = String::with_capacity(256); 71 | sql.push_str("SELECT id,name from tags WHERE name IN ("); 72 | for _i in 0..tags.len() { 73 | sql.push_str("?,"); 74 | } 75 | sql.replace_range(sql.len() - 1.., ")"); 76 | // println!("{}", sql.as_str()); 77 | let mut query = sqlx::query_as::(sql.as_str()); 78 | for tag in tags.iter() { 79 | query = query.bind(tag); 80 | } 81 | let mut tags_in_db = query.fetch_all(&DATA_SOURCE.get().unwrap().sqlite).await?; 82 | 83 | // 查看有没有新的tag 84 | if tags_in_db.len() < tags.len() { 85 | let mut new_tags: Vec = Vec::with_capacity(tags.len() - tags_in_db.len()); 86 | { 87 | let mut tags_in_db_iter = tags_in_db.iter(); 88 | for tag in tags.iter() { 89 | if !tags_in_db_iter.any(|e| e.name.eq(tag)) { 90 | let id = sqlx::query("REPLACE INTO tags(name, created_at)VALUES(?,?)") 91 | .bind(tag) 92 | .bind(time::unix_epoch_sec() as i64) 93 | .execute(&DATA_SOURCE.get().unwrap().sqlite) 94 | .await? 95 | .last_insert_rowid(); 96 | let new_tag = Tag { 97 | id, 98 | name: String::from(tag), 99 | }; 100 | new_tags.push(new_tag); 101 | } 102 | } 103 | } 104 | tags_in_db.append(&mut new_tags); 105 | } 106 | 107 | // 把没有用到的tag id删除 108 | if tags_in_db.len() > 0 { 109 | let mut sql = String::with_capacity(512); 110 | sql.push_str("DELETE FROM tags_usage WHERE post_id = ? AND tag_id NOT IN ("); 111 | for _idx in 0..tags_in_db.len() { 112 | sql.push_str("?,"); 113 | } 114 | sql.replace_range(sql.len() - 1.., ")"); 115 | // println!("{}", sql.as_str()); 116 | let mut query = sqlx::query(sql.as_str()); 117 | for tag in tags_in_db.iter() { 118 | query = query.bind(tag.id); 119 | } 120 | let _tags_in_db = query.execute(&DATA_SOURCE.get().unwrap().sqlite).await?; 121 | } 122 | 123 | let post_id = post_id; 124 | for tag in tags_in_db { 125 | sqlx::query("REPLACE INTO tags_usage(post_id, tag_id)VALUES(?,?)") 126 | .bind(post_id) 127 | .bind(tag.id) 128 | .execute(&DATA_SOURCE.get().unwrap().sqlite) 129 | .await?; 130 | } 131 | Ok(()) 132 | } 133 | 134 | pub(crate) async fn get_tags_by_post_ids(ids: Vec) -> Result>> { 135 | let mut sql = String::from( 136 | "SELECT u.post_id, t.id, t.name FROM tags_usage u INNER JOIN tags t ON u.tag_id = t.id WHERE u.post_id IN (", 137 | ); 138 | for _i in 0..ids.len() { 139 | sql.push_str("?,"); 140 | } 141 | sql.replace_range(sql.len() - 1.., ")"); 142 | let mut query = sqlx::query(&sql); 143 | for id in ids.iter() { 144 | query = query.bind(id); 145 | } 146 | let r = query.fetch_all(&DATA_SOURCE.get().unwrap().sqlite).await?; 147 | let mut d: HashMap> = HashMap::with_capacity(ids.len()); 148 | 149 | for row in r { 150 | let tags = d.entry(row.get(0)).or_insert(vec![]); 151 | tags.push(Tag { 152 | id: row.get(1), 153 | name: row.get(2), 154 | }); 155 | } 156 | 157 | Ok(d) 158 | } 159 | -------------------------------------------------------------------------------- /backend/src/db/user.rs: -------------------------------------------------------------------------------- 1 | use blog_common::util::time; 2 | use blog_common::{dto::user::UserInfo, result::Error}; 3 | use sqlx::Sqlite; 4 | 5 | use crate::{ 6 | db::{self, model::User, DATA_SOURCE}, 7 | util::{crypt, result::Result, snowflake}, 8 | }; 9 | 10 | pub async fn register(email: &str, password: &str) -> Result { 11 | let r = sqlx::query("SELECT id FROM user WHERE email = ?") 12 | .bind(email) 13 | .fetch_optional(&DATA_SOURCE.get().unwrap().sqlite) 14 | .await?; 15 | if r.is_some() { 16 | return Err(Error::AlreadyRegistered.into()); 17 | } 18 | 19 | let user = User { 20 | id: snowflake::gen_id() as i64, 21 | email: email.to_owned(), 22 | password: crypt::encrypt_password(password)?, 23 | created_at: time::unix_epoch_sec() as i64, 24 | }; 25 | 26 | let r = sqlx::query("INSERT INTO user(id,email,password,created_at) VALUES(?,?,?,?)") 27 | .bind(&user.id) 28 | .bind(email) 29 | .bind(&user.password) 30 | .bind(user.created_at as i64) 31 | .execute(&DATA_SOURCE.get().unwrap().sqlite) 32 | .await?; 33 | if r.rows_affected() < 1 { 34 | return Err(Error::RegisterFailed.into()); 35 | } 36 | 37 | Ok((&user).into()) 38 | } 39 | 40 | pub async fn login(email: &str, password: &str) -> Result { 41 | let r = sqlx::query_as::("SELECT * FROM user WHERE email = ?") 42 | .bind(email) 43 | .fetch_optional(&DATA_SOURCE.get().unwrap().sqlite) 44 | .await?; 45 | if r.is_none() { 46 | return Err(Error::LoginFailed.into()); 47 | } 48 | 49 | let u = r.unwrap(); 50 | if crate::util::crypt::verify_password(password, &u.password)? { 51 | Ok((&u).into()) 52 | } else { 53 | Err(Error::LoginFailed.into()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /backend/src/facade/asset.rs: -------------------------------------------------------------------------------- 1 | use core::result::Result; 2 | 3 | use hyper::{body::Body, header}; 4 | use warp::{filters::path::Tail, http::Response, Rejection, Reply}; 5 | 6 | use crate::service::asset; 7 | 8 | pub async fn index() -> Result { 9 | Ok(response_asset("index.html")) 10 | } 11 | 12 | pub async fn get_asset(tail: Tail) -> Result, Rejection> { 13 | Ok(response_asset(tail.as_str())) 14 | } 15 | 16 | fn response_asset(asset: &str) -> Response { 17 | let file = asset::get_asset(asset); 18 | if file.is_none() { 19 | Response::builder().status(404).body("".into()).unwrap() 20 | } else { 21 | let (_name, data, mime) = file.unwrap(); 22 | let r = Response::builder() 23 | .header(header::CONTENT_TYPE, mime) 24 | .header(header::CONTENT_LENGTH, data.len()) 25 | .header(header::CONTENT_ENCODING, "gzip") 26 | .body(data.into()) 27 | .unwrap(); 28 | r 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/src/facade/export.rs: -------------------------------------------------------------------------------- 1 | use blog_common::{ 2 | dto::{git::GitRepositoryInfo, user::UserInfo, Response as ApiResponse}, 3 | result::{Error, ErrorResponse}, 4 | val, 5 | }; 6 | use hyper::body::Body; 7 | use hyper::header::{self, HeaderMap, HeaderValue}; 8 | use warp::{filters::path::Tail, http::Response, Rejection, Reply}; 9 | 10 | use crate::{ 11 | db::management, 12 | db::post, 13 | facade::{session_id_cookie, wrap_json_data, wrap_json_err}, 14 | service::{export, status}, 15 | util::common, 16 | }; 17 | 18 | pub async fn export_handler(tail: Tail, user: Option) -> Result, Rejection> { 19 | if user.is_none() { 20 | return Ok(Response::builder().status(403).body("".into()).unwrap()); 21 | } 22 | let path = tail.as_str(); 23 | if path.eq("hugo") { 24 | return hugo().await; 25 | } 26 | if path.rfind(".zip").is_some() { 27 | return Ok(get_file(path)); 28 | } 29 | Ok(Response::builder().status(404).body("".into()).unwrap()) 30 | } 31 | 32 | fn get_file(file: &str) -> Response { 33 | let file = std::env::current_dir().unwrap().join("export").join(file); 34 | if file.exists() { 35 | match std::fs::read(file.as_path()) { 36 | Ok(d) => { 37 | return Response::builder() 38 | .header(header::CONTENT_TYPE, "application/octet-stream") 39 | .header(header::CONTENT_LENGTH, d.len()) 40 | .body(d.into()) 41 | .unwrap() 42 | }, 43 | Err(e) => { 44 | eprintln!("{:?}", e); 45 | }, 46 | } 47 | } 48 | Response::builder().status(404).body("".into()).unwrap() 49 | } 50 | 51 | async fn hugo() -> Result, Rejection> { 52 | let filename = export::hugo().await?; 53 | let mut uri = String::with_capacity(64); 54 | uri.push_str("/export/"); 55 | uri.push_str(&filename); 56 | // Ok(warp::redirect::temporary(warp::http::Uri::from_static(&uri))) 57 | let r = Response::builder() 58 | .header(header::CONTENT_TYPE, "text/plain") 59 | .header(header::CONTENT_LENGTH, uri.len()) 60 | .body(uri.into()) 61 | .unwrap(); 62 | Ok(r) 63 | } 64 | -------------------------------------------------------------------------------- /backend/src/facade/image.rs: -------------------------------------------------------------------------------- 1 | use core::{convert::Infallible, result::Result}; 2 | 3 | use bytes::Buf; 4 | use hyper::header::{self, HeaderMap, HeaderValue}; 5 | use serde::Serialize; 6 | use warp::{ 7 | filters::multipart::FormData, 8 | filters::path::Tail, 9 | http::{response::Response, StatusCode}, 10 | reply::{Json, Response as WarpResponse}, 11 | Rejection, Reply, 12 | }; 13 | 14 | use blog_common::{ 15 | dto::{post::UploadImage, user::UserInfo}, 16 | result::{Error, ErrorResponse}, 17 | val, 18 | }; 19 | 20 | use crate::{ 21 | db::{post, user}, 22 | facade::{session_id_cookie, wrap_json_data, wrap_json_err}, 23 | image::image, 24 | service::{self, status}, 25 | util::{ 26 | common, 27 | io::{self, SupportFileType}, 28 | }, 29 | }; 30 | 31 | pub async fn verify_image(token: Option) -> Result { 32 | let token = token.unwrap_or(common::simple_uuid()); 33 | dbg!(&token); 34 | match status::get_verify_code(&token) { 35 | Ok(n) => { 36 | let b = crate::image::image::gen_verify_image(n.as_slice()); 37 | let mut r = Response::new(b.into()); 38 | let mut header = HeaderMap::with_capacity(2); 39 | header.insert(header::CONTENT_TYPE, HeaderValue::from_str("image/png").unwrap()); 40 | header.insert( 41 | header::SET_COOKIE, 42 | HeaderValue::from_str(&session_id_cookie(&token)).unwrap(), 43 | ); 44 | // header.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_str("*").unwrap()); 45 | // header.insert(header::ACCESS_CONTROL_ALLOW_CREDENTIALS, HeaderValue::from_str("true").unwrap()); 46 | let headers = r.headers_mut(); 47 | headers.extend(header); 48 | Ok(r) 49 | }, 50 | Err(e) => return Ok(Response::new("Wrong request token".into())), 51 | } 52 | } 53 | 54 | pub async fn get_upload_image(tail: Tail) -> Result { 55 | let tail_str = tail.as_str(); 56 | service::image::get_upload_image(tail_str) 57 | .await 58 | .map(|d| { 59 | let content_length = d.len(); 60 | // 这里指定返回值,否则Rustc推到不出来类型 61 | let mut r: Response = Response::new(d.into()); 62 | let mut header = HeaderMap::with_capacity(2); 63 | let image_mime = if tail_str.ends_with("png") { 64 | "image/png" 65 | } else { 66 | "image/jpg" 67 | }; 68 | header.insert(header::CONTENT_TYPE, HeaderValue::from_str(image_mime).unwrap()); 69 | header.insert(header::CONTENT_LENGTH, HeaderValue::from(content_length)); 70 | let headers = r.headers_mut(); 71 | headers.extend(header); 72 | r 73 | }) 74 | .or_else(|e| { 75 | let message = format!("{}", e.0); 76 | Ok(Response::new(message.into())) 77 | }) 78 | } 79 | 80 | pub async fn upload(post_id: u64, user: Option, data: FormData) -> Result { 81 | if user.is_none() { 82 | return Ok(wrap_json_err(500, Error::NotAuthed)); 83 | } 84 | let upload_image = service::image::upload(post_id, data).await; 85 | upload_image 86 | .map(|d| wrap_json_data(&d)) 87 | .or_else(|e| Ok(wrap_json_err(500, e.0))) 88 | } 89 | 90 | pub async fn upload_title_image(post_id: u64, user: Option, data: FormData) -> Result { 91 | if user.is_none() { 92 | return Ok(wrap_json_err(500, Error::NotAuthed)); 93 | } 94 | let result = service::image::upload(post_id, data).await; 95 | if let Err(e) = result { 96 | return Ok(wrap_json_err(500, e.0)); 97 | } 98 | let images = result.unwrap(); 99 | let image = &images[0]; 100 | post::update_title_image(post_id as i64, &image.relative_path) 101 | .await 102 | .map(|d| wrap_json_data(image)) 103 | .or_else(|e| Ok(wrap_json_err(500, e.0))) 104 | } 105 | 106 | pub async fn save( 107 | post_id: u64, 108 | filename: String, 109 | user: Option, 110 | body: impl Buf, 111 | ) -> Result { 112 | if user.is_none() { 113 | return Ok(wrap_json_err(500, Error::NotAuthed)); 114 | } 115 | let upload_image = service::image::save(post_id, filename, body).await; 116 | upload_image 117 | .map(|d| wrap_json_data(&d)) 118 | .or_else(|e| Ok(wrap_json_err(500, e.0))) 119 | } 120 | 121 | // pub async fn resize_blog_image, T: AsRef<&str>>(b: B, type: T) {} 122 | 123 | pub async fn random_title_image(post_id: u64) -> Result { 124 | crate::service::image::random_title_image(post_id) 125 | .await 126 | .map(|f| wrap_json_data(&f)) 127 | .or_else(|e| Ok(wrap_json_err(500, e.0))) 128 | } 129 | -------------------------------------------------------------------------------- /backend/src/facade/index.rs: -------------------------------------------------------------------------------- 1 | use blog_common::dto::user::UserInfo; 2 | use futures::TryFutureExt; 3 | use warp::{Rejection, Reply}; 4 | 5 | use crate::facade::asset; 6 | use crate::facade::management; 7 | use crate::service::status; 8 | 9 | pub(crate) const INDEX_HTML: &'static str = include_str!("../resource/page/index.html"); 10 | 11 | pub async fn index() -> Result { 12 | //检查是否有data.db,有则返回前端 index,否则返回设置页面 13 | if crate::db::management::has_admin_password().await.unwrap_or(false) { 14 | Ok(warp::reply::html(INDEX_HTML).into_response()) 15 | // Ok(warp::reply::Response::new(INDEX_HTML.into())) 16 | } else { 17 | // Ok(warp::redirect::temporary(hyper::Uri::from_static("/management/index"))) 18 | let response = management::show_settings_with_fake_auth(); 19 | Ok(response) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/facade/management.rs: -------------------------------------------------------------------------------- 1 | use core::{convert::Infallible, result::Result}; 2 | use std::collections::HashMap; 3 | 4 | use blog_common::{ 5 | dto::{ 6 | management::{AdminUser, Setting}, 7 | user::UserInfo, 8 | }, 9 | result::Error, 10 | }; 11 | use hyper::{body::Body, header}; 12 | use warp::{http::Uri, reply::Response, Rejection, Reply}; 13 | 14 | use crate::{ 15 | db::management, 16 | facade, 17 | facade::{wrap_json_data, wrap_json_err}, 18 | service::status, 19 | util::common, 20 | }; 21 | 22 | pub const SETTINGS_HTML: &'static str = include_str!("../resource/page/settings.html"); 23 | const LOGIN_HTML: &'static str = include_str!("../resource/page/login.html"); 24 | const POST_DETAIL_DEFAULT_TEMPLATE: &'static str = include_str!("../resource/static-site/template/post_detail.html"); 25 | 26 | pub fn show_settings_with_fake_auth() -> Response { 27 | let token = common::simple_uuid(); 28 | status::user_online(&token, UserInfo { id: 1 }); 29 | // Ok(warp::redirect::temporary(hyper::Uri::from_static("/management/index"))) 30 | let mut response = warp::reply::Response::new(SETTINGS_HTML.into()); 31 | response.headers_mut().append( 32 | header::SET_COOKIE.as_str(), 33 | super::session_id_cookie(&token).parse().unwrap(), 34 | ); 35 | response 36 | } 37 | 38 | pub async fn index(token: Option) -> Result { 39 | if status::check_auth(token).is_ok() { 40 | Ok(Response::new(SETTINGS_HTML.into())) 41 | // Ok(warp::reply::html(&r)) 42 | } else { 43 | Ok(Response::new(LOGIN_HTML.into())) 44 | // Ok(warp::reply::html(LOGIN_HTML)) 45 | } 46 | } 47 | 48 | pub async fn admin_login(token: Option, params: AdminUser) -> Result { 49 | let token = status::check_verify_code(token, ¶ms.captcha)?; 50 | facade::response(management::admin_login(&token, ¶ms.password).await) 51 | } 52 | 53 | pub async fn update_settings(token: Option, setting: Setting) -> Result { 54 | if let Err(e) = status::check_auth(token) { 55 | return facade::response(Err(e)); 56 | } 57 | facade::response(management::update_setting(setting.into()).await) 58 | } 59 | 60 | pub async fn forgot_password(authority: Option) -> Result { 61 | if let Some(a) = authority { 62 | if a.host().eq("localhost") || a.host().eq("localhost") { 63 | return Ok(show_settings_with_fake_auth()); 64 | } 65 | } 66 | let mut response = Response::new( 67 | "请通过localhost或127.0.0.1访问本页/Please visit this page with host: localhost or 127.0.0.1".into(), 68 | ); 69 | response.headers_mut().append( 70 | header::CONTENT_TYPE.as_str(), 71 | "text/plain; charset=UTF-8".parse().unwrap(), 72 | ); 73 | Ok(response) 74 | } 75 | 76 | pub async fn show_render_templates_page(token: Option) -> Result, Rejection> { 77 | if status::check_auth(token).is_err() { 78 | println!("show_render_templates_page auth failed"); 79 | return Ok(super::management_sign_in("/management/export-templates").into_response()); 80 | } 81 | let response = warp::http::Response::builder().header("Content-Type", "text/html; charset=utf-8"); 82 | let setting = match management::get_setting(crate::util::val::POST_DETAIL_RENDER_TEMPLATE).await { 83 | Ok(s) => s, 84 | Err(e) => return Ok(response.body(format!("{:?}", e.0).into()).unwrap()), 85 | }; 86 | let mut context = tera::Context::new(); 87 | if let Some(setting) = setting { 88 | context.insert("post_detail_template", &setting.content); 89 | } 90 | context.insert("post_detail_template_default", POST_DETAIL_DEFAULT_TEMPLATE); 91 | let html = match crate::service::export::TEMPLATES.render("export-template.html", &context) { 92 | Ok(s) => s, 93 | Err(e) => { 94 | eprintln!("{:?}", e); 95 | format!("Failed render page: {}", e) 96 | }, 97 | }; 98 | Ok(response.body(html.into()).unwrap()) 99 | } 100 | 101 | pub async fn update_render_templates( 102 | token: Option, 103 | data: HashMap, 104 | ) -> Result { 105 | if let Err(e) = status::check_auth(token) { 106 | return facade::response(Err(e)); 107 | } 108 | let setting = crate::db::model::Setting { 109 | item: crate::util::val::POST_DETAIL_RENDER_TEMPLATE.to_string(), 110 | content: data 111 | .get(crate::util::val::POST_DETAIL_RENDER_TEMPLATE) 112 | .map_or(String::new(), |s| String::from(s)), 113 | }; 114 | match management::update_setting(setting).await { 115 | Ok(_) => facade::response(Ok("")), 116 | Err(e) => facade::response(Err(e)), 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /backend/src/facade/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod asset; 2 | pub(crate) mod export; 3 | pub(crate) mod git; 4 | pub(crate) mod image; 5 | pub(crate) mod index; 6 | pub(crate) mod management; 7 | pub(crate) mod post; 8 | pub(crate) mod tag; 9 | pub(crate) mod user; 10 | 11 | use core::{convert::Infallible, result::Result}; 12 | 13 | use blog_common::{ 14 | dto::Response as ApiResponse, 15 | result::{Error, ErrorResponse}, 16 | val, 17 | }; 18 | use bytes::Buf; 19 | use hyper::header::{self, HeaderMap, HeaderValue}; 20 | use serde::Serialize; 21 | use warp::{ 22 | filters::multipart::FormData, 23 | http::{response::Response, StatusCode}, 24 | reply::{Json, Response as WarpResponse}, 25 | Rejection, Reply, 26 | }; 27 | 28 | use crate::util::result::Result as CommonResult; 29 | 30 | // lazy_static_include_str!(INDEX_PAGE_BYTES, "./src/resource/index.html"); 31 | 32 | pub async fn handle_rejection(err: Rejection) -> std::result::Result { 33 | /* 34 | dbg!(&err); 35 | 36 | let code; 37 | let error; 38 | 39 | if err.is_not_found() { 40 | code = StatusCode::NOT_FOUND; 41 | error = Error::NotFound; 42 | } else if let Some(_) = err.find::() { 43 | code = StatusCode::BAD_REQUEST; 44 | error = Error::BadRequest; 45 | } else if let Some(e) = err.find::() { 46 | code = StatusCode::METHOD_NOT_ALLOWED; 47 | error = Error::MethodNotAllowed; 48 | } else { 49 | eprintln!("unhandled error: {:?}", err); 50 | code = StatusCode::INTERNAL_SERVER_ERROR; 51 | error = Error::InternalServerError; 52 | } 53 | 54 | let json = wrap_json_err(code.as_u16(), error); 55 | 56 | Ok(warp::reply::with_status(json, code)) 57 | */ 58 | Ok(warp::reply::html(index::INDEX_HTML).into_response()) 59 | } 60 | 61 | #[inline] 62 | fn wrap_json_data(data: D) -> Json { 63 | let r = ApiResponse:: { 64 | status: 0, 65 | error: None, 66 | data: Some(data), 67 | }; 68 | 69 | warp::reply::json(&r) 70 | } 71 | 72 | #[inline] 73 | fn wrap_json_err(status: u16, error: Error) -> Json { 74 | let r = ApiResponse:: { 75 | status, 76 | error: Some(ErrorResponse { 77 | detail: format!("{}", error), 78 | code: error, 79 | }), 80 | data: None, 81 | }; 82 | 83 | warp::reply::json(&r) 84 | } 85 | 86 | #[inline] 87 | fn response(result: CommonResult) -> Result { 88 | let r = match result { 89 | Ok(d) => wrap_json_data(d), 90 | Err(ew) => { 91 | let e = ew.0; 92 | match e { 93 | Error::BusinessException(m) => wrap_json_err(400, Error::BusinessException(m)), 94 | _ => wrap_json_err(500, e), 95 | } 96 | }, 97 | }; 98 | Ok(r) 99 | } 100 | 101 | // https://stackoverflow.com/questions/62964013/how-can-two-headers-of-the-same-name-be-attached-to-a-warp-reply 102 | 103 | #[inline] 104 | fn session_id_cookie(token: &str) -> String { 105 | format!( 106 | // "{}={}; Domain=songday.com; Secure; HttpOnly; Path=/", 107 | "{}={}; SameSite=Lax; HttpOnly; Path=/;", 108 | val::SESSION_ID_HEADER_NAME, 109 | token, 110 | ) 111 | } 112 | 113 | fn management_sign_in(back_uri: &str) -> impl Reply { 114 | let url_encode = urlencoding::encode(back_uri); 115 | let mut redirect = String::with_capacity(64); 116 | redirect.push_str("/management?.redirect_url="); 117 | redirect.push_str(url_encode.as_ref()); 118 | let uri: warp::http::Uri = redirect.parse().unwrap(); 119 | warp::redirect::temporary(uri) 120 | } 121 | -------------------------------------------------------------------------------- /backend/src/facade/post.rs: -------------------------------------------------------------------------------- 1 | use core::{convert::Infallible, result::Result}; 2 | use std::collections::HashMap; 3 | use std::path::Path; 4 | 5 | use blog_common::{ 6 | dto::{post::PostData, user::UserInfo, Response as ApiResponse}, 7 | result::{Error, ErrorResponse}, 8 | val, 9 | }; 10 | use bytes::Buf; 11 | use hyper::header::{self, HeaderMap, HeaderValue}; 12 | use serde::Serialize; 13 | use sqlx::ColumnIndex; 14 | use warp::{ 15 | filters::multipart::FormData, 16 | http::{response::Response, StatusCode, Uri}, 17 | reply::{Json, Response as WarpResponse}, 18 | Rejection, Reply, 19 | }; 20 | 21 | use crate::{ 22 | db::post, 23 | facade::{session_id_cookie, wrap_json_data, wrap_json_err}, 24 | service::{image, status}, 25 | util::common, 26 | }; 27 | 28 | pub async fn new(token: Option) -> Result { 29 | if status::check_auth(token).is_err() { 30 | return Ok(wrap_json_err(500, Error::NotAuthed)); 31 | } 32 | post::new_post() 33 | .await 34 | .map(|id| wrap_json_data(&id)) 35 | .or_else(|e| Ok(wrap_json_err(500, e.0))) 36 | } 37 | 38 | pub async fn list(pagination_type: String, post_id: u64) -> Result { 39 | match post::list(pagination_type.as_str(), post_id, val::POSTS_PAGE_SIZE).await { 40 | Ok(list) => Ok(wrap_json_data(&list)), 41 | Err(e) => Ok(wrap_json_err(500, e.0)), 42 | } 43 | } 44 | 45 | pub async fn list_by_tag(tag: String, pagination_type: String, post_id: u64) -> Result { 46 | match post::list_by_tag(tag, &pagination_type, post_id, val::POSTS_PAGE_SIZE).await { 47 | Ok(list) => Ok(wrap_json_data(&list)), 48 | Err(e) => Ok(wrap_json_err(500, e.0)), 49 | } 50 | } 51 | 52 | pub async fn save(user: Option, post: PostData) -> Result { 53 | if user.is_none() { 54 | return Ok(wrap_json_err(403, Error::NotAuthed)); 55 | } 56 | match post::save(post).await { 57 | Ok(blog) => Ok(wrap_json_data(&blog)), 58 | Err(e) => Ok(wrap_json_err(500, e.0)), 59 | } 60 | } 61 | 62 | pub async fn show( 63 | token: Option, 64 | id: u64, 65 | query_string: HashMap, 66 | ) -> Result { 67 | let auth_result = status::check_auth(token); 68 | let edit = query_string.contains_key("edit"); 69 | if edit && auth_result.is_err() { 70 | return Ok(wrap_json_err(500, auth_result.unwrap_err().0)); 71 | } 72 | let editable = auth_result.is_ok() && edit; 73 | match post::show(id, editable).await { 74 | Ok(mut blog) => { 75 | blog.editable = editable; 76 | Ok(wrap_json_data(&blog)) 77 | }, 78 | Err(e) => Ok(wrap_json_err(500, e.0)), 79 | } 80 | } 81 | 82 | pub async fn delete(id: u64, user: Option) -> Result { 83 | if user.is_some() { 84 | if let Err(e) = image::delete_post_images(id).await { 85 | eprintln!("{:?}", e); 86 | } else if let Err(e) = post::delete(id).await { 87 | eprintln!("{:?}", e); 88 | } 89 | // post::delete(id).await.map(|_| wrap_json_data("Deleted")).map_err(|e| wrap_json_err(500, e.0)) 90 | } 91 | Ok(warp::redirect::found(Uri::from_static("/"))) 92 | } 93 | -------------------------------------------------------------------------------- /backend/src/facade/tag.rs: -------------------------------------------------------------------------------- 1 | use core::{convert::Infallible, result::Result}; 2 | 3 | use warp::{Rejection, Reply}; 4 | 5 | use crate::{ 6 | db::tag, 7 | facade::{session_id_cookie, wrap_json_data, wrap_json_err}, 8 | }; 9 | 10 | pub async fn top() -> Result { 11 | match tag::top().await { 12 | Ok(list) => Ok(wrap_json_data(&list)), 13 | Err(e) => Ok(wrap_json_err(500, e.0)), 14 | } 15 | } 16 | 17 | pub async fn list() -> Result { 18 | match tag::list().await { 19 | Ok(list) => Ok(wrap_json_data(&list)), 20 | Err(e) => Ok(wrap_json_err(500, e.0)), 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/facade/user.rs: -------------------------------------------------------------------------------- 1 | use core::{convert::Infallible, result::Result}; 2 | 3 | use hyper::header::{self, HeaderMap, HeaderValue}; 4 | use warp::{ 5 | reply::{Json, Response as WarpResponse}, 6 | Rejection, Reply, 7 | }; 8 | 9 | use blog_common::{ 10 | dto::user::{UserInfo, UserInfoWrapper, UserParams}, 11 | result::{Error, ErrorResponse}, 12 | val, 13 | }; 14 | 15 | use crate::{ 16 | db::user, 17 | facade::{session_id_cookie, wrap_json_data, wrap_json_err}, 18 | service::status, 19 | util::common, 20 | }; 21 | 22 | pub async fn register(params: UserParams) -> Result { 23 | if params.password1.len() < 3 { 24 | return Ok(wrap_json_err(500, Error::BusinessException("输入的密码不能少于3位".to_string())).into_response()); 25 | } 26 | 27 | if params.email.len() < 5 || !common::EMAIL_REGEX.is_match(¶ms.email) { 28 | return Ok(wrap_json_err(500, Error::BusinessException("输入的邮箱地址不合法".to_string())).into_response()); 29 | } 30 | 31 | match user::register(¶ms.email, ¶ms.password1).await { 32 | Ok(u) => { 33 | let token = common::simple_uuid(); 34 | status::user_online(&token, u.clone()); 35 | let w = UserInfoWrapper { 36 | user_info: u, 37 | access_token: token, 38 | }; 39 | let reply = wrap_json_data(&w); 40 | let reply_with_header = 41 | warp::reply::with_header(reply, header::SET_COOKIE.as_str(), session_id_cookie(&w.access_token)); 42 | Ok(reply_with_header.into_response()) 43 | }, 44 | Err(e) => { 45 | let reply = wrap_json_err(500, e.0); 46 | Ok(reply.into_response()) 47 | }, 48 | } 49 | } 50 | 51 | pub async fn login(token: Option, params: UserParams) -> Result { 52 | if params.password1.len() < 3 { 53 | return Ok(wrap_json_err(500, Error::BusinessException("输入的密码不能少于3位".to_string())).into_response()); 54 | } 55 | 56 | if params.email.len() < 5 || !common::EMAIL_REGEX.is_match(¶ms.email) { 57 | return Ok(wrap_json_err(500, Error::BusinessException("输入的邮箱地址不合法".to_string())).into_response()); 58 | } 59 | 60 | let token = status::check_verify_code(token, ¶ms.captcha)?; 61 | 62 | match user::login(¶ms.email, ¶ms.password1).await { 63 | Ok(u) => { 64 | status::user_online(&token, u.clone()); 65 | let w = UserInfoWrapper { 66 | user_info: u, 67 | access_token: token, 68 | }; 69 | let reply = wrap_json_data(&w); 70 | let reply_with_header = 71 | warp::reply::with_header(reply, header::SET_COOKIE.as_str(), session_id_cookie(&w.access_token)); 72 | Ok(reply_with_header.into_response()) 73 | }, 74 | Err(e) => { 75 | let reply = wrap_json_err(500, e.0); 76 | Ok(reply.into_response()) 77 | }, 78 | } 79 | } 80 | 81 | pub async fn logout(token: Option) -> Result { 82 | if token.is_some() { 83 | status::user_offline(&token.unwrap()); 84 | } 85 | Ok(wrap_json_data(String::from("Signed out."))) 86 | } 87 | 88 | pub async fn info(token: Option) -> Result { 89 | match status::check_auth(token) { 90 | Ok(u) => Ok(wrap_json_data(u)), 91 | Err(e) => Ok(wrap_json_err(500, e.0)), 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /backend/src/image/asset.rs: -------------------------------------------------------------------------------- 1 | use crate::util::num; 2 | 3 | pub struct NumberImage { 4 | pub data: &'static [u8], 5 | } 6 | 7 | include!(concat!("number_image.rs")); 8 | 9 | pub fn rand_group_number_image(num_pos: usize) -> &'static NumberImage { 10 | let group = num::rand_numbers(0, 4, 1); 11 | &NUMBER_IMAGE_GROUPS[group[0]][num_pos] 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/image/image.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Write, path::Path, vec::Vec}; 2 | 3 | use bytes::{buf::BufMut, Bytes, BytesMut}; 4 | use image::{ 5 | self, 6 | codecs::{ 7 | jpeg::JpegEncoder, 8 | png::{CompressionType, FilterType, PngEncoder}, 9 | }, 10 | ColorType, DynamicImage, GenericImage, GenericImageView, ImageBuffer, ImageEncoder, ImageFormat, Luma, Rgb, Rgba, 11 | RgbaImage, 12 | }; 13 | use rand::{thread_rng, Rng}; 14 | use tokio::fs::copy; 15 | 16 | use blog_common::{dto::UploadFileInfo, result::Error}; 17 | 18 | use crate::util::{result::Result, val}; 19 | 20 | pub type ImageWidth = u32; 21 | pub type ImageHeight = u32; 22 | 23 | const MAX_DIMENSION: u32 = 1000; 24 | 25 | /* 26 | https://stackoverflow.com/questions/35488820/how-to-create-a-rust-struct-with-an-imageimagebuffer-as-a-member 27 | */ 28 | 29 | fn err(image_error: image::error::ImageError) { 30 | match image_error { 31 | image::error::ImageError::Decoding(de) => { 32 | dbg!(de); 33 | }, 34 | image::error::ImageError::Encoding(ee) => { 35 | dbg!(ee); 36 | }, 37 | image::error::ImageError::Parameter(pe) => { 38 | dbg!(pe); 39 | }, 40 | image::error::ImageError::Limits(le) => { 41 | dbg!(le); 42 | }, 43 | image::error::ImageError::Unsupported(ue) => { 44 | dbg!(ue); 45 | }, 46 | image::error::ImageError::IoError(e) => { 47 | dbg!(e); 48 | }, 49 | }; 50 | } 51 | 52 | pub fn gen_verify_image(numbers: &[u8]) -> Bytes { 53 | let number_len = numbers.len() as u32; 54 | const WIDTH: u32 = 64u32; 55 | const HEIGHT: u32 = 64u32; 56 | let width = number_len * WIDTH; 57 | // let mut img = ImageBuffer::, Vec>::from_fn(width, height, |x, y| { 58 | // if x % 2 == 0 || y % 5 == 0 { 59 | // Luma([0u8]) 60 | // } else { 61 | // Luma([255u8]) 62 | // } 63 | // }); 64 | let mut img = RgbaImage::new(width, HEIGHT); 65 | // let raw_data = img.into_raw(); 66 | // let data = raw_data.as_slice(); 67 | // dbg!(data); 68 | 69 | let mut x_offset = 0u32; 70 | for n in numbers.into_iter() { 71 | let number = image::load_from_memory_with_format( 72 | super::asset::rand_group_number_image(*n as usize).data, 73 | image::ImageFormat::Png, 74 | ) 75 | .unwrap(); 76 | let mut rng = thread_rng(); 77 | for (x, y, pixel) in number.to_rgba8().enumerate_pixels() { 78 | // pixel.0[3] = 75; 79 | if x % 10 == 0 || y % 10 == 0 { 80 | img.put_pixel( 81 | x, 82 | y, 83 | Rgba([ 84 | rng.gen_range(0..=255), 85 | rng.gen_range(0..=255), 86 | rng.gen_range(0..=255), 87 | 100, 88 | ]), 89 | ); 90 | } else { 91 | img.put_pixel(x + x_offset, y, *pixel); 92 | } 93 | } 94 | x_offset += WIDTH; 95 | } 96 | 97 | let mut b = BytesMut::with_capacity(16384).writer(); 98 | // let mut encoder = JpegEncoder::new_with_quality(&mut out, 70); 99 | // let r = encoder.encode_image(&img); 100 | let encoder = PngEncoder::new_with_quality(&mut b, CompressionType::Default, FilterType::NoFilter); 101 | encoder.write_image(&img.into_raw(), width, HEIGHT, ColorType::Rgba8); 102 | // dbg!(out.len()); 103 | b.into_inner().freeze() 104 | } 105 | 106 | pub async fn resize_from_file(file: &UploadFileInfo) -> Result<()> { 107 | let image_format = match file.extension.as_str() { 108 | "gif" => ImageFormat::Gif, 109 | "jpg" | "jpeg" => ImageFormat::Jpeg, 110 | "png" => ImageFormat::Png, 111 | _ => return Err(Error::UnsupportedFileType(file.extension.clone()).into()), 112 | }; 113 | 114 | let filepath = file.filepath.as_path(); 115 | 116 | let (mut w, mut h) = match image::image_dimensions(filepath) { 117 | Ok((w, h)) => (w, h), 118 | Err(e) => { 119 | dbg!(e); 120 | return Err(Error::UnknownFileType.into()); 121 | }, 122 | }; 123 | 124 | if h <= MAX_DIMENSION && w <= MAX_DIMENSION { 125 | return Ok(()); 126 | } 127 | 128 | let dynamic_image = match image::open(filepath) { 129 | Ok(i) => i, 130 | Err(e) => { 131 | err(e); 132 | return Err(Error::UnknownFileType.into()); 133 | }, 134 | }; 135 | 136 | if w == h { 137 | w = MAX_DIMENSION; 138 | h = MAX_DIMENSION; 139 | } else if w > h { 140 | h = h * MAX_DIMENSION / w; 141 | w = MAX_DIMENSION; 142 | } else { 143 | w = w * MAX_DIMENSION / h; 144 | h = MAX_DIMENSION; 145 | } 146 | 147 | let d = dynamic_image.thumbnail_exact(w, h); 148 | if let Err(e) = d.save_with_format(&filepath, image_format) { 149 | dbg!(e); 150 | return Err(Error::CreateThumbnailFailed.into()); 151 | } 152 | 153 | Ok(()) 154 | } 155 | 156 | // 下面这个,如果写成:B, 'a,就会提示找不到生命周期 157 | pub fn resize_from_bytes<'a, B>(src_bytes: B) -> Result<()> 158 | where 159 | B: AsRef<&'a [u8]>, 160 | { 161 | let image_type = match image::guess_format(src_bytes.as_ref()) { 162 | Ok(t) => t, 163 | Err(e) => { 164 | err(e); 165 | return Err(Error::UnknownFileType.into()); 166 | }, 167 | }; 168 | 169 | let dynamic_image = match image::load_from_memory_with_format(src_bytes.as_ref(), image_type) { 170 | Ok(i) => i, 171 | Err(e) => { 172 | err(e); 173 | return Err(Error::UnknownFileType.into()); 174 | }, 175 | }; 176 | 177 | Ok(()) 178 | } 179 | -------------------------------------------------------------------------------- /backend/src/image/mod.rs: -------------------------------------------------------------------------------- 1 | pub(in crate::image) mod asset; 2 | pub mod image; 3 | -------------------------------------------------------------------------------- /backend/src/image/number_image.rs: -------------------------------------------------------------------------------- 1 | pub const NUMBER_IMAGE_GROUPS: [[NumberImage; 10]; 4] = [ 2 | GROUP1_NUMBERS, 3 | GROUP2_NUMBERS, 4 | GROUP3_NUMBERS, 5 | GROUP4_NUMBERS, 6 | ]; 7 | pub const GROUP1_NUMBERS: [NumberImage; 10] = [ 8 | NumberImage { 9 | data: include_bytes!("../resource/icon/1-0.png"), 10 | }, 11 | NumberImage { 12 | data: include_bytes!("../resource/icon/1-1.png"), 13 | }, 14 | NumberImage { 15 | data: include_bytes!("../resource/icon/1-2.png"), 16 | }, 17 | NumberImage { 18 | data: include_bytes!("../resource/icon/1-3.png"), 19 | }, 20 | NumberImage { 21 | data: include_bytes!("../resource/icon/1-4.png"), 22 | }, 23 | NumberImage { 24 | data: include_bytes!("../resource/icon/1-5.png"), 25 | }, 26 | NumberImage { 27 | data: include_bytes!("../resource/icon/1-6.png"), 28 | }, 29 | NumberImage { 30 | data: include_bytes!("../resource/icon/1-7.png"), 31 | }, 32 | NumberImage { 33 | data: include_bytes!("../resource/icon/1-8.png"), 34 | }, 35 | NumberImage { 36 | data: include_bytes!("../resource/icon/1-9.png"), 37 | }, 38 | ]; 39 | pub const GROUP2_NUMBERS: [NumberImage; 10] = [ 40 | NumberImage { 41 | data: include_bytes!("../resource/icon/2-0.png"), 42 | }, 43 | NumberImage { 44 | data: include_bytes!("../resource/icon/2-1.png"), 45 | }, 46 | NumberImage { 47 | data: include_bytes!("../resource/icon/2-2.png"), 48 | }, 49 | NumberImage { 50 | data: include_bytes!("../resource/icon/2-3.png"), 51 | }, 52 | NumberImage { 53 | data: include_bytes!("../resource/icon/2-4.png"), 54 | }, 55 | NumberImage { 56 | data: include_bytes!("../resource/icon/2-5.png"), 57 | }, 58 | NumberImage { 59 | data: include_bytes!("../resource/icon/2-6.png"), 60 | }, 61 | NumberImage { 62 | data: include_bytes!("../resource/icon/2-7.png"), 63 | }, 64 | NumberImage { 65 | data: include_bytes!("../resource/icon/2-8.png"), 66 | }, 67 | NumberImage { 68 | data: include_bytes!("../resource/icon/2-9.png"), 69 | }, 70 | ]; 71 | pub const GROUP3_NUMBERS: [NumberImage; 10] = [ 72 | NumberImage { 73 | data: include_bytes!("../resource/icon/3-0.png"), 74 | }, 75 | NumberImage { 76 | data: include_bytes!("../resource/icon/3-1.png"), 77 | }, 78 | NumberImage { 79 | data: include_bytes!("../resource/icon/3-2.png"), 80 | }, 81 | NumberImage { 82 | data: include_bytes!("../resource/icon/3-3.png"), 83 | }, 84 | NumberImage { 85 | data: include_bytes!("../resource/icon/3-4.png"), 86 | }, 87 | NumberImage { 88 | data: include_bytes!("../resource/icon/3-5.png"), 89 | }, 90 | NumberImage { 91 | data: include_bytes!("../resource/icon/3-6.png"), 92 | }, 93 | NumberImage { 94 | data: include_bytes!("../resource/icon/3-7.png"), 95 | }, 96 | NumberImage { 97 | data: include_bytes!("../resource/icon/3-8.png"), 98 | }, 99 | NumberImage { 100 | data: include_bytes!("../resource/icon/3-9.png"), 101 | }, 102 | ]; 103 | pub const GROUP4_NUMBERS: [NumberImage; 10] = [ 104 | NumberImage { 105 | data: include_bytes!("../resource/icon/4-0.png"), 106 | }, 107 | NumberImage { 108 | data: include_bytes!("../resource/icon/4-1.png"), 109 | }, 110 | NumberImage { 111 | data: include_bytes!("../resource/icon/4-2.png"), 112 | }, 113 | NumberImage { 114 | data: include_bytes!("../resource/icon/4-3.png"), 115 | }, 116 | NumberImage { 117 | data: include_bytes!("../resource/icon/4-4.png"), 118 | }, 119 | NumberImage { 120 | data: include_bytes!("../resource/icon/4-5.png"), 121 | }, 122 | NumberImage { 123 | data: include_bytes!("../resource/icon/4-6.png"), 124 | }, 125 | NumberImage { 126 | data: include_bytes!("../resource/icon/4-7.png"), 127 | }, 128 | NumberImage { 129 | data: include_bytes!("../resource/icon/4-8.png"), 130 | }, 131 | NumberImage { 132 | data: include_bytes!("../resource/icon/4-9.png"), 133 | }, 134 | ]; 135 | -------------------------------------------------------------------------------- /backend/src/lib.rs: -------------------------------------------------------------------------------- 1 | // #![feature(const_fn)] 2 | // #![feature(const_mut_refs)] 3 | // #![feature(trait_alias)] 4 | 5 | // #[macro_use] 6 | // extern crate lazy_static_include; 7 | // #[macro_use] 8 | // extern crate lazy_static; 9 | 10 | pub mod db; 11 | mod facade; 12 | mod image; 13 | pub mod service; 14 | pub mod util; 15 | pub mod config; -------------------------------------------------------------------------------- /backend/src/main.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "256"] 2 | 3 | use std::net::SocketAddr; 4 | 5 | use blog_backend::{db, service, util::result,config::{config_loader, self}}; 6 | use clap::Parser; 7 | use futures::future::{join_all, BoxFuture}; 8 | use tokio::{ 9 | runtime::{Builder, Runtime}, 10 | sync::broadcast, 11 | }; 12 | 13 | 14 | 15 | fn main() -> result::Result<()> { 16 | if std::env::var_os("RUST_LOG").is_none() { 17 | std::env::set_var("RUST_LOG", "access-log=info"); 18 | } 19 | pretty_env_logger::init(); 20 | 21 | let mut args = crate::config_loader::Args::parse(); 22 | if args.config.is_some(){ 23 | let config_result = config_loader::load_config(&mut args); 24 | match config_result{ 25 | Ok(_)=>{ 26 | println!("Config Loaded") 27 | }, 28 | Err(_)=>{ 29 | panic!("Config Invalid!"); 30 | }, 31 | _=>() 32 | } 33 | } 34 | let runtime = Builder::new_multi_thread() 35 | .worker_threads(4) 36 | .enable_all() 37 | .thread_name("Songday-blog-service") 38 | .thread_stack_size(1024 * 1024) 39 | .build()?; 40 | 41 | let (tx, rx1) = broadcast::channel(2); 42 | let rx2 = tx.subscribe(); 43 | runtime.spawn(async move { 44 | match tokio::signal::ctrl_c().await { 45 | Ok(()) => { 46 | println!("Shutting down web server..."); 47 | match tx.send(()) { 48 | Ok(_) => {}, 49 | Err(_) => println!("the receiver dropped"), 50 | } 51 | }, 52 | Err(e) => { 53 | eprintln!("{}", e); 54 | }, 55 | } 56 | }); 57 | 58 | let mut addr = String::from(&args.ip); 59 | addr.push_str(":"); 60 | addr.push_str(&args.port.to_string()); 61 | let http_address = addr.parse::()?; 62 | 63 | if args.mode.is_some() && args.mode.unwrap().eq("static") { 64 | println!("Creating static file server instance..."); 65 | let server = runtime.block_on(service::server::create_static_file_server(http_address, rx1))?; 66 | 67 | println!("Starting static file server..."); 68 | runtime.block_on(server); 69 | } else { 70 | println!("Initializing database connection..."); 71 | runtime.block_on(db::init_datasource()); 72 | 73 | println!("Creating server instance..."); 74 | let mut servers: Vec> = Vec::new(); 75 | 76 | let server = runtime.block_on(service::server::create_blog_server(http_address, rx1, &args.cors_host)); 77 | println!("Starting http blog backend server..."); 78 | servers.push(Box::pin(server.unwrap())); 79 | 80 | if args.https_enabled { 81 | let mut addr = String::from(&args.ip); 82 | addr.push_str(":"); 83 | addr.push_str(&args.https_port.to_string()); 84 | let https_address = addr.parse::()?; 85 | 86 | let cert_path = &args.cert_path.unwrap(); 87 | let key_path = &args.key_path.unwrap(); 88 | 89 | if args.hsts_enabled { 90 | let server = service::server::create_tls_blog_server_with_hsts( 91 | https_address, 92 | rx2, 93 | cert_path, 94 | key_path, 95 | &args.cors_host, 96 | ); 97 | let server = runtime.block_on(server); 98 | println!("Starting https blog backend server..."); 99 | servers.push(Box::pin(server.unwrap())); 100 | } else { 101 | let server = 102 | service::server::create_tls_blog_server(https_address, rx2, cert_path, key_path, &args.cors_host); 103 | let server = runtime.block_on(server); 104 | println!("Starting https blog backend server..."); 105 | servers.push(Box::pin(server.unwrap())); 106 | } 107 | } 108 | let server = join_all(servers); 109 | 110 | runtime.block_on(server); 111 | println!("Closing database connections..."); 112 | runtime.block_on(db::shutdown()); 113 | } 114 | 115 | println!("Bye..."); 116 | 117 | Ok(()) 118 | } 119 | -------------------------------------------------------------------------------- /backend/src/resource/icon/1-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-0.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-1.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-2.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-3.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-4.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-5.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-6.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-7.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-8.png -------------------------------------------------------------------------------- /backend/src/resource/icon/1-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/1-9.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-0.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-1.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-2.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-3.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-4.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-5.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-6.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-7.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-8.png -------------------------------------------------------------------------------- /backend/src/resource/icon/2-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/2-9.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-0.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-1.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-2.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-3.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-4.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-5.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-6.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-7.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-8.png -------------------------------------------------------------------------------- /backend/src/resource/icon/3-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/3-9.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-0.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-1.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-2.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-3.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-4.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-5.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-6.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-7.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-8.png -------------------------------------------------------------------------------- /backend/src/resource/icon/4-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/icon/4-9.png -------------------------------------------------------------------------------- /backend/src/resource/page/export-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Templates 7 | 8 | 9 | 10 | 11 | 12 | 19 | 20 | 21 |
22 |

23 | Templates 24 |

25 |

 

26 |

博客详情模板/Post detail template

27 |

28 | 35 |

36 |
37 |
38 |
39 | 40 |
41 |
42 | 43 |
44 |
45 |
46 | 50 |
51 | 52 | -------------------------------------------------------------------------------- /backend/src/resource/page/git-pages-detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Git pages 7 | 8 | 9 | 10 | 11 | 12 | 43 | 44 | 45 |
46 |

47 | Git pages settings 48 |

49 |

 

50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 77 | 78 | {% if branch %} 79 | 80 | 81 | 85 | 86 | 87 | 88 | 101 | 102 | 103 | 104 | 108 | 109 | {% endif %} 110 |
仓库地址/Git repository URL{{remote_url}}
邮箱/Email{{email}}
用户名/UserName{{name}}
分支/Branch 66 | {% if branches|length > 1 %} 67 | 73 | {% else %} 74 | {{branch}} 75 | {% endif %} 76 |
导出的子目录/Subdirectory for exporting 82 | 83 |

This can be empty.

84 |
是否渲染成HTML/Render to HTML 89 |
90 | 94 | 98 |
99 |

Templates management

100 |
同步密码/Repository credential * 105 | 106 |

Repository credential for {{name}}. This tool don't record any credentials.

107 |
111 |
112 | {% if branches|length > 1 %} 113 | 114 | {% else %} 115 |
116 |
117 | 118 |
119 |
120 | 121 |
122 |
123 | 124 |
125 |
126 | {% endif %} 127 |
128 | 132 |
133 | 134 | -------------------------------------------------------------------------------- /backend/src/resource/page/git-pages-init.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Git pages 7 | 8 | 9 | 10 | 11 | 12 | 21 | 22 | 23 |
24 |

25 | 信息配置/Settings 26 |

27 |

 

28 |
29 | 30 |
31 | 32 | 33 | 34 | 35 |
36 |

帮助/Help.

37 |
38 |
39 | 40 |
41 | 42 | 43 | 44 | 45 |
46 |

For git config: 'user.email'.

47 |
48 |
49 | 50 |
51 | 52 | 53 | 54 | 55 |
56 |

For git config: 'user.name'.

57 |
58 |
59 | 60 | 61 |
62 | 66 |
67 | 68 | -------------------------------------------------------------------------------- /backend/src/resource/page/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/src/resource/page/index.html -------------------------------------------------------------------------------- /backend/src/resource/page/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 管理登录/Management sign in 6 | 7 | 8 | 9 | 10 | 36 | 37 | 38 |
39 |

40 | 管理登录/Management sign in 41 |

42 |

 

43 |
44 | 45 |
46 | 47 | 48 | 49 | 50 |
51 |

忘记密码/Forgot password

52 |
53 |
54 | 55 |
56 | 57 | 58 | 59 | 60 |
61 |
62 |
63 |
64 |
65 | 66 |
67 |
68 | 69 |
70 |
71 |
72 | 73 | -------------------------------------------------------------------------------- /backend/src/resource/page/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 配置博客信息 7 | 8 | 9 | 10 | 11 | 12 | 40 | 41 | 42 |
43 |

44 | 信息配置/Settings 45 |

46 |

 

47 |
48 | 49 |
50 | 51 | 52 | 53 | 54 |
55 |

请最少输入1位/Minimum length is 1 character.

56 |
57 |
58 | 59 | 60 |
61 |

 

62 |

63 | 导出/Export 64 |

65 |

 

66 |

67 | 73 | 79 |

80 |
81 | 82 | -------------------------------------------------------------------------------- /backend/src/resource/sql/ddl.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tags ( 2 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 3 | name TEXT(16) NOT NULL, 4 | created_at INTEGER NOT NULL, 5 | updated_at INTEGER, 6 | is_deleted INTEGER DEFAULT 0 NOT NULL, 7 | deleted_at INTEGER, 8 | CONSTRAINT "name" UNIQUE ("name" ASC) 9 | ); 10 | 11 | CREATE TABLE tags_usage ( 12 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 13 | post_id INTEGER NOT NULL, 14 | tag_id INTEGER NOT NULL, 15 | CONSTRAINT "using_tag_UN" UNIQUE ("post_id" ASC, "tag_id" ASC) 16 | ); 17 | CREATE INDEX post_id_IDX ON tags_usage (post_id); 18 | CREATE INDEX tag_id_IDX ON tags_usage (tag_id); 19 | -- CREATE UNIQUE INDEX blog_id_tag_id_IDX ON using_tag (blog_id,tag_id); 20 | 21 | CREATE TABLE posts ( 22 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 23 | title TEXT(64) NOT NULL, 24 | title_image TEXT(1024) NOT NULL, 25 | markdown_content TEXT(65535) NOT NULL, 26 | rendered_content TEXT(65535) NOT NULL, 27 | created_at INTEGER NOT NULL, 28 | updated_at INTEGER, 29 | is_deleted INTEGER DEFAULT 0 NOT NULL, 30 | deleted_at INTEGER 31 | ); 32 | 33 | CREATE TABLE settings ( 34 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 35 | item TEXT(32) NOT NULL, 36 | content TEXT(1024) NOT NULL, 37 | created_at INTEGER NOT NULL, 38 | updated_at INTEGER, 39 | CONSTRAINT "item_UN" UNIQUE ("item" ASC) 40 | ); 41 | -------------------------------------------------------------------------------- /backend/src/resource/sql/dml.sql: -------------------------------------------------------------------------------- 1 | -- INSERT INTO "blog_tag" ("name") VALUES ('70年代'); 2 | -- INSERT INTO "blog_tag" ("name") VALUES ('80年代'); 3 | -- INSERT INTO "blog_tag" ("name") VALUES ('90年代'); 4 | -- INSERT INTO "blog_tag" ("name") VALUES ('复古怀旧'); 5 | -- INSERT INTO "blog_tag" ("name") VALUES ('国内影视'); 6 | -- INSERT INTO "blog_tag" ("name") VALUES ('国外影视'); 7 | -- INSERT INTO "blog_tag" ("name") VALUES ('国内音乐'); 8 | -- INSERT INTO "blog_tag" ("name") VALUES ('国外音乐'); 9 | -- INSERT INTO "blog_tag" ("name") VALUES ('像素、8-Bit'); 10 | -- INSERT INTO "blog_tag" ("name") VALUES ('电子游戏'); 11 | -- INSERT INTO "blog_tag" ("name") VALUES ('电子产品'); 12 | -- INSERT INTO "blog_tag" ("name") VALUES ('报刊杂志'); 13 | -- INSERT INTO "blog_tag" ("name") VALUES ('往期新闻'); 14 | -- INSERT INTO "blog_tag" ("name") VALUES ('生活用品'); 15 | 16 | -- INSERT INTO settings(id,name,domain,copyright,license,admin_password,created_at,updated_at) VALUES (1,'','','','','','2021-10-01 00:00:00','2021-10-01 00:00:00'); -------------------------------------------------------------------------------- /backend/src/resource/static-site/template/hugo.txt: -------------------------------------------------------------------------------- 1 | --- 2 | title: "{{title}}" 3 | date: 2021-08-30T13:44:28+09:00 4 | draft: true 5 | --- 6 | 7 | {{content}} -------------------------------------------------------------------------------- /backend/src/resource/static-site/template/post_detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{title}} 6 | 7 | 8 |

{{title}}

9 |
10 |

{{content}}

11 | 12 | -------------------------------------------------------------------------------- /backend/src/service/asset.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, io::Write}; 2 | 3 | use lazy_static::lazy_static; 4 | 5 | // const ALL_THE_FILES: &[(&str, &[u8])] = &include!(concat!(env!("OUT_DIR"), "/all_the_files.rs")); 6 | const ALL_ASSET_FILES: &[(&str, &[u8], &str)] = &include!("asset_list.rs"); 7 | 8 | lazy_static! { 9 | static ref AEEST_MAP: HashMap<&'static str, usize> = { 10 | let mut asset = HashMap::with_capacity(10); 11 | let mut idx = 0usize; 12 | for (name, _data, _mime) in ALL_ASSET_FILES { 13 | asset.insert(*name, idx); 14 | // asset.insert("", &b[..]); 15 | idx += 1; 16 | } 17 | asset 18 | }; 19 | } 20 | 21 | pub(crate) fn get_asset(path: &str) -> Option<(&'static str, &'static [u8], &'static str)> { 22 | let idx = AEEST_MAP.get(path); 23 | if idx.is_none() { 24 | return None; 25 | } 26 | Some(ALL_ASSET_FILES[*idx.unwrap()]) 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/service/asset_list.rs: -------------------------------------------------------------------------------- 1 | [ 2 | (".stage/bulma.min-82aac43507618108.css", include_bytes!(r#"..\resource\asset/.stage\bulma.min-82aac43507618108.css.gz"#), "text/css"), 3 | (".stage/codemirror.min.css", include_bytes!(r#"..\resource\asset/.stage\codemirror.min.css.gz"#), "text/css"), 4 | (".stage/common.js", include_bytes!(r#"..\resource\asset/.stage\common.js.gz"#), "text/javascript"), 5 | (".stage/editor.html", include_bytes!(r#"..\resource\asset/.stage\editor.html.gz"#), "text/html; charset=utf-8"), 6 | (".stage/favicon.ico", include_bytes!(r#"..\resource\asset/.stage\favicon.ico.gz"#), ""), 7 | (".stage/fontawesome.min-5e9e696c59c57e83.css", include_bytes!(r#"..\resource\asset/.stage\fontawesome.min-5e9e696c59c57e83.css.gz"#), "text/css"), 8 | (".stage/index-ec4ef66164c33b9c.css", include_bytes!(r#"..\resource\asset/.stage\index-ec4ef66164c33b9c.css.gz"#), "text/css"), 9 | (".stage/logo.png", include_bytes!(r#"..\resource\asset/.stage\logo.png.gz"#), ""), 10 | (".stage/regular.min-a0c258fb7c5f655d.css", include_bytes!(r#"..\resource\asset/.stage\regular.min-a0c258fb7c5f655d.css.gz"#), "text/css"), 11 | (".stage/solid.min-70c2e5caa950974d.css", include_bytes!(r#"..\resource\asset/.stage\solid.min-70c2e5caa950974d.css.gz"#), "text/css"), 12 | (".stage/toastui-editor-all.min.js", include_bytes!(r#"..\resource\asset/.stage\toastui-editor-all.min.js.gz"#), "text/javascript"), 13 | (".stage/toastui-editor.min.css", include_bytes!(r#"..\resource\asset/.stage\toastui-editor.min.css.gz"#), "text/css"), 14 | (".stage/webfonts/fa-brands-400.ttf", include_bytes!(r#"..\resource\asset/.stage\webfonts\fa-brands-400.ttf.gz"#), ""), 15 | (".stage/webfonts/fa-brands-400.woff2", include_bytes!(r#"..\resource\asset/.stage\webfonts\fa-brands-400.woff2.gz"#), ""), 16 | (".stage/webfonts/fa-regular-400.ttf", include_bytes!(r#"..\resource\asset/.stage\webfonts\fa-regular-400.ttf.gz"#), ""), 17 | (".stage/webfonts/fa-regular-400.woff2", include_bytes!(r#"..\resource\asset/.stage\webfonts\fa-regular-400.woff2.gz"#), ""), 18 | (".stage/webfonts/fa-solid-900.ttf", include_bytes!(r#"..\resource\asset/.stage\webfonts\fa-solid-900.ttf.gz"#), ""), 19 | (".stage/webfonts/fa-solid-900.woff2", include_bytes!(r#"..\resource\asset/.stage\webfonts\fa-solid-900.woff2.gz"#), ""), 20 | ] 21 | -------------------------------------------------------------------------------- /backend/src/service/export.rs: -------------------------------------------------------------------------------- 1 | use std::fs::OpenOptions; 2 | use std::io::Write; 3 | 4 | use blog_common::dto::git::{GitPushInfo, GitRepositoryInfo}; 5 | use lazy_static::lazy_static; 6 | use tera::Tera; 7 | use zip::write::FileOptions; 8 | 9 | use crate::db::{management, model::Post, post}; 10 | use crate::util::{self, result::Result}; 11 | 12 | static HUGO_TEMPLATE: &'static str = include_str!("../resource/static-site/template/hugo.txt"); 13 | static GIT_PAGES_DETAIL_HTML: &'static str = include_str!("../resource/page/git-pages-detail.html"); 14 | static RENDER_TEMPLATE_HTML: &'static str = include_str!("../resource/page/export-template.html"); 15 | 16 | lazy_static! { 17 | pub static ref TEMPLATES: Tera = { 18 | let mut tera = Tera::default(); 19 | if let Err(e) = tera.add_raw_template("hugo.md", HUGO_TEMPLATE) { 20 | eprintln!("{:?}", e); 21 | } 22 | if let Err(e) = tera.add_raw_template("git-pages-detail.html", GIT_PAGES_DETAIL_HTML) { 23 | eprintln!("{:?}", e); 24 | } 25 | if let Err(e) = tera.add_raw_template("export-template.html", RENDER_TEMPLATE_HTML) { 26 | eprintln!("{:?}", e); 27 | } 28 | tera 29 | }; 30 | } 31 | 32 | fn render(post: &Post, template_name: &str, template: Option<&String>) -> Result { 33 | let mut context = tera::Context::new(); 34 | context.insert("title", &post.title); 35 | context.insert("content", &post.markdown_content); 36 | let r = if template.is_some() { 37 | tera::Tera::one_off(template.unwrap(), &context, true) 38 | } else { 39 | TEMPLATES.render(template_name, &context) 40 | }; 41 | r.map_err(|e| e.into()) 42 | } 43 | 44 | // fn render(post: &Post, template_name: &str) -> String { 45 | // let mut context = tera::Context::new(); 46 | // context.insert("title", &post.title); 47 | // context.insert("content", &post.markdown_content); 48 | // match TEMPLATES.render(template_name, &context) { 49 | // Ok(s) => s, 50 | // Err(e) => { 51 | // eprintln!("{:?}", e); 52 | // String::new() 53 | // }, 54 | // } 55 | // } 56 | 57 | // fn write_posts(posts: &Vec, mut writer: impl std::io::Write) -> Result<()> { 58 | // fn write_posts(posts: &Vec, callback: impl Fn(dyn std::io::Write, &str, &String) -> Result<()>) -> Result<()> { 59 | fn write_posts(posts: &Vec, mut callback: C, file_ext: &str) -> Result<()> 60 | where 61 | C: FnMut(&String, &Post) -> Result<()>, 62 | { 63 | let mut file_name = String::with_capacity(32); 64 | for post in posts { 65 | file_name.push_str(post.id.to_string().as_str()); 66 | file_name.push_str("."); 67 | file_name.push_str(file_ext); 68 | 69 | callback(&file_name, &post)?; 70 | 71 | file_name.clear(); 72 | } 73 | Ok(()) 74 | } 75 | 76 | // fn write_to_zip(mut writer: zip::ZipWriter, filename: &str, content: &String) -> Result<()> { 77 | // writer.start_file(filename, FileOptions::default())?; 78 | // writer.write_all(content.as_bytes())?; 79 | // Ok(()) 80 | // } 81 | 82 | pub async fn hugo() -> Result { 83 | let posts = post::all().await?; 84 | 85 | let export_dir = std::env::current_dir()?.join("export"); 86 | if !export_dir.exists() { 87 | tokio::fs::create_dir(export_dir.as_path()).await?; 88 | } 89 | let mut filename = util::common::simple_uuid(); 90 | filename.push_str(".zip"); 91 | let output_file = export_dir.join(filename.as_str()); 92 | let file = std::fs::File::create(output_file)?; 93 | let mut zip = zip::ZipWriter::new(file); 94 | 95 | let zip_file = |file_name: &String, post: &Post| -> Result<()> { 96 | zip.start_file(file_name, FileOptions::default())?; 97 | let content = render(post, "hugo.md", None)?; 98 | zip.write_all(content.as_bytes())?; 99 | Ok(()) 100 | }; 101 | 102 | write_posts(&posts, zip_file, "md")?; 103 | 104 | /* 105 | let mut file_name = String::with_capacity(32); 106 | for post in posts.iter() { 107 | file_name.push_str(post.id.to_string().as_str()); 108 | file_name.push_str(".md"); 109 | 110 | zip_file(&file_name, post)?; 111 | 112 | // zip.start_file(file_name.as_str(), FileOptions::default())?; 113 | 114 | // let content = render(post, "hugo.md"); 115 | // zip.write_all(content.as_bytes())?; 116 | 117 | file_name.clear(); 118 | } 119 | */ 120 | zip.finish()?; 121 | 122 | Ok(filename) 123 | } 124 | 125 | pub async fn git(git: &GitRepositoryInfo, push_info: &GitPushInfo) -> Result<()> { 126 | let path = super::git::git::get_repository_path(git); 127 | let mut path = path.join("file.txt"); 128 | println!("export path {}", path.as_path().display()); 129 | 130 | let posts = post::all_by_since(git.last_export_second).await?; 131 | let one_off_template = if push_info.render_html { 132 | let setting = management::get_setting(crate::util::val::POST_DETAIL_RENDER_TEMPLATE).await?; 133 | setting.map(|s| s.content) 134 | } else { 135 | None 136 | }; 137 | let (template_name, file_ext) = if one_off_template.is_some() { 138 | ("one_off_template", "html") 139 | } else { 140 | ("hugo.md", "md") 141 | }; 142 | let write_file = |filename: &String, post: &Post| -> Result<()> { 143 | path.set_file_name(filename); 144 | println!("export to file {}", path.as_path().display()); 145 | let mut file = OpenOptions::new() 146 | .create(true) 147 | .write(true) 148 | .truncate(true) 149 | .open(path.as_path())?; 150 | let content = render(post, template_name, one_off_template.as_ref())?; 151 | file.write_all(content.as_bytes())?; 152 | Ok(()) 153 | }; 154 | write_posts(&posts, write_file, file_ext)?; 155 | 156 | Ok(()) 157 | } 158 | -------------------------------------------------------------------------------- /backend/src/service/git/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod git; 2 | pub(crate) mod pull; 3 | -------------------------------------------------------------------------------- /backend/src/service/image.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | path::{Path, PathBuf}, 4 | sync::Arc, 5 | }; 6 | 7 | use blog_common::dto::FormDataItem; 8 | use blog_common::{dto::post::UploadImage, result::Error}; 9 | use bytes::Buf; 10 | use rand::Rng; 11 | use tokio::io::AsyncWriteExt; 12 | use warp::filters::multipart::{FormData, Part}; 13 | 14 | use crate::{ 15 | db::{model::Tag, post}, 16 | image::image, 17 | util::{ 18 | io::{self, SupportFileType}, 19 | result::Result, 20 | }, 21 | }; 22 | 23 | pub async fn get_upload_image(path: &str) -> Result> { 24 | let mut path_buf = PathBuf::with_capacity(32); 25 | path_buf.push("upload"); 26 | let v: Vec<&str> = path.split_terminator('/').collect(); 27 | for n in v { 28 | path_buf.push(n); 29 | } 30 | match tokio::fs::read(path_buf.as_path()).await { 31 | Ok(d) => Ok(d), 32 | Err(e) => { 33 | eprintln!("{} {:?}", path, e); 34 | Err(Error::FileNotFound.into()) 35 | }, 36 | } 37 | } 38 | 39 | pub async fn upload(post_id: u64, data: FormData) -> Result> { 40 | let items = io::save_upload_file( 41 | post_id, 42 | data, 43 | &[SupportFileType::Png, SupportFileType::Jpg, SupportFileType::Gif], 44 | ) 45 | .await?; 46 | let mut images: Vec = Vec::with_capacity(items.len()); 47 | for i in items.iter() { 48 | match i { 49 | FormDataItem::FILE(f) => { 50 | image::resize_from_file(&f).await?; 51 | let relative_path = f.relative_path.to_string(); 52 | let original_filename = f.original_filename.to_string(); 53 | images.push(UploadImage::new(relative_path, original_filename)); 54 | }, 55 | _ => {}, 56 | } 57 | } 58 | Ok(images) 59 | } 60 | 61 | pub async fn save(post_id: u64, filename: String, body: impl Buf) -> Result { 62 | let filename = urlencoding::decode(&filename)?; 63 | let filename = filename.into_owned(); 64 | 65 | let file_info = io::save_upload_stream( 66 | post_id, 67 | filename, 68 | body, 69 | &[SupportFileType::Png, SupportFileType::Jpg, SupportFileType::Gif], 70 | ) 71 | .await?; 72 | image::resize_from_file(&file_info).await?; 73 | let d = UploadImage::new(file_info.relative_path, file_info.original_filename); 74 | Ok(d) 75 | } 76 | 77 | // pub async fn resize_blog_image, T: AsRef<&str>>(b: B, type: T) {} 78 | 79 | // https://rust-lang-nursery.github.io/rust-cookbook/web/clients/download.html 80 | pub async fn random_title_image(id: u64) -> Result { 81 | let url = { 82 | let mut rng = rand::thread_rng(); 83 | if rng.gen_range(1..=100) > 75 { 84 | // https://source.unsplash.com/random/1000x500?keywords.join(",")&sig=cache_buster 85 | "https://source.unsplash.com/random/1000x500" 86 | } else { 87 | "https://picsum.photos/1000/500" 88 | } 89 | }; 90 | let response = reqwest::get(url).await?; 91 | let file_ext = response 92 | .url() 93 | .path_segments() 94 | .and_then(|segments| segments.last()) 95 | .and_then(|name| { 96 | if name.is_empty() { 97 | None 98 | } else { 99 | name.find(".").map(|pos| &name[pos + 1..]) 100 | } 101 | }) 102 | .unwrap_or(match response.headers().get("Content-Type") { 103 | Some(h) => { 104 | let r = h.to_str(); 105 | match r { 106 | Ok(header) => match header { 107 | "image/jpeg" => "jpg", 108 | "image/png" => "png", 109 | _ => "", 110 | }, 111 | Err(e) => { 112 | eprintln!("{:?}", e); 113 | "" 114 | }, 115 | } 116 | }, 117 | None => "", 118 | }); 119 | if file_ext.is_empty() { 120 | return Err(Error::UploadFailed.into()); 121 | } 122 | let filename = format!("{}.{}", id, file_ext); 123 | // let mut file = tokio::fs::File::create(Path::new(&filename)).await?; 124 | let (mut file, _path_buf, relative_path) = io::get_save_file(id, &filename, file_ext, false).await?; 125 | let b = response.bytes().await?; 126 | tokio::io::copy_buf(&mut &b[..], &mut file).await?; 127 | // file.shutdown() 128 | Ok(relative_path) 129 | } 130 | 131 | pub async fn delete_post_images(post_id: u64) -> Result<()> { 132 | let (path, _) = io::get_save_path(post_id, "", "", false).await?; 133 | // let dir = path.parent().unwrap(); 134 | let dir = std::env::current_dir()?.join(path); 135 | println!("dir={:?}", dir); 136 | let mut files = tokio::fs::read_dir(dir).await?; 137 | let post_id = post_id.to_string(); 138 | while let Some(entry) = files.next_entry().await? { 139 | if entry.file_name().into_string().unwrap().starts_with(&post_id) { 140 | println!("Deleting {:?}", entry.file_name()); 141 | tokio::fs::remove_file(entry.path()).await?; 142 | } 143 | } 144 | Ok(()) 145 | } 146 | -------------------------------------------------------------------------------- /backend/src/service/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod asset; 2 | pub(crate) mod export; 3 | pub(crate) mod git; 4 | pub(crate) mod image; 5 | pub mod server; 6 | pub mod status; 7 | -------------------------------------------------------------------------------- /backend/src/service/status.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Arc, vec::Vec}; 2 | 3 | use lazy_static::lazy_static; 4 | use parking_lot::RwLock; 5 | use tokio::time::{sleep, Duration}; 6 | 7 | use blog_common::{dto::user::UserInfo, result::Error, util::time}; 8 | 9 | use crate::util::result::Result; 10 | 11 | const MAX_USER_IDLE_MILLIS: u64 = 1800000; 12 | const MAX_VERIFY_CODE_IDLE_MILLIS: u64 = 300000; 13 | 14 | type OnlineUsers = HashMap; 15 | type VerifyCodes = HashMap; 16 | 17 | lazy_static! { 18 | static ref ONLINE_USERS: Arc> = Arc::new(RwLock::new(HashMap::with_capacity(32))); 19 | static ref VERIFY_CODES: Arc> = Arc::new(RwLock::new(HashMap::with_capacity(128))); 20 | } 21 | 22 | struct OnlineUser { 23 | user: UserInfo, 24 | // #[serde(skip)] 25 | // https://serde.rs/field-attrs.html 26 | pub last_active_time: u64, 27 | } 28 | 29 | struct VerifyCode { 30 | code: Vec, 31 | // #[serde(skip)] 32 | // https://serde.rs/field-attrs.html 33 | pub last_active_time: u64, 34 | } 35 | 36 | pub async fn scanner() { 37 | let mut current_timestamp = 0u64; 38 | loop { 39 | // println!("Scanning online users and verify codes"); 40 | current_timestamp = time::unix_epoch_sec(); 41 | { 42 | let mut online_users = ONLINE_USERS.write(); 43 | let d = &mut *online_users; 44 | d.retain(|_, v| { 45 | if current_timestamp - v.last_active_time > MAX_USER_IDLE_MILLIS { 46 | println!("Remove user {}", dbg!(&v.user.id)); 47 | false 48 | } else { 49 | true 50 | } 51 | }); 52 | } 53 | { 54 | let mut verify_codes = VERIFY_CODES.write(); 55 | let d = &mut *verify_codes; 56 | d.retain(|k, v| { 57 | if current_timestamp - v.last_active_time > MAX_VERIFY_CODE_IDLE_MILLIS { 58 | println!("Remove verifyCode {}", dbg!(k)); 59 | false 60 | } else { 61 | true 62 | } 63 | }); 64 | } 65 | sleep(Duration::from_secs(10)).await; 66 | } 67 | } 68 | 69 | pub(crate) fn check_auth(token: Option) -> Result { 70 | if token.is_none() { 71 | return Err(Error::NotAuthed.into()); 72 | } 73 | let token = token.unwrap(); 74 | if token.len() != 32 { 75 | return Err(Error::NotAuthed.into()); 76 | } 77 | let mut r = ONLINE_USERS.write(); 78 | let d = &mut *r; 79 | if let Some(u) = d.get_mut(&token) { 80 | u.last_active_time = time::unix_epoch_sec(); 81 | return Ok(u.user.clone()); 82 | } 83 | Err(Error::NotAuthed.into()) 84 | } 85 | 86 | pub(crate) fn user_online(token: &str, user: UserInfo) { 87 | ONLINE_USERS.write().insert( 88 | String::from(token), 89 | OnlineUser { 90 | user: user.clone(), 91 | last_active_time: time::unix_epoch_sec(), 92 | }, 93 | ); 94 | } 95 | 96 | pub(crate) fn user_offline(token: &str) { 97 | ONLINE_USERS.write().remove(token); 98 | } 99 | 100 | pub fn get_verify_code(token: &str) -> Result> { 101 | if token.len() != 32 { 102 | return Err(Error::InvalidVerifyCode.into()); 103 | } 104 | { 105 | let r = VERIFY_CODES.read(); 106 | if let Some(v) = r.get(token) { 107 | return Ok(v.code.clone()); 108 | } 109 | } 110 | let numbers: Vec = crate::util::num::rand_numbers(0, 10, 4); 111 | VERIFY_CODES.write().insert( 112 | String::from(token), 113 | VerifyCode { 114 | code: numbers.clone(), 115 | last_active_time: time::unix_epoch_sec(), 116 | }, 117 | ); 118 | Ok(numbers) 119 | } 120 | 121 | // pub fn check_verify_code(token: &str, code: &str) -> bool { 122 | pub fn check_verify_code(token: Option, code: &str) -> Result { 123 | if token.is_none() { 124 | return Err(Error::InvalidSessionId.into()); 125 | } 126 | let token = token.unwrap(); 127 | if token.len() != 32 { 128 | return Err(Error::InvalidSessionId.into()); 129 | } 130 | let valid_code = { 131 | let r = VERIFY_CODES.read(); 132 | if let Some(v) = r.get(&token) { 133 | let mut s = String::with_capacity(8); 134 | for c in v.code.iter() { 135 | s.push_str(c.to_string().as_str()); 136 | } 137 | s.as_str() == code 138 | } else { 139 | false 140 | } 141 | }; 142 | if valid_code { 143 | VERIFY_CODES.write().remove(&token); 144 | } 145 | Ok(token) 146 | } 147 | -------------------------------------------------------------------------------- /backend/src/util/common.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use rand::{rngs::OsRng, RngCore}; 3 | use regex::Regex; 4 | use uuid::Uuid; 5 | 6 | pub fn simple_uuid_with_name(name: &[u8]) -> String { 7 | let uuid = Uuid::new_v5(&Uuid::NAMESPACE_URL, name); 8 | uuid.simple().to_string() 9 | } 10 | 11 | pub fn simple_uuid() -> String { 12 | let mut salt = [0u8; 16]; 13 | OsRng.fill_bytes(&mut salt); 14 | 15 | simple_uuid_with_name(&salt) 16 | } 17 | 18 | lazy_static! { 19 | pub static ref BLANKS: Regex = Regex::new(r"\s\s+").unwrap(); 20 | pub static ref EMAIL_REGEX: Regex = Regex::new(r"[^@ \t\r\n]+@[^@ \t\r\n]+\.[^@ \t\r\n]+").unwrap(); 21 | pub static ref HTML_TAG_REGEX: Regex = Regex::new(r"<[^>]+>|<[^>]>|]>").unwrap(); 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/util/crypt.rs: -------------------------------------------------------------------------------- 1 | use std::vec::Vec; 2 | 3 | // https://medium.com/analytics-vidhya/password-hashing-pbkdf2-scrypt-bcrypt-and-argon2-e25aaf41598e 4 | use argon2::Argon2; 5 | use base64; 6 | use rand::{rngs::OsRng, RngCore}; 7 | // use subtle::ConstantTimeEq; 8 | 9 | use crate::util::result::Result; 10 | 11 | // https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md#phc-string-format 12 | fn to_phc_string(salt: &[u8], encrypted_password: &[u8]) -> String { 13 | // $argon2i$v=19$m=512,t=4,p=2$eM+ZMyYkpDRGaI3xXmuNcQ$c5DeJg3eb5dskVt1mDdxfw 14 | format!( 15 | "$argon2id${}${}", 16 | base64::encode(salt), 17 | base64::encode(encrypted_password) 18 | ) 19 | } 20 | 21 | pub fn encrypt_password(password: &str) -> Result { 22 | let mut salt = [0u8; 64]; 23 | OsRng.fill_bytes(&mut salt); 24 | encrypt_password_salt(&salt, password.as_bytes()) 25 | } 26 | 27 | fn encrypt_password_salt(salt: &[u8], password: &[u8]) -> Result { 28 | let p = argon2::Params::new(10240, 2, 2, None)?; 29 | let a = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, p); 30 | let mut result = vec![0u8; 1024]; 31 | a.hash_password_into(password, &salt, &mut result)?; 32 | 33 | let p = to_phc_string(&salt, result.as_slice()); 34 | // println!("p = {}", &p); 35 | Ok(p) 36 | } 37 | 38 | pub fn verify_password(password: &str, encrypted_password: &str) -> Result { 39 | let d: Vec<_> = encrypted_password.split('$').collect(); 40 | if d.len() != 4 { 41 | return Ok(false); 42 | } 43 | let salt = base64::decode(*d.get(2).unwrap())?; 44 | let p = encrypt_password_salt(salt.as_slice(), password.as_bytes())?; 45 | Ok(p.eq(encrypted_password)) 46 | } 47 | -------------------------------------------------------------------------------- /backend/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod common; 2 | pub(crate) mod crypt; 3 | pub(crate) mod io; 4 | pub(crate) mod num; 5 | pub mod result; 6 | pub(crate) mod snowflake; 7 | pub(crate) mod val; 8 | -------------------------------------------------------------------------------- /backend/src/util/num.rs: -------------------------------------------------------------------------------- 1 | use std::{rc::Rc, vec::Vec}; 2 | 3 | use rand::{ 4 | distributions::uniform::{SampleRange, SampleUniform}, 5 | thread_rng, Rng, 6 | }; 7 | 8 | // const NUMERIC: [u8; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]; 9 | 10 | pub fn rand_numbers(min: T, max: T, amount: usize) -> Vec 11 | where 12 | T: SampleUniform + PartialOrd + Copy, 13 | { 14 | let mut d = Vec::::with_capacity(amount); 15 | let mut rng = thread_rng(); 16 | 17 | for _n in 0..amount { 18 | //let r = min..max; 19 | d.push(rng.gen_range(min..max)) 20 | } 21 | d 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/util/result.rs: -------------------------------------------------------------------------------- 1 | use blog_common::result::Error; 2 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 3 | 4 | pub type Result = core::result::Result; 5 | // pub type Result = core::result::Result; 6 | 7 | #[derive(Debug)] 8 | pub struct ErrorWrapper(pub(crate) Error); 9 | 10 | impl From for ErrorWrapper { 11 | fn from(e: Error) -> Self { 12 | ErrorWrapper(e) 13 | } 14 | } 15 | 16 | // 如果要在Yew前端展示,这里可以不用手动序列化,让Yew反序列化再展示出来就可以了 17 | // impl Serialize for Error { 18 | // fn serialize(&self, serializer: S) -> core::result::Result 19 | // where 20 | // S: Serializer, 21 | // { 22 | // format!("{}", self).serialize(serializer) 23 | // } 24 | // } 25 | 26 | // impl std::fmt::Display for Error { 27 | // fn fmt(&self, f: &mut Formatter<'_>) -> Result { 28 | // unimplemented!() 29 | // } 30 | // } 31 | 32 | // impl std::fmt::Display for ErrResponse { 33 | // fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 34 | // unimplemented!() 35 | // } 36 | // } 37 | 38 | // impl std::error::Error for ErrResponse {} 39 | 40 | impl warp::reject::Reject for ErrorWrapper {} 41 | 42 | impl From for ErrorWrapper { 43 | fn from(e: zip::result::ZipError) -> Self { 44 | eprintln!("{}", e); 45 | Error::ReadPostIdDataByTagFailed.into() 46 | } 47 | } 48 | 49 | impl From for ErrorWrapper { 50 | fn from(e: tera::Error) -> Self { 51 | eprintln!("{}", e); 52 | // todo 53 | Error::ReadPostIdDataByTagFailed.into() 54 | } 55 | } 56 | 57 | impl From for ErrorWrapper { 58 | fn from(e: std::io::Error) -> Self { 59 | eprintln!("{}", e); 60 | Error::ReadPostIdDataByTagFailed.into() 61 | } 62 | } 63 | 64 | impl From for ErrorWrapper { 65 | fn from(e: reqwest::Error) -> Self { 66 | eprintln!("{}", e); 67 | Error::ReadPostIdDataByTagFailed.into() 68 | } 69 | } 70 | 71 | impl From for ErrorWrapper { 72 | fn from(e: std::time::SystemTimeError) -> Self { 73 | eprintln!("{}", e); 74 | Error::ReadPostIdDataByTagFailed.into() 75 | } 76 | } 77 | 78 | // impl From for ErrorWrapper { 79 | // fn from(e: std::env::VarError) -> Self { 80 | // eprintln!("{}", e); 81 | // Error::EnvVarError.into() 82 | // } 83 | // } 84 | 85 | impl From for ErrorWrapper { 86 | fn from(e: std::net::AddrParseError) -> Self { 87 | eprintln!("{}", e); 88 | Error::ParseListeningAddressFailed.into() 89 | } 90 | } 91 | 92 | impl From for ErrorWrapper { 93 | fn from(e: serde_json::error::Error) -> Self { 94 | eprintln!("{}", e); 95 | ErrorWrapper(Error::SerdeError) 96 | } 97 | } 98 | 99 | impl From for ErrorWrapper { 100 | fn from(e: sled::Error) -> Self { 101 | eprintln!("{}", e); 102 | ErrorWrapper(Error::SledDbError) 103 | } 104 | } 105 | 106 | impl From for ErrorWrapper { 107 | fn from(e: sqlx::Error) -> Self { 108 | eprintln!("{}", dbg!(e)); 109 | ErrorWrapper(Error::SqliteDbError) 110 | } 111 | } 112 | 113 | // impl ErrResponse { 114 | // pub fn new(message: &str) -> Self { 115 | // ErrResponse { 116 | // message: String::from(message) 117 | // } 118 | // } 119 | // } 120 | 121 | impl From for ErrorWrapper { 122 | fn from(e: std::string::FromUtf8Error) -> Self { 123 | eprintln!("{:?}", e); 124 | ErrorWrapper(Error::BadRequest) 125 | } 126 | } 127 | 128 | impl From for ErrorWrapper { 129 | fn from(e: argon2::Error) -> Self { 130 | eprintln!("{:?}", e); 131 | ErrorWrapper(Error::BadRequest) 132 | } 133 | } 134 | 135 | impl From for ErrorWrapper { 136 | fn from(e: base64::DecodeError) -> Self { 137 | eprintln!("{:?}", e); 138 | ErrorWrapper(Error::BadRequest) 139 | } 140 | } 141 | 142 | // impl From for ErrorWrapper { 143 | // fn from(e: scrypt::errors::InvalidOutputLen) -> Self { 144 | // eprintln!("{:?}", e); 145 | // ErrorWrapper(Error::BadRequest) 146 | // } 147 | // } 148 | -------------------------------------------------------------------------------- /backend/src/util/snowflake.rs: -------------------------------------------------------------------------------- 1 | use parking_lot::Mutex; 2 | 3 | use blog_common::util::time; 4 | 5 | static LAST_TIMESTAMP: Mutex = Mutex::new(0); 6 | 7 | const START_TIME_MILLIS: u64 = 1643212800; 8 | 9 | pub(crate) fn gen_id() -> u64 { 10 | loop { 11 | let current_timestamp = time::unix_epoch_sec(); 12 | loop { 13 | let mut last_timestamp = LAST_TIMESTAMP.lock(); 14 | let mut sequence = 0u8; 15 | if *last_timestamp == current_timestamp { 16 | // u16::MAX; 17 | if sequence == u8::MAX { 18 | break; 19 | } else { 20 | sequence += 1; 21 | } 22 | } else { 23 | *last_timestamp = current_timestamp; 24 | } 25 | let id = (current_timestamp - START_TIME_MILLIS) << 8; 26 | if sequence == 0 { 27 | return id; 28 | } else { 29 | return id | sequence as u64; 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/util/val.rs: -------------------------------------------------------------------------------- 1 | // #[cfg(target_os = "windows")] 2 | // pub const IMAGE_ROOT_PATH: &str = r"E:\tt"; 3 | 4 | #[cfg(not(target_os = "windows"))] 5 | pub const IMAGE_ROOT_PATH: &str = "/home/songday/website/blog/upload/image"; 6 | 7 | // 这里由于 len() 是 const fn,所以可以被调用 8 | // pub const IMAGE_ROOT_PATH_LENGTH: usize = IMAGE_ROOT_PATH.len(); 9 | // pub const BLOG_PAGE_SIZE: u8 = 20u8; 10 | // pub const I64SIZE: usize = std::mem::size_of::(); 11 | pub(crate) const POST_DETAIL_RENDER_TEMPLATE: &'static str = "post_detail_render_template"; 12 | -------------------------------------------------------------------------------- /backend/upload/0/424410880.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/upload/0/424410880.jpg -------------------------------------------------------------------------------- /backend/upload/6/92189696.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/backend/upload/6/92189696.jpg -------------------------------------------------------------------------------- /common/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | bin/ 13 | pkg/ 14 | wasm-pack.log 15 | **/dist/ -------------------------------------------------------------------------------- /common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "blog-common" 3 | version = "0.5.6" 4 | authors = ["Songday"] 5 | edition = "2021" 6 | 7 | [lib] 8 | name = "blog_common" 9 | # crate-type = ["cdylib", "rlib"] 10 | 11 | [dependencies] 12 | # time = { version = "0.3", features = ["serde"] } 13 | #lazy_static = "1.4" 14 | serde = "1.0" 15 | serde_json = "1.0" 16 | thiserror = "1.0" 17 | #yew = {git = "https://github.com/yewstack/yew", branch = "master"} 18 | -------------------------------------------------------------------------------- /common/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Songday 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /common/README.md: -------------------------------------------------------------------------------- 1 | # blog-frontend 2 | Made from Rust WASM 3 | -------------------------------------------------------------------------------- /common/src/dto/git.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Deserialize, Serialize)] 4 | pub struct GitRepositoryInfo { 5 | pub name: String, 6 | pub email: String, 7 | pub remote_url: String, 8 | pub repository_name: String, 9 | pub branch_name: Option, 10 | pub last_export_second: i64, 11 | } 12 | 13 | #[derive(Deserialize, Serialize)] 14 | pub struct GitPushInfo { 15 | pub subdirectory: String, 16 | pub render_html: bool, 17 | pub repo_credential: String, 18 | } 19 | -------------------------------------------------------------------------------- /common/src/dto/management.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | // use super::user::UserInfo; 4 | 5 | #[derive(Clone, Default, Debug, Deserialize, Serialize)] 6 | pub struct AdminUser { 7 | pub password: String, 8 | pub captcha: String, 9 | } 10 | 11 | #[derive(Clone, Default, Debug, Deserialize, Serialize)] 12 | pub struct Setting { 13 | pub item: String, 14 | pub content: String, 15 | } 16 | 17 | // #[derive(Debug, Deserialize, Serialize)] 18 | // pub struct SiteData { 19 | // pub settings: Setting, 20 | // pub user_info: Option, 21 | // } 22 | -------------------------------------------------------------------------------- /common/src/dto/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::result::ErrorResponse; 6 | 7 | pub mod git; 8 | pub mod management; 9 | pub mod post; 10 | pub mod tag; 11 | pub mod user; 12 | 13 | //https://stackoverflow.com/questions/49953960/cannot-resolve-t-serdedeserializea-when-deriving-deserialize-on-a-generic 14 | //https://stackoverflow.com/questions/54761790/how-to-deserialize-with-for-a-container-using-serde-in-rust 15 | #[derive(Debug, Deserialize, Serialize)] 16 | pub struct Response { 17 | pub status: u16, 18 | pub error: Option, 19 | #[serde(bound(deserialize = "D: Deserialize<'de>", serialize = "D: Serialize"))] 20 | pub data: Option, 21 | } 22 | 23 | pub enum FormDataItem { 24 | TEXT(TextFieldInfo), 25 | FILE(UploadFileInfo), 26 | } 27 | 28 | pub struct TextFieldInfo { 29 | pub name: String, 30 | pub value: String, 31 | } 32 | 33 | pub struct UploadFileInfo { 34 | pub name: String, 35 | pub original_filename: String, 36 | pub relative_path: String, 37 | pub filepath: PathBuf, 38 | pub extension: String, 39 | pub filesize: usize, 40 | } 41 | 42 | impl UploadFileInfo { 43 | pub fn new() -> Self { 44 | UploadFileInfo { 45 | name: String::with_capacity(64), 46 | original_filename: String::with_capacity(128), 47 | relative_path: String::with_capacity(64), 48 | filepath: PathBuf::with_capacity(64), 49 | extension: String::with_capacity(16), 50 | filesize: 0, 51 | } 52 | } 53 | } 54 | 55 | #[derive(Debug, Deserialize, Serialize)] 56 | pub struct PaginationData { 57 | pub total: u64, 58 | #[serde(bound(deserialize = "D: Deserialize<'de>", serialize = "D: Serialize"))] 59 | pub data: D, 60 | } 61 | -------------------------------------------------------------------------------- /common/src/dto/post.rs: -------------------------------------------------------------------------------- 1 | // use std::{ 2 | // fmt::{self, Display}, 3 | // path::Path, 4 | // str::FromStr, 5 | // }; 6 | 7 | use serde::{Deserialize, Serialize}; 8 | 9 | // use crate::result::Error; 10 | 11 | #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] 12 | pub struct PostData { 13 | pub id: i64, 14 | pub title: String, 15 | pub title_image: String, 16 | pub content: String, 17 | pub tags: Option>, 18 | } 19 | 20 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 21 | pub struct PostDetail { 22 | pub id: i64, 23 | pub title: String, 24 | pub title_image: String, 25 | pub content: String, 26 | pub tags: Option>, 27 | pub created_at: u64, 28 | pub updated_at: Option, 29 | pub editable: bool, 30 | } 31 | 32 | impl PostDetail { 33 | pub fn default() -> Self { 34 | PostDetail { 35 | id: 0, 36 | title: String::new(), 37 | title_image: String::new(), 38 | content: String::new(), 39 | tags: None, 40 | created_at: 0, 41 | updated_at: None, 42 | editable: false, 43 | } 44 | } 45 | } 46 | 47 | // #[allow(deadcode)] 48 | // #[derive(Clone)] 49 | // pub struct OptionBlogDetail(pub Option); 50 | // 51 | // impl Display for OptionBlogDetail { 52 | // fn fmt(&self, f: &mut serde_json::ser::Formatter<'_>) -> fmt::Result { 53 | // if self.0.is_none() { 54 | // f.write_str("") 55 | // } else { 56 | // match serde_json::to_string(self.0.as_ref().unwrap()) { 57 | // Ok(s) => write!(f, "{}", &s), 58 | // Err(e) => f.write_str(""), 59 | // } 60 | // } 61 | // } 62 | // } 63 | // 64 | // impl FromStr for OptionBlogDetail { 65 | // type Err = Error; 66 | // 67 | // fn from_str(s: &str) -> Result { 68 | // if s.is_empty() { 69 | // return Ok(OptionBlogDetail(None)); 70 | // } 71 | // unimplemented!() 72 | // } 73 | // } 74 | 75 | #[derive(Debug, Deserialize, Serialize)] 76 | pub struct UploadImage { 77 | pub relative_path: String, 78 | pub original_filename: String, 79 | } 80 | 81 | impl UploadImage { 82 | pub fn new(path: String, original_filename: String) -> Self { 83 | UploadImage { 84 | relative_path: path, 85 | original_filename, 86 | } 87 | } 88 | } 89 | 90 | #[derive(Debug, Deserialize)] 91 | pub struct Tag { 92 | pub name: String, 93 | } 94 | -------------------------------------------------------------------------------- /common/src/dto/tag.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Debug, Deserialize, Serialize)] 4 | pub struct TagUsageAmount { 5 | pub id: i64, 6 | pub name: String, 7 | pub amount: u32, 8 | } 9 | -------------------------------------------------------------------------------- /common/src/dto/user.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Debug, Deserialize, Serialize)] 4 | pub struct UserInfo { 5 | pub id: i64, 6 | } 7 | 8 | // impl yew::html::ImplicitClone for UserInfo {} 9 | 10 | #[derive(Clone, Debug, Deserialize, Serialize)] 11 | pub struct UserInfoWrapper { 12 | pub user_info: UserInfo, 13 | // 由于 cookie 设置了 HttpOnly,所以 JS 读不了 cookie。就通过这个字段将 cookie 传递给前端 14 | pub access_token: String, 15 | } 16 | 17 | // #[derive(Clone, Default, Debug, Deserialize, Serialize)] 18 | // pub struct RegisterParams { 19 | // pub email: String, 20 | // pub password1: String, 21 | // pub password2: String, 22 | // pub captcha: String, 23 | // } 24 | // 25 | // #[derive(Clone, Default, Debug, Deserialize, Serialize)] 26 | // pub struct LoginParams { 27 | // pub email: String, 28 | // pub password: String, 29 | // pub captcha: String, 30 | // } 31 | 32 | #[derive(Clone, Default, Debug, Deserialize, Serialize)] 33 | pub struct UserParams { 34 | pub email: String, 35 | pub password1: String, 36 | pub password2: String, 37 | pub captcha: String, 38 | } 39 | -------------------------------------------------------------------------------- /common/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod dto; 2 | pub mod result; 3 | pub mod util; 4 | pub mod val; 5 | -------------------------------------------------------------------------------- /common/src/result.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use thiserror::Error as ThisError; 3 | 4 | pub type Result = std::result::Result; 5 | // pub type AsyncResult = std::result::Result>; 6 | 7 | #[derive(Clone, ThisError, Debug, Deserialize, Serialize)] 8 | pub enum Error { 9 | // system 10 | // EnvVarError, 11 | #[error("Parsing listening address failed")] 12 | ParseListeningAddressFailed, 13 | #[error("Data save failed")] 14 | SledSaveFailed, 15 | #[error("Database(1) error")] 16 | SledDbError, 17 | #[error("Database(2) error")] 18 | SqliteDbError, 19 | #[error("Deserialize / Serialize failed")] 20 | SerdeError, 21 | #[error("Page not found")] 22 | NotFound, 23 | #[error("请求参数不合法,请检查输入是否正确")] 24 | BadRequest, 25 | #[error("Method not allowed")] 26 | MethodNotAllowed, 27 | #[error("Internal server error")] 28 | InternalServerError, 29 | 30 | // business 31 | #[error("无效的 Session ID")] 32 | InvalidSessionId, 33 | #[error("无效的验证码")] 34 | InvalidVerifyCode, 35 | #[error("登录信息失效,请重新登录")] 36 | NotAuthed, 37 | #[error("登录失败,请重试。")] 38 | LoginFailed, 39 | #[error("Registration failed")] 40 | RegisterFailed, 41 | #[error("Already registered")] 42 | AlreadyRegistered, 43 | #[error("Saving post failed")] 44 | SavePostFailed, 45 | #[error("Can not find post you requested")] 46 | CannotFoundPost, 47 | #[error("Can not find tag you requested")] 48 | CannotFoundTag, 49 | #[error("Upload failed")] 50 | UploadFailed, 51 | #[error("Upload file not found")] 52 | FileNotFound, 53 | #[error("Unknown file type")] 54 | UnknownFileType, 55 | #[error("Unsupported file type {0}")] 56 | UnsupportedFileType(String), 57 | #[error("Creating thumbnail failed")] 58 | CreateThumbnailFailed, 59 | #[error("Reading post id data by tag failed")] 60 | ReadPostIdDataByTagFailed, 61 | #[error("Saving post id data by tag failed")] 62 | SavePostIdDataByTagFailed, 63 | #[error("Tag not found")] 64 | TagNotFound, 65 | 66 | #[error("{0}")] 67 | BusinessException(String), 68 | } 69 | 70 | #[derive(Debug, Deserialize, Serialize)] 71 | pub struct ErrorResponse { 72 | pub code: Error, 73 | pub detail: String, 74 | } 75 | 76 | // 如果要在Yew前端展示,这里可以不用手动序列化,让Yew反序列化再展示出来就可以了 77 | // impl Serialize for Error { 78 | // fn serialize(&self, serializer: S) -> core::result::Result 79 | // where 80 | // S: Serializer, 81 | // { 82 | // format!("{}", self).serialize(serializer) 83 | // } 84 | // } 85 | 86 | // impl std::fmt::Display for Error { 87 | // fn fmt(&self, f: &mut Formatter<'_>) -> Result { 88 | // unimplemented!() 89 | // } 90 | // } 91 | 92 | impl From for Error { 93 | fn from(e: std::io::Error) -> Self { 94 | Error::UnsupportedFileType(format!("{:?}", e)) 95 | } 96 | } 97 | 98 | // impl std::fmt::Display for ErrResponse { 99 | // fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 100 | // unimplemented!() 101 | // } 102 | // } 103 | -------------------------------------------------------------------------------- /common/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod time; 2 | -------------------------------------------------------------------------------- /common/src/util/time.rs: -------------------------------------------------------------------------------- 1 | use std::time::{SystemTime, UNIX_EPOCH}; 2 | 3 | // use chrono::format::strftime::StrftimeItems; 4 | // use lazy_static::lazy_static; 5 | 6 | // lazy_static! { 7 | // static ref DATETIME_FORMAT: StrftimeItems<'static> = StrftimeItems::new("%Y-%m-%d %H:%M:%S"); 8 | // } 9 | 10 | pub fn unix_epoch_sec() -> u64 { 11 | let now = SystemTime::now(); 12 | let d = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); 13 | d.as_secs() 14 | } 15 | -------------------------------------------------------------------------------- /common/src/val.rs: -------------------------------------------------------------------------------- 1 | pub const MAX_BLOG_UPLOAD_IMAGE_SIZE: usize = 5242880; //5mb 2 | pub const SESSION_ID_HEADER_NAME: &'static str = "X-SONGDAY-SESSION-ID"; 3 | pub const USER_AUTH_MARK_HEADER: &'static str = "X-SONGDAY-USER-AUTHED"; 4 | pub const POSTS_PAGE_SIZE: u8 = 8; 5 | pub const DEFAULT_POST_TITLE: &'static str = "未命名/Untitled"; 6 | pub const TAG_SIZES: [&'static str; 3] = [" is-normal", " is-medium", " is-large"]; 7 | pub const TAG_COLORS: [&'static str; 8] = [ 8 | " is-white", 9 | " is-light", 10 | " is-primary", 11 | " is-link", 12 | " is-info", 13 | " is-success", 14 | " is-warning", 15 | " is-danger", 16 | ]; 17 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | **/asset -------------------------------------------------------------------------------- /frontend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "blog-frontend" 3 | version = "0.5.6" 4 | authors = ["Songday"] 5 | edition = "2021" 6 | 7 | [lib] 8 | name = "blog_frontend" 9 | crate-type = ["cdylib", "rlib"] 10 | 11 | [features] 12 | default = ["console_error_panic_hook"] 13 | 14 | [dependencies] 15 | wasm-bindgen = "0.2" 16 | 17 | # The `console_error_panic_hook` crate provides better debugging of panics by 18 | # logging them with `console.error`. This is great for development, but requires 19 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 20 | # code size when deploying. 21 | console_error_panic_hook = { version = "0.1", optional = true } 22 | 23 | blog-common = { path = "../common" } 24 | #fastrand = "1.7" 25 | fluent = "0.16" 26 | getrandom = { version = "0.2", features = ["js"] } 27 | gloo = "0.8" 28 | gloo-file = "0.2" 29 | gloo-utils = "0.1" 30 | js-sys = "0.3" 31 | thiserror = "1.0" 32 | #parking_lot = { version = "0.12", features = ["wasm-bindgen"]} 33 | #lazy_static = "1.4 34 | rand = { version = "0.8", features = ["small_rng"] } 35 | reqwasm = "0.5" 36 | serde = "1.0" 37 | serde_json = "1.0" 38 | time = { version = "0.3", features = ["formatting", "parsing"] } 39 | #unic-langid = "0.9" 40 | urlencoding = "2" 41 | wasm-bindgen-futures = "0.4" 42 | weblog = "0.3.0" 43 | web-sys = { version = "0.3", features = ["Window", "Document", "HtmlDocument"] } 44 | wee_alloc = { version = "0.4" } 45 | yew = { version = "0.20", features = ["csr"] } 46 | yew-router = "0.17" 47 | #yew = {git = "https://github.com/yewstack/yew", branch = "master", features = ["csr"]} 48 | #yew-router = { git = "https://github.com/yewstack/yew", branch="master" } 49 | yew-agent = { git = "https://github.com/yewstack/yew", branch="master" } 50 | 51 | [dev-dependencies] 52 | wasm-bindgen-test = "0.3" 53 | 54 | -------------------------------------------------------------------------------- /frontend/asset/codemirror.min.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Minified by jsDelivr using clean-css v4.2.3. 3 | * Original file: /npm/codemirror@5.63.3/lib/codemirror.css 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | .CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor-mark{background-color:rgba(20,255,20,.5);-webkit-animation:blink 1.06s steps(1) infinite;-moz-animation:blink 1.06s steps(1) infinite;animation:blink 1.06s steps(1) infinite}.cm-animate-fat-cursor{width:auto;-webkit-animation:blink 1.06s steps(1) infinite;-moz-animation:blink 1.06s steps(1) infinite;animation:blink 1.06s steps(1) infinite;background-color:#7e7}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors{visibility:visible}.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:rgba(255,255,0,.4)}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:''}span.CodeMirror-selectedtext{background:0 0} 8 | /*# sourceMappingURL=/sm/e45512c68000c63ec46b0713f75ca92b15b5fe1aa2aa4f7e899ac2d18554a571.map */ -------------------------------------------------------------------------------- /frontend/asset/common.js: -------------------------------------------------------------------------------- 1 | function fetch_get(t, url, callback) { 2 | const clazzName = t.className; 3 | t.disabled = true; 4 | t.className = clazzName + ' is-loading'; 5 | fetch(url).then(response => response.json()) 6 | .then(data => { 7 | t.className = clazzName; 8 | t.disabled = false; 9 | console.log(data); 10 | if (data.status === 0) { 11 | if (url.indexOf('push') > -1) { 12 | let d = document.createElement('div'); 13 | d.innerHTML = 'Push successfully'; 14 | t.parentNode.appendChild(d); 15 | } else { 16 | if (typeof(callback) === 'function') 17 | callback(data); 18 | else 19 | location.href = callback; 20 | } 21 | } else { 22 | showErr(data.error.detail); 23 | } 24 | }) 25 | .catch(err => { 26 | console.log(err); 27 | showErr(err); 28 | }); 29 | } 30 | 31 | function fetch_post(t, url, data, callback) { 32 | const clazzName = t.className; 33 | t.disabled = true; 34 | t.className = clazzName + ' is-loading'; 35 | let contentType, body 36 | if (typeof(data.size) === 'undefined') { 37 | contentType = 'application/json'; 38 | body = JSON.stringify(data); 39 | } else { 40 | contentType = 'application/x-www-form-urlencoded'; 41 | let formBody = []; 42 | data.forEach(function (value, key, map) { 43 | formBody.push(key + "=" + encodeURIComponent(value)); 44 | }); 45 | body = formBody.join("&"); 46 | } 47 | const options = { 48 | method: 'POST', 49 | body: body, 50 | headers: { 51 | 'Content-Type': contentType + ';charset=UTF-8' 52 | } 53 | }; 54 | fetch(url, options).then(response => response.json()) 55 | .then(data => { 56 | t.className = clazzName; 57 | t.disabled = false; 58 | console.log(data); 59 | if (data.status === 0) { 60 | if (typeof(callback) === 'function') 61 | callback(data); 62 | else 63 | location.href = callback; 64 | } else { 65 | showErr(data.error.detail); 66 | } 67 | }) 68 | .catch(err => { 69 | console.log(err); 70 | }); 71 | } 72 | 73 | function showErr(err) { 74 | document.getElementById('errorMessage').innerHTML = err; 75 | document.getElementById('notification').style.display = 'block'; 76 | } 77 | 78 | document.addEventListener('DOMContentLoaded', () => { 79 | (document.querySelectorAll('.notification .delete') || []).forEach(($delete) => { 80 | const $notification = $delete.parentNode; 81 | 82 | $delete.addEventListener('click', () => { 83 | $notification.style.display = 'none'; 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /frontend/asset/editor.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 26 | 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /frontend/asset/editor.js: -------------------------------------------------------------------------------- 1 | export function getContent() { 2 | const w = document.getElementById("editor").contentWindow; 3 | // var iframeDocument = document.getElementById("iframe").contentDocument; 4 | // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/postMessage 5 | // w.editor.setMarkdown(c, false); 6 | return w.getContent(); 7 | } 8 | 9 | let allTagsBox = null; 10 | 11 | export function inputTag(event) { 12 | if (event.keyCode !== 13) 13 | return; 14 | const source = event.target; 15 | addTag(source.value); 16 | source.value = ''; 17 | source.focus(); 18 | } 19 | 20 | export function showOriginTags(tags) { 21 | allTagsBox = document.getElementById('tags'); 22 | document.getElementById('tagsContainer').style.display = 'block'; 23 | for (let i = 0; i < tags.length; i++) 24 | addTag(tags[i]); 25 | } 26 | 27 | function addTag(val) { 28 | if (!val) 29 | return; 30 | const tag = document.createElement('span'); 31 | tag.className = "tag is-primary is-medium"; 32 | tag.innerHTML = val; 33 | 34 | const a = document.createElement('button'); 35 | a.className = "delete is-small"; 36 | a.addEventListener('click', function () { 37 | allTagsBox.removeChild(tag); 38 | }) 39 | tag.appendChild(a); 40 | allTagsBox.appendChild(tag); 41 | // allTagsBox.insertBefore(tag, tagInput); 42 | } 43 | 44 | export function getAddedTags() { 45 | const tags = []; 46 | for (let i = 0; i < allTagsBox.childNodes.length; i++) { 47 | if (allTagsBox.childNodes[i].tagName === 'SPAN') 48 | tags.push(allTagsBox.childNodes[i].firstChild.nodeValue); 49 | } 50 | return tags; 51 | } 52 | 53 | export function randomTitleImage(event, post_id, callback) { 54 | let source = event.target || event.srcElement; 55 | while (source.tagName !== 'BUTTON' && source.parentNode) 56 | source = source.parentNode; 57 | source.disabled = true; 58 | const content = source.innerHtml; 59 | source.innerHtml = ''; 60 | const classes = source.className; 61 | source.className += ' is-loading'; 62 | fetch('/tool/random-title-image/' + post_id) 63 | .then(response => response.json()) 64 | .then(data => { 65 | console.log(data); 66 | if (data.status === 0) { 67 | const image = "/"+data.data; 68 | document.getElementById('title-image').setAttribute("src", image+"?_rnd="+Math.random()); 69 | callback(image); 70 | } 71 | source.innerHtml = content; 72 | source.className = classes; 73 | source.disabled = false; 74 | }) 75 | .catch(err => { 76 | console.log(err); 77 | source.innerHtml = content; 78 | source.className = classes; 79 | source.disabled = false; 80 | }); 81 | } 82 | 83 | export const uploadTitleImage = (event, postId, files, callback) => { 84 | const file = files[0]; 85 | // check file type 86 | if (!['image/jpeg', 'image/png'].includes(file.type)) { 87 | // document.getElementById('uploaded_image').innerHTML = '
Only .jpg and .png image are allowed
'; 88 | // document.getElementsByName('sample_image')[0].value = ''; 89 | return; 90 | } 91 | // check file size 92 | if (file.size > 2 * 1024 * 1024) { 93 | // document.getElementById('uploaded_image').innerHTML = '
File must be less than 2 MB
'; 94 | // document.getElementsByName('sample_image')[0].value = ''; 95 | return; 96 | } 97 | const form_data = new FormData(); 98 | form_data.append('file', file); 99 | form_data.append('title-image-file-name', file.name); 100 | let source = event.target || event.srcElement; 101 | while (source.tagName !== 'BUTTON' && source.parentNode) 102 | source = source.parentNode; 103 | console.log(source); 104 | source.disabled = true; 105 | const content = source.innerHtml; 106 | source.innerHtml = ''; 107 | const classes = source.className; 108 | source.className += ' is-loading'; 109 | fetch("/image/upload-title-image/" + postId, { 110 | method:"POST", 111 | body : form_data 112 | }).then(response => response.json()).then(data => { 113 | // document.getElementById('uploaded_image').innerHTML = '
Image Uploaded Successfully
'; 114 | // document.getElementsByName('sample_image')[0].value = ''; 115 | console.log(data); 116 | if (data.status === 0) { 117 | const image = "/"+data.data.relative_path; 118 | document.getElementById('title-image').setAttribute("src", image+"?_rnd="+Math.random()); 119 | callback(image); 120 | } 121 | source.innerHtml = content; 122 | source.className = classes; 123 | source.disabled = false; 124 | }) 125 | .catch(err => { 126 | console.log(err); 127 | source.innerHtml = content; 128 | source.className = classes; 129 | source.disabled = false; 130 | }); 131 | 132 | } -------------------------------------------------------------------------------- /frontend/asset/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/asset/favicon.ico -------------------------------------------------------------------------------- /frontend/asset/index.scss: -------------------------------------------------------------------------------- 1 | .hero { 2 | &.has-background { 3 | position: relative; 4 | overflow: hidden; 5 | } 6 | 7 | &-background { 8 | position: absolute; 9 | object-fit: cover; 10 | object-position: bottom; 11 | width: 100%; 12 | height: 100%; 13 | 14 | &.is-transparent { 15 | opacity: 0.5; 16 | } 17 | } 18 | } 19 | 20 | .burger { 21 | background-color: transparent; 22 | border: none; 23 | } 24 | 25 | .navbar-brand { 26 | align-items: center; 27 | } 28 | -------------------------------------------------------------------------------- /frontend/asset/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/asset/logo.png -------------------------------------------------------------------------------- /frontend/asset/regular.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.0.0-beta2 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(/asset/webfonts/fa-regular-400.woff2) format("woff2"),url(/asset/webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-family:"Font Awesome 6 Free";font-weight:400} -------------------------------------------------------------------------------- /frontend/asset/show.js: -------------------------------------------------------------------------------- 1 | export function userLanguage() { 2 | return navigator.language; 3 | } 4 | export function showNotificationBox() { 5 | document.getElementById('notification').style.display = 'block'; 6 | } 7 | export function hideNotificationBox(event) { 8 | let source = event.target || event.srcElement; 9 | while (source.id !== 'notification' && source.parentNode) 10 | source = source.parentNode; 11 | source.style.display = 'none'; 12 | } -------------------------------------------------------------------------------- /frontend/asset/solid.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.0.0-beta2 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(/asset/webfonts/fa-solid-900.woff2) format("woff2"),url(/asset/webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-family:"Font Awesome 6 Free";font-weight:900} -------------------------------------------------------------------------------- /frontend/asset/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/asset/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /frontend/asset/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/asset/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /frontend/asset/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/asset/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /frontend/asset/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/asset/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /frontend/asset/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/asset/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /frontend/asset/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/asset/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /frontend/dist/codemirror.min.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Minified by jsDelivr using clean-css v4.2.3. 3 | * Original file: /npm/codemirror@5.63.3/lib/codemirror.css 4 | * 5 | * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files 6 | */ 7 | .CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor-mark{background-color:rgba(20,255,20,.5);-webkit-animation:blink 1.06s steps(1) infinite;-moz-animation:blink 1.06s steps(1) infinite;animation:blink 1.06s steps(1) infinite}.cm-animate-fat-cursor{width:auto;-webkit-animation:blink 1.06s steps(1) infinite;-moz-animation:blink 1.06s steps(1) infinite;animation:blink 1.06s steps(1) infinite;background-color:#7e7}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors{visibility:visible}.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:rgba(255,255,0,.4)}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:''}span.CodeMirror-selectedtext{background:0 0} 8 | /*# sourceMappingURL=/sm/e45512c68000c63ec46b0713f75ca92b15b5fe1aa2aa4f7e899ac2d18554a571.map */ -------------------------------------------------------------------------------- /frontend/dist/common.js: -------------------------------------------------------------------------------- 1 | function fetch_get(t, url, callback) { 2 | const clazzName = t.className; 3 | t.disabled = true; 4 | t.className = clazzName + ' is-loading'; 5 | fetch(url).then(response => response.json()) 6 | .then(data => { 7 | t.className = clazzName; 8 | t.disabled = false; 9 | console.log(data); 10 | if (data.status === 0) { 11 | if (url.indexOf('push') > -1) { 12 | let d = document.createElement('div'); 13 | d.innerHTML = 'Push successfully'; 14 | t.parentNode.appendChild(d); 15 | } else { 16 | if (typeof(callback) === 'function') 17 | callback(data); 18 | else 19 | location.href = callback; 20 | } 21 | } else { 22 | showErr(data.error.detail); 23 | } 24 | }) 25 | .catch(err => { 26 | console.log(err); 27 | showErr(err); 28 | }); 29 | } 30 | 31 | function fetch_post(t, url, data, callback) { 32 | const clazzName = t.className; 33 | t.disabled = true; 34 | t.className = clazzName + ' is-loading'; 35 | let contentType, body 36 | if (typeof(data.size) === 'undefined') { 37 | contentType = 'application/json'; 38 | body = JSON.stringify(data); 39 | } else { 40 | contentType = 'application/x-www-form-urlencoded'; 41 | let formBody = []; 42 | data.forEach(function (value, key, map) { 43 | formBody.push(key + "=" + encodeURIComponent(value)); 44 | }); 45 | body = formBody.join("&"); 46 | } 47 | const options = { 48 | method: 'POST', 49 | body: body, 50 | headers: { 51 | 'Content-Type': contentType + ';charset=UTF-8' 52 | } 53 | }; 54 | fetch(url, options).then(response => response.json()) 55 | .then(data => { 56 | t.className = clazzName; 57 | t.disabled = false; 58 | console.log(data); 59 | if (data.status === 0) { 60 | if (typeof(callback) === 'function') 61 | callback(data); 62 | else 63 | location.href = callback; 64 | } else { 65 | showErr(data.error.detail); 66 | } 67 | }) 68 | .catch(err => { 69 | console.log(err); 70 | }); 71 | } 72 | 73 | function showErr(err) { 74 | document.getElementById('errorMessage').innerHTML = err; 75 | document.getElementById('notification').style.display = 'block'; 76 | } 77 | 78 | document.addEventListener('DOMContentLoaded', () => { 79 | (document.querySelectorAll('.notification .delete') || []).forEach(($delete) => { 80 | const $notification = $delete.parentNode; 81 | 82 | $delete.addEventListener('click', () => { 83 | $notification.style.display = 'none'; 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /frontend/dist/editor.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 26 | 27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /frontend/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/dist/favicon.ico -------------------------------------------------------------------------------- /frontend/dist/index-ec4ef66164c33b9c.css: -------------------------------------------------------------------------------- 1 | .hero.has-background { 2 | position: relative; 3 | overflow: hidden; 4 | } 5 | .hero-background { 6 | position: absolute; 7 | object-fit: cover; 8 | object-position: bottom; 9 | width: 100%; 10 | height: 100%; 11 | } 12 | .hero-background.is-transparent { 13 | opacity: 0.5; 14 | } 15 | 16 | .burger { 17 | background-color: transparent; 18 | border: none; 19 | } 20 | 21 | .navbar-brand { 22 | align-items: center; 23 | } 24 | -------------------------------------------------------------------------------- /frontend/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/dist/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/dist/logo.png -------------------------------------------------------------------------------- /frontend/dist/regular.min-a0c258fb7c5f655d.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.0.0-beta2 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(/asset/webfonts/fa-regular-400.woff2) format("woff2"),url(/asset/webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-family:"Font Awesome 6 Free";font-weight:400} -------------------------------------------------------------------------------- /frontend/dist/snippets/blog-frontend-a0e7f15f5414ab99/asset/editor.js: -------------------------------------------------------------------------------- 1 | export function getContent() { 2 | const w = document.getElementById("editor").contentWindow; 3 | // var iframeDocument = document.getElementById("iframe").contentDocument; 4 | // https://developer.mozilla.org/zh-CN/docs/Web/API/Window/postMessage 5 | // w.editor.setMarkdown(c, false); 6 | return w.getContent(); 7 | } 8 | 9 | let allTagsBox = null; 10 | 11 | export function inputTag(event) { 12 | if (event.keyCode !== 13) 13 | return; 14 | const source = event.target; 15 | addTag(source.value); 16 | source.value = ''; 17 | source.focus(); 18 | } 19 | 20 | export function showOriginTags(tags) { 21 | allTagsBox = document.getElementById('tags'); 22 | document.getElementById('tagsContainer').style.display = 'block'; 23 | for (let i = 0; i < tags.length; i++) 24 | addTag(tags[i]); 25 | } 26 | 27 | function addTag(val) { 28 | if (!val) 29 | return; 30 | const tag = document.createElement('span'); 31 | tag.className = "tag is-primary is-medium"; 32 | tag.innerHTML = val; 33 | 34 | const a = document.createElement('button'); 35 | a.className = "delete is-small"; 36 | a.addEventListener('click', function () { 37 | allTagsBox.removeChild(tag); 38 | }) 39 | tag.appendChild(a); 40 | allTagsBox.appendChild(tag); 41 | // allTagsBox.insertBefore(tag, tagInput); 42 | } 43 | 44 | export function getAddedTags() { 45 | const tags = []; 46 | for (let i = 0; i < allTagsBox.childNodes.length; i++) { 47 | if (allTagsBox.childNodes[i].tagName === 'SPAN') 48 | tags.push(allTagsBox.childNodes[i].firstChild.nodeValue); 49 | } 50 | return tags; 51 | } 52 | 53 | export function randomTitleImage(event, post_id, callback) { 54 | let source = event.target || event.srcElement; 55 | while (source.tagName !== 'BUTTON' && source.parentNode) 56 | source = source.parentNode; 57 | source.disabled = true; 58 | const content = source.innerHtml; 59 | source.innerHtml = ''; 60 | const classes = source.className; 61 | source.className += ' is-loading'; 62 | fetch('/tool/random-title-image/' + post_id) 63 | .then(response => response.json()) 64 | .then(data => { 65 | console.log(data); 66 | if (data.status === 0) { 67 | const image = "/"+data.data; 68 | document.getElementById('title-image').setAttribute("src", image+"?_rnd="+Math.random()); 69 | callback(image); 70 | } 71 | source.innerHtml = content; 72 | source.className = classes; 73 | source.disabled = false; 74 | }) 75 | .catch(err => { 76 | console.log(err); 77 | source.innerHtml = content; 78 | source.className = classes; 79 | source.disabled = false; 80 | }); 81 | } 82 | 83 | export const uploadTitleImage = (event, postId, files, callback) => { 84 | const file = files[0]; 85 | // check file type 86 | if (!['image/jpeg', 'image/png'].includes(file.type)) { 87 | // document.getElementById('uploaded_image').innerHTML = '
Only .jpg and .png image are allowed
'; 88 | // document.getElementsByName('sample_image')[0].value = ''; 89 | return; 90 | } 91 | // check file size 92 | if (file.size > 2 * 1024 * 1024) { 93 | // document.getElementById('uploaded_image').innerHTML = '
File must be less than 2 MB
'; 94 | // document.getElementsByName('sample_image')[0].value = ''; 95 | return; 96 | } 97 | const form_data = new FormData(); 98 | form_data.append('file', file); 99 | form_data.append('title-image-file-name', file.name); 100 | let source = event.target || event.srcElement; 101 | while (source.tagName !== 'BUTTON' && source.parentNode) 102 | source = source.parentNode; 103 | console.log(source); 104 | source.disabled = true; 105 | const content = source.innerHtml; 106 | source.innerHtml = ''; 107 | const classes = source.className; 108 | source.className += ' is-loading'; 109 | fetch("/image/upload-title-image/" + postId, { 110 | method:"POST", 111 | body : form_data 112 | }).then(response => response.json()).then(data => { 113 | // document.getElementById('uploaded_image').innerHTML = '
Image Uploaded Successfully
'; 114 | // document.getElementsByName('sample_image')[0].value = ''; 115 | console.log(data); 116 | if (data.status === 0) { 117 | const image = "/"+data.data.relative_path; 118 | document.getElementById('title-image').setAttribute("src", image+"?_rnd="+Math.random()); 119 | callback(image); 120 | } 121 | source.innerHtml = content; 122 | source.className = classes; 123 | source.disabled = false; 124 | }) 125 | .catch(err => { 126 | console.log(err); 127 | source.innerHtml = content; 128 | source.className = classes; 129 | source.disabled = false; 130 | }); 131 | 132 | } -------------------------------------------------------------------------------- /frontend/dist/snippets/blog-frontend-a0e7f15f5414ab99/asset/show.js: -------------------------------------------------------------------------------- 1 | export function userLanguage() { 2 | return navigator.language; 3 | } 4 | export function showNotificationBox() { 5 | document.getElementById('notification').style.display = 'block'; 6 | } 7 | export function hideNotificationBox(event) { 8 | let source = event.target || event.srcElement; 9 | while (source.id !== 'notification' && source.parentNode) 10 | source = source.parentNode; 11 | source.style.display = 'none'; 12 | } -------------------------------------------------------------------------------- /frontend/dist/solid.min-70c2e5caa950974d.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome Free 6.0.0-beta2 by @fontawesome - https://fontawesome.com 3 | * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) 4 | */ 5 | @font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(/asset/webfonts/fa-solid-900.woff2) format("woff2"),url(/asset/webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-family:"Font Awesome 6 Free";font-weight:900} -------------------------------------------------------------------------------- /frontend/dist/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/dist/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /frontend/dist/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/dist/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /frontend/dist/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/dist/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /frontend/dist/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/dist/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /frontend/dist/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/dist/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /frontend/dist/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/frontend/dist/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/resource/i18n/en-US.txt: -------------------------------------------------------------------------------- 1 | pp = Previous Page 2 | np = Next Page 3 | back = Back 4 | edit = Edit 5 | delete = Delete 6 | deletion_confirm = Data cannot be recovered(including images etc.) 7 | cancel = Cancel 8 | ti = Image 9 | upload_image = Upload image 10 | or = or 11 | download_image = Download image randomly 12 | title = Title 13 | content = Content 14 | edit_post = Edit Post 15 | labels = Labels 16 | add_label = Press 'Enter' to add new tag 17 | update = Update post -------------------------------------------------------------------------------- /frontend/resource/i18n/zh-CN.txt: -------------------------------------------------------------------------------- 1 | pp = 上一页 2 | np = 下一页 3 | back = 返回 4 | edit = 编辑 5 | delete = 删除 6 | deletion_confirm = 删除后,数据将不能恢复(包括图片等数据) 7 | cancel = 取消 8 | ti = 题图 9 | upload_image = 上传图片 10 | or = 或者 11 | download_image = 随机下载图片 12 | title = 标题 13 | content = 内容 14 | edit_post = 编辑博客 15 | labels = 标签 16 | add_label = 按'回车'添加新的标签 17 | update = 更新博客 -------------------------------------------------------------------------------- /frontend/src/app.rs: -------------------------------------------------------------------------------- 1 | use weblog::*; 2 | use yew::prelude::*; 3 | use yew_router::prelude::*; 4 | 5 | use crate::router::{switch, Route}; 6 | 7 | pub enum Msg { 8 | Compose, 9 | } 10 | 11 | pub struct App; 12 | 13 | impl Component for App { 14 | type Message = Msg; 15 | type Properties = (); 16 | 17 | fn create(_ctx: &Context) -> Self { 18 | Self 19 | } 20 | 21 | fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { 22 | match msg { 23 | Msg::Compose => { 24 | let navigator = ctx.link().navigator().unwrap(); 25 | wasm_bindgen_futures::spawn_local(async move { 26 | let response = reqwasm::http::Request::get("/post/new").send().await.unwrap(); 27 | let json: blog_common::dto::Response = response.json().await.unwrap(); 28 | if json.status == 0 { 29 | navigator.push(&Route::ComposePost { id: json.data.unwrap() }); 30 | // yew_router::push_route(crate::router::Route::ComposePost { id: json.data.unwrap() }); 31 | } else { 32 | // ctx.link().location().unwrap().route().set_href("/management"); 33 | if let Some(loc) = web_sys::window().map(|window| window.location()) { 34 | let _ = loc.set_href("/management"); 35 | } else { 36 | console_log!("get location failed"); 37 | } 38 | } 39 | }); 40 | }, 41 | } 42 | false 43 | } 44 | 45 | fn view(&self, ctx: &Context) -> Html { 46 | html! { 47 | // https://cn.bing.com/search?form=MOZLBR&pc=MOZI&q=free+blog+logo 48 | // https://www.designevo.com/logo-maker/ 49 | <> 50 | 105 |
106 | render={switch} /> 107 |
108 | 123 | 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /frontend/src/component/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod posts_list; 2 | pub mod unauthorized; 3 | 4 | pub use posts_list::PostsListComponent; 5 | pub use unauthorized::Unauthorized; 6 | 7 | pub(crate) fn blank_node() -> yew::Html { 8 | let div = gloo_utils::document().create_element("p").unwrap(); 9 | div.set_inner_html(" "); 10 | yew::Html::VRef(div.into()) 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/component/unauthorized.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | 3 | use blog_common::dto::post::PostDetail as PostDetailDto; 4 | use blog_common::dto::Response; 5 | use yew::prelude::*; 6 | use yew_router::prelude::*; 7 | 8 | pub struct Unauthorized {} 9 | 10 | impl Component for Unauthorized { 11 | type Message = (); 12 | type Properties = (); 13 | 14 | fn create(ctx: &Context) -> Self { 15 | Self {} 16 | } 17 | 18 | fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { 19 | true 20 | } 21 | 22 | fn view(&self, ctx: &Context) -> Html { 23 | let loc = ctx.link().location().unwrap(); 24 | let redirect_url = loc.path(); 25 | let redirect_url = urlencoding::encode(loc.path()); 26 | // let redirect_url = redirect_url.into_owned(); 27 | let mut url = String::from("/management?.redirect_url="); 28 | url.push_str(redirect_url.as_ref()); 29 | html! { 30 |
31 |
32 |
33 |

34 | { "需要登录/Unauthorized" } 35 |

36 |

37 | { "请点击这里登录/Please click here to sign in." } 38 |

39 |
40 |
41 |
42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/i18n.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use fluent::{FluentBundle, FluentResource}; 4 | // use unic_langid::LanguageIdentifier; 5 | // use yew_router::navigator::NavigatorKind::Hash; 6 | 7 | static EN_US_TEXT: &'static str = include_str!("../resource/i18n/en-US.txt"); 8 | static ZH_CN_TEXT: &'static str = include_str!("../resource/i18n/zh-CN.txt"); 9 | 10 | pub fn get<'a, 'b>( 11 | accept_language: &'a str, 12 | message_ids: Vec<&'static str>, 13 | ) -> Result, ()> { 14 | let (locale, resource) = match accept_language { 15 | "en-US" => (accept_language, EN_US_TEXT), 16 | _ => ("zh-CN", ZH_CN_TEXT), 17 | }; 18 | let ftl_string = resource.to_owned(); 19 | let res = FluentResource::try_new(ftl_string).expect("Failed to parse an FTL string."); 20 | 21 | let lang_id = locale.parse().expect("Parsing failed."); 22 | let mut bundle = FluentBundle::new(vec![lang_id]); 23 | bundle 24 | .add_resource(&res) 25 | .expect("Failed to add FTL resources to the bundle."); 26 | 27 | let mut result = HashMap::with_capacity(message_ids.len()); 28 | let mut errors = vec![]; 29 | for message_id in message_ids { 30 | let msg = bundle.get_message(message_id).expect("Message doesn't exist."); 31 | let pattern = msg.value().expect("Message has no value."); 32 | let value = bundle.format_pattern(&pattern, None, &mut errors); 33 | errors.clear(); 34 | result.insert(message_id, value.to_string()); 35 | } 36 | Ok(result) 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod app; 2 | pub mod component; 3 | mod i18n; 4 | pub mod page; 5 | pub mod router; 6 | -------------------------------------------------------------------------------- /frontend/src/main.rs: -------------------------------------------------------------------------------- 1 | use blog_frontend::app::App; 2 | use yew::{html, prelude::*, Html}; 3 | use yew_router::prelude::*; 4 | 5 | #[function_component(Main)] 6 | fn app() -> Html { 7 | html! { 8 | 9 | 10 | 11 | } 12 | } 13 | 14 | fn main() { 15 | //yew::start_app::
(); 16 | yew::Renderer::
::new().render(); 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/page/git/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod pages; 2 | -------------------------------------------------------------------------------- /frontend/src/page/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod git; 2 | pub(crate) mod post; 3 | pub(crate) mod tag; 4 | -------------------------------------------------------------------------------- /frontend/src/page/post/list.rs: -------------------------------------------------------------------------------- 1 | use yew::prelude::*; 2 | 3 | use crate::component::PostsListComponent; 4 | 5 | pub struct PostsList {} 6 | 7 | impl Component for PostsList { 8 | type Message = (); 9 | type Properties = (); 10 | 11 | fn create(_ctx: &Context) -> Self { 12 | Self {} 13 | } 14 | 15 | fn view(&self, _ctx: &Context) -> Html { 16 | gloo::utils::document().set_title("博客列表/Posts list"); 17 | 18 | html! { 19 | <> 20 |
21 |
22 |

{ "博客列表/Posts list" }

23 |

{ "All of your quality writing in one place" }

24 |
25 |
26 | 27 | 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/page/post/list_by_tag.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Rc; 2 | use std::vec::Vec; 3 | 4 | use blog_common::dto::post::PostDetail; 5 | use blog_common::dto::{PaginationData, Response}; 6 | use blog_common::val; 7 | use weblog::*; 8 | use yew::prelude::*; 9 | use yew_router::prelude::*; 10 | 11 | use crate::component::PostsListComponent; 12 | use crate::router::Route; 13 | 14 | #[derive(Clone, Debug, Eq, PartialEq, Properties)] 15 | pub struct Props { 16 | pub tag_name: String, 17 | } 18 | 19 | pub struct PostsListByTag { 20 | tag_name: String, 21 | } 22 | 23 | impl Component for PostsListByTag { 24 | type Message = (); 25 | type Properties = Props; 26 | 27 | fn create(ctx: &Context) -> Self { 28 | Self { 29 | tag_name: String::from(&ctx.props().tag_name), 30 | } 31 | } 32 | 33 | fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { 34 | let changed = self.tag_name.ne(&ctx.props().tag_name); 35 | if changed { 36 | weblog::console_log!("changed to load"); 37 | self.tag_name.clear(); 38 | self.tag_name.push_str(&ctx.props().tag_name); 39 | } 40 | changed 41 | } 42 | 43 | fn view(&self, ctx: &Context) -> Html { 44 | let Self { tag_name } = self; 45 | let mut request_uri = String::with_capacity(32); 46 | request_uri.push_str("/post/tag/"); 47 | request_uri.push_str(tag_name); 48 | request_uri.push_str("/"); 49 | 50 | let decoded_tag_name = urlencoding::decode(tag_name).unwrap(); 51 | 52 | gloo::utils::document().set_title(&decoded_tag_name); 53 | 54 | html! { 55 | <> 56 |
57 |
58 |

{ decoded_tag_name }

59 |

{ " " }

60 |
61 |
62 | 63 | 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/src/page/post/mod.rs: -------------------------------------------------------------------------------- 1 | mod compose; 2 | mod detail; 3 | mod list; 4 | mod list_by_tag; 5 | 6 | pub use compose::PostCompose; 7 | pub use detail::PostDetail; 8 | pub use list::PostsList; 9 | pub use list_by_tag::PostsListByTag; 10 | -------------------------------------------------------------------------------- /frontend/src/page/tag/list.rs: -------------------------------------------------------------------------------- 1 | use rand::Rng; 2 | use std::vec::Vec; 3 | 4 | use blog_common::dto::post::PostDetail; 5 | use blog_common::dto::{PaginationData, Response}; 6 | use blog_common::val; 7 | use weblog::*; 8 | use yew::prelude::*; 9 | use yew_router::prelude::*; 10 | 11 | use crate::router::Route; 12 | 13 | #[function_component(TagsListComponent)] 14 | fn tags_list() -> Html { 15 | let tags: UseStateHandle> = use_state(|| Vec::with_capacity(0)); 16 | { 17 | let tags = tags.clone(); 18 | let mut uri = String::with_capacity(32); 19 | uri.push_str("/tags/all"); 20 | use_effect_with_deps( 21 | move |_| { 22 | let tags = tags.clone(); 23 | console_log!("request uri"); 24 | wasm_bindgen_futures::spawn_local(async move { 25 | let response: Response> = reqwasm::http::Request::get(uri.as_str()) 26 | .send() 27 | .await 28 | .unwrap() 29 | .json() 30 | .await 31 | .unwrap(); 32 | tags.set(response.data.unwrap()); 33 | }); 34 | || () 35 | }, 36 | (), 37 | ); 38 | } 39 | let tags = (*tags).clone(); 40 | let len = tags.len(); 41 | if len == 0 { 42 | return html! {}; 43 | } 44 | let mut classes = String::with_capacity(32); 45 | let mut rng = rand::thread_rng(); 46 | let tags = tags 47 | .iter() 48 | .map(|t| { 49 | // let mut roll: usize = fastrand::usize(..val::TAG_SIZES.len()); 50 | let mut roll: usize = rng.gen_range(0..val::TAG_SIZES.len()); 51 | classes.push_str("tag is-light"); 52 | classes.push_str(val::TAG_SIZES[roll]); 53 | // roll = fastrand::usize(..val::TAG_COLORS.len()); 54 | roll = rng.gen_range(0..val::TAG_COLORS.len()); 55 | classes.push_str(val::TAG_COLORS[roll]); 56 | let html = html! { 57 | 58 | to={Route::ListPostsByTag { tag_name: String::from(t) }}> 59 | { t } 60 | > 61 | 62 | }; 63 | classes.clear(); 64 | html 65 | }) 66 | .collect::(); 67 | html! { 68 | <> 69 |
70 |
71 | {tags} 72 |
73 |
74 | 75 | } 76 | } 77 | 78 | pub struct TagsList; 79 | 80 | impl Component for TagsList { 81 | type Message = (); 82 | type Properties = (); 83 | 84 | fn create(_ctx: &Context) -> Self { 85 | Self 86 | } 87 | 88 | fn view(&self, ctx: &Context) -> Html { 89 | gloo::utils::document().set_title("所有标签/All tags"); 90 | 91 | html! { 92 | <> 93 |
94 |
95 |

{ "所有标签/All tags" }

96 |

{ " " }

97 |
98 |
99 | 100 | 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/page/tag/mod.rs: -------------------------------------------------------------------------------- 1 | mod list; 2 | 3 | pub use list::TagsList; 4 | -------------------------------------------------------------------------------- /frontend/src/router.rs: -------------------------------------------------------------------------------- 1 | use yew::prelude::*; 2 | use yew_router::prelude::*; 3 | 4 | use crate::page::post::{PostCompose, PostDetail, PostsList, PostsListByTag}; 5 | use crate::page::tag::TagsList; 6 | 7 | #[derive(Routable, PartialEq, Clone, Debug)] 8 | pub enum Route { 9 | #[at("/posts/:id")] 10 | ShowPost { id: u64 }, 11 | #[at("/posts/compose/:id")] 12 | ComposePost { id: u64 }, 13 | #[at("/posts/tag/:tag_name")] 14 | ListPostsByTag { tag_name: String }, 15 | #[at("/tags")] 16 | Tags, 17 | #[at("/about")] 18 | About, 19 | #[at("/")] 20 | ListPosts, 21 | #[not_found] 22 | #[at("/404")] 23 | NotFound, 24 | } 25 | 26 | #[function_component(About)] 27 | fn about() -> Html { 28 | html! { 29 |
30 |

{"省资源"}

31 |
    32 |
  • {"文件小"}
  • 33 |
  • {"在Windows 10上,只占用了xxx内存"}
  • 34 |
  • {"无任何后台或定时任务"}
  • 35 |
36 |

{"速度快"}

37 |
    38 |
  • {"压测数据"}
  • 39 |
  • {"所有内嵌的静态文件,均使用gzip压缩,提高网络传输速度"}
  • 40 |
41 |

{"功能丰富"}

42 |
    43 |
  • {"集成 Markdown 编辑器"}
  • 44 |
  • {"导出博客数据"}
  • 45 |
  • {"支持提供单独的静态文件服务,并支持动态渲染 Markdown 文件(md格式)"}
  • 46 |
47 |
48 |

{"Light"}

49 |
    50 |
  • {"Small file size."}
  • 51 |
  • {"It consumes only xxxM on Windows 10."}
  • 52 |
  • {"No daemon service and schedule task."}
  • 53 |
54 |

{"Fast"}

55 |
    56 |
  • {"Some benchmark."}
  • 57 |
  • {"All embed files were gzipped for network transfer."}
  • 58 |
59 |

{"Features"}

60 |
    61 |
  • {"Markdown editor included."}
  • 62 |
  • {"Export posts data to other static site generators."}
  • 63 |
  • {"Run as a simple file server, and render markdown files (md ext) dynamically."}
  • 64 |
65 |
66 |

{"如何使用"}

67 |
    68 |
  • {"直接打开执行文件(第一次会自动初始化数据库文件"}
  • 69 |
  • {"通过浏览器访问:http://localhost:9270"}
  • 70 |
  • {"第一次需要输入管理密码,设置了以后,就可以使用了"}
  • 71 |
72 |

{"How to use"}

73 |
    74 |
  • {"Execute file directly, application will initialize database for the first time."}
  • 75 |
  • {"Visit: http://localhost:9270 with any modern browser."}
  • 76 |
  • {"First time it will ask you to setup a password, once it's done, you're ready to go."}
  • 77 |
78 |
79 | } 80 | } 81 | 82 | #[function_component(NotFound)] 83 | fn not_found() -> Html { 84 | html! { 85 |
86 |
87 |
88 |

89 | { "找不到请求的页面/Page not found" } 90 |

91 |

92 | { "找不到请求的页面/Page page does not seem to exist." } 93 |

94 |
95 |
96 |
97 | } 98 | } 99 | 100 | pub fn switch(routes: Route) -> Html { 101 | match routes { 102 | Route::ShowPost { id } => { 103 | html! { } 104 | }, 105 | Route::ListPostsByTag { tag_name } => { 106 | html! { } 107 | }, 108 | Route::ListPosts => { 109 | html! { } 110 | }, 111 | Route::ComposePost { id } => { 112 | html! { } 113 | }, 114 | Route::Tags => { 115 | html! { } 116 | }, 117 | Route::About => { 118 | html! { } 119 | }, 120 | _ => { 121 | html! { } 122 | }, 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /manual/how-to-use-en.md: -------------------------------------------------------------------------------- 1 | ## How to serve my posts? 2 | 3 | ### -------------------------------------------------------------------------------- /manual/how-to-use-zh.md: -------------------------------------------------------------------------------- 1 | ## 如何使用 2 | 3 | ### 1、启动本工具 4 | 可以通过命令行来设置,执行:`blog-backend.exe -h`可以看到帮助信息 5 | ``` 6 | USAGE: 7 | blog-backend.exe [OPTIONS] 8 | 9 | OPTIONS: 10 | --cert-path Cert file path, needed by https 11 | --cors-host Hostname for CORS 12 | -h, --help Print help information 13 | --hsts-enabled Enable HSTS Redirect Server 14 | --https-enabled Enable HTTPS Server 15 | --https-port Specify HTTPS listening port, default value is '443' [default: 16 | 443] 17 | --ip HTTP Server Settings Specify http listening address, e.g.: 18 | 0.0.0.0 or [::] or 127.0.0.1 or other particular ip, default is 19 | '127.0.0.1' [default: 127.0.0.1] 20 | --key-path Key file path, needed by https 21 | --mode Specify run mode: 'static' is for static file serve, 'blog' is 22 | blog warp server mode 23 | --port Specify listening port, default value is '80' [default: 80] 24 | -V, --version Print version information 25 | ``` 26 | 27 | 根据上面的信息,可以了解到,直接执行:`blog-backend.exe`,该服务会启动`HTTP`服务,默认监听:`127.0.0.1:80` 28 | 访问:[http://localhost](http://localhost) 即可 29 | 30 | 如果要修改端口,可以使用:`--port`参数。如:`blog-backend.exe --port 9270` 31 | 然后访问:[http://localhost:9270](http://localhost:9270) 即可 32 | 33 | ### 2、设置管理员密码 34 | 在没有设置管理员密码的时候,系统会自动打开如下页面。 35 | 输入密码(最少1位),点击:“更新”即可 36 | 37 | ## 如何将我的博客展现给其他人看? 38 | 39 | ### 1、使用本工具自带的HTTP服务器 40 | 在上面的“启动本工具”环境,介绍了如何启动。 41 | 我们仅需要做一些小调整,就可以对外服务了。 42 | 1. 使用`--ip`,修改为:`0.0.0.0`,或其它外网IP 43 | 2. `--https-enabled`是用于启用`HTTPS`(需配合`--cert-path`、`--key-path`参数) 44 | 45 | ### 2、导出到Hugo服务器 46 | 在`管理`页面,可以导出为`Hugo`静态文件,使用`Hugo`来渲染。 47 | 48 | ### 3、使用本工具的静态文件服务模式 49 | 启动的时候,指定`--mode static`即可使用该模式。 50 | 51 | -------------------------------------------------------------------------------- /manual/screenshot1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/manual/screenshot1.jpg -------------------------------------------------------------------------------- /manual/screenshot2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/manual/screenshot2.jpg -------------------------------------------------------------------------------- /manual/screenshot_en-US.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/songday/blog-rs/11648a574d599cd43805427c42dc2bb473dfa67a/manual/screenshot_en-US.jpg -------------------------------------------------------------------------------- /manual/screenshots.md: -------------------------------------------------------------------------------- 1 | **博客详情/Post Detail page** 2 | ![DetailPage](screenshot2.jpg) 3 | 4 | --- 5 | 6 | **英文文字/Text in English** 7 | ![i18n](screenshot_en-US.jpg) -------------------------------------------------------------------------------- /scripts/build.bat: -------------------------------------------------------------------------------- 1 | cls 2 | rem set DATABASE_URL=sqlite://data/all.db 3 | cd ..\frontend 4 | del /S /Q dist\* 5 | rmdir /S /Q dist 6 | trunk build 7 | @REM trunk build --release 8 | cd dist 9 | powershell -Command "(Get-Content index.html) -replace '\"/', '\"/asset/' -replace \"'/\", \"'/asset/\" | Out-File -Encoding utf8 index.html" 10 | cd .. 11 | del /S/Q ..\backend\src\resource\asset\* 12 | move dist\index.html ..\backend\src\resource\page\ 13 | xcopy /E dist\* ..\backend\src\resource\asset\ 14 | cd ..\backend 15 | @REM cargo b -vv 16 | cargo r -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # author:FlyingBlackShark 3 | clear 4 | cd ../frontend 5 | rm -rf dist 6 | trunk build 7 | cd dist 8 | sed -i 's?"/?"/asset/?g' index.html 9 | sed -i "s?'/?'/asset/?g" index.html 10 | cd .. 11 | rm -rf ../backend/src/resource/asset/* 12 | mv dist/index.html ../backend/src/resource/page/ 13 | cp -r dist/* ../backend/src/resource/asset/ 14 | cd ../backend 15 | cargo run -------------------------------------------------------------------------------- /scripts/release.bat: -------------------------------------------------------------------------------- 1 | cls 2 | rem set DATABASE_URL=sqlite://data/all.db 3 | cd ..\frontend 4 | del /S /Q dist\* 5 | rmdir /S /Q dist 6 | trunk build --release 7 | cd dist 8 | powershell -Command "(Get-Content index.html) -replace '\"/', '\"/asset/' -replace \"'/\", \"'/asset/\" | Out-File -Encoding utf8 index.html" 9 | cd .. 10 | del /S/Q ..\backend\src\resource\asset\* 11 | move dist\index.html ..\backend\src\resource\page\ 12 | xcopy /E dist\* ..\backend\src\resource\asset\ 13 | cd ..\backend 14 | @REM cargo b -vv 15 | cargo build --release -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # author:FlyingBlackShark 3 | clear 4 | cd ../frontend 5 | rm -rf dist 6 | trunk build --release 7 | cd dist 8 | sed -i 's?"/?"/asset/?g' index.html 9 | sed -i "s?'/?'/asset/?g" index.html 10 | cd .. 11 | rm -rf ../backend/src/resource/asset/* 12 | mv dist/index.html ../backend/src/resource/page/ 13 | cp -r dist/* ../backend/src/resource/asset/ 14 | cd ../backend 15 | cargo build --release --------------------------------------------------------------------------------