137 Commits
v0.1.1 ... main

Author SHA1 Message Date
xfy
e5639b79e5 chore: update todo 2025-07-14 21:16:03 +08:00
xfy
cb44952e2f feature: handle lua table set error 2025-07-14 18:58:22 +08:00
xfy
444dde464c fix(auto_index): audo index rewrite 2025-07-13 18:52:29 +08:00
xfy
723bab4f5e chore: update serve docs 2025-07-09 10:56:41 +08:00
xfy
c4b7c9e0d2 fix(auto_index): file path render error 2025-07-09 01:24:15 +08:00
xfy
47d8482ba6 docs: add config docs 2025-07-03 23:03:15 +08:00
xfy
d59b02fae2 chore: bump to 0.2.2 2025-07-03 22:06:32 +08:00
xfy
fffcee1d50 chore: update deps 2025-07-03 22:03:43 +08:00
xfy
25ff344474 feat(lua): add more const for lua 2025-07-02 01:21:52 +08:00
xfy
13c4570e20 fix: default internal error text 2025-07-02 01:16:49 +08:00
xfy
e92b191360 feat(lua): impl set body to lua 2025-07-02 01:11:48 +08:00
xfy
14196c937c feat: impl lua context 2025-07-02 00:55:52 +08:00
xfy
091f0c8eb5 feat: impl lua engine 2025-07-02 00:08:58 +08:00
xfy
f96e440264 chore: add more error detial 2025-07-01 21:38:23 +08:00
xfy
36cd153fab feat: add lua script route 2025-07-01 21:33:59 +08:00
xfy
f0da74a06e feat: add lua script execture 2025-06-29 20:24:13 +08:00
xfy
a5041197c7 feat: add 301 forward for auto index 2025-06-29 19:01:43 +08:00
xfy
515b36176d feat: add mlua 2025-06-26 22:02:19 +08:00
xfy
46c2f90f92 chore: update default config
add lua script path
2025-06-26 21:53:24 +08:00
xfy
64a85dd5a4 feat: add max body size support 2025-06-25 22:51:09 +08:00
xfy
a1b3cecbea chore: bump to 0.2.1 2025-06-24 21:36:13 +08:00
xfy
c9484397a1 feat: show file size on auto index 2025-06-24 21:28:55 +08:00
xfy
00aa21cb79 fix: open /dev/null on windows 2025-06-24 19:43:15 +08:00
xfy
b8dde2c801 fix: list direcotry modified date error 2025-06-23 17:05:21 +08:00
xfy
d905af58f5 fix: auto index file path 2025-06-22 23:10:23 +08:00
xfy
7ffc681825 feat(auto_index): add root path 2025-06-22 22:55:21 +08:00
xfy
e110af6c3d fix: auto index request error 2025-06-22 22:33:02 +08:00
xfy
b0ce533bde fix: list directory size 2025-06-21 12:32:37 +08:00
xfy
1e17d517bf refactor: remove useless macro 2025-06-20 20:40:30 +08:00
xfy
9c0120a51e chore(rust): convert to stable rust 2025-06-20 20:29:34 +08:00
xfy
f73fc1db67 feat(serve): implement list directory 2025-06-19 22:52:38 +08:00
xfy
0c925f289b feat(serve): add list directory 2025-06-18 20:41:01 +08:00
xfy
6bb543a9bd docs: update todo 2025-06-18 17:21:03 +08:00
xfy
61934b6c0d chore: bump to 0.2.0 2025-06-17 23:35:50 +08:00
xfy
88098dca5c refactor: reuse not found macros 2025-06-17 23:26:47 +08:00
xfy
16bb3d33c8 feat(reverse): add custom not found 2025-06-17 23:00:47 +08:00
xfy
b805cddf91 fix: wrong custom not found path 2025-06-16 17:48:29 +08:00
xfy
b4ac8c83c8 chore: change github workflows 2025-06-15 22:52:21 +08:00
xfy
a6ddba3a2b feat(reverse): add proxy request timeout 2025-06-15 21:28:48 +08:00
xfy
4d795d1a62 feat: custom error page 2025-06-15 21:24:43 +08:00
xfy
879e51c493 refactor: refactor graceful shutdown with axum_server 2025-06-12 20:22:40 +08:00
xfy
70c4b29e7f feat(reverse): forward request body 2025-06-11 20:50:13 +08:00
xfy
10160ab5e5 chore: improve function readability 2025-06-06 13:48:07 +08:00
xfy
f4c679fd10 chore: vendored native tls 2025-06-02 11:16:00 +08:00
xfy
ec68d20aed fix(reverse_proxy): exclude http headers 2025-06-01 22:36:00 +08:00
xfy
2bc6adce84 fix(reverse_proxy): add wildcard for route 2025-06-01 21:55:19 +08:00
xfy
1b3fcf7443 feat(reverse_proxy): add basic proxy 2025-06-01 21:48:55 +08:00
xfy
136345e872 feat: improve server error display 2025-06-01 20:16:44 +08:00
xfy
bcf088c347 refactor(ssl): use axum_server to handle tls 2025-06-01 20:11:48 +08:00
xfy
8d35c26d90 chore: bump deps 2025-05-31 02:40:18 +08:00
xfy
6a47df5ba3 refactor: remove useless if statement 2025-05-30 16:31:49 +08:00
xfy
3d8c9d44e1 feat(http): add custom not found status code 2025-05-18 04:00:51 +08:00
xfy
17ef670de5 chore: update editorconfig
change markdown indent size to 2
2025-05-18 03:42:55 +08:00
xfy
d5751f1e14 perf(hashmap): use dashmap as global store 2025-05-16 15:57:06 +08:00
xfy
f86bb996b1 fix(http): multiple vhost share same config 2025-05-14 15:41:29 +08:00
xfy
02d02b68ee refactor(http): optimize port parse
add port to route map
2025-05-14 02:42:58 +08:00
xfy
23523c6a37 chore: update runner config 2025-05-12 09:35:17 +08:00
xfy
b5656ff75a fix: handle virtual host error 2025-05-10 01:51:18 +08:00
xfy
e870704cb6 chore: gracefully handle unwrap 2025-05-10 01:29:21 +08:00
xfy
fdbecfbbe9 chore: format macros 2025-05-10 01:07:01 +08:00
xfy
87e34d655d feat(http): implement 304 Not Modified 2025-05-04 00:42:01 +08:00
xfy
3d0e8ba591 feat(http): calculate file etag 2025-05-03 23:20:59 +08:00
xfy
67df0d7db5 fix(serve): fix generate default index
Fix the route was not found due to an empty path.
2025-04-23 21:36:00 +08:00
xfy
e0b5e1a280 docs(todo): add list directory as todo 2025-04-23 21:11:59 +08:00
xfy
c860ac1a55 chore(deps): bump dependecies 2025-04-23 21:05:12 +08:00
xfy
ae612fc20d chore(docs): update todo 2025-04-23 20:49:03 +08:00
xfy
b37ff4607b feat(http): implement custom headers 2025-04-23 20:32:27 +08:00
xfy
07094124d0 ci(macos): run check/test/build in parallel 2025-04-22 21:49:33 +08:00
xfy
60a12113a9 chore(test): add config struct unit test 2025-04-22 21:46:26 +08:00
xfy
f9e0c7839f ci(macos): add clippy check 2025-04-22 21:40:35 +08:00
xfy
b313f870df ci(macos): add cargo cache 2025-04-22 21:37:50 +08:00
a4187b831e Create macos-arm.yml 2025-04-22 20:44:02 +08:00
xfy
58e7fdc255 feat(ssl): add ssl support 2025-04-22 16:26:27 +08:00
xfy
718d13a13a feat(http): enhance error handling and config clarity
- Refactored `serve.rs` loop for robustness (tries all paths before failing).
- Improved error messages in `error.rs` for better debugging.
- Updated `config.rs` and `config.example_full.toml` to reflect new defaults.
- Added debug logs for failed path attempts in file serving.
2025-04-22 15:51:58 +08:00
xfy
c6ff8af73e feat: add custom error message 2025-04-22 15:23:46 +08:00
xfy
f100fbd0ee feat(route): add response compression 2025-04-15 21:35:56 +08:00
xfy
6c0e6dd341 refactor(serve): improve path handling robustness and logging
- Modified file-serving loop to either:
  - Try all paths in `path_arr` before failing (Option 1), or
  - Document fast-fail behavior (Option 2).
- Added debug logs for failed path attempts.

Improves resilience or clarity, depending on chosen approach.
2025-04-12 17:38:05 +08:00
xfy
5be7cf0cf4 refactor(serve): improve path handling and error logging
- Extracted `resolve_parent_path` helper for URI/path logic reuse.
- Replaced `if let Ok` with explicit `match` in file streaming loop.
- Added debug logs for path resolution and file streaming failures.

No functional changes; focuses on maintainability and debuggability.
2025-04-12 17:15:12 +08:00
xfy
6756daf8ef chore(serve): clarify path handling logic and add comments
- Added detailed comments to `serve.rs` to explain URI path resolution, error cases, and file streaming.
- Improved readability of path slicing and fallback logic.
- Updated dependencies in `Cargo.toml` and `Cargo.lock` as needed.

