├── .cargo └── config.toml ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── WebAPI.md ├── bd.sh ├── manager ├── Cargo.toml ├── src │ ├── app_path.rs │ ├── builtin_server │ │ └── mod.rs │ ├── config │ │ ├── auth_config.rs │ │ ├── builtin_server_config.rs │ │ ├── core_config.rs │ │ ├── mod.rs │ │ ├── s3_config.rs │ │ ├── web_config.rs │ │ └── webdav_config.rs │ ├── core │ │ ├── archive_tester.rs │ │ ├── data │ │ │ ├── index_file.rs │ │ │ ├── mod.rs │ │ │ ├── version_meta.rs │ │ │ └── version_meta_group.rs │ │ ├── file_hash.rs │ │ ├── mod.rs │ │ ├── rule_filter.rs │ │ ├── tar_reader.rs │ │ └── tar_writer.rs │ ├── diff │ │ ├── abstract_file.rs │ │ ├── diff.rs │ │ ├── disk_file.rs │ │ ├── history_file.rs │ │ └── mod.rs │ ├── main.rs │ ├── task │ │ ├── check.rs │ │ ├── combine.rs │ │ ├── mod.rs │ │ ├── pack.rs │ │ ├── revert.rs │ │ ├── sync.rs │ │ └── test.rs │ ├── upload │ │ ├── file_list_cache.rs │ │ ├── mod.rs │ │ ├── s3.rs │ │ └── webdav.rs │ ├── utility │ │ ├── counted_write.rs │ │ ├── filename_ext.rs │ │ ├── io_utils.rs │ │ ├── mod.rs │ │ ├── partial_read.rs │ │ ├── to_detail_error.rs │ │ ├── traffic_control.rs │ │ └── vec_ext.rs │ └── web │ │ ├── api │ │ ├── fs │ │ │ ├── delete.rs │ │ │ ├── disk_info.rs │ │ │ ├── download.rs │ │ │ ├── extract_file.rs │ │ │ ├── list.rs │ │ │ ├── make_directory.rs │ │ │ ├── mod.rs │ │ │ ├── move.rs │ │ │ ├── sign_file.rs │ │ │ └── upload.rs │ │ ├── misc │ │ │ ├── mod.rs │ │ │ └── version_list.rs │ │ ├── mod.rs │ │ ├── public │ │ │ └── mod.rs │ │ ├── task │ │ │ ├── check.rs │ │ │ ├── combine.rs │ │ │ ├── mod.rs │ │ │ ├── pack.rs │ │ │ ├── revert.rs │ │ │ ├── sync.rs │ │ │ └── test.rs │ │ ├── terminal │ │ │ ├── full.rs │ │ │ ├── mod.rs │ │ │ └── more.rs │ │ ├── user │ │ │ ├── change_password.rs │ │ │ ├── change_username.rs │ │ │ ├── check_token.rs │ │ │ ├── login.rs │ │ │ ├── logout.rs │ │ │ └── mod.rs │ │ └── webpage │ │ │ └── mod.rs │ │ ├── auth_layer.rs │ │ ├── file_status.rs │ │ ├── log.rs │ │ ├── mod.rs │ │ ├── task_executor.rs │ │ └── webstate.rs └── test │ ├── config.toml │ └── user.toml ├── pe_version_info.txt ├── readme.md ├── web ├── .env.development ├── .env.production ├── .github │ └── workflows │ │ └── node.js.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ └── favicon.ico ├── src │ ├── api │ │ ├── fs.js │ │ ├── misc.js │ │ ├── task.js │ │ ├── terminal.js │ │ └── user.js │ ├── assets │ │ └── index.css │ ├── components │ │ ├── FileBreadcrumb │ │ │ └── index.jsx │ │ ├── FolderButtonGroup │ │ │ └── index.jsx │ │ └── TileViewFileExplorer │ │ │ ├── FileItem │ │ │ └── index.jsx │ │ │ ├── index.css │ │ │ └── index.jsx │ ├── main.jsx │ ├── pages │ │ ├── App.jsx │ │ ├── Dashboard │ │ │ ├── Directory │ │ │ │ └── index.jsx │ │ │ ├── Help │ │ │ │ └── index.jsx │ │ │ ├── Log │ │ │ │ └── index.jsx │ │ │ ├── Overview │ │ │ │ └── index.jsx │ │ │ ├── Settings │ │ │ │ └── index.jsx │ │ │ └── index.jsx │ │ ├── Home │ │ │ └── index.jsx │ │ ├── Login │ │ │ └── index.jsx │ │ └── NotFound │ │ │ └── index.jsx │ ├── router │ │ └── index.jsx │ ├── store │ │ ├── index.js │ │ └── modules │ │ │ └── userStore.js │ └── utils │ │ ├── request.js │ │ └── tool.js ├── tailwind.config.js └── vite.config.js └── xtask ├── Cargo.toml └── src └── main.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [alias] 2 | dev = "run --package manager" 3 | ci = "run --package xtask --" -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Manager 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | # on: 8 | # - push 9 | 10 | permissions: 11 | contents: write 12 | pages: read 13 | 14 | env: 15 | CARGO_TERM_COLOR: always 16 | 17 | jobs: 18 | build: 19 | strategy: 20 | matrix: 21 | include: 22 | - os: windows-latest 23 | rustc-target: x86_64-pc-windows-msvc 24 | # - os: ubuntu-latest 25 | # rustc-target: x86_64-unknown-linux-gnu 26 | - os: ubuntu-latest 27 | rustc-target: x86_64-unknown-linux-musl 28 | runs-on: ${{ matrix.os }} 29 | env: 30 | rust-version: '1.83' 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | # - name: Install dependencies for Linux 36 | # if: ${{ runner.os == 'Linux' }} 37 | # run: |- 38 | # sudo apt install libssl-dev 39 | 40 | - name: Install dependencies for Linux musl 41 | if: ${{ runner.os == 'Linux' && contains(matrix.rustc-target, 'musl') }} 42 | run: |- 43 | sudo apt install musl-tools 44 | sudo apt install musl-dev 45 | 46 | - name: Setup Nodejs 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: 22.x 50 | cache: 'npm' 51 | cache-dependency-path: web/package-lock.json 52 | 53 | - name: Build web pages 54 | working-directory: web 55 | run: |- 56 | npm ci 57 | npm run build 58 | 59 | - name: Cargo build 60 | env: 61 | MP_RUSTC_TARGET: ${{ matrix.rustc-target }} 62 | run: |- 63 | rustup update ${{ env.rust-version }} && rustup default ${{ env.rust-version }} 64 | rustup target add ${{ matrix.rustc-target }} 65 | cargo ci manager 66 | 67 | - name: Release 68 | uses: softprops/action-gh-release@v2 69 | with: 70 | files: target/dist/* 71 | 72 | # - name: Distribute 73 | # uses: bxb100/action-upload@main 74 | # with: 75 | # provider: webdav 76 | # provider_options: | 77 | # endpoint=${{ secrets.webdav_endpoint }} 78 | # username=${{ secrets.webdav_username }} 79 | # password=${{ secrets.webdav_password }} 80 | # root=${{ secrets.webdav_root_manager }} 81 | # include: 'target/dist/**' 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /test 3 | .idea -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.linkedProjects": [ 3 | "./manager/Cargo.toml", 4 | "./client/Cargo.toml", 5 | "./shared/Cargo.toml", 6 | "./config_template_derive/Cargo.toml" 7 | ], 8 | "rust-analyzer.checkOnSave": true 9 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "manager", 5 | "xtask" 6 | ] 7 | 8 | [workspace.package] 9 | edition = "2021" 10 | 11 | [profile.release] 12 | debug = "line-directives-only" 13 | split-debuginfo = "packed" 14 | overflow-checks = true 15 | -------------------------------------------------------------------------------- /bd.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cargo build --package manager --target x86_64-unknown-linux-musl -------------------------------------------------------------------------------- /manager/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "manager" 3 | version = "1.0.0" 4 | edition.workspace = true 5 | 6 | [features] 7 | default = [] 8 | bundle-webpage = [] 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | [dependencies] 12 | crc = "3.0.1" 13 | json = "0.12.4" 14 | chrono = {version = "0.4.31", features = ["clock"]} 15 | tokio = { version = "1.36.0", features = ["full"] } 16 | axum = { version = "0.7.7", features = ["multipart", "macros"] } 17 | once_cell = "1.19.0" 18 | regex = "1.10.3" 19 | serde = { version = "1.0", features = ["serde_derive"] } 20 | tar = "0.4.40" 21 | tokio-stream = { version = "0.1.16", features = ["full"] } 22 | tokio-util = { version = "0.7.12", features = ["full"] } 23 | toml = "0.7.3" 24 | tower-http = { version = "0.6.1", features = ["cors"] } 25 | serde_json = "1.0.133" 26 | tower-layer = "0.3.3" 27 | tower-service = "0.3.3" 28 | rand = "0.8.5" 29 | sha2 = "0.10.8" 30 | base16ct = { version = "0.2.0", features = ["alloc"] } 31 | sysinfo = "0.32.0" 32 | base64ct = { version = "1.6.0", features = ["alloc"] } 33 | axum-server = { version = "0.7.1", features = ["tls-rustls-no-provider"] } 34 | urlencoding = "2.1.3" 35 | reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } 36 | reqwest_dav = { version = "0.1.11", default-features = false, features = ["rustls-tls"] } 37 | aws-sdk-s3 = "1.63.0" 38 | include_dir = "0.7.4" 39 | mime_guess = "2.0.5" 40 | clap = { version = "4.4", features = ["derive"] } 41 | 42 | [target.'cfg(target_os = "windows")'.build-dependencies] 43 | embed-resource = "2.4" 44 | 45 | -------------------------------------------------------------------------------- /manager/src/app_path.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::utility::is_running_under_cargo; 4 | 5 | /// 代表各种目录的信息 6 | #[derive(Clone)] 7 | pub struct AppPath { 8 | /// 工作目录 9 | pub working_dir: PathBuf, 10 | 11 | /// 工作空间目录。用来放置要参与更新的文件 12 | pub workspace_dir: PathBuf, 13 | 14 | /// 公共目录。用来存放更新包向外提供服务 15 | pub public_dir: PathBuf, 16 | 17 | /// 外部加载的web目录。当这个目录存在时,会优先从这个目录加载web目录资源,然后是从可执行文件内部 18 | pub web_dir: PathBuf, 19 | 20 | /// 索引文件路径。用来识别当前有哪些更新包 21 | pub index_file: PathBuf, 22 | 23 | /// 配置文件路径。用来存储管理端的配置项目 24 | pub config_file: PathBuf, 25 | 26 | /// 认证数据文件路径。用来存储用户认证等数据 27 | pub auth_file: PathBuf, 28 | } 29 | 30 | impl AppPath { 31 | pub fn new() -> Self { 32 | let mut working_dir = std::env::current_dir().unwrap(); 33 | 34 | // 在开发模式下,会将工作空间移动到test目录下方便测试 35 | if is_running_under_cargo() { 36 | working_dir = working_dir.join("test"); 37 | } 38 | 39 | let workspace_dir = working_dir.join("workspace"); 40 | let public_dir = working_dir.join("public"); 41 | let web_dir = working_dir.join("webpage"); 42 | let index_file = working_dir.join("public/index.json"); 43 | let config_file = working_dir.join("config.toml"); 44 | let auth_file = working_dir.join("user.toml"); 45 | 46 | std::fs::create_dir_all(&workspace_dir).unwrap(); 47 | std::fs::create_dir_all(&public_dir).unwrap(); 48 | 49 | Self { 50 | working_dir, 51 | workspace_dir, 52 | public_dir, 53 | web_dir, 54 | index_file, 55 | config_file, 56 | auth_file, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /manager/src/builtin_server/mod.rs: -------------------------------------------------------------------------------- 1 | //! 运行内置服务端,使用私有协议 2 | use std::future::Future; 3 | use std::io::ErrorKind; 4 | use std::ops::Range; 5 | use std::path::Path; 6 | use std::time::SystemTime; 7 | 8 | use chrono::Local; 9 | use tokio::io::AsyncReadExt; 10 | use tokio::io::AsyncSeekExt; 11 | use tokio::io::AsyncWriteExt; 12 | use tokio::net::TcpListener; 13 | use tokio::net::TcpStream; 14 | 15 | use crate::app_path::AppPath; 16 | use crate::config::Config; 17 | use crate::utility::partial_read::PartialAsyncRead; 18 | use crate::utility::traffic_control::AsyncTrafficControl; 19 | 20 | pub async fn start_builtin_server(config: Config, app_path: AppPath) { 21 | if !config.builtin_server.enabled { 22 | return; 23 | } 24 | 25 | let capacity = config.builtin_server.capacity; 26 | let regain = config.builtin_server.regain; 27 | 28 | if capacity > 0 && regain > 0 { 29 | println!("私有协议服务端已经启动。capacity: {}, regain: {}", capacity, regain); 30 | } else { 31 | println!("私有协议服务端已经启动。"); 32 | } 33 | 34 | let host = config.builtin_server.listen_addr.to_owned(); 35 | let port = format!("{}", config.builtin_server.listen_port); 36 | 37 | println!("private protocol is now listening on {}:{}", host, port); 38 | 39 | let listener = TcpListener::bind(format!("{}:{}", host, port)).await.unwrap(); 40 | 41 | loop { 42 | let (stream, _peer_addr) = listener.accept().await.unwrap(); 43 | 44 | let config = config.clone(); 45 | let app_path = app_path.clone(); 46 | 47 | tokio::spawn(async move { serve_loop(stream, config, app_path).await }); 48 | } 49 | } 50 | 51 | async fn serve_loop(mut stream: TcpStream, config: Config, app_path: AppPath) { 52 | let tbf_burst = config.builtin_server.capacity as u64; 53 | let tbf_rate = config.builtin_server.regain as u64; 54 | let public_dir = app_path.public_dir; 55 | 56 | async fn inner( 57 | mut stream: &mut TcpStream, 58 | tbf_burst: u64, 59 | tbf_rate: u64, 60 | public_dir: &Path, 61 | info: &mut Option<(String, Range)> 62 | ) -> std::io::Result<()> { 63 | // 接收文件路径 64 | let mut path = String::with_capacity(1024); 65 | receive_data(&mut stream).await?.read_to_string(&mut path).await?; 66 | 67 | let start = timeout(stream.read_u64_le()).await?; 68 | let mut end = timeout(stream.read_u64_le()).await?; 69 | 70 | *info = Some((path.to_owned(), start..end)); 71 | 72 | let path = public_dir.join(path); 73 | 74 | assert!(start <= end, "the end is {} and the start is {}", end, start); 75 | 76 | // 检查文件大小 77 | let len = match tokio::fs::metadata(&path).await { 78 | Ok(meta) => { 79 | // 请求的范围不对,返回-2 80 | if end > meta.len() { 81 | stream.write_all(&(-2i64).to_le_bytes()).await?; 82 | return Ok(()); 83 | } 84 | meta.len() 85 | }, 86 | Err(_) => { 87 | // 文件没有找到,返回-1 88 | stream.write_all(&(-1i64).to_le_bytes()).await?; 89 | return Ok(()); 90 | }, 91 | }; 92 | 93 | // 如果不指定范围就发送整个文件 94 | if start == 0 && end == 0 { 95 | end = len as u64; 96 | } 97 | 98 | let mut remains = end - start; 99 | 100 | // 文件已经找到,发送文件大小 101 | stream.write_all(&(remains as i64).to_le_bytes()).await?; 102 | 103 | // 传输文件内容 104 | let mut file = tokio::fs::File::open(path).await?; 105 | file.seek(std::io::SeekFrom::Start(start)).await?; 106 | 107 | // 增加限速效果 108 | let mut file = AsyncTrafficControl::new(&mut file, tbf_burst, tbf_rate); 109 | 110 | while remains > 0 { 111 | let mut buf = [0u8; 32 * 1024]; 112 | let limit = buf.len().min(remains as usize); 113 | let buf = &mut buf[0..limit]; 114 | 115 | let read = file.read(buf).await?; 116 | 117 | stream.write_all(&buf[0..read]).await?; 118 | 119 | remains -= read as u64; 120 | 121 | // tokio::time::sleep(std::time::Duration::from_millis(100)).await; 122 | } 123 | 124 | Ok(()) 125 | } 126 | 127 | loop { 128 | let mut info = Option::<(String, Range)>::None; 129 | 130 | let start = SystemTime::now(); 131 | let result = inner(&mut stream, tbf_burst, tbf_rate, &public_dir, &mut info).await; 132 | let time = SystemTime::now().duration_since(start).unwrap(); 133 | 134 | match result { 135 | Ok(_) => { 136 | // 既然result是ok,那么info一定不是none 137 | let info = info.unwrap(); 138 | let ts = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); 139 | println!("[{}] {} - {} {}+{} ({}ms)", ts, stream.peer_addr().unwrap(), info.0, info.1.start, info.1.end - info.1.start, time.as_millis()); 140 | }, 141 | Err(e) => { 142 | match e.kind() { 143 | ErrorKind::UnexpectedEof => {}, 144 | ErrorKind::ConnectionAborted => {}, 145 | ErrorKind::ConnectionReset => {}, 146 | _ => println!("{} - {:?}", stream.peer_addr().unwrap(), e.kind()), 147 | } 148 | 149 | break; 150 | }, 151 | } 152 | } 153 | } 154 | 155 | async fn _send_data(stream: &mut TcpStream, data: &[u8]) -> std::io::Result<()> { 156 | stream.write_u64_le(data.len() as u64).await?; 157 | stream.write_all(data).await?; 158 | 159 | Ok(()) 160 | } 161 | 162 | async fn receive_data<'a>(stream: &'a mut TcpStream) -> std::io::Result> { 163 | let len = timeout(stream.read_u64_le()).await?; 164 | 165 | Ok(PartialAsyncRead::new(stream, len)) 166 | } 167 | 168 | async fn timeout>>(f: F) -> std::io::Result { 169 | tokio::select! { 170 | _ = tokio::time::sleep(std::time::Duration::from_secs(30)) => { 171 | return Err(std::io::Error::new(ErrorKind::UnexpectedEof, "timeout")); 172 | }, 173 | d = f => { 174 | d 175 | } 176 | } 177 | } -------------------------------------------------------------------------------- /manager/src/config/auth_config.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::sync::Arc; 3 | use std::time::SystemTime; 4 | 5 | use rand::seq::SliceRandom; 6 | use rand::Rng; 7 | use serde::Deserialize; 8 | use serde::Serialize; 9 | use sha2::Digest; 10 | use sha2::Sha256; 11 | use tokio::sync::Mutex; 12 | 13 | use crate::app_path::AppPath; 14 | 15 | /// 用户认证相关配置 16 | #[derive(Clone)] 17 | pub struct AuthConfig { 18 | app_path: AppPath, 19 | inner: Arc>, 20 | } 21 | 22 | impl AuthConfig { 23 | pub async fn load(app_path: AppPath) -> (Self, Option) { 24 | let exist = tokio::fs::try_exists(&app_path.auth_file).await.unwrap(); 25 | 26 | if exist { 27 | let content = std::fs::read_to_string(&app_path.auth_file).unwrap(); 28 | let data = toml::from_str::(&content).unwrap(); 29 | 30 | return (Self { app_path, inner: Arc::new(Mutex::new(data)) }, None); 31 | } 32 | 33 | let password = random_password(); 34 | 35 | let inner = Inner::new(password.clone()); 36 | 37 | let this = Self { app_path, inner: Arc::new(Mutex::new(inner)) }; 38 | 39 | this.save().await; 40 | 41 | return (this, Some(password)); 42 | } 43 | 44 | pub async fn set_username(&mut self, username: &str) { 45 | let mut lock = self.inner.lock().await; 46 | 47 | lock.username = username.to_owned(); 48 | } 49 | 50 | pub async fn set_password(&mut self, password: &str) { 51 | let mut lock = self.inner.lock().await; 52 | 53 | lock.password = hash(password); 54 | } 55 | 56 | pub async fn test_username(&self, username: &str) -> bool { 57 | let lock = self.inner.lock().await; 58 | 59 | lock.username == username 60 | } 61 | 62 | pub async fn test_password(&self, password: &str) -> bool { 63 | let lock = self.inner.lock().await; 64 | 65 | lock.password == hash(password) 66 | } 67 | 68 | pub async fn regen_token(&mut self) -> String { 69 | const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 70 | 71 | let mut lock = self.inner.lock().await; 72 | 73 | let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); 74 | 75 | // 有效期6个小时 76 | lock.expire = now + 6 * 60 * 60; 77 | 78 | let mut rng = rand::rngs::OsRng; 79 | let new_token: String = CHARSET.choose_multiple(&mut rng, 32).map(|e| *e as char).collect(); 80 | 81 | lock.token = hash(&new_token); 82 | 83 | new_token 84 | } 85 | 86 | pub async fn clear_token(&mut self) { 87 | let mut lock = self.inner.lock().await; 88 | 89 | lock.token = "".to_owned(); 90 | } 91 | 92 | pub async fn validate_token(&self, token: &str) -> Result<(), &'static str> { 93 | let lock = self.inner.lock().await; 94 | 95 | let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); 96 | 97 | // 检查token是否存在 98 | if lock.token.is_empty() { 99 | return Err("empty token"); 100 | } 101 | 102 | // 检查token是否有效 103 | if lock.token != hash(token) { 104 | return Err("invalid token"); 105 | } 106 | 107 | // 检查token是否过期 108 | if lock.expire < now { 109 | return Err("token expired"); 110 | } 111 | 112 | Ok(()) 113 | } 114 | 115 | pub async fn save(&self) { 116 | let lock = self.inner.lock().await; 117 | 118 | let content = toml::to_string_pretty(lock.deref()).unwrap(); 119 | 120 | std::fs::write(&self.app_path.auth_file, content).unwrap(); 121 | } 122 | 123 | pub async fn username(&self) -> String { 124 | let lock = self.inner.lock().await; 125 | 126 | lock.username.to_owned() 127 | } 128 | 129 | pub async fn password(&self) -> String { 130 | let lock = self.inner.lock().await; 131 | 132 | lock.password.to_owned() 133 | } 134 | } 135 | 136 | /// 用户认证相关配置 137 | #[derive(Serialize, Deserialize, Clone)] 138 | #[serde(rename_all = "kebab-case")] 139 | pub struct Inner { 140 | /// 用户名 141 | pub username: String, 142 | 143 | /// 密码的hash,计算方法:sha256(password) 144 | pub password: String, 145 | 146 | /// 目前保存的token的hash 147 | pub token: String, 148 | 149 | /// token的到期时间 150 | pub expire: u64, 151 | } 152 | 153 | impl Inner { 154 | fn new(password: String) -> Self { 155 | Self { 156 | username: "admin".to_owned(), 157 | password: hash(&password), 158 | token: "".to_owned(), 159 | expire: 0, 160 | } 161 | } 162 | } 163 | 164 | fn hash(text: &str) -> String { 165 | let hash = Sha256::digest(text); 166 | 167 | base16ct::lower::encode_string(&hash) 168 | } 169 | 170 | /// 生成一串随机的密码 171 | fn random_password() -> String { 172 | const RAND_POOL: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; 173 | let mut rng = rand::thread_rng(); 174 | 175 | let mut password = String::new(); 176 | 177 | for _ in 0..12 { 178 | let value = rng.gen_range(0..RAND_POOL.len()); 179 | 180 | password.push(RAND_POOL[value] as char); 181 | } 182 | 183 | password 184 | } -------------------------------------------------------------------------------- /manager/src/config/builtin_server_config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use serde::Serialize; 3 | 4 | /// 私有协议服务端相关配置 5 | #[derive(Serialize, Deserialize, Clone)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | pub struct BuiltinServerConfig { 8 | /// 是否启动私有协议服务器功能 9 | pub enabled: bool, 10 | 11 | /// 私有协议服务器的监听地址 12 | pub listen_addr: String, 13 | 14 | /// 私有协议服务器的监听端口 15 | pub listen_port: u16, 16 | 17 | /// 内置服务端之限速功能的突发容量,单位为字节,默认为0不开启限速。 18 | /// 如果需要开启可以填写建议值1048576(背后的限速算法为令牌桶) 19 | pub capacity: u32, 20 | 21 | /// 内置服务端之限速功能的每秒回复的令牌数,单位为字节,默认为0不开启限速。 22 | /// 如果需要开启,这里填写需要限制的最大速度即可,比如1048576代表单链接限速1mb/s(背后的限速算法为令牌桶) 23 | pub regain: u32, 24 | } 25 | 26 | impl Default for BuiltinServerConfig { 27 | fn default() -> Self { 28 | Self { 29 | enabled: true, 30 | listen_addr: "0.0.0.0".to_owned(), 31 | listen_port: 6700, 32 | capacity: 0, 33 | regain: 0, 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /manager/src/config/core_config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use serde::Serialize; 3 | 4 | /// 核心功能配置(主要是打包相关) 5 | #[derive(Serialize, Deserialize, Clone, Default)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | pub struct CoreConfig { 8 | /// 要排除的文件规则,格式为正则表达式,暂时不支持Glob表达式 9 | /// 匹配任意一条规则时,文件就会被忽略(忽略:管理端会当这个文件不存在一般) 10 | /// 编写规则时可以使用check命令快速调试是否生效 11 | pub exclude_rules: Vec, 12 | 13 | /// 是否工作在webui模式下,还是在交互式命令行模式下 14 | pub webui_mode: bool, 15 | } -------------------------------------------------------------------------------- /manager/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use serde::Serialize; 3 | 4 | use crate::app_path::AppPath; 5 | use crate::config::builtin_server_config::BuiltinServerConfig; 6 | use crate::config::core_config::CoreConfig; 7 | use crate::config::s3_config::S3Config; 8 | use crate::config::web_config::WebConfig; 9 | use crate::config::webdav_config::WebdavConfig; 10 | 11 | pub mod core_config; 12 | pub mod web_config; 13 | pub mod auth_config; 14 | pub mod builtin_server_config; 15 | pub mod s3_config; 16 | pub mod webdav_config; 17 | 18 | /// 全局配置 19 | #[derive(Serialize, Deserialize, Clone, Default)] 20 | #[serde(default, rename_all = "kebab-case")] 21 | pub struct Config { 22 | /// 核心配置项 23 | pub core: CoreConfig, 24 | 25 | /// web相关配置项 26 | pub web: WebConfig, 27 | 28 | /// 私有协议服务端配置项 29 | pub builtin_server: BuiltinServerConfig, 30 | 31 | /// s3上传相关配置项 32 | pub s3: S3Config, 33 | 34 | /// webdav上传相关配置项 35 | pub webdav: WebdavConfig, 36 | } 37 | 38 | impl Config { 39 | pub async fn load(app_path: &AppPath) -> Self { 40 | let exist = tokio::fs::try_exists(&app_path.config_file).await.unwrap(); 41 | 42 | // 生成默认配置文件 43 | if !exist { 44 | let content = toml::to_string_pretty(&Config::default()).unwrap(); 45 | std::fs::write(&app_path.config_file, content).unwrap(); 46 | } 47 | 48 | // 加载配置文件 49 | let content = tokio::fs::read_to_string(&app_path.config_file).await.unwrap(); 50 | let config = toml::from_str::(&content).unwrap(); 51 | 52 | config 53 | } 54 | } -------------------------------------------------------------------------------- /manager/src/config/s3_config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use serde::Serialize; 3 | 4 | /// s3对象存储上传的配置 5 | #[derive(Serialize, Deserialize, Clone, Default)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | pub struct S3Config { 8 | /// 启用webdav的上传 9 | pub enabled: bool, 10 | 11 | /// 端点地址 12 | pub endpoint: String, 13 | 14 | /// 桶名 15 | pub bucket: String, 16 | 17 | /// 地域 18 | pub region: String, 19 | 20 | /// 认证id 21 | pub access_id: String, 22 | 23 | /// 认证key 24 | pub secret_key: String, 25 | } 26 | -------------------------------------------------------------------------------- /manager/src/config/web_config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use serde::Serialize; 3 | 4 | use crate::utility::is_running_under_cargo; 5 | 6 | /// web相关功能配置 7 | #[derive(Serialize, Deserialize, Clone)] 8 | #[serde(default, rename_all = "kebab-case")] 9 | pub struct WebConfig { 10 | /// webui的监听地址 11 | pub listen_addr: String, 12 | 13 | /// webui的监听端口 14 | pub listen_port: u16, 15 | 16 | /// https的证书文件 17 | pub tls_cert_file: String, 18 | 19 | /// https的私钥文件 20 | pub tls_key_file: String, 21 | 22 | /// 控制`Access-Control-Allow-Credentials`的值 23 | pub cors_allow_credentials: bool, 24 | 25 | /// 控制`Access-Control-Allow-Headers`的值 26 | pub cors_allow_headers: Vec, 27 | 28 | /// 控制`Access-Control-Allow-Methods`的值 29 | pub cors_allow_methods: Vec, 30 | 31 | /// 控制`Access-Control-Allow-Origin`的值 32 | pub cors_allow_origin: Vec, 33 | 34 | /// 控制`Access-Control-Allow-Private-Network`的值 35 | pub cors_allow_private_network: bool, 36 | 37 | /// 控制`Access-Control-Expose-Headers`的值 38 | pub cors_expose_headers: Vec, 39 | 40 | /// 首页的文件名。用来在访问根目录时展示给用户的页面 41 | pub index_filename: String, 42 | 43 | /// 遇到文件404时,重定向到哪个文件。主要用于支持前端的SinglePageApplication特性 44 | pub redirect_404: String, 45 | } 46 | 47 | impl Default for WebConfig { 48 | fn default() -> Self { 49 | if is_running_under_cargo() { 50 | Self { 51 | listen_addr: "0.0.0.0".to_owned(), 52 | listen_port: 6710, 53 | tls_cert_file: "".to_owned(), 54 | tls_key_file: "".to_owned(), 55 | cors_allow_credentials: false, 56 | cors_allow_headers: vec!["*".to_owned()], 57 | cors_allow_methods: vec!["*".to_owned()], 58 | cors_allow_origin: vec!["*".to_owned()], 59 | cors_allow_private_network: false, 60 | cors_expose_headers: vec!["*".to_owned()], 61 | index_filename: "index.html".to_owned(), 62 | redirect_404: "index.html".to_owned(), 63 | } 64 | } else { 65 | Self { 66 | listen_addr: "0.0.0.0".to_owned(), 67 | listen_port: 6710, 68 | tls_cert_file: "".to_owned(), 69 | tls_key_file: "".to_owned(), 70 | cors_allow_credentials: false, 71 | cors_allow_headers: vec![], 72 | cors_allow_methods: vec![], 73 | cors_allow_origin: vec![], 74 | cors_allow_private_network: false, 75 | cors_expose_headers: vec![], 76 | index_filename: "index.html".to_owned(), 77 | redirect_404: "index.html".to_owned(), 78 | } 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /manager/src/config/webdav_config.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | use serde::Serialize; 3 | 4 | /// webdav上传的配置 5 | #[derive(Serialize, Deserialize, Clone, Default)] 6 | #[serde(default, rename_all = "kebab-case")] 7 | pub struct WebdavConfig { 8 | /// 启用webdav的上传 9 | pub enabled: bool, 10 | 11 | /// 主机部分 12 | pub host: String, 13 | 14 | /// 用户名 15 | pub username: String, 16 | 17 | /// 密码 18 | pub password: String, 19 | } 20 | -------------------------------------------------------------------------------- /manager/src/core/archive_tester.rs: -------------------------------------------------------------------------------- 1 | //! 更新包解压测试 2 | 3 | use std::collections::HashMap; 4 | use std::fmt::Debug; 5 | use std::ops::Deref; 6 | use std::path::Path; 7 | use std::path::PathBuf; 8 | 9 | use crate::core::data::version_meta::FileChange; 10 | use crate::core::file_hash::calculate_hash; 11 | use crate::core::tar_reader::TarReader; 12 | use crate::diff::abstract_file::AbstractFile; 13 | use crate::diff::diff::Diff; 14 | use crate::diff::history_file::HistoryFile; 15 | 16 | pub struct ArchiveTester { 17 | /// key: 文件路径,value: (更新包路径, 偏移值, 长度, 版本号) 18 | file_locations: HashMap, 19 | 20 | /// 当前文件状态 21 | history: HistoryFile, 22 | 23 | finished: bool, 24 | } 25 | 26 | impl ArchiveTester { 27 | pub fn new() -> Self { 28 | Self { 29 | file_locations: HashMap::new(), 30 | history: HistoryFile::new_empty(), 31 | finished: false, 32 | } 33 | } 34 | 35 | /// 添加一个待测文件 36 | pub fn feed(&mut self, archive: impl AsRef, meta_offset: u64, meta_len: u32) { 37 | let mut reader = TarReader::new(&archive); 38 | let meta_group = reader.read_metadata_group(meta_offset, meta_len); 39 | 40 | for meta in &meta_group { 41 | self.history.replay_operations(&meta); 42 | 43 | // 记录所有文件的数据和来源 44 | for change in &meta.changes { 45 | match change { 46 | FileChange::UpdateFile { path, offset, len, .. } => { 47 | let tuple = (archive.as_ref().to_owned(), *offset, *len, meta.label.to_owned()); 48 | self.file_locations.insert(path.to_owned(), tuple); 49 | }, 50 | FileChange::DeleteFile { path } => { 51 | self.file_locations.remove(path); 52 | }, 53 | FileChange::MoveFile { from, to } => { 54 | let hold = self.file_locations.remove(from).unwrap(); 55 | self.file_locations.insert(to.to_owned(), hold); 56 | } 57 | _ => (), 58 | } 59 | } 60 | } 61 | } 62 | 63 | /// 开始测试 64 | pub fn finish ()>(mut self, mut f: F) -> Result<(), Failure> { 65 | self.finished = true; 66 | 67 | let empty = HistoryFile::new_empty(); 68 | let diff = Diff::diff(&self.history, &empty, None); 69 | 70 | let mut vec = Vec::<&HistoryFile>::new(); 71 | 72 | for f in &diff.added_files { 73 | vec.push(f); 74 | } 75 | 76 | for f in &diff.modified_files { 77 | vec.push(f); 78 | } 79 | 80 | let total = vec.len(); 81 | 82 | for (index, up) in vec.iter().enumerate() { 83 | let path = up.path(); 84 | let path = path.deref(); 85 | let (archive, offset, len, label) = self.file_locations.get(path).unwrap(); 86 | 87 | // println!("{index}/{total} 正在测试 {label} 的 {path} ({offset}+{len})"); 88 | f(Testing { index, total, label, path, offset: *offset, len: *len }); 89 | 90 | let mut reader = TarReader::new(&archive); 91 | let mut open = reader.open_file(*offset, *len); 92 | let actual = calculate_hash(&mut open); 93 | let expected = up.hash(); 94 | let expected = expected.deref(); 95 | 96 | if &actual != expected { 97 | return Err(Failure { 98 | path: path.to_owned(), 99 | label: label.to_owned(), 100 | actual, 101 | expected: expected.to_owned(), 102 | }); 103 | } 104 | 105 | // assert!( 106 | // &actual == expected, 107 | // "文件哈希不匹配!文件路径: {}, 版本: {} 实际: {}, 预期: {}, 偏移: 0x{offset:x}, 长度: {len}", 108 | // path, label, actual, expected 109 | // ); 110 | } 111 | 112 | Ok(()) 113 | } 114 | } 115 | 116 | impl Drop for ArchiveTester { 117 | fn drop(&mut self) { 118 | assert!(self.finished, "ArchiveTester is not finished yet!") 119 | } 120 | } 121 | 122 | /// 代表测试过程中的日志 123 | #[derive(Debug)] 124 | pub struct Testing<'a> { 125 | /// 当前正在测试第几个文件 126 | pub index: usize, 127 | 128 | /// 一共有多少个文件 129 | pub total: usize, 130 | 131 | /// 正在测试的文件所属的版本标签 132 | pub label: &'a str, 133 | 134 | /// 正在测试的文件在更新包里的相对路径 135 | pub path: &'a str, 136 | 137 | /// 正在测试的文件在更新包里的偏移地址 138 | pub offset: u64, 139 | 140 | /// 正在测试的文件在更新包里的大小 141 | pub len: u64, 142 | } 143 | 144 | /// 代表一个更新包测试的失败结果 145 | #[derive(Debug)] 146 | pub struct Failure { 147 | /// 失败的文件的路径 148 | pub path: String, 149 | 150 | /// 失败的文件所属的版本标签 151 | pub label: String, 152 | 153 | /// 实际的校验值 154 | pub actual: String, 155 | 156 | /// 预期的校验值 157 | pub expected: String, 158 | } 159 | 160 | 161 | -------------------------------------------------------------------------------- /manager/src/core/data/index_file.rs: -------------------------------------------------------------------------------- 1 | //! 版本索引 2 | 3 | use std::ops::Index; 4 | use std::path::Path; 5 | 6 | use json::JsonValue; 7 | 8 | /// 代表一个版本的索引信息 9 | /// 10 | /// 保存时会被序列化成一个Json对象 11 | /// 12 | /// ```json 13 | /// { 14 | /// "label": "1.2", 15 | /// "file": "1.2.tar", 16 | /// "offset": 7A9C, 17 | /// "length": 1000, 18 | /// "hash": "23B87EA52C893" 19 | /// } 20 | /// ``` 21 | #[derive(Clone)] 22 | pub struct VersionIndex { 23 | /// 版本号 24 | pub label: String, 25 | 26 | /// 版本的数据存在哪个文件里 27 | pub filename: String, 28 | 29 | /// 元数据组的偏移值 30 | pub offset: u64, 31 | 32 | /// 元数据组的长度 33 | pub len: u32, 34 | 35 | /// 整个tar包文件的校验 36 | pub hash: String, 37 | } 38 | 39 | /// 代表一个索引文件 40 | pub struct IndexFile { 41 | versions: Vec 42 | } 43 | 44 | impl IndexFile { 45 | /// 创建一个IndexFile 46 | pub fn new() -> Self { 47 | Self { versions: Vec::new() } 48 | } 49 | 50 | /// 从文件加载索引文件 51 | pub fn load_from_file(index_file: &Path) -> Self { 52 | let content = std::fs::read_to_string(index_file) 53 | .unwrap_or_else(|_| "[]".to_owned()); 54 | 55 | Self::load_from_json(&content) 56 | } 57 | 58 | /// 从Json字符串加载 59 | pub fn load_from_json(json: &str) -> Self { 60 | let root = json::parse(json).unwrap(); 61 | let mut versions = Vec::::new(); 62 | 63 | for v in root.members() { 64 | let label = v["label"].as_str().unwrap().to_owned(); 65 | let filename = v["filename"].as_str().unwrap().to_owned(); 66 | let offset = v["offset"].as_u64().unwrap(); 67 | let len = v["length"].as_u32().unwrap(); 68 | let hash = v["hash"].as_str().unwrap().to_owned(); 69 | 70 | versions.push(VersionIndex { label, filename, len, offset, hash }) 71 | } 72 | 73 | Self { versions } 74 | } 75 | 76 | /// 将索引数据写到`index_file`文件里 77 | pub fn save(&self, index_file: &Path) { 78 | let mut root = JsonValue::new_array(); 79 | 80 | for v in &self.versions { 81 | let mut obj = JsonValue::new_object(); 82 | 83 | obj.insert("label", v.label.to_owned()).unwrap(); 84 | obj.insert("filename", v.filename.to_owned()).unwrap(); 85 | obj.insert("offset", v.offset).unwrap(); 86 | obj.insert("length", v.len).unwrap(); 87 | obj.insert("hash", v.hash.to_owned()).unwrap(); 88 | 89 | root.push(obj).unwrap(); 90 | } 91 | 92 | std::fs::write(index_file, root.pretty(4)).unwrap() 93 | } 94 | 95 | /// 添加一个新版本 96 | pub fn add(&mut self, version: VersionIndex) { 97 | self.versions.push(version); 98 | } 99 | 100 | /// 检查是否包含指定的版本号 101 | pub fn contains(&self, label: &str) -> bool { 102 | self.versions.iter().any(|e| e.label == label) 103 | } 104 | 105 | /// 查找一个版本的索引数据 106 | pub fn find(&self, label: &str) -> Option<&VersionIndex> { 107 | self.versions.iter().find(|e| e.label == label) 108 | } 109 | 110 | /// 查找一个版本的可变索引数据 111 | pub fn find_mut(&mut self, label: &str) -> Option<&mut VersionIndex> { 112 | self.versions.iter_mut().find(|e| e.label == label) 113 | } 114 | 115 | /// 版本的数量 116 | pub fn len(&self) -> usize { 117 | self.versions.len() 118 | } 119 | } 120 | 121 | impl Index for IndexFile { 122 | type Output = VersionIndex; 123 | 124 | fn index(&self, index: usize) -> &Self::Output { 125 | &self.versions[index] 126 | } 127 | } 128 | 129 | impl<'a> IntoIterator for &'a IndexFile { 130 | type Item = &'a VersionIndex; 131 | 132 | type IntoIter = std::slice::Iter<'a, VersionIndex>; 133 | 134 | fn into_iter(self) -> Self::IntoIter { 135 | (&self.versions).into_iter() 136 | } 137 | } 138 | 139 | impl IntoIterator for IndexFile { 140 | type Item = VersionIndex; 141 | 142 | type IntoIter = std::vec::IntoIter; 143 | 144 | fn into_iter(self) -> Self::IntoIter { 145 | self.versions.into_iter() 146 | } 147 | } -------------------------------------------------------------------------------- /manager/src/core/data/mod.rs: -------------------------------------------------------------------------------- 1 | //! 数据存储(版本索引和版本元数据) 2 | //! 3 | //! ### 管理端文件存储结构 4 | //! 5 | //! 1. public:存放更新包和索引文件的地方 6 | //! 2. workspace:日常维护要更新的文件的地方 7 | //! 5. config.toml:管理端的配置文件 8 | //! 9 | //! ### public目录下的文件 10 | //! 11 | //! public目录负责存储所有更新包文件,索引文件这些供大家下载的公共文件 12 | //! 13 | //! 1. index.txt:索引文件,也叫版本号列表文件,会存储每个版本的元数据的信息 14 | //! 2. index.internal.txt:同上,但是用于支持灰度发布 15 | //! 3. combined.tar:合并包,所有合并后的更新包内容都会放到这个文件里,名字固定叫combined.tar 16 | //! 4. 1.0.tar:用户创建的1.0版本更新包 17 | //! 5. 1.1.tar:用户创建的1.1版本更新包 18 | //! 6. 还有更多用户创建的更新包... 19 | //! 20 | //! 合并包文件和普通用户创建更新包文件是个容器,一个文件里面可以容纳多个版本的数据。 21 | //! 一般情况下,合并包会装多个版本的数据,而普通包只装一个版本的数据。 22 | //! 在合并更新包时,所有的普通包内的内容会被全部挪动到合并包里面去 23 | 24 | pub mod version_meta; 25 | pub mod index_file; 26 | pub mod version_meta_group; 27 | -------------------------------------------------------------------------------- /manager/src/core/data/version_meta_group.rs: -------------------------------------------------------------------------------- 1 | //! 版本元数据组 2 | //! 3 | //! 版本元数据组代表一个元数据列表。在存储时会序列化成一个Json列表 4 | //! 5 | //! 元数据列表因为是列表,可以存储多个元数据。但实际中只有合并包里才会存储多个版本,普通包中只存一个 6 | //! 7 | //! ```json 8 | //! [ 9 | //! { 10 | //! "label": "1.0", // 版本号 11 | //! "logs": "这是这个版本的更新记录文字示例", // 这个版本的更新日志 12 | //! "changes": [] // 记录所有文件修改操作 13 | //! }, 14 | //! { 15 | //! "label": "1.2", // 版本号 16 | //! "logs": "这是1.0版本的更新记录文字示例", // 这个版本的更新日志 17 | //! "changes": [] // 记录所有文件修改操作 18 | //! }, 19 | //! { 20 | //! "label": "1.3", // 版本号 21 | //! "logs": "这是1.1版本的更新记录文字示例", // 这个版本的更新日志 22 | //! "changes": [] // 记录所有文件修改操作 23 | //! } 24 | //! ] 25 | //! ``` 26 | 27 | use json::JsonValue; 28 | 29 | use crate::core::data::version_meta::VersionMeta; 30 | use crate::utility::vec_ext::VecRemoveIf; 31 | 32 | /// 代表一组版本元数据,每个更新包tar文件都能容纳多个版本的元数据,也叫一组 33 | pub struct VersionMetaGroup(pub Vec); 34 | 35 | impl VersionMetaGroup { 36 | /// 创建一个组空的元数据 37 | pub fn new() -> Self { 38 | Self(Vec::new()) 39 | } 40 | 41 | /// 创建单个元数据组 42 | pub fn with_one(meta: VersionMeta) -> Self { 43 | Self([meta].into()) 44 | } 45 | 46 | /// 从json字符串进行解析 47 | pub fn parse(meta: &str) -> Self { 48 | let root = json::parse(meta).unwrap(); 49 | 50 | VersionMetaGroup(root.members().map(|e| VersionMeta::load(e)).collect()) 51 | } 52 | 53 | /// 将元数据组序列化成json字符串 54 | pub fn serialize(&self) -> String { 55 | let mut obj = JsonValue::new_array(); 56 | 57 | for v in &self.0 { 58 | obj.push(v.serialize()).unwrap(); 59 | } 60 | 61 | obj.pretty(4) 62 | } 63 | 64 | // /// 添加一个元数据组 65 | // pub fn add_group(&mut self, group: VersionMetaGroup) { 66 | // for meta in group.0 { 67 | // if self.contains_meta(&meta.label) { 68 | // continue; 69 | // } 70 | 71 | // self.add_meta(meta); 72 | // } 73 | // } 74 | 75 | /// 添加单个元数据 76 | pub fn add_meta(&mut self, meta: VersionMeta) { 77 | assert!(!self.contains_meta(&meta.label)); 78 | 79 | self.0.push(meta); 80 | } 81 | 82 | /// 删除一个元数据 83 | pub fn remove_meta(&mut self, label: &str) -> bool { 84 | self.0.remove_if(|e| e.label == label) 85 | } 86 | 87 | /// 检查是否包括一个元数据 88 | pub fn contains_meta(&self, label: &str) -> bool { 89 | self.0.iter().any(|e| e.label == label) 90 | } 91 | 92 | /// 查找一个元数据 93 | pub fn find_meta(&self, label: &str) -> Option<&VersionMeta> { 94 | self.0.iter().find(|e| e.label == label) 95 | } 96 | } 97 | 98 | impl IntoIterator for VersionMetaGroup { 99 | type Item = VersionMeta; 100 | 101 | type IntoIter = std::vec::IntoIter; 102 | 103 | fn into_iter(self) -> Self::IntoIter { 104 | self.0.into_iter() 105 | } 106 | } 107 | 108 | impl<'a> IntoIterator for &'a VersionMetaGroup { 109 | type Item = &'a VersionMeta; 110 | 111 | type IntoIter = std::slice::Iter<'a, VersionMeta>; 112 | 113 | fn into_iter(self) -> Self::IntoIter { 114 | (&self.0).into_iter() 115 | } 116 | } 117 | 118 | impl<'a> IntoIterator for &'a mut VersionMetaGroup { 119 | type Item = &'a mut VersionMeta; 120 | 121 | type IntoIter = std::slice::IterMut<'a, VersionMeta>; 122 | 123 | fn into_iter(self) -> Self::IntoIter { 124 | (&mut self.0).into_iter() 125 | } 126 | } -------------------------------------------------------------------------------- /manager/src/core/file_hash.rs: -------------------------------------------------------------------------------- 1 | //! 计算文件哈希相关操作 2 | 3 | use std::io::Read; 4 | 5 | use crc::Crc; 6 | use crc::CRC_16_IBM_SDLC; 7 | use crc::CRC_64_XZ; 8 | use tokio::io::AsyncRead; 9 | use tokio::io::AsyncReadExt; 10 | 11 | /// 计算文件哈希值 12 | pub fn calculate_hash(read: &mut impl Read) -> String { 13 | // 所有计算文件哈希值时都会调用此函数,可以在此函数中替换任意哈希算法 14 | 15 | let crc64 = Crc::::new(&CRC_64_XZ); 16 | let mut crc64 = crc64.digest(); 17 | 18 | let crc16 = Crc::::new(&CRC_16_IBM_SDLC); 19 | let mut crc16 = crc16.digest(); 20 | 21 | let mut buffer = [0u8; 16 * 1024]; 22 | 23 | loop { 24 | let count = read.read(&mut buffer).unwrap(); 25 | 26 | if count == 0 { 27 | break; 28 | } 29 | 30 | crc64.update(&buffer[0..count]); 31 | crc16.update(&buffer[0..count]); 32 | } 33 | 34 | format!("{:016x}_{:04x}", &crc64.finalize(), crc16.finalize()) 35 | } 36 | 37 | /// 计算文件哈希值 38 | pub async fn calculate_hash_async(read: &mut (impl AsyncRead + Unpin)) -> String { 39 | // 所有计算文件哈希值时都会调用此函数,可以在此函数中替换任意哈希算法 40 | 41 | let crc64 = Crc::::new(&CRC_64_XZ); 42 | let mut crc64 = crc64.digest(); 43 | 44 | let crc16 = Crc::::new(&CRC_16_IBM_SDLC); 45 | let mut crc16 = crc16.digest(); 46 | 47 | let mut buffer = [0u8; 16 * 1024]; 48 | 49 | tokio::pin!(read); 50 | 51 | loop { 52 | let count = read.read(&mut buffer).await.unwrap(); 53 | 54 | if count == 0 { 55 | break; 56 | } 57 | 58 | crc64.update(&buffer[0..count]); 59 | crc16.update(&buffer[0..count]); 60 | } 61 | 62 | format!("{:016x}_{:04x}", &crc64.finalize(), crc16.finalize()) 63 | } -------------------------------------------------------------------------------- /manager/src/core/mod.rs: -------------------------------------------------------------------------------- 1 | //! 更新包读写以及其它核心功能 2 | //! 3 | //! ### 为什么使用tar格式 4 | //! 5 | //! 更新包的存储格式是tar格式,但实际上程序解压或者读取里面的文件时并不会去读取tar的条目头。 6 | //! 而是从元数据里拿到数据的偏移值直接读取文件数据。即使条目头受损也不会影响读取 7 | //! 8 | //! 之所以没有用自定义的二进制格式来代替tar,是因为tar格式调试起来比较容易。 9 | //! 大部分压缩软件可以直接打开,如果是自定义二进制格式则必须要用winhex之类的软件。 10 | //! 所以出现问题时可以很方便地用压缩软件读取压缩包里面的文件,对排查问题很有帮助 11 | //! 12 | //! 另外tar包里的文件顺序不可以打乱,也就是一旦创建好就再也不能修改, 13 | //! 不然会出现读取错误 14 | 15 | pub mod tar_reader; 16 | pub mod tar_writer; 17 | pub mod archive_tester; 18 | pub mod rule_filter; 19 | pub mod data; 20 | pub mod file_hash; 21 | -------------------------------------------------------------------------------- /manager/src/core/rule_filter.rs: -------------------------------------------------------------------------------- 1 | //! 规则过滤 2 | 3 | use regex::Regex; 4 | 5 | /// 代表单个过滤规则 6 | pub struct Rule { 7 | /// 原始字符串,主要是调试输出用途 8 | pub raw: String, 9 | 10 | /// 编译后的规则对象 11 | pub pattern: Regex, 12 | } 13 | 14 | /// 代表一组过滤规则 15 | pub struct RuleFilter { 16 | pub filters: Vec 17 | } 18 | 19 | impl RuleFilter { 20 | /// 创建一个空的规则过滤器,空的规则过滤器会通过任何测试的文件 21 | pub fn new() -> Self { 22 | Self::from_rules([""; 0].iter()) 23 | } 24 | 25 | /// 从一个字符串引用迭代器创建规则过滤器 26 | pub fn from_rules<'a>(rules: impl Iterator>) -> RuleFilter { 27 | let mut regexes_compiled = Vec::::new(); 28 | 29 | for pattern in rules { 30 | let raw_pattern = pattern.as_ref(); 31 | let pattern = raw_pattern; 32 | regexes_compiled.push(Rule { 33 | raw: raw_pattern.to_owned(), 34 | pattern: Regex::new(&pattern).unwrap(), 35 | }); 36 | } 37 | 38 | RuleFilter { filters: regexes_compiled } 39 | } 40 | 41 | /// 测试一段字符串能否通过任何一个规则测试,如果不能通过或者规则列表为空,返回`default`。 42 | pub fn test_any(&self, text: &str, default: bool) -> bool { 43 | if self.filters.is_empty() { 44 | return default; 45 | } 46 | 47 | self.filters.iter().any(|filter| filter.pattern.is_match(text)) 48 | } 49 | 50 | /// 测试一段字符串能否通过所有的规则测试,如果不能通过或者规则列表为空,返回`default`。 51 | pub fn test_all(&self, text: &str, default: bool) -> bool { 52 | if self.filters.is_empty() { 53 | return default; 54 | } 55 | 56 | self.filters.iter().all(|filter| filter.pattern.is_match(text)) 57 | } 58 | } -------------------------------------------------------------------------------- /manager/src/core/tar_reader.rs: -------------------------------------------------------------------------------- 1 | //! 读取更新包 2 | 3 | use std::io::Read; 4 | use std::io::Seek; 5 | use std::io::SeekFrom; 6 | use std::path::Path; 7 | 8 | use crate::core::data::version_meta_group::VersionMetaGroup; 9 | use crate::utility::partial_read::PartialRead; 10 | 11 | /// 代表一个更新包读取器,用于读取tar格式的更新包里面的数据 12 | pub struct TarReader { 13 | open: std::fs::File 14 | } 15 | 16 | impl TarReader { 17 | /// 创建一个TarReader,从`file`读取数据 18 | pub fn new(file: impl AsRef) -> Self { 19 | Self { open: std::fs::File::open(file).unwrap() } 20 | } 21 | 22 | /// 读取更新包中的元数据,需要提供元数据的`offset`和`len`以便定位 23 | pub fn read_metadata_group(&mut self, offset: u64, len: u32) -> VersionMetaGroup { 24 | let mut buf = Vec::::new(); 25 | buf.resize(len as usize, 0); 26 | 27 | self.open.seek(SeekFrom::Start(offset)).unwrap(); 28 | self.open.read_exact(&mut buf).unwrap(); 29 | 30 | VersionMetaGroup::parse(std::str::from_utf8(&buf).unwrap()) 31 | } 32 | 33 | /// 读取更新包中的一个文件数据,需要提供文件的`offset`和`len`以便定位 34 | pub fn open_file(&mut self, offset: u64, len: u64) -> PartialRead<&mut std::fs::File> { 35 | self.open.seek(SeekFrom::Start(offset)).unwrap(); 36 | 37 | PartialRead::new(&mut self.open, len) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /manager/src/core/tar_writer.rs: -------------------------------------------------------------------------------- 1 | //! 写入更新包 2 | 3 | use std::collections::HashMap; 4 | use std::io::Read; 5 | use std::path::Path; 6 | 7 | use crate::core::data::version_meta::FileChange; 8 | use crate::core::data::version_meta_group::VersionMetaGroup; 9 | use crate::utility::counted_write::CountedWrite; 10 | use crate::utility::partial_read::PartialRead; 11 | 12 | pub struct MetadataLocation { 13 | pub offset: u64, 14 | pub length: u32, 15 | } 16 | 17 | /// 代表一个更新包写入器,用于生成tar格式的更新包 18 | pub struct TarWriter { 19 | builder: tar::Builder>, 20 | addresses: HashMap, 21 | finished: bool, 22 | } 23 | 24 | impl TarWriter { 25 | /// 创建一个TarWriter,并将数据写到`file`文件中 26 | pub fn new(file: impl AsRef) -> Self { 27 | let open = std::fs::File::options().create(true).truncate(true).write(true).open(file).unwrap(); 28 | 29 | Self { 30 | builder: tar::Builder::new(CountedWrite::new(open)), 31 | addresses: HashMap::new(), 32 | finished: false, 33 | } 34 | } 35 | 36 | /// 往更新包里添加一个文件,除了数据和长度以外,还需要额外提供文件路径和所属版本号 37 | pub fn add_file(&mut self, mut data: impl Read, len: u64, path: &str, version: &str) { 38 | assert!(!self.finished, "TarWriter has already closed"); 39 | 40 | // 写入更新包中 41 | let mut header = tar::Header::new_gnu(); 42 | header.set_size(len); 43 | 44 | let partial_read = PartialRead::new(&mut data, len); 45 | self.builder.append_data(&mut header, path, partial_read).unwrap(); 46 | 47 | let mut padding = 512 - (len % 512); 48 | 49 | if padding >= 512 { 50 | padding = 0; 51 | } 52 | 53 | let position = self.builder.get_ref().count(); 54 | 55 | // println!(">>> {}: {}, {}, padding: {}", path, len, ptr, padding); 56 | 57 | // 记录当前数据偏移位置 58 | let key = format!("{}_{}", path, version); 59 | let tar_offset = position - len - padding; 60 | 61 | self.addresses.insert(key, tar_offset); 62 | } 63 | 64 | /// 完成更新包的创建,并返回元数据的偏移值和长度 65 | pub fn finish(mut self, mut meta_group: VersionMetaGroup) -> MetadataLocation { 66 | assert!(!self.finished, "TarWriter has already closed"); 67 | 68 | // 更新元数据中的偏移值 69 | for meta in &mut meta_group { 70 | for change in meta.changes.iter_mut() { 71 | if let FileChange::UpdateFile { path, offset, .. } = change { 72 | // 合并文件时,中间版本里的文件数据为了节省空间,是不存储的 73 | // 也就是说即使这些元数据里有offset,len这些数据,但这些数据都是无效的 74 | // 正常情况下客户端也不会去这个数据,如果读取了那么必定是数据受损了 75 | let key = format!("{}_{}", path, &meta.label); 76 | match self.addresses.get(&key) { 77 | Some(addr) => *offset = *addr, 78 | None => (), 79 | } 80 | } 81 | } 82 | } 83 | 84 | // 序列化元数据组 85 | let metadata_offset = self.builder.get_ref().count(); 86 | let file_content = meta_group.serialize(); 87 | let file_content = file_content.as_bytes(); 88 | 89 | // 写入元数据 90 | let mut header = tar::Header::new_gnu(); 91 | header.set_size(file_content.len() as u64); 92 | self.builder.append_data(&mut header, "metadata.txt", std::io::Cursor::new(&file_content)).unwrap(); 93 | 94 | // 写入完毕 95 | self.builder.finish().unwrap(); 96 | self.finished = true; 97 | 98 | MetadataLocation { 99 | offset: metadata_offset + 512, 100 | length: file_content.len() as u32, 101 | } 102 | } 103 | } 104 | 105 | impl Drop for TarWriter { 106 | fn drop(&mut self) { 107 | assert!(self.finished, "TarWriter has not closed yet"); 108 | } 109 | } -------------------------------------------------------------------------------- /manager/src/diff/abstract_file.rs: -------------------------------------------------------------------------------- 1 | //! 抽象文件 2 | //! 3 | //! [`AbstractFile`]是对[`HistoryFile`]和[`DiskFile`]的公共抽象。 4 | //! 可以让[`Diff`]类在不知道具体类型的情况下,对比文件差异。 5 | //! 同时提供了一些辅助函数来帮助实现[`AbstractFile`] 6 | //! 7 | //! [`AbstractFile`]继承自[`Clone`],所以建议具体实现类型使用[`Rc`]或者[`Arc`] 8 | //! 将实际数据包装一下,以支持低成本clone操作 9 | //! 10 | //! [`HistoryFile`]: super::history_file::HistoryFile 11 | //! [`DiskFile`]: super::disk_file::DiskFile 12 | //! [`Diff`]: super::diff::Diff 13 | //! [`Rc`]: std::rc::Rc 14 | //! [`Arc`]: std::sync::Arc 15 | 16 | use std::collections::LinkedList; 17 | use std::ops::Deref; 18 | use std::time::SystemTime; 19 | 20 | /// 从借用返回迭代器 21 | pub trait BorrowIntoIterator { 22 | type Item; 23 | 24 | fn iter(&self) -> impl Iterator; 25 | } 26 | 27 | /// 代表一个抽象的文件,提供一些文件的基本接口 28 | pub trait AbstractFile : Clone { 29 | /// 获取父文件 30 | fn parent(&self) -> Option; 31 | 32 | /// 获取文件名 33 | fn name(&self) -> impl Deref; 34 | 35 | /// 获取哈希值 36 | fn hash(&self) -> impl Deref; 37 | 38 | /// 获取文件长度 39 | fn len(&self) -> u64; 40 | 41 | /// 获取文件修改时间 42 | fn modified(&self) -> SystemTime; 43 | 44 | /// 是不是一个目录 45 | fn is_dir(&self) -> bool; 46 | 47 | /// 获取文件的相对路径 48 | fn path(&self) -> impl Deref; 49 | 50 | /// 获取子文件列表 51 | fn files(&self) -> impl BorrowIntoIterator; 52 | 53 | /// 搜索一个子文件,支持多层级搜索 54 | fn find(&self, path: &str) -> Option; 55 | } 56 | 57 | /// 查找文件的辅助函数,实现了大部分查找逻辑,可以很方便地直接使用 58 | pub fn find_file_helper(parent: &T, path: &str) -> Option { 59 | assert!(parent.is_dir()); 60 | assert!(!path.contains("\\")); 61 | 62 | let mut result = parent.to_owned(); 63 | 64 | for frag in path.split("/") { 65 | let found = result.files().iter().find(|f| f.name().deref() == frag); 66 | 67 | match found { 68 | Some(found) => result = found, 69 | None => return None, 70 | } 71 | } 72 | 73 | return Some(result); 74 | } 75 | 76 | /// 计算相对路径的辅助函数,实现了大部分计算路径的逻辑,可以很方便地直接使用。 77 | /// 78 | /// 但顶层目录的文件名不会被计算到结果中 79 | pub fn calculate_path_helper(name: &str, parent: Option<&impl AbstractFile>) -> String { 80 | match parent { 81 | Some(parent) => { 82 | let parent_path = parent.path(); 83 | let parent_path = parent_path.deref(); 84 | 85 | if parent_path.starts_with(":") { 86 | name.to_owned() 87 | } else { 88 | format!("{}/{}", parent_path, name) 89 | } 90 | }, 91 | None => format!(":{}:", name), 92 | } 93 | } 94 | 95 | /// 将抽象文件转换为调试字符串的辅助函数,可以输出很多有用的调试信息 96 | pub fn abstract_file_to_string(f: &impl AbstractFile) -> String { 97 | if f.is_dir() { 98 | format!("{} (directory: {}) {}", &f.name().deref(), f.files().iter().count(), f.path().deref()) 99 | } else { 100 | let dt = chrono::DateTime::::from(f.modified().to_owned()); 101 | 102 | format!("{} ({}, {}, {}) {}", &f.name().deref(), f.len(), f.hash().deref(), dt.format("%Y-%m-%d %H:%M:%S"), f.path().deref()) 103 | } 104 | } 105 | 106 | /// 遍历并输出所有层级下所有文件和目录的实用函数,主要用作调试用途 107 | pub fn walk_abstract_file(file: &impl AbstractFile, indent: usize) -> String { 108 | let mut buf = String::with_capacity(1024); 109 | let mut stack = LinkedList::new(); 110 | 111 | stack.push_back((file.to_owned(), 0)); 112 | 113 | while let Some(pop) = stack.pop_back() { 114 | let (f, depth) = pop; 115 | 116 | for _ in 0..depth * indent { 117 | buf += " "; 118 | } 119 | 120 | buf += &format!("{}\n", abstract_file_to_string(&f)); 121 | 122 | if f.is_dir() { 123 | for ff in f.files().iter() { 124 | stack.push_back((ff.to_owned(), depth + 1)); 125 | } 126 | } 127 | } 128 | 129 | buf 130 | } -------------------------------------------------------------------------------- /manager/src/diff/disk_file.rs: -------------------------------------------------------------------------------- 1 | //! 磁盘文件对象 2 | 3 | use std::cell::RefCell; 4 | use std::collections::LinkedList; 5 | use std::fmt::Debug; 6 | use std::ops::Deref; 7 | use std::path::Path; 8 | use std::path::PathBuf; 9 | use std::rc::Rc; 10 | use std::rc::Weak; 11 | use std::time::SystemTime; 12 | 13 | use crate::core::file_hash::calculate_hash; 14 | use crate::diff::abstract_file::calculate_path_helper; 15 | use crate::diff::abstract_file::find_file_helper; 16 | use crate::diff::abstract_file::walk_abstract_file; 17 | use crate::diff::abstract_file::AbstractFile; 18 | use crate::diff::abstract_file::BorrowIntoIterator; 19 | use crate::utility::filename_ext::GetFileNamePart; 20 | 21 | /// 借用哈希 22 | pub struct BorrowedHash<'a>(std::cell::Ref<'a, Option>); 23 | 24 | impl Deref for BorrowedHash<'_> { 25 | type Target = String; 26 | 27 | fn deref(&self) -> &Self::Target { 28 | self.0.as_ref().unwrap() 29 | } 30 | } 31 | 32 | /// 借用子文件列表 33 | pub struct IntoIter<'a>(std::cell::Ref<'a, Option>>); 34 | 35 | impl BorrowIntoIterator for IntoIter<'_> { 36 | type Item = DiskFile; 37 | 38 | fn iter(&self) -> impl Iterator { 39 | self.0.as_ref().unwrap().iter().map(|f| f.to_owned()) 40 | } 41 | } 42 | 43 | /// 代表一个DiskFile的实际数据部分 44 | pub struct Inner { 45 | /// 文件在磁盘上的绝对路径 46 | file: PathBuf, 47 | 48 | /// 父文件 49 | parent: Weak, 50 | 51 | /// 文件名 52 | name: String, 53 | 54 | /// 文件长度 55 | len: u64, 56 | 57 | /// 文件修改时间 58 | modified: SystemTime, 59 | 60 | /// 是不是一个目录 61 | is_dir: bool, 62 | 63 | /// 文件的相对路径 64 | path: RefCell, 65 | 66 | /// 文件的哈希值缓存 67 | hash: RefCell>, 68 | 69 | /// 子文件列表缓存 70 | children: RefCell>>, 71 | } 72 | 73 | /// 代表目前磁盘上的文件状态,主要用于和历史状态对比计算文件差异 74 | #[derive(Clone)] 75 | pub struct DiskFile(Rc); 76 | 77 | impl Deref for DiskFile { 78 | type Target = Rc; 79 | 80 | fn deref(&self) -> &Self::Target { 81 | &self.0 82 | } 83 | } 84 | 85 | impl DiskFile { 86 | /// 从磁盘路径创建 87 | pub fn new(path: PathBuf, parent: Weak) -> Self { 88 | let filename = path.filename().to_owned(); 89 | let metadata = std::fs::metadata(&path).unwrap(); 90 | let strong_parent = parent.clone().upgrade().map(|p| DiskFile(p)); 91 | 92 | let inner = Inner { 93 | file: path, 94 | parent, 95 | name: filename.to_owned(), 96 | len: metadata.len(), 97 | modified: metadata.modified().unwrap(), 98 | is_dir: metadata.is_dir(), 99 | path: RefCell::new(calculate_path_helper(&filename, strong_parent.as_ref())), 100 | hash: RefCell::new(None), 101 | children: RefCell::new(None), 102 | }; 103 | 104 | Self(Rc::new(inner)) 105 | } 106 | 107 | /// 返回磁盘路径的引用 108 | pub fn disk_file(&self) -> &Path { 109 | &self.file 110 | } 111 | } 112 | 113 | impl AbstractFile for DiskFile { 114 | fn parent(&self) -> Option { 115 | self.parent.upgrade().map(|f| DiskFile(f)) 116 | } 117 | 118 | fn name(&self) -> impl Deref { 119 | &self.name 120 | } 121 | 122 | fn hash(&self) -> impl Deref { 123 | assert!(!self.is_dir); 124 | 125 | let mut hash_mut = self.hash.borrow_mut(); 126 | 127 | if hash_mut.is_none() { 128 | let mut fd = std::fs::File::open(&self.file).unwrap(); 129 | *hash_mut = Some(calculate_hash(&mut fd)); 130 | } 131 | 132 | drop(hash_mut); 133 | 134 | BorrowedHash(self.hash.borrow()) 135 | } 136 | 137 | fn len(&self) -> u64 { 138 | self.len 139 | } 140 | 141 | fn modified(&self) -> SystemTime { 142 | self.modified 143 | } 144 | 145 | fn is_dir(&self) -> bool { 146 | self.is_dir 147 | } 148 | 149 | fn path(&self) -> impl Deref { 150 | self.path.borrow() 151 | } 152 | 153 | fn files(&self) -> impl BorrowIntoIterator { 154 | assert!(self.is_dir); 155 | 156 | let mut children_mut = self.children.borrow_mut(); 157 | 158 | if children_mut.is_none() { 159 | let mut result = LinkedList::new(); 160 | 161 | for file in std::fs::read_dir(&self.file).unwrap() { 162 | let file = file.unwrap(); 163 | 164 | let child = DiskFile::new(file.path(), Rc::downgrade(&self.0)); 165 | 166 | result.push_back(child); 167 | } 168 | 169 | *children_mut = Some(result); 170 | } 171 | 172 | drop(children_mut); 173 | 174 | IntoIter(self.children.borrow()) 175 | } 176 | 177 | fn find(&self, path: &str) -> Option { 178 | find_file_helper(self, path) 179 | } 180 | } 181 | 182 | impl Debug for DiskFile { 183 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 184 | f.write_str(&walk_abstract_file(self, 4)) 185 | } 186 | } -------------------------------------------------------------------------------- /manager/src/diff/mod.rs: -------------------------------------------------------------------------------- 1 | //! 目录差异对比 2 | //! 3 | //! 计算文件差异是将一个新目录和一个旧目录下面的文件内容进行对比, 4 | //! 然后计算出新目录相较旧目录新增了什么文件,删除了什么文件等操作的过程 5 | //! 6 | //! 文件差异会分成5类 7 | //! 1. 删除的文件 8 | //! 2. 删除的目录 9 | //! 3. 覆盖的文件(新增和修改都视为覆盖) 10 | //! 4. 创建的目录 11 | //! 5. 移动的文件 12 | //! 13 | //! 在扫描文件差异时,会遇到各种情况,然后分别记录成不同文件操作,具体的决策表如下 14 | //! 15 | //! | 决策表 | 现在是目录 | 现在是文件 | 现在不存在了 | 16 | //! | ---------------- | -------------------------------------------------------- | ------------------------------------------------ | -------------------- | 17 | //! | 之前是目录 | 不做记录,而是进一步对比目录里面的内容 | 旧目录记录为删除,并将新增文件的记录为覆盖 | 记录这个目录删除行为 | 18 | //! | 之前是文件 | 旧文件记录为删除,并记录新增的目录下的全部文件内容为覆盖 | 先对比修改时间,再对比文件哈希,不同则记录为覆盖 | 记录这个文件删除行为 | 19 | //! | 之前没有这个文件 | 记录新增的目录下的全部文件内容为覆盖 | 记录这个新增的文件数据为覆盖 | 什么也不做 | 20 | //! 21 | //! 其中移动文件的操作无法直接检测出来,但是可以通过检查一下新增文件列表(覆盖文件列表)和删除文件列表。如果发现这两个列表中有哈希值相同的文件存在,那么就可以认为这是一个文件移动操作。此时把这个文件从这俩列表里拿出来,然后插到文件移动列表中 22 | 23 | pub mod diff; 24 | pub mod disk_file; 25 | pub mod history_file; 26 | pub mod abstract_file; -------------------------------------------------------------------------------- /manager/src/main.rs: -------------------------------------------------------------------------------- 1 | //! mcpatch2管理端第二版 2 | 3 | use std::ffi::OsString; 4 | use std::io::Write; 5 | use std::str::FromStr; 6 | 7 | use clap::Parser; 8 | use clap::Subcommand; 9 | 10 | use crate::app_path::AppPath; 11 | use crate::builtin_server::start_builtin_server; 12 | use crate::config::Config; 13 | use crate::task::check::task_check; 14 | use crate::task::combine::task_combine; 15 | use crate::task::pack::task_pack; 16 | use crate::task::revert::task_revert; 17 | use crate::task::test::task_test; 18 | use crate::web::log::Console; 19 | use crate::web::serve_web; 20 | 21 | pub mod utility; 22 | pub mod diff; 23 | pub mod core; 24 | pub mod config; 25 | pub mod web; 26 | pub mod builtin_server; 27 | pub mod upload; 28 | pub mod app_path; 29 | pub mod task; 30 | 31 | #[derive(Parser)] 32 | struct CommandLineInterface { 33 | #[command(subcommand)] 34 | command: Commands 35 | } 36 | 37 | #[derive(Subcommand)] 38 | enum Commands { 39 | /// 打包一个新的版本 40 | Pack { 41 | /// 指定新的版本号 42 | version_label: String 43 | }, 44 | 45 | /// 检查工作空间的文件修改情况 46 | Check, 47 | 48 | /// 合并更新包 49 | Combine, 50 | 51 | /// 测试所有更新包是否能正常读取 52 | Test, 53 | 54 | /// 还原工作空间目录的修改 55 | Revert, 56 | 57 | /// 运行私有协议服务端 58 | Serve, 59 | 60 | /// 运行webui 61 | Webui, 62 | } 63 | 64 | fn main() { 65 | std::env::set_var("RUST_BACKTRACE", "1"); 66 | 67 | let runtime = tokio::runtime::Builder::new_multi_thread() 68 | .enable_all() 69 | .build() 70 | .unwrap(); 71 | 72 | runtime.block_on(async move { 73 | let apppath = AppPath::new(); 74 | let config = Config::load(&apppath).await; 75 | let console = Console::new_cli(); 76 | 77 | match std::env::args().len() > 1 { 78 | // 如果带了启动参数,就进入命令行模式 79 | true => commandline_mode(apppath, config, console).await, 80 | 81 | // 如果不带启动参数,就进入交互式模式 82 | false => interactive_mode(apppath, config, console).await, 83 | } 84 | }); 85 | } 86 | 87 | /// 命令行模式,每次只运行一个命令 88 | async fn commandline_mode(apppath: AppPath, config: Config, console: Console) -> i32 { 89 | handle_command(&apppath, &config, &console, CommandLineInterface::parse()).await 90 | } 91 | 92 | /// 交互式模式,可以重复运行命令 93 | async fn interactive_mode(apppath: AppPath, config: Config, console: Console) -> i32 { 94 | let stdin = std::io::stdin(); 95 | let mut stdout = std::io::stdout(); 96 | let mut buf = String::with_capacity(1024); 97 | 98 | loop { 99 | print!("> "); 100 | stdout.flush().unwrap(); 101 | 102 | buf.clear(); 103 | buf += &format!("\"{}\" ", std::env::args().next().unwrap()); 104 | let _len = match stdin.read_line(&mut buf) { 105 | Ok(len) => len, 106 | Err(_) => break, 107 | }; 108 | 109 | let args = buf.trim().split(" ").map(|e| OsString::from_str(e).unwrap()).collect::>(); 110 | 111 | match CommandLineInterface::try_parse_from(args) { 112 | Ok(cmd) => { handle_command(&apppath, &config, &console, cmd).await; }, 113 | Err(err) => { println!("\n\n {}", err); }, 114 | }; 115 | } 116 | 117 | 0 118 | } 119 | 120 | async fn handle_command(apppath: &AppPath, config: &Config, console: &Console, cmd: CommandLineInterface) -> i32 { 121 | let result = match cmd.command { 122 | Commands::Pack { version_label } => task_pack(version_label, "".to_owned(), apppath, config, console), 123 | Commands::Check => task_check(apppath, config, console), 124 | Commands::Combine => task_combine(apppath, config, console), 125 | Commands::Test => task_test(apppath, config, console), 126 | Commands::Revert => task_revert(apppath, config, console), 127 | Commands::Serve => { 128 | start_builtin_server(config.clone(), apppath.clone()).await; 129 | 130 | 0 131 | }, 132 | Commands::Webui => { 133 | serve_web(apppath.clone(), config.clone()).await; 134 | 135 | 0 136 | }, 137 | }; 138 | 139 | result as i32 140 | } 141 | -------------------------------------------------------------------------------- /manager/src/task/check.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Weak; 2 | 3 | use crate::app_path::AppPath; 4 | use crate::config::Config; 5 | use crate::core::data::index_file::IndexFile; 6 | use crate::core::tar_reader::TarReader; 7 | use crate::diff::diff::Diff; 8 | use crate::diff::disk_file::DiskFile; 9 | use crate::diff::history_file::HistoryFile; 10 | use crate::web::log::Console; 11 | 12 | pub fn task_check(apppath: &AppPath, config: &Config, console: &Console) -> u8 { 13 | // 读取现有更新包,并复现在history上 14 | let index_file = IndexFile::load_from_file(&apppath.index_file); 15 | 16 | console.log_debug("正在读取数据"); 17 | 18 | let mut history = HistoryFile::new_empty(); 19 | 20 | for v in &index_file { 21 | let mut reader = TarReader::new(apppath.public_dir.join(&v.filename)); 22 | let meta_group = reader.read_metadata_group(v.offset, v.len); 23 | 24 | for meta in meta_group { 25 | history.replay_operations(&meta); 26 | } 27 | } 28 | 29 | // 对比文件 30 | console.log_debug("正在扫描文件更改"); 31 | 32 | let exclude_rules = &config.core.exclude_rules; 33 | let disk_file = DiskFile::new(apppath.workspace_dir.clone(), Weak::new()); 34 | let diff = Diff::diff(&disk_file, &history, Some(&exclude_rules)); 35 | 36 | // 输出文件差异 37 | console.log_info(format!("{:#?}", diff)); 38 | console.log_info(format!("{}", diff)); 39 | 40 | 0 41 | } -------------------------------------------------------------------------------- /manager/src/task/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod check; 2 | pub mod combine; 3 | pub mod pack; 4 | pub mod revert; 5 | pub mod sync; 6 | pub mod test; 7 | -------------------------------------------------------------------------------- /manager/src/task/pack.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::rc::Weak; 3 | 4 | use crate::app_path::AppPath; 5 | use crate::config::Config; 6 | use crate::core::archive_tester::ArchiveTester; 7 | use crate::core::data::index_file::IndexFile; 8 | use crate::core::data::index_file::VersionIndex; 9 | use crate::core::data::version_meta::VersionMeta; 10 | use crate::core::data::version_meta_group::VersionMetaGroup; 11 | use crate::core::tar_reader::TarReader; 12 | use crate::core::tar_writer::TarWriter; 13 | use crate::diff::abstract_file::AbstractFile; 14 | use crate::diff::diff::Diff; 15 | use crate::diff::disk_file::DiskFile; 16 | use crate::diff::history_file::HistoryFile; 17 | use crate::web::log::Console; 18 | 19 | 20 | pub fn task_pack(version_label: String, change_logs: String, apppath: &AppPath, config: &Config, console: &Console) -> u8 { 21 | // 读取更新日志 22 | let change_logs = match change_logs.is_empty() { 23 | false => change_logs, 24 | true => { 25 | // 如果没有指定更新日志,就从文件里读取 26 | let logs_file = apppath.working_dir.join("logs.txt"); 27 | 28 | match std::fs::read_to_string(&logs_file) { 29 | Ok(text) => text, 30 | Err(_) => "没有更新记录".to_owned(), 31 | } 32 | }, 33 | }; 34 | 35 | 36 | let mut index_file = IndexFile::load_from_file(&apppath.index_file); 37 | 38 | if index_file.contains(&version_label) { 39 | console.log_error(format!("版本号已经存在: {}", version_label)); 40 | return 1; 41 | } 42 | 43 | // 1. 读取所有历史版本,并推演出上个版本的文件状态,用于和工作空间目录对比生成文件差异 44 | // 读取现有更新包,并复现在history上 45 | console.log_debug("正在读取数据"); 46 | 47 | let mut history = HistoryFile::new_dir("workspace_root", Weak::new()); 48 | 49 | for v in &index_file { 50 | let mut reader = TarReader::new(apppath.public_dir.join(&v.filename)); 51 | let meta_group = reader.read_metadata_group(v.offset, v.len); 52 | 53 | for meta in &meta_group { 54 | history.replay_operations(&meta); 55 | } 56 | } 57 | 58 | // 对比文件 59 | console.log_debug("正在扫描文件更改"); 60 | 61 | let exclude_rules = &config.core.exclude_rules; 62 | let disk_file = DiskFile::new(apppath.workspace_dir.clone(), Weak::new()); 63 | let diff = Diff::diff(&disk_file, &history, Some(exclude_rules)); 64 | 65 | if !diff.has_diff() { 66 | console.log_error("目前工作目录还没有任何文件修改"); 67 | return 1; 68 | } 69 | 70 | console.log_info(format!("{:#?}", diff)); 71 | 72 | // 2. 将所有“覆盖的文件”的数据和元数据写入到更新包中,同时更新元数据中每个文件的偏移值 73 | // 创建新的更新包,将所有文件修改写进去 74 | std::fs::create_dir_all(&apppath.public_dir).unwrap(); 75 | let version_filename = format!("{}.tar", version_label); 76 | let version_file = apppath.public_dir.join(&version_filename); 77 | let mut writer = TarWriter::new(&version_file); 78 | 79 | // 写入每个更新的文件数据 80 | let mut vec = Vec::<&DiskFile>::new(); 81 | 82 | for f in &diff.added_files { 83 | vec.push(f); 84 | } 85 | 86 | for f in &diff.modified_files { 87 | vec.push(f); 88 | } 89 | 90 | let mut counter = 1; 91 | for f in &vec { 92 | console.log_debug(format!("打包({}/{}) {}", counter, vec.len(), f.path().deref())); 93 | counter += 1; 94 | 95 | let path = f.path().to_owned(); 96 | let disk_file = apppath.workspace_dir.join(&path); 97 | let open = std::fs::File::options().read(true).open(disk_file).unwrap(); 98 | 99 | // 提供的len必须和读取到的长度严格相等 100 | let meta = open.metadata().unwrap(); 101 | assert_eq!(meta.len(), f.len()); 102 | 103 | writer.add_file(open, f.len(), &path, &version_label); 104 | } 105 | 106 | // 写入元数据 107 | console.log_debug("写入元数据"); 108 | 109 | // 读取写好的更新记录 110 | let meta = VersionMeta::new(version_label.clone(), change_logs, diff.to_file_changes()); 111 | let meta_group = VersionMetaGroup::with_one(meta); 112 | let meta_info = writer.finish(meta_group); 113 | 114 | // 3. 更新索引文件 115 | index_file.add(VersionIndex { 116 | label: version_label.to_owned(), 117 | filename: version_filename, 118 | offset: meta_info.offset, 119 | len: meta_info.length, 120 | hash: "no hash".to_owned(), 121 | }); 122 | 123 | // 进行解压测试 124 | console.log_debug("正在测试"); 125 | 126 | let mut tester = ArchiveTester::new(); 127 | for v in &index_file { 128 | tester.feed(apppath.public_dir.join(&v.filename), v.offset, v.len); 129 | } 130 | tester.finish(|e| console.log_debug(format!("{}/{} 正在测试 {} 的 {} ({}+{})", e.index, e.total, e.label, e.path, e.offset, e.len))).unwrap(); 131 | 132 | console.log_info("测试通过,打包完成!"); 133 | 134 | index_file.save(&apppath.index_file); 135 | 136 | // // 生成上传脚本 137 | // let context = TemplateContext { 138 | // upload_files: vec![version_file.strip_prefix(&ctx.working_dir).unwrap().to_str().unwrap().to_owned()], 139 | // delete_files: Vec::new(), 140 | // }; 141 | 142 | // generate_upload_script(context, ctx, &version_label); 143 | 144 | 0 145 | } -------------------------------------------------------------------------------- /manager/src/task/revert.rs: -------------------------------------------------------------------------------- 1 | use std::fs::FileTimes; 2 | use std::ops::Deref; 3 | use std::rc::Weak; 4 | 5 | use crate::app_path::AppPath; 6 | use crate::config::Config; 7 | use crate::core::data::index_file::IndexFile; 8 | use crate::core::tar_reader::TarReader; 9 | use crate::diff::abstract_file::AbstractFile; 10 | use crate::diff::diff::Diff; 11 | use crate::diff::disk_file::DiskFile; 12 | use crate::diff::history_file::HistoryFile; 13 | use crate::web::log::Console; 14 | 15 | 16 | pub fn task_revert(apppath: &AppPath, config: &Config, console: &Console) -> u8 { 17 | let index_file = IndexFile::load_from_file(&apppath.index_file); 18 | 19 | // 读取现有更新包,并复现在history上 20 | console.log_debug("正在读取数据"); 21 | 22 | let mut history = HistoryFile::new_empty(); 23 | 24 | for v in &index_file { 25 | let mut reader = TarReader::new(apppath.public_dir.join(&v.filename)); 26 | let meta_group = reader.read_metadata_group(v.offset, v.len); 27 | 28 | for meta in meta_group { 29 | history.replay_operations(&meta); 30 | } 31 | } 32 | 33 | // 对比文件 34 | console.log_debug("正在扫描文件更改"); 35 | 36 | let exclude_rules = &config.core.exclude_rules; 37 | let disk_file = DiskFile::new(apppath.workspace_dir.clone(), Weak::new()); 38 | let diff = Diff::diff(&history, &disk_file, Some(exclude_rules)); 39 | drop(disk_file); 40 | 41 | // 输出文件差异 42 | // if is_running_under_cargo() { 43 | // // console.log("{:#?}", diff); 44 | // // console.log("{}", diff); 45 | // } 46 | 47 | // 退回 48 | console.log_debug("正在退回文件修改"); 49 | 50 | for mk in diff.added_folders { 51 | let dir = apppath.workspace_dir.join(mk.path().deref()); 52 | 53 | if let Err(e) = std::fs::create_dir_all(dir) { 54 | panic!("{}: {:?}", mk.path().deref(), e); 55 | } 56 | } 57 | 58 | for mv in diff.renamed_files { 59 | let src = mv.0.disk_file(); 60 | let dst = apppath.workspace_dir.join(mv.1.path().deref()); 61 | 62 | if let Err(e) = std::fs::rename(src, dst) { 63 | panic!("{} => {}: {:?}", mv.0.path().deref(), mv.1.path().deref(), e); 64 | } 65 | } 66 | 67 | for rm in diff.missing_files { 68 | let file = rm.disk_file(); 69 | 70 | if let Err(e) = std::fs::remove_file(file) { 71 | panic!("{}({}): {:?}", rm.path().deref(), file.to_str().unwrap(), e); 72 | } 73 | } 74 | 75 | for rm in diff.missing_folders { 76 | let dir = rm.disk_file(); 77 | 78 | if let Err(e) = std::fs::remove_dir(dir) { 79 | panic!("{}: {:?}", rm.path().deref(), e); 80 | } 81 | } 82 | 83 | let mut vec = Vec::<&HistoryFile>::new(); 84 | 85 | for f in &diff.added_files { 86 | vec.push(&f); 87 | } 88 | 89 | for f in &diff.modified_files { 90 | vec.push(&f); 91 | } 92 | 93 | for up in vec { 94 | let file = apppath.workspace_dir.join(up.path().deref()); 95 | 96 | let loc = up.file_location(); 97 | let meta = index_file.find(&loc.version).unwrap(); 98 | 99 | let mut reader = TarReader::new(apppath.public_dir.join(&meta.filename)); 100 | 101 | let open = std::fs::File::options() 102 | .write(true) 103 | .truncate(true) 104 | .create(true) 105 | .open(file); 106 | 107 | let mut open = match open { 108 | Ok(open) => open, 109 | Err(e) => panic!("{}: {}", up.path().deref(), e.to_string()), 110 | }; 111 | 112 | let mut src = reader.open_file(loc.offset, loc.length as u64); 113 | 114 | std::io::copy(&mut src, &mut open).unwrap(); 115 | 116 | open.set_times(FileTimes::new().set_modified(up.modified())).unwrap(); 117 | } 118 | 119 | console.log_info("工作空间目录已经退回到未修改之前"); 120 | 121 | 0 122 | } -------------------------------------------------------------------------------- /manager/src/task/sync.rs: -------------------------------------------------------------------------------- 1 | use std::time::UNIX_EPOCH; 2 | 3 | use crate::app_path::AppPath; 4 | use crate::config::Config; 5 | use crate::upload::file_list_cache::FileListCache; 6 | use crate::upload::s3::S3Target; 7 | use crate::upload::webdav::WebdavTarget; 8 | use crate::upload::UploadTarget; 9 | use crate::web::log::Console; 10 | 11 | pub fn task_upload(apppath: &AppPath, config: &Config, console: &Console) -> u8 { 12 | let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap(); 13 | 14 | runtime.block_on(async move { 15 | let webdav_config = config.webdav.clone(); 16 | let s3_config = config.s3.clone(); 17 | 18 | // 先上传webdav 19 | if webdav_config.enabled { 20 | let target = FileListCache::new(WebdavTarget::new(webdav_config).await); 21 | 22 | if let Err(err) = upload("webdav", target, &apppath, console).await { 23 | console.log_error(err); 24 | return 1; 25 | } 26 | } 27 | 28 | // 再上传s3 29 | if s3_config.enabled { 30 | let target = FileListCache::new(S3Target::new(s3_config).await); 31 | 32 | if let Err(err) = upload("s3", target, &apppath, console).await { 33 | console.log_error(err); 34 | return 1; 35 | } 36 | } 37 | 38 | 0 39 | }); 40 | 41 | 0 42 | } 43 | 44 | async fn upload(name: &str, mut target: impl UploadTarget, apppath: &AppPath, console: &Console) -> Result<(), String> { 45 | console.log_debug("收集本地文件列表..."); 46 | let local = get_local(&apppath).await; 47 | 48 | console.log_debug(format!("收集 {} 上的文件列表...", name)); 49 | let remote = target.list().await?; 50 | 51 | console.log_debug("计算文件列表差异..."); 52 | 53 | // 寻找上传/覆盖的文件 54 | let mut need_upload = Vec::new(); 55 | 56 | for (f, mtime) in &local { 57 | if remote.iter().any(|e| &e.0 == f && e.1.abs_diff(*mtime) < 3) { 58 | continue; 59 | } 60 | 61 | need_upload.push(f.clone()); 62 | } 63 | 64 | // 寻找删除的文件 65 | let mut need_delete = Vec::new(); 66 | 67 | for (f, _) in &remote { 68 | if local.iter().all(|e| &e.0 != f) { 69 | need_delete.push(f.clone()); 70 | } 71 | } 72 | 73 | // 上传文件 74 | for f in &need_upload { 75 | console.log_debug(format!("上传文件: {}", f)); 76 | 77 | target.upload(&f, apppath.public_dir.join(&f)).await?; 78 | } 79 | 80 | // 删除文件 81 | for f in &need_delete { 82 | console.log_debug(format!("删除文件: {}", f)); 83 | 84 | target.delete(&f).await?; 85 | } 86 | 87 | console.log_info("文件同步完成"); 88 | 89 | Ok(()) 90 | } 91 | 92 | async fn get_local(apppath: &AppPath) -> Vec<(String, u64)> { 93 | let mut dir = tokio::fs::read_dir(&apppath.public_dir).await.unwrap(); 94 | 95 | let mut files = Vec::new(); 96 | 97 | while let Some(entry) = dir.next_entry().await.unwrap() { 98 | let file = entry.file_name().to_str().unwrap().to_owned(); 99 | let mtime = entry.metadata().await.unwrap().modified().unwrap(); 100 | 101 | files.push((file, mtime.duration_since(UNIX_EPOCH).unwrap().as_secs())); 102 | } 103 | 104 | files 105 | } -------------------------------------------------------------------------------- /manager/src/task/test.rs: -------------------------------------------------------------------------------- 1 | use crate::app_path::AppPath; 2 | use crate::config::Config; 3 | use crate::core::archive_tester::ArchiveTester; 4 | use crate::core::data::index_file::IndexFile; 5 | use crate::web::log::Console; 6 | 7 | 8 | pub fn task_test(apppath: &AppPath, _config: &Config, console: &Console) -> u8 { 9 | console.log_debug("正在执行更新包的解压测试"); 10 | 11 | let index_file = IndexFile::load_from_file(&apppath.index_file); 12 | 13 | let mut tester = ArchiveTester::new(); 14 | 15 | // 读取现有更新包 16 | for v in &index_file { 17 | tester.feed(apppath.public_dir.join(&v.filename), v.offset, v.len); 18 | } 19 | 20 | // 执行测试 21 | tester.finish(|e| console.log_debug(format!("{}/{} 正在测试 {} 的 {} ({}+{})", e.index, e.total, e.label, e.path, e.offset, e.len))).unwrap(); 22 | 23 | console.log_info("测试通过!"); 24 | 25 | 0 26 | } -------------------------------------------------------------------------------- /manager/src/upload/file_list_cache.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::PathBuf; 3 | use std::time::SystemTime; 4 | use std::time::UNIX_EPOCH; 5 | 6 | use crate::upload::UploadTarget; 7 | 8 | const FILELIST: &str = ".filelist.txt"; 9 | 10 | /// 代表文件列表缓存功能。用来将 UploadTarget 的列目录操作转换为文件读取操作, 11 | /// 也就是把文件列表存储在一个txt文件里,这样可以避免对真实 UploadTarget 执行列目录操作,以避免误删用户的其它文件 12 | pub struct FileListCache where T : UploadTarget { 13 | /// 真实 UploadTarget 对象 14 | target: T, 15 | 16 | /// 缓存的文件列表 17 | cache: Option>, 18 | } 19 | 20 | impl FileListCache where T : UploadTarget { 21 | pub fn new(target: T) -> Self { 22 | Self { 23 | target, 24 | cache: None, 25 | } 26 | } 27 | 28 | /// 往文件列表里添加一个文件 29 | async fn add_file(&mut self, filename: &str, mtime: u64) { 30 | let filelist = self.cache.as_mut().unwrap(); 31 | 32 | filelist.insert(filename.to_owned(), mtime); 33 | } 34 | 35 | /// 从文件列表里删除一个文件 36 | async fn remove_file(&mut self, filename: &str) { 37 | let filelist = self.cache.as_mut().unwrap(); 38 | 39 | assert!(filelist.remove(filename).is_some()); 40 | } 41 | 42 | /// 读取文件列表 43 | async fn read_filelist(&mut self) -> Result, String> { 44 | if self.cache.is_none() { 45 | let text = self.target.read(FILELIST).await?; 46 | 47 | let mut files = HashMap::new(); 48 | 49 | if let Some(text) = text { 50 | let lines = text.split("\n") 51 | .filter(|e| !e.is_empty()); 52 | 53 | for line in lines { 54 | let mut split = line.split(";"); 55 | 56 | let filename = split.next().unwrap().to_owned(); 57 | let mtime = split.next(); 58 | 59 | let mtime = match mtime { 60 | Some(mt) => u64::from_str_radix(mt, 10).unwrap(), 61 | None => { 62 | println!("114514"); 63 | 64 | 123456 65 | }, 66 | }; 67 | 68 | files.insert(filename, mtime); 69 | } 70 | } 71 | 72 | self.cache = Some(files); 73 | } 74 | 75 | let result = self.cache.as_ref().unwrap().iter() 76 | .map(|e| (e.0.to_owned(), *e.1)) 77 | .collect(); 78 | 79 | Ok(result) 80 | } 81 | 82 | /// 写入文件列表 83 | async fn write_filelist(&mut self) -> Result<(), String> { 84 | assert!(self.cache.is_some()); 85 | 86 | let content = self.cache.as_ref().unwrap() 87 | .iter() 88 | .map(|e| format!("{};{}", e.0, e.1)) 89 | .collect::>() 90 | .join("\n"); 91 | 92 | self.target.write(FILELIST, &content).await 93 | } 94 | } 95 | 96 | impl UploadTarget for FileListCache where T : UploadTarget { 97 | async fn list(&mut self) -> Result, String> { 98 | // 这里列目录操作不会调用真实 UploadTatget 的方法,而是使用自己的文件缓存给替代掉 99 | let list = self.read_filelist().await?; 100 | 101 | Ok(list) 102 | } 103 | 104 | async fn read(&mut self, filename: &str) -> Result, String> { 105 | // 转发到真实 UploadTatget 上去处理 106 | self.target.read(filename).await 107 | } 108 | 109 | async fn write(&mut self, filename: &str, content: &str) -> Result<(), String> { 110 | // 转发到真实 UploadTatget 上去处理 111 | self.target.write(filename, content).await?; 112 | 113 | // 同时也要更新文件修改时间和文件列表 114 | let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); 115 | 116 | self.add_file(filename, now).await; 117 | 118 | // 主动更新文件列表 119 | self.write_filelist().await?; 120 | 121 | Ok(()) 122 | } 123 | 124 | async fn upload(&mut self, filename: &str, filepath: PathBuf) -> Result<(), String> { 125 | let ts = filepath.metadata().unwrap().modified().unwrap().duration_since(UNIX_EPOCH).unwrap().as_secs(); 126 | 127 | // 转发到真实 UploadTatget 上去处理 128 | self.target.upload(filename, filepath).await?; 129 | 130 | self.add_file(filename, ts).await; 131 | 132 | // 主动更新文件列表 133 | self.write_filelist().await?; 134 | 135 | Ok(()) 136 | } 137 | 138 | async fn delete(&mut self, filename: &str) -> Result<(), String> { 139 | // 转发到真实 UploadTatget 上去处理 140 | self.target.delete(filename).await?; 141 | 142 | self.remove_file(filename).await; 143 | 144 | // 主动更新文件列表 145 | self.write_filelist().await?; 146 | 147 | Ok(()) 148 | } 149 | } -------------------------------------------------------------------------------- /manager/src/upload/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod s3; 2 | pub mod webdav; 3 | pub mod file_list_cache; 4 | 5 | use std::future::Future; 6 | use std::path::PathBuf; 7 | 8 | pub trait UploadTarget { 9 | fn list(&mut self) -> impl Future, String>>; 10 | 11 | fn read(&mut self, filename: &str) -> impl Future, String>>; 12 | 13 | fn write(&mut self, filename: &str, content: &str) -> impl Future>; 14 | 15 | fn upload(&mut self, filename: &str, filepath: PathBuf) -> impl Future>; 16 | 17 | fn delete(&mut self, filename: &str) -> impl Future>; 18 | } -------------------------------------------------------------------------------- /manager/src/upload/s3.rs: -------------------------------------------------------------------------------- 1 | use core::str; 2 | use std::path::PathBuf; 3 | 4 | use aws_sdk_s3::config::BehaviorVersion; 5 | use aws_sdk_s3::config::Region; 6 | use aws_sdk_s3::primitives::ByteStream; 7 | use aws_sdk_s3::types::CompletedMultipartUpload; 8 | use aws_sdk_s3::types::CompletedPart; 9 | use aws_sdk_s3::Client; 10 | use tokio::io::AsyncReadExt; 11 | 12 | use crate::config::s3_config::S3Config; 13 | use crate::upload::UploadTarget; 14 | use crate::utility::to_detail_error::ToDetailError; 15 | 16 | pub struct S3Target { 17 | config: S3Config, 18 | client: Client, 19 | } 20 | 21 | impl S3Target { 22 | pub async fn new(config: S3Config) -> Self { 23 | let cfg = aws_sdk_s3::config::Builder::new() 24 | .endpoint_url(config.endpoint.clone()) 25 | .region(Region::new(config.region.clone())) 26 | .behavior_version(BehaviorVersion::v2024_03_28()) 27 | .credentials_provider(aws_sdk_s3::config::Credentials::new( 28 | config.access_id.clone(), 29 | config.secret_key.clone(), 30 | None, 31 | None, 32 | "mcpatch-provider" 33 | )) 34 | .build(); 35 | 36 | let client = aws_sdk_s3::Client::from_conf(cfg); 37 | 38 | Self { 39 | config, 40 | client, 41 | } 42 | } 43 | } 44 | 45 | impl UploadTarget for S3Target { 46 | async fn list(&mut self) -> Result, String> { 47 | // println!("list"); 48 | 49 | let list_rsp = self.client 50 | .list_objects() 51 | .bucket(&self.config.bucket) 52 | // .key("") 53 | .send() 54 | .await 55 | .map_err(|e| e.to_detail_error())?; 56 | 57 | Ok(list_rsp.contents().iter().map(|e| (e.key().unwrap().to_owned(), e.last_modified().unwrap().secs() as u64)).collect()) 58 | } 59 | 60 | async fn read(&mut self, filename: &str) -> Result, String> { 61 | // println!("read: {}", filename); 62 | 63 | let result = self.client.get_object() 64 | .bucket(&self.config.bucket) 65 | .key(filename) 66 | .send() 67 | .await; 68 | 69 | let read = match result { 70 | Ok(ok) => ok, 71 | Err(err) => { 72 | if let Some(e) = err.as_service_error() { 73 | if e.is_no_such_key() { 74 | return Ok(None); 75 | } 76 | } 77 | 78 | return Err(err.to_detail_error()); 79 | }, 80 | }; 81 | 82 | let body = read.body.collect().await.unwrap(); 83 | let bytes = body.into_bytes(); 84 | let text = std::str::from_utf8(&bytes).unwrap().to_owned(); 85 | 86 | Ok(Some(text)) 87 | } 88 | 89 | async fn write(&mut self, filename: &str, content: &str) -> Result<(), String> { 90 | // println!("write {}", filename); 91 | 92 | let _result = self.client.put_object() 93 | .bucket(&self.config.bucket) 94 | .key(filename) 95 | .body(ByteStream::from(content.as_bytes().to_vec())) 96 | .send() 97 | .await 98 | .map_err(|e| e.to_string())?; 99 | 100 | Ok(()) 101 | } 102 | 103 | async fn upload(&mut self, filename: &str, filepath: PathBuf) -> Result<(), String> { 104 | // println!("upload {} => {}", filepath.to_str().unwrap(), filename); 105 | 106 | let metadata = tokio::fs::metadata(&filepath).await.unwrap(); 107 | let file_size = metadata.len(); 108 | 109 | let file = tokio::fs::File::open(&filepath).await.unwrap(); 110 | let mut file = tokio::io::BufReader::new(file); 111 | 112 | // 准备分块上传 113 | let rsp = self.client 114 | .create_multipart_upload() 115 | .bucket(&self.config.bucket) 116 | .key(filename) 117 | .send() 118 | .await 119 | .map_err(|e| e.to_string())?; 120 | 121 | let upload_id = rsp.upload_id.unwrap(); 122 | 123 | let mut part_number = 1; 124 | 125 | let mut complete_parts = CompletedMultipartUpload::builder(); 126 | let mut buffer = vec![0; 8 * 1024 * 1024]; 127 | 128 | // 分块上传 129 | let mut uploaded = 0; 130 | 131 | while uploaded < file_size { 132 | let read_size = file.read(&mut buffer).await.unwrap(); 133 | 134 | // 完成上传 135 | if read_size == 0 { 136 | break; 137 | } 138 | 139 | // 上传当前块 140 | let body = ByteStream::from(buffer[..read_size].to_vec()); 141 | 142 | let rsp = self.client 143 | .upload_part() 144 | .bucket(&self.config.bucket) 145 | .key(filename) 146 | .part_number(part_number) 147 | .upload_id(upload_id.clone()) 148 | .body(body) 149 | .send() 150 | .await 151 | .map_err(|e| e.to_string())?; 152 | 153 | // 保存etag 154 | let cp = CompletedPart::builder() 155 | .part_number(part_number) 156 | .e_tag(rsp.e_tag.unwrap()) 157 | .build(); 158 | complete_parts = complete_parts.parts(cp); 159 | 160 | uploaded += read_size as u64; 161 | part_number += 1; 162 | } 163 | 164 | // 结束上传 165 | let _rsp = self.client 166 | .complete_multipart_upload() 167 | .bucket(&self.config.bucket) 168 | .key(filename) 169 | .upload_id(upload_id) 170 | .multipart_upload(complete_parts.build()) 171 | .send() 172 | .await 173 | .map_err(|e| e.to_string())?; 174 | 175 | Ok(()) 176 | } 177 | 178 | async fn delete(&mut self, filename: &str) -> Result<(), String> { 179 | // println!("delete {}", filename); 180 | 181 | let _result = self.client 182 | .delete_object() 183 | .bucket(&self.config.bucket) 184 | .key(filename) 185 | .send() 186 | .await 187 | .map_err(|e| e.to_string())?; 188 | 189 | Ok(()) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /manager/src/upload/webdav.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::time::Duration; 3 | 4 | use reqwest_dav::list_cmd::ListEntity; 5 | use reqwest_dav::Client; 6 | use reqwest_dav::ClientBuilder; 7 | use reqwest_dav::Depth; 8 | 9 | use crate::config::webdav_config::WebdavConfig; 10 | use crate::upload::UploadTarget; 11 | use crate::utility::to_detail_error::ToDetailError; 12 | 13 | pub struct WebdavTarget { 14 | _config: WebdavConfig, 15 | client: Client, 16 | } 17 | 18 | impl WebdavTarget { 19 | pub async fn new(config: WebdavConfig) -> Self { 20 | let reqwest_client = reqwest_dav::re_exports::reqwest::ClientBuilder::new() 21 | .connect_timeout(Duration::from_millis(10000 as u64)) 22 | .read_timeout(Duration::from_millis(10000 as u64)) 23 | // .danger_accept_invalid_certs(config.http_ignore_certificate) 24 | .use_rustls_tls() // https://github.com/seanmonstar/reqwest/issues/2004#issuecomment-2180557375 25 | .build() 26 | .unwrap(); 27 | 28 | let client = ClientBuilder::new() 29 | .set_agent(reqwest_client) 30 | .set_host(config.host.clone()) 31 | .set_auth(reqwest_dav::Auth::Basic(config.username.clone(), config.password.clone())) 32 | .build() 33 | .unwrap(); 34 | 35 | Self { 36 | _config: config, 37 | client, 38 | } 39 | } 40 | } 41 | 42 | impl UploadTarget for WebdavTarget { 43 | async fn list(&mut self) -> Result, String> { 44 | let items = self.client.list("", Depth::Number(1)).await 45 | .map_err(|e| e.to_detail_error())?; 46 | 47 | let mut files = Vec::new(); 48 | 49 | for item in items { 50 | if let ListEntity::File(file) = item { 51 | files.push((file.href, file.last_modified.timestamp() as u64)); 52 | } 53 | } 54 | 55 | Ok(files) 56 | } 57 | 58 | async fn read(&mut self, filename: &str) -> Result, String> { 59 | let rsp = match self.client.get(&format!("/{}", filename)).await { 60 | Ok(ok) => ok, 61 | Err(err) => { 62 | if let reqwest_dav::types::Error::Decode(err) = &err { 63 | if let reqwest_dav::types::DecodeError::Server(err) = err { 64 | if err.response_code == 404 { 65 | return Ok(None); 66 | } 67 | } 68 | } 69 | 70 | return Err(err.to_detail_error()); 71 | }, 72 | }; 73 | 74 | Ok(Some(rsp.text().await.unwrap())) 75 | } 76 | 77 | async fn write(&mut self, filename: &str, content: &str) -> Result<(), String> { 78 | self.client.put(filename, content.to_owned()).await 79 | .map_err(|e| e.to_detail_error())?; 80 | 81 | Ok(()) 82 | } 83 | 84 | async fn upload(&mut self, filename: &str, filepath: PathBuf) -> Result<(), String> { 85 | let file = tokio::fs::File::open(filepath).await.unwrap(); 86 | 87 | self.client.put(filename, file).await 88 | .map_err(|e| e.to_detail_error())?; 89 | 90 | Ok(()) 91 | } 92 | 93 | async fn delete(&mut self, filename: &str) -> Result<(), String> { 94 | self.client.delete(filename).await 95 | .map_err(|e| e.to_detail_error())?; 96 | 97 | Ok(()) 98 | } 99 | } -------------------------------------------------------------------------------- /manager/src/utility/counted_write.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | /// 代表一个计数的Write对象 4 | pub struct CountedWrite(W, u64); 5 | 6 | impl CountedWrite { 7 | pub fn new(write: W) -> Self { 8 | Self(write, 0) 9 | } 10 | 11 | pub fn count(&self) -> u64 { 12 | self.1 13 | } 14 | } 15 | 16 | impl Write for CountedWrite { 17 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 18 | match self.0.write(buf) { 19 | Ok(count) => { 20 | self.1 += count as u64; 21 | Ok(count) 22 | }, 23 | Err(e) => Err(e), 24 | } 25 | } 26 | 27 | fn flush(&mut self) -> std::io::Result<()> { 28 | self.0.flush() 29 | } 30 | } 31 | 32 | // impl Deref for CountedWrite { 33 | // type Target = W; 34 | 35 | // fn deref(&self) -> &Self::Target { 36 | // &self.0 37 | // } 38 | // } 39 | 40 | // impl DerefMut for CountedWrite { 41 | // fn deref_mut(&mut self) -> &mut Self::Target { 42 | // &mut self.0 43 | // } 44 | // } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use std::io::Read; 49 | 50 | use crate::utility::counted_write::CountedWrite; 51 | 52 | #[test] 53 | fn test_counted_writer() { 54 | let count = 1024 * 1024 * 64; 55 | 56 | let mut src = std::io::repeat(0).take(count); 57 | let mut dst = CountedWrite::new(std::io::sink()); 58 | 59 | let copied = std::io::copy(&mut src, &mut dst).unwrap(); 60 | 61 | assert_eq!(copied, count); 62 | } 63 | } -------------------------------------------------------------------------------- /manager/src/utility/filename_ext.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::path::PathBuf; 3 | 4 | /// 给PathBuf和Path增加快速获取文件名的扩展方法 5 | pub trait GetFileNamePart { 6 | /// 返回文件名部分 7 | fn filename(&self) -> &str; 8 | } 9 | 10 | impl GetFileNamePart for PathBuf { 11 | fn filename(&self) -> &str { 12 | self.file_name().unwrap().to_str().unwrap() 13 | } 14 | } 15 | 16 | impl GetFileNamePart for Path { 17 | fn filename(&self) -> &str { 18 | self.file_name().unwrap().to_str().unwrap() 19 | } 20 | } -------------------------------------------------------------------------------- /manager/src/utility/io_utils.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | 3 | /// 从`read`里不断读取内容,直到末尾。 4 | /// 5 | /// 每当遇到`\n`字符时,调用一次`f` 6 | pub fn read_into_lines(mut read: impl Read, mut f: impl FnMut(&str) -> R) { 7 | let mut line = Vec::with_capacity(128); 8 | let mut buf = [0u8; 4 * 1024]; 9 | 10 | loop { 11 | let count = read.read(&mut buf).unwrap(); 12 | 13 | if count == 0 { 14 | if !line.is_empty() { 15 | let l = std::str::from_utf8(&line).unwrap().trim(); 16 | 17 | if !l.is_empty() { 18 | f(l); 19 | line.clear(); 20 | } 21 | } 22 | 23 | break; 24 | } 25 | 26 | for b in &buf[0..count] { 27 | let b = *b; 28 | 29 | if b == '\n' as u8 { 30 | if !line.is_empty() { 31 | let l = std::str::from_utf8(&line).unwrap().trim(); 32 | 33 | if !l.is_empty() { 34 | f(l); 35 | line.clear(); 36 | } 37 | } 38 | } else { 39 | line.push(b); 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /manager/src/utility/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod traffic_control; 2 | pub mod counted_write; 3 | pub mod to_detail_error; 4 | pub mod io_utils; 5 | pub mod partial_read; 6 | pub mod filename_ext; 7 | pub mod vec_ext; 8 | 9 | /// 判断是否在cargo环境中运行 10 | pub fn is_running_under_cargo() -> bool { 11 | #[cfg(debug_assertions)] 12 | let result = std::env::vars().any(|p| p.0.eq_ignore_ascii_case("CARGO")); 13 | 14 | #[cfg(not(debug_assertions))] 15 | let result = false; 16 | 17 | result 18 | } 19 | 20 | /// 将一个`iter`所有内容连接成字符串,分隔符是`split` 21 | pub fn join_string(iter: impl Iterator>, split: &str) -> String { 22 | let mut result = String::new(); 23 | let mut insert = false; 24 | 25 | for e in iter { 26 | if insert { 27 | result.push_str(split); 28 | } 29 | 30 | result.push_str(e.as_ref()); 31 | insert = true; 32 | } 33 | 34 | result 35 | } 36 | 37 | -------------------------------------------------------------------------------- /manager/src/utility/partial_read.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | 3 | use tokio::io::AsyncRead; 4 | use tokio::pin; 5 | 6 | /// 代表一个限制读取数量的Read 7 | pub struct PartialRead(R, u64); 8 | 9 | impl PartialRead { 10 | pub fn new(read: R, count: u64) -> Self { 11 | Self(read, count) 12 | } 13 | } 14 | 15 | impl Read for PartialRead { 16 | fn read(&mut self, buf: &mut [u8]) -> std::io::Result { 17 | if self.1 == 0 { 18 | return Ok(0); 19 | } 20 | let consume = self.1.min(buf.len() as u64); 21 | self.1 -= consume; 22 | self.0.read(&mut buf[0..consume as usize]) 23 | } 24 | } 25 | 26 | /// 代表一个限制读取数量的Read 27 | pub struct PartialAsyncRead(R, u64); 28 | 29 | impl PartialAsyncRead { 30 | pub fn new(read: R, count: u64) -> Self { 31 | Self(read, count) 32 | } 33 | 34 | pub fn count(&self) -> u64 { 35 | self.1 36 | } 37 | } 38 | 39 | impl AsyncRead for PartialAsyncRead { 40 | fn poll_read( 41 | mut self: std::pin::Pin<&mut Self>, 42 | cx: &mut std::task::Context<'_>, 43 | buf: &mut tokio::io::ReadBuf<'_>, 44 | ) -> std::task::Poll> { 45 | if self.1 == 0 { 46 | return std::task::Poll::Ready(Ok(())); 47 | } 48 | 49 | let limit = buf.remaining().min(self.1 as usize); 50 | let partial = &mut buf.take(limit); 51 | 52 | let read = &mut self.0; 53 | pin!(read); 54 | 55 | match read.poll_read(cx, partial) { 56 | std::task::Poll::Ready(ready) => { 57 | let adv = partial.filled().len(); 58 | 59 | unsafe { buf.assume_init(adv); } 60 | buf.advance(adv); 61 | 62 | self.1 -= adv as u64; 63 | 64 | std::task::Poll::Ready(ready) 65 | }, 66 | std::task::Poll::Pending => std::task::Poll::Pending, 67 | } 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | mod tests { 73 | use tokio::io::AsyncReadExt; 74 | 75 | use crate::utility::partial_read::PartialAsyncRead; 76 | 77 | #[test] 78 | fn partial_async_read_test() { 79 | let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); 80 | 81 | runtime.block_on(async { 82 | const TOTAL: usize = 884; 83 | const BUF: usize = 1000; 84 | 85 | let mut data = Vec::::new(); 86 | 87 | for i in 0..TOTAL { 88 | data.push((i % u8::MAX as usize) as u8); 89 | } 90 | 91 | let mut expectation = data.clone(); 92 | expectation.reverse(); 93 | 94 | let mut data = &data[..]; 95 | 96 | let count = data.len(); 97 | let mut partial = PartialAsyncRead::new(&mut data, count as u64); 98 | 99 | let mut buf = [0u8; BUF]; 100 | 101 | loop { 102 | let read = partial.read(&mut buf).await.unwrap(); 103 | 104 | if read == 0 { 105 | break; 106 | } 107 | 108 | for b in &buf[0..read] { 109 | assert_eq!(b, &expectation.pop().unwrap()); 110 | } 111 | } 112 | 113 | assert!(expectation.is_empty()); 114 | }); 115 | } 116 | } -------------------------------------------------------------------------------- /manager/src/utility/to_detail_error.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | pub trait ToDetailError { 4 | fn to_detail_error(self) -> String; 5 | } 6 | 7 | impl ToDetailError for T { 8 | fn to_detail_error(self) -> String { 9 | format!("{:#?}", self) 10 | } 11 | } -------------------------------------------------------------------------------- /manager/src/utility/traffic_control.rs: -------------------------------------------------------------------------------- 1 | use std::task::Poll; 2 | use std::time::Duration; 3 | use std::time::SystemTime; 4 | 5 | use once_cell::sync::Lazy; 6 | use tokio::io::AsyncRead; 7 | 8 | /// 专门给`AsyncTrafficControl`用的runtime,用来再令牌不够的情况下,延时唤醒waker 9 | static TASK_WAKER_RUNTIME: Lazy = Lazy::new(|| { 10 | tokio::runtime::Builder::new_multi_thread() 11 | .thread_name("async tc waker") 12 | .enable_all() 13 | .worker_threads(1) 14 | .build() 15 | .unwrap() 16 | }); 17 | 18 | /// 基于令牌桶(Token Bucket)的简单流量控制(Token Bucket Filter) 19 | pub struct AsyncTrafficControl<'a, R: AsyncRead + Unpin> { 20 | /// 源数据 21 | read: &'a mut R, 22 | 23 | /// 桶中令牌的数量 24 | bucket: u64, 25 | 26 | /// 桶的容量 27 | capacity: u64, 28 | 29 | /// 每毫秒往桶中增加多少令牌 30 | rate_per_ms: u64, 31 | 32 | /// 上传调用的时间 33 | last_time: SystemTime, 34 | } 35 | 36 | impl<'a, R: AsyncRead + Unpin> AsyncTrafficControl<'a, R> { 37 | pub fn new(read: &'a mut R, capacity: u64, rate_per_second: u64) -> Self { 38 | Self { 39 | read, 40 | bucket: 0, 41 | capacity, 42 | rate_per_ms: rate_per_second / 1000, 43 | last_time: SystemTime::now(), 44 | } 45 | } 46 | } 47 | 48 | impl<'a, R: AsyncRead + Unpin> AsyncRead for AsyncTrafficControl<'a, R> { 49 | fn poll_read( 50 | mut self: std::pin::Pin<&mut Self>, 51 | cx: &mut std::task::Context<'_>, 52 | buf: &mut tokio::io::ReadBuf<'_>, 53 | ) -> std::task::Poll> { 54 | // 如果参数设置为0,则不经过限速直接读取 55 | if self.rate_per_ms == 0 || self.capacity == 0 { 56 | let read = &mut self.read; 57 | tokio::pin!(read); 58 | return read.poll_read(cx, buf); 59 | } 60 | 61 | // 循环等待,直到有足够的令牌 62 | let consumption = { 63 | let now = SystemTime::now(); 64 | let dt = now.duration_since(self.last_time).unwrap(); 65 | 66 | // 将检测粒度增加到1ms以上。不然因为整数的原因,当dt.as_millis()小于0时,new_tokens永远是0 67 | if dt.as_millis() > 0 { 68 | self.last_time = now; 69 | 70 | let new_tokens = dt.as_millis() as u64 * self.rate_per_ms; 71 | 72 | self.bucket += new_tokens; 73 | self.bucket = self.bucket.min(self.capacity); 74 | } 75 | 76 | // 计算本次能消耗掉的令牌数 77 | let consumption = self.bucket.min(buf.remaining() as u64); 78 | 79 | // println!("bucket: {} | com: {}", self.bucket, consumption); 80 | 81 | match consumption > 0 { 82 | true => consumption, 83 | false => { 84 | // println!("set"); 85 | 86 | // 如果令牌用完了,就需要等100毫秒再唤醒,好等待新的令牌过来 87 | let waker = cx.waker().clone(); 88 | TASK_WAKER_RUNTIME.spawn(async move { 89 | // println!("a"); 90 | tokio::time::sleep(Duration::from_millis(100)).await; 91 | // println!("b"); 92 | waker.wake(); 93 | }); 94 | 95 | return Poll::Pending; 96 | }, 97 | } 98 | }; 99 | 100 | // println!("+ bucket: {}, consumption: {}", self.bucket, consumption); 101 | 102 | // 按consumption取出一小部分缓冲区 103 | let mut new_buf = buf.take(consumption as usize); 104 | 105 | // pin住read对象 106 | let read = &mut self.read; 107 | tokio::pin!(read); 108 | 109 | // 进行读取 110 | match read.poll_read(cx, &mut new_buf) { 111 | Poll::Ready(_) => (), 112 | Poll::Pending => return Poll::Pending, 113 | } 114 | 115 | // 读取成功后,消耗掉这些令牌 116 | let consumption = new_buf.filled().len() as u64; 117 | 118 | // 上面的buf.take()返回的是一个新的buf,往这个新的buf里写东西,buf本身是感知不到的 119 | // 所以这里需要手动推进一下缓冲区指针 120 | buf.advance(consumption as usize); 121 | 122 | self.bucket -= consumption; 123 | 124 | Poll::Ready(Ok(())) 125 | } 126 | } -------------------------------------------------------------------------------- /manager/src/utility/vec_ext.rs: -------------------------------------------------------------------------------- 1 | /// 按条件删除Vec中的元素,会从末尾往前删以避免元素移动。并返回是否有元素被删除 2 | pub trait VecRemoveIf { 3 | fn remove_if(&mut self, f: impl FnMut(&T) -> bool) -> bool; 4 | } 5 | 6 | impl VecRemoveIf for Vec { 7 | /// 按条件删除元素,如果要删除某个元素请返回true,反之返回false 8 | fn remove_if(&mut self, mut f: impl FnMut(&T) -> bool) -> bool { 9 | let mut removed = false; 10 | 11 | for i in (0..self.len()).rev() { 12 | if f(&self[i]) { 13 | self.remove(i); 14 | removed = true; 15 | } 16 | } 17 | 18 | removed 19 | } 20 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/delete.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use axum::Json; 4 | use serde::Deserialize; 5 | 6 | use crate::web::api::PublicResponseBody; 7 | use crate::web::webstate::WebState; 8 | 9 | #[derive(Deserialize)] 10 | pub struct RequestBody { 11 | /// 要删除的文件路径 12 | path: String, 13 | } 14 | 15 | pub async fn api_delete(State(state): State, Json(payload): Json) -> Response { 16 | let path = payload.path; 17 | 18 | // 路径不能为空 19 | if path.is_empty() { 20 | return PublicResponseBody::<()>::err("parameter 'path' is empty"); 21 | } 22 | 23 | let file = state.apppath.working_dir.join(path); 24 | 25 | if !file.exists() { 26 | return PublicResponseBody::<()>::err("file not exists."); 27 | } 28 | 29 | if file.is_dir() { 30 | match tokio::fs::remove_dir_all(&file).await { 31 | Ok(_) => (), 32 | Err(err) => return PublicResponseBody::<()>::err(&format!("{:?}", err)), 33 | } 34 | } else { 35 | match tokio::fs::remove_file(&file).await { 36 | Ok(_) => (), 37 | Err(err) => return PublicResponseBody::<()>::err(&format!("{:?}", err)), 38 | } 39 | } 40 | 41 | // 清除文件状态缓存 42 | let mut status = state.status.lock().await; 43 | status.invalidate(); 44 | 45 | PublicResponseBody::<()>::ok_no_data() 46 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/disk_info.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use serde::Serialize; 4 | 5 | use crate::web::api::PublicResponseBody; 6 | use crate::web::webstate::WebState; 7 | 8 | #[derive(Serialize)] 9 | pub struct ResponseData { 10 | pub dev: String, 11 | pub used: u64, 12 | pub total: u64, 13 | } 14 | 15 | pub async fn api_disk_info(State(state): State) -> Response { 16 | #[allow(unused_mut)] 17 | let mut path = state.apppath.working_dir.canonicalize().unwrap().to_str().unwrap().to_string(); 18 | 19 | #[cfg(target_os = "windows")] 20 | if path.starts_with(r"\\?\") { 21 | path = path[4..].to_owned(); 22 | } 23 | 24 | let one_peta_bytes: u64 = 1 * 1024 * 1024 * 1024 * 1024 * 1024; 25 | let mut usages = (one_peta_bytes, one_peta_bytes, "none".to_owned()); 26 | 27 | let disks = sysinfo::Disks::new_with_refreshed_list(); 28 | 29 | for disk in disks.list() { 30 | let name = disk.name().to_str().unwrap().to_owned(); 31 | let mount = disk.mount_point().to_str().unwrap().replace(r"\\", r"\"); 32 | 33 | if path.starts_with(&mount) { 34 | let total = disk.total_space(); 35 | let available = disk.available_space(); 36 | 37 | usages = (total - available, total, name); 38 | } 39 | } 40 | 41 | PublicResponseBody::::ok(ResponseData { 42 | used: usages.0, 43 | total: usages.1, 44 | dev: usages.2, 45 | }) 46 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/download.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use axum::Json; 4 | use base64ct::Encoding; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | 8 | use crate::web::api::PublicResponseBody; 9 | use crate::web::webstate::WebState; 10 | 11 | #[derive(Deserialize)] 12 | pub struct RequestBody { 13 | /// 要列目录的路径 14 | path: String, 15 | } 16 | 17 | #[derive(Serialize)] 18 | pub struct ResponseData { 19 | /// 经过base64编码的完整文件内容 20 | pub content: String, 21 | } 22 | 23 | pub async fn api_download(State(state): State, Json(payload): Json) -> Response { 24 | // 路径不能为空 25 | if payload.path.is_empty() { 26 | return PublicResponseBody::::err("parameter 'path' is empty, and it is not allowed."); 27 | } 28 | 29 | let file = state.apppath.working_dir.join(payload.path); 30 | 31 | if !file.exists() || !file.is_file() { 32 | return PublicResponseBody::::err("file not exists."); 33 | } 34 | 35 | // println!("download: {:?}", file); 36 | 37 | let data = tokio::fs::read(&file).await.unwrap(); 38 | 39 | let b64 = base64ct::Base64::encode_string(&data); 40 | 41 | PublicResponseBody::::ok(ResponseData { content: b64 }) 42 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/extract_file.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::time::Duration; 3 | use std::time::SystemTime; 4 | 5 | use axum::body::Body; 6 | use axum::extract::Query; 7 | use axum::extract::State; 8 | use axum::response::Response; 9 | use sha2::Digest; 10 | use sha2::Sha256; 11 | 12 | use crate::utility::filename_ext::GetFileNamePart; 13 | use crate::web::webstate::WebState; 14 | 15 | pub async fn api_extract_file(State(state): State, Query(params): Query>) -> Response { 16 | let signature = match params.get("sign") { 17 | Some(ok) => ok, 18 | None => return Response::builder() 19 | .status(403) 20 | .body(Body::empty()) 21 | .unwrap(), 22 | }; 23 | 24 | let mut split = signature.split(":"); 25 | 26 | let path = match split.next() { 27 | Some(ok) => ok, 28 | None => return Response::builder().status(403).body(Body::empty()).unwrap(), 29 | }; 30 | 31 | let expire = match split.next() { 32 | Some(ok) => match u64::from_str_radix(ok, 10) { 33 | Ok(ok) => ok, 34 | Err(_) => return Response::builder().status(403).body(Body::empty()).unwrap(), 35 | }, 36 | None => return Response::builder().status(403).body(Body::empty()).unwrap(), 37 | }; 38 | 39 | let digest = match split.next() { 40 | Some(ok) => ok, 41 | None => return Response::builder().status(403).body(Body::empty()).unwrap(), 42 | }; 43 | 44 | let username = state.auth.username().await; 45 | let password = state.auth.password().await; 46 | 47 | let hash = hash(&format!("{}:{}:{}@{}", path, expire, username, password)); 48 | 49 | if hash != digest { 50 | return Response::builder().status(403).body(Body::new("invalid signature".to_owned())).unwrap(); 51 | } 52 | 53 | // 检查是否超过有效期 54 | if (SystemTime::UNIX_EPOCH + Duration::from_secs(expire)).duration_since(SystemTime::UNIX_EPOCH).is_err() { 55 | return Response::builder().status(403).body(Body::new("signature is outdate".to_owned())).unwrap(); 56 | } 57 | 58 | let path = state.apppath.working_dir.join(path); 59 | 60 | let metadata = tokio::fs::metadata(&path).await.unwrap(); 61 | 62 | let file = tokio::fs::File::options() 63 | .read(true) 64 | .open(&path) 65 | .await 66 | .unwrap(); 67 | 68 | let file = tokio_util::io::ReaderStream::new(file); 69 | 70 | Response::builder() 71 | .header(axum::http::header::CONTENT_TYPE, "application/octet-stream") 72 | .header(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", path.filename())) 73 | .header(axum::http::header::CONTENT_LENGTH, format!("{}", metadata.len())) 74 | .body(Body::from_stream(file)).unwrap() 75 | } 76 | 77 | fn hash(text: &impl AsRef) -> String { 78 | let hash = Sha256::digest(text.as_ref()); 79 | 80 | base16ct::lower::encode_string(&hash) 81 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/list.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTime; 2 | 3 | use axum::extract::State; 4 | use axum::response::Response; 5 | use axum::Json; 6 | use serde::Deserialize; 7 | use serde::Serialize; 8 | 9 | use crate::web::api::PublicResponseBody; 10 | use crate::web::file_status::SingleFileStatus; 11 | use crate::web::webstate::WebState; 12 | 13 | #[derive(Deserialize)] 14 | pub struct RequestBody { 15 | /// 要列目录的路径 16 | path: String, 17 | } 18 | 19 | #[derive(Serialize)] 20 | pub struct ResponseData { 21 | pub files: Vec, 22 | } 23 | 24 | #[derive(Serialize)] 25 | pub struct File { 26 | pub name: String, 27 | pub is_directory: bool, 28 | pub size: u64, 29 | pub ctime: u64, 30 | pub mtime: u64, 31 | pub state: String, 32 | } 33 | 34 | #[axum::debug_handler] 35 | pub async fn api_list(State(state): State, Json(payload): Json) -> Response { 36 | let mut status = state.status.lock().await; 37 | 38 | let dir = state.apppath.working_dir.join(&payload.path); 39 | 40 | // println!("list: {:?}", dir); 41 | 42 | if !dir.exists() || !dir.is_dir() { 43 | return PublicResponseBody::::err("directory not exists."); 44 | } 45 | 46 | let mut files = Vec::::new(); 47 | 48 | let mut read_dir = tokio::fs::read_dir(&dir).await.unwrap(); 49 | 50 | while let Some(entry) = read_dir.next_entry().await.unwrap() { 51 | let is_directory = entry.file_type().await.unwrap().is_dir(); 52 | let metadata = entry.metadata().await.unwrap(); 53 | 54 | let status = match entry.path().strip_prefix(&state.apppath.workspace_dir) { 55 | Ok(ok) => status.get_file_status(&ok.to_str().unwrap().replace("\\", "/")).await, 56 | Err(_) => SingleFileStatus::Keep, 57 | }; 58 | 59 | // let relative_path = entry.path().strip_prefix(&state.app_path.working_dir).unwrap().to_str().unwrap().replace("\\", "/"); 60 | // println!("relative: {:?}", relative_path); 61 | 62 | files.push(File { 63 | name: entry.file_name().to_str().unwrap().to_string(), 64 | is_directory, 65 | size: if is_directory { 0 } else { metadata.len() }, 66 | ctime: metadata.created().map(|e| e.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs()).unwrap_or(0), 67 | mtime: metadata.modified().unwrap().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(), 68 | state: match status { 69 | SingleFileStatus::Keep => "keep".to_owned(), 70 | SingleFileStatus::Added => "added".to_owned(), 71 | SingleFileStatus::Modified => "modified".to_owned(), 72 | SingleFileStatus::Missing => "missing".to_owned(), 73 | SingleFileStatus::Gone => "gone".to_owned(), 74 | SingleFileStatus::Come => "come".to_owned(), 75 | }, 76 | }); 77 | } 78 | 79 | PublicResponseBody::::ok(ResponseData { files }) 80 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/make_directory.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use axum::Json; 4 | use serde::Deserialize; 5 | 6 | use crate::web::api::PublicResponseBody; 7 | use crate::web::webstate::WebState; 8 | 9 | #[derive(Deserialize)] 10 | pub struct RequestBody { 11 | /// 要列目录的路径 12 | path: String, 13 | } 14 | 15 | pub async fn api_make_directory(State(state): State, Json(payload): Json) -> Response { 16 | let path = payload.path; 17 | 18 | // 路径不能为空 19 | if path.is_empty() { 20 | return PublicResponseBody::<()>::err("parameter 'path' is empty, and it is not allowed."); 21 | } 22 | 23 | let file = state.apppath.working_dir.join(path); 24 | 25 | println!("make_directory: {:?}", file); 26 | 27 | if file.exists() || file.is_dir() { 28 | return PublicResponseBody::<()>::err("directory has already existed."); 29 | } 30 | 31 | tokio::fs::create_dir(&file).await.unwrap(); 32 | 33 | // 清除文件状态缓存 34 | let mut status = state.status.lock().await; 35 | status.invalidate(); 36 | 37 | PublicResponseBody::<()>::ok_no_data() 38 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod disk_info; 2 | pub mod list; 3 | pub mod upload; 4 | pub mod download; 5 | pub mod make_directory; 6 | pub mod delete; 7 | pub mod sign_file; 8 | pub mod extract_file; 9 | pub mod r#move; 10 | -------------------------------------------------------------------------------- /manager/src/web/api/fs/move.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use axum::Json; 4 | use serde::Deserialize; 5 | 6 | use crate::web::api::PublicResponseBody; 7 | use crate::web::webstate::WebState; 8 | 9 | #[derive(Deserialize)] 10 | pub struct RequestBody { 11 | /// 原路径 12 | from: String, 13 | 14 | /// 目标路径 15 | to: String, 16 | } 17 | 18 | pub async fn api_move(State(state): State, Json(payload): Json) -> Response { 19 | let from = payload.from; 20 | let to = payload.to; 21 | 22 | // 路径不能为空 23 | if from.is_empty() { 24 | return PublicResponseBody::<()>::err("parameter 'from' is empty"); 25 | } 26 | 27 | if to.is_empty() { 28 | return PublicResponseBody::<()>::err("parameter 'to' is empty"); 29 | } 30 | 31 | let file_from = state.apppath.working_dir.join(&from); 32 | let file_to = state.apppath.working_dir.join(&to); 33 | 34 | if !file_from.exists() { 35 | return PublicResponseBody::<()>::err(&format!("'{}' not exists.", from)); 36 | } 37 | 38 | if file_to.exists() { 39 | return PublicResponseBody::<()>::err(&format!("'{}' exists.", to)); 40 | } 41 | 42 | match tokio::fs::rename(&file_from, &file_to).await { 43 | Ok(_) => (), 44 | Err(err) => return PublicResponseBody::<()>::err(&format!("{:?}", err)), 45 | } 46 | 47 | // 清除文件状态缓存 48 | let mut status = state.status.lock().await; 49 | status.invalidate(); 50 | 51 | PublicResponseBody::<()>::ok_no_data() 52 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/sign_file.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | use std::time::SystemTime; 3 | 4 | use axum::extract::State; 5 | use axum::response::Response; 6 | use axum::Json; 7 | use serde::Deserialize; 8 | use serde::Serialize; 9 | use sha2::Digest; 10 | use sha2::Sha256; 11 | 12 | use crate::web::api::PublicResponseBody; 13 | use crate::web::webstate::WebState; 14 | 15 | #[derive(Deserialize)] 16 | pub struct RequestBody { 17 | /// 要下载的文件路径 18 | path: String, 19 | } 20 | 21 | #[derive(Serialize)] 22 | pub struct ResponseData { 23 | /// 文件的签名数据 24 | signature: String, 25 | } 26 | 27 | pub async fn api_sign_file(State(state): State, Json(payload): Json) -> Response { 28 | // 路径不能为空 29 | if payload.path.is_empty() { 30 | return PublicResponseBody::::err("parameter 'path' is empty, and it is not allowed."); 31 | } 32 | 33 | let path = state.apppath.working_dir.join(payload.path); 34 | 35 | if !path.exists() || !path.is_file() { 36 | return PublicResponseBody::::err("file not exists."); 37 | } 38 | 39 | let username = state.auth.username().await; 40 | let password = state.auth.password().await; 41 | 42 | let relative_path = path.strip_prefix(&state.apppath.working_dir).unwrap().to_str().unwrap().to_owned(); 43 | let expire = SystemTime::now() + Duration::from_secs(2 * 60 * 60); 44 | let unix_ts = expire.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); 45 | 46 | let core_data = format!("{}:{}", relative_path, unix_ts); 47 | let full_data = format!("{}:{}@{}", core_data, username, password); 48 | let digest = hash(&full_data); 49 | let signature = format!("{}:{}", core_data, digest); 50 | 51 | // println!("full_data: {} | signature: {}", full_data, signature); 52 | 53 | PublicResponseBody::::ok(ResponseData { signature }) 54 | } 55 | 56 | fn hash(text: &impl AsRef) -> String { 57 | let hash = Sha256::digest(text.as_ref()); 58 | 59 | base16ct::lower::encode_string(&hash) 60 | } -------------------------------------------------------------------------------- /manager/src/web/api/fs/upload.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::extract::State; 3 | use axum::http::HeaderMap; 4 | use axum::response::Response; 5 | use tokio::io::AsyncWriteExt; 6 | use tokio_stream::StreamExt; 7 | 8 | use crate::web::api::PublicResponseBody; 9 | use crate::web::webstate::WebState; 10 | 11 | pub async fn api_upload_fs(State(state): State, headers: HeaderMap, body: Body) -> Response { 12 | let path = match headers.get("path") { 13 | Some(ok) => ok.to_str().unwrap(), 14 | None => return PublicResponseBody::<()>::err("no filed 'path' is found in headers."), 15 | }; 16 | 17 | // 对path进行url解码 18 | let path = urlencoding::decode(path).unwrap().to_string(); 19 | 20 | // 路径不能为空 21 | if path.is_empty() { 22 | return PublicResponseBody::<()>::err("parameter 'path' is empty, and it is not allowed."); 23 | } 24 | 25 | let file = state.apppath.working_dir.join(path); 26 | 27 | println!("upload: {:?}", file); 28 | 29 | if file.is_dir() { 30 | return PublicResponseBody::<()>::err("file is not writable."); 31 | } 32 | 33 | // 自动创建上级目录 34 | tokio::fs::create_dir_all(file.parent().unwrap()).await.unwrap(); 35 | 36 | let mut f = tokio::fs::File::options() 37 | .create(true) 38 | .write(true) 39 | .truncate(true) 40 | .open(file) 41 | .await 42 | .unwrap(); 43 | 44 | let mut body_stream = body.into_data_stream(); 45 | 46 | while let Some(frame) = body_stream.next().await { 47 | let frame = match frame { 48 | Ok(ok) => ok, 49 | Err(err) => return PublicResponseBody::<()>::err(&format!("err: {:?}", err)), 50 | }; 51 | 52 | f.write_all(&frame).await.unwrap(); 53 | } 54 | 55 | // 清除文件状态缓存 56 | let mut status = state.status.lock().await; 57 | status.invalidate(); 58 | 59 | PublicResponseBody::<()>::ok_no_data() 60 | } -------------------------------------------------------------------------------- /manager/src/web/api/misc/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod version_list; 2 | -------------------------------------------------------------------------------- /manager/src/web/api/misc/version_list.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use serde::Serialize; 4 | 5 | use crate::core::data::index_file::IndexFile; 6 | use crate::core::data::version_meta::FileChange; 7 | use crate::core::data::version_meta::VersionMeta; 8 | use crate::core::tar_reader::TarReader; 9 | use crate::web::api::PublicResponseBody; 10 | use crate::web::webstate::WebState; 11 | 12 | #[derive(Serialize)] 13 | pub struct ResponseBody { 14 | /// 要删除的文件路径 15 | versions: Vec, 16 | } 17 | 18 | #[derive(Serialize)] 19 | pub struct Version { 20 | pub label: String, 21 | pub size: u64, 22 | pub change_logs: String, 23 | } 24 | 25 | pub async fn api_version_list(State(state): State) -> Response { 26 | let index_file = IndexFile::load_from_file(&state.apppath.index_file); 27 | 28 | let mut metas = Vec::::new(); 29 | 30 | for v in &index_file { 31 | let mut reader = TarReader::new(state.apppath.public_dir.join(&v.filename)); 32 | let meta_group = reader.read_metadata_group(v.offset, v.len); 33 | 34 | for meta in meta_group { 35 | metas.push(meta); 36 | } 37 | } 38 | 39 | let mut versions = Vec::::new(); 40 | 41 | for meta in metas { 42 | let mut total_size = 0u64; 43 | 44 | for change in &meta.changes { 45 | if let FileChange::UpdateFile { len, .. } = change { 46 | total_size += len; 47 | } 48 | } 49 | 50 | versions.push(Version { 51 | label: meta.label, 52 | size: total_size, 53 | change_logs: meta.logs, 54 | }); 55 | } 56 | 57 | PublicResponseBody::::ok(ResponseBody { versions }) 58 | } -------------------------------------------------------------------------------- /manager/src/web/api/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::response::Response; 3 | use serde::Serialize; 4 | 5 | pub mod user; 6 | pub mod task; 7 | pub mod fs; 8 | pub mod terminal; 9 | pub mod public; 10 | pub mod webpage; 11 | pub mod misc; 12 | 13 | /// 公共响应体 14 | #[derive(Serialize)] 15 | pub struct PublicResponseBody where T : Serialize { 16 | /// 状态码,1代表成功,其它值则代表失败 17 | pub code: i32, 18 | 19 | /// 附带的消息,通常在失败的时候用来说明原因 20 | pub msg: String, 21 | 22 | /// 返回的数据,仅当请求成功时有值,失败则为null 23 | /// 部分接口可能没有data部分,但是code仍要进行检查和弹出toast提示 24 | pub data: Option, 25 | } 26 | 27 | impl PublicResponseBody where T : Serialize { 28 | pub fn ok(data: T) -> Response { 29 | Self { 30 | code: 1, 31 | msg: "ok".to_owned(), 32 | data: Some(data), 33 | }.to_response() 34 | } 35 | 36 | pub fn ok_no_data() -> Response { 37 | Self { 38 | code: 1, 39 | msg: "ok".to_owned(), 40 | data: None, 41 | }.to_response() 42 | } 43 | 44 | pub fn err(reason: &str) -> Response { 45 | Self { 46 | code: -1, 47 | msg: reason.to_owned(), 48 | data: None, 49 | }.to_response() 50 | } 51 | 52 | pub fn err_token_expired(reason: &str) -> Response { 53 | Self { 54 | code: -2, 55 | msg: reason.to_owned(), 56 | data: None, 57 | }.to_response() 58 | } 59 | 60 | fn to_response(self) -> Response { 61 | let json = serde_json::to_string_pretty(&self).unwrap(); 62 | 63 | Response::builder() 64 | .header(axum::http::header::CONTENT_TYPE, "application/json;charset=UTF-8") 65 | .body(Body::new(json)) 66 | .unwrap() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /manager/src/web/api/public/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::extract::Path; 3 | use axum::extract::State; 4 | use axum::http::HeaderMap; 5 | use axum::response::Response; 6 | use reqwest::StatusCode; 7 | use tokio::io::AsyncSeekExt; 8 | 9 | use crate::utility::filename_ext::GetFileNamePart; 10 | use crate::utility::partial_read::PartialAsyncRead; 11 | use crate::web::webstate::WebState; 12 | 13 | pub async fn api_public(State(state): State, headers: HeaderMap, Path(path): Path) -> Response { 14 | println!("+public: {}", path); 15 | 16 | let path = state.apppath.public_dir.join(path); 17 | 18 | if !path.is_file() { 19 | return Response::builder().status(404).body(Body::empty()).unwrap(); 20 | } 21 | 22 | let range = headers.get("range") 23 | // 拿出range的值 24 | .map(|e| e.to_str().unwrap()) 25 | // 检查是否以bytes开头 26 | .filter(|e| e.starts_with("bytes=")) 27 | // 提取出bytes=后面的部分 28 | .map(|e| e["bytes=".len()..].split("-")) 29 | // 提取出开始字节和结束字节 30 | .and_then(|mut e| Some((e.next()?, e.next()?))) 31 | // 解析开始字节和结束字节 32 | .and_then(|e| Some((u64::from_str_radix(e.0, 10).ok()?, u64::from_str_radix(e.1, 10).ok()? + 1))) 33 | // 开始和结束不能都等于0 34 | .filter(|e| e != &(0, 0)) 35 | // 转换成range 36 | .map(|e| e.0..e.1); 37 | 38 | // 检查range参数 39 | if let Some(range) = &range { 40 | if range.end < range.start { 41 | return Response::builder().status(403).body(Body::from("incorrect range")).unwrap(); 42 | } 43 | } 44 | 45 | let metadata = tokio::fs::metadata(&path).await.unwrap(); 46 | 47 | let mut file = tokio::fs::File::options() 48 | .read(true) 49 | .open(&path) 50 | .await 51 | .unwrap(); 52 | 53 | if let Some(range) = &range { 54 | file.seek(std::io::SeekFrom::Start(range.start)).await.unwrap(); 55 | } 56 | 57 | let len = match &range { 58 | Some(range) => range.end - range.start, 59 | None => metadata.len(), 60 | }; 61 | 62 | let file = tokio_util::io::ReaderStream::new(PartialAsyncRead::new(file, len)); 63 | 64 | let mut builder = Response::builder(); 65 | 66 | builder = builder.header(axum::http::header::CONTENT_TYPE, "application/octet-stream"); 67 | builder = builder.header(axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", path.filename())); 68 | builder = builder.header(axum::http::header::CONTENT_LENGTH, format!("{}", len)); 69 | 70 | if let Some(range) = &range { 71 | builder = builder.header(axum::http::header::CONTENT_RANGE, format!("{}-{}/{}", range.start, range.end - 1, metadata.len())); 72 | builder = builder.status(StatusCode::PARTIAL_CONTENT); 73 | } 74 | 75 | builder.body(Body::from_stream(file)).unwrap() 76 | } 77 | -------------------------------------------------------------------------------- /manager/src/web/api/task/check.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::http::HeaderMap; 3 | use axum::response::Response; 4 | 5 | use crate::task::check::task_check; 6 | use crate::web::webstate::WebState; 7 | 8 | /// 检查工作空间目录的文件修改情况,类似于git status命令 9 | pub async fn api_status(State(state): State, headers: HeaderMap) -> Response { 10 | let wait = headers.get("wait").is_some(); 11 | 12 | state.clone().te.lock().await 13 | .try_schedule(wait, state.clone(), move || do_status(state)).await 14 | } 15 | 16 | fn do_status(state: WebState) -> u8 { 17 | task_check(&state.apppath, &state.config, &state.console) 18 | } 19 | -------------------------------------------------------------------------------- /manager/src/web/api/task/combine.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::http::HeaderMap; 3 | use axum::response::Response; 4 | 5 | use crate::task::combine::task_combine; 6 | use crate::web::webstate::WebState; 7 | 8 | // 执行更新包合并操作 9 | pub async fn api_combine(State(state): State, headers: HeaderMap) -> Response { 10 | let wait = headers.get("wait").is_some(); 11 | 12 | state.clone().te.lock().await 13 | .try_schedule(wait, state.clone(), move || do_combine(state)).await 14 | } 15 | 16 | fn do_combine(state: WebState) -> u8 { 17 | task_combine(&state.apppath, &state.config, &state.console) 18 | } 19 | -------------------------------------------------------------------------------- /manager/src/web/api/task/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod pack; 2 | pub mod test; 3 | pub mod combine; 4 | pub mod check; 5 | pub mod revert; 6 | pub mod sync; 7 | -------------------------------------------------------------------------------- /manager/src/web/api/task/pack.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::http::HeaderMap; 3 | use axum::response::Response; 4 | use axum::Json; 5 | use serde::Deserialize; 6 | 7 | use crate::task::pack::task_pack; 8 | use crate::web::webstate::WebState; 9 | 10 | #[derive(Deserialize)] 11 | pub struct RequestBody { 12 | /// 新包的版本号 13 | label: String, 14 | 15 | /// 新包的更新记录 16 | change_logs: String, 17 | } 18 | 19 | /// 打包新版本 20 | pub async fn api_pack(State(state): State, headers: HeaderMap, Json(payload): Json) -> Response { 21 | let wait = headers.get("wait").is_some(); 22 | 23 | state.clone().te.lock().await 24 | .try_schedule(wait, state.clone(), move || do_check(payload, state)).await 25 | } 26 | 27 | fn do_check(payload: RequestBody, state: WebState) -> u8 { 28 | let version_label = payload.label; 29 | let change_logs = payload.change_logs; 30 | 31 | task_pack(version_label, change_logs, &state.apppath, &state.config, &state.console) 32 | } -------------------------------------------------------------------------------- /manager/src/web/api/task/revert.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::http::HeaderMap; 3 | use axum::response::Response; 4 | 5 | use crate::task::revert::task_revert; 6 | use crate::web::webstate::WebState; 7 | 8 | /// 恢复工作空间目录到未修改的时候 9 | /// 10 | /// 有时可能修改了工作空间目录下的文件,但是觉得不满意,想要退回未修改之前,那么可以使用revert命令 11 | pub async fn api_revert(State(state): State, headers: HeaderMap) -> Response { 12 | let wait = headers.get("wait").is_some(); 13 | 14 | state.clone().te.lock().await 15 | .try_schedule(wait, state.clone(), move || do_revert(state)).await 16 | } 17 | 18 | pub fn do_revert(state: WebState) -> u8 { 19 | task_revert(&state.apppath, &state.config, &state.console) 20 | } -------------------------------------------------------------------------------- /manager/src/web/api/task/sync.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::http::HeaderMap; 3 | use axum::response::Response; 4 | 5 | use crate::task::sync::task_upload; 6 | use crate::web::webstate::WebState; 7 | 8 | /// 同步public目录 9 | pub async fn api_upload_api(State(state): State, headers: HeaderMap) -> Response { 10 | let wait = headers.get("wait").is_some(); 11 | 12 | state.clone().te.lock().await 13 | .try_schedule(wait, state.clone(), move || do_upload(state)).await 14 | } 15 | 16 | fn do_upload(state: WebState) -> u8 { 17 | task_upload(&state.apppath, &state.config, &state.console) 18 | } 19 | -------------------------------------------------------------------------------- /manager/src/web/api/task/test.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::http::HeaderMap; 3 | use axum::response::Response; 4 | 5 | use crate::task::test::task_test; 6 | use crate::web::webstate::WebState; 7 | 8 | /// 执行更新包解压测试 9 | pub async fn api_test(State(state): State, headers: HeaderMap) -> Response { 10 | let wait = headers.get("wait").is_some(); 11 | 12 | state.clone().te.lock().await 13 | .try_schedule(wait, state.clone(), move || do_test(state)).await 14 | } 15 | 16 | fn do_test(state: WebState) -> u8 { 17 | task_test(&state.apppath, &state.config, &state.console) 18 | } -------------------------------------------------------------------------------- /manager/src/web/api/terminal/full.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use serde::Serialize; 4 | 5 | use crate::web::api::PublicResponseBody; 6 | use crate::web::log::LogOutputed; 7 | use crate::web::webstate::WebState; 8 | 9 | #[derive(Serialize)] 10 | pub struct ResponseData { 11 | /// 返回的日志文本 12 | pub content: Vec, 13 | } 14 | 15 | pub async fn api_full(State(state): State) -> Response { 16 | let console = &state.console; 17 | 18 | let buf = console.get_logs(true); 19 | 20 | PublicResponseBody::::ok(ResponseData { content: buf }) 21 | } -------------------------------------------------------------------------------- /manager/src/web/api/terminal/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod more; 2 | pub mod full; 3 | -------------------------------------------------------------------------------- /manager/src/web/api/terminal/more.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use serde::Serialize; 4 | 5 | use crate::web::api::PublicResponseBody; 6 | use crate::web::log::LogOutputed; 7 | use crate::web::webstate::WebState; 8 | 9 | #[derive(Serialize)] 10 | pub struct ResponseData { 11 | /// 返回的日志文本 12 | pub content: Vec, 13 | } 14 | 15 | pub async fn api_more(State(state): State) -> Response { 16 | let console = &state.console; 17 | 18 | let buf = console.get_logs(false); 19 | 20 | PublicResponseBody::::ok(ResponseData { content: buf }) 21 | } -------------------------------------------------------------------------------- /manager/src/web/api/user/change_password.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use axum::Json; 4 | use serde::Deserialize; 5 | 6 | use crate::web::api::PublicResponseBody; 7 | use crate::web::webstate::WebState; 8 | 9 | #[derive(Deserialize)] 10 | pub struct RequestBody { 11 | /// 旧密码 12 | old_password: String, 13 | 14 | /// 新密码 15 | new_password: String, 16 | } 17 | 18 | pub async fn api_change_password(State(state): State, Json(payload): Json) -> Response { 19 | let mut auth = state.auth; 20 | 21 | if !auth.test_password(&payload.old_password).await { 22 | return PublicResponseBody::<()>::err("incorrect current password"); 23 | } 24 | 25 | // 修改密码 26 | auth.set_password(&payload.new_password).await; 27 | 28 | // 使token失效 29 | auth.clear_token().await; 30 | 31 | auth.save().await; 32 | 33 | PublicResponseBody::<()>::ok_no_data() 34 | } -------------------------------------------------------------------------------- /manager/src/web/api/user/change_username.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use axum::Json; 4 | use serde::Deserialize; 5 | 6 | use crate::web::api::PublicResponseBody; 7 | use crate::web::webstate::WebState; 8 | 9 | #[derive(Deserialize)] 10 | pub struct RequestBody { 11 | /// 新用户名 12 | new_username: String, 13 | } 14 | 15 | pub async fn api_change_username(State(state): State, Json(payload): Json) -> Response { 16 | let mut auth = state.auth; 17 | 18 | // 修改用户名 19 | auth.set_username(&payload.new_username).await; 20 | 21 | // 使token失效 22 | auth.clear_token().await; 23 | 24 | auth.save().await; 25 | 26 | PublicResponseBody::<()>::ok_no_data() 27 | } -------------------------------------------------------------------------------- /manager/src/web/api/user/check_token.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | 4 | use crate::web::api::PublicResponseBody; 5 | use crate::web::webstate::WebState; 6 | 7 | pub async fn api_check_token(State(_state): State) -> Response { 8 | PublicResponseBody::<()>::ok_no_data() 9 | } -------------------------------------------------------------------------------- /manager/src/web/api/user/login.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | use axum::Json; 4 | use serde::Deserialize; 5 | use serde::Serialize; 6 | 7 | use crate::web::api::PublicResponseBody; 8 | use crate::web::webstate::WebState; 9 | 10 | #[derive(Deserialize)] 11 | pub struct RequestBody { 12 | /// 用户名 13 | username: String, 14 | 15 | /// 密码 16 | password: String, 17 | } 18 | 19 | #[derive(Serialize)] 20 | pub struct ResponseData { 21 | pub token: String, 22 | } 23 | 24 | pub async fn api_login(State(state): State, Json(payload): Json) -> Response { 25 | let mut auth = state.auth; 26 | 27 | let ok = auth.test_username(&payload.username).await && auth.test_password(&payload.password).await; 28 | 29 | if !ok { 30 | return PublicResponseBody::::err("incorrect username or password"); 31 | } 32 | 33 | // 生成新的token 34 | let new_token = auth.regen_token().await; 35 | 36 | auth.save().await; 37 | 38 | PublicResponseBody::::ok(ResponseData { token: new_token }) 39 | } -------------------------------------------------------------------------------- /manager/src/web/api/user/logout.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::State; 2 | use axum::response::Response; 3 | 4 | use crate::web::api::PublicResponseBody; 5 | use crate::web::webstate::WebState; 6 | 7 | pub async fn api_logout(State(state): State) -> Response { 8 | let mut auth = state.auth; 9 | 10 | auth.clear_token().await; 11 | 12 | auth.save().await; 13 | 14 | PublicResponseBody::<()>::ok_no_data() 15 | } -------------------------------------------------------------------------------- /manager/src/web/api/user/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod login; 2 | pub mod logout; 3 | pub mod change_password; 4 | pub mod change_username; 5 | pub mod check_token; 6 | -------------------------------------------------------------------------------- /manager/src/web/api/webpage/mod.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::extract::Path; 3 | use axum::extract::State; 4 | use axum::response::Response; 5 | 6 | use crate::web::webstate::WebState; 7 | 8 | #[cfg(feature = "bundle-webpage")] 9 | static WEBPAGE_DIR: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/../web/dist"); 10 | 11 | pub async fn api_webpage(State(state): State, Path(path): Path) -> Response { 12 | respond_file(&path, &state).await 13 | } 14 | 15 | pub async fn api_webpage_index(State(state): State) -> Response { 16 | respond_file("", &state).await 17 | } 18 | 19 | async fn respond_file(mut path: &str, state: &WebState) -> Response { 20 | let raw_path = path.to_owned(); 21 | 22 | if path == "" { 23 | path = &state.config.web.index_filename; 24 | } 25 | 26 | // 当外部文件夹存在时,优先从外部文件夹响应 27 | if state.apppath.web_dir.exists() { 28 | println!("+webpage-o /{}", raw_path); 29 | 30 | return respond_from_outer(path, state).await; 31 | } 32 | 33 | println!("+webpage-i /{}", raw_path); 34 | 35 | // 从文件内部响应 36 | #[cfg(feature = "bundle-webpage")] 37 | return respond_from_inner(path, state).await; 38 | 39 | #[allow(unreachable_code)] 40 | { panic!("the webpdage folder does not exist: {}", state.apppath.web_dir.to_str().unwrap()); } 41 | } 42 | 43 | /// 从可执行文件内部响应页面文件请求 44 | #[cfg(feature = "bundle-webpage")] 45 | async fn respond_from_inner(mut path: &str, state: &WebState) -> Response { 46 | // println!("inner"); 47 | 48 | // 文件找不到就尝试访问404文件 49 | if !WEBPAGE_DIR.contains(path) && !state.config.web.redirect_404.is_empty() { 50 | path = &state.config.web.redirect_404; 51 | } 52 | 53 | // 如果还是找不到就返回404了 54 | if !WEBPAGE_DIR.contains(path) { 55 | return Response::builder().status(404).body(Body::empty()).unwrap(); 56 | } 57 | 58 | // 下面正常处理请求 59 | let file = WEBPAGE_DIR.get_file(path).unwrap(); 60 | 61 | let contents = file.contents(); 62 | 63 | let mime_info = mime_guess::from_path(path).first_or(mime_guess::mime::APPLICATION_OCTET_STREAM); 64 | 65 | return Response::builder() 66 | .header(axum::http::header::CONTENT_TYPE, mime_info.essence_str()) 67 | .header(axum::http::header::CONTENT_LENGTH, format!("{}", contents.len())) 68 | .body(Body::from_stream(tokio_util::io::ReaderStream::new(contents))) 69 | .unwrap(); 70 | } 71 | 72 | /// 从外部的webpage目录响应页面文件请求 73 | async fn respond_from_outer(path: &str, state: &WebState) -> Response { 74 | // println!("outer"); 75 | 76 | let mut path = state.apppath.web_dir.join(path); 77 | 78 | // 文件找不到就尝试访问404文件 79 | if !path.is_file() && !state.config.web.redirect_404.is_empty() { 80 | path = state.apppath.web_dir.join(&state.config.web.redirect_404); 81 | } 82 | 83 | // 如果还是找不到就返回404了 84 | if !path.is_file() { 85 | return Response::builder().status(404).body(Body::empty()).unwrap(); 86 | } 87 | 88 | let metadata = tokio::fs::metadata(&path).await.unwrap(); 89 | 90 | let file = tokio::fs::File::options() 91 | .read(true) 92 | .open(&path) 93 | .await 94 | .unwrap(); 95 | 96 | let mime_info = mime_guess::from_path(path).first_or(mime_guess::mime::APPLICATION_OCTET_STREAM); 97 | 98 | Response::builder() 99 | .header(axum::http::header::CONTENT_TYPE, mime_info.essence_str()) 100 | .header(axum::http::header::CONTENT_LENGTH, format!("{}", metadata.len())) 101 | .body(Body::from_stream(tokio_util::io::ReaderStream::new(file))) 102 | .unwrap() 103 | } 104 | 105 | -------------------------------------------------------------------------------- /manager/src/web/auth_layer.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::pin::Pin; 3 | use std::task::Poll; 4 | 5 | use axum::body::Body; 6 | use axum::http::Request; 7 | use axum::http::Response; 8 | use tower_layer::Layer; 9 | use tower_service::Service; 10 | 11 | use crate::web::api::PublicResponseBody; 12 | use crate::web::webstate::WebState; 13 | 14 | /// 身份认证middleware 15 | #[derive(Clone)] 16 | pub struct AuthLayer { 17 | webstate: WebState 18 | } 19 | 20 | impl AuthLayer { 21 | pub fn new(webstate: WebState) -> Self { 22 | Self { webstate } 23 | } 24 | } 25 | 26 | impl Layer for AuthLayer { 27 | type Service = AuthService; 28 | 29 | fn layer(&self, service: S) -> Self::Service { 30 | AuthService { 31 | webstate: self.webstate.clone(), 32 | service 33 | } 34 | } 35 | } 36 | 37 | #[derive(Clone)] 38 | pub struct AuthService { 39 | webstate: WebState, 40 | service: S, 41 | } 42 | 43 | impl Service> for AuthService where 44 | S: Service, Response = Response>, 45 | S::Future: Send + 'static, 46 | // Req: Send + 'static, 47 | // Rsp: Send + 'static, 48 | { 49 | type Response = S::Response; 50 | type Error = S::Error; 51 | type Future = Pin> + Send>>; 52 | 53 | fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { 54 | self.service.poll_ready(cx) 55 | } 56 | 57 | fn call(&mut self, req: Request) -> Self::Future { 58 | let uri = req.uri().to_string(); 59 | println!("url = {:?}", uri); 60 | 61 | let webstate = self.webstate.clone(); 62 | 63 | // 获取token 64 | let token_header = match req.headers().get("token") { 65 | Some(ok) => ok.to_str().unwrap().to_owned(), 66 | None => "".to_owned(), 67 | }; 68 | 69 | let fut = self.service.call(req); 70 | 71 | Box::pin(async move { 72 | // 如果token验证失败,就不调用后面的逻辑,直接返回错误 73 | if let Err(reason) = webstate.auth.validate_token(&token_header).await { 74 | return Ok(PublicResponseBody::<()>::err_token_expired(reason)); 75 | } 76 | 77 | // 请求继续往后走 78 | fut.await 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /manager/src/web/file_status.rs: -------------------------------------------------------------------------------- 1 | use std::rc::Weak; 2 | 3 | use crate::app_path::AppPath; 4 | use crate::core::data::index_file::IndexFile; 5 | use crate::core::tar_reader::TarReader; 6 | use crate::config::Config; 7 | use crate::diff::abstract_file::AbstractFile; 8 | use crate::diff::diff::Diff; 9 | use crate::diff::disk_file::DiskFile; 10 | use crate::diff::history_file::HistoryFile; 11 | 12 | pub struct FileStatus { 13 | pub app_path: AppPath, 14 | pub config: Config, 15 | pub status: Option, 16 | } 17 | 18 | impl FileStatus { 19 | pub fn new(app_path: AppPath, config: Config) -> Self { 20 | Self { app_path, config, status: None } 21 | } 22 | 23 | /// 清空缓存,下次会进行重建 24 | pub fn invalidate(&mut self) { 25 | self.status = None; 26 | } 27 | 28 | /// 获取一个文件的修改状态 29 | pub async fn get_file_status(&mut self, path: &str) -> SingleFileStatus { 30 | let status = self.refresh().await; 31 | 32 | let path = &path.to_string(); 33 | 34 | // println!("> {}", path); 35 | 36 | if status.added_folders.contains(path) { 37 | // println!("1 {}", join_string(status.added_folders.iter().map(|e| e.to_owned()), "\n")); 38 | return SingleFileStatus::Added; 39 | } 40 | 41 | if status.added_files.contains(path) { 42 | // println!("2"); 43 | return SingleFileStatus::Added; 44 | } 45 | 46 | if status.modified_files.contains(path) { 47 | // println!("3"); 48 | return SingleFileStatus::Modified; 49 | } 50 | 51 | if status.missing_folders.contains(path) { 52 | // println!("4"); 53 | return SingleFileStatus::Missing; 54 | } 55 | 56 | if status.missing_files.contains(path) { 57 | // println!("5"); 58 | return SingleFileStatus::Missing; 59 | } 60 | 61 | if status.gone_files.contains(path) { 62 | // println!("6"); 63 | return SingleFileStatus::Gone; 64 | } 65 | 66 | if status.come_files.contains(path) { 67 | // println!("7"); 68 | return SingleFileStatus::Come; 69 | } 70 | 71 | // 如果目录下有文件有变动,也要视为修改状态 72 | if status.added_folders.iter().any(|e| e.starts_with(path)) { 73 | // println!("a"); 74 | return SingleFileStatus::Modified; 75 | } 76 | if status.added_files.iter().any(|e| e.starts_with(path)) { 77 | // println!("b"); 78 | return SingleFileStatus::Modified; 79 | } 80 | if status.modified_files.iter().any(|e| e.starts_with(path)) { 81 | // println!("c"); 82 | return SingleFileStatus::Modified; 83 | } 84 | if status.missing_folders.iter().any(|e| e.starts_with(path)) { 85 | // println!("d"); 86 | return SingleFileStatus::Modified; 87 | } 88 | if status.missing_files.iter().any(|e| e.starts_with(path)) { 89 | // println!("e"); 90 | return SingleFileStatus::Modified; 91 | } 92 | if status.gone_files.iter().any(|e| e.starts_with(path)) { 93 | // println!("f"); 94 | return SingleFileStatus::Modified; 95 | } 96 | if status.come_files.iter().any(|e| e.starts_with(path)) { 97 | // println!("g"); 98 | return SingleFileStatus::Modified; 99 | } 100 | 101 | // println!("8"); 102 | return SingleFileStatus::Keep; 103 | } 104 | 105 | /// 尝试重新生成文件状态缓存 106 | async fn refresh(&mut self) -> &Status { 107 | if self.status.is_none() { 108 | println!("rebuild cache"); 109 | 110 | let app_path = &self.app_path; 111 | 112 | // 读取现有更新包,并复现在history上 113 | let index_file = IndexFile::load_from_file(&app_path.index_file); 114 | 115 | let mut history = HistoryFile::new_empty(); 116 | 117 | for v in &index_file { 118 | let mut reader = TarReader::new(app_path.public_dir.join(&v.filename)); 119 | let meta_group = reader.read_metadata_group(v.offset, v.len); 120 | 121 | for meta in meta_group { 122 | history.replay_operations(&meta); 123 | } 124 | } 125 | 126 | // 对比文件 127 | let exclude_rules = &self.config.core.exclude_rules; 128 | let disk_file = DiskFile::new(app_path.workspace_dir.clone(), Weak::new()); 129 | let diff = Diff::diff(&disk_file, &history, Some(&exclude_rules)); 130 | 131 | let mut status = Status::default(); 132 | 133 | for f in diff.added_folders { 134 | status.added_folders.push(f.path().to_owned()); 135 | } 136 | 137 | for f in diff.added_files { 138 | status.added_files.push(f.path().to_owned()); 139 | } 140 | 141 | for f in diff.modified_files { 142 | status.modified_files.push(f.path().to_owned()); 143 | } 144 | 145 | for f in diff.missing_folders { 146 | status.missing_folders.push(f.path().to_owned()); 147 | } 148 | 149 | for f in diff.missing_files { 150 | status.missing_files.push(f.path().to_owned()); 151 | } 152 | 153 | for f in diff.renamed_files { 154 | status.gone_files.push(f.0.path().to_owned()); 155 | status.come_files.push(f.1.path().to_owned()); 156 | } 157 | 158 | self.status = Some(status); 159 | } 160 | 161 | return &self.status.as_ref().unwrap(); 162 | } 163 | } 164 | 165 | pub enum SingleFileStatus { 166 | /// 文件无变更 167 | Keep, 168 | 169 | /// 新增的文件或者目录 170 | Added, 171 | 172 | /// 修改的文件或者目录 173 | Modified, 174 | 175 | /// 删除的文件或者目录 176 | Missing, 177 | 178 | /// 被移动走的的文件或者目录 179 | Gone, 180 | 181 | /// 被移动过来的文件或者目录 182 | Come, 183 | } 184 | 185 | /// 保存计算出来的文件状态缓存 186 | #[derive(Default)] 187 | pub struct Status { 188 | pub added_folders: Vec, 189 | pub added_files: Vec, 190 | pub modified_files: Vec, 191 | pub missing_folders: Vec, 192 | pub missing_files: Vec, 193 | pub gone_files: Vec, 194 | pub come_files: Vec, 195 | } 196 | -------------------------------------------------------------------------------- /manager/src/web/log.rs: -------------------------------------------------------------------------------- 1 | use std::collections::LinkedList; 2 | use std::sync::Arc; 3 | use std::sync::Mutex; 4 | use std::time::SystemTime; 5 | 6 | use serde::ser::SerializeMap; 7 | use serde::Serialize; 8 | 9 | pub const MAX_LOGS: usize = 1000; 10 | 11 | #[derive(PartialEq)] 12 | enum Mode { 13 | Cli, Webui 14 | } 15 | 16 | /// 代表一个日志缓冲区。负责收集各种任务运行中的输出 17 | #[derive(Clone)] 18 | pub struct Console { 19 | inner: Arc>, 20 | } 21 | 22 | impl Console { 23 | pub fn new_cli() -> Self { 24 | Self { 25 | inner: Arc::new(Mutex::new(Inner { buf: LinkedList::new(), mode: Mode::Cli })) 26 | } 27 | } 28 | 29 | pub fn new_webui() -> Self { 30 | Self { 31 | inner: Arc::new(Mutex::new(Inner { buf: LinkedList::new(), mode: Mode::Webui })) 32 | } 33 | } 34 | 35 | /// 获取目前的日志。 36 | /// 37 | /// + 若`full`为true,则获取所有的日志 38 | /// + 若`full`为false,则获取从上次调用此方法以来的新产生的日志 39 | pub fn get_logs<'a>(&'a self, full: bool) -> Vec { 40 | let mut lock = self.inner.lock().unwrap(); 41 | 42 | let mut entries = Vec::::new(); 43 | 44 | if full { 45 | for log in &mut lock.buf { 46 | log.read = true; 47 | } 48 | 49 | for line in &lock.buf { 50 | entries.push(LogOutputed { time: line.time, content: line.content.to_owned(), level: line.level.clone() }); 51 | } 52 | } else { 53 | for line in &lock.buf { 54 | if !line.read { 55 | entries.push(LogOutputed { time: line.time, content: line.content.to_owned(), level: line.level.clone() }); 56 | } 57 | } 58 | 59 | for log in &mut lock.buf { 60 | log.read = true; 61 | } 62 | } 63 | 64 | entries 65 | } 66 | 67 | /// 记录一条“调试”日志 68 | pub fn log_debug(&self, content: impl AsRef) { 69 | self.log(content, LogLevel::Debug); 70 | } 71 | 72 | /// 记录一条“普通”日志 73 | pub fn log_info(&self, content: impl AsRef) { 74 | self.log(content, LogLevel::Info); 75 | } 76 | 77 | /// 记录一条“警告”日志 78 | pub fn log_warning(&self, content: impl AsRef) { 79 | self.log(content, LogLevel::Warning); 80 | } 81 | 82 | /// 记录一条“错误”日志 83 | pub fn log_error(&self, content: impl AsRef) { 84 | self.log(content, LogLevel::Error); 85 | } 86 | 87 | /// 记录一条日志 88 | fn log(&self, content: impl AsRef, level: LogLevel) { 89 | let mut lock = self.inner.lock().unwrap(); 90 | 91 | for line in content.as_ref().split("\n") { 92 | println!("{}", line); 93 | 94 | if lock.mode == Mode::Webui { 95 | lock.buf.push_back(Line::new(line.to_owned(), level)); 96 | 97 | while lock.buf.len() > MAX_LOGS { 98 | lock.buf.pop_front(); 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | pub struct Inner { 106 | pub buf: LinkedList, 107 | mode: Mode, 108 | } 109 | 110 | /// 代表单条日志,序列化专用 111 | pub struct LogOutputed { 112 | /// 日志的产生时间 113 | pub time: SystemTime, 114 | 115 | /// 日志的内容 116 | pub content: String, 117 | 118 | /// 日志的重要等级 119 | pub level: LogLevel, 120 | } 121 | 122 | impl Serialize for LogOutputed { 123 | fn serialize(&self, serializer: S) -> Result where S: serde::Serializer { 124 | let unix_ts = self.time.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs(); 125 | 126 | let mut map = serializer.serialize_map(Some(3))?; 127 | map.serialize_entry("time", &unix_ts)?; 128 | map.serialize_entry("content", &self.content)?; 129 | map.serialize_entry("level", &self.level)?; 130 | map.end() 131 | } 132 | } 133 | 134 | #[derive(Clone)] 135 | pub struct Line { 136 | /// 这条日志被阅读过吗 137 | pub read: bool, 138 | 139 | /// 日志的产生时间 140 | pub time: SystemTime, 141 | 142 | /// 日志的内容 143 | pub content: String, 144 | 145 | /// 日志的重要等级 146 | pub level: LogLevel, 147 | } 148 | 149 | impl Line { 150 | pub fn new(content: String, level: LogLevel) -> Self { 151 | Self { 152 | read: false, 153 | time: SystemTime::now(), 154 | content, 155 | level, 156 | } 157 | } 158 | } 159 | 160 | #[derive(Clone, Copy)] 161 | pub enum LogLevel { 162 | Debug, 163 | Info, 164 | Warning, 165 | Error, 166 | } 167 | 168 | impl Serialize for LogLevel { 169 | fn serialize(&self, serializer: S) -> Result where S: serde::Serializer { 170 | let text = match self { 171 | LogLevel::Debug => "debug", 172 | LogLevel::Info => "info", 173 | LogLevel::Warning => "warning", 174 | LogLevel::Error => "error", 175 | }; 176 | 177 | serializer.collect_str(text) 178 | } 179 | } -------------------------------------------------------------------------------- /manager/src/web/task_executor.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | use std::sync::Arc; 3 | use std::time::Duration; 4 | 5 | use axum::body::Body; 6 | use axum::response::Response; 7 | use tokio::sync::Mutex; 8 | 9 | use crate::web::api::PublicResponseBody; 10 | use crate::web::webstate::WebState; 11 | 12 | /// 代表长时间任务执行器 13 | pub struct LongTimeExecutor { 14 | busy: Arc>, 15 | } 16 | 17 | impl LongTimeExecutor { 18 | pub fn new() -> Self { 19 | Self { 20 | busy: Arc::new(Mutex::new(false)) 21 | } 22 | } 23 | 24 | /// 尝试执行一个任务。并直接生成Response对象 25 | pub async fn try_schedule(&self, wait: bool, state: WebState, f: F) -> Response where 26 | F: FnOnce() -> u8, 27 | F: Send + 'static 28 | { 29 | // 同时只能有一个任务在运行 30 | if self.is_busy().await { 31 | return PublicResponseBody::<()>::err("it is busy now") 32 | } 33 | 34 | // 先把缓冲区里所有的日志标记为已读 35 | state.console.get_logs(true); 36 | 37 | // 执行任务 38 | let code = self.schedule(wait, f).await; 39 | 40 | // 如果不等待的话,就直接返回 41 | if !wait { 42 | return PublicResponseBody::<()>::ok_no_data(); 43 | } 44 | 45 | // 拿到任务返回代码 46 | let code = code.unwrap(); 47 | 48 | // 收集期间的所有日志输出 49 | let mut buf = String::with_capacity(1024); 50 | 51 | for log in state.console.get_logs(false) { 52 | buf += &log.content; 53 | buf += "\n"; 54 | } 55 | 56 | // 将日志输出写到Response里 57 | Response::builder() 58 | .status(if code == 0 { 200 } else { 500 }) 59 | .body(Body::new(buf)) 60 | .unwrap() 61 | } 62 | 63 | /// 当然有任务在执行吗 64 | async fn is_busy(&self) -> bool { 65 | *self.busy.lock().await 66 | } 67 | 68 | /// 执行一个任务 69 | /// 70 | /// + 当`wait`为true时,会等待任务结束后返回,同时携带返回代码 71 | /// + 当`wait`为false时,会立即返回,没有返回代码 72 | async fn schedule(&self, wait: bool, f: F) -> Option where 73 | F: FnOnce() -> u8, 74 | F: Send + 'static 75 | { 76 | assert!(!self.is_busy().await); 77 | 78 | // 设置busy标记 79 | *self.busy.lock().await = true; 80 | 81 | let busy_clone = self.busy.clone(); 82 | let returns = Arc::new(Mutex::new(0u8)); 83 | 84 | // 准备启动单独线程执行任务 85 | let returns2 = returns.clone(); 86 | let handle = std::thread::Builder::new() 87 | .name("mcpatch-task".into()) 88 | .spawn(move || { 89 | // 执行任务 90 | let value = f(); 91 | 92 | // 保存返回代码 93 | *returns2.blocking_lock() = value; 94 | 95 | // 设置busy标记 96 | *busy_clone.blocking_lock() = false; 97 | }) 98 | .unwrap(); 99 | 100 | // 如果不等待,立即返回 101 | if !wait { 102 | return None; 103 | } 104 | 105 | // 等待任务运行结束 106 | while !handle.is_finished() { 107 | tokio::time::sleep(Duration::from_millis(200)).await; 108 | } 109 | 110 | // 返回返回代码 111 | let v = *returns.lock().await.deref(); 112 | 113 | Some(v) 114 | } 115 | } -------------------------------------------------------------------------------- /manager/src/web/webstate.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use tokio::sync::Mutex; 4 | 5 | use crate::app_path::AppPath; 6 | use crate::config::auth_config::AuthConfig; 7 | use crate::config::Config; 8 | use crate::web::file_status::FileStatus; 9 | use crate::web::log::Console; 10 | use crate::web::task_executor::LongTimeExecutor; 11 | 12 | /// 整个web服务共享的上下文对象 13 | #[derive(Clone)] 14 | pub struct WebState { 15 | pub apppath: AppPath, 16 | pub config: Config, 17 | pub auth: AuthConfig, 18 | pub console: Console, 19 | pub te: Arc>, 20 | pub status: Arc>, 21 | } 22 | 23 | impl WebState { 24 | pub fn new(app_path: AppPath, config: Config, auth: AuthConfig) -> Self { 25 | Self { 26 | apppath: app_path.clone(), 27 | config: config.clone(), 28 | auth, 29 | console: Console::new_webui(), 30 | te: Arc::new(Mutex::new(LongTimeExecutor::new())), 31 | status: Arc::new(Mutex::new(FileStatus::new(app_path, config))), 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /manager/test/config.toml: -------------------------------------------------------------------------------- 1 | [core] 2 | exclude-rules = [] 3 | webui-mode = false 4 | 5 | [web] 6 | listen-addr = "0.0.0.0" 7 | listen-port = 6710 8 | tls-cert-file = "" 9 | tls-key-file = "" 10 | cors-allow-credentials = false 11 | cors-allow-headers = ["*"] 12 | cors-allow-methods = ["*"] 13 | cors-allow-origin = ["*"] 14 | cors-allow-private-network = false 15 | cors-expose-headers = ["*"] 16 | index-filename = "index.html" 17 | redirect-404 = "index.html" 18 | 19 | [builtin-server] 20 | enabled = true 21 | listen-addr = "0.0.0.0" 22 | listen-port = 6700 23 | capacity = 0 24 | regain = 0 25 | 26 | [s3] 27 | enabled = false 28 | endpoint = "" 29 | bucket = "" 30 | region = "" 31 | access-id = "" 32 | secret-key = "" 33 | 34 | [webdav] 35 | enabled = false 36 | host = "" 37 | username = "" 38 | password = "" 39 | -------------------------------------------------------------------------------- /manager/test/user.toml: -------------------------------------------------------------------------------- 1 | username = "admin" 2 | password = "59c87f5bd0c2321110992a710e517cf389c01317604ae40eb48fcd41b395d27f" 3 | token = "" 4 | expire = 0 5 | -------------------------------------------------------------------------------- /pe_version_info.txt: -------------------------------------------------------------------------------- 1 | fn main() { 2 | // 为windows平台增加pe文件版本号信息 3 | #[cfg(target_os = "windows")] 4 | { 5 | use std::path::PathBuf; 6 | 7 | let rc_file = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("pe.rc"); 8 | 9 | println!("cargo:rerun-if-changed={}", rc_file.to_str().unwrap()); 10 | 11 | let major = env!("CARGO_PKG_VERSION_MAJOR"); 12 | let minor = env!("CARGO_PKG_VERSION_MINOR"); 13 | let patch = env!("CARGO_PKG_VERSION_PATCH"); 14 | let pre = if env!("CARGO_PKG_VERSION_PRE").is_empty() { "0" } else { env!("CARGO_PKG_VERSION_PRE") }; 15 | 16 | println!("1 VERSIONINFO FILEVERSION {major},{minor},{patch},{pre} {{ }}"); 17 | 18 | let rc_content = format!("1 VERSIONINFO FILEVERSION {major},{minor},{patch},{pre} {{ }}"); 19 | 20 | std::fs::write(&rc_file, rc_content).unwrap(); 21 | 22 | embed_resource::compile(&rc_file, embed_resource::NONE); 23 | } 24 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ### McPatch2 2 | 3 | McPatch第二版的管理端的源代码(也包括web页面源代码)。 4 | 5 | ### crates说明 6 | 7 | | 名称 | 用途 | 8 | | ---------------------- | ------------------------------------------------------------ | 9 | | manager | 管理端主程序。负责更新包的打包和管理工作,也提供内置开箱即用的内置服务端 | 10 | | xtask | 用于ci/cd自动化打包的行为和命令 | 11 | 12 | ### 常用命令说明 13 | 14 | | 命令 | 作用 | 15 | | ----------------------------------- | ------------------------------------ | 16 | | `cargo dev` | 开发场景下,启动管理端程序进行测试 | 17 | | `cargo ci` | 自动构建场景下,打包管理端 | 18 | -------------------------------------------------------------------------------- /web/.env.development: -------------------------------------------------------------------------------- 1 | VITE_API_URL=http://localhost:6710/api 2 | -------------------------------------------------------------------------------- /web/.env.production: -------------------------------------------------------------------------------- 1 | VITE_API_URL=/api 2 | -------------------------------------------------------------------------------- /web/.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | contents: write 18 | id-token: write 19 | 20 | strategy: 21 | matrix: 22 | node-version: [ 22.x ] 23 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | cache: 'npm' 33 | - run: npm ci 34 | - run: npm run build --if-present 35 | 36 | - name: Compress dist directory 37 | run: | 38 | cd dist 39 | zip -r ../dist.zip . 40 | cd .. 41 | 42 | - name: Create Release 43 | id: create_release 44 | uses: actions/create-release@v1 45 | with: 46 | tag_name: ${{ github.ref }} 47 | release_name: ${{ github.ref }} 48 | body: "Release created from commit ${{ github.sha }}" 49 | prerelease: true 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Upload dist to Release 54 | uses: actions/upload-release-asset@v1 55 | with: 56 | upload_url: ${{ steps.create_release.outputs.upload_url }} 57 | asset_path: ./dist.zip 58 | asset_name: dist.zip 59 | asset_content_type: application/zip 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 asforest 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 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # McPatch全新2.0版本的webui前端仓库 2 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | McPatch 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mc-patch2-web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@reduxjs/toolkit": "^2.3.0", 13 | "antd": "^5.22.2", 14 | "axios": "^1.7.7", 15 | "lucide-react": "^0.460.0", 16 | "react": "^18.3.1", 17 | "react-dom": "^18.3.1", 18 | "react-redux": "^9.1.2", 19 | "react-router-dom": "^6.28.0" 20 | }, 21 | "devDependencies": { 22 | "@vitejs/plugin-react": "^4.3.3", 23 | "autoprefixer": "^10.4.20", 24 | "postcss": "^8.4.49", 25 | "tailwindcss": "^3.4.15", 26 | "vite": "^5.4.10" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BalloonUpdate/McPatch2/703a3b21e27bd0d7aba96b574a3fab697c342ced/web/public/favicon.ico -------------------------------------------------------------------------------- /web/src/api/fs.js: -------------------------------------------------------------------------------- 1 | import instance from "@/utils/request.js"; 2 | import axios from "axios"; 3 | import store from "@/store/index.js"; 4 | 5 | export const fsDiskInfoRequest = () => instance.post('/fs/disk-info', {}) 6 | 7 | export const fsListRequest = (path = '') => instance.post('/fs/list', {path}) 8 | 9 | export const fsMakeDirectoryRequest = (path = '') => instance.post('/fs/make-directory', {path}) 10 | 11 | export const fsDeleteRequest = (path = '') => instance.post('/fs/delete', {path}) 12 | 13 | export const fsSignFileRequest = (path = '') => instance.post('/fs/sign-file', {path}) 14 | 15 | export const fsUploadRequest = (path = '', file, onProgress) => { 16 | return axios.post(`${import.meta.env.VITE_API_URL}/fs/upload`, file, { 17 | headers: { 18 | 'Token': store.getState().user.token, 19 | 'Content-Type': 'application/octet-stream', 20 | 'Path': encodeURIComponent(path) 21 | }, 22 | onUploadProgress: (event) => { 23 | let percent = Math.floor((event.loaded / event.total) * 100); 24 | onProgress({percent}); 25 | }, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /web/src/api/misc.js: -------------------------------------------------------------------------------- 1 | import instance from "@/utils/request.js"; 2 | 3 | export const miscVersionListRequest = () => instance.post('/misc/version-list', {}) 4 | -------------------------------------------------------------------------------- /web/src/api/task.js: -------------------------------------------------------------------------------- 1 | import instance from "@/utils/request.js"; 2 | 3 | export const taskPackRequest = (label, changeLogs) => instance.post('/task/pack', { 4 | label: label, 5 | change_logs: changeLogs 6 | }) 7 | 8 | export const taskCombineRequest = () => instance.post('/task/combine', {}) 9 | 10 | export const taskTestRequest = () => instance.post('/task/test', {}) 11 | 12 | export const taskRevertRequest = () => instance.post('/task/revert', {}) 13 | 14 | export const taskUploadRequest = () => instance.post('/task/upload', {}) 15 | 16 | export const taskStatusRequest = () => instance.post('/task/status', {}) 17 | -------------------------------------------------------------------------------- /web/src/api/terminal.js: -------------------------------------------------------------------------------- 1 | import instance from "@/utils/request.js"; 2 | 3 | export const terminalFullRequest = () => instance.post('/terminal/full', {}) 4 | 5 | export const terminalMoreRequest = () => instance.post('/terminal/more', {}) 6 | -------------------------------------------------------------------------------- /web/src/api/user.js: -------------------------------------------------------------------------------- 1 | import instance from "@/utils/request.js"; 2 | 3 | export const userLoginRequest = (username, password) => instance.post('/user/login', {username, password}) 4 | 5 | export const userSignOutRequest = () => instance.post('/user/logout', {}) 6 | 7 | export const userChangeUsernameRequest = (newUsername) => instance.post('/user/change-username', { 8 | new_username: newUsername 9 | }) 10 | 11 | export const userChangePasswordRequest = (oldPassword, newPassword) => instance.post('/user/change-password', { 12 | old_password: oldPassword, 13 | new_password: newPassword 14 | }) 15 | 16 | export const userCheckTokenRequest = () => instance.post('/user/check-token', {}) 17 | -------------------------------------------------------------------------------- /web/src/assets/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | border: 0; 8 | padding: 0; 9 | } 10 | 11 | ::-webkit-scrollbar { 12 | width: 10px; 13 | height: 1px; 14 | } 15 | 16 | ::-webkit-scrollbar-thumb { 17 | border-radius: 10px; 18 | -webkit-box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2); 19 | background: #ccc; 20 | } 21 | 22 | ::-webkit-scrollbar-track { 23 | border-radius: 10px; 24 | -webkit-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0); 25 | } 26 | -------------------------------------------------------------------------------- /web/src/components/FileBreadcrumb/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Index = ({path, handlerBreadcrumb}) => { 4 | 5 | const items = ['root', ...path] 6 | return ( 7 | <> 8 |
9 |
工作目录
10 |
    11 | { 12 | items.map((item, index) => { 13 | return ( 14 |
  • 15 | { 16 | items.length - 1 !== index ? 17 |
    18 | 19 | {" / "} 20 |
    : 21 |
    22 | {item} 23 |
    24 | } 25 |
  • 26 | ) 27 | }) 28 | } 29 |
