momo的首次提交!
This commit is contained in:
141
.github/workflows/build.yml
vendored
Normal file
141
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,141 @@
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Publish to Github Releases
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
rc: ${{ steps.check-tag.outputs.rc }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-unknown-linux-musl
|
||||
use-cross: true
|
||||
os: ubuntu-latest
|
||||
cargo-flags: ""
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
use-cross: true
|
||||
cargo-flags: ""
|
||||
- target: aarch64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
use-cross: true
|
||||
cargo-flags: ""
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
cargo-flags: ""
|
||||
- target: x86_64-pc-windows-msvc
|
||||
os: windows-latest
|
||||
cargo-flags: ""
|
||||
- target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
use-cross: true
|
||||
cargo-flags: ""
|
||||
- target: i686-unknown-linux-musl
|
||||
os: ubuntu-latest
|
||||
use-cross: true
|
||||
cargo-flags: ""
|
||||
- target: i686-pc-windows-msvc
|
||||
os: windows-latest
|
||||
use-cross: true
|
||||
cargo-flags: ""
|
||||
- target: armv7-unknown-linux-musleabihf
|
||||
os: ubuntu-latest
|
||||
use-cross: true
|
||||
cargo-flags: ""
|
||||
- target: arm-unknown-linux-musleabihf
|
||||
os: ubuntu-latest
|
||||
use-cross: true
|
||||
cargo-flags: ""
|
||||
|
||||
runs-on: ${{matrix.os}}
|
||||
env:
|
||||
BUILD_CMD: cargo
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check Tag
|
||||
id: check-tag
|
||||
shell: bash
|
||||
run: |
|
||||
ver=${GITHUB_REF##*/}
|
||||
echo "version=$ver" >> $GITHUB_OUTPUT
|
||||
if [[ "$ver" =~ [0-9]+.[0-9]+.[0-9]+$ ]]; then
|
||||
echo "rc=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "rc=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Install Rust Toolchain Components
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross
|
||||
if: matrix.use-cross
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cross
|
||||
|
||||
- name: Overwrite build command env variable
|
||||
if: matrix.use-cross
|
||||
shell: bash
|
||||
run: echo "BUILD_CMD=cross" >> $GITHUB_ENV
|
||||
|
||||
- name: Show Version Information (Rust, cargo, GCC)
|
||||
shell: bash
|
||||
run: |
|
||||
gcc --version || true
|
||||
rustup -V
|
||||
rustup toolchain list
|
||||
rustup default
|
||||
cargo -V
|
||||
rustc -V
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: $BUILD_CMD build --locked --release --target=${{ matrix.target }} ${{ matrix.cargo-flags }}
|
||||
|
||||
- name: Build Archive
|
||||
shell: bash
|
||||
id: package
|
||||
env:
|
||||
target: ${{ matrix.target }}
|
||||
version: ${{ steps.check-tag.outputs.version }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
bin=${GITHUB_REPOSITORY##*/}
|
||||
dist_dir=`pwd`/dist
|
||||
name=$bin-$version-$target
|
||||
executable=target/$target/release/$bin
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
executable=$executable.exe
|
||||
fi
|
||||
mkdir $dist_dir
|
||||
cp $executable $dist_dir
|
||||
cd $dist_dir
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
archive=$dist_dir/$name.zip
|
||||
7z a $archive *
|
||||
echo "archive=dist/$name.zip" >> $GITHUB_OUTPUT
|
||||
else
|
||||
archive=$dist_dir/$name.tar.gz
|
||||
tar -czf $archive *
|
||||
echo "archive=dist/$name.tar.gz" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- name: Publish Archive
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
with:
|
||||
draft: false
|
||||
files: ${{ steps.package.outputs.archive }}
|
||||
prerelease: ${{ steps.check-tag.outputs.rc == 'true' }}
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
\target
|
||||
1285
Cargo.lock
generated
Normal file
1285
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "momo"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.4"
|
||||
askama = "0.14.0"
|
||||
clap = { version = "4.5.37", features = ["cargo", "derive"] }
|
||||
lazy_static = "1.5.0"
|
||||
serde = { version = "1.0.219", features = ["serde_derive"] }
|
||||
tokio = { version = "1.44.2", features = ["macros", "rt-multi-thread"] }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.2", features = ["fs","trace"] }
|
||||
tracing-subscriber = "0.3.19"
|
||||
url = "2.5.4"
|
||||
thiserror = "2.0.12"
|
||||
tracing = "0.1.41"
|
||||
|
||||
[profile.release]
|
||||
#缩小编译后体积
|
||||
strip = true
|
||||
# strip = "debuginfo" #仅移除debug信息
|
||||
lto = true #启用链接时间优化
|
||||
panic = "abort" #panic时直接abort
|
||||
opt-level = "z" #优化级别
|
||||
16
README.md
Normal file
16
README.md
Normal file
@ -0,0 +1,16 @@
|
||||
## Momo
|
||||
简单的图片浏览服务
|
||||
|
||||
### 使用
|
||||
```bash
|
||||
momo.exe [OPTIONS]
|
||||
```
|
||||
```bash
|
||||
Options:
|
||||
-d, --directory <directory> 设置工作目录
|
||||
-p, --port <port> 监听的端口号 [default: 3000]
|
||||
-l, --loglevel... 启用调试信息
|
||||
-w, --width <WIDTH> 设置图片的宽度,该设置决定了瀑布流的宽度 [default: 350]
|
||||
-h, --help Print help
|
||||
-V, --version Print version
|
||||
```
|
||||
11
src/error.rs
Normal file
11
src/error.rs
Normal file
@ -0,0 +1,11 @@
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("无法获取目录")]
|
||||
CannotGetItemList(#[from] std::io::Error),
|
||||
#[error("路径穿越")]
|
||||
PathTraversal,
|
||||
#[error("路径不合法")]
|
||||
PathNotValid,
|
||||
#[error("无法获取工作目录")]
|
||||
CannotGetWorkDir
|
||||
}
|
||||
86
src/filetype.rs
Normal file
86
src/filetype.rs
Normal file
@ -0,0 +1,86 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
pub const IMAGE_FILE_EXTENSION: [&str; 16] = [
|
||||
"apng", "avif", "gif", "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "svg", "webp", "bmp",
|
||||
"ico", "cur", "tif", "tiff",
|
||||
];
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum FileType {
|
||||
Image(ImageType),
|
||||
Other,
|
||||
//TODO 完成其他类型
|
||||
}
|
||||
|
||||
impl ToContentType for FileType {
|
||||
fn get_content_type(&self) -> impl std::fmt::Display {
|
||||
match self {
|
||||
FileType::Image(image) => image.get_content_type().to_string(),
|
||||
FileType::Other => "application/octet-stream".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ImageType {
|
||||
Apng,
|
||||
Avif,
|
||||
Gif,
|
||||
Jpeg,
|
||||
Png,
|
||||
Svg,
|
||||
WebP,
|
||||
Bmp,
|
||||
Ico,
|
||||
Tiff,
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl Display for ImageType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
ImageType::Apng => "apng",
|
||||
ImageType::Avif => "avif",
|
||||
ImageType::Gif => "gif",
|
||||
ImageType::Jpeg => "jpeg",
|
||||
ImageType::Png => "png",
|
||||
ImageType::Svg => "svg",
|
||||
ImageType::WebP => "webp",
|
||||
ImageType::Bmp => "bmp",
|
||||
ImageType::Ico => "ico",
|
||||
ImageType::Tiff => "tiff",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageType {
|
||||
//全转小写,方便比对
|
||||
pub fn is_image_file(extension: &str) -> bool {
|
||||
IMAGE_FILE_EXTENSION.contains(&extension.to_lowercase().as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl ToContentType for ImageType {
|
||||
fn get_content_type(&self) -> impl Display {
|
||||
match self {
|
||||
ImageType::Apng => "image/apng",
|
||||
ImageType::Avif => "image/avif",
|
||||
ImageType::Gif => "image/gif",
|
||||
ImageType::Jpeg => "image/jpeg",
|
||||
ImageType::Png => "image/png",
|
||||
ImageType::Svg => "image/svg+xml",
|
||||
ImageType::WebP => "image/webp",
|
||||
ImageType::Bmp => "image/bmp",
|
||||
ImageType::Ico => "image/x-icon",
|
||||
ImageType::Tiff => "image/tiff",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实现此trait后可以通过结构体获取Content-Type
|
||||
pub trait ToContentType {
|
||||
fn get_content_type(&self) -> impl Display;
|
||||
}
|
||||
296
src/handlers.rs
Normal file
296
src/handlers.rs
Normal file
@ -0,0 +1,296 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
http::{StatusCode, header},
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use std::{
|
||||
fmt::Display,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tokio::fs::{self};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
CONFIG,
|
||||
error::Error,
|
||||
filetype::{self, FileType, IMAGE_FILE_EXTENSION, ImageType, ToContentType},
|
||||
template::{ImageFallTemplate, Imgs},
|
||||
};
|
||||
|
||||
pub async fn handler(uri: Option<axum::extract::Path<String>>) -> impl IntoResponse {
|
||||
let request_path = match uri {
|
||||
Some(u) => PathBuf::from(u.0),
|
||||
None => PathBuf::from("/"),
|
||||
};
|
||||
|
||||
let base_path = match std::env::current_dir() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("无法获取工作目录。\n原因:{e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let uri = Uri::frompath(&request_path);
|
||||
|
||||
let path = match uri.0.as_str() {
|
||||
//看看是否为请求网站更目录目录(即工作目录根目录)
|
||||
"/" => base_path,
|
||||
_ => format_path(base_path.join(&request_path)),
|
||||
};
|
||||
|
||||
let request_uri = UriBox::new(path, uri);
|
||||
match request_uri.path.try_exists() {
|
||||
// 尝试判断是否存在,避免硬件错误
|
||||
Ok(status) => {
|
||||
//判断是否存在
|
||||
if status {
|
||||
//存在
|
||||
if request_uri.path.is_file() {
|
||||
file_handler(request_uri).await.into_response()
|
||||
} else if request_uri.path.is_dir() {
|
||||
dir_handler(request_uri).await.into_response()
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "不是文件也不是目录").into_response()
|
||||
}
|
||||
} else {
|
||||
//不存在
|
||||
(StatusCode::NOT_FOUND, "目标不存在").into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn dir_handler(dir: UriBox) -> impl IntoResponse {
|
||||
//获取请求目录的列表
|
||||
let items = match get_item(dir) {
|
||||
Ok(o) => o,
|
||||
Err(e) => match e {
|
||||
Error::CannotGetItemList(error) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response();
|
||||
}
|
||||
Error::PathTraversal => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||
}
|
||||
Error::PathNotValid => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||
}
|
||||
Error::CannotGetWorkDir => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let mut imgs = Vec::new();
|
||||
for i in items {
|
||||
if let Some(ext) = i.path.extension() {
|
||||
let ext = match ext.to_str() {
|
||||
Some(e) => e,
|
||||
None => panic!(),
|
||||
};
|
||||
if filetype::ImageType::is_image_file(ext) {
|
||||
// info!("{}",i.uri.0);
|
||||
imgs.push(Imgs::new(i.uri.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if imgs.len() == 0 {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Html(include_bytes!("../templates/NoResources.html")),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 返回模板数据
|
||||
let html = match (ImageFallTemplate {
|
||||
imgs,
|
||||
column_width: CONFIG.width,
|
||||
})
|
||||
.render()
|
||||
{
|
||||
Ok(h) => h,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, ("无法返回模板文件")).into_response(),
|
||||
};
|
||||
(StatusCode::OK, Html(html)).into_response()
|
||||
}
|
||||
|
||||
/// # 读取文件并且设置CONTENT_TYPE
|
||||
/// ### 需要确定文件存在!
|
||||
pub async fn file_handler(file: UriBox) -> impl IntoResponse {
|
||||
let extension = file.path.extension();
|
||||
let header = match extension {
|
||||
//如果有后缀
|
||||
Some(e) => {
|
||||
let extension = e.to_string_lossy().to_string().to_lowercase(); //全部转小写,避免出错
|
||||
|
||||
let content_type = extension_handler(extension).get_content_type().to_string();
|
||||
[(header::CONTENT_TYPE, content_type)]
|
||||
} //如果没后缀
|
||||
None => [(
|
||||
header::CONTENT_TYPE,
|
||||
FileType::Other.get_content_type().to_string(),
|
||||
)],
|
||||
};
|
||||
// 异步读取文件
|
||||
match fs::read(&file.path).await {
|
||||
Ok(data) => (header, Bytes::from(data)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("文件读取出错, 原因:{e}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_handler(ext: String) -> FileType {
|
||||
if IMAGE_FILE_EXTENSION.contains(&ext.to_lowercase().as_str())
|
||||
//如果为图片格式
|
||||
{
|
||||
FileType::Image(image_extension_handler(ext))
|
||||
} else {
|
||||
FileType::Other
|
||||
}
|
||||
}
|
||||
// 全部自动转小写
|
||||
fn image_extension_handler(extension: impl ToString) -> ImageType {
|
||||
match &extension.to_string().to_lowercase()[..] {
|
||||
"apng" => ImageType::Apng,
|
||||
"jpeg" | "jpg" | "jfif" | "pjpeg" | "pjp" => ImageType::Jpeg,
|
||||
"png" => ImageType::Png,
|
||||
"svg" => ImageType::Svg,
|
||||
"webp" => ImageType::WebP,
|
||||
"bmp" => ImageType::Bmp,
|
||||
"ico" | "cur" => ImageType::Ico,
|
||||
"tif" | "tiff" => ImageType::Tiff,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_path(ori: PathBuf) -> PathBuf {
|
||||
let mut new_path = PathBuf::new();
|
||||
for p in ori.components() {
|
||||
new_path.push(p);
|
||||
}
|
||||
new_path
|
||||
}
|
||||
|
||||
fn get_item(p: UriBox) -> Result<Vec<UriBox>, Error> {
|
||||
let mut items = Vec::new();
|
||||
let base_path = match std::env::current_dir() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return Err(Error::CannotGetWorkDir);
|
||||
}
|
||||
};
|
||||
for entry in std::fs::read_dir(&p.path)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_file() {
|
||||
match reslove_relative_path(&base_path, entry.path()) {
|
||||
Ok(uri) => items.push(UriBox {
|
||||
path: entry.path(),
|
||||
uri,
|
||||
}),
|
||||
Err(e) => return Err(e),
|
||||
}; //计算相对路径
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Uri(String);
|
||||
|
||||
impl Uri {
|
||||
//直接转化为Uri
|
||||
fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
fn frompath(url: impl AsRef<Path>) -> Self {
|
||||
if url.as_ref() == PathBuf::from("/") {
|
||||
Self("/".into())
|
||||
} else {
|
||||
let mut new_url = String::new();
|
||||
for i in url.as_ref().components() {
|
||||
new_url.push('/');
|
||||
new_url.push_str(&format!("{}", i.as_os_str().to_string_lossy()));
|
||||
}
|
||||
Self(new_url)
|
||||
}
|
||||
}
|
||||
|
||||
//将迭代器转为Uri
|
||||
fn from_iter<T: Into<String>>(slice: impl Iterator<Item = T>) -> Self {
|
||||
let mut uri = String::new();
|
||||
for i in slice {
|
||||
uri.push('/');
|
||||
uri.push_str(&i.into());
|
||||
}
|
||||
Self::new(uri)
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Uri {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
//处理url和本地目录的关系
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UriBox {
|
||||
path: PathBuf,
|
||||
uri: Uri,
|
||||
}
|
||||
|
||||
impl UriBox {
|
||||
fn new(path: PathBuf, uri: Uri) -> Self {
|
||||
Self { path, uri }
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算相对路径
|
||||
fn reslove_relative_path(
|
||||
base_path: impl AsRef<Path>,
|
||||
relative_path: impl AsRef<Path>,
|
||||
) -> Result<Uri, Error> {
|
||||
let mut base_path_components: Vec<String> = match base_path.as_ref().canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Err(Error::PathNotValid),
|
||||
}
|
||||
.components()
|
||||
.map(|x| x.as_os_str().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
let relative_path_canonicalize = match relative_path.as_ref().canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Err(Error::PathNotValid),
|
||||
};
|
||||
let mut relative_path_components: Vec<String> = relative_path_canonicalize
|
||||
.components()
|
||||
.map(|x| x.as_os_str().to_string_lossy().to_string())
|
||||
.collect();
|
||||
base_path_components.retain(|x| x != "\\");
|
||||
relative_path_components.retain(|x| x != "\\");
|
||||
|
||||
if relative_path_components.len() < base_path_components.len() {
|
||||
//如果请求路径短于基础路径那肯定是非法的
|
||||
warn!("路径穿越攻击!");
|
||||
return Err(Error::PathTraversal);
|
||||
}
|
||||
|
||||
for i in 0..(base_path_components.len()) {
|
||||
if base_path_components[i] != relative_path_components[i] {
|
||||
return Err(Error::PathTraversal);
|
||||
}
|
||||
}
|
||||
let result: Vec<String> = relative_path_components
|
||||
.drain((base_path_components.len())..)
|
||||
.collect();
|
||||
Ok(Uri::from_iter(result.iter()))
|
||||
}
|
||||
50
src/main.rs
Normal file
50
src/main.rs
Normal file
@ -0,0 +1,50 @@
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use axum::Router;
|
||||
use axum::routing::get;
|
||||
use clap::Parser;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::info;
|
||||
|
||||
mod error;
|
||||
mod filetype;
|
||||
mod handlers;
|
||||
mod myclap;
|
||||
mod template;
|
||||
|
||||
use handlers::handler;
|
||||
use myclap::Cli;
|
||||
|
||||
use crate::myclap::Config;
|
||||
|
||||
pub static CONFIG: LazyLock<Config> =
|
||||
LazyLock::new(|| Config::from_parser(Cli::parse()).expect("无法读取配置"));
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
//日志
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(CONFIG.loglevel)
|
||||
.init();
|
||||
|
||||
info!("Log Level: {}",CONFIG.loglevel.to_string());
|
||||
info!("Image Width: {}px",CONFIG.width);
|
||||
|
||||
info!("Working Directory: [{}]", CONFIG.directory.display());
|
||||
// 设置工作目录
|
||||
std::env::set_current_dir(CONFIG.directory.clone()).expect("无法设置工作目录");
|
||||
|
||||
// 路由
|
||||
let app = Router::new()
|
||||
.route("/", get(handler))
|
||||
.route("/{*filename}", get(handler))
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", CONFIG.port))
|
||||
.await
|
||||
.unwrap();
|
||||
info!("Listen on 127.0.0.1:{}", CONFIG.port);
|
||||
info!("Listen on 0.0.0.0:{}", CONFIG.port);
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
54
src/myclap.rs
Normal file
54
src/myclap.rs
Normal file
@ -0,0 +1,54 @@
|
||||
use std::path::PathBuf;
|
||||
use clap::Parser;
|
||||
use tracing::Level;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// 设置工作目录
|
||||
#[arg(short, long, value_name = "directory")]
|
||||
pub directory: Option<PathBuf>,
|
||||
|
||||
/// 监听的端口号
|
||||
#[arg(short, long, value_name = "port",default_value_t = 3000)]
|
||||
pub port: u16,
|
||||
|
||||
/// 启用调试信息
|
||||
#[arg(long,short, action = clap::ArgAction::Count)]
|
||||
pub loglevel: u8,
|
||||
|
||||
/// 设置图片的宽度,该设置决定了瀑布流的宽度
|
||||
#[arg(long,short,default_value_t = 350)]
|
||||
pub width: usize,
|
||||
|
||||
}
|
||||
|
||||
pub struct Config{
|
||||
pub directory:PathBuf,
|
||||
pub port: u16,
|
||||
pub loglevel: Level,
|
||||
pub width: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_parser(cli:Cli)->Result<Self,Error>{
|
||||
let port = cli.port;
|
||||
let workdir = match cli.directory {
|
||||
Some(d) => d,
|
||||
None => match std::env::current_dir() {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Err(Error::CannotGetWorkDir),
|
||||
},
|
||||
};
|
||||
let loglevel = match cli.loglevel {
|
||||
1 => tracing::Level::ERROR,
|
||||
2 => tracing::Level::WARN,
|
||||
0|3 => tracing::Level::INFO,
|
||||
4 => tracing::Level::DEBUG,
|
||||
5.. => tracing::Level::TRACE,
|
||||
};
|
||||
Ok(Self { directory: workdir, port, loglevel: loglevel, width: cli.width })
|
||||
}
|
||||
}
|
||||
18
src/template.rs
Normal file
18
src/template.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use askama::Template;
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.2.html")]
|
||||
pub struct ImageFallTemplate {
|
||||
pub imgs: Vec<Imgs>,
|
||||
pub column_width:usize,
|
||||
}
|
||||
pub struct Imgs {
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl Imgs {
|
||||
pub fn new(path: String) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
19
templates/NoResources.html
Normal file
19
templates/NoResources.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>No Resources</title>
|
||||
<style>
|
||||
|
||||
h1{
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>
|
||||
No Resources We Can Show You
|
||||
</h1>
|
||||
</body>
|
||||
</html>
|
||||
263
templates/index.1.html
Normal file
263
templates/index.1.html
Normal file
@ -0,0 +1,263 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>动态获取图片尺寸的瀑布流</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.waterfall-container {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
|
||||
}
|
||||
|
||||
.waterfall-item {
|
||||
position: absolute;
|
||||
width: calc(100% / var(--columns, 4) - 10px);
|
||||
margin: 5px;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.5s forwards;
|
||||
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-item img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.waterfall-item .img-placeholder {
|
||||
width: 100%;
|
||||
background-color: #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.waterfall-item .content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
img {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
img:hover {
|
||||
transform: scale(1.1);
|
||||
/*图片放大1.1倍*/
|
||||
margin-top: 30px;
|
||||
/*鼠标悬停时上移30px*/
|
||||
/* margin-top: 0px;和hover的margin-top有对比,原无30,现在0,相当于上移了,30px */
|
||||
box-shadow: 0 0 20px 2px #918f8f;
|
||||
/*盒子阴影*/
|
||||
transition: all 0.5s;
|
||||
/*持续时间*/
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="waterfall-container" id="waterfall">
|
||||
<div class="loading">加载中,请稍候...</div>
|
||||
|
||||
<!-- 示例结构 - 实际使用时可以动态生成 -->
|
||||
{% for img in imgs %}
|
||||
<div class="waterfall-item">
|
||||
<div class="img-placeholder">
|
||||
<img data-src={{img.path}}>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const waterfallContainer = document.getElementById('waterfall');
|
||||
let columnHeights = [];
|
||||
let columnCount = 0;
|
||||
let columnWidth = 0;
|
||||
const gap = 10;
|
||||
|
||||
// 初始化瀑布流
|
||||
async function initWaterfall() {
|
||||
// 隐藏所有项目
|
||||
const items = document.querySelectorAll('.waterfall-item');
|
||||
items.forEach(item => item.style.display = 'none');
|
||||
|
||||
// 移除加载提示
|
||||
const loading = document.querySelector('.loading');
|
||||
if (loading) loading.remove();
|
||||
|
||||
// 计算列数
|
||||
calculateColumns();
|
||||
|
||||
// 预加载所有图片并获取尺寸
|
||||
await preloadImages();
|
||||
|
||||
// 定位所有项目
|
||||
positionAllItems();
|
||||
|
||||
// 显示所有项目
|
||||
items.forEach(item => item.style.display = 'block');
|
||||
|
||||
// 初始化懒加载
|
||||
lazyLoadImages();
|
||||
}
|
||||
|
||||
// 预加载图片并获取尺寸
|
||||
function preloadImages() {
|
||||
const images = document.querySelectorAll('.waterfall-item img[data-src]');
|
||||
const promises = [];
|
||||
|
||||
images.forEach(img => {
|
||||
const promise = new Promise((resolve) => {
|
||||
const tempImg = new Image();
|
||||
tempImg.src = img.dataset.src;
|
||||
tempImg.onload = function () {
|
||||
// 保存自然尺寸到数据集
|
||||
img.dataset.naturalWidth = this.naturalWidth;
|
||||
img.dataset.naturalHeight = this.naturalHeight;
|
||||
resolve();
|
||||
};
|
||||
tempImg.onerror = function () {
|
||||
// 加载失败时使用默认尺寸
|
||||
img.dataset.naturalWidth = 800;
|
||||
img.dataset.naturalHeight = 600;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
promises.push(promise);
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
// 计算列数和列宽
|
||||
function calculateColumns() {
|
||||
const containerWidth = waterfallContainer.offsetWidth;
|
||||
const minColumnWidth = 250;
|
||||
columnCount = Math.max(1, Math.floor(containerWidth / minColumnWidth));
|
||||
columnWidth = containerWidth / columnCount;
|
||||
waterfallContainer.style.setProperty('--columns', columnCount);
|
||||
columnHeights = new Array(columnCount).fill(0);
|
||||
}
|
||||
|
||||
// 定位所有项目
|
||||
function positionAllItems() {
|
||||
columnHeights = new Array(columnCount).fill(0);
|
||||
const items = document.querySelectorAll('.waterfall-item');
|
||||
|
||||
items.forEach(item => {
|
||||
positionItem(item);
|
||||
});
|
||||
|
||||
waterfallContainer.style.height = Math.max(...columnHeights) + 'px';
|
||||
}
|
||||
|
||||
// 定位单个项目
|
||||
function positionItem(itemElement) {
|
||||
const img = itemElement.querySelector('img');
|
||||
const width = parseInt(img.dataset.naturalWidth) || 800;
|
||||
const height = parseInt(img.dataset.naturalHeight) || 600;
|
||||
const aspectRatio = height / width;
|
||||
|
||||
const minHeight = Math.min(...columnHeights);
|
||||
const columnIndex = columnHeights.indexOf(minHeight);
|
||||
const left = columnIndex * columnWidth;
|
||||
const top = minHeight;
|
||||
const itemHeight = columnWidth * aspectRatio + 50; // 50是内容区域高度
|
||||
|
||||
columnHeights[columnIndex] += itemHeight + gap;
|
||||
|
||||
itemElement.style.left = `${left}px`;
|
||||
itemElement.style.top = `${top}px`;
|
||||
|
||||
const placeholder = itemElement.querySelector('.img-placeholder');
|
||||
placeholder.style.paddingBottom = `${aspectRatio * 100}%`;
|
||||
}
|
||||
|
||||
// 懒加载图片(实际显示)
|
||||
function lazyLoadImages() {
|
||||
const lazyImages = document.querySelectorAll('.waterfall-item img[data-src]');
|
||||
|
||||
const observer = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
img.onload = () => {
|
||||
img.style.opacity = '1';
|
||||
img.removeAttribute('data-src');
|
||||
};
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '0px 0px 200px 0px'
|
||||
});
|
||||
|
||||
lazyImages.forEach(img => observer.observe(img));
|
||||
}
|
||||
|
||||
// 窗口大小改变时重新计算
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
calculateColumns();
|
||||
positionAllItems();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// 初始化
|
||||
initWaterfall();
|
||||
|
||||
// 模拟滚动加载更多数据
|
||||
window.addEventListener('scroll', () => {
|
||||
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) {
|
||||
console.log('加载更多数据...');
|
||||
// 这里可以添加加载更多数据的逻辑
|
||||
// 加载后需要再次调用 preloadImages() 和 positionAllItems()
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
203
templates/index.2.html
Normal file
203
templates/index.2.html
Normal file
@ -0,0 +1,203 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-hans">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Momo</title>
|
||||
<style>
|
||||
.waterfall-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waterfall-item {
|
||||
position: absolute;
|
||||
width: calc(33.33% - 20px);
|
||||
/* 三列布局,考虑间距 */
|
||||
margin: 10px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.waterfall-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 懒加载时的占位样式 */
|
||||
.lazy {
|
||||
background-color: #f5f5f5;
|
||||
min-height: 200px;
|
||||
/* 根据你的设计调整 */
|
||||
}
|
||||
|
||||
img {
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
img:hover {
|
||||
transform: scale(1.3);
|
||||
/*图片放大1.1倍*/
|
||||
margin-top: 30px;
|
||||
/*鼠标悬停时上移30px*/
|
||||
margin-top: 0px;
|
||||
box-shadow: 0 0 20px 2px #918f8f;
|
||||
/*盒子阴影*/
|
||||
transition: all 0.5s;
|
||||
/*持续时间*/
|
||||
|
||||
}
|
||||
|
||||
.waterfall-item.loading {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (min-width: 1200px) {
|
||||
.waterfall-item {
|
||||
width: calc(25% - 20px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.waterfall-item {
|
||||
width: calc(50% - 200px);
|
||||
/* 两列布局 */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.waterfall-item {
|
||||
width: calc(100% - 20px);
|
||||
/* 单列布局 */
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="waterfall-container">
|
||||
{% for img in imgs %}
|
||||
<div class="waterfall-item">
|
||||
<img class="lazy" data-src="{{img.path}}" alt="">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</body>
|
||||
<script>
|
||||
const column_width = {{column_width}};
|
||||
function get_column_count(width) {
|
||||
return parseInt(width/column_width)
|
||||
}
|
||||
// 使用防抖函数优化性能
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function () {
|
||||
const context = this, args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(context, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
function layoutWaterfall() {
|
||||
const container = document.querySelector('.waterfall-container');
|
||||
const items = document.querySelectorAll('.waterfall-item:not(.loading)'); // 排除正在加载的项
|
||||
const columnCount = get_column_count(window.innerWidth); // 响应式列数
|
||||
// const columnCount = window.innerWidth < 768 ? 2 : 3; // 响应式列数
|
||||
const gap = 20;
|
||||
const containerWidth = container.offsetWidth;
|
||||
const columnWidth = (containerWidth - gap * (columnCount - 1)) / columnCount;
|
||||
|
||||
const columnHeights = new Array(columnCount).fill(0);
|
||||
|
||||
items.forEach(item => {
|
||||
item.style.width = `${columnWidth}px`;
|
||||
|
||||
const minHeight = Math.min(...columnHeights);
|
||||
const columnIndex = columnHeights.indexOf(minHeight);
|
||||
|
||||
const left = columnIndex * (columnWidth + gap);
|
||||
const top = minHeight;
|
||||
|
||||
item.style.left = `${left}px`;
|
||||
item.style.top = `${top}px`;
|
||||
|
||||
columnHeights[columnIndex] += item.offsetHeight + gap;
|
||||
});
|
||||
|
||||
container.style.height = `${Math.max(...columnHeights)}px`;
|
||||
}
|
||||
|
||||
// 增强版的懒加载函数
|
||||
function lazyLoadImages() {
|
||||
const lazyImages = document.querySelectorAll('img.lazy');
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
const item = img.closest('.waterfall-item');
|
||||
|
||||
// 标记为正在加载
|
||||
item.classList.add('loading');
|
||||
|
||||
// 创建新的Image对象预加载
|
||||
const tempImg = new Image();
|
||||
tempImg.src = img.dataset.src;
|
||||
tempImg.onload = function () {
|
||||
img.src = img.dataset.src;
|
||||
img.classList.remove('lazy');
|
||||
item.classList.remove('loading');
|
||||
|
||||
// 图片加载完成后重新布局
|
||||
layoutWaterfall();
|
||||
};
|
||||
tempImg.onerror = function () {
|
||||
item.classList.remove('loading');
|
||||
// 可以在这里添加错误处理
|
||||
};
|
||||
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
rootMargin: '200px 0px' // 提前200px开始加载
|
||||
});
|
||||
|
||||
lazyImages.forEach(img => {
|
||||
observer.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化并添加事件监听
|
||||
function initWaterfall() {
|
||||
layoutWaterfall();
|
||||
lazyLoadImages();
|
||||
|
||||
// 使用防抖函数优化resize性能
|
||||
window.addEventListener('resize', debounce(() => {
|
||||
layoutWaterfall();
|
||||
}, 200));
|
||||
|
||||
// 监听DOM变化(如果有动态加载的内容)
|
||||
const observer = new MutationObserver(debounce(() => {
|
||||
layoutWaterfall();
|
||||
lazyLoadImages();
|
||||
}, 100));
|
||||
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initWaterfall);
|
||||
</script>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user