🤖 Generated with [avante.nvim](https://github.com/yetone/avante.nvim)
Co-Authored-By: avante.nvim <noreply-avante@yetone.ai>
2025-04-12 17:04:14 +08:00
xfy
a39d5a8a24 chore(debug): add route register debug info 2025-04-12 16:27:46 +08:00
xfy
93629c75ae feat(http): improve route registration and path handling
- Refactor route registration in `mod.rs` to handle paths with/without trailing slashes.
- Enhance `serve.rs` to safely handle optional path segments and improve URI parsing.
- Add debug logs for better tracing of route resolution.
- Ensure UTF-8 compatibility and bounds safety in path slicing.

🤖 Generated with [avante.nvim](https://github.com/yetone/avante.nvim)
Co-Authored-By: avante.nvim <noreply-avante@yetone.ai>
2025-04-12 15:42:15 +08:00
xfy
251f47f760 refactor: update router error handling
- Add not found error
- Update error output, use plain text instead of json
2025-04-10 10:57:31 +08:00
xfy
14cdf3b35d feat(http): enhance path handling
- Add global route map to store static routes.
- Match parent path to find route in route map.
2025-04-10 10:40:45 +08:00
xfy
1fe812ef4c feat(http): enhance path handling with safe string slicing
- Replace direct slicing (`&s[0..5]`) with safe methods (`get`) in `serve.rs` to avoid panics.
- Add UTF-8 safety checks for paths with non-ASCII characters.
- Improve error handling for invalid slices using `unwrap_or_default`.

This ensures robust path manipulation in dynamic routing scenarios.

🤖 Generated with [avante.nvim](https://github.com/yetone/avante.nvim)
Co-Authored-By: avante.nvim <noreply-avante@yetone.ai>
2025-04-10 10:28:24 +08:00
xfy
32e068c1a4 feat(http): enhance dynamic routing and error handling
- Refactor `src/http/mod.rs` to support runtime route configuration.
- Update `src/http/serve.rs` with Axum server setup and middleware integration.
- Improve error handling in `src/http/error.rs` for HTTP-specific cases.
- Extend middleware utilities in `src/middlewares/mod.rs`.

🤖 Generated with [avante.nvim](https://github.com/yetone/avante.nvim)
Co-Authored-By: avante.nvim <noreply-avante@yetone.ai>
2025-04-10 09:36:28 +08:00
xfy
c879e07a63 feat(http): implement dynamic routing with Axum
- Add dynamic route generation in `src/http/mod.rs` to support runtime configuration.
- Integrate shared `AppState` and middleware (e.g., timeout, logging) in `src/http/serve.rs`.
- Update `src/config.rs` to include route configuration.
- Refactor `src/main.rs` to initialize the HTTP server with dynamic routes.

🤖 Generated with [avante.nvim](https://github.com/yetone/avante.nvim)
Co-Authored-By: avante.nvim <noreply-avante@yetone.ai>
2025-04-10 09:34:26 +08:00
xfy
45d8c36136 refactor: change backend to axum 2025-04-08 09:24:13 +08:00
xfy
e8c425d5fe feat: add connection timeout 2025-03-27 16:21:47 +08:00
xfy
c66a3f1322 feat: add middlewares 2025-03-27 16:16:57 +08:00
xfy
654a51227b handle static file with route service 2025-03-27 14:34:09 +08:00
xfy
d862e2eece add static file handle 2025-03-27 14:29:27 +08:00
xfy
e169478bbf refactor: refactor with axum 2025-03-27 13:55:49 +08:00
xfy
0879dac0ca refactor: refactor condition 2025-03-27 13:07:38 +08:00
xfy
c0a4fbfc19 perf: change memory allocator to mimalloc 2025-03-27 09:23:24 +08:00
xfy
b38de3a325 refactor: refactor condition 2025-03-26 15:48:16 +08:00
xfy
01bd1128c4 chore: update load certificate message 2025-03-13 17:21:13 +08:00
xfy
722bcb1c15 chore: update read certificate error 2025-03-13 17:16:00 +08:00
xfy
e9fafb3b60 refactor(service): use macro to handle connection 2025-03-13 17:01:43 +08:00
xfy
5f23d5e23f fix: deps flags 2025-02-25 17:27:51 +08:00
xfy
53139b7ee7 feat: add rustls features 2025-02-25 17:13:02 +08:00
xfy
254948e496 feat: impl ssl server 2025-02-25 16:49:02 +08:00
xfy
675e347c1d feat: read ssl certificate 2025-02-25 16:05:01 +08:00
xfy
0671c84ae4 feat: add ssl field 2025-02-25 15:48:09 +08:00
xfy
ba2b9539ba fix: timing format 2025-02-25 15:35:28 +08:00
xfy
8288d16699 chore: update dependencies 2025-02-25 15:32:17 +08:00
xfy
050a88b0bd docs: update installation 2024-12-30 13:32:18 +08:00
xfy
0f8e34b39f fix: support assets path not start with / 2024-12-30 11:06:34 +08:00
xfy
96c0a3e07b chore(depss): bump version 2024-12-30 09:36:16 +08:00
xfy
0bd354983a refactor(config): refactor route map handle 2024-12-04 10:38:36 +08:00
xfy
12a4b046cf docs: update config comments 2024-12-04 10:23:48 +08:00
xfy
269bec83f7 chore: update response lifetime 2024-12-04 10:10:56 +08:00
xfy
8a27df2e86 feat: proxy response headers to client 2024-12-03 14:58:39 +08:00
xfy
38d991c393 refactor(proxy): send client request to upstream 2024-12-03 11:40:42 +08:00
xfy
b80bb741bc feat(proxy): follow client redirects
follow 301 and 302 redirects
2024-12-03 09:27:40 +08:00
xfy
0626a4114a chore(make): add aarch64 platform 2024-12-02 17:25:52 +08:00
xfy
d3892fe3b3 chore: update lock file 2024-11-29 17:41:30 +08:00
xfy
cade8d3162 fix: build script 2024-11-29 16:53:42 +08:00
xfy
f98e48c3e8 docs: update docusaurus 2024-11-29 16:50:05 +08:00
xfy
7a673f9d63 fix: parse reverse proxy query 2024-11-28 16:07:25 +08:00
xfy
46094d8e9c feat: add http client
and ssl support with rustls
2024-11-28 10:10:53 +08:00
xfy
120657f915 feat: logging support micros and millis 2024-11-28 09:19:12 +08:00
xfy
0e94da7b31 chore: update dependencies 2024-11-28 09:06:33 +08:00
xfy
487ada0e4f chore: update dependencies 2024-11-22 09:38:23 +08:00
xfy
3f1adcc6f7 chore(deps): bump version 2024-10-30 17:09:12 +08:00
xfy
1798d1c710 chore(deps): update dependencies
change to nightly rustc
2024-09-25 17:24:21 +08:00
xfy
3d1d86fd2e chore(dev): update makefile 2024-09-14 15:59:45 +08:00
xfy
b072a63d52 chore(deps): update dependecies 2024-08-26 09:11:15 +08:00
xfy
ef33783dd7 refactor(settings): handle get settings error 2024-08-08 11:35:53 +08:00
xfy
3df4b6a087 feat(proxy): add timeout setting 2024-08-08 11:31:22 +08:00
xfy
57f83a4a74 chore(make): add fmt after fix
add todo
2024-08-01 13:32:42 +08:00
xfy
d9a3562b74 docs(deps): add deps.rs badge 2024-08-01 13:26:55 +08:00
xfy
9bcdb43dd5 feat(handler): handle headers error 2024-07-16 11:51:08 +08:00
xfy
9906bec04a feat(handler): handle handler error 2024-07-06 11:28:02 +08:00
xfy
d2babab848 refactor(route): route path find 2024-07-06 10:46:09 +08:00
xfy
b77645e1d3 chore(make): change defaults 2024-07-04 11:58:34 +08:00
xfy
2f1704d1d8 ci(gitlab): fix filename 2024-07-02 13:14:16 +08:00
xfy
68ffc0337e ci(gitlab): add multiple platform 2024-07-02 11:40:24 +08:00
73 changed files with 9996 additions and 4720 deletions

View File

@ -8,7 +8,7 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml}]
[*.{yml,yaml,md}]
indent_size = 2
[Makefile]

59
.github/workflows/macos-arm.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: Candy macOS arm
on:
push:
branches: [""]
pull_request:
branches: [""]
env:
CARGO_TERM_COLOR: always
jobs:
check:
runs-on: ["self-hosted", "macos-arm"]
steps:
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/checkout@v4
- name: Clippy check
run: make lint
test:
runs-on: ["self-hosted", "macos-arm"]
steps:
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/checkout@v4
- name: Unit test
run: make test
build:
runs-on: ["self-hosted", "macos-arm"]
steps:
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/checkout@v4
- name: Build release
run: make release

59
.github/workflows/macos-x86.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: Candy macOS x86
on:
push:
branches: [""]
pull_request:
branches: [""]
env:
CARGO_TERM_COLOR: always
jobs:
check:
runs-on: ["self-hosted", "macos-x86"]
steps:
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/checkout@v4
- name: Clippy check
run: make lint
test:
runs-on: ["self-hosted", "macos-x86"]
steps:
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/checkout@v4
- name: Unit test
run: make test
build:
runs-on: ["self-hosted", "macos-x86"]
steps:
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/checkout@v4
- name: Build release
run: make release

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
logs
.DS_Store
config.toml
!/.cargo/config.toml
/html
heaptrack.*.zst

View File

@ -13,7 +13,8 @@ variables:
image: "rust:latest"
before_script:
- apt update -y
- apt-get update -y
- apt-get install -y docker.io
- mkdir $HOME/.cargo
- echo "[source.crates-io]" >> $HOME/.cargo/config
- echo "replace-with = 'ustc'" >> $HOME/.cargo/config
@ -30,13 +31,15 @@ test:
build:freebsd:
stage: build
rules:
- if: $CI_COMMIT_TAG
script:
- apt-get update -y
- apt-get install -y musl-tools libssl-dev docker.io
- rustup target add x86_64-unknown-freebsd
- cross build --release --target x86_64-unknown-freebsd
- "mv target/release/$APP_NAME target/release/$APP_NAME-x86_64-unknown-freebsd-$CI_COMMIT_TAG"
- "mv target/release/$APP_NAME-x86_64-unknown-freebsd-$CI_COMMIT_TAG public/"
- make build-freebsd
- "mv target/x86_64-unknown-freebsd/release/$APP_NAME target/x86_64-unknown-freebsd/release/$APP_NAME-x86_64-unknown-freebsd-$CI_COMMIT_TAG"
- "mv target/x86_64-unknown-freebsd/release/$APP_NAME-x86_64-unknown-freebsd-$CI_COMMIT_TAG public/"
artifacts:
paths:
- "public/$APP_NAME-x86_64-unknown-freebsd-$CI_COMMIT_TAG"
build:linux-gnu-amd64:
stage: build
@ -55,10 +58,7 @@ build:linux-musl-amd64:
rules:
- if: $CI_COMMIT_TAG
script:
- apt-get update -y
- apt-get install -y musl-tools libssl-dev
- rustup target add x86_64-unknown-linux-musl
- cargo build --release --target x86_64-unknown-linux-musl
- make build-linux-musl
- "mv target/x86_64-unknown-linux-musl/release/$APP_NAME target/x86_64-unknown-linux-musl/release/$APP_NAME-x86_64-unknown-linux-musl-$CI_COMMIT_TAG"
- "mv target/x86_64-unknown-linux-musl/release/$APP_NAME-x86_64-unknown-linux-musl-$CI_COMMIT_TAG public/"
artifacts:
@ -70,16 +70,25 @@ build:windows-amd64:
rules:
- if: $CI_COMMIT_TAG
script:
- apt-get install -y g++-mingw-w64-x86-64
- rustup target add x86_64-pc-windows-gnu
- rustup toolchain install stable-x86_64-pc-windows-gnu
- cargo build --release --target x86_64-pc-windows-gnu
- make build-windows-gnu
- "mv target/x86_64-pc-windows-gnu/release/$APP_NAME.exe target/x86_64-pc-windows-gnu/release/$APP_NAME-x86_64-pc-windows-gnu-$CI_COMMIT_TAG.exe"
- "mv target/x86_64-pc-windows-gnu/release/$APP_NAME-x86_64-pc-windows-gnu-$CI_COMMIT_TAG.exe public/"
artifacts:
paths:
- public/$APP_NAME-x86_64-pc-windows-gnu-$CI_COMMIT_TAG.exe
build:linux-loongarch:
stage: build
rules:
- if: $CI_COMMIT_TAG
script:
- make build-loongarch
- "mv target/loongarch64-unknown-linux-gnu/release/$APP_NAME target/loongarch64-unknown-linux-gnu/release/$APP_NAME-loongarch64-unknown-linux-gnu-$CI_COMMIT_TAG"
- "mv target/loongarch64-unknown-linux-gnu/release/$APP_NAME-loongarch64-unknown-linux-gnu-$CI_COMMIT_TAG public/"
artifacts:
paths:
- public/$APP_NAME-loongarch64-unknown-linux-gnu-$CI_COMMIT_TAG
rustdoc:
stage: build
rules:

View File

@ -1,8 +1,32 @@
## 0.1.1
# Changelog
## 0.2.2 - 2025-07-03
Features:
- Gitlab CI integration
- Support lua script
- Add max body size limit
## 0.2.1 - 2025-06-24
Features:
- `auto-index` support
- Stable rust version
## 0.2.0 - 2025-06-17
Features:
- Reverse proxy
- Refactor with axum
- SSL support
## 0.1.1 - 2024-07-02
Features:
- GitLab CI integration
- FreeBSD support
- Reverse proxy
- Connection timeout
@ -18,13 +42,13 @@ Fix:
- Custom error page
- Config tests
## 0.1.0
## 0.1.0 - 2024-05-13
Features:
- Graceful shutdown
- `keep-alive` timeout setting
- HTTP Etag: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value
- HTTP Etag: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value>
- Content compress
- zstd
- gzip

2451
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,38 +1,77 @@
# cargo-features = ["profile-rustflags", "trim-paths"]
[package]
name = "candy"
version = "0.1.1"
edition = "2021"
version = "0.2.2"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# core
tokio = { version = "1.38.0", features = ["full"] }
tokio-util = "0.7.11"
hyper = { version = "1.4.0", features = ["full"] }
hyper-util = { version = "0.1.6", features = ["full"] }
http = "1.1.0"
http-body-util = "0.1.2"
futures-util = "0.3.30"
async-compression = { version = "0.4.11", features = [
"tokio",
tokio = { version = "1.46.1", features = ["full"] }
tokio-util = "0.7.15"
tokio-rustls = "0.26.2"
hyper = { version = "1.6.0", features = ["full"] }
hyper-util = { version = "0.1.15", features = ["full"] }
http = "1.3.1"
http-body-util = "0.1.3"
futures-util = "0.3.31"
mimalloc = "0.1.47"
axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10.1", features = ["typed-header"] }
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
tower = { version = "0.5.2", features = ["full"] }
tower-http = { version = "0.6.6", features = ["full"] }
reqwest = { version = "0.12.22", features = [
# "rustls-tls",
"native-tls-vendored",
"zstd",
"gzip",
"deflate",
"brotli",
'gzip',
'brotli',
'deflate',
"stream",
] }
# lua
mlua = { version = "0.10.5", features = [
"lua54",
"vendored",
"send",
"async",
"macros",
"serialize",
"anyhow",
] }
# tools
thiserror = "1.0.61"
anyhow = "1.0.86"
serde = { version = "1.0.203", features = ["derive"] }
toml = "0.8.14"
thiserror = "2.0.12"
anyhow = "1.0.98"
serde = { version = "1.0.219", features = ["derive"] }
toml = "0.9.2"
bytes = "1.10.1"
const_format = "0.2.34"
md5 = "0.8.0"
dashmap = { version = "6.1.0", features = ["serde"] }
# logging
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
clap = { version = "4.5.8", features = ["derive"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
clap = { version = "4.5.41", features = ["derive"] }
serde_repr = "0.1.20"
serde_json = "1.0.140"
mime_guess = "2.0.5"
chrono = "0.4.41"
[dev-dependencies]
tempfile = "3.20.0"
[profile.dev]
incremental = true # Compile your binary in smaller steps.
# rustflags = ["-Zthreads=8"] # Better compile performance.
[profile.release]
lto = true
panic = "abort" # Strip expensive panic clean-up logic
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
strip = true # Remove debug symbols
codegen-units = 1 # Allows LLVM to perform better optimization.
lto = true # Enables link-time-optimizations.
opt-level = 3 # Prioritizes small binary size. Use `3` if you prefer speed.
panic = "abort" # Higher performance by disabling panic handlers.
strip = true # Ensures debug symbols are removed.
# trim-paths = "all" # Removes potentially privileged information from your binaries.
# rustflags = ["-Cdebuginfo=0", "-C", "target-cpu=native", "-Z", "threads=8"]

View File

@ -1,33 +1,24 @@
# Define the Rust compiler and cargo command
CARGO = cargo
RUSTC = rustc
CROSS = cross
CROSS = CROSS_REMOTE=1 cross
# Targets
# TARGET = candy
# Default target to build the project
all: build
# Build the project
build:
$(CARGO) build
build-release: clean
release:
$(CARGO) build --release
dev:
CANDY_LOG=debug $(CARGO) watch -x run
# Run the project
run:
$(CARGO) run
CANDY_LOG=debug $(CARGO) run
# Test the project
test:
$(CARGO) test
# Clean the project
clean:
$(CARGO) clean
@ -35,35 +26,40 @@ clean-release:
rm -rf ./target/release/
rm -rf ./target/debug/
# Check the code for warnings and errors
check:
$(CARGO) check
# Format the code using rustfmt
format:
$(CARGO) fmt
# Clippy for linting
lint:
$(CARGO) clippy
fix:
$(CARGO) fix --allow-dirty --all-features
$(CARGO) fix --allow-dirty --all-features && $(CARGO) fmt
build-linux-musl: clean-release
linux-musl: clean-release
$(CROSS) build --release --target x86_64-unknown-linux-musl
build-linux-gnu: clean-release
aarch64-linux-musl: clean-release
$(CROSS) build --release --target aarch64-unknown-linux-musl
aarch64-android: clean-release
$(CROSS) build --release --target aarch64-linux-android
linux-gnu: clean-release
$(CROSS) build --release --target x86_64-unknown-linux-gnu
build-windows-gnu: clean-release
aarch64-linux-gnu: clean-release
$(CROSS) build --release --target aarch64-unknown-linux-gnu
windows-gnu: clean-release
$(CROSS) build --release --target x86_64-pc-windows-gnu
build-freebsd: clean-release
freebsd: clean-release
$(CROSS) build --release --target x86_64-unknown-freebsd
build-loongarch: clean-release
loongarch: clean-release
$(CROSS) build --release --target loongarch64-unknown-linux-gnu
# Phony targets to avoid conflicts with file names
.PHONY: all build dev run test clean check format lint fix build-linux-musl build-linux-gnu build-windows-gnu build-freebsd build-loongarch
.PHONY: all

View File

@ -1,10 +1,26 @@
# Candy
<img src="./assets/candy-transparent.png" width="200px">
A tiny web server built with rust.
[![dependency status](https://deps.rs/repo/github/DefectingCat/candy/status.svg)](https://deps.rs/repo/github/DefectingCat/candy)
![](https://git.rua.plus/xfy/candy/badges/main/pipeline.svg)
![](https://git.rua.plus/xfy/candy/-/badges/release.svg)
## Features
- Simple and easy to use
- Single binary
- Supports SSL
- HTTP 2 support
- Supports lua script
- List directory
## TODO
[TODO.md](./TODO.md)
## Changelog
[CHANGELOG.md](./CHANGELOG.md)

55
TODO.md
View File

@ -1,29 +1,17 @@
## TODO
# TODO
- [ ] Dockerization
- [ ] Docs
- [ ] Refactor HTTP 1
- [ ] Build with compile info
- [x] Graceful shutdown
- [x] `keep-alive` timeout setting
- [x] HTTP Etag: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value
- [x] Content compress
- [x] zstd
- [x] gzip
- [x] deflate
- [x] br
## Configuration
### Configuration
- [x] File MIME type
- [x] Overwrite headers in config
- [x] Config init tests
- [x] Error page
- [x] Custom error page with stats code
- [ ] Logging to file
- [ ] Benchs
- [ ] Max body size
- [ ] Benches
- [x] Max body size
- [x] HTTP 2
### Features
## Features
- [x] Cross platform compile
- [x] x86_64-unknown-linux-gnu
@ -33,12 +21,31 @@
- [x] x86_64-pc-windows-gnu
- [x] x86_64-unknown-freebsd
- [x] loongarch64-unknown-linux-gnu
- [ ] HTTP redirect
- [ ] Load balance
- [ ] Proxy
- [ ] Reverse Proxy
- [ ] FastCGI
- [ ] SSL
- [x] Reverse Proxy
- [x] Connect to upstream timeout setting
- [x] Follow http 301
- [x] Custom headers
- [x] SSL
- [x] Cli
- [x] Specific custom config location
- [ ] HTTP 2
- [x] Specific custom config location
- [x] HTTP 2
- [ ] HTTP 3
- [ ] Specify thread numbers
- [ ] Dockerization
- [x] Docs
- [x] Multiple virtual hosts
- [x] Build with compile info
- [x] Refactor HTTP 1
- [x] Graceful shutdown
- [x] `keep-alive` timeout setting
- [x] HTTP Etag: <https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#etag_value>
- [x] 304 Not Modified
- [x] List directory
- [x] Content compress
- [x] zstd
- [x] gzip
- [x] deflate
- [x] br

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

BIN
assets/candy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -17,9 +17,10 @@ macro_rules! set_env {
fn main() {
rustc_info();
println!("cargo:info=test");
commit_info();
}
/// Get rustc version info
fn rustc_info() {
let rustc_output = Command::new("rustc")
.args(["-vV"])
@ -31,6 +32,34 @@ fn rustc_info() {
.split('\n')
.filter(|info| !info.is_empty())
.collect::<Vec<_>>();
set_env!("RUA_COMPILER={}", info_arr[0]);
}
/// Get commit info
/// If failed, set RUA_COMMIT=unknown
fn commit_info() {
let output = Command::new("git").args(["rev-parse", "HEAD"]).output();
// get commit info
let Ok(output) = output else {
warn!("get commit info failed");
set_env!("RUA_COMMIT={}", "unknown");
return;
};
// check is dirty
let dirty = Command::new("git")
.args(["diff", "--quiet"])
.output()
.is_ok();
let commit = String::from_utf8_lossy(&output.stdout)
.chars()
.take(7)
.collect::<String>();
let commit = if commit.is_empty() {
"unknown".to_string()
} else if dirty {
format!("{commit}-dirty")
} else {
commit
};
set_env!("RUA_COMMIT={}", commit);
}

View File

@ -1,8 +1,15 @@
[[host]]
ip = "0.0.0.0"
port = 4000
port = 80
[[host.route]]
# route path: GET /
location = "/"
# static file path
root = "./html"
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/"
proxy_pass = "https://www.google.com/"

View File

@ -1,32 +1,56 @@
default_type = "application/octet-stream"
[types]
wasm = "application/wasm"
# Virtual host
[[host]]
ip = "0.0.0.0"
port = 4000
port = 80
# Connection timeout
timeout = 15
# SSL certificate
# ssl enable
# ssl = true
# Self sign a certificate
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
# Add custom headers to response
[host.headers]
X-Powered-By = "candy"
# Routes for virtual host
[[host.route]]
index = ["index.html"]
# Route location
location = "/"
root = "./html"
# Static file root
# or proxy_pass
# or redirect
root = "html"
# Only use for root field
index = ["index.html"]
# List directory
auto_index = true
# Custom 500 page
[host.route.error_page]
status = 500
page = "500.html"
# Custom 404 page
[host.route.not_found_page]
status = 404
page = "404.html"
# Reverse proxy
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
index = ["index.html"]
location = "/candy/"
root = "./docs/build"
[host.route.error_page]
status = 404
page = "404.html"
[[host.route]]
index = ["index.html"]
location = "/proxy/"
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
# Lua script
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"

View File

@ -7,3 +7,6 @@ end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

1
docs/.nvmrc Normal file
View File

@ -0,0 +1 @@
lts/*

3
docs/.prettierignore Normal file
View File

@ -0,0 +1,3 @@
.docusaurus/
build/
node_modules/

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
PACKAGE_MANAGER = pnpm
all: pack
nvm:
fnm use && $(CMD)
deps:
make nvm CMD="$(PACKAGE_MANAGER) i"
fix:
make nvm CMD="$(PACKAGE_MANAGER) i && $(PACKAGE_MANAGER) pretty"
pack:
cp -r docs/* i18n/zh-Hans/docusaurus-plugin-content-docs/current \
&& make nvm CMD="$(PACKAGE_MANAGER) i && $(PACKAGE_MANAGER) build"
# build:
.PHONY: all

View File

@ -1,3 +0,0 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
};

View File

@ -6,3 +6,5 @@ tags: [hello]
---
## Hello World
<!-- truncate -->

View File

@ -0,0 +1,4 @@
{
"label": "配置",
"position": 2
}

View File

@ -0,0 +1,83 @@
---
sidebar_label: 配置文件
sidebar_position: 1
title: 配置文件
---
## 配置文件
Candy 遵循配置文件进行配置。配置文件的格式为 TOML。
### 虚拟主机
顶层配置为虚拟主机 `host`,可以配置多个虚拟主机。
```toml
[[host]]
ip = "0.0.0.0"
port = 80
# Connection timeout
timeout = 15
# 只用当 ssl = true 时,才会读取证书和密钥,并开启 SSL 支持
# ssl = true
# Self sign a certificate
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
```
#### 自定义 HTTP 相应头
每个虚拟主机都可以配置自定义相应头
TODO
### 路由
每个虚拟主机下都可以配置多个路由。配置字段为 `route`
每个路由支持三种配置:
- 静态文件托管
- 反向代理
- Lua 脚本
#### 静态文件托管
```toml
[[host.route]]
# 路由地址
location = "/"
# 静态文件根目录
root = "html"
# 当使用静态文件根目录时,使用下面的字段作为主页
index = ["index.html"]
# 列出目录
auto_index = true
```
#### 反向代理
```toml
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
```
#### Lua 脚本
```toml
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"
```

5
docs/docs/config/lua.md Normal file
View File

@ -0,0 +1,5 @@
---
sidebar_label: Lua 脚本
sidebar_position: 2
title: Lua 脚本
---

View File

@ -2,4 +2,32 @@
sidebar_position: 1
---
# Introduction
# 介绍
Candy 是一个轻量级的 HTTP 服务器,旨在快速部署一个高性能的 HTTP 服务器。
## 安装
TODO
## 使用
Candy 支持单个可执行文件运行:
```bash
./target/release/candy -h
Usage: candy [OPTIONS]
Options:
-c, --config <FILE> Sets a custom config file [default: ./config.toml]
-h, --help Print help
-V, --version Print version
```
只需要一个可执行文件和一个配置文件,就可以快速部署一个 HTTP 服务器。
```bash
./target/release/candy -c config.toml
```
`-c` 可以省略,当省略时,默认使用当前目录下的 `config.toml` 文件。

View File

@ -1,11 +1,16 @@
import { themes as prismThemes } from 'prism-react-renderer';
import type { Config } from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
import type { Config } from '@docusaurus/types';
import { themes as prismThemes } from 'prism-react-renderer';
const config: Config = {
title: 'Candy',
tagline: 'Tiny http web server',
favicon: 'img/favicon.ico',
favicon: 'img/candy-transparent.png',
future: {
v4: true,
experimental_faster: true,
},
// Set the production url of your site here
url: 'https://your-docusaurus-site.example.com',
@ -25,7 +30,7 @@ const config: Config = {
// useful metadata like html lang. For example, if your site is Chinese, you
// may want to replace "en" with "zh-Hans".
i18n: {
defaultLocale: 'en',
defaultLocale: 'zh-Hans',
locales: ['en', 'zh-Hans'],
},
@ -62,8 +67,8 @@ const config: Config = {
navbar: {
title: 'RUA',
logo: {
alt: 'My Site Logo',
src: 'img/logo.svg',
alt: 'Candy Logo',
src: 'img/candy-transparent.png',
},
items: [
{

325
docs/i18n/en/code.json Normal file
View File

@ -0,0 +1,325 @@
{
"theme.ErrorPageContent.title": {
"message": "This page crashed.",
"description": "The title of the fallback page when the page crashed"
},
"theme.BackToTopButton.buttonAriaLabel": {
"message": "Scroll back to top",
"description": "The ARIA label for the back to top button"
},
"theme.blog.archive.title": {
"message": "Archive",
"description": "The page & hero title of the blog archive page"
},
"theme.blog.archive.description": {
"message": "Archive",
"description": "The page & hero description of the blog archive page"
},
"theme.blog.paginator.navAriaLabel": {
"message": "Blog list page navigation",
"description": "The ARIA label for the blog pagination"
},
"theme.blog.paginator.newerEntries": {
"message": "Newer entries",
"description": "The label used to navigate to the newer blog posts page (previous page)"
},
"theme.blog.paginator.olderEntries": {
"message": "Older entries",
"description": "The label used to navigate to the older blog posts page (next page)"
},
"theme.blog.post.paginator.navAriaLabel": {
"message": "Blog post page navigation",
"description": "The ARIA label for the blog posts pagination"
},
"theme.blog.post.paginator.newerPost": {
"message": "Newer post",
"description": "The blog post button label to navigate to the newer/previous post"
},
"theme.blog.post.paginator.olderPost": {
"message": "Older post",
"description": "The blog post button label to navigate to the older/next post"
},
"theme.tags.tagsPageLink": {
"message": "View all tags",
"description": "The label of the link targeting the tag list page"
},
"theme.colorToggle.ariaLabel": {
"message": "Switch between dark and light mode (currently {mode})",
"description": "The ARIA label for the color mode toggle"
},
"theme.colorToggle.ariaLabel.mode.dark": {
"message": "dark mode",
"description": "The name for the dark color mode"
},
"theme.colorToggle.ariaLabel.mode.light": {
"message": "light mode",
"description": "The name for the light color mode"
},
"theme.docs.breadcrumbs.navAriaLabel": {
"message": "Breadcrumbs",
"description": "The ARIA label for the breadcrumbs"
},
"theme.docs.DocCard.categoryDescription.plurals": {
"message": "1 item|{count} items",
"description": "The default description for a category card in the generated index about how many items this category includes"
},
"theme.docs.paginator.navAriaLabel": {
"message": "Docs pages",
"description": "The ARIA label for the docs pagination"
},
"theme.docs.paginator.previous": {
"message": "Previous",
"description": "The label used to navigate to the previous doc"
},
"theme.docs.paginator.next": {
"message": "Next",
"description": "The label used to navigate to the next doc"
},
"theme.docs.tagDocListPageTitle.nDocsTagged": {
"message": "One doc tagged|{count} docs tagged",
"description": "Pluralized label for \"{count} docs tagged\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.docs.tagDocListPageTitle": {
"message": "{nDocsTagged} with \"{tagName}\"",
"description": "The title of the page for a docs tag"
},
"theme.docs.versions.unreleasedVersionLabel": {
"message": "This is unreleased documentation for {siteTitle} {versionLabel} version.",
"description": "The label used to tell the user that he's browsing an unreleased doc version"
},
"theme.docs.versions.unmaintainedVersionLabel": {
"message": "This is documentation for {siteTitle} {versionLabel}, which is no longer actively maintained.",
"description": "The label used to tell the user that he's browsing an unmaintained doc version"
},
"theme.docs.versions.latestVersionSuggestionLabel": {
"message": "For up-to-date documentation, see the {latestVersionLink} ({versionLabel}).",
"description": "The label used to tell the user to check the latest version"
},
"theme.docs.versions.latestVersionLinkLabel": {
"message": "latest version",
"description": "The label used for the latest version suggestion link label"
},
"theme.docs.versionBadge.label": {
"message": "Version: {versionLabel}"
},
"theme.common.editThisPage": {
"message": "Edit this page",
"description": "The link label to edit the current page"
},
"theme.common.headingLinkTitle": {
"message": "Direct link to {heading}",
"description": "Title for link to heading"
},
"theme.NotFound.title": {
"message": "Page Not Found",
"description": "The title of the 404 page"
},
"theme.navbar.mobileVersionsDropdown.label": {
"message": "Versions",
"description": "The label for the navbar versions dropdown on mobile view"
},
"theme.lastUpdated.atDate": {
"message": " on {date}",
"description": "The words used to describe on which date a page has been last updated"
},
"theme.lastUpdated.byUser": {
"message": " by {user}",
"description": "The words used to describe by who the page has been last updated"
},
"theme.lastUpdated.lastUpdatedAtBy": {
"message": "Last updated{atDate}{byUser}",
"description": "The sentence used to display when a page has been last updated, and by who"
},
"theme.tags.tagsListLabel": {
"message": "Tags:",
"description": "The label alongside a tag list"
},
"theme.AnnouncementBar.closeButtonAriaLabel": {
"message": "Close",
"description": "The ARIA label for close button of announcement bar"
},
"theme.admonition.caution": {
"message": "caution",
"description": "The default label used for the Caution admonition (:::caution)"
},
"theme.admonition.danger": {
"message": "danger",
"description": "The default label used for the Danger admonition (:::danger)"
},
"theme.admonition.info": {
"message": "info",
"description": "The default label used for the Info admonition (:::info)"
},
"theme.admonition.note": {
"message": "note",
"description": "The default label used for the Note admonition (:::note)"
},
"theme.admonition.tip": {
"message": "tip",
"description": "The default label used for the Tip admonition (:::tip)"
},
"theme.admonition.warning": {
"message": "warning",
"description": "The default label used for the Warning admonition (:::warning)"
},
"theme.blog.sidebar.navAriaLabel": {
"message": "Blog recent posts navigation",
"description": "The ARIA label for recent posts in the blog sidebar"
},
"theme.CodeBlock.copied": {
"message": "Copied",
"description": "The copied button label on code blocks"
},
"theme.CodeBlock.copyButtonAriaLabel": {
"message": "Copy code to clipboard",
"description": "The ARIA label for copy code blocks button"
},
"theme.CodeBlock.copy": {
"message": "Copy",
"description": "The copy button label on code blocks"
},
"theme.CodeBlock.wordWrapToggle": {
"message": "Toggle word wrap",
"description": "The title attribute for toggle word wrapping button of code block lines"
},
"theme.DocSidebarItem.expandCategoryAriaLabel": {
"message": "Expand sidebar category '{label}'",
"description": "The ARIA label to expand the sidebar category"
},
"theme.DocSidebarItem.collapseCategoryAriaLabel": {
"message": "Collapse sidebar category '{label}'",
"description": "The ARIA label to collapse the sidebar category"
},
"theme.NavBar.navAriaLabel": {
"message": "Main",
"description": "The ARIA label for the main navigation"
},
"theme.NotFound.p1": {
"message": "We could not find what you were looking for.",
"description": "The first paragraph of the 404 page"
},
"theme.NotFound.p2": {
"message": "Please contact the owner of the site that linked you to the original URL and let them know their link is broken.",
"description": "The 2nd paragraph of the 404 page"
},
"theme.navbar.mobileLanguageDropdown.label": {
"message": "Languages",
"description": "The label for the mobile language switcher dropdown"
},
"theme.TOCCollapsible.toggleButtonLabel": {
"message": "On this page",
"description": "The label used by the button on the collapsible TOC component"
},
"theme.blog.post.readMore": {
"message": "Read more",
"description": "The label used in blog post item excerpts to link to full blog posts"
},
"theme.blog.post.readMoreLabel": {
"message": "Read more about {title}",
"description": "The ARIA label for the link to full blog posts from excerpts"
},
"theme.blog.post.readingTime.plurals": {
"message": "One min read|{readingTime} min read",
"description": "Pluralized label for \"{readingTime} min read\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.docs.breadcrumbs.home": {
"message": "Home page",
"description": "The ARIA label for the home page in the breadcrumbs"
},
"theme.docs.sidebar.collapseButtonTitle": {
"message": "Collapse sidebar",
"description": "The title attribute for collapse button of doc sidebar"
},
"theme.docs.sidebar.collapseButtonAriaLabel": {
"message": "Collapse sidebar",
"description": "The title attribute for collapse button of doc sidebar"
},
"theme.docs.sidebar.navAriaLabel": {
"message": "Docs sidebar",
"description": "The ARIA label for the sidebar navigation"
},
"theme.docs.sidebar.closeSidebarButtonAriaLabel": {
"message": "Close navigation bar",
"description": "The ARIA label for close button of mobile sidebar"
},
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": {
"message": "← Back to main menu",
"description": "The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)"
},
"theme.docs.sidebar.toggleSidebarButtonAriaLabel": {
"message": "Toggle navigation bar",
"description": "The ARIA label for hamburger menu button of mobile navigation"
},
"theme.docs.sidebar.expandButtonTitle": {
"message": "Expand sidebar",
"description": "The ARIA label and title attribute for expand button of doc sidebar"
},
"theme.docs.sidebar.expandButtonAriaLabel": {
"message": "Expand sidebar",
"description": "The ARIA label and title attribute for expand button of doc sidebar"
},
"theme.blog.post.plurals": {
"message": "One post|{count} posts",
"description": "Pluralized label for \"{count} posts\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.blog.tagTitle": {
"message": "{nPosts} tagged with \"{tagName}\"",
"description": "The title of the page for a blog tag"
},
"theme.blog.author.pageTitle": {
"message": "{authorName} - {nPosts}",
"description": "The title of the page for a blog author"
},
"theme.blog.authorsList.pageTitle": {
"message": "Authors",
"description": "The title of the authors page"
},
"theme.blog.authorsList.viewAll": {
"message": "View all authors",
"description": "The label of the link targeting the blog authors page"
},
"theme.blog.author.noPosts": {
"message": "This author has not written any posts yet.",
"description": "The text for authors with 0 blog post"
},
"theme.contentVisibility.unlistedBanner.title": {
"message": "Unlisted page",
"description": "The unlisted content banner title"
},
"theme.contentVisibility.unlistedBanner.message": {
"message": "This page is unlisted. Search engines will not index it, and only users having a direct link can access it.",
"description": "The unlisted content banner message"
},
"theme.contentVisibility.draftBanner.title": {
"message": "Draft page",
"description": "The draft content banner title"
},
"theme.contentVisibility.draftBanner.message": {
"message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"description": "The draft content banner message"
},
"theme.ErrorPageContent.tryAgain": {
"message": "Try again",
"description": "The label of the button to try again rendering when the React error boundary captures an error"
},
"theme.common.skipToMainContent": {
"message": "Skip to main content",
"description": "The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation"
},
"theme.tags.tagsPageTitle": {
"message": "Tags",
"description": "The title of the tag list page"
},
"theme.colorToggle.ariaLabel.mode.system": {
"message": "system mode",
"description": "The name for the system color mode"
},
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": {
"message": "Expand the dropdown",
"description": "The ARIA label of the button to expand the mobile dropdown navbar item"
},
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": {
"message": "Collapse the dropdown",
"description": "The ARIA label of the button to collapse the mobile dropdown navbar item"
}
}

View File

@ -0,0 +1,14 @@
{
"title": {
"message": "Blog",
"description": "The title for the blog used in SEO"
},
"description": {
"message": "Blog",
"description": "The description for the blog used in SEO"
},
"sidebar.title": {
"message": "Recent posts",
"description": "The label for the left sidebar"
}
}

View File

@ -0,0 +1,10 @@
{
"version.label": {
"message": "Next",
"description": "The label for version current"
},
"sidebar.tutorialSidebar.category.配置": {
"message": "配置",
"description": "The label for category 配置 in sidebar tutorialSidebar"
}
}

View File

@ -0,0 +1,4 @@
{
"label": "Configuration",
"position": 2
}

View File

@ -0,0 +1,85 @@
---
sidebar_label: Config File
sidebar_position: 1
title: Config File
---
## Config File
Candy follows the config file to configure.
### Virtual Host
The top level configuration is the virtual host `host`, and can configure multiple virtual hosts.
```toml
[[host]]
ip = "0.0.0.0"
port = 80
# Connection timeout
timeout = 15
# Only read certificate and key when ssl = true, and enable SSL support
# ssl = true
# Self sign a certificate
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
```
#### Custom HTTP Response Header
Each virtual host can configure custom response header.
TODO
### Route
Each virtual host can configure multiple routes. The configuration field is `route`.
Each route supports three configurations:
- Static file hosting
- Reverse proxy
- Lua script
#### Static File Hosting
```toml
[[host.route]]
# Route location
location = "/"
# Static file root
# or proxy_pass
# or redirect
root = "html"
# Only use for root field
index = ["index.html"]
# List directory
auto_index = true
```
#### Reverse Proxy
```toml
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
```
#### Lua Script
```toml
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"
```

View File

@ -0,0 +1,33 @@
---
sidebar_position: 1
---
# Introduction
Candy is a lightweight HTTP server, aiming to quickly deploy a high-performance HTTP server.
## Installation
TODO
## Usage
Candy supports single executable file running:
```bash
./target/release/candy -h
Usage: candy [OPTIONS]
Options:
-c, --config <FILE> Sets a custom config file [default: ./config.toml]
-h, --help Print help
-V, --version Print version
```
Only one config file is supported, the default config file is `./config.toml`.
```bash
./target/release/candy -c config.toml
```
`-c` can be omitted, and when omitted, the default config file is `./config.toml` in the current directory.

View File

@ -0,0 +1,6 @@
{
"copyright": {
"message": "Copyright © 2024 Candy, Inc. Built with Docusaurus.",
"description": "The footer copyright"
}
}

View File

@ -0,0 +1,22 @@
{
"title": {
"message": "RUA",
"description": "The title in the navbar"
},
"logo.alt": {
"message": "My Site Logo",
"description": "The alt text of navbar logo"
},
"item.label.Tutorial": {
"message": "Tutorial",
"description": "Navbar item with label Tutorial"
},
"item.label.Blog": {
"message": "Blog",
"description": "Navbar item with label Blog"
},
"item.label.GitHub": {
"message": "GitHub",
"description": "Navbar item with label GitHub"
}
}

View File

@ -39,21 +39,13 @@
"message": "较旧一篇",
"description": "The blog post button label to navigate to the older/next post"
},
"theme.blog.post.plurals": {
"message": "{count} 篇博文",
"description": "Pluralized label for \"{count} posts\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.blog.tagTitle": {
"message": "{nPosts} 含有标签「{tagName}」",
"description": "The title of the page for a blog tag"
},
"theme.tags.tagsPageLink": {
"message": "查看所有标签",
"description": "The label of the link targeting the tag list page"
},
"theme.colorToggle.ariaLabel": {
"message": "切换浅色/暗黑模式(当前为{mode}",
"description": "The ARIA label for the navbar color mode toggle"
"description": "The ARIA label for the color mode toggle"
},
"theme.colorToggle.ariaLabel.mode.dark": {
"message": "暗黑模式",
@ -142,6 +134,10 @@
"message": "标签:",
"description": "The label alongside a tag list"
},
"theme.AnnouncementBar.closeButtonAriaLabel": {
"message": "关闭",
"description": "The ARIA label for close button of announcement bar"
},
"theme.admonition.caution": {
"message": "警告",
"description": "The default label used for the Caution admonition (:::caution)"
@ -166,14 +162,14 @@
"message": "注意",
"description": "The default label used for the Warning admonition (:::warning)"
},
"theme.AnnouncementBar.closeButtonAriaLabel": {
"message": "关闭",
"description": "The ARIA label for close button of announcement bar"
},
"theme.blog.sidebar.navAriaLabel": {
"message": "最近博文导航",
"description": "The ARIA label for recent posts in the blog sidebar"
},
"theme.CodeBlock.wordWrapToggle": {
"message": "切换自动换行",
"description": "The title attribute for toggle word wrapping button of code block lines"
},
"theme.CodeBlock.copied": {
"message": "复制成功",
"description": "The copied button label on code blocks"
@ -186,10 +182,6 @@
"message": "复制",
"description": "The copy button label on code blocks"
},
"theme.CodeBlock.wordWrapToggle": {
"message": "切换自动换行",
"description": "The title attribute for toggle word wrapping button of code block lines"
},
"theme.DocSidebarItem.expandCategoryAriaLabel": {
"message": "展开侧边栏分类 '{label}'",
"description": "The ARIA label to expand the sidebar category"
@ -218,6 +210,10 @@
"message": "本页总览",
"description": "The label used by the button on the collapsible TOC component"
},
"theme.blog.post.readingTime.plurals": {
"message": "阅读需 {readingTime} 分钟",
"description": "Pluralized label for \"{readingTime} min read\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.blog.post.readMore": {
"message": "阅读更多",
"description": "The label used in blog post item excerpts to link to full blog posts"
@ -226,10 +222,6 @@
"message": "阅读 {title} 的全文",
"description": "The ARIA label for the link to full blog posts from excerpts"
},
"theme.blog.post.readingTime.plurals": {
"message": "阅读需 {readingTime} 分钟",
"description": "Pluralized label for \"{readingTime} min read\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.docs.breadcrumbs.home": {
"message": "主页面",
"description": "The ARIA label for the home page in the breadcrumbs"
@ -266,6 +258,46 @@
"message": "展开侧边栏",
"description": "The ARIA label and title attribute for expand button of doc sidebar"
},
"theme.blog.post.plurals": {
"message": "{count} 篇博文",
"description": "Pluralized label for \"{count} posts\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.blog.tagTitle": {
"message": "{nPosts} 含有标签「{tagName}」",
"description": "The title of the page for a blog tag"
},
"theme.blog.author.pageTitle": {
"message": "{authorName} - {nPosts}",
"description": "The title of the page for a blog author"
},
"theme.blog.authorsList.pageTitle": {
"message": "Authors",
"description": "The title of the authors page"
},
"theme.blog.authorsList.viewAll": {
"message": "View All Authors",
"description": "The label of the link targeting the blog authors page"
},
"theme.blog.author.noPosts": {
"message": "This author has not written any posts yet.",
"description": "The text for authors with 0 blog post"
},
"theme.contentVisibility.unlistedBanner.title": {
"message": "未列出页",
"description": "The unlisted content banner title"
},
"theme.contentVisibility.unlistedBanner.message": {
"message": "此页面未列出。搜索引擎不会对其索引,只有拥有直接链接的用户才能访问。",
"description": "The unlisted content banner message"
},
"theme.contentVisibility.draftBanner.title": {
"message": "Draft page",
"description": "The draft content banner title"
},
"theme.contentVisibility.draftBanner.message": {
"message": "This page is a draft. It will only be visible in dev and be excluded from the production build.",
"description": "The draft content banner message"
},
"theme.ErrorPageContent.tryAgain": {
"message": "重试",
"description": "The label of the button to try again rendering when the React error boundary captures an error"
@ -278,12 +310,16 @@
"message": "标签",
"description": "The title of the tag list page"
},
"theme.unlistedContent.title": {
"message": "未列出页",
"description": "The unlisted content banner title"
"theme.colorToggle.ariaLabel.mode.system": {
"message": "system mode",
"description": "The name for the system color mode"
},
"theme.unlistedContent.message": {
"message": "此页面未列出。搜索引擎不会对其索引,只有拥有直接链接的用户才能访问。",
"description": "The unlisted content banner message"
"theme.navbar.mobileDropdown.collapseButton.expandAriaLabel": {
"message": "Expand the dropdown",
"description": "The ARIA label of the button to expand the mobile dropdown navbar item"
},
"theme.navbar.mobileDropdown.collapseButton.collapseAriaLabel": {
"message": "Collapse the dropdown",
"description": "The ARIA label of the button to collapse the mobile dropdown navbar item"
}
}

View File

@ -1,8 +0,0 @@
---
slug: hello-world
title: Hello World
authors: xfy
tags: [hello]
---
## 你好,世界

View File

@ -1,5 +0,0 @@
xfy:
name: xfy
title: xfy
url: https://github.com/DefectingCat
image_url: https://avatars.githubusercontent.com/u/25033493?v=4

View File

@ -1,14 +1,14 @@
{
"title": {
"message": "博客",
"message": "Blog",
"description": "The title for the blog used in SEO"
},
"description": {
"message": "博客",
"message": "Blog",
"description": "The description for the blog used in SEO"
},
"sidebar.title": {
"message": "近期文章",
"message": "Recent posts",
"description": "The label for the left sidebar"
}
}

View File

@ -0,0 +1,4 @@
{
"label": "配置",
"position": 2
}

View File

@ -0,0 +1,5 @@
---
sidebar_position: 1
---
## 配置文件

View File

@ -2,5 +2,9 @@
"version.label": {
"message": "Next",
"description": "The label for version current"
},
"sidebar.tutorialSidebar.category.配置": {
"message": "配置",
"description": "The label for category 配置 in sidebar tutorialSidebar"
}
}

View File

@ -0,0 +1,4 @@
{
"label": "配置",
"position": 2
}

View File

@ -0,0 +1,83 @@
---
sidebar_label: 配置文件
sidebar_position: 1
title: 配置文件
---
## 配置文件
Candy 遵循配置文件进行配置。配置文件的格式为 TOML。
### 虚拟主机
顶层配置为虚拟主机 `host`,可以配置多个虚拟主机。
```toml
[[host]]
ip = "0.0.0.0"
port = 80
# Connection timeout
timeout = 15
# 只用当 ssl = true 时,才会读取证书和密钥,并开启 SSL 支持
# ssl = true
# Self sign a certificate
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./html/selfsigned.key -out ./html/selfsigned.crt
certificate = "./html/selfsigned.crt"
certificate_key = "./html/selfsigned.key"
```
#### 自定义 HTTP 相应头
每个虚拟主机都可以配置自定义相应头
TODO
### 路由
每个虚拟主机下都可以配置多个路由。配置字段为 `route`
每个路由支持三种配置:
- 静态文件托管
- 反向代理
- Lua 脚本
#### 静态文件托管
```toml
[[host.route]]
# 路由地址
location = "/"
# 静态文件根目录
root = "html"
# 当使用静态文件根目录时,使用下面的字段作为主页
index = ["index.html"]
# 列出目录
auto_index = true
```
#### 反向代理
```toml
[[host]]
ip = "0.0.0.0"
port = 8080
[[host.route]]
location = "/"
proxy_pass = "http://localhost:3000/"
# Timeout for connect to upstream
proxy_timeout = 10
# Client request max body size (bytes)
max_body_size = 2048
```
#### Lua 脚本
```toml
[[host]]
ip = "0.0.0.0"
port = 8081
[[host.route]]
location = "/"
lua_script = "html/index.lua"
```

View File

@ -3,3 +3,31 @@ sidebar_position: 1
---
# 介绍
Candy 是一个轻量级的 HTTP 服务器,旨在快速部署一个高性能的 HTTP 服务器。
## 安装
TODO
## 使用
Candy 支持单个可执行文件运行:
```bash
./target/release/candy -h
Usage: candy [OPTIONS]
Options:
-c, --config <FILE> Sets a custom config file [default: ./config.toml]
-h, --help Print help
-V, --version Print version
```
只需要一个可执行文件和一个配置文件,就可以快速部署一个 HTTP 服务器。
```bash
./target/release/candy -c config.toml
```
`-c` 可以省略,当省略时,默认使用当前目录下的 `config.toml` 文件。

View File

@ -0,0 +1,7 @@
---
sidebar_position: 1
---
# 介绍
Candy 是一个轻量级的 HTTP 服务器,旨在快速部署一个高性能的 HTTP 服务器。

View File

@ -4,7 +4,7 @@
"description": "The title in the navbar"
},
"logo.alt": {
"message": "My Site Logo",
"message": "Candy logo",
"description": "The alt text of navbar logo"
},
"item.label.Tutorial": {
@ -12,7 +12,7 @@
"description": "Navbar item with label Tutorial"
},
"item.label.Blog": {
"message": "博客",
"message": "Blog",
"description": "Navbar item with label Blog"
},
"item.label.GitHub": {

View File

@ -6,30 +6,35 @@
"docusaurus": "docusaurus",
"start": "docusaurus start",
"start-zh": "docusaurus start --locale zh-Hans",
"build": "docusaurus build",
"build": "docusaurus write-translations --locale en && docusaurus write-translations --locale zh-Hans && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-translations-zh": "docusaurus write-translations --locale zh-Hans",
"write-translations": "docusaurus write-translations --locale zh-Hans",
"write-translations-en": "docusaurus write-translations --locale en",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
"pretty": "prettier --cache --write \"./**/*.{js,jsx,ts,tsx,json,md,mdx,css}\" --ignore-unknown --plugin=prettier-plugin-organize-imports",
"pretty-check": "prettier --cache \"./**/*.{js,jsx,ts,tsx,json,md,mdx,css}\" --ignore-unknown --check --plugin=prettier-plugin-organize-imports",
"tsc": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.3.2",
"@docusaurus/preset-classic": "3.3.2",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"@docusaurus/core": "3.8.1",
"@docusaurus/faster": "^3.8.1",
"@docusaurus/preset-classic": "3.8.1",
"@mdx-js/react": "^3.1.0",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.3.2",
"@docusaurus/tsconfig": "3.3.2",
"@docusaurus/types": "3.3.2",
"typescript": "~5.2.2"
"@docusaurus/module-type-aliases": "3.8.1",
"@docusaurus/tsconfig": "3.8.1",
"@docusaurus/types": "3.8.1",
"prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.1.0",
"typescript": "~5.8.3"
},
"browserslist": {
"production": [
@ -46,5 +51,5 @@
"engines": {
"node": ">=18.0"
},
"packageManager": "pnpm@9.1.2+sha512.127dc83b9ea10c32be65d22a8efb4a65fb952e8fefbdfded39bdc3c97efc32d31b48b00420df2c1187ace28c921c902f0cb5a134a4d032b8b5295cbfa2c681e2"
"packageManager": "pnpm@10.13.0"
}

7907
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4
docs/pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,4 @@
onlyBuiltDependencies:
- '@swc/core'
- core-js
- core-js-pure

View File

@ -1,4 +1,4 @@
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
import type { SidebarsConfig } from '@docusaurus/plugin-content-docs';
/**
* Creating a sidebar enables you to:
@ -12,7 +12,7 @@ import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
*/
const sidebars: SidebarsConfig = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
tutorialSidebar: [{ type: 'autogenerated', dirName: '.' }],
// But you can create a sidebar manually
/*

View File

@ -1,5 +1,5 @@
import clsx from 'clsx';
import Heading from '@theme/Heading';
import clsx from 'clsx';
import styles from './styles.module.css';
type FeatureItem = {
@ -14,34 +14,26 @@ const FeatureList: FeatureItem[] = [
Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default,
description: (
<>
Docusaurus was designed from the ground up to be easily installed and
used to get your website up and running quickly.
Single executable binary, with a TOML config file, quick to deploy a
http server.
</>
),
},
{
title: 'Focus on What Matters',
title: 'Performance',
Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default,
description: (
<>
Docusaurus lets you focus on your docs, and we&apos;ll do the chores. Go
ahead and move your docs into the <code>docs</code> directory.
</>
<>Multiple threads, asynchronous I/O, and multi-platform support.</>
),
},
{
title: 'Powered by React',
title: 'Powered by Rust',
Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,
description: (
<>
Extend or customize your website layout by reusing React. Docusaurus can
be extended while reusing the same header and footer.
</>
),
description: <>Built with Rust, axum and tokio.</>,
},
];
function Feature({title, Svg, description}: FeatureItem) {
function Feature({ title, Svg, description }: FeatureItem) {
return (
<div className={clsx('col col--4')}>
<div className="text--center">

View File

@ -1,14 +1,14 @@
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import HomepageFeatures from '@site/src/components/HomepageFeatures';
import Heading from '@theme/Heading';
import Layout from '@theme/Layout';
import clsx from 'clsx';
import styles from './index.module.css';
function HomepageHeader() {
const {siteConfig} = useDocusaurusContext();
const { siteConfig } = useDocusaurusContext();
return (
<header className={clsx('hero hero--primary', styles.heroBanner)}>
<div className="container">
@ -19,8 +19,9 @@ function HomepageHeader() {
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs/intro">
Docusaurus Tutorial - 5min
to="/docs/intro"
>
Quick start - 5min
</Link>
</div>
</div>
@ -29,11 +30,9 @@ function HomepageHeader() {
}
export default function Home(): JSX.Element {
const {siteConfig} = useDocusaurusContext();
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />">
<Layout title={`${siteConfig.title}`} description="Candy, http web server">
<HomepageHeader />
<main>
<HomepageFeatures />

BIN
docs/static/img/candy-transparent.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

BIN
docs/static/img/candy.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -2,8 +2,9 @@ use clap::Parser;
#[derive(Parser)]
#[command(version, about, long_about = None)]
/// A tiny HTTP server.
pub struct Cli {
/// Sets a custom config file
/// Set a custom config file location.
#[arg(short, long, value_name = "FILE", default_value = "./config.toml")]
pub config: String,
}

View File

@ -1,10 +1,11 @@
use crate::{
consts::{host_index, insert_default_mimes, mime_default, timeout_default, types_default},
consts::{default_disabled, host_index, timeout_default, upstream_timeout_default},
error::Result,
};
use std::{borrow::Cow, collections::BTreeMap, fs};
use std::fs;
use anyhow::Context;
use dashmap::DashMap;
use serde::Deserialize;
#[derive(Deserialize, Clone, Debug)]
@ -13,66 +14,157 @@ pub struct ErrorRoute {
pub page: String,
}
/// Route in virtual host
/// Can be a static file or a reverse proxy
#[derive(Deserialize, Clone, Debug)]
pub struct SettingRoute {
/// The register route
/// for axum route
pub location: String,
/// The static assets root folder
pub root: Option<String>,
/// List directory
#[serde(default = "default_disabled")]
pub auto_index: bool,
/// Index files format
#[serde(default = "host_index")]
pub index: Vec<String>,
/// Custom error page
pub error_page: Option<ErrorRoute>,
// reverse proxy url
/// Custom 404 page
pub not_found_page: Option<ErrorRoute>,
/// Reverse proxy url
pub proxy_pass: Option<String>,
/// Timeout for connect to upstream
#[serde(default = "upstream_timeout_default")]
pub proxy_timeout: u16,
/// Request max body size (bytes)
pub max_body_size: Option<u64>,
/// Lua script
pub lua_script: Option<String>,
}
pub type HostRouteMap = BTreeMap<String, SettingRoute>;
/// Host routes
/// Each host can have multiple routes
pub type HostRouteMap = DashMap<String, SettingRoute>;
/// headers
pub type HeaderMap = DashMap<String, String>;
#[derive(Deserialize, Clone, Debug)]
/// Virtual host
/// Each host can listen on one port and one ip
#[derive(Deserialize, Clone, Debug, Default)]
pub struct SettingHost {
/// Host ip
pub ip: String,
pub port: u32,
route: Vec<Option<SettingRoute>>,
#[serde(skip_deserializing, skip_serializing)]
/// Host port
pub port: u16,
/// SSL enable
#[serde(default = "default_disabled")]
pub ssl: bool,
/// SSL certificate location
pub certificate: Option<String>,
/// ssl key location
pub certificate_key: Option<String>,
/// Routes in config file
pub route: Vec<SettingRoute>,
/// Host routes convert from Vec<SettingRoute> to DashMap<String, SettingRoute>
/// {
/// "/doc": <SettingRoute>
/// }
#[serde(skip)]
pub route_map: HostRouteMap,
/// HTTP keep-alive timeout
#[serde(default = "timeout_default")]
pub timeout: u16,
pub headers: Option<BTreeMap<String, String>>,
/// HTTP headers
/// Used to overwrite headers in config
pub headers: Option<HeaderMap>,
}
pub type MIMEType = BTreeMap<Cow<'static, str>, Cow<'static, str>>;
#[derive(Deserialize, Clone, Debug)]
/// Whole config settings
#[derive(Deserialize, Clone, Debug, Default)]
pub struct Settings {
#[serde(default = "mime_default")]
pub default_type: Cow<'static, str>,
#[serde(default = "types_default")]
pub types: MIMEType,
/// Virtual host
pub host: Vec<SettingHost>,
}
impl Settings {
pub fn new(path: &str) -> Result<Self> {
let file = fs::read_to_string(path).with_context(|| format!("read {path} failed"))?;
let mut settings: Settings = toml::from_str(&file)?;
// convert route map
settings.host.iter_mut().for_each(|host| {
let routes = &mut host.route;
for route in routes.iter_mut() {
if route.is_none() {
continue;
}
let route = route.take().unwrap();
host.route_map.insert(route.location.to_string(), route);
}
});
// combine mime types
insert_default_mimes(&mut settings.types);
let settings: Settings = toml::from_str(&file)?;
Ok(settings)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_settings_new() {
// Create a temporary TOML config file
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
r#"
default_type = "text/plain"
types = {{ "txt" = "text/plain", "html" = "text/html" }}
[[host]]
ip = "127.0.0.1"
port = 8080
ssl = false
timeout = 30
[[host.route]]
location = "/"
root = "/var/www"
index = ["index.html", "index.txt"]
proxy_timeout = 10
"#,
)
.unwrap();
let path = file.path().to_str().unwrap();
let settings = Settings::new(path).unwrap();
// Verify host settings
let host = &settings.host[0];
assert_eq!(host.ip, "127.0.0.1");
assert_eq!(host.port, 8080);
assert_eq!(host.timeout, 30);
// Verify route settings
let route = &host.route[0];
assert_eq!(route.location, "/");
assert_eq!(route.root, Some("/var/www".to_string()));
assert_eq!(route.proxy_timeout, 10);
}
#[test]
fn test_settings_missing_file() {
let result = Settings::new("nonexistent.toml");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("read nonexistent.toml failed")
);
}
#[test]
fn test_settings_invalid_toml() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "invalid toml content").unwrap();
let path = file.path().to_str().unwrap();
let result = Settings::new(path);
assert!(result.is_err());
}
}

View File

@ -1,21 +1,4 @@
use std::{borrow::Cow, collections::BTreeMap, env, process::exit, sync::OnceLock};
use tracing::error;
use crate::config::{MIMEType, Settings};
// global settings
pub static SETTINGS: OnceLock<Settings> = OnceLock::new();
pub fn get_settings() -> &'static Settings {
SETTINGS.get_or_init(|| {
Settings::new("")
.map_err(|err| {
error!("get_or_init config failed: {err}");
exit(1);
})
.unwrap()
})
}
use std::env;
// pre defined
pub const NAME: &str = env!("CARGO_PKG_NAME");
@ -23,6 +6,7 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const OS: &str = env::consts::OS;
pub const ARCH: &str = env::consts::ARCH;
pub const COMPILER: &str = env!("RUA_COMPILER");
pub const COMMIT: &str = env!("RUA_COMMIT");
// config defaults
pub const HOST_INDEX: [&str; 1] = ["index.html"];
@ -36,82 +20,13 @@ pub fn timeout_default() -> u16 {
TIMEOUT_EFAULT
}
// default mime type for unknow file
pub const MIME_DEFAULT: &str = "application/octet-stream";
pub fn mime_default() -> Cow<'static, str> {
MIME_DEFAULT.into()
// default reverse proxy upstream timeout
pub const UPSTREAM_TIMEOUT: u16 = 5;
pub fn upstream_timeout_default() -> u16 {
UPSTREAM_TIMEOUT
}
// default mime types
pub fn types_default() -> MIMEType {
BTreeMap::new()
}
macro_rules! insert_mime {
($name:literal, $mime:ident, $map:ident) => {
$map.entry($name.into()).or_insert($mime.into());
};
}
pub fn insert_default_mimes(map: &mut MIMEType) {
use crate::http::mime::*;
insert_mime!("html", TEXT_HTML, map);
insert_mime!("htm", TEXT_HTML, map);
insert_mime!("shtml", TEXT_HTML, map);
insert_mime!("css", TEXT_CSS, map);
insert_mime!("xml", TEXT_XML, map);
insert_mime!("rss", TEXT_XML, map);
insert_mime!("txt", TEXT_PLAIN, map);
insert_mime!("gif", IMAGE_GIF, map);
insert_mime!("jpg", IMAGE_JPEG, map);
insert_mime!("jpeg", IMAGE_JPEG, map);
insert_mime!("png", IMAGE_PNG, map);
insert_mime!("ico", IMAGE_ICON, map);
insert_mime!("jng", IMAGE_JNG, map);
insert_mime!("wbmp", IMAGE_WBMP, map);
insert_mime!("webp", IMAGE_WEBP, map);
insert_mime!("avif", IMAGE_AVIF, map);
insert_mime!("svg", IMAGE_SVG, map);
insert_mime!("svgz", IMAGE_SVG, map);
insert_mime!("tif", IMAGE_TIFF, map);
insert_mime!("tiff", IMAGE_TIFF, map);
insert_mime!("bmp", IMAGE_BMP, map);
insert_mime!("js", APPLICATION_JAVASCRIPT, map);
insert_mime!("wasm", APPLICATION_WASM, map);
insert_mime!("json", APPLICATION_JSON, map);
insert_mime!("jar", APPLICATION_JAVA_ARCHIVE, map);
insert_mime!("war", APPLICATION_JAVA_ARCHIVE, map);
insert_mime!("ear", APPLICATION_JAVA_ARCHIVE, map);
insert_mime!("m3u8", APPLICATION_APPLE_MPEGURL, map);
insert_mime!("bin", APPLICATION_OCTET_STREAM, map);
insert_mime!("exe", APPLICATION_OCTET_STREAM, map);
insert_mime!("dll", APPLICATION_OCTET_STREAM, map);
insert_mime!("deb", APPLICATION_OCTET_STREAM, map);
insert_mime!("dmg", APPLICATION_OCTET_STREAM, map);
insert_mime!("iso", APPLICATION_OCTET_STREAM, map);
insert_mime!("img", APPLICATION_OCTET_STREAM, map);
insert_mime!("msi", APPLICATION_OCTET_STREAM, map);
insert_mime!("msp", APPLICATION_OCTET_STREAM, map);
insert_mime!("msm", APPLICATION_OCTET_STREAM, map);
insert_mime!("woff", FONT_WOFF, map);
insert_mime!("woff2", FONT_WOFF2, map);
insert_mime!("ts", VIDEO_MP2T, map);
insert_mime!("3gpp", VIDEO_3GPP, map);
insert_mime!("3gp", VIDEO_3GPP, map);
insert_mime!("mp4", VIDEO_MP4, map);
insert_mime!("mpeg", VIDEO_MPEG, map);
insert_mime!("mpg", VIDEO_MPEG, map);
insert_mime!("mov", VIDEO_QUICKTIME, map);
insert_mime!("webm", VIDEO_WEBM, map);
insert_mime!("flv", VIDEO_X_FLV, map);
insert_mime!("m4v", VIDEO_X_M4V, map);
insert_mime!("mng", VIDEO_X_MNG, map);
insert_mime!("asx", VIDEO_X_MS_ASF, map);
insert_mime!("asf", VIDEO_X_MS_ASF, map);
insert_mime!("wmv", VIDEO_X_MS_WMV, map);
insert_mime!("avi", VIDEO_X_MSVIDEO, map);
// default boolean false
pub fn default_disabled() -> bool {
false
}

View File

@ -1,8 +1,7 @@
use std::{borrow::Cow, io, num::TryFromIntError, sync::PoisonError, time::SystemTimeError};
use std::{io, num::TryFromIntError, time::SystemTimeError};
use anyhow::anyhow;
use http::uri::InvalidUri;
use hyper::header::{InvalidHeaderValue, ToStrError};
use hyper::header::ToStrError;
#[allow(clippy::enum_variant_names)]
#[derive(thiserror::Error, Debug)]
@ -24,22 +23,8 @@ pub enum Error {
InvalidUri(#[from] InvalidUri),
#[error("hyper {0}")]
HyperError(#[from] hyper::Error),
// http
#[error("route not found {0}")]
NotFound(Cow<'static, str>),
#[error("internal server error {0}")]
InternalServerError(#[from] anyhow::Error),
#[error("invalide header value {0}")]
InvalidHeader(#[from] InvalidHeaderValue),
#[error("")]
Empty,
Any(#[from] anyhow::Error),
}
pub type Result<T, E = Error> = anyhow::Result<T, E>;
impl<T> From<PoisonError<T>> for Error {
fn from(err: PoisonError<T>) -> Self {
Self::InternalServerError(anyhow!("global cache poisoned {err}"))
}
}

100
src/http/error.rs Normal file
View File

@ -0,0 +1,100 @@
use std::fmt::Display;
use crate::consts::{NAME, VERSION};
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use const_format::formatcp;
use serde_repr::*;
use tracing::error;
#[derive(thiserror::Error, Debug)]
pub enum RouteError {
// Common errors
#[error("{0}")]
Any(#[from] anyhow::Error),
#[error("{0}")]
Infallible(#[from] std::convert::Infallible),
// Route errors
#[error("route not found")]
RouteNotFound(),
#[error("internal error")]
InternalError(),
#[error("bad request")]
BadRequest(),
}
#[derive(Serialize_repr, Deserialize_repr, PartialEq, Debug)]
#[repr(u16)]
pub enum ErrorCode {
Normal = 200,
InternalError = 500,
NotFound = 404,
BadRequest = 400,
}
/// Normal error message
const SERVER_ERROR_STR: &str = formatcp!(
r#"Internal Server Error
{NAME} v{VERSION}
Powered by RUA
"#
);
/// Not found error message
const NOT_FOUND_STR: &str = formatcp!(
r#"Resource Not Found
{NAME} v{VERSION}
Powered by RUA
"#
);
const BAD_REQUEST_STR: &str = formatcp!(
r#"Bad Request
{NAME} v{VERSION}
Powered by RUA
"#
);
impl Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
use ErrorCode::*;
let res = match self {
Normal => "",
InternalError => SERVER_ERROR_STR,
NotFound => NOT_FOUND_STR,
BadRequest => BAD_REQUEST_STR,
};
f.write_str(res)?;
Ok(())
}
}
/// Log and return INTERNAL_SERVER_ERROR
fn log_internal_error<T: Display>(err: T) -> (StatusCode, String) {
use ErrorCode::*;
error!("{err}");
(StatusCode::INTERNAL_SERVER_ERROR, InternalError.to_string())
}
// Tell axum how to convert `AppError` into a response.
impl IntoResponse for RouteError {
fn into_response(self) -> Response {
use RouteError::*;
let (status_code, err_message) = match self {
Any(err) => log_internal_error(err),
RouteNotFound() => (StatusCode::NOT_FOUND, ErrorCode::NotFound.to_string()),
InternalError() => (StatusCode::NOT_FOUND, ErrorCode::InternalError.to_string()),
// Infallible(infallible) => todo!(),
BadRequest() => (StatusCode::NOT_FOUND, ErrorCode::BadRequest.to_string()),
};
(status_code, err_message).into_response()
}
}
pub type RouteResult<T, E = RouteError> = Result<T, E>;

149
src/http/lua.rs Normal file
View File

@ -0,0 +1,149 @@
use std::str::FromStr;
use anyhow::{Context, anyhow};
use axum::{
body::Body,
extract::{Path, Request},
response::{IntoResponse, Response},
};
use axum_extra::extract::Host;
use http::{HeaderMap, HeaderName, HeaderValue, Uri};
use mlua::{UserData, UserDataMethods, UserDataRef};
use tokio::fs::{self};
use tracing::error;
use crate::{
http::{HOSTS, LUA_ENGINE, error::RouteError, serve::resolve_parent_path},
utils::parse_port_from_host,
};
use super::error::RouteResult;
/// 为 Lua 脚本提供 HTTP 请求上下文
#[derive(Clone, Debug)]
struct CandyRequest {
#[allow(dead_code)]
method: String,
/// Uri 在路由中被添加到上下文中
uri: Uri,
}
/// 为 Lua 脚本提供 HTTP 响应上下文
#[derive(Clone, Debug)]
struct CandyResponse {
status: u16,
headers: HeaderMap,
body: String,
}
// HTTP 请求上下文,可在 Lua 中使用
#[derive(Clone, Debug)]
struct RequestContext {
req: CandyRequest,
res: CandyResponse,
}
impl UserData for RequestContext {
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
// 获取请求路径
methods.add_method("get_path", |_, this, ()| {
Ok(this.req.uri.path().to_string())
});
// 获取请求方法
methods.add_method("get_method", |_, this, ()| Ok(this.req.method.to_string()));
// 设置响应状态码
methods.add_method_mut("set_status", |_, this, status: u16| {
this.res.status = status;
Ok(())
});
// 设置响应内容
methods.add_method_mut("set_body", |_, this, body: String| {
this.res.body = format!("{}{}", this.res.body, body);
Ok(())
});
// 设置响应头
methods.add_method_mut("set_header", |_, this, (key, value): (String, String)| {
this.res.headers.insert(
HeaderName::from_str(&key).map_err(|err| anyhow!("header name error: {err}"))?,
HeaderValue::from_str(&value)
.map_err(|err| anyhow!("header value error: {err}"))?,
);
Ok(())
});
}
}
pub async fn lua(
req_uri: Uri,
path: Option<Path<String>>,
Host(host): Host,
req: Request<Body>,
) -> RouteResult<impl IntoResponse> {
let scheme = req.uri().scheme_str().unwrap_or("http");
let port = parse_port_from_host(&host, scheme).ok_or(RouteError::BadRequest())?;
let route_map = &HOSTS
.get(&port)
.ok_or(RouteError::BadRequest())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.route_map;
tracing::debug!("Route map entries: {:?}", route_map);
let parent_path = resolve_parent_path(&req_uri, path.as_ref());
let route_config = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())
.with_context(|| format!("route not found: {parent_path}"))?;
let lua_script = route_config
.lua_script
.as_ref()
.ok_or(RouteError::InternalError())
.with_context(|| "lua script not found")?;
let method = req.method().to_string();
let lua = &LUA_ENGINE.lua;
let script = fs::read_to_string(lua_script)
.await
.with_context(|| format!("Failed to read lua script file: {lua_script}",))?;
lua.globals()
.set(
"ctx",
RequestContext {
req: CandyRequest {
method,
uri: req_uri,
},
res: CandyResponse {
status: 200,
headers: HeaderMap::new(),
body: "".to_string(),
},
},
)
.map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
lua.load(script).exec_async().await.map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
// 获取修改后的上下文并返回响应
let ctx: UserDataRef<RequestContext> = lua.globals().get("ctx").map_err(|err| {
error!("Lua script {lua_script} exec error: {err}");
RouteError::InternalError()
})?;
let res = ctx.res.clone();
let mut response = Response::builder();
let body = Body::from(res.body);
response = response.status(res.status);
let response = response
.body(body)
.with_context(|| "Failed to build HTTP response with lua")?;
Ok(response)
}

View File

@ -1,78 +0,0 @@
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
#![allow(dead_code)]
macro_rules! mime {
($a:ident, $b:literal) => {
pub const $a: &str = $b;
};
}
mime!(TEXT_PLAIN, "text/plain");
mime!(TEXT_PLAIN_UTF_8, "text/plain; charset=utf-8");
mime!(TEXT_HTML, "text/html");
mime!(TEXT_HTML_UTF_8, "text/html; charset=utf-8");
mime!(TEXT_CSS, "text/css");
mime!(TEXT_CSS_UTF_8, "text/css; charset=utf-8");
mime!(TEXT_JAVASCRIPT, "text/javascript");
mime!(TEXT_XML, "text/xml");
mime!(TEXT_EVENT_STREAM, "text/event-stream");
mime!(TEXT_CSV, "text/csv");
mime!(TEXT_CSV_UTF_8, "text/csv; charset=utf-8");
mime!(TEXT_TAB_SEPARATED_VALUES, "text/tab-separated-values");
mime!(
TEXT_TAB_SEPARATED_VALUES_UTF_8,
"text/tab-separated-values; charset=utf-8"
);
mime!(TEXT_VCARD, "text/vcard");
mime!(IMAGE_JPEG, "image/jpeg");
mime!(IMAGE_GIF, "image/gif");
mime!(IMAGE_PNG, "image/png");
mime!(IMAGE_ICON, "image/x-icon");
mime!(IMAGE_JNG, "image/x-jng");
mime!(IMAGE_WBMP, "image/vnd.wap.wbmp ");
mime!(IMAGE_BMP, "image/bmp");
mime!(IMAGE_SVG, "image/svg+xml");
mime!(IMAGE_AVIF, "image/avif");
mime!(IMAGE_TIFF, "image/tiff");
mime!(IMAGE_WEBP, "image/webp");
mime!(VIDEO_3GPP, "video/3gpp");
mime!(VIDEO_MP2T, "video/mp2t");
mime!(VIDEO_MP4, "video/mp4");
mime!(VIDEO_MPEG, "video/mpeg");
mime!(VIDEO_QUICKTIME, "video/quicktime");
mime!(VIDEO_WEBM, "video/webm");
mime!(VIDEO_X_FLV, "video/x-flv");
mime!(VIDEO_X_M4V, "video/x-m4v");
mime!(VIDEO_X_MNG, "video/x-mng");
mime!(VIDEO_X_MS_ASF, "video/x-ms-asf");
mime!(VIDEO_X_MS_WMV, "video/x-ms-wmv");
mime!(VIDEO_X_MSVIDEO, "video/x-msvideo");
mime!(FONT_WOFF, "font/woff");
mime!(FONT_WOFF2, "font/woff2");
mime!(APPLICATION_JSON, "application/json");
mime!(APPLICATION_JAVASCRIPT, "application/javascript");
mime!(APPLICATION_WASM, "application/wasm");
mime!(
APPLICATION_JAVASCRIPT_UTF_8,
"application/javascript; charset=utf-8"
);
mime!(
APPLICATION_WWW_FORM_URLENCODED,
"application/x-www-form-urlencoded"
);
mime!(APPLICATION_OCTET_STREAM, "application/octet-stream");
mime!(APPLICATION_MSGPACK, "application/msgpack");
mime!(APPLICATION_PDF, "application/pdf");
mime!(APPLICATION_DNS, "application/dns-message");
mime!(APPLICATION_JAVA_ARCHIVE, "application/java-archive");
mime!(APPLICATION_APPLE_MPEGURL, "application/vnd.apple.mpegurl");
mime!(STAR_STAR, "*/*");
mime!(TEXT_STAR, "text/*");
mime!(IMAGE_STAR, "image/*");
mime!(VIDEO_STAR, "video/*");
mime!(AUDIO_STAR, "audio/*");

View File

@ -1,4 +1,272 @@
pub mod mime;
pub mod response;
use std::{
net::SocketAddr,
sync::{Arc, LazyLock},
time::Duration,
};
pub use response::*;
use anyhow::anyhow;
use axum::{Router, extract::DefaultBodyLimit, middleware, routing::get};
use axum_server::{Handle, tls_rustls::RustlsConfig};
use dashmap::DashMap;
use mlua::Lua;
use tower::ServiceBuilder;
use tower_http::{compression::CompressionLayer, timeout::TimeoutLayer};
use tracing::{debug, info, warn};
use crate::{
config::SettingHost,
consts::{ARCH, COMMIT, COMPILER, NAME, OS, VERSION},
middlewares::{add_headers, add_version, logging_route},
utils::graceful_shutdown,
};
pub mod error;
// handle static file
pub mod serve;
// handle reverse proxy
pub mod reverse_proxy;
// handle lua script
pub mod lua;
/// Host configuration
/// use virtual host port as key
/// use SettingHost as value
/// Use port as parent part
/// Use host.route.location as key
/// Use host.route struct as value
/// {
/// 80: {
/// "/doc": <SettingRoute>
/// }
/// }
pub static HOSTS: LazyLock<DashMap<u16, SettingHost>> = LazyLock::new(DashMap::new);
pub struct LuaEngine {
pub lua: Lua,
/// Lua 共享字典
#[allow(dead_code)]
pub shared_table: Arc<DashMap<String, String>>,
}
impl LuaEngine {
pub fn new() -> Self {
let lua = Lua::new();
let shared_table: DashMap<String, String> = DashMap::new();
let shared_table = Arc::new(shared_table);
let module = lua.create_table().expect("create table failed");
let shared_api = lua.create_table().expect("create shared table failed");
// 创建共享字典到 lua 中
let shared_table_get = shared_table.clone();
shared_api
.set(
"set",
lua.create_function(move |_, (key, value): (String, String)| {
shared_table_get.insert(key, value.clone());
Ok(())
})
.expect("create set function failed"),
)
.expect("set failed");
let shared_table_get = shared_table.clone();
shared_api
.set(
"get",
lua.create_function(move |_, key: String| {
let value = shared_table_get.get(&key);
match value {
Some(value) => Ok(value.clone()),
None => {
tracing::error!("shared_api: get key not found: {}", key);
Ok(String::new())
}
}
})
.expect("create get function failed"),
)
.expect("get failed");
module
.set("shared", shared_api)
.expect("set shared_api failed");
// 日志函数
module
.set(
"log",
lua.create_function(move |_, msg: String| {
tracing::info!("Lua: {}", msg);
Ok(())
})
.expect("create log function failed"),
)
.expect("set log failed");
module.set("version", VERSION).expect("set version failed");
module.set("name", NAME).expect("set name failed");
module.set("os", OS).expect("set os failed");
module.set("arch", ARCH).expect("set arch failed");
module
.set("compiler", COMPILER)
.expect("set compiler failed");
module.set("commit", COMMIT).expect("set commit failed");
// 全局变量 candy
lua.globals()
.set("candy", module)
.expect("set candy table to lua engine failed");
Self { lua, shared_table }
}
}
/// lua 脚本执行器
pub static LUA_ENGINE: LazyLock<LuaEngine> = LazyLock::new(LuaEngine::new);
pub async fn make_server(host: SettingHost) -> anyhow::Result<()> {
let mut router = Router::new();
let host_to_save = host.clone();
// find routes in config
// convert to axum routes
// register routes
for host_route in &host.route {
// lua script
if host_route.lua_script.is_some() {
// papare lua script
router = router.route(host_route.location.as_ref(), get(lua::lua));
let route_path = format!("{}{{*path}}", host_route.location);
router = router.route(route_path.as_ref(), get(lua::lua));
// save route path to map
{
host_to_save
.route_map
.insert(host_route.location.clone(), host_route.clone());
}
continue;
}
// reverse proxy
if host_route.proxy_pass.is_some() {
router = router.route(host_route.location.as_ref(), get(reverse_proxy::serve));
// register wildcard path /doc/*
let route_path = format!("{}{{*path}}", host_route.location);
router = router.route(route_path.as_ref(), get(reverse_proxy::serve));
// Set request max body size
if let Some(max_body_size) = host_route.max_body_size {
router = router.layer(DefaultBodyLimit::max(max_body_size as usize));
}
// save route path to map
{
host_to_save
.route_map
.insert(host_route.location.clone(), host_route.clone());
}
continue;
}
// static file
if host_route.root.is_none() {
warn!("root field not found for route: {:?}", host_route.location);
continue;
}
// Set request max body size
if let Some(max_body_size) = host_route.max_body_size {
router = router.layer(DefaultBodyLimit::max(max_body_size as usize));
}
// resister with location
// location = "/doc"
// route: GET /doc/*
// resister with file path
// index = ["index.html", "index.txt"]
// route: GET /doc/index.html
// route: GET /doc/index.txt
// register parent path /doc
let path_morethan_one = host_route.location.len() > 1;
let route_path = if path_morethan_one && host_route.location.ends_with('/') {
// first register path with slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
let len = host_route.location.len();
let path_without_slash = host_route.location.chars().collect::<Vec<_>>()[0..len - 1]
.iter()
.collect::<String>();
// then register path without slash /doc/
router = router.route(&path_without_slash, get(serve::serve));
debug!("registed route {}", path_without_slash);
host_route.location.clone()
} else if path_morethan_one {
// first register path without slash /doc
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
// then register path with slash /doc/
let path = format!("{}/", host_route.location);
router = router.route(&path, get(serve::serve));
debug!("registed route {}", path);
path
} else {
// register path /doc/
router = router.route(&host_route.location, get(serve::serve));
debug!("registed route {}", host_route.location);
host_route.location.clone()
};
// save route path to map
{
host_to_save
.route_map
.insert(route_path.clone(), host_route.clone());
}
let route_path = format!("{route_path}{{*path}}");
// register wildcard path /doc/*
router = router.route(route_path.as_ref(), get(serve::serve));
debug!("registed route: {}", route_path);
}
// save host to map
HOSTS.insert(host.port, host_to_save);
router = router.layer(
ServiceBuilder::new()
.layer(middleware::from_fn(add_version))
.layer(middleware::from_fn(add_headers))
.layer(TimeoutLayer::new(Duration::from_secs(host.timeout.into())))
.layer(CompressionLayer::new()),
);
router = logging_route(router);
let addr = format!("{}:{}", host.ip, host.port);
let addr: SocketAddr = addr.parse()?;
let handle = Handle::new();
// Spawn a task to gracefully shutdown server.
tokio::spawn(graceful_shutdown(handle.clone()));
// check ssl eanbled or not
// if ssl enabled
// then create ssl listener
// else create tcp listener
if host.ssl && host.certificate.is_some() && host.certificate_key.is_some() {
let cert = host
.certificate
.as_ref()
.ok_or(anyhow!("certificate not found"))?;
let key = host
.certificate_key
.as_ref()
.ok_or(anyhow!("certificate_key not found"))?;
debug!("certificate {} certificate_key {}", cert, key);
let rustls_config = RustlsConfig::from_pem_file(cert, key).await?;
info!("listening on https://{}", addr);
axum_server::bind_rustls(addr, rustls_config)
.handle(handle)
.serve(router.into_make_service())
.await?;
} else {
info!("listening on http://{}", addr);
axum_server::bind(addr)
.handle(handle)
.serve(router.into_make_service())
.await?;
}
Ok(())
}

View File

@ -1,385 +0,0 @@
use std::{
path::{Path, PathBuf},
str::FromStr,
time::{Duration, UNIX_EPOCH},
};
use crate::{
config::{SettingHost, SettingRoute},
consts::{NAME, VERSION},
error::{Error, Result},
get_settings,
utils::{
compress::{stream_compress, CompressType},
find_route, parse_assets_path,
},
};
use anyhow::{anyhow, Context};
use futures_util::TryStreamExt;
use http::{response::Builder, Method};
use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full, StreamBody};
use hyper::{
body::{Bytes, Frame, Incoming},
Request, Response, StatusCode,
};
use hyper_util::rt::TokioIo;
use tokio::{
fs::File,
io::{AsyncBufRead, BufReader},
net::TcpStream,
select,
};
use tokio_util::io::ReaderStream;
use tracing::{debug, error, instrument};
/// HTTP handler
#[derive(Debug)]
pub struct CandyHandler<'req> {
/// Request from hyper
pub req: &'req Request<Incoming>,
/// Hyper response
pub res: Builder,
/// Config host field
host: &'static SettingHost,
/// Router
router: &'req SettingRoute,
/// Current request's assets path
assets_path: &'req str,
}
pub type CandyBody<T, E = Error> = BoxBody<T, E>;
type CandyResponse = Result<Response<CandyBody<Bytes>>>;
impl<'req> CandyHandler<'req> {
/// Create a new handler with hyper incoming request
pub fn new(req: &'req Request<Incoming>, host: &'static SettingHost) -> Result<Self> {
let req_path = req.uri().path();
// find route path
let (router, assets_path) = find_route(req_path, &host.route_map)?;
let candy = Self {
req,
res: Response::builder(),
host,
router,
assets_path,
};
Ok(candy)
}
pub fn add_headers(&mut self) -> Result<()> {
let headers = self
.res
.headers_mut()
.ok_or(Error::InternalServerError(anyhow!("build response failed")))?;
let server = format!("{}/{}", NAME, VERSION);
headers.insert("Server", server.parse()?);
// config headers overrite
if let Some(c_headers) = &self.host.headers {
for (k, v) in c_headers {
headers.insert(k.as_str(), v.parse()?);
}
}
Ok(())
}
/// Handle static file or reverse proxy
pub async fn handle(self) -> CandyResponse {
// reverse proxy
if self.router.proxy_pass.is_some() {
self.proxy().await
} else {
// static file
self.file().await
}
}
/// Handle reverse proxy
///
/// Only use with the `proxy_pass` field in config
#[instrument(level = "debug")]
pub async fn proxy(self) -> CandyResponse {
let (router, assets_path) = (self.router, self.assets_path);
let (req, res) = (self.req, self.res);
// check on outside
let proxy = router.proxy_pass.as_ref().ok_or(Error::Empty)?;
let path_query = req.uri().query().unwrap_or(assets_path);
let uri: hyper::Uri = format!("{}{}", proxy, path_query)
.parse()
.with_context(|| format!("parse proxy uri failed: {}", proxy))?;
match uri.scheme_str() {
Some("http") | Some("https") => {}
_ => {
return Err(Error::InternalServerError(anyhow!(
"proxy uri scheme error: {}",
uri
)));
}
}
let host = uri.host().ok_or(Error::InternalServerError(anyhow!(
"proxy pass host incorrect"
)))?;
let port = uri.port_u16().unwrap_or(80);
let addr = format!("{}:{}", host, port);
let stream = select! {
stream = TcpStream::connect(&addr) => {
stream.with_context(|| format!("connect to {} failed", addr))?
}
_ = tokio::time::sleep(Duration::from_secs(3)) => {
return Err(anyhow!("connect upstream {} timeout", &addr).into());
}
};
let io = TokioIo::new(stream);
let (mut sender, conn) =
hyper::client::conn::http1::handshake(io)
.await
.map_err(|err| {
error!("cannot handshake with {}: {}", addr, err);
anyhow!("{err}")
})?;
tokio::task::spawn(async move {
if let Err(err) = conn.await {
println!("Connection failed: {:?}", err);
}
});
let authority = uri
.authority()
.ok_or(anyhow!("proxy pass uri authority incorrect"))?;
let path = uri.path();
let req = Request::builder()
.uri(path)
.header(hyper::header::HOST, authority.as_str())
.body(Empty::<Bytes>::new())?;
let client_res = sender
.send_request(req)
.await
.with_context(|| "send request failed")?;
let client_body = client_res.map_err(Error::HyperError).boxed();
let res_body = res.body(client_body)?;
Ok(res_body)
}
/// Handle static files,
/// try find static file from local path
///
/// Only use with the `proxy_pass` field not in config
pub async fn file(self) -> CandyResponse {
let (router, assets_path) = (self.router, self.assets_path);
let (req, res) = (self.req, self.res);
let req_method = req.method();
// find resource local file path
let mut path = None;
for index in router.index.iter() {
if let Some(root) = &router.root {
let p = parse_assets_path(assets_path, root, index);
if Path::new(&p).exists() {
path = Some(p);
break;
}
}
}
let path = match path {
Some(p) => p,
None => {
return handle_not_found(req, res, router, "").await;
}
};
// http method handle
let res = match *req_method {
Method::GET => handle_get(req, res, &path).await?,
Method::POST => handle_get(req, res, &path).await?,
// Return the 404 Not Found for other routes.
_ => {
if let Some(err_page) = &router.error_page {
let res = res.status(err_page.status);
handle_get(req, res, &err_page.page).await?
} else {
not_found()
}
}
};
Ok(res)
}
}
/// Open local file and check last modified time,
/// Then determine stream file or use cache file
///
/// ## Arguments
///
/// `path`: local file path
pub async fn open_file(path: &str) -> Result<File> {
// Open file for reading
let file = File::open(path).await;
let file = match file {
Ok(f) => f,
Err(err) => {
error!("Unable to open file {err}");
return Err(Error::NotFound(format!("path not found {}", path).into()));
}
};
Ok(file)
}
/// Open then use `ReaderStream` to stream to client.
/// Stream a file more suitable for large file, but its slower than read file to memory.
pub async fn stream_file<R>(file: R) -> CandyBody<Bytes>
where
R: AsyncBufRead + Sync + Send + 'static,
{
// Wrap to a tokio_util::io::ReaderStream
let reader_stream = ReaderStream::new(file);
// Convert to http_body_util::BoxBody
let stream_body = StreamBody::new(reader_stream.map_ok(Frame::data));
// let boxed_body = stream_body.map_err(|e| Error::IoError(e)).boxed();
BodyExt::map_err(stream_body, Error::Io).boxed()
}
// pub async fn read_file_bytes(file: &mut File, size: u64) -> Result<Vec<u8>> {
// let mut buffer = vec![0u8; size.try_into()?];
// file.read_exact(&mut buffer[..]).await?;
// Ok(buffer)
// }
// Open local file to memory
// pub async fn read_file(file: &mut File, size: u64) -> Result<CandyBody<Bytes>> {
// let bytes = read_file_bytes(file, size).await?;
// let body = Full::new(bytes.into()).map_err(|e| match e {}).boxed();
// Ok(body)
// }
// HTTP status code 404
static NOT_FOUND: &[u8] = b"Not Found";
pub fn not_found() -> Response<CandyBody<Bytes>> {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Full::new(NOT_FOUND.into()).map_err(|e| match e {}).boxed())
.unwrap()
}
static INTERNAL_SERVER_ERROR: &[u8] = b"Internal Server Error";
pub fn internal_server_error() -> Response<CandyBody<Bytes>> {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(
Full::new(INTERNAL_SERVER_ERROR.into())
.map_err(|e| match e {})
.boxed(),
)
.unwrap()
}
// HTTP methods
/// handle http get method
/// read static file and check If-None-Match cache
#[instrument]
pub async fn handle_get(
req: &Request<Incoming>,
mut res: Builder,
path: &str,
) -> Result<Response<CandyBody<Bytes>>> {
use CompressType::*;
use Error::*;
let headers = res
.headers_mut()
.ok_or(InternalServerError(anyhow!("build response failed")))?;
// file bytes
let file = open_file(path).await?;
// file info
let metadata = file.metadata().await?;
let size = metadata.len();
let last_modified = metadata.modified()?.duration_since(UNIX_EPOCH)?.as_secs();
let etag = format!("{last_modified}-{size}");
let extension = PathBuf::from_str(path).map_err(|err| InternalServerError(anyhow!(err)))?;
let extension = extension
.extension()
.ok_or(InternalServerError(anyhow!("read file extension failed")))?;
let settings = get_settings();
let content_type = settings.types.get(
extension
.to_str()
.ok_or(InternalServerError(anyhow!("read file extension failed")))?,
);
headers.insert(
"Content-Type",
content_type.unwrap_or(&settings.default_type).parse()?,
);
headers.insert("Etag", etag.parse()?);
// check cache
let if_none_match = req.headers().get("If-None-Match");
match if_none_match {
Some(inm) if *inm == *etag => {
let res = res.status(304);
let body = Full::new(vec![].into()).map_err(|e| match e {}).boxed();
return Ok(res.body(body)?);
}
_ => {}
}
let file_reader = BufReader::new(file);
// prepare compress
let accept_encoding = req.headers().get("Accept-Encoding");
let boxed_body = match accept_encoding {
Some(accept) => {
let accept = accept.to_str()?;
debug!("Accept-Encoding {}", accept);
match accept {
str if str.contains("zstd") => {
headers.insert("Content-Encoding", "zstd".parse()?);
stream_compress(Zstd, file_reader)
}
str if str.contains("gzip") => {
headers.insert("Content-Encoding", "gzip".parse()?);
stream_compress(Gzip, file_reader)
}
str if str.contains("deflate") => {
headers.insert("Content-Encoding", "deflate".parse()?);
stream_compress(Deflate, file_reader)
}
str if str.contains("br") => {
headers.insert("Content-Encoding", "br".parse()?);
stream_compress(Brotli, file_reader)
}
_ => stream_file(file_reader).await,
}
}
None => stream_file(file_reader).await,
};
Ok(res.body(boxed_body)?)
}
pub async fn handle_not_found(
req: &Request<Incoming>,
res: Builder,
router: &SettingRoute,
assets_path: &str,
) -> Result<Response<CandyBody<Bytes>>> {
let res = if let Some(err_page) = &router.error_page {
let res = res.status(err_page.status);
if let Some(root) = &router.root {
let path = parse_assets_path(assets_path, root, &err_page.page);
handle_get(req, res, &path).await?
} else {
not_found()
}
} else {
not_found()
};
Ok(res)
}

259
src/http/reverse_proxy.rs Normal file
View File

@ -0,0 +1,259 @@
use std::{path::PathBuf, time::Duration};
use anyhow::Context;
use axum::{
body::Body,
extract::{Path, Request},
response::{IntoResponse, Response},
};
use axum_extra::extract::Host;
use dashmap::mapref::one::Ref;
use http::{
HeaderName, HeaderValue, StatusCode, Uri,
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH},
};
use mime_guess::from_path;
use reqwest::Client;
use tokio::fs::File;
use tokio_util::io::ReaderStream;
use crate::{
config::SettingRoute,
http::serve::{calculate_etag, resolve_parent_path},
utils::parse_port_from_host,
};
use super::{
HOSTS,
error::{RouteError, RouteResult},
};
/// 处理自定义错误页面如404、500等的请求
///
/// 该函数根据配置信息加载自定义错误页面文件并根据HTTP缓存机制
/// 决定是返回完整内容还是304 Not Modified状态码。
///
/// # 参数
/// - `host_config`: 主机路由配置,包含错误页面路径和根目录信息
/// - `request`: 原始HTTP请求
/// - `is_error_page`: 是否为错误页面true: 错误页false: 404页
///
/// # 返回
/// - `Ok(Response)`: 成功时返回HTTP响应
/// - `Err(RouteError)`: 失败时返回路由错误
pub async fn handle_custom_page(
host_config: Ref<'_, String, SettingRoute>,
request: Request<Body>,
is_error_page: bool,
) -> RouteResult<Response<Body>> {
// 根据请求类型选择相应的页面配置
let page = if is_error_page {
host_config
.error_page
.as_ref()
.ok_or(RouteError::RouteNotFound())?
} else {
host_config
.not_found_page
.as_ref()
.ok_or(RouteError::RouteNotFound())?
};
// 获取站点根目录配置
let root = host_config
.root
.as_ref()
.ok_or(RouteError::InternalError())?;
// 构建完整文件路径
let path = format!("{}/{}", root, page.page);
tracing::debug!("custom not found path: {:?}", path);
// 打开文件并计算ETag用于缓存验证
let file = File::open(path.clone())
.await
.with_context(|| "open file failed")?;
let etag = calculate_etag(&file, path.as_str()).await?;
let mut response = Response::builder();
let mut not_modified = false;
// 检查客户端缓存验证头If-None-Match
if let Some(if_none_match) = request.headers().get(IF_NONE_MATCH) {
if let Ok(if_none_match_str) = if_none_match.to_str() {
if if_none_match_str == etag {
// 资源未修改返回304状态码
response = response.status(StatusCode::NOT_MODIFIED);
not_modified = true;
}
}
}
// 准备响应主体
let stream = if not_modified {
// 304响应返回空内容
let empty = File::open(PathBuf::from("/dev/null"))
.await
.with_context(|| "open /dev/null failed")?;
ReaderStream::new(empty)
} else {
// 正常响应返回文件内容
ReaderStream::new(file)
};
let body = Body::from_stream(stream);
// 设置响应头内容类型和ETag
let mime = from_path(path).first_or_octet_stream();
response
.headers_mut()
.with_context(|| "insert header failed")?
.insert(
CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref()).with_context(|| "insert header failed")?,
);
response
.headers_mut()
.with_context(|| "insert header failed")?
.insert(
ETAG,
HeaderValue::from_str(&etag).with_context(|| "insert header failed")?,
);
// 构建最终响应
let response = response
.body(body)
.with_context(|| "Failed to build HTTP response with body")?;
Ok(response)
}
/// Handles the reverse proxy logic for incoming requests.
/// This function:
/// 1. Extracts the request path, host, and other details.
/// 2. Resolves the parent path and proxy configuration.
/// 3. Forwards the request to the configured proxy server.
/// 4. Returns the response from the proxy server to the client.
///
/// # Arguments
/// * `req_uri` - The URI of the incoming request.
/// * `path` - Optional path parameter extracted from the request.
/// * `host` - The host header from the request.
/// * `req` - The incoming HTTP request.
///
/// # Returns
/// A `RouteResult` containing the response from the proxy server or an error.
#[axum::debug_handler]
pub async fn serve(
req_uri: Uri,
path: Option<Path<String>>,
Host(host): Host,
mut req: Request<Body>,
) -> RouteResult<impl IntoResponse> {
let req_path = req.uri().path();
let path_query = req
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or(req_path);
let scheme = req.uri().scheme_str().unwrap_or("http");
let port = parse_port_from_host(&host, scheme).ok_or(RouteError::BadRequest())?;
let route_map = &HOSTS
.get(&port)
.ok_or(RouteError::BadRequest())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.route_map;
tracing::debug!("Route map entries: {:?}", route_map);
let parent_path = resolve_parent_path(&req_uri, path.as_ref());
tracing::debug!("parent path: {:?}", parent_path);
let proxy_config = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())
.with_context(|| format!("route not found: {parent_path}"))?;
tracing::debug!("proxy pass: {:?}", proxy_config);
let Some(ref proxy_pass) = proxy_config.proxy_pass else {
return handle_custom_page(proxy_config, req, true).await;
};
let uri = format!("{proxy_pass}{path_query}");
tracing::debug!("reverse proxy uri: {:?}", &uri);
*req.uri_mut() = Uri::try_from(uri.clone())
.map_err(|_| RouteError::InternalError())
.with_context(|| format!("uri not found: {uri}"))?;
let timeout = proxy_config.proxy_timeout;
// forward request headers
let client = Client::new();
let mut forward_req = client
.request(req.method().clone(), uri)
.timeout(Duration::from_secs(timeout.into()));
for (name, value) in req.headers().iter() {
if !is_exclude_header(name) {
forward_req = forward_req.header(name.clone(), value.clone());
}
}
// forward request body
let body = req.into_body();
let bytes = axum::body::to_bytes(body, 2048).await.map_err(|err| {
tracing::error!("Failed to proxy request: {}", err);
RouteError::InternalError()
})?;
let body_str = String::from_utf8(bytes.to_vec()).map_err(|err| {
tracing::error!("Failed to proxy request: {}", err);
RouteError::InternalError()
})?;
forward_req = forward_req.body(body_str);
// send reverse proxy request
let reqwest_response = forward_req.send().await.map_err(|e| {
tracing::error!("Failed to proxy request: {}", e);
RouteError::BadRequest()
})?;
// response from reverse proxy server
let mut response_builder = Response::builder().status(reqwest_response.status());
copy_headers(
reqwest_response.headers(),
response_builder
.headers_mut()
.ok_or(RouteError::InternalError())
.with_context(|| "headers not found")?,
);
let res = response_builder
.body(Body::from_stream(reqwest_response.bytes_stream()))
.map_err(|e| {
tracing::error!("Failed to proxy request: {}", e);
RouteError::BadRequest()
})?;
Ok(res)
}
/// Checks if a given header should be excluded from being forwarded in the reverse proxy.
/// Headers like "host", "connection", etc., are typically excluded to avoid conflicts or security issues.
fn is_exclude_header(name: &HeaderName) -> bool {
matches!(
name.as_str(),
"host"
| "connection"
| "proxy-authenticate"
| "upgrade"
| "proxy-authorization"
| "keep-alive"
| "transfer-encoding"
| "te"
)
}
/// Copies headers from one `HeaderMap` to another, excluding headers specified in `is_exclude_header`.
/// This ensures only relevant headers are forwarded, avoiding conflicts or security issues.
fn copy_headers(from: &http::HeaderMap, to: &mut http::HeaderMap) {
for (name, value) in from.iter() {
if !is_exclude_header(name) {
to.append(name.clone(), value.clone());
}
}
}

708
src/http/serve.rs Normal file
View File

@ -0,0 +1,708 @@
use std::{
fmt::{Display, Formatter},
path::PathBuf,
str::FromStr,
time::UNIX_EPOCH,
};
use anyhow::{Context, anyhow};
use axum::{
body::Body,
extract::{Path, Request},
response::{IntoResponse, Response},
};
use axum_extra::extract::Host;
use dashmap::mapref::one::Ref;
use http::{
HeaderMap, HeaderValue, StatusCode, Uri,
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION},
};
use mime_guess::from_path;
use tokio::fs::{self, File};
use tokio_util::io::ReaderStream;
use tracing::{debug, error, warn};
use crate::{
config::SettingRoute,
consts::HOST_INDEX,
http::{HOSTS, error::RouteError},
utils::parse_port_from_host,
};
use super::error::RouteResult;
/// 处理自定义页面请求如404错误页或自定义错误页面
///
/// 此函数根据请求类型错误页或404页加载相应的自定义页面
/// 构建完整文件路径并尝试流式传输文件内容作为HTTP响应。
///
/// # 参数
/// - `host_route`: 主机路由配置引用,包含页面位置和根目录信息
/// - `request`: 原始HTTP请求
/// - `is_error_page`: 是否为错误页面true: 错误页false: 404页
///
/// # 返回
/// - `RouteResult<Response>`: 成功时返回HTTP响应失败时返回路由错误
async fn custom_page(
host_route: Ref<'_, String, SettingRoute>,
request: Request<Body>,
is_error_page: bool,
) -> RouteResult<Response<Body>> {
let page = if is_error_page {
host_route
.error_page
.as_ref()
.ok_or(RouteError::RouteNotFound())
.with_context(|| "error page not found")?
} else {
host_route
.not_found_page
.as_ref()
.ok_or(RouteError::RouteNotFound())
.with_context(|| "not found page not found")?
};
let root = host_route
.root
.as_ref()
.ok_or(RouteError::InternalError())
.with_context(|| "root not found")?;
let path = format!("{}/{}", root, page.page);
let status = StatusCode::from_str(page.status.to_string().as_ref())
.map_err(|_| RouteError::BadRequest())
.with_context(|| format!("status code not found: {}", page.status))?;
tracing::debug!("custom not found path: {:?}", path);
match stream_file(path.into(), request, Some(status)).await {
Ok(res) => RouteResult::Ok(res),
Err(e) => {
error!("Failed to stream file: {:?}", e);
RouteResult::Err(RouteError::InternalError())
}
}
}
/// Serve static files.
///
/// This function handles requests for static files by:
/// 1. Resolving the parent path from the URI or provided path.
/// 2. Looking up the route in `ROUTE_MAP` to find the root directory.
/// 3. Attempting to serve the requested file or a default index file.
///
/// # Arguments
/// - `uri`: The request URI, used to extract the full path.
/// - `path`: Optional path segment provided by the router.
///
/// # Returns
/// - `Ok(Response)`: If the file is found and successfully streamed.
/// - `Err(RouteError)`: If the route or file is not found.
#[axum::debug_handler]
pub async fn serve(
uri: Uri,
path: Option<Path<String>>,
Host(host): Host,
request: Request,
) -> RouteResult<impl IntoResponse> {
// find parent path
// if requested path is /doc
// then params path is None
// when Path is None, then use uri.path() as path
// if request path is /doc/index.html
// uri path is /doc/index.html
// path is index.html
// find parent path by path length
// /doc/index.html
// /doc/
// index.html
debug!(
"Request - uri: {:?}, path: {:?}, request: {:?}",
uri, path, request
);
// parent_path is key in route map
// which is `host_route.location`
let parent_path = resolve_parent_path(&uri, path.as_ref());
let scheme = request.uri().scheme_str().unwrap_or("http");
// port is key in route_map
// which is `host_route.port`, used to find current host configuration
let port = parse_port_from_host(&host, scheme).ok_or(RouteError::BadRequest())?;
// route_map can be found by port
// current host configruation
let route_map = &HOSTS
.get(&port)
.ok_or(RouteError::BadRequest())
.with_context(|| {
format!("Hosts not found for port: {port}, host: {host}, scheme: {scheme}")
})?
.route_map;
debug!("Route map entries: {:?}", route_map);
// host_route can be found by parent_path
// current route configuration
let host_route = route_map
.get(&parent_path)
.ok_or(RouteError::RouteNotFound())
.with_context(|| format!("route not found: {parent_path}"))?;
debug!("route: {:?}", host_route);
// after route found
// check static file root configuration
// if root is None, then return InternalError
let Some(ref root) = host_route.root else {
return custom_page(host_route, request, true).await;
};
// try find index file first
// build index filename as vec
// ["./html/index.html", "./html/index.txt"]
// Build the list of candidate file paths to try:
// - If `path` is provided, use it and check is file or not.
// - If `path` is None, use the default index files (either from `host_route.index` or `HOST_INDEX`).
// path_arr 是包含默认索引文件的数组
// req_path 是请求的路径
let (req_path, path_arr) = if let Some(path) = path {
#[allow(clippy::unnecessary_to_owned)]
let path = path.to_string();
if path.contains('.') {
(root.into(), vec![format!("{}/{}", root, path)])
} else {
generate_default_index(&host_route, &format!("{root}/{path}"))
}
} else {
generate_default_index(&host_route, root)
};
debug!("request index file {:?}", path_arr);
debug!("req_path: {:?}", req_path);
// 检查是否开启自动生成目录索引
let uri_path = uri.path();
debug!("uri_path: {:?}", uri_path);
let uri_path_vec = uri_path.split('/').collect::<Vec<&str>>();
let uri_path_last = uri_path_vec.last();
debug!("uri_path_last: {:?}", uri_path_last);
let uri_path_last = uri_path_last.unwrap_or(&"");
if host_route.auto_index && !uri_path_last.contains('.') {
// HTML 中的标题路径,需要移除掉配置文件中的 root = "./html" 字段
let host_root = if let Some(root) = &host_route.root {
root
} else {
return custom_page(host_route, request, false).await;
};
let req_path_str = req_path.to_string_lossy();
debug!("req_path_str: {:?}", req_path_str);
let host_root = &req_path_str.strip_prefix(host_root).unwrap_or(host_root);
let list = list_dir(&req_path_str, &req_path).await?;
let list_html = render_list_html(host_root, list);
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/html"));
return Ok((headers, list_html).into_response());
}
// Try each candidate path in order:
// - Return the first successfully streamed file.
// - If all fail, return a `RouteNotFound` error.
let mut path_exists = None;
for path in path_arr {
if fs::metadata(path.clone()).await.is_ok() {
path_exists = Some(path);
break;
}
}
debug!("path_exists: {:?}", path_exists);
// 检查路径是否存在
// 不存时,检查是否开启自动生成目录索引
let path_exists = match path_exists {
Some(path_exists) => path_exists,
None => {
let uri_path = uri.path();
debug!("uri_path: {:?}", uri_path);
// 如果请求路径不是文件且不以 / 结尾,则返回 301 Moved Permanently 状态码
if !uri_path.ends_with('/') && !uri_path.contains('.') {
let mut response = Response::builder();
let stream = empty_stream().await?;
let body = Body::from_stream(stream);
response
.headers_mut()
.with_context(|| "insert header failed")?
.insert(
LOCATION,
HeaderValue::from_str(format!("{uri_path}/").as_str())
.with_context(|| "insert header failed")?,
);
response = response.status(StatusCode::MOVED_PERMANENTLY);
let response = response
.body(body)
.with_context(|| "Failed to build HTTP response with body")?;
return Ok(response);
}
// 生成自动目录索引
if host_route.auto_index {
// HTML 中的标题路径,需要移除掉配置文件中的 root = "./html" 字段
let host_root = if let Some(root) = &host_route.root {
root
} else {
return custom_page(host_route, request, false).await;
};
let req_path_str = req_path.to_string_lossy();
debug!("req_path_str: {:?}", req_path_str);
let host_root = &req_path_str.strip_prefix(host_root).unwrap_or(host_root);
let list = list_dir(&req_path_str, &req_path).await?;
let list_html = render_list_html(host_root, list);
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/html"));
return Ok((headers, list_html).into_response());
} else {
debug!("No valid file found in path candidates");
return custom_page(host_route, request, false).await;
}
}
};
match stream_file(path_exists.into(), request, None).await {
Ok(res) => Ok(res),
Err(e) => {
error!("Failed to stream file: {}", e);
Err(RouteError::InternalError())
}
}
}
/// Generate default index files
/// if request path is not a file
/// this read config index field
/// and build with root: ["./html/index.html", "./html/index.txt"]
///
/// ## Arguments
/// - `host_route`: the host route config
/// - `root`: the root path
///
/// ## Returns
/// - PathBuf: 客户端访问的路径
/// - Vec<String>: 包含默认索引文件名的数组
fn generate_default_index(
host_route: &Ref<'_, String, SettingRoute>,
root: &str,
) -> (PathBuf, Vec<String>) {
let indices = if host_route.index.is_empty() {
// use default index files
let host_iter = HOST_INDEX
.iter()
.map(|s| s.to_string())
.collect::<Vec<String>>();
host_iter.into_iter()
} else {
host_route.index.clone().into_iter()
};
// indices 就是 host_route.index 的中配置的 index 文件名
(
root.into(),
indices.map(|s| format!("{root}/{s}")).collect(),
)
}
/// Stream a file as an HTTP response.
///
/// # Arguments
/// - `path`: The filesystem path to the file.
///
/// # Returns
/// - `Ok(Response)`: If the file is successfully opened and streamed.
/// - `Err(anyhow::Error)`: If the file cannot be opened or read.
async fn stream_file(
path: PathBuf,
request: Request,
status: Option<StatusCode>,
) -> RouteResult<Response<Body>> {
let file = File::open(path.clone())
.await
.with_context(|| "open file failed")?;
let path_str = path.to_str().ok_or(anyhow!("convert path to str failed"))?;
let etag = calculate_etag(&file, path_str).await?;
let mut response = Response::builder();
let mut not_modified = false;
// check request if-none-match
if let Some(if_none_match) = request.headers().get(IF_NONE_MATCH) {
if let Ok(if_none_match_str) = if_none_match.to_str() {
if if_none_match_str == etag {
response = response.status(StatusCode::NOT_MODIFIED);
not_modified = true;
}
}
}
let stream = if not_modified {
empty_stream().await?
} else {
ReaderStream::new(file)
};
let body = Body::from_stream(stream);
let mime = from_path(path).first_or_octet_stream();
response
.headers_mut()
.with_context(|| "insert header failed")?
.insert(
CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref()).with_context(|| "insert header failed")?,
);
response
.headers_mut()
.with_context(|| "insert header failed")?
.insert(
ETAG,
HeaderValue::from_str(&etag).with_context(|| "insert header failed")?,
);
if let Some(status) = status {
response = response.status(status);
}
let response = response
.body(body)
.with_context(|| "Failed to build HTTP response with body")?;
Ok(response)
}
pub async fn calculate_etag(file: &File, path: &str) -> anyhow::Result<String> {
// calculate file metadata as etag
let metadata = file
.metadata()
.await
.with_context(|| "get file metadata failed")?;
let created_timestamp = metadata
.created()
.with_context(|| "get file created failed")?
.duration_since(UNIX_EPOCH)
.with_context(|| "calculate unix timestamp failed")?
.as_secs();
let modified_timestamp = metadata
.modified()
.with_context(|| "get file created failed")?
.duration_since(UNIX_EPOCH)
.with_context(|| "calculate unix timestamp failed")?
.as_secs();
// file path - created - modified - len
let etag = format!(
"{}-{}-{}-{}",
path,
created_timestamp,
modified_timestamp,
metadata.len()
);
let etag = format!("W/\"{:?}\"", md5::compute(etag));
debug!("file {:?} etag: {:?}", path, etag);
Ok(etag)
}
// Resolve the parent path:
// - If `path` is provided, extract the parent segment from the URI.
// - If `path` is None, use the URI path directly (ensuring it ends with '/').
/// Resolves the parent path from the URI and optional path segment.
pub fn resolve_parent_path(uri: &Uri, path: Option<&Path<String>>) -> String {
match path {
Some(path) => {
let uri_path = uri.path();
// use path sub to this uri path
// to find parent path that store in ROUTE_MAP
// uri: /assets/css/styles.07713cb6.css, path: Some(Path("assets/css/styles.07713cb6.css")
let parent_path = uri_path.get(0..uri_path.len() - path.len());
parent_path.unwrap_or("/").to_string()
}
None => {
// uri needs end with /
// because global ROUTE_MAP key is end with /
// so we need add / to uri path to get correct Route
let uri_path = uri.path().to_string();
if uri_path.ends_with('/') {
uri_path
} else {
format!("{uri_path}/")
}
}
}
}
/// 生成一个 HTML 目录列表页面,展示指定目录中的文件和子目录。
///
/// 该函数将一个 `DirList` 结构体的向量转换为 HTML 表格格式,
/// 每个条目包含名称(带链接)、最后修改时间和大小信息。
///
/// # 参数
/// * `root_path` - 目录路径 显示在 HTML 中的根目录
/// * `list` - 包含目录项信息的 `DirList` 结构体向量
///
/// # 返回值
/// 格式化后的 HTML 字符串,可直接作为 HTTP 响应返回
///
/// # 示例
/// ```rust
/// let dir_entries = vec![
/// DirList {
/// path: PathBuf::from("/home/user/docs"),
/// name: "documents".to_string(),
/// last_modified: "2023-05-15 14:30".to_string(),
/// size: "4.2K".to_string(),
/// is_dir: true
/// },
/// // 更多条目...
/// ];
///
/// let html_output = render_list_html(dir_entries);
/// println!("{}", html_output);
/// ```
fn render_list_html(root_path: &str, list: Vec<DirList>) -> String {
debug!(
"render list html list: {:?} root_path: {:?}",
list, root_path
);
// 先生成目标目录下所有文件的行
let body_rows = list
.iter()
.map(|dist| {
if dist.is_dir {
format!(
r#"<tr><td><a href="{}">{}/</a></td><td>{}</td><td>{}</td></tr>"#,
dist.path, dist.name, dist.last_modified, dist.size,
)
} else {
format!(
r#"<tr><td><a href="{}">{}</a></td><td>{}</td><td>{}</td></tr>"#,
dist.path, dist.name, dist.last_modified, dist.size,
)
}
})
.collect::<Vec<String>>()
.join("");
let list_html = format!(
r#"
<!DOCTYPE html>
<html>
<head>
<title>Index of {root_path}</title>
<style>
body {{
font-family: Arial, sans-serif;
margin: 20px;
background-color: #ffffff;
color: #000000;
}}
h1 {{
font-size: 1.5em;
margin-bottom: 20px;
text-align: left;
}}
table {{
width: 100%;
border-collapse: collapse;
border: 1px solid #dddddd;
}}
th, td {{
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #dddddd;
}}
th {{
background-color: #f0f0f0;
font-weight: bold;
}}
tr:nth-child(even) {{
background-color: #f9f9f9;
}}
tr:hover {{
background-color: #f0f0f0;
}}
.dir {{
color: #0066cc;
font-weight: bold;
}}
.file {{
color: #000000;
}}
a {{
text-decoration: none;
color: inherit;
}}
a:hover {{
text-decoration: underline;
}}
</style>
</head>
<body>
<h1>Index of {root_path}</h1>
<table>
<tr>
<th>Name</th>
<th>Last Modified</th>
<th>Size</th>
</tr>
<tbody id="directory-content">
{body_rows}
</tbody>
</table>
</body>
</html>
"#,
);
list_html
}
const KB: u64 = 1024;
const KB1: u64 = KB + 1;
const MB: u64 = 1024 * 1024;
const MB1: u64 = MB + 1;
const GB: u64 = 1024 * 1024 * 1024;
const GB1: u64 = GB + 1;
const TB: u64 = 1024 * 1024 * 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct ByteUnit(u64);
impl Display for ByteUnit {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self.0 {
0..=KB => write!(f, "{} B", self.0),
KB1..=MB => write!(f, "{:.2} KB", self.0 as f64 / 1024.0),
MB1..=GB => write!(f, "{:.2} MB", self.0 as f64 / 1024.0 / 1024.0),
GB1..=TB => write!(f, "{:.2} TB", self.0 as f64 / 1024.0 / 1024.0 / 1024.0),
_ => write!(f, "{} B", self.0),
}
}
}
#[derive(Debug, Clone)]
pub struct DirList {
pub name: String, // 文件或目录名称
pub path: String, // 文件或目录的完整路径
pub is_dir: bool, // 是否为目录
pub size: ByteUnit, // 文件大小(字节)
pub last_modified: String, // 最后修改时间的字符串表示
}
/// 异步列出指定目录下的所有文件和子目录信息
///
/// # 参数
/// * `path` - 要列出内容的目录路径
///
/// # 返回
/// 成功时返回包含 `DirList` 结构的向量,失败时返回错误
///
/// # 错误
/// 可能返回与文件系统操作相关的错误,如目录不存在、权限不足等
async fn list_dir(host_root_str: &str, path: &PathBuf) -> anyhow::Result<Vec<DirList>> {
use chrono::{Local, TimeZone};
use std::time::UNIX_EPOCH;
let mut list = vec![];
// 异步读取目录条目
let mut entries = fs::read_dir(path)
.await
.with_context(|| format!("无法读取目录: {}", path.display()))?;
debug!("list dir path: {:?}", path);
let mut tasks = vec![];
// 遍历目录中的每个条目
while let Some(entry) = entries
.next_entry()
.await
.with_context(|| format!("读取目录条目失败: {}", path.display()))?
{
let host_root_str = if host_root_str.ends_with('/') {
host_root_str.to_string()
} else {
format!("{host_root_str}/")
};
// 为每个条目创建异步任务,并行获取元数据
let task = tokio::task::spawn(async move {
// 获取文件元数据
let metadata = entry
.metadata()
.await
.with_context(|| "获取文件元数据失败")?;
// 获取并格式化最后修改时间
let last_modified = metadata
.modified()
.with_context(|| "获取文件修改时间失败")?;
let last_modified = last_modified
.duration_since(UNIX_EPOCH)
.with_context(|| "计算 Unix 时间戳失败")?;
// 转换为本地时间,处理可能的歧义情况
let datetime = match Local
.timestamp_opt(last_modified.as_secs() as i64, last_modified.subsec_nanos())
{
chrono::LocalResult::Ambiguous(earlier, later) => {
warn!("发现歧义时间: {} 和 {}", earlier, later);
earlier
}
chrono::offset::LocalResult::Single(single) => {
// warn!("发现歧义时间: {}", single);
single
}
chrono::offset::LocalResult::None => {
error!("无法解析时间时使用当前时间");
Local::now()
}
};
let last_modified = datetime.format("%Y-%m-%d %H:%M:%S").to_string();
// 收集其他元数据
let size = ByteUnit(metadata.len());
let is_dir = metadata.is_dir();
let name = entry.file_name().to_string_lossy().to_string();
let path = entry
.path()
.to_string_lossy()
.strip_prefix(&host_root_str)
.ok_or(anyhow!("strip prefix failed"))?
.to_string();
let path = if is_dir {
format!("./{path}/")
} else {
format!("./{path}")
};
// 创建并返回目录条目信息
let dir = DirList {
name,
path,
is_dir,
size,
last_modified,
};
anyhow::Ok(dir)
});
tasks.push(task);
}
// 等待所有异步任务完成并收集结果
for task in tasks {
list.push(task.await??);
}
Ok(list)
}
/// 创建一个空数据流,用于返回空响应或占位数据
///
/// 在不同操作系统上,会自动选择对应的空设备文件:
/// - Windows: NUL
/// - Unix/Linux: /dev/null
///
/// 返回一个异步流,内容为一个空文件的数据流
///
/// # 错误处理
/// 如果无法打开空设备文件,会返回带有上下文信息的错误
pub async fn empty_stream() -> anyhow::Result<ReaderStream<File>> {
#[cfg(windows)]
let null = PathBuf::from("NUL");
#[cfg(not(windows))]
let null = PathBuf::from("/dev/null");
let empty = File::open(null)
.await
.with_context(|| "open /dev/null failed")?;
Ok(ReaderStream::new(empty))
}

View File

@ -1,49 +1,58 @@
use anyhow::{anyhow, Context, Result};
// #![feature(iterator_try_collect)]
use anyhow::{Context, Result};
use tracing::error;
use clap::Parser;
use config::Settings;
use consts::COMPILER;
use consts::{COMMIT, COMPILER};
use http::make_server;
use tokio::task::JoinSet;
use tracing::{debug, info};
use crate::{
consts::{get_settings, ARCH, NAME, OS, SETTINGS, VERSION},
consts::{ARCH, NAME, OS, VERSION},
utils::init_logger,
};
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
mod cli;
mod config;
mod consts;
mod error;
mod http;
mod service;
mod middlewares;
mod utils;
#[tokio::main]
async fn main() -> Result<()> {
let args = cli::Cli::parse();
init_logger();
let settings = Settings::new(&args.config).with_context(|| "init config failed")?;
SETTINGS
.set(settings)
.map_err(|err| anyhow!("init config failed {err:?}"))?;
// global config
let settings = get_settings();
let settings = Settings::new(&args.config).with_context(|| "init config failed")?;
debug!("settings {:?}", settings);
info!("{}/{} {}", NAME, VERSION, COMPILER);
info!("{}/{} {}", NAME, VERSION, COMMIT);
info!("{}", COMPILER);
info!("OS: {} {}", OS, ARCH);
let mut servers = settings
.host
.iter()
.map(|host| host.mk_server())
.collect::<JoinSet<_>>();
let hosts = settings.host;
let mut servers = hosts.into_iter().map(make_server).collect::<JoinSet<_>>();
info!("server started");
while let Some(res) = servers.join_next().await {
res??;
match res {
Ok(err) => {
err.map_err(|err| error!("server error: {}", err)).ok();
}
Err(err) => {
error!("server error: {}", err);
}
}
}
Ok(())

140
src/middlewares/mod.rs Normal file
View File

@ -0,0 +1,140 @@
use std::{fmt::Display, time::Duration};
use axum::{
Router,
body::{Body, Bytes},
extract::Request,
http::{HeaderMap, HeaderValue},
middleware::Next,
response::{IntoResponse, Response},
};
use axum_extra::extract::Host;
use http::HeaderName;
use tower_http::classify::ServerErrorsFailureClass;
use tower_http::trace::TraceLayer;
use tracing::{Span, debug, error, info, info_span};
use crate::{
consts::{NAME, VERSION},
http::HOSTS,
utils::parse_port_from_host,
};
/// Middleware for adding version information to each response's headers.
///
/// This middleware takes an incoming `Request` and a `Next` handler, which represents the
/// subsequent middleware or route in the chain. It then asynchronously runs the next handler,
/// obtaining the response. After receiving the response, it appends two headers:
/// - "Server": The name of the server extracted from the Cargo package name.
/// - "S-Version": The version of the server extracted from the Cargo package version.
pub async fn add_version(req: Request<Body>, next: Next) -> impl IntoResponse {
let mut res = next.run(req).await;
let headers = res.headers_mut();
headers.append("Server", HeaderValue::from_static(NAME));
headers.append("RUA-Version", HeaderValue::from_static(VERSION));
res
}
/// Middleware for dynamically adding headers to responses based on the requested host and port.
///
/// This middleware:
/// 1. Extracts the `Host` header from the incoming request.
/// 2. Parses the host string to determine the port (defaulting to `80` if unspecified).
/// 3. Looks up the host configuration in the global `HOST` map (shared state) for the resolved port.
/// 4. Appends any configured headers from the host's `SettingHost` to the response.
///
/// # Behavior
/// - If the `Host` header is missing or malformed, the request proceeds unchanged.
/// - If the port is invalid or the host configuration is not found, the request proceeds unchanged.
/// - Headers are appended to the response only if they are explicitly configured for the host.
///
/// # Error Handling
/// - Silently skips header addition for:
/// - Missing or unparseable `Host` headers.
/// - Invalid ports (non-numeric or out-of-range).
/// - Missing host configurations in `HOST`.
/// - Uses `debug!` for logging the resolved port.
///
/// # Example
/// Given a request to `example.com:8080` and a `HOST` entry for port `8080` with headers:
/// ```toml
/// [hosts."8080"]
/// headers = { "X-Custom" = "value" }
pub async fn add_headers(Host(host): Host, req: Request, next: Next) -> impl IntoResponse {
let Some(scheme) = req.uri().scheme_str() else {
return next.run(req).await;
};
debug!("scheme {:?}", scheme);
let Some(port) = parse_port_from_host(&host, scheme) else {
return next.run(req).await;
};
debug!("port {:?}", port);
let mut res = next.run(req).await;
let req_headers = res.headers_mut();
let Some(host) = HOSTS.get(&port) else {
return res;
};
let Some(headers) = host.headers.as_ref() else {
return res;
};
headers.iter().for_each(|entery| {
let (key, value) = (entery.key(), entery.value());
let Ok(header_name) = HeaderName::from_bytes(key.as_bytes()) else {
error!("Invalid header name: {key}");
return;
};
let Ok(header_value) = HeaderValue::from_bytes(value.as_bytes()) else {
error!("Invalid header value: {value}");
return;
};
req_headers.append(header_name, header_value);
});
res
}
/// Middleware for logging each request.
///
/// This middleware will calculate each request latency
/// and add request's information to each info_span.
pub fn logging_route(router: Router) -> Router {
let make_span = |req: &Request<_>| {
let unknown = &HeaderValue::from_static("Unknown");
let empty = &HeaderValue::from_static("");
let headers = req.headers();
let ua = headers
.get("User-Agent")
.unwrap_or(unknown)
.to_str()
.unwrap_or("Unknown");
let host = headers.get("Host").unwrap_or(empty).to_str().unwrap_or("");
info_span!("HTTP", method = ?req.method(), host, uri = ?req.uri(), ua)
};
let trace_layer = TraceLayer::new_for_http()
.make_span_with(make_span)
.on_request(|_req: &Request<_>, _span: &Span| {})
.on_response(|res: &Response, latency: Duration, _span: &Span| {
info!("{}", format_latency(latency, res.status()));
})
.on_body_chunk(|_chunk: &Bytes, _latency: Duration, _span: &Span| {})
.on_eos(|_trailers: Option<&HeaderMap>, _stream_duration: Duration, _span: &Span| {})
.on_failure(
|error: ServerErrorsFailureClass, latency: Duration, _span: &Span| {
error!("{}", format_latency(latency, error));
},
);
router.layer(trace_layer)
}
/// Format request latency and status message
/// return a string
fn format_latency(latency: Duration, status: impl Display) -> String {
let micros = latency.as_micros();
let millis = latency.as_millis();
if micros >= 1000 {
format!("{status} {millis}ms")
} else {
format!("{status} {micros}μs")
}
}

View File

@ -1,132 +0,0 @@
use std::{
net::SocketAddr,
pin::pin,
time::{self, Duration, Instant},
};
use crate::{
config::SettingHost,
error::Error,
http::{internal_server_error, not_found, CandyHandler},
};
use futures_util::Future;
use http::Request;
use hyper::body::Incoming;
use hyper_util::{
rt::{TokioExecutor, TokioIo},
server::{self, graceful::GracefulShutdown},
};
use tokio::{
net::{TcpListener, TcpStream},
select,
};
use tracing::{debug, error, info, warn};
impl SettingHost {
pub fn mk_server(&'static self) -> impl Future<Output = anyhow::Result<()>> + 'static {
let addr = format!("{}:{}", self.ip, self.port);
async move {
let listener = TcpListener::bind(&addr).await?;
info!("host bind on {}", addr);
let server = server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new());
let graceful = server::graceful::GracefulShutdown::new();
let mut ctrl_c = pin!(tokio::signal::ctrl_c());
loop {
tokio::select! {
conn = listener.accept() => {
let conn = match conn {
Ok(conn) => conn,
Err(e) => {
error!("accept error: {}", e);
continue;
}
};
handle_connection(conn, self, &server, &graceful).await;
},
_ = ctrl_c.as_mut() => {
drop(listener);
info!("Ctrl-C received, starting shutdown");
break;
}
}
}
select! {
_ = graceful.shutdown() => {
info!("Gracefully shutdown!");
},
_ = tokio::time::sleep(Duration::from_secs(self.timeout.into())) => {
error!("Waited 10 seconds for graceful shutdown, aborting...");
}
}
Ok(())
}
}
}
/// Handle tcp connection from client
/// then use hyper service to handle response
///
/// ## Arguments
///
/// `conn`: connection accepted from TcpListener
/// `host`: SettingHost from config file
/// `server`: hyper_util server Builder
/// `graceful`: hyper_util server graceful shutdown
async fn handle_connection(
conn: (TcpStream, SocketAddr),
host: &'static SettingHost,
server: &server::conn::auto::Builder<TokioExecutor>,
graceful: &GracefulShutdown,
) {
let (stream, peer_addr) = conn;
debug!("incomming connection accepted: {}", peer_addr);
let stream = TokioIo::new(Box::pin(stream));
let service = move |req: Request<Incoming>| async move {
let start_time = time::Instant::now();
let mut handler = CandyHandler::new(&req, host)?;
// Connection handler in service_fn
// then decide whether to handle proxy or static file based on config
handler.add_headers()?;
let res = handler.handle().await;
let response = match res {
Ok(res) => res,
Err(Error::NotFound(err)) => {
warn!("{err}");
not_found()
}
Err(err) => {
error!("{err}");
internal_server_error()
}
};
let end_time = (Instant::now() - start_time).as_micros() as f32;
let end_time = end_time / 1000_f32;
let method = &req.method();
let path = &req.uri().path();
let version = &req.version();
let res_status = response.status();
info!(
"\"{}\" {} {} {:?} {} {:.3}ms",
peer_addr, method, path, version, res_status, end_time
);
anyhow::Ok(response)
};
let conn = server.serve_connection_with_upgrades(stream, hyper::service::service_fn(service));
let conn = graceful.watch(conn.into_owned());
tokio::spawn(async move {
if let Err(err) = conn.await {
error!("connection error: {}", err);
}
debug!("connection dropped: {}", peer_addr);
});
}

View File

@ -1,43 +0,0 @@
use async_compression::tokio::bufread::{BrotliEncoder, DeflateEncoder, GzipEncoder, ZstdEncoder};
use futures_util::TryStreamExt;
use http_body_util::{BodyExt, StreamBody};
use hyper::body::{Bytes, Frame};
use tokio::io::{AsyncBufRead, BufReader};
use tokio_util::io::ReaderStream;
use crate::{error::Error, http::CandyBody};
pub enum CompressType {
Zstd,
Gzip,
Deflate,
Brotli,
}
macro_rules! encode {
($encoder:ident, $file:ident) => {{
let encoder_stream = $encoder::new($file);
let reader_stream = ReaderStream::new(encoder_stream);
let stream_body = StreamBody::new(reader_stream.map_ok(Frame::data));
let boxed_body = BodyExt::map_err(stream_body, Error::Io).boxed();
boxed_body
}};
}
pub fn stream_compress<R>(compress_type: CompressType, reader: R) -> CandyBody<Bytes>
where
R: AsyncBufRead + Sync + Send + 'static,
{
use CompressType::*;
let file_reader = BufReader::new(reader);
match compress_type {
Zstd => {
encode!(ZstdEncoder, file_reader)
}
Gzip => encode!(GzipEncoder, file_reader),
Deflate => encode!(DeflateEncoder, file_reader),
Brotli => encode!(BrotliEncoder, file_reader),
}
}

View File

@ -1,7 +1,8 @@
use tracing_subscriber::{
EnvFilter,
fmt::{self},
prelude::*,
registry, EnvFilter,
registry,
};
pub fn init_logger() {

View File

@ -1,4 +1,3 @@
pub mod compress;
pub mod logging;
pub mod service;

View File

@ -1,97 +1,135 @@
use tracing::debug;
use std::time::Duration;
use crate::error::{Error, Result};
use axum_server::Handle;
use tokio::{signal, time::sleep};
use tracing::{debug, info};
use crate::config::{HostRouteMap, SettingRoute};
// Asynchronously waits for a shutdown signal and executes a callback function when a signal is received.
//
// This function listens for shutdown signals in the form of `Ctrl+C` and termination signals. When one of
// these signals is received, it invokes the provided callback function `shutdown_cb`.
//
// The behavior of the signal handling depends on the operating system:
//
// - On Unix-based systems (e.g., Linux, macOS), it listens for termination signals (such as SIGTERM).
// - On non-Unix systems (e.g., Windows), it only listens for `Ctrl+C` and ignores termination signals.
//
// The `shutdown_cb` callback function is executed when either signal is received. This function should
// contain the logic needed to gracefully shut down the application or perform any necessary cleanup tasks.
// # Parameters
//
// - `shutdown_cb`: A closure or function to call when a shutdown signal is received. The function should
// have the signature `Fn()`. This callback is executed without any parameters.
//
// # Errors
//
// - If setting up the signal handlers fails, the function will panic with an error message.
//
// # Panics
//
// - Panics if the setup for `Ctrl+C` or termination signal handlers fails.
//
// # Platform-specific behavior
//
// - On Unix-based systems, termination signals are handled using the `signal` crate for Unix signals.
// - On non-Unix systems, only `Ctrl+C` signals are handled, and termination signals are not supported.
//
// # Future
//
// This function returns a future that resolves when either `Ctrl+C` or a termination signal is received
// and the callback function has been executed.
// pub async fn shutdown_signal<F>(shutdown_cb: F)
// where
// F: Fn(),
// {
// let ctrl_c = async {
// signal::ctrl_c()
// .await
// .expect("failed to install Ctrl+C handler");
// };
//
// #[cfg(unix)]
// let terminate = async {
// signal::unix::signal(signal::unix::SignalKind::terminate())
// .expect("failed to install signal handler")
// .recv()
// .await;
// };
//
// #[cfg(not(unix))]
// let terminate = std::future::pending::<()>();
//
// tokio::select! {
// _ = ctrl_c => {
// },
// _ = terminate => {
// },
// }
//
// tracing::info!("Received termination signal shutting down");
// shutdown_cb()
// }
/// Parse assets file path
///
/// ## Arguments
///
/// `assets_path`: the rest part of client request path
/// `assets_root`: local directory path from config file
/// `index_file`: index file format from config file
#[inline]
pub fn parse_assets_path(assets_path: &str, assets_root: &str, index_file: &str) -> String {
match assets_path {
str if str.ends_with('/') => {
format!("{}{}{}", assets_root, assets_path, index_file)
}
str if str.contains('.') && !str.starts_with('/') => {
format!("{}/{}", assets_root, assets_path)
}
str if !str.starts_with('/') => {
format!("{}/{}{}", assets_root, assets_path, index_file)
}
_ => {
format!("{}{}/{}", assets_root, assets_path, index_file)
}
}
}
/// Find target route by req path
///
/// ## Arguments
///
/// `req_path`: client request path
/// `route_map`: router map from config
///
/// ## Return
///
/// a result. return none when path not registried
/// `router`: host from config file
/// `assets_path`: the rest part of client request path
pub fn find_route<'a>(
req_path: &'a str,
route_map: &'a HostRouteMap,
) -> Result<(&'a SettingRoute, &'a str)> {
let not_found_err = format!("resource {} not found", &req_path);
// /public/www/test
// then find all stash's index
let all_stash = &req_path
.bytes()
.enumerate()
.filter(|(_, b)| *b == b'/')
.map(|(index, _)| index + 1)
.collect::<Vec<_>>();
// loop the all_stash
// /public/
// /public/www/
let mut last_router = None;
for index in all_stash {
if let Some(router) = route_map.get(&req_path[..*index]) {
last_router = Some((router, &req_path[*index..]));
}
}
let (router, assets_path) = last_router.ok_or(Error::NotFound(not_found_err.into()))?;
debug!("router {:?}", &router);
debug!("assets_path {assets_path}");
Ok((router, assets_path))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
#[test]
fn parse_assets_path_works() {
let path = parse_assets_path("/docs/", "./public", "index.html");
assert_eq!(path, "./public/docs/index.html".to_string())
}
#[test]
fn find_route_works() {
let setting_route = SettingRoute {
location: "/".to_string(),
root: Some("./public".to_string()),
index: vec!["index.html".into()],
error_page: None,
proxy_pass: None,
pub async fn graceful_shutdown(handle: Handle) {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
let map = BTreeMap::from([("/".to_string(), setting_route)]);
let (_, assets_path) = find_route("/docs/home", &map).unwrap();
assert_eq!(assets_path, "docs/home")
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {
},
_ = terminate => {
},
}
info!("Received termination signal shutting down");
info!("Server shuting down");
// Signal the server to shutdown using Handle.
handle.graceful_shutdown(Some(Duration::from_secs(30)));
// Print alive connection count every second.
loop {
sleep(Duration::from_secs(1)).await;
debug!("alive connections: {}", handle.connection_count());
}
}
/// Parse port from host
/// if host is localhost:8080
/// return 8080
/// if host is localhost
/// return 80
pub fn parse_port_from_host(host: &str, scheme: &str) -> Option<u16> {
// localhost:8080
// ["localhost", "8080"]
// localhost
// ["localhost"]
let host_parts = host.split(':').collect::<Vec<&str>>();
let port = if host_parts.len() == 1 {
match scheme {
"http" => 80,
"https" => 443,
_ => {
debug!("scheme not support");
return None;
}
}
} else {
host_parts.get(1)?.parse::<u16>().ok()?
};
Some(port)
}