30 |
31 | 32 | ); 33 | }; 34 | 35 | export default Index; 36 | -------------------------------------------------------------------------------- /web/src/components/FolderButtonGroup/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {Button, Input, message, Modal, Upload} from "antd"; 3 | import {fsUploadRequest, fsMakeDirectoryRequest} from "@/api/fs.js"; 4 | 5 | const Index = ({path, getFileList}) => { 6 | 7 | const [makeDirectoryShow, setMakeDirectoryShow] = useState(false) 8 | const [directory, setDirectory] = useState(''); 9 | const [uploadFile, setUploadFile] = useState(null) 10 | const [uploadFileList, setUploadFileList] = useState([]); 11 | const [messageApi, contextHolder] = message.useMessage(); 12 | 13 | const makeDirectory = async () => { 14 | let key = path.join('/'); 15 | key = key.length === 0 ? directory : `${key}/${directory}` 16 | const {code, msg, data} = await fsMakeDirectoryRequest(key); 17 | if (code === 1) { 18 | setMakeDirectoryShow(false) 19 | messageApi.success('文件夹创建成功.') 20 | getFileList() 21 | } else { 22 | messageApi.error(msg) 23 | } 24 | } 25 | 26 | const uploadFileProps = { 27 | showUploadList: false, 28 | multiple: false, 29 | maxCount: 1, 30 | customRequest: async (options) => { 31 | const {file, onSuccess, onError, onProgress} = options; 32 | let key = path.join('/'); 33 | key = key.length === 0 ? file.name : `${key}/${file.name}` 34 | 35 | try { 36 | const res = await fsUploadRequest(key, file, onProgress); 37 | onSuccess(res); 38 | } catch (error) { 39 | onError(error); 40 | } 41 | }, 42 | onChange: (info) => { 43 | if (info.file.status === 'done') { 44 | messageApi.success('上传成功.') 45 | getFileList() 46 | } else if (info.file.status === 'error') { 47 | messageApi.error('上传失败.') 48 | } 49 | } 50 | }; 51 | 52 | const uploadFileExplorerProps = { 53 | showUploadList: false, 54 | directory: true, 55 | multiple: false, 56 | maxCount: 1, 57 | customRequest: async (options) => { 58 | const {file, onSuccess, onError, onProgress} = options; 59 | let key = path.join('/'); 60 | key = key.length === 0 ? file.webkitRelativePath : `${key}/${file.webkitRelativePath}` 61 | 62 | try { 63 | const res = await fsUploadRequest(key, file, onProgress); 64 | onSuccess(res); 65 | } catch (error) { 66 | onError(error); 67 | } 68 | }, 69 | onChange: (info) => { 70 | if (info.file.status === 'done') { 71 | messageApi.success('上传成功.') 72 | getFileList() 73 | } else if (info.file.status === 'error') { 74 | messageApi.error('上传失败.') 75 | } 76 | } 77 | }; 78 | 79 | return ( 80 | <> 81 | {contextHolder} 82 |
83 | 84 | 87 | 88 | 89 | 92 | 93 | 94 |
95 | 96 | makeDirectory()} 102 | onCancel={() => setMakeDirectoryShow(false)}> 103 |
104 | setDirectory(e.target.value)}/> 109 |
110 |
111 | 112 | ); 113 | }; 114 | 115 | export default Index; 116 | -------------------------------------------------------------------------------- /web/src/components/TileViewFileExplorer/FileItem/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Index = ({item}) => { 4 | 5 | const getBgColor = (item) => { 6 | if (item.state === 'added') return 'hover:bg-green-100'; 7 | if (item.state === 'modified') return 'hover:bg-yellow-100'; 8 | if (item.state === 'missing') return 'hover:bg-red-100'; 9 | if (item.state === 'gone') return 'hover:bg-cyan-100'; 10 | if (item.state === 'come') return 'hover:bg-violet-100'; 11 | return 'hover:bg-gray-200'; 12 | }; 13 | 14 | const getTextColor = (item) => { 15 | if (item.state === 'added') return 'text-green-500'; 16 | if (item.state === 'modified') return 'text-yellow-500'; 17 | if (item.state === 'missing') return 'text-red-500'; 18 | if (item.state === 'gone') return 'text-cyan-500'; 19 | if (item.state === 'come') return 'text-violet-500'; 20 | return 'text-gray-500'; 21 | }; 22 | 23 | return ( 24 | <> 25 |
28 |
29 | {item.is_directory ? '📁' : '📄'} 30 |
31 |
{item.name}
33 |
34 | 35 | ); 36 | }; 37 | 38 | export default Index; 39 | -------------------------------------------------------------------------------- /web/src/components/TileViewFileExplorer/index.css: -------------------------------------------------------------------------------- 1 | .text-item { 2 | @apply flex rounded-md items-center w-full text-sm text-gray-700 dark:text-white; 3 | } 4 | -------------------------------------------------------------------------------- /web/src/components/TileViewFileExplorer/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useRef, useState} from 'react'; 2 | import FileItem from "@/components/TileViewFileExplorer/FileItem/index.jsx"; 3 | import './index.css' 4 | import {fsDeleteRequest, fsSignFileRequest} from "@/api/fs.js"; 5 | import {message} from "antd"; 6 | import {showFileSize, showTime} from "@/utils/tool.js"; 7 | 8 | const Index = ({path, getFileList, items, handlerNextPath}) => { 9 | 10 | const [isOpen, setIsOpen] = useState(false); 11 | const [menuPosition, setMenuPosition] = useState({x: 0, y: 0}); 12 | const [isAnimating, setIsAnimating] = useState(false); 13 | const [selectedItem, setSelectedItem] = useState({}) 14 | const menuRef = useRef(null); 15 | const [messageApi, contextHolder] = message.useMessage(); 16 | 17 | const handleContextMenu = (e, index) => { 18 | e.preventDefault(); 19 | const {clientX: mouseX, clientY: mouseY} = e; 20 | setSelectedItem(items[index]); 21 | setMenuPosition({x: mouseX, y: mouseY}); 22 | setIsOpen(true); 23 | setIsAnimating(false) 24 | setTimeout(() => setIsAnimating(true), 5); 25 | }; 26 | 27 | const closeMenu = () => setIsOpen(false); 28 | 29 | useEffect(() => { 30 | const handleClickOutside = (e) => { 31 | if (menuRef.current && !menuRef.current.contains(e.target)) { 32 | closeMenu(); 33 | } 34 | }; 35 | 36 | document.addEventListener('click', handleClickOutside); 37 | 38 | return () => { 39 | document.removeEventListener('click', handleClickOutside); 40 | }; 41 | }, []); 42 | 43 | const fsOpenOrDownload = async (item) => { 44 | closeMenu() 45 | if (item.is_directory) { 46 | handlerNextPath(item) 47 | } else { 48 | let key = path.join('/'); 49 | key = key.length === 0 ? item.name : `${key}/${item.name}` 50 | 51 | const {code, msg, data} = await fsSignFileRequest(key); 52 | if (code === 1) { 53 | const link = document.createElement('a'); 54 | link.href = `${import.meta.env.VITE_API_URL}/fs/extract-file?sign=${data.signature}`; 55 | document.body.appendChild(link); 56 | link.click(); 57 | document.body.removeChild(link); 58 | } else { 59 | messageApi.error(msg); 60 | } 61 | } 62 | } 63 | 64 | const fsDelete = async (item) => { 65 | let key = path.join('/'); 66 | key = key.length === 0 ? item.name : `${key}/${item.name}` 67 | 68 | const {code, msg, data} = await fsDeleteRequest(key); 69 | if (code === 1) { 70 | messageApi.success('删除成功') 71 | getFileList() 72 | } else { 73 | messageApi.error(msg); 74 | } 75 | closeMenu() 76 | } 77 | 78 | return ( 79 | <> 80 | {contextHolder} 81 |
82 | { 83 | items.map((item, index) => ( 84 |
fsOpenOrDownload(item)} 87 | onContextMenu={(e) => handleContextMenu(e, index)} onClick={closeMenu}> 88 | 89 |
90 | )) 91 | } 92 |
93 | 94 | { 95 | isOpen ? 96 |
107 |
108 |
110 |
名称: {selectedItem.name}
111 |
类型: {selectedItem.is_directory ? "文件夹" : "文件"}
112 |
大小: {showFileSize(selectedItem.size)}
113 |
状态: {selectedItem.state}
114 |
创建时间: {showTime(selectedItem.ctime)}
115 |
修改时间: {showTime(selectedItem.mtime)}
116 |
117 | { 118 | selectedItem.is_directory && 119 | <> 120 | 125 | 126 | } 127 | { 128 | !selectedItem.is_directory && 129 | <> 130 | 135 | 136 | } 137 | { 138 | 143 | } 144 |
145 |
: <> 146 | } 147 | 148 | ); 149 | }; 150 | 151 | export default Index; 152 | -------------------------------------------------------------------------------- /web/src/main.jsx: -------------------------------------------------------------------------------- 1 | import {StrictMode} from 'react' 2 | import {createRoot} from 'react-dom/client' 3 | import '@/assets/index.css' 4 | 5 | import {RouterProvider} from "react-router-dom"; 6 | import {Provider} from "react-redux"; 7 | 8 | import router from "@/router/index.jsx"; 9 | import store from "@/store/index.js"; 10 | 11 | createRoot(document.getElementById('root')).render( 12 | 13 | 14 | 15 | 16 | 17 | ) 18 | -------------------------------------------------------------------------------- /web/src/pages/App.jsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import {Outlet} from "react-router-dom"; 3 | import {ConfigProvider, FloatButton, theme} from "antd"; 4 | import {MoonStar, Sun} from "lucide-react"; 5 | 6 | const App = () => { 7 | 8 | const [darkMode, setDarkMode] = useState(localStorage.getItem("darkMode") === "true"); 9 | 10 | useEffect(() => { 11 | if (darkMode) { 12 | document.documentElement.classList.add('dark'); 13 | } else { 14 | document.documentElement.classList.remove('dark'); 15 | } 16 | localStorage.setItem('darkMode', darkMode.toString()); 17 | }, [darkMode]); 18 | 19 | return ( 20 | <> 21 | 23 |
24 | 25 | : } 27 | tooltip={
深色模式
} 28 | onClick={() => setDarkMode(!darkMode)}/> 29 |
30 |
31 | 32 | ); 33 | }; 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /web/src/pages/Dashboard/Directory/index.jsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import FileBreadcrumb from "@/components/FileBreadcrumb/index.jsx"; 3 | import {fsListRequest} from "@/api/fs.js"; 4 | import TileViewFileExplorer from "@/components/TileViewFileExplorer/index.jsx"; 5 | import FolderButtonGroup from "@/components/FolderButtonGroup/index.jsx"; 6 | 7 | const Index = () => { 8 | const [path, setPath] = useState(JSON.parse(localStorage.getItem("filePath")) || []); 9 | const [fileList, setFileList] = useState([]) 10 | 11 | useEffect(() => { 12 | localStorage.setItem("filePath", JSON.stringify(path)); 13 | }, [path]); 14 | 15 | const getFileList = async () => { 16 | const {code, msg, data} = await fsListRequest(path.join('/')); 17 | if (code === 1) { 18 | setFileList(data.files) 19 | } 20 | } 21 | 22 | const handlerNextPath = (item) => { 23 | setPath(prev => [...prev, item.name]); 24 | } 25 | 26 | const handlerBreadcrumb = (index) => { 27 | setPath(prev => prev.slice(0, index)); 28 | } 29 | 30 | useEffect(() => { 31 | getFileList() 32 | }, [path]); 33 | 34 | return ( 35 | <> 36 |
37 |
38 | 39 |
40 |
41 | 42 |
43 |
44 | 49 |
50 |
51 | 52 | ); 53 | }; 54 | 55 | export default Index; 56 | -------------------------------------------------------------------------------- /web/src/pages/Dashboard/Help/index.jsx: -------------------------------------------------------------------------------- 1 | import {SquareArrowOutUpRight} from "lucide-react"; 2 | 3 | const Index = () => { 4 | return ( 5 | <> 6 |
7 |
8 | 官方文档: 9 | 11 | [打开 ] 12 | 13 |
14 |
15 | 16 | ); 17 | }; 18 | 19 | export default Index; 20 | -------------------------------------------------------------------------------- /web/src/pages/Dashboard/Overview/index.jsx: -------------------------------------------------------------------------------- 1 | import {fsDiskInfoRequest} from "@/api/fs.js"; 2 | import {useEffect, useState} from "react"; 3 | 4 | const Index = () => { 5 | const [diskInfo, setDiskInfo] = useState({total: 0, used: 0}); 6 | 7 | const getDiskInfo = async () => { 8 | const {code, msg, data} = await fsDiskInfoRequest(); 9 | if (code === 1) { 10 | setDiskInfo(data) 11 | } 12 | } 13 | 14 | useEffect(() => { 15 | getDiskInfo() 16 | }, []); 17 | 18 | return ( 19 | <> 20 |
21 |
磁盘使用量
22 |
23 |
26 |
27 |
28 |
29 |
{(diskInfo.used / 1024 / 1024 / 1024).toFixed(2)}GB / {(diskInfo.total / 1024 / 1024 / 1024).toFixed(2)}GB
30 |
{(diskInfo.used / diskInfo.total * 100).toFixed(2)}%
31 |
32 |
33 | 34 | ); 35 | }; 36 | 37 | export default Index; 38 | -------------------------------------------------------------------------------- /web/src/pages/Dashboard/Settings/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Button, Card, Form, Input, message} from "antd"; 3 | import {userChangePasswordRequest, userChangeUsernameRequest} from "@/api/user.js"; 4 | import {useNavigate} from "react-router-dom"; 5 | import {useDispatch} from "react-redux"; 6 | import {clearToken} from "@/store/modules/userStore.js"; 7 | 8 | const Index = () => { 9 | 10 | const navigate = useNavigate(); 11 | const dispatch = useDispatch(); 12 | const [messageApi, contextHolder] = message.useMessage(); 13 | 14 | const submitChangeUsername = async (values) => { 15 | const {code, msg, data} = await userChangeUsernameRequest(values.newUsername); 16 | if (code === 1) { 17 | dispatch(clearToken()) 18 | navigate('/login?type=changeUsername'); 19 | } else { 20 | messageApi.error(msg) 21 | } 22 | } 23 | 24 | const submitChangePassword = async (values) => { 25 | const {code, msg, data} = await userChangePasswordRequest(values.oldPassword, values.newPassword); 26 | if (code === 1) { 27 | dispatch(clearToken()) 28 | navigate('/login?type=changePassword'); 29 | } else { 30 | messageApi.error(msg) 31 | } 32 | } 33 | 34 | return ( 35 | <> 36 | {contextHolder} 37 |
38 | 39 |
43 | 44 | 45 | 46 | 47 | 48 | 49 |
50 |
51 | 52 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
66 |
67 |
68 | 69 | ); 70 | }; 71 | 72 | export default Index; 73 | -------------------------------------------------------------------------------- /web/src/pages/Dashboard/index.jsx: -------------------------------------------------------------------------------- 1 | import {Outlet, useLocation, useNavigate} from "react-router-dom"; 2 | import {AppWindow, CircleHelp, CircleUserRound, Folder, LogOut, ScrollText, Settings} from "lucide-react"; 3 | import {userCheckTokenRequest, userSignOutRequest} from "@/api/user.js"; 4 | import {message} from "antd"; 5 | import {useDispatch, useSelector} from "react-redux"; 6 | import {clearToken} from "@/store/modules/userStore.js"; 7 | import {useEffect} from "react"; 8 | 9 | const navs = [ 10 | { 11 | nav: '/dashboard', 12 | name: '概览', 13 | icon: 14 | }, 15 | { 16 | nav: '/dashboard/directory', 17 | name: '目录', 18 | icon: 19 | }, 20 | { 21 | nav: '/dashboard/log', 22 | name: '日志', 23 | icon: 24 | } 25 | ] 26 | 27 | const navsFooter = [ 28 | { 29 | nav: '/dashboard/help', 30 | name: '帮助', 31 | icon: 32 | }, 33 | { 34 | nav: '/dashboard/settings', 35 | name: '设置', 36 | icon: 37 | } 38 | ] 39 | 40 | const Index = () => { 41 | 42 | const navigate = useNavigate(); 43 | const location = useLocation(); 44 | const user = useSelector((state) => state.user); 45 | const dispatch = useDispatch(); 46 | const [messageApi, contextHolder] = message.useMessage(); 47 | 48 | useEffect(() => { 49 | checkToken() 50 | }, []); 51 | 52 | const checkToken = async () => { 53 | if (!user.token) { 54 | navigate("/login?type=notLogin"); 55 | } 56 | 57 | const {code, msg, data} = await userCheckTokenRequest(); 58 | if (code !== 1) { 59 | dispatch(clearToken()) 60 | navigate('/login?type=checkToken'); 61 | } 62 | } 63 | 64 | const signOut = async () => { 65 | const {code, msg, data} = await userSignOutRequest() 66 | if (code === 1) { 67 | dispatch(clearToken()) 68 | navigate('/login?type=signOut'); 69 | } else { 70 | messageApi.error(msg) 71 | } 72 | } 73 | 74 | return ( 75 | <> 76 | {contextHolder} 77 |
78 |
80 |
81 |
82 |
navigate('/')}> 83 |
McPatch
84 |
85 |
86 |
87 |
    88 | { 89 | navs.map((item, idx) => { 90 | const isActive = location.pathname === item.nav 91 | return ( 92 |
  • 93 |
    navigate(item.nav)} 94 | className={`flex items-center gap-x-2 text-gray-600 dark:text-white p-2 rounded-lg cursor-pointer ${isActive ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-900 active:bg-gray-100 dark:active:bg-gray-800 duration-150'}`}> 95 |
    {item.icon}
    96 | {item.name} 97 |
    98 |
  • 99 | ) 100 | }) 101 | } 102 |
103 |
104 |
    105 | { 106 | navsFooter.map((item, idx) => { 107 | const isActive = location.pathname === item.nav 108 | return ( 109 |
  • 110 |
    navigate(item.nav)} 111 | className={`flex items-center gap-x-2 text-gray-600 dark:text-white p-2 rounded-lg cursor-pointer ${isActive ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-900 active:bg-gray-100 dark:active:bg-gray-800 duration-150'}`}> 112 |
    {item.icon}
    113 | {item.name} 114 |
    115 |
  • 116 | ) 117 | }) 118 | } 119 |
  • 120 |
    signOut()} 122 | className={`flex items-center gap-x-2 text-gray-600 dark:text-white p-2 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 active:bg-gray-100 dark:active:bg-gray-800 duration-150}`}> 123 |
    124 | 退出登录 125 |
    126 |
  • 127 |
128 |
129 |
130 | {/**/} 131 | 132 |
133 | ADMIN 134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 | 142 |
143 | 144 |
145 |
146 | 147 | ); 148 | }; 149 | 150 | export default Index; 151 | -------------------------------------------------------------------------------- /web/src/pages/Home/index.jsx: -------------------------------------------------------------------------------- 1 | import {useNavigate} from "react-router-dom"; 2 | import {useSelector} from "react-redux"; 3 | import {theme} from "antd"; 4 | 5 | const {useToken} = theme; 6 | 7 | const Index = () => { 8 | 9 | const user = useSelector(state => state.user) 10 | const navigate = useNavigate(); 11 | const {token} = useToken(); 12 | 13 | const checkStatus = () => { 14 | if (user.token) { 15 | navigate("/dashboard") 16 | } else { 17 | navigate("/login") 18 | } 19 | } 20 | 21 | return ( 22 | <> 23 |
24 |

26 | McPatch 27 |

28 |

29 | McPatch 是一个给 Minecraft 客户端做文件更新的独立应用程序.只要你想,你可以通过这个程序向你服务器的玩家提供一切内容. 30 |

31 | 36 |
37 | 38 | ); 39 | }; 40 | 41 | export default Index; 42 | -------------------------------------------------------------------------------- /web/src/pages/Login/index.jsx: -------------------------------------------------------------------------------- 1 | import {useNavigate, useSearchParams} from "react-router-dom"; 2 | import {useDispatch, useSelector} from "react-redux"; 3 | import {userLogin} from "@/store/modules/userStore.js"; 4 | import {message} from "antd"; 5 | import {useEffect, useState} from "react"; 6 | 7 | const Index = () => { 8 | 9 | const [username, setUsername] = useState('') 10 | const navigate = useNavigate() 11 | const [params] = useSearchParams(); 12 | const dispatch = useDispatch() 13 | const [messageApi, contextHolder] = message.useMessage(); 14 | const user = useSelector(state => state.user) 15 | 16 | useEffect(() => { 17 | if (user.username) { 18 | setUsername(user.username); 19 | } 20 | 21 | const type = params.get('type'); 22 | switch (type) { 23 | case 'signOut': 24 | messageApi.success('退出成功!'); 25 | break; 26 | case 'changeUsername': 27 | messageApi.success('修改用户名成功!'); 28 | break; 29 | case 'changePassword': 30 | messageApi.success('修改密码成功!'); 31 | break; 32 | case 'notLogin': 33 | messageApi.success('请先登录!'); 34 | break; 35 | case 'checkToken': 36 | messageApi.success('Token无效!'); 37 | break; 38 | } 39 | }, []); 40 | 41 | const login = async (e) => { 42 | e.preventDefault() 43 | 44 | const password = e.target[1].value 45 | 46 | const {flag, msg} = await dispatch(userLogin(username, password)); 47 | if (flag) { 48 | navigate('/dashboard'); 49 | } else { 50 | messageApi.error(msg); 51 | } 52 | } 53 | 54 | return ( 55 | <> 56 | {contextHolder} 57 |
58 |
59 |
60 |
McPatch
61 |
62 |
65 |
66 | 69 | setUsername(e.target.value)} 72 | name="username" 73 | type="text" 74 | required 75 | className="w-full mt-2 px-3 py-2 bg-transparent outline-none border focus:border-indigo-600 shadow-sm rounded-lg" 76 | /> 77 |
78 |
79 | 82 | 88 |
89 |
90 | 忘记密码? 92 |
93 | 98 |
99 |
100 |
101 | 102 | ); 103 | }; 104 | 105 | export default Index; 106 | -------------------------------------------------------------------------------- /web/src/pages/NotFound/index.jsx: -------------------------------------------------------------------------------- 1 | import {ArrowBigLeftDash} from "lucide-react"; 2 | import {useNavigate} from "react-router-dom"; 3 | 4 | const Index = () => { 5 | const navigate = useNavigate(); 6 | 7 | return ( 8 | <> 9 |
10 |
11 |
McPatch
12 |

13 | 404. 对不起,您要查找的页面无法找到或已被删除. 14 |

15 |
navigate(-1)} 16 | className="text-indigo-600 duration-150 hover:text-indigo-400 font-medium inline-flex items-center gap-x-1 cursor-pointer"> 17 | 返回 18 | 19 |
20 |
21 |
22 | 23 | ); 24 | }; 25 | 26 | export default Index; 27 | -------------------------------------------------------------------------------- /web/src/router/index.jsx: -------------------------------------------------------------------------------- 1 | import {createBrowserRouter} from "react-router-dom"; 2 | import App from "@/pages/App.jsx"; 3 | import Home from "@/pages/Home/index.jsx"; 4 | import NotFound from "@/pages/NotFound/index.jsx"; 5 | import Dashboard from "@/pages/Dashboard/index.jsx"; 6 | import Overview from "@/pages/Dashboard/Overview/index.jsx"; 7 | import Directory from "@/pages/Dashboard/Directory/index.jsx"; 8 | import Log from "@/pages/Dashboard/Log/index.jsx"; 9 | import Help from "@/pages/Dashboard/Help/index.jsx"; 10 | import Settings from "@/pages/Dashboard/Settings/index.jsx"; 11 | import Login from "@/pages/Login/index.jsx"; 12 | 13 | const router = createBrowserRouter([ 14 | { 15 | path: '/', 16 | element: , 17 | children: [ 18 | { 19 | index: true, 20 | element: 21 | }, 22 | { 23 | path: 'login', 24 | element: 25 | }, 26 | { 27 | path: 'dashboard', 28 | element: , 29 | children: [ 30 | { 31 | index: true, 32 | element: 33 | }, 34 | { 35 | path: 'directory', 36 | element: 37 | }, 38 | { 39 | path: 'log', 40 | element: 41 | }, 42 | { 43 | path: 'help', 44 | element: 45 | }, 46 | { 47 | path: 'settings', 48 | element: 49 | } 50 | ] 51 | }, 52 | { 53 | path: '*', 54 | element: 55 | } 56 | ] 57 | } 58 | ]) 59 | 60 | export default router 61 | -------------------------------------------------------------------------------- /web/src/store/index.js: -------------------------------------------------------------------------------- 1 | import {configureStore} from "@reduxjs/toolkit"; 2 | import userReducer from "@/store/modules/userStore.js"; 3 | 4 | const store = configureStore({ 5 | reducer: { 6 | user: userReducer, 7 | } 8 | }) 9 | 10 | export default store 11 | -------------------------------------------------------------------------------- /web/src/store/modules/userStore.js: -------------------------------------------------------------------------------- 1 | import {createSlice} from "@reduxjs/toolkit"; 2 | import {userLoginRequest} from "@/api/user.js"; 3 | 4 | const userStore = createSlice({ 5 | name: "user", 6 | initialState: { 7 | username: localStorage.getItem('username') || '', 8 | token: localStorage.getItem('token') || '' 9 | }, 10 | reducers: { 11 | setUser: (state, action) => { 12 | state.username = action.payload.username; 13 | localStorage.setItem('username', action.payload.username); 14 | }, 15 | setToken(state, action) { 16 | state.token = action.payload.token; 17 | localStorage.setItem('token', action.payload.token); 18 | }, 19 | clearToken(state) { 20 | state.token = ''; 21 | localStorage.removeItem('token'); 22 | } 23 | } 24 | }) 25 | 26 | const {setUser, setToken, clearToken} = userStore.actions; 27 | const userLogin = (username, password) => { 28 | return async (dispatch) => { 29 | const {code, msg, data} = await userLoginRequest(username, password); 30 | const flag = code === 1 31 | if (flag) { 32 | dispatch(setUser({username})) 33 | dispatch(setToken(data)) 34 | } 35 | 36 | return {flag, msg} 37 | } 38 | } 39 | export {setUser, userLogin, clearToken} 40 | 41 | const reducer = userStore.reducer 42 | export default reducer 43 | -------------------------------------------------------------------------------- /web/src/utils/request.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import store from "@/store/index.js"; 3 | 4 | const instance = axios.create({ 5 | baseURL: import.meta.env.VITE_API_URL, 6 | timeout: 10000 7 | }) 8 | 9 | instance.interceptors.request.use( 10 | config => { 11 | config.headers.setContentType("application/json") 12 | 13 | const token = store.getState().user.token; 14 | if (token) { 15 | config.headers.Token = token 16 | } 17 | return config 18 | }, 19 | error => { 20 | return error 21 | } 22 | ) 23 | 24 | instance.interceptors.response.use( 25 | res => { 26 | if (res.status === 200) { 27 | return res.data 28 | } 29 | }, 30 | error => { 31 | return error 32 | } 33 | ) 34 | 35 | export default instance 36 | -------------------------------------------------------------------------------- /web/src/utils/tool.js: -------------------------------------------------------------------------------- 1 | export const generateRandomStr = (length = 8) => { 2 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 3 | let result = ''; 4 | const charactersLength = characters.length; 5 | 6 | for (let i = 0; i < length; i++) { 7 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 8 | } 9 | 10 | return result; 11 | } 12 | 13 | export const showFileSize = (size) => { 14 | if (size > (1024 * 1024)) { 15 | return `${(size / 1024 / 1024).toFixed(2)} MB` 16 | } else if (size > 1024) { 17 | return `${(size / 1024).toFixed(2)} KB` 18 | } else { 19 | return `${size} Bytes` 20 | } 21 | } 22 | 23 | export const showTime = (timestamp) => { 24 | return new Date(timestamp * 1000).toLocaleString('zh-CN', { 25 | year: 'numeric', 26 | month: '2-digit', 27 | day: '2-digit', 28 | hour: '2-digit', 29 | minute: '2-digit', 30 | second: '2-digit', 31 | hour12: false 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: 'selector', 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | } 13 | 14 | -------------------------------------------------------------------------------- /web/vite.config.js: -------------------------------------------------------------------------------- 1 | import {fileURLToPath, URL} from 'node:url' 2 | 3 | import {defineConfig} from 'vite' 4 | import react from '@vitejs/plugin-react' 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | base: '/', 9 | plugins: [ 10 | react() 11 | ], 12 | resolve: { 13 | alias: { 14 | '@': fileURLToPath(new URL('./src', import.meta.url)) 15 | } 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /xtask/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xtask" 3 | version = "1.0.0" 4 | edition.workspace = true 5 | -------------------------------------------------------------------------------- /xtask/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::path::PathBuf; 3 | use std::process::Command; 4 | 5 | type ProcessResult = Result<(), Box>; 6 | 7 | fn main() -> ProcessResult { 8 | dist_binary("manager", "m", Some("bundle-webpage".to_owned())) 9 | } 10 | 11 | fn dist_binary(crate_name: &str, production_name: &str, features: Option) -> ProcessResult { 12 | std::env::set_var("RUST_BACKTRACE", "1"); 13 | 14 | let ref_name = github_ref_name(); 15 | let dist_dir = project_root().join("target/dist"); 16 | let target = TargetInfo::get(crate_name, production_name, &ref_name, &dist_dir); 17 | 18 | // build artifacts 19 | let cargo = std::env::var("CARGO").unwrap(); 20 | 21 | let mut cmd = Command::new(cargo); 22 | 23 | cmd.current_dir(project_root()); 24 | 25 | let mut args = Vec::::new(); 26 | 27 | args.push("build".to_owned()); 28 | args.push("--release".to_owned()); 29 | args.push("--package".to_owned()); 30 | args.push(crate_name.to_owned()); 31 | args.push("--target".to_owned()); 32 | args.push(target.rustc_target.to_owned()); 33 | 34 | if let Some(features) = features { 35 | args.push("--features".to_owned()); 36 | args.push(features.to_owned()); 37 | } 38 | 39 | cmd.args(args); 40 | 41 | let status = cmd.status()?; 42 | 43 | if !status.success() { 44 | Err("cargo build failed")?; 45 | } 46 | 47 | // pick up artifacts 48 | drop(std::fs::remove_dir_all(&dist_dir)); 49 | std::fs::create_dir_all(&dist_dir).unwrap(); 50 | 51 | // executable 52 | std::fs::copy(&target.artifact_path, dist_dir.join(&target.artifact_path_versioned)).unwrap(); 53 | 54 | // symbol 55 | if let Some(symbols) = target.symbols_path { 56 | std::fs::copy(&symbols, dist_dir.join(&target.symbols_path_versioned.unwrap())).unwrap(); 57 | } 58 | 59 | Ok(()) 60 | } 61 | 62 | fn project_root() -> PathBuf { 63 | Path::new(&env!("CARGO_MANIFEST_DIR")) 64 | .ancestors() 65 | .nth(1) 66 | .unwrap() 67 | .to_path_buf() 68 | } 69 | 70 | fn github_ref_name() -> String { 71 | std::env::var("GITHUB_REF_NAME").map(|e| e[1..].to_owned()).unwrap_or("0.0.0".to_owned()) 72 | } 73 | 74 | struct TargetInfo { 75 | rustc_target: String, 76 | 77 | artifact_path: PathBuf, 78 | symbols_path: Option, 79 | 80 | artifact_path_versioned: PathBuf, 81 | symbols_path_versioned: Option, 82 | } 83 | 84 | impl TargetInfo { 85 | fn get(crate_name: &str, production_name: &str, version_label: &str, dist_dir: &Path) -> Self { 86 | let rustc_target = match std::env::var("MP_RUSTC_TARGET") { 87 | Ok(t) => t, 88 | Err(_) => { 89 | if cfg!(target_os = "linux") { 90 | "x86_64-unknown-linux-gnu".to_owned() 91 | } else if cfg!(target_os = "windows") { 92 | "x86_64-pc-windows-msvc".to_owned() 93 | } else if cfg!(target_os = "macos") { 94 | "x86_64-apple-darwin".to_owned() 95 | } else { 96 | panic!("Unsupported OS, maybe try setting MP_RUSTC_TARGET") 97 | } 98 | }, 99 | }; 100 | let profile_path = project_root().join(format!("target/{}/release", &rustc_target)); 101 | let is_windows = rustc_target.contains("-windows-"); 102 | 103 | let (exe_suffix, symbols_suffix) = match is_windows { 104 | true => (".exe", Some(".pdb")), 105 | false => ("", None), 106 | }; 107 | 108 | let symbols_name = symbols_suffix.map(|e| format!("{}{e}", crate_name.replace("-", "_"))); 109 | let symbols_name_versioned = symbols_suffix.map(|e| format!("{production_name}-{version_label}-{rustc_target}{e}")); 110 | 111 | let artifact_name = format!("{crate_name}{exe_suffix}"); 112 | let artifact_name_versioned = format!("{production_name}-{version_label}-{rustc_target}{exe_suffix}"); 113 | 114 | let artifact_path = profile_path.join(&artifact_name); 115 | let symbols_path = symbols_name.as_ref().map(|e| profile_path.join(e)); 116 | 117 | let artifact_path_versioned = dist_dir.join(&artifact_name_versioned); 118 | let symbols_path_versioned = symbols_name_versioned.as_ref().map(|e| dist_dir.join(e)); 119 | 120 | Self { rustc_target, artifact_path, symbols_path, artifact_path_versioned, symbols_path_versioned } 121 | } 122 | } --------------------------------------------------------------------------------