Showing preview only (394K chars total). Download the full file or copy to clipboard to get everything.
Repository: svenstaro/miniserve
Branch: master
Commit: 0d70b98ca15b
Files: 65
Total size: 375.0 KB
Directory structure:
gitextract_74o55m93/
├── .cargo/
│ └── config.toml
├── .dockerignore
├── .editorconfig
├── .github/
│ ├── FUNDING.yml
│ ├── dependabot.yml
│ └── workflows/
│ ├── build-release.yml
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── Cargo.toml
├── Containerfile
├── Containerfile.alpine
├── LICENSE
├── Makefile
├── README.md
├── data/
│ ├── style.scss
│ └── themes/
│ ├── archlinux.scss
│ ├── ayu_dark.scss
│ ├── monokai.scss
│ ├── squirrel.scss
│ └── zenburn.scss
├── packaging/
│ └── miniserve@.service
├── release.toml
├── rustfmt.toml
├── src/
│ ├── archive.rs
│ ├── args.rs
│ ├── auth.rs
│ ├── config.rs
│ ├── consts.rs
│ ├── errors.rs
│ ├── file_op.rs
│ ├── file_utils.rs
│ ├── listing.rs
│ ├── main.rs
│ ├── pipe.rs
│ ├── renderer.rs
│ └── webdav_fs.rs
└── tests/
├── api.rs
├── archive.rs
├── auth.rs
├── auth_file.rs
├── bind.rs
├── cli.rs
├── create_directories.rs
├── data/
│ ├── auth1.txt
│ ├── cert.pem
│ ├── cert_ec.pem
│ ├── cert_rsa.pem
│ ├── generate_tls_certs.sh
│ ├── key_ec.pem
│ ├── key_pkcs1.pem
│ └── key_pkcs8.pem
├── fixtures/
│ └── mod.rs
├── header.rs
├── navigation.rs
├── paste.rs
├── qrcode.rs
├── raw.rs
├── readme.rs
├── rm_files.rs
├── serve_request.rs
├── tls.rs
├── upload_files.rs
├── utils/
│ └── mod.rs
└── webdav.rs
================================================
FILE CONTENTS
================================================
================================================
FILE: .cargo/config.toml
================================================
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.i686-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
================================================
FILE: .dockerignore
================================================
target
================================================
FILE: .editorconfig
================================================
[*.rs]
indent_style = space
indent_size = 4
================================================
FILE: .github/FUNDING.yml
================================================
github: svenstaro
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: cargo
directory: "/"
schedule:
interval: monthly
groups:
all-dependencies:
patterns:
- "*"
================================================
FILE: .github/workflows/build-release.yml
================================================
name: Build/publish release
on: [push, pull_request]
jobs:
publish:
name: Binary ${{ matrix.target }} (on ${{ matrix.os }})
runs-on: ${{ matrix.os }}
outputs:
version: ${{ steps.extract_version.outputs.version }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
compress: true
cargo_flags: ""
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
compress: true
cargo_flags: ""
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
compress: true
cargo_flags: ""
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
compress: true
cargo_flags: ""
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
compress: true
cargo_flags: ""
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
compress: true
cargo_flags: ""
- os: ubuntu-latest
target: arm-unknown-linux-musleabihf
compress: true
cargo_flags: ""
- os: ubuntu-latest
target: riscv64gc-unknown-linux-gnu
compress: false
cargo_flags: "--no-default-features"
- os: windows-latest
target: x86_64-pc-windows-msvc
compress: true
cargo_flags: ""
- os: windows-latest
target: i686-pc-windows-msvc
compress: true
cargo_flags: ""
- os: macos-latest
target: x86_64-apple-darwin
compress: false
cargo_flags: ""
- os: macos-latest
target: aarch64-apple-darwin
compress: false
cargo_flags: ""
- os: ubuntu-latest
target: x86_64-unknown-freebsd
compress: false
cargo_flags: ""
- os: ubuntu-latest
target: x86_64-unknown-illumos
compress: false
cargo_flags: ""
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
- run: sudo apt install musl-tools
if: startsWith(matrix.os, 'ubuntu')
- name: cargo build
uses: houseabsolute/actions-rust-cross@v0
with:
command: build
args: --release --locked ${{ matrix.cargo_flags }}
target: ${{ matrix.target }}
- name: Set exe extension for Windows
run: echo "EXE=.exe" >> $env:GITHUB_ENV
if: startsWith(matrix.os, 'windows')
- name: Compress binaries
uses: svenstaro/upx-action@v2
with:
files: target/${{ matrix.target }}/release/miniserve${{ env.EXE }}
args: --best --lzma
strip: false # We're stripping already in Cargo.toml
if: ${{ matrix.compress }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: target/${{ matrix.target }}/release/miniserve${{ env.EXE }}
- name: Get version from tag
id: extract_version
run: |
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
shell: bash
- name: Install CHANGELOG parser
uses: taiki-e/install-action@parse-changelog
- name: Get CHANGELOG entry
run: parse-changelog CHANGELOG.md ${{ steps.extract_version.outputs.version }} | tee changelog_entry
if: startsWith(github.ref_name, 'v') && github.ref_type == 'tag'
shell: bash
- name: Read changelog entry from file
id: changelog_entry
uses: juliangruber/read-file-action@v1
with:
path: ./changelog_entry
if: startsWith(github.ref_name, 'v') && github.ref_type == 'tag'
- name: Release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: target/${{ matrix.target }}/release/miniserve${{ env.EXE }}
tag: ${{ github.ref_name }}
asset_name: miniserve-${{ steps.extract_version.outputs.version }}-${{ matrix.target }}${{ env.EXE }}
body: ${{ steps.changelog_entry.outputs.content }}
if: startsWith(github.ref_name, 'v') && github.ref_type == 'tag'
container-images:
name: Publish images
runs-on: ubuntu-latest
needs: publish
# Run for tags and pushes to the default branch
if: (startsWith(github.ref_name, 'v') && github.ref_type == 'tag') || github.event.repository.default_branch == github.ref_name
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Download artifact aarch64-unknown-linux-gnu
uses: actions/download-artifact@v4
with:
name: aarch64-unknown-linux-gnu
path: target/aarch64-unknown-linux-gnu/release
- name: Download artifact x86_64-unknown-linux-gnu
uses: actions/download-artifact@v4
with:
name: x86_64-unknown-linux-gnu
path: target/x86_64-unknown-linux-gnu/release
- name: Download artifact armv7-unknown-linux-gnueabihf
uses: actions/download-artifact@v4
with:
name: armv7-unknown-linux-gnueabihf
path: target/armv7-unknown-linux-gnueabihf/release
- name: Download artifact aarch64-unknown-linux-musl
uses: actions/download-artifact@v4
with:
name: aarch64-unknown-linux-musl
path: target/aarch64-unknown-linux-musl/release
- name: Download artifact x86_64-unknown-linux-musl
uses: actions/download-artifact@v4
with:
name: x86_64-unknown-linux-musl
path: target/x86_64-unknown-linux-musl/release
- name: Download artifact armv7-unknown-linux-musleabihf
uses: actions/download-artifact@v4
with:
name: armv7-unknown-linux-musleabihf
path: target/armv7-unknown-linux-musleabihf/release
- name: podman login
run: podman login --username ${{ secrets.DOCKERHUB_USERNAME }} --password ${{ secrets.DOCKERHUB_TOKEN }} docker.io
- name: podman build linux/arm64
run: podman build --format docker --platform linux/arm64/v8 --manifest miniserve -f Containerfile target/aarch64-unknown-linux-gnu/release
- name: podman build linux/amd64
run: podman build --format docker --platform linux/amd64 --manifest miniserve -f Containerfile target/x86_64-unknown-linux-gnu/release
- name: podman build linux/arm
run: podman build --format docker --platform linux/arm/v7 --manifest miniserve -f Containerfile target/armv7-unknown-linux-gnueabihf/release
- name: podman manifest push latest
run: podman manifest push miniserve docker.io/svenstaro/miniserve:latest
- name: podman manifest push tag version
run: podman manifest push miniserve docker.io/svenstaro/miniserve:${{ needs.publish.outputs.version }}
if: startsWith(github.ref_name, 'v')
- name: podman build linux/arm64 (alpine edition)
run: podman build --format docker --platform linux/arm64/v8 --manifest miniserve-alpine -f Containerfile.alpine target/aarch64-unknown-linux-musl/release
- name: podman build linux/amd64 (alpine edition)
run: podman build --format docker --platform linux/amd64 --manifest miniserve-alpine -f Containerfile.alpine target/x86_64-unknown-linux-musl/release
- name: podman build linux/arm (alpine edition)
run: podman build --format docker --platform linux/arm/v7 --manifest miniserve-alpine -f Containerfile.alpine target/armv7-unknown-linux-musleabihf/release
- name: podman manifest push latest (alpine edition)
run: podman manifest push miniserve-alpine docker.io/svenstaro/miniserve:alpine
- name: podman manifest push tag version (alpine edition)
run: podman manifest push miniserve-alpine docker.io/svenstaro/miniserve:${{ needs.publish.outputs.version }}-alpine
if: startsWith(github.ref_name, 'v')
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on: [push, pull_request]
jobs:
ci:
name: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: cargo build
run: cargo build
- name: cargo test
run: cargo test -- --test-threads 1
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy -- -D warnings
================================================
FILE: .gitignore
================================================
# Generated by Cargo
# will have compiled files and executables
/target/
# These are backup files generated by rustfmt
**/*.rs.bk
# Editor-specific ignores
.idea/
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
<!-- next-header -->
## [Unreleased] - ReleaseDate
- Add Ayu Dark theme [#1551](https://github.com/svenstaro/miniserve/pull/1551) (thanks @rysb-dev)
## [0.33.0] - 2026-02-16
- Add `--log-color` to explicitly control when to print colors [#1529](https://github.com/svenstaro/miniserve/pull/1529) (thanks @MrCroxx)
- Fix `--rm-files` not working with `--route-preifx` [#1531](https://github.com/svenstaro/miniserve/pull/1531) (thanks @Jianchi-Chen)
- Add illumos builds [#1543](https://github.com/svenstaro/miniserve/pull/1543)
- Ignore failure on file hash calculation during upload [#1542](https://github.com/svenstaro/miniserve/pull/1542) (thanks @outloudvi)
- Add `--quiet` flag to reduce output and silence warnings [#1539](https://github.com/svenstaro/miniserve/pull/1539) (thanks @joshleeb)
- Enable some markdown rendering extensions [#1545](https://github.com/svenstaro/miniserve/pull/1545) (thanks @duskmoon314)
- Add `--pastebin` flag to enable pastebin form [#1546](https://github.com/svenstaro/miniserve/pull/1546) (thanks @rktjmp)
- Always use forward slashes in zip files [#1534](https://github.com/svenstaro/miniserve/pull/1534) (thanks @pzhlkj6612)
## [0.32.0] - 2025-09-17
- Skip hash calculation when crypto.subtle is not available [#1507](https://github.com/svenstaro/miniserve/pull/1507) (thanks @outloudvi)
- Fix hostname:port getting repeated in wget footer if TLS is used [#1515](https://github.com/svenstaro/miniserve/pull/1515) (thanks @xtay573269555)
- Add `--chmod` to set file mode after upload on unix [#1506](https://github.com/svenstaro/miniserve/pull/1506) (thanks @lilydjwg)
- Add `--rm-files` to allow file deletion [#1518](https://github.com/svenstaro/miniserve/pull/1518) (thanks @NOBLESSE and @cyqsimon)
## [0.31.0] - 2025-06-27
- Fix filtering symlinks when hosting WebDAV [#1502](https://github.com/svenstaro/miniserve/pull/1502) (thanks @ahti)
- Enable renaming during file upload if duplicate exists [#1453](https://github.com/svenstaro/miniserve/pull/1453) (thanks @Atreyagaurav)
## [0.30.0] - 2025-06-26
- Add `--file-external-url` to generate links pointing to another server [#1492](https://github.com/svenstaro/miniserve/pull/1492) (thanks @jankeymeulen)
- Add date pill and sort links for mobile views [#1473](https://github.com/svenstaro/miniserve/pull/1473) (thanks @Flat)
- Add upload progress bar and allow for multiple concurrent file uploads [#1431](https://github.com/svenstaro/miniserve/pull/1431) (thanks @AlecDivito)
- Add `--size-display` to allow for toggling file size display between `human` and `exact` [#1261](https://github.com/svenstaro/miniserve/pull/1261) (thanks @Lzzzzzt)
- Add well-known healthcheck route at `/__miniserve_internal/healthcheck` (of `/<prefix>/__miniserve_internal/healthcheck` when using `--route-prefix`)
- Add asynchronous recursive directory size counting [#1482](https://github.com/svenstaro/miniserve/pull/1482)
- Add link to miniserve GitHub page to footer
- Add `--directory-size` flag to enable directory size counting
- Fix --no-symlinks not filtering files and dirs nested in symlinks [#1495](https://github.com/svenstaro/miniserve/pull/1495) (thanks @ahti)
## [0.29.0] - 2025-02-06
- Make URL encoding fully WHATWG-compliant [#1454](https://github.com/svenstaro/miniserve/pull/1454) (thanks @cyqsimon)
- Fix `OVERWRITE_FILES` env var not being prefixed by `MINISERVE_` [#1457](https://github.com/svenstaro/miniserve/issues/1457)
- Change `font-weight` of regular files to be `normal` to improve readability [#1471](https://github.com/svenstaro/miniserve/pull/1471) (thanks @shaicoleman)
- Add webdav support [#1415](https://github.com/svenstaro/miniserve/pull/1415) (thanks @ahti)
- Move favicon and css to stable, non-random routes [#1472](https://github.com/svenstaro/miniserve/pull/1472) (thanks @ahti)
## [0.28.0] - 2024-09-12
- Fix wrapping text in mobile view when the file name too long [#1379](https://github.com/svenstaro/miniserve/pull/1379) (thanks @chaibiq)
- Fix missing drag-form when dragging file in to browser [#1390](https://github.com/svenstaro/miniserve/pull/1390) (thanks @chaibiq)
- Improve documentation for the --header parameter [#1389](https://github.com/svenstaro/miniserve/pull/1389) (thanks @orwithout)
- Don't show mkdir option when the directory is not upload allowed [#1442](https://github.com/svenstaro/miniserve/pull/1442) (thanks @Atreyagaurav)
## [0.27.1] - 2024-03-16
- Add `Add file and folder symbols` [#1365](https://github.com/svenstaro/miniserve/pull/1365) (thanks @chaibiq)
## [0.27.0] - 2024-03-16
- Add `-C/--compress-response` to enable response compression [1315](https://github.com/svenstaro/miniserve/pull/1315) (thanks @zuisong)
- Refactor errors [#1331](https://github.com/svenstaro/miniserve/pull/1331) (thanks @cyqsimon)
- Add `-I/--disable-inexing` [#1329](https://github.com/svenstaro/miniserve/pull/1329) (thanks @dyc3)
## [0.26.0] - 2024-01-13
- Properly handle read-only errors on Windows [#1310](https://github.com/svenstaro/miniserve/pull/1310) (thanks @ViRb3)
- Use `tokio::fs` instead of `std::fs` to enable async file operations [#445](https://github.com/svenstaro/miniserve/issues/445)
- Add `-S`/`--default-sorting-method` and `-O`/`--default-sorting-order` flags [#1308](https://github.com/svenstaro/miniserve/pull/1308) (thanks @ElliottLandsborough)
## [0.25.0] - 2024-01-07
- Add `--pretty-urls` [#1193](https://github.com/svenstaro/miniserve/pull/1193) (thanks @nlopes)
- Fix single quote display with `--show-wget-footer` [#1191](https://github.com/svenstaro/miniserve/pull/1191) (thanks @d-air1)
- Remove header Content-Encoding when archiving [#1290](https://github.com/svenstaro/miniserve/pull/1290) (thanks @5long)
- Prevent illegal request path from crashing program [#1285](https://github.com/svenstaro/miniserve/pull/1285) (thanks @cyqsimon)
- Fixed issue where serving files with a newline would fail [#1294](https://github.com/svenstaro/miniserve/issues/1294)
## [0.24.0] - 2023-07-06
- Fix ANSI color codes are printed when not a tty [#1095](https://github.com/svenstaro/miniserve/pull/1095)
- Allow parameters to be provided via environment variables [#1160](https://github.com/svenstaro/miniserve/pull/1160)
## [0.23.2] - 2023-04-28
- Build Windows build with static CRT [#1107](https://github.com/svenstaro/miniserve/pull/1107)
## [0.23.1] - 2023-04-17
- Add EC key support [#1080](https://github.com/svenstaro/miniserve/issues/1080)
## [0.23.0] - 2023-03-01
- Update to clap v4
- Show localized datetime [#949](https://github.com/svenstaro/miniserve/pull/949) (thanks @IvkinStanislav)
- Fix sorting breaks subdir downloading [#991](https://github.com/svenstaro/miniserve/pull/991) (thanks @Vam-Jam)
- Fix wget footer [#1043](https://github.com/svenstaro/miniserve/pull/1043) (thanks @Yusuto)
## [0.22.0] - 2022-09-20
- Faster QR code generation [#848](https://github.com/svenstaro/miniserve/pull/848) (thanks @cyqsimon)
- Make `--readme` support not only `README.md` but also `README` and `README.txt` rendered as
plaintext [#911](https://github.com/svenstaro/miniserve/pull/911) (thanks @Atreyagaurav)
- Change `-u/--upload-files` slightly in the sense that it can now either be provided by itself as
before or receive a file path to restrict uploading to only that path. Can be provided multiple
times for multiple allowed paths [#858](https://github.com/svenstaro/miniserve/pull/858) (thanks
@jonasdiemer)
## [0.21.0] - 2022-09-15
- Fix bug where static files would be served incorrectly when using `--random-route` [#835](https://github.com/svenstaro/miniserve/pull/835) (thanks @solarknight)
- Add `--readme` to render the README in the current directory after the file listing [#860](https://github.com/svenstaro/miniserve/pull/860) (thanks @Atreyagaurav)
- Add more architectures (and also additional container images)
## [0.20.0] - 2022-06-26
- Fixed security issue where it was possible to upload files to locations pointed to by symlinks
even when symlinks were disabled [#781](https://github.com/svenstaro/miniserve/pull/781) (thanks @sheepy0125)
- Added `--hide-theme-selector` flag to hide the theme selector functionality in the frontend [#805](https://github.com/svenstaro/miniserve/pull/805https://github.com/svenstaro/miniserve/pull/805) (thanks @flamingoodev)
- Added `--mkdir` flag to allow for uploading directories [#781](https://github.com/svenstaro/miniserve/pull/781) (thanks @sheepy0125)
## [0.19.5] - 2022-05-18
- Fix security issue where `--no-symlinks` would only hide symlinks from listing but it would
still be possible to follow them if the path was known
## [0.19.4] - 2022-04-02
- Fix random route leaking on error pages [#764](https://github.com/svenstaro/miniserve/pull/764) (thanks @steffhip)
## [0.19.3] - 2022-03-15
- Allow to set the accept input attribute to arbitrary values using `-m` and `-M` [#755](https://github.com/svenstaro/miniserve/pull/755) (thanks @mayjs)
## [0.19.2] - 2022-02-21
- Add man page support via `--print-manpage` [#738](https://github.com/svenstaro/miniserve/pull/738)
## [0.19.1] - 2022-02-16
- Better MIME type guessing support due to updated mime_guess
## [0.19.0] - 2022-02-06
- Fix panic when using TLS in some instances [#670](https://github.com/svenstaro/miniserve/issues/670) (thanks @aliemjay)
- Add `--route-prefix` to add a fixed route prefix [#728](https://github.com/svenstaro/miniserve/pull/728) (thanks @aliemjay and @Jikstra)
- Allow tapping the whole row in mobile view [#729](https://github.com/svenstaro/miniserve/pull/729)
## [0.18.0] - 2021-10-26
- Add raw mode and raw mode footer display [#508](https://github.com/svenstaro/miniserve/pull/508) (thanks @Jikstra)
- Add SPA mode [#515](https://github.com/svenstaro/miniserve/pull/515) (thanks @sinking-point)
## [0.17.0] - 2021-09-04
- Print QR codes on terminal [#524](https://github.com/svenstaro/miniserve/pull/524) (thanks @aliemjay)
- Fix mobile layout info pills taking whole width [#591](https://github.com/svenstaro/miniserve/issues/591)
- Fix security exploit when uploading is enabled [#590](https://github.com/svenstaro/miniserve/pull/590) [#518](https://github.com/svenstaro/miniserve/issues/518) (thanks @aliemjay)
- Fix uploading to symlink directories [#590](https://github.com/svenstaro/miniserve/pull/590) [#466](https://github.com/svenstaro/miniserve/issues/466) (thanks @aliemjay)
## [0.16.0] - 2021-08-31
- Fix serving files with backslashes in their names [#578](https://github.com/svenstaro/miniserve/pull/578) (thanks @Jikstra)
- Fix behavior of downloading symlinks by upgrading to actix-web 4 [#582](https://github.com/svenstaro/miniserve/pull/582) [#462](https://github.com/svenstaro/miniserve/issues/462) (thanks @aliemjay)
- List directory if index file not found [#583](https://github.com/svenstaro/miniserve/pull/583) [#275](https://github.com/svenstaro/miniserve/pull/583) (thanks @aliemjay)
- Add special colors for visited links [#521](https://github.com/svenstaro/miniserve/pull/521) (thanks @raffomania)
- Switch from structopt to clap v3 [#587](https://github.com/svenstaro/miniserve/pull/587)
This enables slightly nicer help output as well as much better completions.
- Fix network interface handling [#500](https://github.com/svenstaro/miniserve/pull/500) [#470](https://github.com/svenstaro/miniserve/issues/470) [#405](https://github.com/svenstaro/miniserve/issues/405) [#422](https://github.com/svenstaro/miniserve/issues/422) (thanks @aliemjay)
- Implement show symlink destination [#542](https://github.com/svenstaro/miniserve/pull/542) [#499](https://github.com/svenstaro/miniserve/issues/499) (thanks @deantvv)
- Fix error page not being correctly themed [#529](https://github.com/svenstaro/miniserve/pull/529) [#588](https://github.com/svenstaro/miniserve/issues/588) (@aliemjay)
## [0.15.0] - 2021-08-27
- Add hardened systemd template unit file to `packaging/miniserve@.service`
- Fix qrcodegen dependency problem [#568](https://github.com/svenstaro/miniserve/issues/568)
- Remove animation on QR code hover (it was kind of annoying as it makes things less snappy)
- Add TLS support [#576](https://github.com/svenstaro/miniserve/pull/576)
## [0.14.0] - 2021-04-18
- Fix breadcrumbs for right-to-left languages [#489](https://github.com/svenstaro/miniserve/pull/489) (thanks @aliemjay)
- Fix URL percent encoding for special characters [#485](https://github.com/svenstaro/miniserve/pull/485) (thanks @aliemjay)
- Wrap breadcrumbs at any char [#496](https://github.com/svenstaro/miniserve/pull/496) (thanks @aliemjay)
- Add separate flags for compressed and uncompressed tar archives [#492](https://github.com/svenstaro/miniserve/pull/492) (thanks @deantvv)
- Bump deps
- Fix Firefox becoming confused when opening a `.gz` file directly [#160](https://github.com/svenstaro/miniserve/issues/160)
- Prefer UTF8 for text responses [#263](https://github.com/svenstaro/miniserve/issues/263)
- Resolve symlinks on directory listing [#479](https://github.com/svenstaro/miniserve/pull/479) (thanks @aliemjay)
## [0.13.0] - 2021-03-28
- Change default log level to `Warn`
- Change some messages a bit to be more clear
- Add `--print-completions` to print shell completions for various supported shells [#482](https://github.com/svenstaro/miniserve/pull/482) (thanks @rouge8)
- Don't print some messages if not attached to an interactive terminal
- Refuse to start if not attached to interactive terminal and no explicit path is provided
This is a security consideration as you wouldn't want to run miniserve without an explicit path
as a service. You could end up serving `/` or `/root` in case those working directories are set.
## [0.12.1] - 2021-03-27
- Fix QR code not showing when using both `--random-route` and `--qrcode` [#480](https://github.com/svenstaro/miniserve/pull/480) (thanks @rouge8)
- Add FreeBSD binaries
## [0.12.0] - 2021-03-20
- Add option `-H`/`--hidden` to show hidden files
- Start instantly in case an explicit index is chosen
- Fix DoS issue when deliberately sending unconforming URL paths
- Add footer [#456](https://github.com/svenstaro/miniserve/pull/456) (thanks @levaitamas)
- Switched from failure to thiserror for error handling
## [0.11.0] - 2021-02-28
- Add binaries for more architectures
- Upgrade lockfile which fixes some security issues
- Allow multiple file upload [#434](https://github.com/svenstaro/miniserve/pull/434) (thanks @mhuesch)
- Allow for setting custom headers via `--header` [#452](https://github.com/svenstaro/miniserve/pull/452) (thanks @deantvv)
## [0.10.4] - 2021-01-05
- Add `--dirs-first`/`-D` option to list directories first [#423](https://github.com/svenstaro/miniserve/pull/423) (thanks @levaitamas)
## [0.10.3] - 2020-11-09
- Actually fix publish workflow
## [0.10.2] - 2020-11-09
- Fix publish workflow
## [0.10.1] - 2020-11-09
- Now compiles on stable! :D
## [0.10.0] - 2020-10-02
- Add embedded favicon [#364](https://github.com/svenstaro/miniserve/issues/364)
- Add `--title` option which can be used to set the page title [#378](https://github.com/svenstaro/miniserve/pull/378) (thanks @ahti)
- Default title is now the same host received in the request [#378](https://github.com/svenstaro/miniserve/pull/378) (thanks @ahti)
- Client-side color-scheme handling [#380](https://github.com/svenstaro/miniserve/pull/380) (thanks @ahti)
## [0.9.0] - 2020-09-16
- Added prebuilt binaries for AARCH64, ARMv7, and ARM [#350](https://github.com/svenstaro/miniserve/pull/350)
- Remove percent-encoding in heading and title [#362](https://github.com/svenstaro/miniserve/pull/362) (thanks @ahti)
- Make name ordering case-insensitive [#362](https://github.com/svenstaro/miniserve/pull/362) (thanks @ahti)
- Give name column more space [#362](https://github.com/svenstaro/miniserve/pull/362) (thanks @ahti)
- Fix double-escaping [#354](https://github.com/svenstaro/miniserve/issues/354)
- Upgrade to actix-web 3.0
- Fix time display for files created "now" [#373](https://github.com/svenstaro/miniserve/pull/373) (thanks @imp and @KevCui)
## [0.8.0] - 2020-07-22
- Accept port 0 to find a random free port and use that [#327](https://github.com/svenstaro/miniserve/pull/327) (thanks @parrotmac)
- Show QR code in interface [#330](https://github.com/svenstaro/miniserve/pull/330) (thanks @wyhaya)
- Ported to actix-web 2 and futures 0.3 [#343](https://github.com/svenstaro/miniserve/pull/343) (thanks @equal-l2)
## [0.7.0] - 2020-05-14
- Add zip archiving [#297](https://github.com/svenstaro/miniserve/pull/297) (thanks @marawan31)
## [0.6.0] - 2020-03-14
- Add option to disable archives [#235](https://github.com/svenstaro/miniserve/pull/235) (thanks @DamianX)
- Fix minor bug when using `--random-route` [#219](https://github.com/svenstaro/miniserve/pull/219)
- Add a default index serving option [#189](https://github.com/svenstaro/miniserve/pull/189)
## [0.5.0] - 2019-06-24
- Add streaming download of tar archives (thanks @gyscos)
- Add support for hashed passwords (thanks @KSXGitHub)
- Add support for multiple auth flags (thanks @KSXGitHub)
- Some theme related bug fixes (thanks @boastful-squirrel)
<!-- next-url -->
[Unreleased]: https://github.com/svenstaro/miniserve/compare/v0.33.0...HEAD
[0.33.0]: https://github.com/svenstaro/miniserve/compare/v0.32.0...v0.33.0
[0.32.0]: https://github.com/svenstaro/miniserve/compare/v0.31.0...v0.32.0
[0.31.0]: https://github.com/svenstaro/miniserve/compare/v0.30.0...v0.31.0
[0.30.0]: https://github.com/svenstaro/miniserve/compare/v0.29.0...v0.30.0
[0.29.0]: https://github.com/svenstaro/miniserve/compare/v0.28.0...v0.29.0
[0.28.0]: https://github.com/svenstaro/miniserve/compare/v0.27.1...v0.28.0
[0.27.1]: https://github.com/svenstaro/miniserve/compare/v0.27.0...v0.27.1
[0.27.0]: https://github.com/svenstaro/miniserve/compare/v0.26.0...v0.27.0
[0.26.0]: https://github.com/svenstaro/miniserve/compare/v0.25.0...v0.26.0
[0.25.0]: https://github.com/svenstaro/miniserve/compare/v0.24.0...v0.25.0
[0.24.0]: https://github.com/svenstaro/miniserve/compare/v0.23.2...v0.24.0
[0.23.2]: https://github.com/svenstaro/miniserve/compare/v0.23.1...v0.23.2
[0.23.1]: https://github.com/svenstaro/miniserve/compare/v0.23.0...v0.23.1
[0.23.0]: https://github.com/svenstaro/miniserve/compare/v0.22.0...v0.23.0
[0.22.0]: https://github.com/svenstaro/miniserve/compare/v0.21.0...v0.22.0
[0.21.0]: https://github.com/svenstaro/miniserve/compare/v0.20.0...v0.21.0
[0.20.0]: https://github.com/svenstaro/miniserve/compare/v0.19.5...v0.20.0
[0.19.5]: https://github.com/svenstaro/miniserve/compare/v0.19.4...v0.19.5
[0.19.4]: https://github.com/svenstaro/miniserve/compare/v0.19.3...v0.19.4
[0.19.3]: https://github.com/svenstaro/miniserve/compare/v0.19.2...v0.19.3
[0.19.2]: https://github.com/svenstaro/miniserve/compare/v0.19.1...v0.19.2
[0.19.1]: https://github.com/svenstaro/miniserve/compare/v0.19.0...v0.19.1
[0.19.0]: https://github.com/svenstaro/miniserve/compare/v0.18.0...v0.19.0
[0.18.0]: https://github.com/svenstaro/miniserve/compare/v0.17.0...v0.18.0
[0.17.0]: https://github.com/svenstaro/miniserve/compare/v0.16.0...v0.17.0
[0.16.0]: https://github.com/svenstaro/miniserve/compare/v0.15.0...v0.16.0
[0.15.0]: https://github.com/svenstaro/miniserve/compare/v0.14.0...v0.15.0
[0.14.0]: https://github.com/svenstaro/miniserve/compare/v0.13.0...v0.14.0
[0.13.0]: https://github.com/svenstaro/miniserve/compare/v0.12.1...v0.13.0
[0.12.1]: https://github.com/svenstaro/miniserve/compare/v0.12.0...v0.12.1
[0.12.0]: https://github.com/svenstaro/miniserve/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/svenstaro/miniserve/compare/v0.10.4...v0.11.0
[0.10.4]: https://github.com/svenstaro/miniserve/compare/v0.10.3...v0.10.4
[0.10.3]: https://github.com/svenstaro/miniserve/compare/v0.10.2...v0.10.3
[0.10.2]: https://github.com/svenstaro/miniserve/compare/v0.10.1...v0.10.2
[0.10.1]: https://github.com/svenstaro/miniserve/compare/v0.10.0...v0.10.1
[0.10.0]: https://github.com/svenstaro/miniserve/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/svenstaro/miniserve/compare/v0.8.0...v0.9.0
[0.8.0]: https://github.com/svenstaro/miniserve/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/svenstaro/miniserve/compare/v0.6.0...v0.7.0
[0.6.0]: https://github.com/svenstaro/miniserve/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/svenstaro/miniserve/compare/v0.4.0...v0.5.0
================================================
FILE: Cargo.toml
================================================
[package]
name = "miniserve"
version = "0.33.0"
description = "For when you really just want to serve some files over HTTP right now!"
authors = ["Sven-Hendrik Haase <svenstaro@gmail.com>", "Boastful Squirrel <boastful.squirrel@gmail.com>"]
repository = "https://github.com/svenstaro/miniserve"
license = "MIT"
readme = "README.md"
keywords = ["serve", "http-server", "static-files", "http", "server"]
categories = ["command-line-utilities", "network-programming", "web-programming::http-server"]
edition = "2024"
[profile.release]
codegen-units = 1
lto = true
opt-level = 'z'
panic = 'abort'
strip = true
[dependencies]
actix-files = "0.6.9"
actix-multipart = "0.7"
actix-web = { version = "4", features = ["macros", "compress-brotli", "compress-gzip", "compress-zstd"], default-features = false }
actix-web-httpauth = "0.8"
alphanumeric-sort = "1"
anyhow = "1"
async-walkdir = "2.1.0"
bytesize = "2"
chrono = "0.4"
chrono-humanize = "0.2"
clap = { version = "4", features = ["derive", "cargo", "wrap_help", "deprecated", "env"] }
clap_complete = "4"
clap_mangen = "0.2"
colored = "3"
comrak = { version = "0.50", default-features = false }
dav-server = { version = "0.11", features = ["actix-compat"] }
fast_qr = { version = "0.13", features = ["svg"] }
futures = "0.3"
grass = { version = "0.13", features = ["macro"], default-features = false }
hex = "0.4"
httparse = "1"
if-addrs = "0.15"
libflate = "2"
log = "0.4"
maud = "0.27"
mime = "0.3"
nanoid = "0.4"
percent-encoding = "2"
port_check = "0.3"
regex = "1"
rustix = { version = "1.1.4", features = ["process", "fs"] }
rustls = { version = "0.23", features = ["ring"], optional = true, default-features = false }
rustls-pemfile = { version = "2", optional = true }
serde = { version = "1", features = ["derive"] }
sha2 = "0.10"
simplelog = "0.12"
socket2 = "0.6"
strum = { version = "0.28", features = ["derive"] }
tar = "0.4"
tempfile = "3.26.0"
thiserror = "2"
tokio = { version = "1.47.1", features = ["fs", "macros"] }
zip = { version = "8", default-features = false }
[features]
default = ["tls"]
# This feature allows us to use rustls only on architectures supported by ring.
# See also https://github.com/briansmith/ring/issues/1182
# and https://github.com/briansmith/ring/issues/562
# and https://github.com/briansmith/ring/issues/1367
tls = ["rustls", "rustls-pemfile", "actix-web/rustls-0_23"]
[dev-dependencies]
assert_cmd = "2"
assert_fs = "1"
predicates = "3"
pretty_assertions = "1.2"
regex = "1"
reqwest = { version = "0.13", features = ["blocking", "multipart", "json", "rustls-no-provider"], default-features = false }
reqwest_dav = { version = "0.3", default-features = false }
rstest = "0.26"
select = "0.6"
url = "2"
[target.'cfg(not(windows))'.dev-dependencies]
# fake_tty does not support Windows for now
fake-tty = "0.3.1"
================================================
FILE: Containerfile
================================================
FROM debian:testing-slim
COPY --chmod=755 miniserve /app/
ENTRYPOINT ["/app/miniserve"]
================================================
FILE: Containerfile.alpine
================================================
FROM docker.io/alpine
COPY --chmod=755 miniserve /app/
ENTRYPOINT ["/app/miniserve"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2018 Sven-Hendrik Haase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
.PHONY: local
local:
cargo build --release
.PHONY: run
run:
ifndef ARGS
@echo Run "make run" with ARGS set to pass arguments…
endif
cargo run --release -- $(ARGS)
.PHONY: build-linux
build-linux:
cargo build --target x86_64-unknown-linux-musl --release --locked
strip target/x86_64-unknown-linux-musl/release/miniserve
upx --lzma target/x86_64-unknown-linux-musl/release/miniserve
.PHONY: build-win
build-win:
RUSTFLAGS="-C linker=x86_64-w64-mingw32-gcc" cargo build --target x86_64-pc-windows-gnu --release --locked
strip target/x86_64-pc-windows-gnu/release/miniserve.exe
upx --lzma target/x86_64-pc-windows-gnu/release/miniserve.exe
.PHONY: build-apple
build-apple:
cargo build --target x86_64-apple-darwin --release --locked
strip target/x86_64-apple-darwin/release/miniserve
upx --lzma target/x86_64-apple-darwin/release/miniserve
================================================
FILE: README.md
================================================
<p align="center">
<img src="data/logo.svg" alt="miniserve - a CLI tool to serve files and dirs over HTTP"><br>
</p>
# miniserve - a CLI tool to serve files and dirs over HTTP
[](https://github.com/svenstaro/miniserve/actions)
[](https://cloud.docker.com/repository/docker/svenstaro/miniserve/)
[](https://crates.io/crates/miniserve)
[](https://github.com/svenstaro/miniserve/blob/master/LICENSE)
[](https://github.com/svenstaro/miniserve/stargazers)
[](https://github.com/svenstaro/miniserve/releases)
[](https://github.com/svenstaro/miniserve)
**For when you really just want to serve some files over HTTP right now!**
**miniserve** is a small, self-contained cross-platform CLI tool that allows you to just grab the binary and serve some file(s) via HTTP.
Sometimes this is just a more practical and quick way than doing things properly.
## Screenshot

## How to use
### Serve a directory:
miniserve linux-distro-collection/
### Serve a single file:
miniserve linux-distro.iso
### Set a custom index file to serve instead of a file listing:
miniserve --index test.html
### Serve an SPA (Single Page Application) so that non-existent paths are forwarded to the SPA's router instead
miniserve --spa --index index.html
### Require username/password:
miniserve --auth joe:123 unreleased-linux-distros/
### Require username/password as hash:
pw=$(echo -n "123" | sha256sum | cut -f 1 -d ' ')
miniserve --auth joe:sha256:$pw unreleased-linux-distros/
### Require username/password from file (separate logins with new lines):
miniserve --auth-file auth.txt unreleased-linux-distros/
### Generate random 6-hexdigit URL:
miniserve -i 192.168.0.1 --random-route /tmp
# Serving path /private/tmp at http://192.168.0.1/c789b6
### Bind to multiple interfaces:
miniserve -i 192.168.0.1 -i 10.13.37.10 -i ::1 /tmp/myshare
### Insert custom headers
miniserve --header "Cache-Control:no-cache" --header "X-Custom-Header:custom-value" -p 8080 /tmp/myshare
# Check headers in another terminal
curl -I http://localhost:8080
If a header is already set or previously inserted, it will not be overwritten.
### Start with TLS:
miniserve --tls-cert my.cert --tls-key my.key /tmp/myshare
# Fullchain TLS and HTTP Strict Transport Security (HSTS)
miniserve --tls-cert fullchain.pem --tls-key my.key --header "Strict-Transport-Security: max-age=31536000; includeSubDomains; preload" /tmp/myshare
If the parameter value has spaces, be sure to wrap it in quotes.
(To achieve an A+ rating at https://www.ssllabs.com/ssltest/, enabling both fullchain TLS and HSTS is necessary.)
### Upload a file using `curl`:
# in one terminal
miniserve -u -- .
# in another terminal
curl -F "path=@$FILE" http://localhost:8080/upload\?path\=/
(where `$FILE` is the path to the file. This uses miniserve's default port of 8080)
Note that for uploading, we have to use `--` to disambiguate the argument to `-u`.
This is because `-u` can also take a path (or multiple). If a path argument to `-u` is given,
uploading will only be possible to the provided paths as opposed to every path.
Another effect of this is that you can't just combine flags like this `-uv` when `-u` is used. In
this example, you'd need to use `-u -v`.
### Create a directory using `curl`:
# in one terminal
miniserve --upload-files --mkdir .
# in another terminal
curl -F "mkdir=$DIR_NAME" http://localhost:8080/upload\?path=\/
(where `$DIR_NAME` is the name of the directory. This uses miniserve's default port of 8080.)
### Use the raw renderer for use with simple viewers
You can pass `?raw=true` with requests where you only require minimal HTML output for CLI-based browsers such as `lynx` or `w3m`.
This is enabled by default without any extra flags:
miniserve .
curl http://localhost:8080?raw=true
You can enable a convenient copy-pastable footer for `wget` using `--show-wget-footer`:
miniserve --show-wget-footer .
Afterwards, check the bottom of any rendered page.
It'll have a neat `wget` command you can easily copy-paste to recursively grab the current directory.
### Take pictures and upload them from smartphones:
miniserve -u -m image -q
This uses the `--media-type` option, which sends a hint for the expected media type to the browser.
Some mobile browsers like Firefox on Android will offer to open the camera app when seeing this.
## Features
- Easy to use
- Just works: Correct MIME types handling out of the box
- Single binary drop-in with no extra dependencies required
- Authentication support with username and password (and hashed password)
- Mega fast and highly parallel (thanks to [Rust](https://www.rust-lang.org/) and [Actix](https://actix.rs/))
- Folder download (compressed on the fly as `.tar.gz` or `.zip`)
- File uploading
- Directory creation
- Pretty themes (with light and dark theme support)
- Scan QR code for quick access
- Shell completions
- Sane and secure defaults
- TLS (for supported architectures)
- Supports README.md rendering like on GitHub
- Range requests
- WebDAV support
- Healthcheck route (at `/__miniserve_internal/healthcheck`)
## Usage
```
For when you really just want to serve some files over HTTP right now!
Usage: miniserve [OPTIONS] [PATH]
Arguments:
[PATH]
Which path to serve
[env: MINISERVE_PATH=]
Options:
-v, --verbose
Be verbose, includes emitting access logs
[env: MINISERVE_VERBOSE=]
--index <INDEX>
The name of a directory index file to serve, like "index.html"
Normally, when miniserve serves a directory, it creates a listing for that directory. However, if a
directory contains this file, miniserve will serve that file instead.
[env: MINISERVE_INDEX=]
--spa
Activate SPA (Single Page Application) mode
This will cause the file given by --index to be served for all non-existing file paths. In effect,
this will serve the index file whenever a 404 would otherwise occur in order to allow the SPA
router to handle the request instead.
[env: MINISERVE_SPA=]
--pretty-urls
Activate Pretty URLs mode
This will cause the server to serve the equivalent `.html` file indicated by the path.
`/about` will try to find `about.html` and serve it.
[env: MINISERVE_PRETTY_URLS=]
-p, --port <PORT>
Port to use
[env: MINISERVE_PORT=]
[default: 8080]
-i, --interfaces <INTERFACES>
Interface to listen on
[env: MINISERVE_INTERFACE=]
-a, --auth <AUTH>
Set authentication
Currently supported formats:
username:password, username:sha256:hash, username:sha512:hash
(e.g. joe:123, joe:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3)
[env: MINISERVE_AUTH=]
--auth-file <AUTH_FILE>
Read authentication values from a file
Example file content:
joe:123
bob:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
bill:
[env: MINISERVE_AUTH_FILE=]
--route-prefix <ROUTE_PREFIX>
Use a specific route prefix
[env: MINISERVE_ROUTE_PREFIX=]
--random-route
Generate a random 6-hexdigit route
[env: MINISERVE_RANDOM_ROUTE=]
--file-external-url <FILE_BASE_URL>
Optional external URL (e.g., 'http://external.example.com:8081') prepended to file links in listings.
Allows serving files from a different URL than the browsing instance. Useful for setups like:
one authenticated instance for browsing, linking files (via this option) to a second,
non-indexed (-I) instance for direct downloads. This obscures the full file list on
the download server, while users can still copy direct file URLs for sharing.
The external URL is put verbatim in front of the relative location of the file, including the protocol.
The user should take care this results in a valid URL, no further checks are being done.
[env: MINISERVE_FILE_EXTERNAL_URL=]
-P, --no-symlinks
Hide symlinks in listing and prevent them from being followed
[env: MINISERVE_NO_SYMLINKS=]
-H, --hidden
Show hidden files
[env: MINISERVE_HIDDEN=]
-S, --default-sorting-method <DEFAULT_SORTING_METHOD>
Default sorting method for file list
[env: MINISERVE_DEFAULT_SORTING_METHOD=]
[default: name]
Possible values:
- name: Sort by name
- size: Sort by size
- date: Sort by last modification date (natural sort: follows alphanumerical order)
-O, --default-sorting-order <DEFAULT_SORTING_ORDER>
Default sorting order for file list
[env: MINISERVE_DEFAULT_SORTING_ORDER=]
[default: desc]
Possible values:
- asc: Ascending order
- desc: Descending order
-c, --color-scheme <COLOR_SCHEME>
Default color scheme
[env: MINISERVE_COLOR_SCHEME=]
[default: squirrel]
[possible values: squirrel, archlinux, zenburn, monokai]
-d, --color-scheme-dark <COLOR_SCHEME_DARK>
Default color scheme
[env: MINISERVE_COLOR_SCHEME_DARK=]
[default: archlinux]
[possible values: squirrel, archlinux, zenburn, monokai]
-q, --qrcode
Enable QR code display
[env: MINISERVE_QRCODE=]
-u, --upload-files [<ALLOWED_UPLOAD_DIR>]
Enable file uploading (and optionally specify for which directory)
The provided path is not a physical file system path. Instead, it's relative to the serve dir. For
instance, if the serve dir is '/home/hello', set this to '/upload' to allow uploading to
'/home/hello/upload'. When specified via environment variable, a path always needs to be specified.
[env: MINISERVE_ALLOWED_UPLOAD_DIR=]
--web-upload-files-concurrency <WEB_UPLOAD_CONCURRENCY>
Configure amount of concurrent uploads when visiting the website. Must have upload-files option enabled for this setting to matter.
[env: MINISERVE_WEB_UPLOAD_CONCURRENCY=]
[default: 0]
-U, --mkdir
Enable creating directories
[env: MINISERVE_MKDIR_ENABLED=]
-m, --media-type <MEDIA_TYPE>
Specify uploadable media types
[env: MINISERVE_MEDIA_TYPE=]
[possible values: image, audio, video]
-M, --raw-media-type <MEDIA_TYPE_RAW>
Directly specify the uploadable media type expression
[env: MINISERVE_RAW_MEDIA_TYPE=]
-o, --on-duplicate-files <ON_DUPLICATE_FILES>
What to do if existing files with same name is present during file upload
If you enable renaming files, the renaming will occur by adding numerical suffix to the filename before the final extension. For example file.txt will be uploaded
as file-1.txt, the number will be increased until an available filename is found.
[env: MINISERVE_ON_DUPLICATE_FILES=]
[default: error]
[possible values: error, overwrite, rename]
-R, --rm-files [<ALLOWED_RM_DIR>]
Enable file and directory deletion (and optionally specify for which directory)
[env: MINISERVE_ALLOWED_RM_DIR=]
-r, --enable-tar
Enable uncompressed tar archive generation
[env: MINISERVE_ENABLE_TAR=]
-g, --enable-tar-gz
Enable gz-compressed tar archive generation
[env: MINISERVE_ENABLE_TAR_GZ=]
-z, --enable-zip
Enable zip archive generation
WARNING: Zipping large directories can result in out-of-memory exception because zip generation is
done in memory and cannot be sent on the fly
[env: MINISERVE_ENABLE_ZIP=]
-C, --compress-response
Compress response
WARNING: Enabling this option may slow down transfers due to CPU overhead, so it is disabled by
default.
Only enable this option if you know that your users have slow connections or if you want to
minimize your server's bandwidth usage.
[env: MINISERVE_COMPRESS_RESPONSE=]
-D, --dirs-first
List directories first
[env: MINISERVE_DIRS_FIRST=]
-t, --title <TITLE>
Shown instead of host in page title and heading
[env: MINISERVE_TITLE=]
--header <HEADER>
Inserts custom headers into the responses. Specify each header as a 'Header:Value' pair. This
parameter can be used multiple times to add multiple headers.
Example: --header "Header1:Value1" --header "Header2:Value2" (If a header is already set or
previously inserted, it will not be overwritten.)
[env: MINISERVE_HEADER=]
-l, --show-symlink-info
Visualize symlinks in directory listing
[env: MINISERVE_SHOW_SYMLINK_INFO=]
-F, --hide-version-footer
Hide version footer
[env: MINISERVE_HIDE_VERSION_FOOTER=]
--hide-theme-selector
Hide theme selector
[env: MINISERVE_HIDE_THEME_SELECTOR=]
-W, --show-wget-footer
If enabled, display a wget command to recursively download the current directory
[env: MINISERVE_SHOW_WGET_FOOTER=]
--print-completions <shell>
Generate completion file for a shell
[possible values: bash, elvish, fish, powershell, zsh]
--print-manpage
Generate man page
--tls-cert <TLS_CERT>
TLS certificate to use
[env: MINISERVE_TLS_CERT=]
--tls-key <TLS_KEY>
TLS private key to use
[env: MINISERVE_TLS_KEY=]
--readme
Enable README.md rendering in directories
[env: MINISERVE_README=]
-I, --disable-indexing
Disable indexing
This will prevent directory listings from being generated and return an error instead.
[env: MINISERVE_DISABLE_INDEXING=]
--enable-webdav
Enable read-only WebDAV support (PROPFIND requests)
Currently incompatible with -P|--no-symlinks (see
https://github.com/messense/dav-server-rs/issues/37)
[env: MINISERVE_ENABLE_WEBDAV=]
--log-color <LOG_COLOR>
Set the color style of the log output
"auto" (default) enables colors only when the output is a terminal. "always" always enables colors.
"never" always disables colors.
[env: MINISERVE_LOG_COLOR=]
[default: auto]
[possible values: auto, always, never]
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
```
## How to install
<a href="https://repology.org/project/miniserve/versions"><img align="right" src="https://repology.org/badge/vertical-allrepos/miniserve.svg" alt="Packaging status"></a>
**On Linux**: Download `miniserve-linux` from [the releases page](https://github.com/svenstaro/miniserve/releases) and run
chmod +x miniserve-linux
./miniserve-linux
Alternatively, if you are on **Arch Linux**, you can do
pacman -S miniserve
On [Termux](https://termux.com/)
pkg install miniserve
**On OSX**: Download `miniserve-osx` from [the releases page](https://github.com/svenstaro/miniserve/releases) and run
chmod +x miniserve-osx
./miniserve-osx
Alternatively install with [Homebrew](https://brew.sh/):
brew install miniserve
miniserve
**On Windows**: Download `miniserve-win.exe` from [the releases page](https://github.com/svenstaro/miniserve/releases) and run
miniserve-win.exe
Alternatively install with [Scoop](https://scoop.sh/):
scoop install miniserve
**With Cargo**: Make sure you have a recent version of Rust. Then you can run
cargo install --locked miniserve
miniserve
**With Docker:** Make sure the Docker daemon is running and then run
docker run -v /tmp:/tmp -p 8080:8080 --rm -it docker.io/svenstaro/miniserve /tmp
**With Podman:** Just run
podman run -v /tmp:/tmp -p 8080:8080 --rm -it docker.io/svenstaro/miniserve /tmp
**With Helm:** See [this third-party Helm chart](https://codeberg.org/wrenix/helm-charts/src/branch/main/miniserve) by @wrenix.
## Shell completions
If you'd like to make use of the built-in shell completion support, you need to run `miniserve
--print-completions <your-shell>` and put the completions in the correct place for your shell. A
few examples with common paths are provided below:
# For bash
miniserve --print-completions bash > ~/.local/share/bash-completion/completions/miniserve
# For zsh
miniserve --print-completions zsh > /usr/local/share/zsh/site-functions/_miniserve
# For fish
miniserve --print-completions fish > ~/.config/fish/completions/miniserve.fish
## systemd
A hardened systemd-compatible unit file can be found in `packaging/miniserve@.service`. You could
install this to `/etc/systemd/system/miniserve@.service` and start and enable `miniserve` as a
daemon on a specific serve path `/my/serve/path` like this:
systemctl enable --now miniserve@-my-serve-path
Keep in mind that you'll have to use `systemd-escape` to properly escape a path for this usage.
In case you want to customize the particular flags that miniserve launches with, you can use
systemctl edit miniserve@-my-serve-path
and set the `[Service]` part in the resulting `override.conf` file. For instance:
[Service]
ExecStart=
ExecStart=/usr/bin/miniserve --enable-tar --enable-zip --no-symlinks --verbose -i ::1 -p 1234 --title hello --color-scheme monokai --color-scheme-dark monokai -- %I
Make sure to leave the `%I` at the very end in place or the wrong path might be served.
Alternatively, you can configure the service via environment variables:
[Service]
Environment=MINISERVE_ENABLE_TAR=true
Environment=MINISERVE_ENABLE_ZIP=true
Environment="MINISERVE_TITLE=hello world"
...
You might additionally have to override `IPAddressAllow` and `IPAddressDeny` if you plan on making
miniserve directly available on a public interface.
## Binding behavior
For convenience reasons, miniserve will try to bind on all interfaces by default (if no `-i` is provided).
It will also do that if explicitly provided with `-i 0.0.0.0` or `-i ::`.
In all of the aforementioned cases, it will bind on both IPv4 and IPv6.
If provided with an explicit non-default interface, it will ONLY bind to that interface.
You can provide `-i` multiple times to bind to multiple interfaces at the same time.
## Why use this over alternatives?
- darkhttpd: Not easily available on Windows and it's not as easy as download-and-go.
- Python built-in webserver: Need to have Python installed, it's low performance, and also doesn't do correct MIME type handling in some cases.
- netcat: Not as convenient to use and sending directories is [somewhat involved](https://nakkaya.com/2009/04/15/using-netcat-for-file-transfers/).
## Releasing
This is mostly a note for me on how to release this thing:
- Make sure [CHANGELOG.md](./CHANGELOG.md) is up to date.
- `cargo release <version>`
- `cargo release --execute <version>`
- Releases will automatically be deployed by GitHub Actions.
- Update Arch package.
================================================
FILE: data/style.scss
================================================
@use "themes/archlinux" with ($generate_default: false);
@use "themes/ayu_dark" with ($generate_default: false);
@use "themes/monokai" with ($generate_default: false);
@use "themes/squirrel" with ($generate_default: false);
@use "themes/zenburn" with ($generate_default: false);
// theme colors can be found at the bottom
$themes: squirrel, archlinux, ayu_dark, monokai, zenburn;
html {
font-smoothing: antialiased;
text-rendering: optimizeLegibility;
width: 100%;
height: 100%;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: normal;
color: var(--text_color);
background: var(--background);
position: relative;
min-height: 100%;
}
.container {
padding: 1.5rem 5rem;
}
$upload_container_height: 18rem;
.upload_area {
display: block;
position: fixed;
bottom: 1rem;
right: -105%;
color: #ffffff;
box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3);
background: linear-gradient(135deg, #73a5ff, #5477f5);
padding: 0px;
margin: 0px;
opacity: 1; // Change this
transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
border-radius: 4px;
text-decoration: none;
min-width: 400px;
max-width: 600px;
z-index: 2147483647;
max-height: $upload_container_height;
overflow: hidden;
&.active {
right: 1rem;
}
#upload-toggle {
display: none;
transition: transform 0.3s ease;
cursor: pointer;
}
}
.upload_container {
max-height: $upload_container_height;
display: flex;
flex-direction: column;
}
.upload_header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: var(--upload_modal_header_background);
color: var(--upload_modal_header_color);
}
.upload_header svg {
width: 24px;
height: 24px;
}
.upload_action {
background-color: var(--upload_modal_sub_header_background);
color: var(--upload_modal_header_color);
padding: 0.25rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75em;
font-weight: 500;
}
.upload_cancel {
background: none;
border: none;
font-weight: 500;
cursor: pointer;
}
.upload_files {
padding: 0px;
margin: 0px;
flex: 1;
overflow-y: auto;
max-height: inherit;
}
.upload_file_list {
background-color: var(--upload_modal_file_item_background);
color: var(--upload_modal_file_item_color);
padding: 0px;
margin: 0px;
list-style: none;
list-style: none;
display: flex;
flex-direction: column;
align-items: stretch;
overflow-y: scroll;
}
.upload_file_container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1rem calc(1rem - 2px) 1rem;
}
.upload_file_action {
display: flex;
justify-content: right;
}
.file_progress_bar {
width: 0%;
border-top: 2px solid var(--progress_bar_background);
transition: width 0.25s ease;
&.cancelled {
border-color: var(--error_color);
}
&.failed {
border-color: var(--error_color);
}
&.complete {
border-color: var(--success_color);
}
}
.upload_file_text {
font-size: 0.80em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.cancelled {
text-decoration: line-through;
}
&.failed {
text-decoration: line-through;
}
}
.file_cancel_upload {
padding-left: 0.25rem;
font-size: 1em;
cursor: pointer;
border: none;
background: inherit;
font-size: 1em;
color: var(--error_color);
&.complete {
color: var(--success_color);
}
}
.title {
word-break: break-all;
}
.title a {
font-weight: bold;
color: var(--directory_link_color);
}
.footer {
text-align: center;
padding-top: 1.5rem;
font-size: 0.7em;
color: var(--footer_color);
.downloadDirectory {
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
.cmd {
margin: 0;
padding-left: 5px;
line-height: 13px;
font-family: monospace;
}
}
}
a {
text-decoration: none;
}
a.root,
a.root:visited,
.root-chevron {
font-weight: bold;
color: var(--root_link_color);
}
a:hover {
text-decoration: underline;
}
a.directory {
font-weight: bold;
color: var(--directory_link_color);
&:visited {
color: var(--directory_link_color_visited);
}
}
a.file,
.error-back {
color: var(--file_link_color);
&:visited {
color: var(--file_link_color_visited);
}
}
a.file::before {
content: "📃 ";
}
a.directory::before {
content: "📁 ";
}
a.directory:hover {
color: var(--directory_link_color);
}
a.file:hover {
color: var(--file_link_color);
}
a.symlink,
a.symlink:visited {
font-weight: bold;
color: var(--symlink_color);
}
.symlink-symbol::after {
content: "⇢";
display: inline-block;
border: 1px solid;
margin-left: 0.5rem;
margin-right: 0.5rem;
border-radius: 0.2rem;
padding: 0 0.1rem;
}
nav {
padding: 0 5rem;
display: flex;
justify-content: flex-end;
}
nav>div {
position: relative;
margin-left: 0.5rem;
}
nav p {
padding: 0.5rem 1rem;
width: 8rem;
text-align: center;
background: var(--switch_theme_background);
color: var(--change_theme_link_color);
}
nav p+* {
display: none;
position: absolute;
left: 0;
right: 0;
top: 100%;
}
@keyframes show {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
nav>div:hover p {
cursor: pointer;
color: var(--switch_theme_link_color);
}
nav>div:hover p+* {
display: block;
border-top: 1px solid var(--switch_theme_border);
}
nav .qrcode {
padding: 0.5rem;
background: var(--switch_theme_background);
}
nav .qrcode svg {
display: block;
}
nav .theme {
margin: 0;
padding: 0;
list-style-type: none;
}
nav .theme li {
width: 100%;
background: var(--switch_theme_background);
}
nav .theme li a {
display: block;
width: 100%;
padding: 0.5rem 0;
text-align: center;
color: var(--switch_theme_link_color);
}
nav .theme li a:visited {
color: var(--switch_theme_link_color);
}
nav .theme li a:hover {
text-decoration: underline;
color: var(--change_theme_link_color_hover);
}
p {
margin: 0;
padding: 0;
}
h1 {
margin-top: 0;
font-size: 1.5rem;
}
table {
margin-top: 2rem;
width: 100%;
border: 0;
table-layout: auto;
background: var(--table_background);
}
table thead tr th,
table tbody tr td {
padding: 0.5625rem 0.625rem;
font-size: 0.875rem;
color: var(--table_text_color);
text-align: left;
line-height: 1.125rem;
}
table tbody tr td p {
display: flex;
align-items: center;
}
table thead tr th {
padding: 0.5rem 0.625rem 0.625rem;
font-weight: bold;
color: var(--table_header_text_color);
}
table thead th.size {
width: 6em;
}
table thead th.date {
width: 21em;
}
table thead th.actions {
width: 4em;
}
table tbody tr:nth-child(odd) {
background: var(--odd_row_background);
}
table tbody tr:nth-child(even) {
background: var(--even_row_background);
}
table thead {
background: var(--table_header_background);
}
table tbody tr:hover {
background: var(--active_row_color);
}
td.size-cell {
text-align: right;
}
td.date-cell {
display: flex;
justify-content: space-between;
}
tr.entry-type-directory .size-cell {
&:not([data-size])::after {
content: "xxxx KiB"; // hidden placeholder to get text-like width and height
color: transparent;
animation:
shimmer 2.5s ease-in-out reverse infinite,
bump 1.25s ease-out alternate-reverse infinite;
background:
linear-gradient(to left, #e6e6e633 0%, #e6e6e633 20%, #e7e7e744 40%, #ececec70 45%, #e7e7e755 60%, #e6e6e633 80%, #e6e6e633 100%),
linear-gradient(to bottom, #ffffff00 40%, #ffffff18 60%, #ffffff50 80%);
background-size: 500% 160%;
border-radius: 4px;
}
&[data-size]::after {
content: attr(data-size);
}
@keyframes bump {
from { background-position-y: 30%; }
to { background-position-y: 0; }
}
@keyframes shimmer {
from { background-position-x: 0; }
to { background-position-x: 100%; }
}
}
td.actions-cell button {
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
border: none;
}
td.actions-cell .rm_form {
display: flex;
place-content: center;
}
td.actions-cell .rm_form button {
background: var(--rm_button_background);
color: var(--rm_button_text_color);
}
.history {
color: var(--date_text_color);
}
span.size,
span.mobile-info.history {
white-space: nowrap;
border-radius: 1rem;
background: var(--size_background_color);
padding: 0 0.25rem;
margin: 0 0.25rem;
font-size: 0.7rem;
color: var(--size_text_color);
}
.mobile-info {
display: none;
}
.mobile-info a,
.mobile-info a:visited {
color: var(--size_text_color);
}
th a,
th a:visited,
.chevron {
color: var(--table_header_text_color);
}
.chevron,
.root-chevron {
margin-right: 0.5rem;
font-size: 1.2em;
font-weight: bold;
}
th span.active a,
th span.active span {
color: var(--table_header_active_color);
}
.back {
position: fixed;
width: 3rem;
height: 3rem;
align-items: center;
justify-content: center;
bottom: 3rem;
right: 3.75rem;
background: var(--back_button_background);
border-radius: 100%;
box-shadow: 0 0 8px -4px #888888;
color: var(--back_button_link_color);
display: none;
padding: 0;
font-size: 2em;
}
.back:visited {
color: var(--back_button_link_color);
}
.back:hover {
color: var(--back_button_link_color_hover);
font-weight: bold;
text-decoration: none;
background: var(--back_button_background_hover);
}
//
// Toolbar & tools inside the bar.
//
.toolbar {
--tool-gap-between: 0.5rem;
--tool-spacing-inside: 0.5rem;
}
.toolbar .tool_row {
margin-top: 1rem;
display: flex;
gap: var(--tool-gap-between);
flex-direction: column;
@media (min-width: 760px) {
flex-direction: row;
}
}
// Upload tools has 4 configurations
//
// a) Upload enabled,
// b) Upload enabled, mkdir enabled
// c) Upload enabled, paste enabled
// d) Upload enabled, mkdir enabled, paste enabled
//
// At larger screen sizes, for a and b, we render the tools horizontal, as at
// min-content width. For c and d, we render upload (and mkdir) at min-content
// width, stacked vertically and let paste fill the remaining space.
//
// At smaller screen sizes we render any available elememnts in a full-width
// stack.
//
// We render via grid not flex as it affords us better control of the
// stack/unstack in b.
@media (min-width: 760px) {
.toolbar .tool_row.upload_tools {
display: grid;
grid-template-columns: min-content min-content;
.tool[data-tool="upload"] {
grid-column: 1 / 2;
grid-row: 1 / 2;
}
.tool[data-tool="mkdir"] {
grid-column: 2 / 3;
grid-row: 1 / 2;
}
&:has([data-tool="pastebin"]) {
grid-template-columns: min-content auto;
.tool[data-tool="upload"] {
grid-column: 1 / 2;
grid-row: 1 / 2;
}
.tool[data-tool="mkdir"] {
grid-column: 1 / 2;
grid-row: 2 / 3;
}
.tool[data-tool="pastebin"] {
grid-column: 2 / 3;
grid-row: 1 / 3;
}
}
}
}
.toolbar form.tool {
padding: 1rem;
border: 1px solid var(--upload_form_border_color);
background: var(--upload_form_background);
& > * {
margin-bottom: var(--tool-spacing-inside);
&:last-child {
margin-bottom: 0;
}
}
p {
font-size: 0.8rem;
color: var(--upload_text_color);
}
input {
padding: 0.5rem;
margin-right: 0.2rem;
border-radius: 0.2rem;
border: 0;
display: inline;
}
button {
background: var(--upload_button_background);
padding: 0.5rem;
border-radius: 0.2rem;
color: var(--upload_button_text_color);
border: none;
min-width: max-content;
}
div {
display: flex;
align-items: baseline;
justify-content: space-between;
}
}
//
// Toolbar tool specific styling
//
.toolbar .tool[data-tool="download"] {
padding: 0.125rem;
display: flex;
flex-direction: row;
align-items: flex-start;
flex-wrap: wrap;
a {
background: var(--download_button_background);
padding: 0.5rem;
border-radius: 0.2rem;
}
a, a:visited {
color: var(--download_button_link_color);
}
a:hover {
background: var(--download_button_background_hover);
color: var(--download_button_link_color_hover);
}
a:not(:last-of-type) {
margin-right: 1rem;
}
}
.toolbar .tool[data-tool="pastebin"] {
textarea {
width: 100%;
resize: vertical;
min-height: 4rem;
padding: 0.5rem;
border-radius: 0.2rem;
border: 0;
}
}
.form,
.drag-form {
display: none;
background: var(--drag_background);
position: absolute;
border: 0.5rem dashed var(--drag_border_color);
width: calc(100% - 1rem);
height: calc(100% - 1rem);
text-align: center;
z-index: 2;
margin: 0 5px;
}
.form_title {
position: fixed;
color: var(--drag_text_color);
top: 50%;
width: 100%;
text-align: center;
}
.error {
margin: 2rem;
}
.error p {
margin: 1rem 0;
font-size: 0.9rem;
word-break: break-all;
}
.error p:first-of-type {
font-size: 1.25rem;
color: var(--error_color);
margin-bottom: 2rem;
}
.error p:nth-of-type(2) {
font-weight: bold;
}
.error-nav {
margin-top: 4rem;
}
@media (max-width: 760px) {
nav {
padding: 0 2.5rem;
}
.container {
padding: 1.5rem 2.5rem;
}
h1 {
font-size: 1.4em;
}
td:not(:nth-child(1)),
th:not(:nth-child(1)) {
display: none;
}
.mobile-info {
display: inline-flex;
align-items: center;
margin: auto;
}
table tbody tr td {
padding-top: 0;
padding-bottom: 0;
}
a {
padding: 0.5625rem 0;
}
a.directory {
flex-grow: 1;
}
.file-entry {
align-items: center;
}
a.root,
a.file {
flex-grow: 1;
}
.back {
display: flex;
}
.back {
right: 1.5rem;
}
$upload_container_height_mobile: 100vh;
.upload_area {
width: 100%;
height: 136px;
max-height: $upload_container_height_mobile;
max-width: unset;
min-width: unset;
bottom: 0;
transition: height 0.3s ease;
&.active {
right: 0;
left: 0;
}
#upload-toggle {
display: block;
transition: transform 0.3s ease;
}
}
.upload_container {
max-height: $upload_container_height_mobile;
}
}
@media (max-width: 600px) {
h1 {
font-size: 1.375em;
}
nav {
padding: 0 1rem;
}
.container {
padding: 1rem;
}
}
@media (max-width: 400px) {
nav {
padding: 0 0.5rem;
}
.container {
padding: 0.5rem;
}
h1 {
font-size: 1.375em;
}
.back {
right: 1.5rem;
}
}
@mixin theme($name) {
@if $name ==squirrel {
@include squirrel.theme();
}
@else if $name ==archlinux {
@include archlinux.theme();
}
@else if $name ==ayu_dark {
@include ayu_dark.theme();
}
@else if $name ==monokai {
@include monokai.theme();
}
@else if $name ==zenburn {
@include zenburn.theme();
}
@else {
@error "Invalid theme: #{$name}";
}
}
%active_theme_link {
font-weight: bold;
color: var(--switch_theme_active);
}
// when no specific theme is applied, highlight the `default` theme button in
// the theme menu
body:not([data-theme]) nav .theme li[data-theme="default"] a {
@extend %active_theme_link;
}
@each $theme in $themes {
body[data-theme="#{$theme}"] {
@include theme($theme);
// highlight the currently active theme in the theme selection menu
nav .theme li[data-theme="#{$theme}"] a {
@extend %active_theme_link;
}
}
}
================================================
FILE: data/themes/archlinux.scss
================================================
$generate_default: true !default;
@mixin theme {
--background: #383c4a;
--text_color: #fefefe;
--directory_link_color: #03a9f4;
--directory_link_color_visited: #0388f4;
--file_link_color: #ea95ff;
--file_link_color_visited: #a595ff;
--symlink_color: #66d9ef;
--table_background: #353946;
--table_text_color: #eeeeee;
--table_header_background: #5294e2;
--table_header_text_color: #eeeeee;
--table_header_active_color: #ffffff;
--active_row_color: #5194e259;
--odd_row_background: #404552;
--even_row_background: #4b5162;
--root_link_color: #abb2bb;
--download_button_background: #ea95ff;
--download_button_background_hover: #eea7ff;
--download_button_link_color: #ffffff;
--download_button_link_color_hover: #ffffff;
--back_button_background: #ea95ff;
--back_button_background_hover: #ea95ff;
--back_button_link_color: #ffffff;
--back_button_link_color_hover: #ffffff;
--date_text_color: #9ebbdc;
--at_color: #9ebbdc;
--switch_theme_background: #4b5162;
--switch_theme_link_color: #fefefe;
--switch_theme_active: #ea95ff;
--switch_theme_border: #6a728a;
--change_theme_link_color: #fefefe;
--change_theme_link_color_hover: #fefefe;
--upload_text_color: #fefefe;
--upload_form_border_color: #353946;
--upload_form_background: #4b5162;
--upload_button_background: #ea95ff;
--upload_button_text_color: #ffffff;
--rm_button_background: #ea95ff;
--rm_button_text_color: #ffffff;
--drag_background: #3333338f;
--drag_border_color: #fefefe;
--drag_text_color: #fefefe;
--size_background_color: #5294e2;
--size_text_color: #fefefe;
--error_color: #e44b4b;
--footer_color: #8eabcc;
--success_color: #52e28a;
--upload_modal_header_background: #5294e2;
--upload_modal_header_color: #eeeeee;
--upload_modal_sub_header_background: #35547a;
--upload_modal_file_item_background: #eeeeee;
--upload_modal_file_item_color: #111111;
--upload_modal_file_upload_complete_background: #cccccc;
--progress_bar_background: #5294e2;
};
@if $generate_default {
body {
@include theme;
}
}
================================================
FILE: data/themes/ayu_dark.scss
================================================
$generate_default: true !default;
@mixin theme {
--background: #0d1017;
--text_color: #bfbdb6;
--directory_link_color: #e6b450;
--directory_link_color_visited: #c99a3a;
--file_link_color: #aad94c;
--file_link_color_visited: #7fb032;
--symlink_color: #73b8ff;
--table_background: #131721;
--table_text_color: #bfbdb6;
--table_header_background: #1c2130;
--table_header_text_color: #bfbdb6;
--table_header_active_color: #e6b450;
--active_row_color: #e6b45030;
--odd_row_background: #111720;
--even_row_background: #0f1219;
--root_link_color: #39bae6;
--download_button_background: #e6b450;
--download_button_background_hover: #d9a53e;
--download_button_link_color: #0d1017;
--download_button_link_color_hover: #0d1017;
--back_button_background: #e6b450;
--back_button_background_hover: #d9a53e;
--back_button_link_color: #0d1017;
--back_button_link_color_hover: #0d1017;
--date_text_color: #39bae6;
--at_color: #39bae6;
--switch_theme_background: #131721;
--switch_theme_link_color: #bfbdb6;
--switch_theme_active: #e6b450;
--switch_theme_border: #1c2130;
--change_theme_link_color: #bfbdb6;
--change_theme_link_color_hover: #e6b450;
--upload_text_color: #bfbdb6;
--upload_form_border_color: #1c2130;
--upload_form_background: #131721;
--upload_button_background: #e6b450;
--upload_button_text_color: #0d1017;
--rm_button_background: #f26d78;
--rm_button_text_color: #0d1017;
--drag_background: #0d10178f;
--drag_border_color: #e6b450;
--drag_text_color: #bfbdb6;
--size_background_color: #1c2130;
--size_text_color: #bfbdb6;
--error_color: #d95757;
--footer_color: #39bae6;
--success_color: #7fd962;
--upload_modal_header_background: #1c2130;
--upload_modal_header_color: #bfbdb6;
--upload_modal_sub_header_background: #0d1017;
--upload_modal_file_item_background: #bfbdb6;
--upload_modal_file_item_color: #0d1017;
--upload_modal_file_upload_complete_background: #636a72;
--progress_bar_background: #e6b450;
};
@if $generate_default {
body {
@include theme;
}
}
================================================
FILE: data/themes/monokai.scss
================================================
$generate_default: true !default;
@mixin theme {
--background: #272822;
--text_color: #f8f8f2;
--directory_link_color: #f92672;
--directory_link_color_visited: #bc39a7;
--file_link_color: #a6e22e;
--file_link_color_visited: #4cb936;
--symlink_color: #29b8db;
--table_background: #3b3a32;
--table_text_color: #f8f8f0;
--table_header_background: #75715e;
--table_header_text_color: #f8f8f2;
--table_header_active_color: #e6db74;
--active_row_color: #ae81fe3d;
--odd_row_background: #3e3d32;
--even_row_background: #49483e;
--root_link_color: #66d9ef;
--download_button_background: #ae81ff;
--download_button_background_hover: #c6a6ff;
--download_button_link_color: #f8f8f0;
--download_button_link_color_hover: #f8f8f0;
--back_button_background: #ae81ff;
--back_button_background_hover: #ae81ff;
--back_button_link_color: #f8f8f0;
--back_button_link_color_hover: #f8f8f0;
--date_text_color: #66d9ef;
--at_color: #66d9ef;
--switch_theme_background: #3b3a32;
--switch_theme_link_color: #f8f8f2;
--switch_theme_active: #a6e22e;
--switch_theme_border: #49483e;
--change_theme_link_color: #f8f8f2;
--change_theme_link_color_hover: #f8f8f2;
--upload_text_color: #f8f8f2;
--upload_form_border_color: #3b3a32;
--upload_form_background: #49483e;
--upload_button_background: #ae81ff;
--upload_button_text_color: #f8f8f0;
--rm_button_background: #ae81ff;
--rm_button_text_color: #f8f8f0;
--drag_background: #3333338f;
--drag_border_color: #f8f8f2;
--drag_text_color: #f8f8f2;
--size_background_color: #75715e;
--size_text_color: #f8f8f2;
--error_color: #d02929;
--footer_color: #56c9df;
--success_color: #52e28a;
--upload_modal_header_background: #75715e;
--upload_modal_header_color: #eeeeee;
--upload_modal_sub_header_background: #323129;
--upload_modal_file_item_background: #eeeeee;
--upload_modal_file_item_color: #111111;
--upload_modal_file_upload_complete_background: #cccccc;
--progress_bar_background: #5294e2;
};
@if $generate_default {
body {
@include theme;
}
}
================================================
FILE: data/themes/squirrel.scss
================================================
$generate_default: true !default;
@mixin theme {
--background: #ffffff;
--text_color: #323232;
--directory_link_color: #d02474;
--directory_link_color_visited: #9b1985;
--file_link_color: #0086b3;
--file_link_color_visited: #974ec2;
--symlink_color: #ADD8E6;
--table_background: #ffffff;
--table_text_color: #323232;
--table_header_background: #323232;
--table_header_text_color: #f5f5f5;
--table_header_active_color: #ffffff;
--active_row_color: #f6f8fa;
--odd_row_background: #fbfbfb;
--even_row_background: #f2f2f2;
--root_link_color: #323232;
--download_button_background: #d02474;
--download_button_background_hover: #f52d8a;
--download_button_link_color: #ffffff;
--download_button_link_color_hover: #ffffff;
--back_button_background: #d02474;
--back_button_background_hover: #d02474;
--back_button_link_color: #ffffff;
--back_button_link_color_hover: #ffffff;
--date_text_color: #797979;
--at_color: #797979;
--switch_theme_background: #323232;
--switch_theme_link_color: #f5f5f5;
--switch_theme_active: #d02474;
--switch_theme_border: #49483e;
--change_theme_link_color: #f5f5f5;
--change_theme_link_color_hover: #f5f5f5;
--upload_text_color: #323232;
--upload_form_border_color: #d2d2d2;
--upload_form_background: #f2f2f2;
--upload_button_background: #d02474;
--upload_button_text_color: #ffffff;
--rm_button_background: #d02474;
--rm_button_text_color: #ffffff;
--drag_background: #3333338f;
--drag_border_color: #ffffff;
--drag_text_color: #ffffff;
--size_background_color: #323232;
--size_text_color: #ffffff;
--error_color: #d02424;
--footer_color: #898989;
--success_color: #52e28a;
--upload_modal_header_background: #323232;
--upload_modal_header_color: #eeeeee;
--upload_modal_sub_header_background: #171616;
--upload_modal_file_item_background: #eeeeee;
--upload_modal_file_item_color: #111111;
--upload_modal_file_upload_complete_background: #cccccc;
--progress_bar_background: #5294e2;
};
@if $generate_default {
body {
@include theme;
}
}
================================================
FILE: data/themes/zenburn.scss
================================================
$generate_default: true !default;
@mixin theme {
--background: #3f3f3f;
--text_color: #efefef;
--directory_link_color: #f0dfaf;
--directory_link_color_visited: #ebc390;
--file_link_color: #87d6d5;
--file_link_color_visited: #a7b9ec;
--symlink_color: #11a8cd;
--table_background: #4a4949;
--table_text_color: #efefef;
--table_header_background: #7f9f7f;
--table_header_text_color: #efefef;
--table_header_active_color: #efef8f;
--active_row_color: #7e9f7f9c;
--odd_row_background: #777777;
--even_row_background: #5a5a5a;
--root_link_color: #dca3a3;
--download_button_background: #cc9393;
--download_button_background_hover: #dca3a3;
--download_button_link_color: #efefef;
--download_button_link_color_hover: #efefef;
--back_button_background: #cc9393;
--back_button_background_hover: #cc9393;
--back_button_link_color: #efefef;
--back_button_link_color_hover: #efefef;
--date_text_color: #cfbfaf;
--at_color: #cfbfaf;
--switch_theme_background: #4a4949;
--switch_theme_link_color: #efefef;
--switch_theme_active: #efef8f;
--switch_theme_border: #5a5a5a;
--change_theme_link_color: #efefef;
--change_theme_link_color_hover: #efefef;
--upload_text_color: #efefef;
--upload_form_border_color: #4a4949;
--upload_form_background: #777777;
--upload_button_background: #cc9393;
--upload_button_text_color: #efefef;
--rm_button_background: #cc9393;
--rm_button_text_color: #efefef;
--drag_background: #3333338f;
--drag_border_color: #efefef;
--drag_text_color: #efefef;
--size_background_color: #7f9f7f;
--size_text_color: #efefef;
--error_color: #d06565;
--footer_color: #bfaf9f;
--success_color: #52e28a;
--upload_modal_header_background: #7f9f7f;
--upload_modal_header_color: #eeeeee;
--upload_modal_sub_header_background: #404e40;
--upload_modal_file_item_background: #eeeeee;
--upload_modal_file_item_color: #111111;
--upload_modal_file_upload_complete_background: #cccccc;
--progress_bar_background: #5294e2;
};
@if $generate_default {
body {
@include theme;
}
}
================================================
FILE: packaging/miniserve@.service
================================================
[Unit]
Description=miniserve for %i
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/bin/miniserve -- %I
IPAccounting=yes
IPAddressAllow=localhost
IPAddressDeny=any
DynamicUser=yes
PrivateTmp=yes
PrivateUsers=yes
PrivateDevices=yes
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=yes
ProtectClock=yes
ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectKernelModules=yes
ProtectKernelTunables=yes
ProtectProc=invisible
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_DAC_READ_SEARCH
[Install]
WantedBy=multi-user.target
================================================
FILE: release.toml
================================================
sign-commit = true
sign-tag = true
pre-release-replacements = [
{file="CHANGELOG.md", search="Unreleased", replace="{{version}}"},
{file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1},
{file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}"},
{file="CHANGELOG.md", search="<!-- next-header -->", replace="<!-- next-header -->\n\n## [Unreleased] - ReleaseDate"},
{file="CHANGELOG.md", search="<!-- next-url -->", replace="<!-- next-url -->\n[Unreleased]: https://github.com/svenstaro/miniserve/compare/{{tag_name}}...HEAD", exactly=1},
]
# Get rid of the default cargo-release "chore: " prefix in messages as we don't
# use semantic commits in this repository.
pre-release-commit-message = "Release {{crate_name}} version {{version}}"
tag-message = "Release {{crate_name}} version {{version}}"
================================================
FILE: rustfmt.toml
================================================
# This empty config file ensures the default formatter settings are enforced for
# all contributors, regardless of their custom global settings.
================================================
FILE: src/archive.rs
================================================
use std::fs::File;
use std::io::{Cursor, Read, Write};
use std::path::{Path, PathBuf};
use libflate::gzip::Encoder;
use serde::Deserialize;
use strum::{Display, EnumIter, EnumString};
use tar::Builder;
use zip::{ZipWriter, write};
use crate::errors::RuntimeError;
/// Available archive methods
#[derive(Deserialize, Clone, Copy, EnumIter, EnumString, Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ArchiveMethod {
/// Gzipped tarball
TarGz,
/// Regular tarball
Tar,
/// Regular zip
Zip,
}
impl ArchiveMethod {
pub fn extension(self) -> String {
match self {
Self::TarGz => "tar.gz",
Self::Tar => "tar",
Self::Zip => "zip",
}
.to_string()
}
pub fn content_type(self) -> String {
match self {
Self::TarGz => "application/gzip",
Self::Tar => "application/tar",
Self::Zip => "application/zip",
}
.to_string()
}
pub fn is_enabled(self, tar_enabled: bool, tar_gz_enabled: bool, zip_enabled: bool) -> bool {
match self {
Self::TarGz => tar_gz_enabled,
Self::Tar => tar_enabled,
Self::Zip => zip_enabled,
}
}
/// Make an archive out of the given directory, and write the output to the given writer.
///
/// Recursively includes all files and subdirectories.
///
/// If `skip_symlinks` is `true`, symlinks fill not be followed and will just be ignored.
pub fn create_archive<T, W>(
self,
dir: T,
skip_symlinks: bool,
out: W,
) -> Result<(), RuntimeError>
where
T: AsRef<Path>,
W: std::io::Write,
{
let dir = dir.as_ref();
match self {
Self::TarGz => tar_gz(dir, skip_symlinks, out),
Self::Tar => tar_dir(dir, skip_symlinks, out),
Self::Zip => zip_dir(dir, skip_symlinks, out),
}
}
}
/// Write a gzipped tarball of `dir` in `out`.
fn tar_gz<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), RuntimeError>
where
W: std::io::Write,
{
let mut out = Encoder::new(out).map_err(|e| RuntimeError::IoError("GZIP".to_string(), e))?;
tar_dir(dir, skip_symlinks, &mut out)?;
out.finish()
.into_result()
.map_err(|e| RuntimeError::IoError("GZIP finish".to_string(), e))?;
Ok(())
}
/// Write a tarball of `dir` in `out`.
///
/// The target directory will be saved as a top-level directory in the archive.
///
/// For example, consider this directory structure:
///
/// ```ignore
/// a
/// └── b
/// └── c
/// ├── e
/// ├── f
/// └── g
/// ```
///
/// Making a tarball out of `"a/b/c"` will result in this archive content:
///
/// ```ignore
/// c
/// ├── e
/// ├── f
/// └── g
/// ```
fn tar_dir<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), RuntimeError>
where
W: std::io::Write,
{
let inner_folder = dir.file_name().ok_or_else(|| {
RuntimeError::InvalidPathError("Directory name terminates in \"..\"".to_string())
})?;
let directory = inner_folder.to_str().ok_or_else(|| {
RuntimeError::InvalidPathError(
"Directory name contains invalid UTF-8 characters".to_string(),
)
})?;
tar(dir, directory.to_string(), skip_symlinks, out)
.map_err(|e| RuntimeError::ArchiveCreationError("tarball".to_string(), Box::new(e)))
}
/// Writes a tarball of `dir` in `out`.
///
/// The content of `src_dir` will be saved in the archive as a folder named `inner_folder`.
fn tar<W>(
src_dir: &Path,
inner_folder: String,
skip_symlinks: bool,
out: W,
) -> Result<(), RuntimeError>
where
W: std::io::Write,
{
let mut tar_builder = Builder::new(out);
tar_builder.follow_symlinks(!skip_symlinks);
// Recursively adds the content of src_dir into the archive stream
tar_builder
.append_dir_all(inner_folder, src_dir)
.map_err(|e| {
RuntimeError::IoError(
format!(
"Failed to append the content of '{}' to the TAR archive",
src_dir.to_str().unwrap_or("file")
),
e,
)
})?;
// Finish the archive
tar_builder.into_inner().map_err(|e| {
RuntimeError::IoError("Failed to finish writing the TAR archive".to_string(), e)
})?;
Ok(())
}
/// Write a zip of `dir` in `out`.
///
/// The target directory will be saved as a top-level directory in the archive.
///
/// For example, consider this directory structure:
///
/// ```ignore
/// a
/// └── b
/// └── c
/// ├── e
/// ├── f
/// └── g
/// ```
///
/// Making a zip out of `"a/b/c"` will result in this archive content:
///
/// ```ignore
/// c
/// ├── e
/// ├── f
/// └── g
/// ```
fn create_zip_from_directory<W>(
out: W,
directory: &Path,
skip_symlinks: bool,
) -> Result<(), RuntimeError>
where
W: std::io::Write + std::io::Seek,
{
let options =
write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
let mut paths_queue: Vec<PathBuf> = vec![directory.to_path_buf()];
let zip_root_folder_name = directory.file_name().ok_or_else(|| {
RuntimeError::InvalidPathError("Directory name terminates in \"..\"".to_string())
})?;
let mut zip_writer = ZipWriter::new(out);
let mut buffer = Vec::new();
while !paths_queue.is_empty() {
let next = paths_queue.pop().ok_or_else(|| {
RuntimeError::ArchiveCreationDetailError("Could not get path from queue".to_string())
})?;
let current_dir = next.as_path();
let directory_entry_iterator = std::fs::read_dir(current_dir)
.map_err(|e| RuntimeError::IoError("Could not read directory".to_string(), e))?;
let zip_directory = Path::new(zip_root_folder_name).join(
current_dir.strip_prefix(directory).map_err(|_| {
RuntimeError::ArchiveCreationDetailError(
"Could not append base directory".to_string(),
)
})?,
);
for entry in directory_entry_iterator {
let entry_path = entry
.ok()
.ok_or_else(|| {
RuntimeError::InvalidPathError(
"Directory name terminates in \"..\"".to_string(),
)
})?
.path();
let entry_metadata = std::fs::metadata(entry_path.clone()).map_err(|e| {
RuntimeError::IoError(
format!(
"Could not get file metadata of '{}'",
entry_path.to_string_lossy()
)
.to_string(),
e,
)
})?;
if entry_metadata.file_type().is_symlink() && skip_symlinks {
continue;
}
let current_entry_name = entry_path.file_name().ok_or_else(|| {
RuntimeError::InvalidPathError("Invalid file or directory name".to_string())
})?;
// To let every software correctly parse the file structure in ZIP files that are produced
// on any platform (esp. Windows), always use forward slashes. The documentation:
// https://users.cs.jmu.edu/buchhofp/forensics/formats/pkzip.html
let relative_path = if cfg!(windows) {
let branch = zip_directory
.as_os_str()
.to_string_lossy()
.trim_end_matches(r"\") // every branch ends with two backslashes "\\".
.replace(r"\", "/"); // every branch uses backslash "\" as path separators.
let leaf = current_entry_name.to_string_lossy();
format!("{branch}/{leaf}") // construct a Unix-style path in the simplest way.
} else {
zip_directory
.join(current_entry_name)
.into_os_string()
.to_string_lossy()
.into_owned()
};
if entry_metadata.is_file() {
let mut f = File::open(&entry_path)
.map_err(|e| RuntimeError::IoError("Could not open file".to_string(), e))?;
f.read_to_end(&mut buffer).map_err(|e| {
RuntimeError::IoError("Could not read from file".to_string(), e)
})?;
zip_writer.start_file(relative_path, options).map_err(|_| {
RuntimeError::ArchiveCreationDetailError(
"Could not add file path to ZIP".to_string(),
)
})?;
zip_writer.write(buffer.as_ref()).map_err(|_| {
RuntimeError::ArchiveCreationDetailError(
"Could not write file to ZIP".to_string(),
)
})?;
buffer.clear();
} else if entry_metadata.is_dir() {
zip_writer
.add_directory(relative_path, options)
.map_err(|_| {
RuntimeError::ArchiveCreationDetailError(
"Could not add directory path to ZIP".to_string(),
)
})?;
paths_queue.push(entry_path.clone());
}
}
}
zip_writer.finish().map_err(|_| {
RuntimeError::ArchiveCreationDetailError("Could not finish writing ZIP archive".to_string())
})?;
Ok(())
}
/// Writes a zip of `dir` in `out`.
///
/// The content of `src_dir` will be saved in the archive as the folder named .
fn zip_data<W>(src_dir: &Path, skip_symlinks: bool, mut out: W) -> Result<(), RuntimeError>
where
W: std::io::Write,
{
let mut data = Vec::new();
let memory_file = Cursor::new(&mut data);
create_zip_from_directory(memory_file, src_dir, skip_symlinks).map_err(|e| {
RuntimeError::ArchiveCreationError(
"Failed to create the ZIP archive".to_string(),
Box::new(e),
)
})?;
out.write_all(data.as_mut_slice())
.map_err(|e| RuntimeError::IoError("Failed to write the ZIP archive".to_string(), e))?;
Ok(())
}
fn zip_dir<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), RuntimeError>
where
W: std::io::Write,
{
let inner_folder = dir.file_name().ok_or_else(|| {
RuntimeError::InvalidPathError("Directory name terminates in \"..\"".to_string())
})?;
inner_folder.to_str().ok_or_else(|| {
RuntimeError::InvalidPathError(
"Directory name contains invalid UTF-8 characters".to_string(),
)
})?;
zip_data(dir, skip_symlinks, out)
.map_err(|e| RuntimeError::ArchiveCreationError("zip".to_string(), Box::new(e)))
}
================================================
FILE: src/args.rs
================================================
use std::fmt::Display;
use std::net::IpAddr;
use std::path::PathBuf;
use actix_web::http::header::{HeaderMap, HeaderName, HeaderValue};
use clap::{Parser, ValueEnum, ValueHint};
use crate::auth;
use crate::listing::{SortingMethod, SortingOrder};
use crate::renderer::ThemeSlug;
#[derive(ValueEnum, Clone)]
pub enum MediaType {
Image,
Audio,
Video,
}
#[derive(Debug, ValueEnum, Clone, Default, Copy)]
pub enum DuplicateFile {
#[default]
Error,
Overwrite,
Rename,
}
#[derive(ValueEnum, Clone)]
pub enum SizeDisplay {
Human,
Exact,
}
impl Display for SizeDisplay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SizeDisplay::Human => write!(f, "human"),
SizeDisplay::Exact => write!(f, "exact"),
}
}
}
#[derive(Debug, ValueEnum, Clone, Copy, Default)]
pub enum LogColor {
#[default]
Auto,
Always,
Never,
}
#[derive(Parser)]
#[command(name = "miniserve", author, about, version)]
pub struct CliArgs {
/// Be verbose, includes emitting access logs
#[arg(short = 'v', long = "verbose", env = "MINISERVE_VERBOSE")]
pub verbose: bool,
/// Which path to serve
#[arg(value_hint = ValueHint::AnyPath, env = "MINISERVE_PATH")]
pub path: Option<PathBuf>,
/// The path to where file uploads will be written to before being moved to their
/// correct location. It's wise to make sure that this directory will be written to
/// disk and not into memory.
///
/// This value will only be used **IF** file uploading is enabled. If this option is
/// not set, the operating system default temporary directory will be used.
#[arg(
long = "temp-directory",
value_hint = ValueHint::FilePath,
requires = "allowed_upload_dir",
value_parser(validate_is_dir_and_exists),
env = "MINISERVER_TEMP_UPLOAD_DIRECTORY")
]
pub temp_upload_directory: Option<PathBuf>,
/// The name of a directory index file to serve, like "index.html"
///
/// Normally, when miniserve serves a directory, it creates a listing for that directory.
/// However, if a directory contains this file, miniserve will serve that file instead.
#[arg(long, value_hint = ValueHint::FilePath, env = "MINISERVE_INDEX")]
pub index: Option<PathBuf>,
/// Activate SPA (Single Page Application) mode
///
/// This will cause the file given by --index to be served for all non-existing file paths. In
/// effect, this will serve the index file whenever a 404 would otherwise occur in order to
/// allow the SPA router to handle the request instead.
#[arg(long, requires = "index", env = "MINISERVE_SPA")]
pub spa: bool,
/// Reduce output and silence warnings.
#[arg(long, env = "MINISERVE_QUIET")]
pub quiet: bool,
/// Activate Pretty URLs mode
///
/// This will cause the server to serve the equivalent `.html` file indicated by the path.
///
/// `/about` will try to find `about.html` and serve it.
#[arg(long, env = "MINISERVE_PRETTY_URLS")]
pub pretty_urls: bool,
/// Port to use
#[arg(
short = 'p',
long = "port",
default_value = "8080",
env = "MINISERVE_PORT"
)]
pub port: u16,
/// Interface to listen on
#[arg(
short = 'i',
long = "interfaces",
value_parser(parse_interface),
num_args(1),
env = "MINISERVE_INTERFACE"
)]
pub interfaces: Vec<IpAddr>,
/// Set authentication
///
/// Currently supported formats:
/// username:password, username:sha256:hash, username:sha512:hash
/// (e.g. joe:123, joe:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3)
#[arg(
short = 'a',
long = "auth",
value_parser(parse_auth),
num_args(1),
env = "MINISERVE_AUTH",
verbatim_doc_comment
)]
pub auth: Vec<auth::RequiredAuth>,
/// Read authentication values from a file
///
/// Example file content:
///
/// joe:123
/// bob:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3
/// bill:
#[arg(long, value_hint = ValueHint::FilePath, env = "MINISERVE_AUTH_FILE", verbatim_doc_comment)]
pub auth_file: Option<PathBuf>,
/// Use a specific route prefix
#[arg(long = "route-prefix", env = "MINISERVE_ROUTE_PREFIX")]
pub route_prefix: Option<String>,
/// Generate a random 6-hexdigit route
#[arg(
long = "random-route",
conflicts_with("route_prefix"),
env = "MINISERVE_RANDOM_ROUTE"
)]
pub random_route: bool,
/// Hide symlinks in listing and prevent them from being followed
#[arg(short = 'P', long = "no-symlinks", env = "MINISERVE_NO_SYMLINKS")]
pub no_symlinks: bool,
/// Show hidden files
#[arg(short = 'H', long = "hidden", env = "MINISERVE_HIDDEN")]
pub hidden: bool,
/// Default sorting method for file list
#[arg(
short = 'S',
long = "default-sorting-method",
default_value = "name",
ignore_case = true,
env = "MINISERVE_DEFAULT_SORTING_METHOD"
)]
pub default_sorting_method: SortingMethod,
/// Default sorting order for file list
#[arg(
short = 'O',
long = "default-sorting-order",
default_value = "desc",
ignore_case = true,
env = "MINISERVE_DEFAULT_SORTING_ORDER"
)]
pub default_sorting_order: SortingOrder,
/// Default color scheme
#[arg(
short = 'c',
long = "color-scheme",
default_value = "squirrel",
ignore_case = true,
env = "MINISERVE_COLOR_SCHEME"
)]
pub color_scheme: ThemeSlug,
/// Default color scheme
#[arg(
short = 'd',
long = "color-scheme-dark",
default_value = "archlinux",
ignore_case = true,
env = "MINISERVE_COLOR_SCHEME_DARK"
)]
pub color_scheme_dark: ThemeSlug,
/// Enable QR code display
#[arg(short = 'q', long = "qrcode", env = "MINISERVE_QRCODE")]
pub qrcode: bool,
/// Enable file uploading (and optionally specify for which directory)
///
/// The provided path is not a physical file system path. Instead, it's relative to the serve
/// dir. For instance, if the serve dir is '/home/hello', set this to '/upload' to allow
/// uploading to '/home/hello/upload'.
/// When specified via environment variable, a path always needs to be specified.
#[arg(short = 'u', long = "upload-files", value_hint = ValueHint::FilePath, num_args(0..=1), value_delimiter(','), env = "MINISERVE_ALLOWED_UPLOAD_DIR")]
pub allowed_upload_dir: Option<Vec<PathBuf>>,
/// Configure amount of concurrent uploads when visiting the website. Must have
/// upload-files option enabled for this setting to matter.
///
/// For example, a value of 4 would mean that the web browser will only upload
/// 4 files at a time to the web server when using the web browser interface.
///
/// When the value is kept at 0, it attempts to resolve all the uploads at once
/// in the web browser.
///
/// NOTE: Web pages have a limit of how many active HTTP connections that they
/// can make at one time, so even though you might set a concurrency limit of
/// 100, the browser might only make progress on the max amount of connections
/// it allows the web page to have open.
#[arg(
long = "web-upload-files-concurrency",
env = "MINISERVE_WEB_UPLOAD_CONCURRENCY",
default_value = "0"
)]
pub web_upload_concurrency: usize,
/// Set unix file permissions of uploaded files
///
/// This takes an octal number, for example 0600. By default 0666 & ~umask is used to simulate
/// the system's default behavior.
#[cfg(unix)]
#[arg(
long = "chmod",
value_parser(parse_file_mode),
env = "MINISERVE_CHMOD",
requires = "allowed_upload_dir"
)]
pub chmod: Option<u16>,
/// Enable recursive directory size calculation
///
/// This is disabled by default because it is a potentially fairly IO intensive operation.
#[arg(long = "directory-size", env = "MINISERVE_DIRECTORY_SIZE")]
pub directory_size: bool,
/// Enable creating directories
#[arg(
short = 'U',
long = "mkdir",
requires = "allowed_upload_dir",
env = "MINISERVE_MKDIR_ENABLED"
)]
pub mkdir_enabled: bool,
/// Enable creating pastebin 'pastes'
///
/// 'pastes' are plaintext files created in the current directory. Creation requires file
/// uploads be enabled.
#[arg(
long = "pastebin",
requires = "allowed_upload_dir",
env = "MINISERVE_PASTEBIN_ENABLED"
)]
pub pastebin_enabled: bool,
/// Specify uploadable media types
#[arg(
short = 'm',
long = "media-type",
requires = "allowed_upload_dir",
env = "MINISERVE_MEDIA_TYPE"
)]
pub media_type: Option<Vec<MediaType>>,
/// Directly specify the uploadable media type expression
#[arg(
short = 'M',
long = "raw-media-type",
requires = "allowed_upload_dir",
conflicts_with = "media_type",
env = "MINISERVE_RAW_MEDIA_TYPE"
)]
pub media_type_raw: Option<String>,
/// What to do if existing files with same name is present during file upload
///
/// If you enable renaming files, the renaming will occur by
/// adding a numerical suffix to the filename before the final
/// extension. For example file.txt will be uploaded as
/// file-1.txt, the number will be increased until an available
/// filename is found.
#[arg(
short = 'o',
long = "on-duplicate-files",
env = "MINISERVE_ON_DUPLICATE_FILES",
default_value = "error"
)]
pub on_duplicate_files: DuplicateFile,
/// Enable file and directory deletion (and optionally specify for which directory)
#[arg(
short = 'R',
long = "rm-files",
value_hint = ValueHint::DirPath,
num_args(0..=1),
value_delimiter(','),
env = "MINISERVE_ALLOWED_RM_DIR"
)]
pub allowed_rm_dir: Option<Vec<PathBuf>>,
/// Enable uncompressed tar archive generation
#[arg(short = 'r', long = "enable-tar", env = "MINISERVE_ENABLE_TAR")]
pub enable_tar: bool,
/// Enable gz-compressed tar archive generation
#[arg(short = 'g', long = "enable-tar-gz", env = "MINISERVE_ENABLE_TAR_GZ")]
pub enable_tar_gz: bool,
/// Enable zip archive generation
///
/// WARNING: Zipping large directories can result in out-of-memory exception
/// because zip generation is done in memory and cannot be sent on the fly
#[arg(short = 'z', long = "enable-zip", env = "MINISERVE_ENABLE_ZIP")]
pub enable_zip: bool,
/// Compress response
///
/// WARNING: Enabling this option may slow down transfers due to CPU overhead, so it is
/// disabled by default.
///
/// Only enable this option if you know that your users have slow connections or if you want to
/// minimize your server's bandwidth usage.
#[arg(
short = 'C',
long = "compress-response",
env = "MINISERVE_COMPRESS_RESPONSE"
)]
pub compress_response: bool,
/// List directories first
#[arg(short = 'D', long = "dirs-first", env = "MINISERVE_DIRS_FIRST")]
pub dirs_first: bool,
/// Shown instead of host in page title and heading
#[arg(short = 't', long = "title", env = "MINISERVE_TITLE")]
pub title: Option<String>,
/// Inserts custom headers into the responses. Specify each header as a 'Header:Value' pair.
/// This parameter can be used multiple times to add multiple headers.
///
/// Example:
/// --header "Header1:Value1" --header "Header2:Value2"
/// (If a header is already set or previously inserted, it will not be overwritten.)
#[arg(
long = "header",
value_parser(parse_header),
num_args(1),
env = "MINISERVE_HEADER"
)]
pub header: Vec<HeaderMap>,
/// Visualize symlinks in directory listing
#[arg(
short = 'l',
long = "show-symlink-info",
env = "MINISERVE_SHOW_SYMLINK_INFO"
)]
pub show_symlink_info: bool,
/// Hide version footer
#[arg(
short = 'F',
long = "hide-version-footer",
env = "MINISERVE_HIDE_VERSION_FOOTER"
)]
pub hide_version_footer: bool,
/// Hide theme selector
#[arg(long = "hide-theme-selector", env = "MINISERVE_HIDE_THEME_SELECTOR")]
pub hide_theme_selector: bool,
/// If enabled, display a wget command to recursively download the current directory
#[arg(
short = 'W',
long = "show-wget-footer",
env = "MINISERVE_SHOW_WGET_FOOTER"
)]
pub show_wget_footer: bool,
/// Generate completion file for a shell
#[arg(long = "print-completions", value_name = "shell")]
pub print_completions: Option<clap_complete::Shell>,
/// Generate man page
#[arg(long = "print-manpage")]
pub print_manpage: bool,
/// TLS certificate to use
#[cfg(feature = "tls")]
#[arg(long = "tls-cert", requires = "tls_key", value_hint = ValueHint::FilePath, env = "MINISERVE_TLS_CERT")]
pub tls_cert: Option<PathBuf>,
/// TLS private key to use
#[cfg(feature = "tls")]
#[arg(long = "tls-key", requires = "tls_cert", value_hint = ValueHint::FilePath, env = "MINISERVE_TLS_KEY")]
pub tls_key: Option<PathBuf>,
/// Enable README.md rendering in directories
#[arg(long, env = "MINISERVE_README")]
pub readme: bool,
/// Disable indexing
///
/// This will prevent directory listings from being generated
/// and return an error instead.
#[arg(short = 'I', long, env = "MINISERVE_DISABLE_INDEXING")]
pub disable_indexing: bool,
/// Enable read-only WebDAV support (PROPFIND requests)
#[arg(long, env = "MINISERVE_ENABLE_WEBDAV")]
pub enable_webdav: bool,
/// Show served file size in exact bytes
#[arg(long, default_value_t = SizeDisplay::Human, env = "MINISERVE_SIZE_DISPLAY")]
pub size_display: SizeDisplay,
/// Optional external URL (e.g., 'http://external.example.com:8081') prepended to file links in listings.
///
/// Allows serving files from a different URL than the browsing instance. Useful for setups like:
/// one authenticated instance for browsing, linking files (via this option) to a second,
/// non-indexed (-I) instance for direct downloads. This obscures the full file list on
/// the download server, while users can still copy direct file URLs for sharing.
/// The external URL is put verbatim in front of the relative location of the file, including the protocol.
/// The user should take care this results in a valid URL, no further checks are being done.
#[arg(long = "file-external-url", env = "MINISERVE_FILE_EXTERNAL_URL")]
pub file_external_url: Option<String>,
/// Set the color style of the log output
///
/// "auto" (default) enables colors only when the output is a terminal.
/// "always" always enables colors.
/// "never" always disables colors.
#[arg(
long = "log-color",
env = "MINISERVE_LOG_COLOR",
default_value = "auto"
)]
pub log_color: LogColor,
}
/// Checks whether an interface is valid, i.e. it can be parsed into an IP address
fn parse_interface(src: &str) -> Result<IpAddr, std::net::AddrParseError> {
src.parse::<IpAddr>()
}
/// Validate that a path passed in is a directory and it exists.
fn validate_is_dir_and_exists(s: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(s);
if path.exists() && path.is_dir() {
Ok(path)
} else {
Err(format!(
"Upload temporary directory must exist and be a directory. \
Validate that path {path:?} meets those requirements."
))
}
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum AuthParseError {
/// Might occur if the HTTP credential string does not respect the expected format
#[error(
"Invalid format for credentials string. Expected username:password, username:sha256:hash or username:sha512:hash"
)]
InvalidAuthFormat,
/// Might occur if the hash method is neither sha256 nor sha512
#[error("{0} is not a valid hashing method. Expected sha256 or sha512")]
InvalidHashMethod(String),
/// Might occur if the HTTP auth hash password is not a valid hex code
#[error("Invalid format for password hash. Expected hex code")]
InvalidPasswordHash,
/// Might occur if the HTTP auth password exceeds 255 characters
#[error("HTTP password length exceeds 255 characters")]
PasswordTooLong,
}
/// Parse authentication requirement
pub fn parse_auth(src: &str) -> Result<auth::RequiredAuth, AuthParseError> {
use AuthParseError as E;
let mut split = src.splitn(3, ':');
let invalid_auth_format = Err(E::InvalidAuthFormat);
let username = match split.next() {
Some(username) => username,
None => return invalid_auth_format,
};
// second_part is either password in username:password or method in username:method:hash
let second_part = match split.next() {
// This allows empty passwords, as the spec does not forbid it
Some(password) => password,
None => return invalid_auth_format,
};
let password = if let Some(hash_hex) = split.next() {
let hash_bin = hex::decode(hash_hex).map_err(|_| E::InvalidPasswordHash)?;
match second_part {
"sha256" => auth::RequiredAuthPassword::Sha256(hash_bin),
"sha512" => auth::RequiredAuthPassword::Sha512(hash_bin),
_ => return Err(E::InvalidHashMethod(second_part.to_owned())),
}
} else {
// To make it Windows-compatible, the password needs to be shorter than 255 characters.
// After 255 characters, Windows will truncate the value.
// As for the username, the spec does not mention a limit in length
if second_part.len() > 255 {
return Err(E::PasswordTooLong);
}
auth::RequiredAuthPassword::Plain(second_part.to_owned())
};
Ok(auth::RequiredAuth {
username: username.to_owned(),
password,
})
}
/// Custom header parser (allow multiple headers input)
pub fn parse_header(src: &str) -> Result<HeaderMap, httparse::Error> {
let mut headers = [httparse::EMPTY_HEADER; 1];
let header = format!("{src}\n");
httparse::parse_headers(header.as_bytes(), &mut headers)?;
let mut header_map = HeaderMap::new();
if let Some(h) = headers.first()
&& h.name != httparse::EMPTY_HEADER.name
{
header_map.insert(
HeaderName::from_bytes(h.name.as_bytes()).unwrap(),
HeaderValue::from_bytes(h.value).unwrap(),
);
}
Ok(header_map)
}
#[cfg(unix)]
pub fn parse_file_mode(src: &str) -> Result<u16, std::num::ParseIntError> {
u16::from_str_radix(src, 8)
}
#[rustfmt::skip]
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use pretty_assertions::assert_eq;
/// Helper function that creates a `RequiredAuth` structure
fn create_required_auth(username: &str, password: &str, encrypt: &str) -> auth::RequiredAuth {
use auth::*;
use RequiredAuthPassword::*;
let password = match encrypt {
"plain" => Plain(password.to_owned()),
"sha256" => Sha256(hex::decode(password).unwrap()),
"sha512" => Sha512(hex::decode(password).unwrap()),
_ => panic!("Unknown encryption type"),
};
auth::RequiredAuth {
username: username.to_owned(),
password,
}
}
#[rstest(
auth_string, username, password, encrypt,
case("username:password", "username", "password", "plain"),
case("username:sha256:abcd", "username", "abcd", "sha256"),
case("username:sha512:abcd", "username", "abcd", "sha512")
)]
fn parse_auth_valid(auth_string: &str, username: &str, password: &str, encrypt: &str) {
assert_eq!(
parse_auth(auth_string).unwrap(),
create_required_auth(username, password, encrypt),
);
}
#[rstest(
auth_string, err_msg,
case(
"foo",
"Invalid format for credentials string. Expected username:password, username:sha256:hash or username:sha512:hash"
),
case(
"username:blahblah:abcd",
"blahblah is not a valid hashing method. Expected sha256 or sha512"
),
case(
"username:sha256:invalid",
"Invalid format for password hash. Expected hex code"
),
case(
"username:sha512:invalid",
"Invalid format for password hash. Expected hex code"
),
)]
fn parse_auth_invalid(auth_string: &str, err_msg: &str) {
let err = parse_auth(auth_string).unwrap_err();
assert_eq!(format!("{err}"), err_msg.to_owned());
}
}
================================================
FILE: src/auth.rs
================================================
use actix_web::{HttpMessage, dev::ServiceRequest, web};
use actix_web_httpauth::extractors::basic::BasicAuth;
use sha2::{Digest, Sha256, Sha512};
use crate::errors::RuntimeError;
#[derive(Clone, Debug)]
/// HTTP Basic authentication parameters
pub struct BasicAuthParams {
pub username: String,
pub password: String,
}
impl From<BasicAuth> for BasicAuthParams {
fn from(auth: BasicAuth) -> Self {
Self {
username: auth.user_id().to_string(),
password: auth.password().unwrap_or_default().to_string(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
/// `password` field of `RequiredAuth`
pub enum RequiredAuthPassword {
Plain(String),
Sha256(Vec<u8>),
Sha512(Vec<u8>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
/// Authentication structure to match `BasicAuthParams` against
pub struct RequiredAuth {
pub username: String,
pub password: RequiredAuthPassword,
}
/// Return `true` if `basic_auth` is matches any of `required_auth`
pub fn match_auth(basic_auth: &BasicAuthParams, required_auth: &[RequiredAuth]) -> bool {
required_auth
.iter()
.any(|RequiredAuth { username, password }| {
basic_auth.username == *username && compare_password(&basic_auth.password, password)
})
}
/// Return `true` if `basic_auth_pwd` meets `required_auth_pwd`'s requirement
pub fn compare_password(basic_auth_pwd: &str, required_auth_pwd: &RequiredAuthPassword) -> bool {
match &required_auth_pwd {
RequiredAuthPassword::Plain(required_password) => *basic_auth_pwd == *required_password,
RequiredAuthPassword::Sha256(password_hash) => {
compare_hash::<Sha256>(basic_auth_pwd, password_hash)
}
RequiredAuthPassword::Sha512(password_hash) => {
compare_hash::<Sha512>(basic_auth_pwd, password_hash)
}
}
}
/// Return `true` if hashing of `password` by `T` algorithm equals to `hash`
pub fn compare_hash<T: Digest>(password: &str, hash: &[u8]) -> bool {
get_hash::<T>(password) == hash
}
/// Get hash of a `text`
pub fn get_hash<T: Digest>(text: &str) -> Vec<u8> {
let mut hasher = T::new();
hasher.update(text);
hasher.finalize().to_vec()
}
pub struct CurrentUser {
pub name: String,
}
pub async fn handle_auth(
req: ServiceRequest,
cred: BasicAuth,
) -> actix_web::Result<ServiceRequest, (actix_web::Error, ServiceRequest)> {
let required_auth = &req
.app_data::<web::Data<crate::MiniserveConfig>>()
.unwrap()
.auth;
req.extensions_mut().insert(CurrentUser {
name: cred.user_id().to_string(),
});
if match_auth(&cred.into(), required_auth) {
Ok(req)
} else {
Err((RuntimeError::InvalidHttpCredentials.into(), req))
}
}
#[rustfmt::skip]
#[cfg(test)]
mod tests {
use super::*;
use rstest::{rstest, fixture};
use pretty_assertions::assert_eq;
/// Return a hashing function corresponds to given name
fn get_hash_func(name: &str) -> impl FnOnce(&str) -> Vec<u8> {
match name {
"sha256" => get_hash::<Sha256>,
"sha512" => get_hash::<Sha512>,
_ => panic!("Invalid hash method"),
}
}
#[rstest(
password, hash_method, hash,
case("abc", "sha256", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"),
case("abc", "sha512", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"),
)]
fn test_get_hash(password: &str, hash_method: &str, hash: &str) {
let hash_func = get_hash_func(hash_method);
let expected = hex::decode(hash).expect("Provided hash is not a valid hex code");
let received = hash_func(password);
assert_eq!(received, expected);
}
/// Helper function that creates a `RequiredAuth` structure and encrypt `password` if necessary
fn create_required_auth(username: &str, password: &str, encrypt: &str) -> RequiredAuth {
use RequiredAuthPassword::*;
let password = match encrypt {
"plain" => Plain(password.to_owned()),
"sha256" => Sha256(get_hash::<sha2::Sha256>(password)),
"sha512" => Sha512(get_hash::<sha2::Sha512>(password)),
_ => panic!("Unknown encryption type"),
};
RequiredAuth {
username: username.to_owned(),
password,
}
}
#[rstest(
should_pass, param_username, param_password, required_username, required_password, encrypt,
case(true, "obi", "hello there", "obi", "hello there", "plain"),
case(false, "obi", "hello there", "obi", "hi!", "plain"),
case(true, "obi", "hello there", "obi", "hello there", "sha256"),
case(false, "obi", "hello there", "obi", "hi!", "sha256"),
case(true, "obi", "hello there", "obi", "hello there", "sha512"),
case(false, "obi", "hello there", "obi", "hi!", "sha512")
)]
fn test_single_auth(
should_pass: bool,
param_username: &str,
param_password: &str,
required_username: &str,
required_password: &str,
encrypt: &str,
) {
assert_eq!(
match_auth(
&BasicAuthParams {
username: param_username.to_owned(),
password: param_password.to_owned(),
},
&[create_required_auth(required_username, required_password, encrypt)],
),
should_pass,
)
}
/// Helper function that creates a sample of multiple accounts
#[fixture]
fn account_sample() -> Vec<RequiredAuth> {
[
("usr0", "pwd0", "plain"),
("usr1", "pwd1", "plain"),
("usr2", "pwd2", "sha256"),
("usr3", "pwd3", "sha256"),
("usr4", "pwd4", "sha512"),
("usr5", "pwd5", "sha512"),
]
.iter()
.map(|(username, password, encrypt)| create_required_auth(username, password, encrypt))
.collect()
}
#[rstest(
username, password,
case("usr0", "pwd0"),
case("usr1", "pwd1"),
case("usr2", "pwd2"),
case("usr3", "pwd3"),
case("usr4", "pwd4"),
case("usr5", "pwd5"),
)]
fn test_multiple_auth_pass(
account_sample: Vec<RequiredAuth>,
username: &str,
password: &str,
) {
assert!(match_auth(
&BasicAuthParams {
username: username.to_owned(),
password: password.to_owned(),
},
&account_sample,
));
}
#[rstest]
fn test_multiple_auth_wrong_username(account_sample: Vec<RequiredAuth>) {
assert_eq!(match_auth(
&BasicAuthParams {
username: "unregistered user".to_owned(),
password: "pwd0".to_owned(),
},
&account_sample,
), false);
}
#[rstest(
username, password,
case("usr0", "pwd5"),
case("usr1", "pwd4"),
case("usr2", "pwd3"),
case("usr3", "pwd2"),
case("usr4", "pwd1"),
case("usr5", "pwd0"),
)]
fn test_multiple_auth_wrong_password(
account_sample: Vec<RequiredAuth>,
username: &str,
password: &str,
) {
assert_eq!(match_auth(
&BasicAuthParams {
username: username.to_owned(),
password: password.to_owned(),
},
&account_sample,
), false);
}
}
================================================
FILE: src/config.rs
================================================
use std::{
fs::File,
io::{BufRead, BufReader},
net::{IpAddr, Ipv4Addr, Ipv6Addr},
path::{Path, PathBuf},
};
use actix_web::http::header::HeaderMap;
use anyhow::{Context, Result, anyhow};
#[cfg(feature = "tls")]
use rustls_pemfile as pemfile;
#[cfg(unix)]
use crate::file_utils::get_default_filemode;
use crate::{
args::{CliArgs, DuplicateFile, LogColor, MediaType, parse_auth},
auth::RequiredAuth,
file_utils::sanitize_path,
listing::{SortingMethod, SortingOrder},
renderer::ThemeSlug,
};
/// Possible characters for random routes
const ROUTE_ALPHABET: [char; 16] = [
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f',
];
#[derive(Debug, Clone)]
/// Configuration of the Miniserve application
pub struct MiniserveConfig {
/// Enable verbose mode
pub verbose: bool,
/// Path to be served by miniserve
pub path: std::path::PathBuf,
/// Temporary directory that should be used when files are uploaded to the server
pub temp_upload_directory: Option<std::path::PathBuf>,
/// Port on which miniserve will be listening
pub port: u16,
/// IP address(es) on which miniserve will be available
pub interfaces: Vec<IpAddr>,
/// Enable HTTP basic authentication
pub auth: Vec<RequiredAuth>,
/// If false, miniserve will serve the current working directory
pub path_explicitly_chosen: bool,
/// Enable symlink resolution
pub no_symlinks: bool,
/// Show hidden files
pub show_hidden: bool,
/// Default sorting method
pub default_sorting_method: SortingMethod,
/// Default sorting order
pub default_sorting_order: SortingOrder,
/// Route prefix; Either empty or prefixed with slash
pub route_prefix: String,
/// Well-known healthcheck route (prefixed if route_prefix is provided)
pub healthcheck_route: String,
/// Well-known API route (prefixed if route_prefix is provided)
pub api_route: String,
/// Well-known favicon route (prefixed if route_prefix is provided)
pub favicon_route: String,
/// Well-known css route (prefixed if route_prefix is provided)
pub css_route: String,
/// Default color scheme
pub default_color_scheme: ThemeSlug,
/// Default dark mode color scheme
pub default_color_scheme_dark: ThemeSlug,
/// The name of a directory index file to serve, like "index.html"
///
/// Normally, when miniserve serves a directory, it creates a listing for that directory.
/// However, if a directory contains this file, miniserve will serve that file instead.
pub index: Option<std::path::PathBuf>,
/// Activate SPA (Single Page Application) mode
///
/// This will cause the file given by `index` to be served for all non-existing file paths. In
/// effect, this will serve the index file whenever a 404 would otherwise occur in order to
/// allow the SPA router to handle the request instead.
pub spa: bool,
/// Reduce output and silence warnings.
pub quiet: bool,
/// Activate Pretty URLs mode
///
/// This will cause the server to serve the equivalent `.html` file indicated by the path.
///
/// `/about` will try to find `about.html` and serve it.
pub pretty_urls: bool,
/// Enable QR code display
pub show_qrcode: bool,
/// Enable recursive directory size calculation
pub directory_size: bool,
/// Enable creating directories
pub mkdir_enabled: bool,
/// Enable file upload
pub file_upload: bool,
/// Enable pastepin creation
pub pastebin_enabled: bool,
/// Max amount of concurrency when uploading multiple files
pub web_upload_concurrency: usize,
/// chmod permissions of uploaded files
#[cfg(unix)]
pub upload_chmod: u16,
/// List of allowed upload directories
pub allowed_upload_dir: Vec<String>,
/// HTML accept attribute value
pub uploadable_media_type: Option<String>,
/// What to do on upload if filename already exists
pub on_duplicate_files: DuplicateFile,
/// Enable file and directory deletion
pub rm_enabled: bool,
/// List of allowed deletion directories
pub allowed_rm_dir: Vec<String>,
/// If false, creation of uncompressed tar archives is disabled
pub tar_enabled: bool,
/// If false, creation of gz-compressed tar archives is disabled
pub tar_gz_enabled: bool,
/// If false, creation of zip archives is disabled
pub zip_enabled: bool,
/// Enable compress response
pub compress_response: bool,
/// If enabled, directories are listed first
pub dirs_first: bool,
/// Shown instead of host in page title and heading
pub title: Option<String>,
/// If specified, header will be added
pub header: Vec<HeaderMap>,
/// If specified, symlink destination will be shown
pub show_symlink_info: bool,
/// If enabled, version footer is hidden
pub hide_version_footer: bool,
/// If enabled, theme selector is hidden
pub hide_theme_selector: bool,
/// If enabled, display a wget command to recursively download the current directory
pub show_wget_footer: bool,
/// If enabled, render the readme from the current directory
pub readme: bool,
/// If enabled, indexing is disabled.
pub disable_indexing: bool,
/// If enabled, respond to WebDAV requests (read-only).
pub webdav_enabled: bool,
/// If enabled, will show in exact byte size of the file
pub show_exact_bytes: bool,
/// If set, use provided rustls config for TLS
#[cfg(feature = "tls")]
pub tls_rustls_config: Option<rustls::ServerConfig>,
#[cfg(not(feature = "tls"))]
pub tls_rustls_config: Option<()>,
/// Optional external URL to prepend to file links in listings
pub file_external_url: Option<String>,
/// Color choice for the log output
pub log_color: LogColor,
}
impl MiniserveConfig {
/// Parses the command line arguments
pub fn try_from_args(args: CliArgs) -> Result<Self> {
let interfaces = if !args.interfaces.is_empty() {
args.interfaces
} else {
vec![
IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0)),
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
]
};
let route_prefix = match (args.route_prefix, args.random_route) {
(Some(prefix), _) => format!("/{}", prefix.trim_matches('/')),
(_, true) => format!("/{}", nanoid::nanoid!(6, &ROUTE_ALPHABET)),
_ => "".to_owned(),
};
let mut auth = args.auth;
if let Some(path) = args.auth_file {
let file = File::open(path)?;
let lines = BufReader::new(file).lines();
for line in lines {
auth.push(parse_auth(line?.as_str())?);
}
}
// Format some well-known routes at paths that are very unlikely to conflict with real
// files.
// If --random-route is enabled, in order to not leak the random generated route, we must not use it
// as static files prefix.
// Otherwise, we should apply route_prefix to static files.
let (healthcheck_route, api_route, favicon_route, css_route) = if args.random_route {
(
"/__miniserve_internal/healthcheck".into(),
"/__miniserve_internal/api".into(),
"/__miniserve_internal/favicon.svg".into(),
"/__miniserve_internal/style.css".into(),
)
} else {
(
format!("{}/{}", route_prefix, "__miniserve_internal/healthcheck"),
format!("{}/{}", route_prefix, "__miniserve_internal/api"),
format!("{}/{}", route_prefix, "__miniserve_internal/favicon.svg"),
format!("{}/{}", route_prefix, "__miniserve_internal/style.css"),
)
};
let default_color_scheme = args.color_scheme;
let default_color_scheme_dark = args.color_scheme_dark;
let path_explicitly_chosen = args.path.is_some() || args.index.is_some();
let port = match args.port {
0 => port_check::free_local_port().context("No free ports available")?,
_ => args.port,
};
#[cfg(feature = "tls")]
let tls_rustls_server_config =
if let (Some(tls_cert), Some(tls_key)) = (args.tls_cert, args.tls_key) {
let cert_file = &mut BufReader::new(
File::open(&tls_cert)
.context(format!("Couldn't access TLS certificate {tls_cert:?}"))?,
);
let key_file = &mut BufReader::new(
File::open(&tls_key).context(format!("Couldn't access TLS key {tls_key:?}"))?,
);
let cert_chain = pemfile::certs(cert_file)
.map(|cert| cert.expect("Invalid certificate in certificate chain"))
.collect();
let private_key = pemfile::private_key(key_file)
.context("Reading private key file")?
.expect("No private key found");
let server_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, private_key)?;
Some(server_config)
} else {
None
};
#[cfg(not(feature = "tls"))]
let tls_rustls_server_config = None;
let uploadable_media_type = args.media_type_raw.or_else(|| {
args.media_type.map(|types| {
types
.into_iter()
.map(|t| match t {
MediaType::Audio => "audio/*",
MediaType::Image => "image/*",
MediaType::Video => "video/*",
})
.collect::<Vec<_>>()
.join(",")
})
});
let allowed_upload_dir = args
.allowed_upload_dir
.as_ref()
.map(|paths| validate_allowed_paths(paths, args.hidden))
.transpose()?
.unwrap_or_default();
let allowed_rm_dir = args
.allowed_rm_dir
.as_ref()
.map(|paths| validate_allowed_paths(paths, args.hidden))
.transpose()?
.unwrap_or_default();
let show_exact_bytes = match args.size_display {
crate::args::SizeDisplay::Human => false,
crate::args::SizeDisplay::Exact => true,
};
#[cfg(unix)]
let upload_chmod = args.chmod.unwrap_or_else(get_default_filemode);
Ok(Self {
verbose: args.verbose,
path: args.path.unwrap_or_else(|| PathBuf::from(".")),
temp_upload_directory: args.temp_upload_directory,
port,
interfaces,
auth,
path_explicitly_chosen,
no_symlinks: args.no_symlinks,
show_hidden: args.hidden,
default_sorting_method: args.default_sorting_method,
default_sorting_order: args.default_sorting_order,
route_prefix,
healthcheck_route,
api_route,
favicon_route,
css_route,
default_color_scheme,
default_color_scheme_dark,
index: args.index,
spa: args.spa,
quiet: args.quiet,
pretty_urls: args.pretty_urls,
on_duplicate_files: args.on_duplicate_files,
show_qrcode: args.qrcode,
directory_size: args.directory_size,
mkdir_enabled: args.mkdir_enabled,
file_upload: args.allowed_upload_dir.is_some(),
pastebin_enabled: args.pastebin_enabled,
web_upload_concurrency: args.web_upload_concurrency,
#[cfg(unix)]
upload_chmod,
allowed_upload_dir,
uploadable_media_type,
rm_enabled: args.allowed_rm_dir.is_some(),
allowed_rm_dir,
tar_enabled: args.enable_tar,
tar_gz_enabled: args.enable_tar_gz,
zip_enabled: args.enable_zip,
dirs_first: args.dirs_first,
title: args.title,
header: args.header,
show_symlink_info: args.show_symlink_info,
hide_version_footer: args.hide_version_footer,
hide_theme_selector: args.hide_theme_selector,
show_wget_footer: args.show_wget_footer,
readme: args.readme,
disable_indexing: args.disable_indexing,
webdav_enabled: args.enable_webdav,
tls_rustls_config: tls_rustls_server_config,
compress_response: args.compress_response,
show_exact_bytes,
file_external_url: args.file_external_url,
log_color: args.log_color,
})
}
}
fn validate_allowed_paths(paths: &[impl AsRef<Path>], allow_hidden: bool) -> Result<Vec<String>> {
paths
.iter()
.map(|p| {
sanitize_path(p, allow_hidden)
.map(|p| p.display().to_string().replace('\\', "/"))
.ok_or(anyhow!("Illegal path {:?}", p.as_ref()))
})
.collect()
}
================================================
FILE: src/consts.rs
================================================
use fast_qr::ECL;
/// The error correction level to use for all QR code generation.
pub const QR_EC_LEVEL: ECL = ECL::L;
/// The margin size for the SVG QR code on the webpage.
pub const SVG_QR_MARGIN: usize = 1;
================================================
FILE: src/errors.rs
================================================
use std::str::FromStr;
use actix_web::{
HttpRequest, HttpResponse, ResponseError,
body::{BoxBody, MessageBody},
dev::{ResponseHead, ServiceRequest, ServiceResponse},
http::{StatusCode, header},
middleware::Next,
web,
};
use thiserror::Error;
use crate::{MiniserveConfig, renderer::render_error};
#[derive(Debug, Error)]
pub enum StartupError {
/// Any kind of IO errors
#[error("{0}\ncaused by: {1}")]
IoError(String, std::io::Error),
/// In case miniserve was invoked without an interactive terminal and without an explicit path
#[error("Refusing to start as no explicit serve path was set and no interactive terminal was attached
Please set an explicit serve path like: `miniserve /my/path`")]
NoExplicitPathAndNoTerminal,
/// In case miniserve was invoked with --no-symlinks but the serve path is a symlink
#[error("The -P|--no-symlinks option was provided but the serve path '{0}' is a symlink")]
NoSymlinksOptionWithSymlinkServePath(String),
#[error("The --enable-webdav option was provided, but the serve path '{0}' is a file")]
WebdavWithFileServePath(String),
}
#[derive(Debug, Error)]
pub enum RuntimeError {
/// Any kind of IO errors
#[error("{0}\ncaused by: {1}")]
IoError(String, std::io::Error),
/// Might occur during file upload, when processing the multipart request fails
#[error("Failed to process multipart request\ncaused by: {0}")]
MultipartError(String),
/// Might occur during file upload
#[error("File already exists, and the on_duplicate_files option is set to error out")]
DuplicateFileError,
/// Uploaded hash not correct
#[error("File hash that was provided did not match checksum of uploaded file")]
UploadHashMismatchError,
/// Upload not allowed
#[error("Upload not allowed to this directory")]
UploadForbiddenError,
/// Remove not allowed
#[error("Remove not allowed to this directory")]
RmForbiddenError,
/// Any error related to an invalid path (failed to retrieve entry name, unexpected entry type, etc)
#[error("Invalid path\ncaused by: {0}")]
InvalidPathError(String),
/// Might occur if the user has insufficient permissions to create an entry in a given directory
#[error("Insufficient permissions to create file in {0}")]
InsufficientPermissionsError(String),
/// Any error related to parsing
#[error("Failed to parse {0}\ncaused by: {1}")]
ParseError(String, String),
/// Might occur when the creation of an archive fails
#[error("An error occurred while creating the {0}\ncaused by: {1}")]
ArchiveCreationError(String, Box<RuntimeError>),
/// More specific archive creation failure reason
#[error("{0}")]
ArchiveCreationDetailError(String),
/// Might occur when the HTTP credentials are not correct
#[error("Invalid credentials for HTTP authentication")]
InvalidHttpCredentials,
/// Might occur when an HTTP request is invalid
#[error("Invalid HTTP request\ncaused by: {0}")]
InvalidHttpRequestError(String),
/// Might occur when trying to access a page that does not exist
#[error("Route {0} could not be found")]
RouteNotFoundError(String),
}
impl ResponseError for RuntimeError {
fn status_code(&self) -> StatusCode {
use RuntimeError as E;
use StatusCode as S;
match self {
E::IoError(_, _) => S::INTERNAL_SERVER_ERROR,
E::UploadHashMismatchError => S::BAD_REQUEST,
E::MultipartError(_) => S::BAD_REQUEST,
E::DuplicateFileError => S::CONFLICT,
E::UploadForbiddenError => S::FORBIDDEN,
E::RmForbiddenError => S::FORBIDDEN,
E::InvalidPathError(_) => S::BAD_REQUEST,
E::InsufficientPermissionsError(_) => S::FORBIDDEN,
E::ParseError(_, _) => S::BAD_REQUEST,
E::ArchiveCreationError(_, err) => err.status_code(),
E::ArchiveCreationDetailError(_) => S::INTERNAL_SERVER_ERROR,
E::InvalidHttpCredentials => S::UNAUTHORIZED,
E::InvalidHttpRequestError(_) => S::BAD_REQUEST,
E::RouteNotFoundError(_) => S::NOT_FOUND,
}
}
fn error_response(&self) -> HttpResponse {
log_error_chain(self.to_string());
let mut resp = HttpResponse::build(self.status_code());
if let Self::InvalidHttpCredentials = self {
resp.append_header((
header::WWW_AUTHENTICATE,
header::HeaderValue::from_static("Basic realm=\"miniserve\""),
));
}
resp.content_type(mime::TEXT_PLAIN_UTF_8)
.body(self.to_string())
}
}
/// Middleware to convert plain-text error responses to user-friendly web pages
pub async fn error_page_middleware(
req: ServiceRequest,
next: Next<impl MessageBody + 'static>,
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
let res = next.call(req).await?.map_into_boxed_body();
if (res.status().is_client_error() || res.status().is_server_error())
&& res.request().path() != "/upload"
&& res
.headers()
.get(header::CONTENT_TYPE)
.map(AsRef::as_ref)
.and_then(|s| std::str::from_utf8(s).ok())
.and_then(|s| mime::Mime::from_str(s).ok())
.as_ref()
.map(mime::Mime::essence_str)
== Some(mime::TEXT_PLAIN.as_ref())
{
let req = res.request().clone();
Ok(res.map_body(|head, body| map_error_page(&req, head, body)))
} else {
Ok(res)
}
}
fn map_error_page(req: &HttpRequest, head: &mut ResponseHead, body: BoxBody) -> BoxBody {
let error_msg = match body.try_into_bytes() {
Ok(bytes) => bytes,
Err(body) => return body,
};
let error_msg = match std::str::from_utf8(&error_msg) {
Ok(msg) => msg,
_ => return BoxBody::new(error_msg),
};
let conf = req.app_data::<web::Data<MiniserveConfig>>().unwrap();
let return_address = req
.headers()
.get(header::REFERER)
.and_then(|h| h.to_str().ok())
.unwrap_or("/");
head.headers.insert(
header::CONTENT_TYPE,
mime::TEXT_HTML_UTF_8.essence_str().try_into().unwrap(),
);
BoxBody::new(render_error(error_msg, head.status, conf, return_address).into_string())
}
pub fn log_error_chain(description: String) {
for cause in description.lines() {
log::error!("{cause}");
}
}
================================================
FILE: src/file_op.rs
================================================
//! Handlers for file upload and removal
#[cfg(target_family = "unix")]
use std::collections::HashSet;
use std::io::ErrorKind;
#[cfg(target_family = "unix")]
use std::os::unix::fs::MetadataExt;
use std::path::{Component, Path, PathBuf};
#[cfg(target_family = "unix")]
use std::sync::Arc;
use actix_web::{HttpRequest, HttpResponse, http::header, web};
use async_walkdir::WalkDir;
use futures::{StreamExt, TryStreamExt};
use log::{error, info, warn};
use serde::Deserialize;
use sha2::digest::DynDigest;
use sha2::{Digest, Sha256, Sha512};
use tempfile::NamedTempFile;
use tokio::fs;
use tokio::io::AsyncWriteExt;
#[cfg(target_family = "unix")]
use tokio::sync::RwLock;
use crate::{
args::DuplicateFile, config::MiniserveConfig, errors::RuntimeError,
file_utils::contains_symlink, file_utils::sanitize_path,
};
enum FileHash {
SHA256(String),
SHA512(String),
}
impl FileHash {
pub fn get_hasher(&self) -> Box<dyn DynDigest> {
match self {
Self::SHA256(_) => Box::new(Sha256::new()),
Self::SHA512(_) => Box::new(Sha512::new()),
}
}
pub fn get_hash(&self) -> &str {
match self {
Self::SHA256(string) => string,
Self::SHA512(string) => string,
}
}
}
/// Get the recursively calculated dir size for a given dir
///
/// Counts hardlinked files only once if the OS supports hardlinks.
///
/// Expects `dir` to be sanitized. This function doesn't do any sanitization itself.
pub async fn recursive_dir_size(dir: &Path) -> Result<u64, RuntimeError> {
#[cfg(target_family = "unix")]
let seen_inodes = Arc::new(RwLock::new(HashSet::new()));
let mut entries = WalkDir::new(dir);
let mut total_size = 0;
loop {
match entries.next().await {
Some(Ok(entry)) => {
if let Ok(metadata) = entry.metadata().await
&& metadata.is_file()
{
// On Unix, we want to filter inodes that we've already seen so we get a
// more accurate count of real size used on disk.
#[cfg(target_family = "unix")]
{
let (device_id, inode) = (metadata.dev(), metadata.ino());
// Check if this file has been seen before based on its device ID and
// inode number
if seen_inodes.read().await.contains(&(device_id, inode)) {
continue;
} else {
seen_inodes.write().await.insert((device_id, inode));
}
}
total_size += metadata.len();
}
}
Some(Err(e)) => {
if let Some(io_err) = e.into_io() {
match io_err.kind() {
ErrorKind::PermissionDenied => warn!(
"Error trying to read file when calculating dir size: {io_err}, ignoring"
),
_ => return Err(RuntimeError::InvalidPathError(io_err.to_string())),
}
}
}
None => break,
}
}
Ok(total_size)
}
/// Saves file data from a multipart form field (`field`) to `file_path`. Optionally overwriting
/// existing file and comparing the uploaded file checksum to the user provided `file_hash`.
///
/// Returns total bytes written to file.
async fn save_file(
field: &mut actix_multipart::Field,
mut file_path: PathBuf,
on_duplicate_files: DuplicateFile,
file_checksum: Option<&FileHash>,
temporary_upload_directory: Option<&PathBuf>,
#[cfg(unix)] chmod: u16,
) -> Result<u64, RuntimeError> {
if file_path.exists() {
match on_duplicate_files {
DuplicateFile::Error => return Err(RuntimeError::DuplicateFileError),
DuplicateFile::Overwrite => (),
DuplicateFile::Rename => {
// extract extension of the file and the file stem without extension
// file.txt => (file, txt)
let file_name = file_path.file_stem().unwrap_or_default().to_string_lossy();
let file_ext = file_path.extension().map(|s| s.to_string_lossy());
for i in 1.. {
// increment the number N in {file_name}-{N}.{file_ext}
// format until available name is found (e.g. file-1.txt, file-2.txt, etc)
let fp = if let Some(ext) = &file_ext {
file_path.with_file_name(format!("{file_name}-{i}.{ext}"))
} else {
file_path.with_file_name(format!("{file_name}-{i}"))
};
// If we have a file name that doesn't exist yet then we'll use that.
if !fp.exists() {
file_path = fp;
break;
}
}
}
}
}
let temp_upload_directory = temporary_upload_directory.cloned();
// Tempfile doesn't support async operations, so we'll do it on a background thread.
let temp_upload_directory_task = tokio::task::spawn_blocking(move || {
// If the user provided a temporary directory path, then use it.
if let Some(temp_directory) = temp_upload_directory {
NamedTempFile::new_in(temp_directory)
} else {
NamedTempFile::new()
}
});
// Validate that the temporary task completed successfully.
let named_temp_file_task = match temp_upload_directory_task.await {
Ok(named_temp_file) => Ok(named_temp_file),
Err(err) => Err(RuntimeError::MultipartError(format!(
"Failed to complete spawned task to create named temp file. {err}",
))),
}?;
// Validate the the temporary file was created successfully.
let named_temp_file = match named_temp_file_task {
Err(err) if err.kind() == ErrorKind::PermissionDenied => Err(
RuntimeError::InsufficientPermissionsError(file_path.display().to_string()),
),
Err(err) => Err(RuntimeError::IoError(
format!("Failed to create temporary file {}", file_path.display()),
err,
)),
Ok(file) => Ok(file),
}?;
// Convert the temporary file into a non-temporary file. This allows us
// to control the lifecycle of the file. This is useful for us because
// we need to convert the temporary file into an async enabled file and
// on successful upload, we want to move it to the target directory.
let (file, temp_path) = named_temp_file
.keep()
.map_err(|err| RuntimeError::IoError("Failed to keep temporary file".into(), err.error))?;
let mut temp_file = tokio::fs::File::from_std(file);
let mut written_len = 0;
let mut hasher = file_checksum.as_ref().map(|h| h.get_hasher());
let mut save_upload_file_error: Option<RuntimeError> = None;
// This while loop take a stream (in this case `field`) and awaits
// new chunks from the websocket connection. The while loop reads
// the file from the HTTP connection and writes it to disk or until
// the stream from the multipart request is aborted.
while let Some(Ok(bytes)) = field.next().await {
// If the hasher exists (if the user has also sent a chunksum with the request)
// then we want to update the hasher with the new bytes uploaded.
if let Some(hasher) = hasher.as_mut() {
hasher.update(&bytes)
}
// Write the bytes from the stream into our temporary file.
if let Err(e) = temp_file.write_all(&bytes).await {
// Failed to write to file. Drop it and return the error
save_upload_file_error =
Some(RuntimeError::IoError("Failed to write to file".into(), e));
break;
}
// record the bytes written to the file.
written_len += bytes.len() as u64;
}
if save_upload_file_error.is_none() {
// Flush the changes to disk so that we are sure they are there.
if let Err(e) = temp_file.flush().await {
save_upload_file_error = Some(RuntimeError::IoError(
"Failed to flush all the file writes to disk".into(),
e,
));
}
}
// Drop the file expcitly here because IF there is an error when writing to the
// temp file, we won't be able to remove as per the comment in `tokio::fs::remove_file`
// > Note that there is no guarantee that the file is immediately deleted
// > (e.g. depending on platform, other open file descriptors may prevent immediate removal).
drop(temp_file);
// If there was an error during uploading.
if let Some(e) = save_upload_file_error {
// If there was an error when writing the file to disk, remove it and return
// the error that was encountered.
let _ = tokio::fs::remove_file(temp_path).await;
return Err(e);
}
// There isn't a way to get notified when a request is cancelled
// by the user in actix it seems. References:
// - https://github.com/actix/actix-web/issues/1313
// - https://github.com/actix/actix-web/discussions/3011
// Therefore, we are relying on the fact that the web UI uploads a
// hash of the file to determine if it was completed uploaded or not.
if let Some(hasher) = hasher
&& let Some(expected_hash) = file_checksum.as_ref().map(|f| f.get_hash())
{
let actual_hash = hex::encode(hasher.finalize());
if actual_hash != expected_hash {
warn!(
"The expected file hash {expected_hash} did not match the calculated hash of {actual_hash}. This can be caused if a file upload was aborted."
);
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(RuntimeError::UploadHashMismatchError);
}
}
info!("File upload successful to {temp_path:?}. Moving to {file_path:?}",);
if let Err(err) = tokio::fs::rename(&temp_path, &file_path).await {
match err.kind() {
ErrorKind::CrossesDevices => {
warn!(
"File writen to {temp_path:?} must be copied to {file_path:?} because it's on a different filesystem"
);
let copy_result = tokio::fs::copy(&temp_path, &file_path).await;
if let Err(e) = tokio::fs::remove_file(&temp_path).await {
error!("Failed to clean up temp file at {temp_path:?} with error {e:?}");
}
copy_result.map_err(|e| {
RuntimeError::IoError(
format!("Failed to copy file from {temp_path:?} to {file_path:?}"),
e,
)
})?;
}
_ => {
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(RuntimeError::IoError(
format!("Failed to move temporary file {temp_path:?} to {file_path:?}",),
err,
));
}
}
}
#[cfg(unix)]
{
info!("Changing file mode (chmod) to {chmod:o}");
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(chmod.into());
if let Err(err) = tokio::fs::set_permissions(&file_path, perms).await {
return Err(RuntimeError::IoError(
format!("Failed to chmod {chmod:o} {file_path:?}"),
err,
));
}
}
Ok(written_len)
}
struct HandleMultipartOpts<'a> {
on_duplicate_files: DuplicateFile,
allow_mkdir: bool,
allow_hidden_paths: bool,
allow_symlinks: bool,
file_hash: Option<&'a FileHash>,
upload_directory: Option<&'a PathBuf>,
}
/// Handles a single field in a multipart form
async fn handle_multipart(
mut field: actix_multipart::Field,
path: PathBuf,
opts: HandleMultipartOpts<'_>,
#[cfg(unix)] chmod: u16,
) -> Result<u64, RuntimeError> {
let HandleMultipartOpts {
on_duplicate_files,
allow_mkdir,
allow_hidden_paths,
allow_symlinks,
file_hash,
upload_directory,
} = opts;
let field_name = field.name().expect("No name field found").to_string();
match tokio::fs::metadata(&path).await {
Err(_) => Err(RuntimeError::InsufficientPermissionsError(
path.display().to_string(),
)),
Ok(metadata) if !metadata.is_dir() => Err(RuntimeError::InvalidPathError(format!(
"cannot upload file to {}, since it's not a directory",
&path.display()
))),
Ok(_) => Ok(()),
}?;
if field_name == "mkdir" {
if !allow_mkdir {
return Err(RuntimeError::InsufficientPermissionsError(
path.display().to_string(),
));
}
let mut user_given_path = PathBuf::new();
let mut absolute_path = path.clone();
// Get the path the user gave
let mkdir_path_bytes = field.try_next().await;
match mkdir_path_bytes {
Ok(Some(mkdir_path_bytes)) => {
let mkdir_path = std::str::from_utf8(&mkdir_path_bytes).map_err(|e| {
RuntimeError::ParseError(
"Failed to parse 'mkdir' path".to_string(),
e.to_string(),
)
})?;
let mkdir_path = mkdir_path.replace('\\', "/");
absolute_path.push(&mkdir_path);
user_given_path.push(&mkdir_path);
}
_ => {
return Err(RuntimeError::ParseError(
"Failed to parse 'mkdir' path".to_string(),
"".to_string(),
));
}
};
// Disallow using `..` (parent) in mkdir path
if user_given_path
.components()
.any(|c| c == Component::ParentDir)
{
return Err(RuntimeError::InvalidPathError(
"Cannot use '..' in mkdir path".to_string(),
));
}
// Hidden paths check
sanitize_path(&user_given_path, allow_hidden_paths).ok_or_else(|| {
RuntimeError::InvalidPathError("Cannot use hidden paths in mkdir path".to_string())
})?;
// Ensure there are no illegal symlinks
if !allow_symlinks {
match contains_symlink(&absolute_path) {
Err(err) => Err(RuntimeError::InsufficientPermissionsError(err.to_string()))?,
Ok(true) => Err(RuntimeError::InsufficientPermissionsError(format!(
"{user_given_path:?} traverses through a symlink"
)))?,
Ok(false) => (),
}
}
return match tokio::fs::create_dir_all(&absolute_path).await {
Err(err) if err.kind() == ErrorKind::PermissionDenied => Err(
RuntimeError::InsufficientPermissionsError(path.display().to_string()),
),
Err(err) => Err(RuntimeError::IoError(
format!("Failed to create {}", user_given_path.display()),
err,
)),
Ok(_) => Ok(0),
};
}
let filename = field
.content_disposition()
.expect("No content-disposition field found")
.get_filename()
.ok_or_else(|| {
RuntimeError::ParseError(
"HTTP header".to_string(),
"Failed to retrieve the name of the file to upload".to_string(),
)
})?;
let filename_path = sanitize_path(Path::new(&filename), allow_hidden_paths)
.ok_or_else(|| RuntimeError::InvalidPathError("Invalid file name to upload".to_string()))?;
// Ensure there are no illegal symlinks in the file upload path
if !allow_symlinks {
match contains_symlink(&path) {
Err(err) => Err(RuntimeError::InsufficientPermissionsError(err.to_string()))?,
Ok(true) => Err(RuntimeError::InsufficientPermissionsError(format!(
"{path:?} traverses through a symlink"
)))?,
Ok(false) => (),
}
}
save_file(
&mut field,
path.join(filename_path),
on_duplicate_files,
file_hash,
upload_directory,
#[cfg(unix)]
chmod,
)
.await
}
/// Query parameters used by upload and rm APIs
#[derive(Deserialize, Default)]
pub struct FileOpQueryParameters {
path: PathBuf,
}
/// Handle incoming request to upload a file or create a directory.
/// Target file path is expected as path parameter in URI and is interpreted as relative from
/// server root directory. Any path which will go outside of this directory is considered
/// invalid.
/// This method returns future.
pub async fn upload_file(
req: HttpRequest,
query: web::Query<FileOpQueryParameters>,
payload: web::Payload,
) -> Result<HttpResponse, RuntimeError> {
let conf = req.app_data::<web::Data<MiniserveConfig>>().unwrap();
let upload_path = sanitize_path(&query.path, conf.show_hidden).ok_or_else(|| {
RuntimeError::InvalidPathError("Invalid value for 'path' parameter".to_string())
})?;
let app_root_dir = conf.path.canonicalize().map_err(|e| {
RuntimeError::IoError("Failed to resolve path served by miniserve".to_string(), e)
})?;
// Disallow paths outside of allowed directories
let upload_allowed = conf.allowed_upload_dir.is_empty()
|| conf
.allowed_upload_dir
.iter()
.any(|s| upload_path.starts_with(s));
if !upload_allowed {
return Err(RuntimeError::UploadForbiddenError);
}
// Disallow the target path to go outside of the served directory
// The target directory shouldn't be canonicalized when it gets passed to
// handle_multipart so that it can check for symlinks if needed
let non_canonicalized_target_dir = app_root_dir.join(upload_path);
match non_canonicalized_target_dir.canonicalize() {
Ok(path) if !conf.no_symlinks => Ok(path),
Ok(path) if path.starts_with(&app_root_dir) => Ok(path),
_ => Err(RuntimeError::InvalidHttpRequestError(
"Invalid value for 'path' parameter".to_string(),
)),
}?;
let upload_directory = conf.temp_upload_directory.as_ref();
let file_hash = if let (Some(hash), Some(hash_function)) = (
req.headers()
.get("X-File-Hash")
.and_then(|h| h.to_str().ok()),
req.headers()
.get("X-File-Hash-Function")
.and_then(|h| h.to_str().ok()),
) {
match hash_function.to_ascii_uppercase().as_str() {
"SHA256" => Some(FileHash::SHA256(hash.to_string())),
"SHA512" => Some(FileHash::SHA512(hash.to_string())),
sha => {
return Err(RuntimeError::InvalidHttpRequestError(format!(
"Invalid header value found for 'X-File-Hash-Function'. Supported values are SHA256 or SHA512. Found {sha}.",
)));
}
}
} else {
None
};
let hash_ref = file_hash.as_ref();
actix_multipart::Multipart::new(req.headers(), payload)
.map_err(|x| RuntimeError::MultipartError(x.to_string()))
.and_then(|field| {
handle_multipart(
field,
non_canonicalized_target_dir.clone(),
HandleMultipartOpts {
on_duplicate_files: conf.on_duplicate_files,
allow_mkdir: conf.mkdir_enabled,
allow_hidden_paths: conf.show_hidden,
allow_symlinks: !conf.no_symlinks,
file_hash: hash_ref,
upload_directory,
},
#[cfg(unix)]
conf.upload_chmod,
)
})
.try_collect::<Vec<u64>>()
.await?;
let return_path = req
.headers()
.get(header::REFERER)
.and_then(|h| h.to_str().ok())
.unwrap_or("/");
Ok(HttpResponse::SeeOther()
.append_header((header::LOCATION, return_path))
.finish())
}
/// Handle incoming request to remove a file or directory.
///
/// Target file path is expected as path parameter in URI and is interpreted as relative from
/// server root directory. Any path which will go outside of this directory is considered
/// invalid.
pub async fn rm_file(
req: HttpRequest,
query: web::Query<FileOpQueryParameters>,
) -> Result<HttpResponse, RuntimeError> {
let conf = req.app_data::<web::Data<MiniserveConfig>>().unwrap();
let rm_path = sanitize_path(&query.path, conf.show_hidden).ok_or_else(|| {
RuntimeError::InvalidPathError("Invalid value for 'path' parameter".to_string())
})?;
let app_root_dir = conf.path.canonicalize().map_err(|e| {
RuntimeError::IoError("Failed to resolve path served by miniserve".to_string(), e)
})?;
// Disallow paths outside of allowed directories
let rm_allowed = conf.allowed_rm_dir.is_empty()
|| conf.allowed_rm_dir.iter().any(|s| rm_path.starts_with(s));
if !rm_allowed {
return Err(RuntimeError::RmForbiddenError);
}
// Disallow the target path to go outside of the served directory
let canonicalized_rm_path = match app_root_dir.join(&rm_path).canonicalize() {
Ok(path) if !conf.no_symlinks => Ok(path),
Ok(path) if path.starts_with(&app_root_dir) => Ok(path),
_ => Err(RuntimeError::InvalidHttpRequestError(
"Invalid value for 'path' parameter".to_string(),
)),
}?;
// Handle non-existent path
if !canonicalized_rm_path.exists() {
return Err(RuntimeError::RouteNotFoundError(format!(
"{rm_path:?} does not exist"
)));
}
// Remove
let rm_res = if canonicalized_rm_path.is_dir() {
fs::remove_dir_all(&canonicalized_rm_path).await
} else {
fs::remove_file(&canonicalized_rm_path).await
};
if let Err(err) = rm_res {
Err(RuntimeError::IoError(
format!("Failed to remove {rm_path:?}"),
err,
))?;
}
let return_path = req
.headers()
.get(header::REFERER)
.and_then(|h| h.to_str().ok())
.unwrap_or("/");
Ok(HttpResponse::SeeOther()
.append_header((header::LOCATION, return_path))
.finish())
}
================================================
FILE: src/file_utils.rs
================================================
#[cfg(unix)]
use rustix::{fs::Mode, process::umask};
use std::{
io,
path::{Component, Path, PathBuf},
};
/// Guarantee that the path is relative and cannot traverse back to parent directories
/// and optionally prevent traversing hidden directories.
///
/// See the unit tests tests::test_sanitize_path* for examples
pub fn sanitize_path(path: impl AsRef<Path>, traverse_hidden: bool) -> Option<PathBuf> {
let mut buf = PathBuf::new();
for comp in path.as_ref().components() {
match comp {
Component::Normal(name) => buf.push(name),
Component::ParentDir => {
buf.pop();
}
_ => (),
}
}
// Double-check that all components are Normal and check for hidden dirs
for comp in buf.components() {
match comp {
Component::Normal(_) if traverse_hidden => (),
Component::Normal(name) if !name.to_str()?.starts_with('.') => (),
_ => return None,
}
}
Some(buf)
}
/// Checks if any segment of the path is a symlink.
///
/// This function fails if [`std::fs::symlink_metadata`] fails, which usually
/// means user has no permission to access the path.
pub fn contains_symlink(path: impl AsRef<Path>) -> io::Result<bool> {
let contains_symlink = path
.as_ref()
.ancestors()
// On Windows, `\\?\` won't exist even though it's the root, but there's no need to check it
// So we filter it out
.filter(|p| p.exists())
.map(|p| p.symlink_metadata())
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.any(|p| p.file_type().is_symlink());
Ok(contains_symlink)
}
/// Get default file creation permissions by umask
#[cfg(unix)]
pub fn get_default_filemode() -> u16 {
let old = umask(Mode::all());
umask(old);
let mode = 0o666 & (!old).as_raw_mode();
mode as u16
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
#[case("/foo", "foo")]
#[case("////foo", "foo")]
#[case("C:/foo", if cfg!(windows) { "foo" } else { "C:/foo" })]
#[case("../foo", "foo")]
#[case("../foo/../bar/abc", "bar/abc")]
fn test_sanitize_path(#[case] input: &str, #[case] output: &str) {
assert_eq!(
sanitize_path(Path::new(input), true).unwrap(),
Path::new(output)
);
assert_eq!(
sanitize_path(Path::new(input), false).unwrap(),
Path::new(output)
);
}
#[rstest]
#[case(".foo")]
#[case("/.foo")]
#[case("foo/.bar/foo")]
fn test_sanitize_path_no_hidden_files(#[case] input: &str) {
assert_eq!(sanitize_path(Path::new(input), false), None);
}
}
================================================
FILE: src/listing.rs
================================================
#![allow(clippy::format_push_string)]
use std::io;
use std::path::{Component, Path};
use std::time::SystemTime;
use actix_web::{
HttpMessage, HttpRequest, HttpResponse, dev::ServiceResponse, http::Uri, web, web::Query,
};
use bytesize::ByteSize;
use clap::ValueEnum;
use comrak::{Options as ComrakOptions, markdown_to_html};
use percent_encoding::{percent_decode_str, utf8_percent_encode};
use regex::Regex;
use serde::Deserialize;
use strum::{Display, EnumString};
use self::percent_encode_sets::COMPONENT;
use crate::archive::ArchiveMethod;
use crate::auth::CurrentUser;
use crate::errors::{self, RuntimeError};
use crate::renderer;
/// "percent-encode sets" as defined by WHATWG specs:
/// https://url.spec.whatwg.org/#percent-encoded-bytes
pub mod percent_encode_sets {
use percent_encoding::{AsciiSet, CONTROLS};
pub const QUERY: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'#').add(b'<').add(b'>');
pub const PATH: &AsciiSet = &QUERY.add(b'?').add(b'`').add(b'{').add(b'}');
pub const USERINFO: &AsciiSet = &PATH
.add(b'/')
.add(b':')
.add(b';')
.add(b'=')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'|');
pub const COMPONENT: &AsciiSet = &USERINFO.add(b'$').add(b'%').add(b'&').add(b'+').add(b',');
}
/// Query parameters used by listing APIs
#[derive(Deserialize, Default)]
pub struct ListingQueryParameters {
pub sort: Option<SortingMethod>,
pub order: Option<SortingOrder>,
pub raw: Option<bool>,
download: Option<ArchiveMethod>,
}
/// Available sorting methods
#[derive(Debug, Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum SortingMethod {
#[default]
/// Sort by name
Name,
/// Sort by size
Size,
/// Sort by last modification date (natural sort: follows alphanumerical order)
Date,
}
/// Available sorting orders
#[derive(Debug, Deserialize, Default, Clone, EnumString, Display, Copy, ValueEnum)]
pub enum SortingOrder {
/// Ascending order
#[serde(alias = "asc")]
#[strum(serialize = "asc")]
Asc,
/// Descending order
#[default]
#[serde(alias = "desc")]
#[strum(serialize = "desc")]
Desc,
}
/// Possible entry types
#[derive(PartialEq, Clone, Display, Eq)]
#[strum(serialize_all = "snake_case")]
pub enum EntryType {
/// Entry is a directory
Directory,
/// Entry is a file
File,
}
/// Entry
pub struct Entry {
/// Name of the entry
pub name: String,
/// Type of the entry
pub entry_type: EntryType,
/// URL of the entry
pub link: String,
/// Size in byte of the entry. Only available for EntryType::File
pub size: Option<bytesize::ByteSize>,
/// Last modification date
pub last_modification_date: Option<SystemTime>,
/// Path of symlink pointed to
pub symlink_info: Option<String>,
}
impl Entry {
fn new(
name: String,
entry_type: EntryType,
link: String,
size: Option<bytesize::ByteSize>,
last_modification_date: Option<SystemTime>,
symlink_info: Option<String>,
) -> Self {
Self {
name,
entry_type,
link,
size,
last_modification_date,
symlink_info,
}
}
/// Returns whether the entry is a directory
pub fn is_dir(&self) -> bool {
self.entry_type == EntryType::Directory
}
/// Returns whether the entry is a file
pub fn is_file(&self) -> bool {
self.entry_type == EntryType::File
}
}
/// One entry in the path to the listed directory
pub struct Breadcrumb {
/// Name of directory
pub name: String,
/// Link to get to directory, relative to listed directory
pub link: String,
}
impl Breadcrumb {
fn new(name: String, link: String) -> Self {
Self { name, link }
}
}
pub async fn file_handler(req: HttpRequest) -> actix_web::Result<actix_files::NamedFile> {
let path = &req
.app_data::<web::Data<crate::MiniserveConfig>>()
.unwrap()
.path;
actix_files::NamedFile::open(path).map_err(Into::into)
}
/// List a directory and renders a HTML file accordingly
/// Adapted from https://docs.rs/actix-web/0.7.13/src/actix_web/fs.rs.html#564
pub fn directory_listing(
dir: &actix_files::Directory,
req: &HttpRequest,
) -> io::Result<ServiceResponse> {
let extensions = req.extensions();
let current_user: Option<&CurrentUser> = extensions.get::<CurrentUser>();
let conf = req.app_data::<web::Data<crate::MiniserveConfig>>().unwrap();
if conf.disable_indexing {
return Ok(ServiceResponse::new(
req.clone(),
HttpResponse::NotFound()
.content_type(mime::TEXT_PLAIN_UTF_8)
.body("File not found."),
));
}
let serve_path = req.path();
let base = Path::new(serve_path);
let random_route_abs = format!("/{}", conf.route_prefix);
let abs_uri = {
let res = Uri::builder()
.scheme(req.connection_info().scheme())
.authority(req.connection_info().host())
.path_and_query(req.path())
.build();
match res {
Ok(uri) => uri,
Err(err) => return Ok(ServiceResponse::from_err(err, req.clone())),
}
};
let is_root = base.parent().is_none() || Path::new(&req.path()) == Path::new(&random_route_abs);
let encoded_dir = match base.strip_prefix(random_route_abs) {
Ok(c_d) => Path::new("/").join(c_d),
Err(_) => base.to_path_buf(),
}
.display()
.to_string();
let breadcrumbs = {
let title = conf
.title
.clone()
.unwrap_or_else(|| req.connection_info().host().into());
let decoded = percent_decode_str(&encoded_dir).decode_utf8_lossy();
let mut res: Vec<Breadcrumb> = Vec::new();
let mut link_accumulator = format!("{}/", &conf.route_prefix);
let mut components = Path::new(&*decoded).components().peekable();
while let Some(c) = components.next() {
let name;
match c {
Component::RootDir => {
name = title.clone();
}
Component::Normal(s) => {
name = s.to_string_lossy().to_string();
link_accumulator
.push_str(&(utf8_percent_encode(&name, COMPONENT).to_string() + "/"));
}
_ => name = "".to_string(),
};
res.push(Breadcrumb::new(
name,
if components.peek().is_some() {
link_accumulator.clone()
} else {
".".to_string()
},
));
}
res
};
let query_params = extract_query_parameters(req);
let mut entries: Vec<Entry> = Vec::new();
let mut readme: Option<(String, String)> = None;
let readme_rx: Regex = Regex::new("^readme([.](md|txt))?$").unwrap();
for entry in dir.path.read_dir()? {
if dir.is_visible(&entry) || conf.show_hidden {
let entry = entry?;
// show file url as relative to static path
let file_name = entry.file_name().to_string_lossy().to_string();
let (is_symlink, metadata) = match entry.metadata() {
Ok(metadata) if metadata.file_type().is_symlink() => {
// for symlinks, get the metadata of the original file
(true, std::fs::metadata(entry.path()))
}
res => (false, res),
};
let symlink_dest = (is_symlink && conf.show_symlink_info)
.then(|| entry.path())
.and_then(|path| std::fs::read_link(path).ok())
.map(|path| path.to_string_lossy().into_owned());
let file_url = base
.join(utf8_percent_encode(&file_name, COMPONENT).to_string())
.to_string_lossy()
.to_string();
// if file is a directory, add '/' to the end of the name
if let Ok(metadata) = metadata {
if conf.no_symlinks && is_symlink {
continue;
}
let last_modification_date = metadata.modified().ok();
if metadata.is_dir() {
entries.push(Entry::new(
file_name,
EntryType::Directory,
file_url,
None,
last_modification_date,
symlink_dest,
));
} else if metadata.is_file() {
let file_link = match &conf.file_external_url {
Some(external_url) => {
// Construct the full relative path including subdirectories
// encoded_dir holds the current directory path relative to the prefix (e.g., /subdir1/subdir2)
let current_relative_dir = encoded_dir.trim_matches('/'); // Remove leading/trailing slashes if any
// Combine the relative directory path and the filename
let full_relative_path = if current_relative_dir.is_empty() {
// If in the root directory, just use the filename
utf8_percent_encode(&file_name, COMPONENT).to_string()
} else {
// Otherwise, join directory and filename
format!(
"{}/{}",
current_relative_dir,
utf8_percent_encode(&file_name, COMPONENT)
)
};
// Join the external external URL with the full relative path
format!(
"{}/{}",
external_url.trim_end_matches('/'), // Base URL without trailing slash
full_relative_path // Relative path (dir + file) - should not have leading slash here
)
}
None => file_url,
};
entries.push(Entry::new(
file_name.clone(),
EntryType::File,
file_link,
Some(ByteSize::b(metadata.len())),
last_modification_date,
symlink_dest,
));
if conf.readme && readme_rx.is_match(&file_name.to_lowercase()) {
let ext = file_name.split('.').next_back().unwrap().to_lowercase();
readme = Some((
file_name.to_string(),
if ext == "md" {
let mut options = ComrakOptions::default();
// Enable some GFM extensions
options.extension.strikethrough = true;
options.extension.table = true;
options.extension.autolink = true;
options.extension.tasklist = true;
markdown_to_html(&std::fs::read_to_string(entry.path())?, &options)
} else {
format!("<pre>{}</pre>", &std::fs::read_to_string(entry.path())?)
},
));
}
}
} else {
continue;
}
}
}
match query_params.sort.unwrap_or(conf.default_sorting_method) {
SortingMethod::Name => entries.sort_by(|e1, e2| {
alphanumeric_sort::compare_str(e1.name.to_lowercase(), e2.name.to_lowercase())
}),
SortingMethod::Size => entries.sort_by(|e1, e2| {
// If we can't get the size of the entry (directory for instance)
// let's consider it's 0b
e2.size
.unwrap_or_else(|| ByteSize::b(0))
.cmp(&e1.size.unwrap_or_else(|| ByteSize::b(0)))
}),
SortingMethod::Date => entries.sort_by(|e1, e2| {
// If, for some reason, we can't get the last modification date of an entry
// let's consider it was modified on UNIX_EPOCH (01/01/19270 00:00:00)
e2.last_modification_date
.unwrap_or(SystemTime::UNIX_EPOCH)
.cmp(&e1.last_modification_date.unwrap_or(SystemTime::UNIX_EPOCH))
}),
};
if let SortingOrder::Asc = query_params.order.unwrap_or(conf.default_sorting_order) {
entries.reverse()
}
// List directories first
if conf.dirs_first {
entries.sort_by_key(|e| !e.is_dir());
}
if let Some(archive_method) = query_params.download {
if !archive_method.is_enabled(conf.tar_enabled, conf.tar_gz_enabled, conf.zip_enabled) {
return Ok(ServiceResponse::new(
req.clone(),
HttpResponse::Forbidden()
.content_type(mime::TEXT_PLAIN_UTF_8)
.body("Archive creation is disabled."),
));
}
log::info!(
"Creating an archive ({extension}) of {path}...",
extension = archive_method.extension(),
path = &dir.path.display().to_string()
);
let file_name = format!(
"{}.{}",
dir.path.file_name().unwrap().to_str().unwrap(),
archive_method.extension()
);
// We will create the archive in a separate thread, and stream the content using a pipe.
// The pipe is made of a futures channel, and an adapter to implement the `Write` trait.
// Include 10 messages of buffer for erratic connection speeds.
let (tx, rx) = futures::channel::mpsc::channel::<io::Result<actix_web::web::Bytes>>(10);
let pipe = crate::pipe::Pipe::new(tx);
// Start the actual archive creation in a separate thread.
let dir = dir.path.to_path_buf();
let skip_symlinks = conf.no_symlinks;
std::thread::spawn(move || {
if let Err(err) = archive_method.create_archive(dir, skip_symlinks, pipe) {
log::error!("Error during archive creation: {err:?}");
}
});
Ok(ServiceResponse::new(
req.clone(),
HttpResponse::Ok()
.content_type(archive_method.content_type())
.append_header(("Content-Transfer-Encoding", "binary"))
.append_header((
"Content-Disposition",
format!("attachment; filename={file_name:?}"),
))
.body(actix_web::body::BodyStream::new(rx)),
))
} else {
Ok(ServiceResponse::new(
req.clone(),
HttpResponse::Ok().content_type(mime::TEXT_HTML_UTF_8).body(
renderer::page(
entries,
readme,
&abs_uri,
is_root,
query_params,
&breadcrumbs,
&encoded_dir,
conf,
current_user,
)
.into_string(),
),
))
}
}
pub fn extract_query_parameters(req: &HttpRequest) -> ListingQueryParameters {
match Query::<ListingQueryParameters>::from_query(req.query_string()) {
Ok(Query(query_params)) => query_params,
Err(e) => {
let err = RuntimeError::ParseError("query parameters".to_string(), e.to_string());
errors::log_error_chain(err.to_string());
ListingQueryParameters::default()
}
}
}
================================================
FILE: src/main.rs
================================================
use std::io::{self, IsTerminal, Write};
use std::net::{IpAddr, SocketAddr, TcpListener};
use std::thread;
use std::time::Duration;
use actix_files::NamedFile;
use actix_web::middleware::from_fn;
use actix_web::{
App, HttpRequest, HttpResponse, Responder,
dev::{ServiceRequest, ServiceResponse, fn_service},
guard,
http::{Method, header::ContentType},
middleware, web,
};
use actix_web_httpauth::middleware::HttpAuthentication;
use anyhow::Result;
use bytesize::ByteSize;
use clap::{CommandFactory, Parser, crate_version};
use colored::*;
use dav_server::{
DavHandler, DavMethodSet,
actix::{DavRequest, DavResponse},
};
use fast_qr::QRBuilder;
use log::{error, info, trace, warn};
use percent_encoding::percent_decode_str;
use serde::Deserialize;
mod archive;
mod args;
mod auth;
mod config;
mod consts;
mod errors;
mod file_op;
mod file_utils;
mod listing;
mod pipe;
mod renderer;
mod webdav_fs;
use crate::args::LogColor;
use crate::config::MiniserveConfig;
use crate::errors::{RuntimeError, StartupError};
use crate::file_op::recursive_dir_size;
use crate::webdav_fs::RestrictedFs;
static STYLESHEET: &str = grass::include!("data/style.scss");
fn main() -> Result<()> {
let args = args::CliArgs::parse();
if let Some(shell) = args.print_completions {
let mut clap_app = args::CliArgs::command();
let app_name = clap_app.get_name().to_string();
clap_complete::generate(shell, &mut clap_app, app_name, &mut io::stdout());
return Ok(());
}
if args.print_manpage {
let clap_app = args::CliArgs::command();
let man = clap_mangen::Man::new(clap_app);
man.render(&mut io::stdout())?;
return Ok(());
}
let miniserve_config = MiniserveConfig::try_from_args(args)?;
run(miniserve_config).inspect_err(|e| {
errors::log_error_chain(e.to_string());
})?;
Ok(())
}
#[actix_web::main(miniserve)]
async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupError> {
let log_level = if miniserve_config.verbose {
simplelog::LevelFilter::Info
} else {
simplelog::LevelFilter::Warn
};
let color_choice = match miniserve_config.log_color {
LogColor::Auto => {
if io::stdout().is_terminal() {
simplelog::ColorChoice::Auto
} else {
simplelog::ColorChoice::Never
}
}
LogColor::Always => {
colored::control::SHOULD_COLORIZE.set_override(true);
simplelog::ColorChoice::Always
}
LogColor::Never => {
colored::control::SHOULD_COLORIZE.set_override(false);
simplelog::ColorChoice::Never
}
};
trace!(
"Set log color, simplelog = {:?}, colored = {:?}",
color_choice,
colored::control::SHOULD_COLORIZE.should_colorize(),
);
simplelog::TermLogger::init(
log_level,
simplelog::ConfigBuilder::new()
.set_time_format_rfc2822()
.build(),
simplelog::TerminalMode::Mixed,
color_choice,
)
.or_else(|_| simplelog::SimpleLogger::init(log_level, simplelog::Config::default()))
.expect("Couldn't initialize logger");
if miniserve_config.no_symlinks && miniserve_config.path.is_symlink() {
return Err(StartupError::NoSymlinksOptionWithSymlinkServePath(
miniserve_config.path.to_string_lossy().to_string(),
));
}
if miniserve_config.webdav_enabled && miniserve_config.path.is_file() {
return Err(StartupError::WebdavWithFileServePath(
miniserve_config.path.to_string_lossy().to_string(),
));
}
let inside_config = miniserve_config.clone();
let canon_path = miniserve_config
.path
.canonicalize()
.map_err(|e| StartupError::IoError("Failed to resolve path to be served".to_string(), e))?;
// warn if --index is specified but not found
if let Some(ref index) = miniserve_config.index
&& !canon_path.join(index).exists()
&& !miniserve_config.quiet
{
warn!(
"The file '{}' provided for option --index could not be found.",
index.to_string_lossy(),
);
}
let path_string = canon_path.to_string_lossy();
if !miniserve_config.quiet {
println!(
"{name} v{version}",
name = "miniserve".bold(),
version = crate_version!()
);
}
if !miniserve_config.path_explicitly_chosen {
// If the path to serve has NOT been explicitly chosen and if this is NOT an interactive
// terminal, we should refuse to start for security reasons. This would be the case when
// running miniserve as a service but forgetting to set the path. This could be pretty
// dangerous if given with an undesired context path (for instance /root or /).
if !io::stdout().is_terminal() {
return Err(StartupError::NoExplicitPathAndNoTerminal);
}
if !miniserve_config.quiet {
warn!(
"miniserve has been invoked without an explicit path so it will serve the current directory after a short delay."
);
warn!(
"Invoke with -h|--help to see options or invoke as `miniserve .` to hide this advice."
);
print!("Starting server in ");
io::stdout()
.flush()
.map_err(|e| StartupError::IoError("Failed to write data".to_string(), e))?;
for c in "3… 2… 1… \n".chars() {
print!("{c}");
io::stdout()
.flush()
.map_err(|e| StartupError::IoError("Failed to write data".to_string(), e))?;
thread::sleep(Duration::from_millis(500));
}
}
}
let display_urls = {
let (mut ifaces, wildcard): (Vec<_>, Vec<_>) = miniserve_config
.interfaces
.clone()
.into_iter()
.partition(|addr| !addr.is_unspecified());
// Replace wildcard addresses with local interface addresses
if !wildcard.is_empty() {
let all_ipv4 = wildcard.iter().any(|addr| addr.is_ipv4());
let all_ipv6 = wildcard.iter().any(|addr| addr.is_ipv6());
ifaces = if_addrs::get_if_addrs()
.unwrap_or_else(|e| {
error!("Failed to get local interface addresses: {e}");
Default::default()
})
.into_iter()
.map(|iface| iface.ip())
.filter(|ip| (all_ipv4 && ip.is_ipv4()) || (all_ipv6 && ip.is_ipv6()))
.collect();
ifaces.sort();
}
ifaces
.into_iter()
.map(|addr| match addr {
IpAddr::V4(_) => format!("{}:{}", addr, miniserve_config.port),
IpAddr::V6(_) => format!("[{}]:{}", addr, miniserve_config.port),
})
.map(|addr| match miniserve_config.tls_rustls_config {
Some(_) => format!("https://{addr}"),
None => format!("http://{addr}"),
})
.map(|url| format!("{}{}", url, miniserve_config.route_prefix))
.collect::<Vec<_>>()
};
let socket_addresses = miniserve_config
.interfaces
.iter()
.map(|&interface| SocketAddr::new(interface, miniserve_config.port))
.collect::<Vec<_>>();
let display_sockets = socket_addresses
.iter()
.map(|sock| sock.to_string().green().bold().to_string())
.collect::<Vec<_>>();
let stylesheet = web::Data::new(
[
STYLESHEET,
inside_config.default_color_scheme.css(),
inside_config.default_color_scheme_dark.css_dark().as_str(),
]
.join("\n"),
);
let srv = actix_web::HttpServer::new(move || {
App::new()
.wrap(configure_header(&inside_config.clone()))
.app_data(web::Data::new(inside_config.clone()))
.app_data(stylesheet.clone())
.wrap(from_fn(errors::error_page_middleware))
.wrap(middleware::Logger::default())
.wrap(middleware::Condition::new(
miniserve_config.compress_response,
middleware::Compress::default(),
))
.route(&inside_config.healthcheck_route, web::get().to(healthcheck))
.route(&inside_config.api_route, web::post().to(api))
.route(&inside_config.favicon_route, web::get().to(favicon))
.route(&inside_config.css_route, web::get().to(css))
.service(
web::scope(&inside_config.route_prefix)
.wrap(middleware::Condition::new(
!inside_config.auth.is_empty(),
actix_web::middleware::Compat::new(HttpAuthentication::basic(
auth::handle_auth,
)),
))
.configure(|c| configure_app(c, &inside_config)),
)
.default_service(web::get().to(error_404))
});
let srv = socket_addresses.iter().try_fold(srv, |srv, addr| {
let listener = create_tcp_listener(*addr)
.map_err(|e| StartupError::IoError(format!("Failed to bind server to {addr}"), e))?;
#[cfg(feature = "tls")]
let srv = match &miniserve_config.tls_rustls_config {
Some(tls_config) => srv.listen_rustls_0_23(listener, tls_config.clone()),
None => srv.listen(listener),
};
#[cfg(not(feature = "tls"))]
let srv = srv.listen(listener);
srv.map_err(|e| StartupError::IoError(format!("Failed to bind server to {addr}"), e))
})?;
let srv = srv.shutdown_timeout(0).run();
if !miniserve_config.quiet {
println!("Bound to {}", display_sockets.join(", "));
println!("Serving path {}", path_string.yellow().bold());
println!(
"Available at (non-exhaustive list):\n {}\n",
display_urls
.iter()
.map(|url| url.green().bold().to_string())
.collect::<Vec<_>>()
.join("\n "),
);
}
// print QR code to terminal
if miniserve_config.show_qrcode && io::stdout().is_terminal() {
for url in display_urls
.iter()
.filter(|url| !url.contains("//127.0.0.1:") && !url.contains("//[::1]:"))
{
match QRBuilder::new(url.clone()).ecl(consts::QR_EC_LEVEL).build() {
Ok(qr) => {
println!("QR code for {}:", url.green().bold());
qr.print();
}
Err(e) => {
error!("Failed to render QR to terminal: {e:?}");
}
};
}
}
if !miniserve_config.quiet && io::stdout().is_terminal() {
println!("Quit by pressing CTRL-C");
}
srv.await
.map_err(|e| StartupError::IoError("".to_owned(), e))
}
/// Allows us to set low-level socket options
///
/// This mainly used to set `set_only_v6` socket option
/// to get a consistent behavior across platforms.
/// see: https://github.com/svenstaro/miniserve/pull/500
fn create_tcp_listener(addr: SocketAddr) -> io::Result<TcpListener> {
use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::for_address(addr), Type::STREAM, Some(Protocol::TCP))?;
if addr.is_ipv6() {
socket.set_only_v6(true)?;
}
socket.set_reuse_address(true)?;
socket.b
gitextract_74o55m93/
├── .cargo/
│ └── config.toml
├── .dockerignore
├── .editorconfig
├── .github/
│ ├── FUNDING.yml
│ ├── dependabot.yml
│ └── workflows/
│ ├── build-release.yml
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── Cargo.toml
├── Containerfile
├── Containerfile.alpine
├── LICENSE
├── Makefile
├── README.md
├── data/
│ ├── style.scss
│ └── themes/
│ ├── archlinux.scss
│ ├── ayu_dark.scss
│ ├── monokai.scss
│ ├── squirrel.scss
│ └── zenburn.scss
├── packaging/
│ └── miniserve@.service
├── release.toml
├── rustfmt.toml
├── src/
│ ├── archive.rs
│ ├── args.rs
│ ├── auth.rs
│ ├── config.rs
│ ├── consts.rs
│ ├── errors.rs
│ ├── file_op.rs
│ ├── file_utils.rs
│ ├── listing.rs
│ ├── main.rs
│ ├── pipe.rs
│ ├── renderer.rs
│ └── webdav_fs.rs
└── tests/
├── api.rs
├── archive.rs
├── auth.rs
├── auth_file.rs
├── bind.rs
├── cli.rs
├── create_directories.rs
├── data/
│ ├── auth1.txt
│ ├── cert.pem
│ ├── cert_ec.pem
│ ├── cert_rsa.pem
│ ├── generate_tls_certs.sh
│ ├── key_ec.pem
│ ├── key_pkcs1.pem
│ └── key_pkcs8.pem
├── fixtures/
│ └── mod.rs
├── header.rs
├── navigation.rs
├── paste.rs
├── qrcode.rs
├── raw.rs
├── readme.rs
├── rm_files.rs
├── serve_request.rs
├── tls.rs
├── upload_files.rs
├── utils/
│ └── mod.rs
└── webdav.rs
SYMBOL INDEX (277 symbols across 33 files)
FILE: src/archive.rs
type ArchiveMethod (line 17) | pub enum ArchiveMethod {
method extension (line 29) | pub fn extension(self) -> String {
method content_type (line 38) | pub fn content_type(self) -> String {
method is_enabled (line 47) | pub fn is_enabled(self, tar_enabled: bool, tar_gz_enabled: bool, zip_e...
method create_archive (line 60) | pub fn create_archive<T, W>(
function tar_gz (line 80) | fn tar_gz<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), Runt...
function tar_dir (line 118) | fn tar_dir<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), Run...
function tar (line 139) | fn tar<W>(
function create_zip_from_directory (line 196) | fn create_zip_from_directory<W>(
function zip_data (line 313) | fn zip_data<W>(src_dir: &Path, skip_symlinks: bool, mut out: W) -> Resul...
function zip_dir (line 332) | fn zip_dir<W>(dir: &Path, skip_symlinks: bool, out: W) -> Result<(), Run...
FILE: src/args.rs
type MediaType (line 13) | pub enum MediaType {
type DuplicateFile (line 20) | pub enum DuplicateFile {
type SizeDisplay (line 28) | pub enum SizeDisplay {
method fmt (line 34) | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
type LogColor (line 43) | pub enum LogColor {
type CliArgs (line 52) | pub struct CliArgs {
function parse_interface (line 465) | fn parse_interface(src: &str) -> Result<IpAddr, std::net::AddrParseError> {
function validate_is_dir_and_exists (line 470) | fn validate_is_dir_and_exists(s: &str) -> Result<PathBuf, String> {
type AuthParseError (line 483) | pub enum AuthParseError {
function parse_auth (line 504) | pub fn parse_auth(src: &str) -> Result<auth::RequiredAuth, AuthParseErro...
function parse_header (line 548) | pub fn parse_header(src: &str) -> Result<HeaderMap, httparse::Error> {
function parse_file_mode (line 567) | pub fn parse_file_mode(src: &str) -> Result<u16, std::num::ParseIntError> {
function create_required_auth (line 579) | fn create_required_auth(username: &str, password: &str, encrypt: &str) -...
function parse_auth_valid (line 602) | fn parse_auth_valid(auth_string: &str, username: &str, password: &str, e...
function parse_auth_invalid (line 628) | fn parse_auth_invalid(auth_string: &str, err_msg: &str) {
FILE: src/auth.rs
type BasicAuthParams (line 9) | pub struct BasicAuthParams {
method from (line 15) | fn from(auth: BasicAuth) -> Self {
type RequiredAuthPassword (line 25) | pub enum RequiredAuthPassword {
type RequiredAuth (line 33) | pub struct RequiredAuth {
function match_auth (line 39) | pub fn match_auth(basic_auth: &BasicAuthParams, required_auth: &[Require...
function compare_password (line 48) | pub fn compare_password(basic_auth_pwd: &str, required_auth_pwd: &Requir...
function compare_hash (line 61) | pub fn compare_hash<T: Digest>(password: &str, hash: &[u8]) -> bool {
function get_hash (line 66) | pub fn get_hash<T: Digest>(text: &str) -> Vec<u8> {
type CurrentUser (line 72) | pub struct CurrentUser {
function handle_auth (line 76) | pub async fn handle_auth(
function get_hash_func (line 104) | fn get_hash_func(name: &str) -> impl FnOnce(&str) -> Vec<u8> {
function test_get_hash (line 117) | fn test_get_hash(password: &str, hash_method: &str, hash: &str) {
function create_required_auth (line 125) | fn create_required_auth(username: &str, password: &str, encrypt: &str) -...
function test_single_auth (line 150) | fn test_single_auth(
function account_sample (line 172) | fn account_sample() -> Vec<RequiredAuth> {
function test_multiple_auth_pass (line 195) | fn test_multiple_auth_pass(
function test_multiple_auth_wrong_username (line 210) | fn test_multiple_auth_wrong_username(account_sample: Vec<RequiredAuth>) {
function test_multiple_auth_wrong_password (line 229) | fn test_multiple_auth_wrong_password(
FILE: src/config.rs
constant ROUTE_ALPHABET (line 25) | const ROUTE_ALPHABET: [char; 16] = [
type MiniserveConfig (line 31) | pub struct MiniserveConfig {
method try_from_args (line 207) | pub fn try_from_args(args: CliArgs) -> Result<Self> {
function validate_allowed_paths (line 385) | fn validate_allowed_paths(paths: &[impl AsRef<Path>], allow_hidden: bool...
FILE: src/consts.rs
constant QR_EC_LEVEL (line 4) | pub const QR_EC_LEVEL: ECL = ECL::L;
constant SVG_QR_MARGIN (line 7) | pub const SVG_QR_MARGIN: usize = 1;
FILE: src/errors.rs
type StartupError (line 16) | pub enum StartupError {
type RuntimeError (line 35) | pub enum RuntimeError {
method status_code (line 94) | fn status_code(&self) -> StatusCode {
method error_response (line 115) | fn error_response(&self) -> HttpResponse {
function error_page_middleware (line 132) | pub async fn error_page_middleware(
function map_error_page (line 157) | fn map_error_page(req: &HttpRequest, head: &mut ResponseHead, body: BoxB...
function log_error_chain (line 183) | pub fn log_error_chain(description: String) {
FILE: src/file_op.rs
type FileHash (line 35) | enum FileHash {
method get_hasher (line 41) | pub fn get_hasher(&self) -> Box<dyn DynDigest> {
method get_hash (line 48) | pub fn get_hash(&self) -> &str {
function recursive_dir_size (line 61) | pub async fn recursive_dir_size(dir: &Path) -> Result<u64, RuntimeError> {
function save_file (line 111) | async fn save_file(
type HandleMultipartOpts (line 298) | struct HandleMultipartOpts<'a> {
function handle_multipart (line 308) | async fn handle_multipart(
type FileOpQueryParameters (line 443) | pub struct FileOpQueryParameters {
function upload_file (line 452) | pub async fn upload_file(
function rm_file (line 549) | pub async fn rm_file(
FILE: src/file_utils.rs
function sanitize_path (line 12) | pub fn sanitize_path(path: impl AsRef<Path>, traverse_hidden: bool) -> O...
function contains_symlink (line 41) | pub fn contains_symlink(path: impl AsRef<Path>) -> io::Result<bool> {
function get_default_filemode (line 58) | pub fn get_default_filemode() -> u16 {
function test_sanitize_path (line 77) | fn test_sanitize_path(#[case] input: &str, #[case] output: &str) {
function test_sanitize_path_no_hidden_files (line 92) | fn test_sanitize_path_no_hidden_files(#[case] input: &str) {
FILE: src/listing.rs
constant QUERY (line 27) | pub const QUERY: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'#').add...
constant PATH (line 28) | pub const PATH: &AsciiSet = &QUERY.add(b'?').add(b'`').add(b'{').add(b'}');
constant USERINFO (line 29) | pub const USERINFO: &AsciiSet = &PATH
constant COMPONENT (line 40) | pub const COMPONENT: &AsciiSet = &USERINFO.add(b'$').add(b'%').add(b'&')...
type ListingQueryParameters (line 45) | pub struct ListingQueryParameters {
type SortingMethod (line 56) | pub enum SortingMethod {
type SortingOrder (line 70) | pub enum SortingOrder {
type EntryType (line 86) | pub enum EntryType {
type Entry (line 95) | pub struct Entry {
method new (line 116) | fn new(
method is_dir (line 135) | pub fn is_dir(&self) -> bool {
method is_file (line 140) | pub fn is_file(&self) -> bool {
type Breadcrumb (line 146) | pub struct Breadcrumb {
method new (line 155) | fn new(name: String, link: String) -> Self {
function file_handler (line 160) | pub async fn file_handler(req: HttpRequest) -> actix_web::Result<actix_f...
function directory_listing (line 170) | pub fn directory_listing(
function extract_query_parameters (line 451) | pub fn extract_query_parameters(req: &HttpRequest) -> ListingQueryParame...
FILE: src/main.rs
function main (line 50) | fn main() -> Result<()> {
function run (line 77) | async fn run(miniserve_config: MiniserveConfig) -> Result<(), StartupErr...
function create_tcp_listener (line 335) | fn create_tcp_listener(addr: SocketAddr) -> io::Result<TcpListener> {
function configure_header (line 347) | fn configure_header(conf: &MiniserveConfig) -> middleware::DefaultHeaders {
function configure_app (line 357) | fn configure_app(app: &mut web::ServiceConfig, conf: &MiniserveConfig) {
function dav_handler (line 474) | async fn dav_handler(req: DavRequest, davhandler: web::Data<DavHandler>)...
function error_404 (line 478) | async fn error_404(req: HttpRequest) -> Result<HttpResponse, RuntimeErro...
function healthcheck (line 482) | async fn healthcheck() -> impl Responder {
type ApiCommand (line 487) | enum ApiCommand {
function api (line 494) | async fn api(
function favicon (line 531) | async fn favicon() -> impl Responder {
function css (line 538) | async fn css(stylesheet: web::Data<String>) -> impl Responder {
FILE: src/pipe.rs
type Pipe (line 12) | pub struct Pipe {
method new (line 19) | pub fn new(destination: Sender<io::Result<Bytes>>) -> Self {
method drop (line 28) | fn drop(&mut self) {
method write (line 34) | fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
method flush (line 46) | fn flush(&mut self) -> io::Result<()> {
FILE: src/renderer.rs
function page (line 22) | pub fn page(
function raw (line 244) | pub fn raw(entries: Vec<Entry>, is_root: bool, conf: &MiniserveConfig) -...
function qr_code_svg (line 278) | fn qr_code_svg(url: &Uri, margin: usize) -> Result<String, QRCodeError> {
function breadcrumbs_to_path_string (line 288) | fn breadcrumbs_to_path_string(breadcrumbs: &[Breadcrumb]) -> String {
function version_footer (line 297) | fn version_footer() -> Markup {
function wget_footer (line 308) | fn wget_footer(
function build_upload_action (line 360) | fn build_upload_action(
function build_mkdir_action (line 378) | fn build_mkdir_action(mkdir_route: &str, encoded_dir: &str) -> String {
constant THEME_PICKER_CHOICES (line 382) | const THEME_PICKER_CHOICES: &[(&str, &str)] = &[
type ThemeSlug (line 392) | pub enum ThemeSlug {
method css (line 406) | pub fn css(&self) -> &str {
method css_dark (line 416) | pub fn css_dark(&self) -> String {
function qr_spoiler (line 422) | fn qr_spoiler(show_qrcode: bool, content: &Uri) -> Markup {
function color_scheme_selector (line 441) | fn color_scheme_selector(hide_theme_selector: bool) -> Markup {
function color_scheme_link (line 461) | fn color_scheme_link(color_scheme: &(&str, &str)) -> Markup {
function archive_button (line 472) | fn archive_button(
function make_link_with_trailing_slash (line 497) | fn make_link_with_trailing_slash(link: &str) -> String {
function parametrized_link (line 506) | fn parametrized_link(
function sortable_title (line 533) | fn sortable_title(
function rm_form (line 566) | fn rm_form(rm_route: &str, encoded_path: &str, prefix: &str) -> Markup {
type ActionsConf (line 578) | struct ActionsConf<'a> {
function entry_row (line 584) | fn entry_row(
function arrow_up (line 675) | fn arrow_up() -> Markup {
function chevron_left (line 680) | fn chevron_left() -> Markup {
function chevron_up (line 685) | fn chevron_up() -> Markup {
function chevron_down (line 690) | fn chevron_down() -> Markup {
function page_header (line 695) | fn page_header(
function convert_to_local (line 1154) | fn convert_to_local(src_time: Option<SystemTime>) -> Option<String> {
function humanize_systemtime (line 1162) | fn humanize_systemtime(time: Option<SystemTime>) -> Option<String> {
function render_error (line 1167) | pub fn render_error(
function to_html (line 1210) | fn to_html(wget_part: &str) -> String {
function uri (line 1216) | fn uri(x: &str) -> Uri {
function test_wget_footer_trivial (line 1221) | fn test_wget_footer_trivial() {
function test_wget_footer_with_root_dir (line 1229) | fn test_wget_footer_with_root_dir() {
function test_wget_footer_with_root_dir_and_user (line 1242) | fn test_wget_footer_with_root_dir_and_user() {
function test_wget_footer_escaping (line 1257) | fn test_wget_footer_escaping() {
function test_wget_footer_ip (line 1272) | fn test_wget_footer_ip() {
function test_wget_footer_externalurl (line 1280) | fn test_wget_footer_externalurl() {
function test_rm_form_strips_prefix (line 1293) | fn test_rm_form_strips_prefix() {
FILE: src/webdav_fs.rs
type RestrictedFs (line 23) | pub struct RestrictedFs {
method new (line 34) | pub fn new<P: AsRef<Path>>(base: P, show_hidden: bool, no_symlinks: bo...
method is_path_allowed (line 46) | async fn is_path_allowed(&self, path: &DavPath) -> bool {
function path_has_hidden_components (line 58) | fn path_has_hidden_components(path: &DavPath) -> bool {
function path_has_symlink_components (line 66) | async fn path_has_symlink_components(path: &DavPath, base_path: &Path) -...
method open (line 87) | fn open<'a>(
method read_dir (line 101) | fn read_dir<'a>(
method metadata (line 155) | fn metadata<'a>(&'a self, path: &'a DavPath) -> DavFsFuture<'a, Box<dyn ...
method symlink_metadata (line 165) | fn symlink_metadata<'a>(&'a self, path: &'a DavPath) -> DavFsFuture<'a, ...
FILE: tests/api.rs
function api_dir_size (line 19) | fn api_dir_size(
function api_dir_size_prevent_path_transversal_attacks (line 50) | fn api_dir_size_prevent_path_transversal_attacks(
FILE: tests/archive.rs
type ArchiveKind (line 12) | enum ArchiveKind {
method server_option (line 19) | fn server_option(&self) -> &'static str {
method link_text (line 27) | fn link_text(&self) -> &'static str {
method download_param (line 35) | fn download_param(&self) -> &'static str {
function fetch_index_document (line 44) | fn fetch_index_document(
function download_archive_bytes (line 55) | fn download_archive_bytes(
function assert_link_presence (line 67) | fn assert_link_presence(document: &Document, present: &[&str], absent: &...
function archives_are_disabled_links (line 88) | fn archives_are_disabled_links(server: TestServer, reqwest_client: Clien...
function archives_are_disabled_downloads (line 108) | fn archives_are_disabled_downloads(
function archives_are_disabled_when_indexing_disabled_links (line 121) | fn archives_are_disabled_when_indexing_disabled_links(
function archives_are_disabled_when_indexing_disabled_downloads (line 145) | fn archives_are_disabled_when_indexing_disabled_downloads(
function archives_links_and_downloads (line 162) | fn archives_links_and_downloads(
type ExpectedLen (line 207) | enum ExpectedLen {
function archive_behave_differently_with_broken_symlinks (line 222) | fn archive_behave_differently_with_broken_symlinks(
function zip_archives_store_entry_name_in_unix_style (line 243) | fn zip_archives_store_entry_name_in_unix_style(
FILE: tests/auth.rs
function auth_accepts (line 22) | fn auth_accepts(
function auth_rejects (line 69) | fn auth_rejects(
function auth_multiple_accounts_pass (line 111) | fn auth_multiple_accounts_pass(
function auth_multiple_accounts_wrong_username (line 135) | fn auth_multiple_accounts_wrong_username(
function auth_multiple_accounts_wrong_password (line 157) | fn auth_multiple_accounts_wrong_password(
FILE: tests/auth_file.rs
function auth_file_accepts (line 13) | fn auth_file_accepts(
function auth_file_rejects (line 40) | fn auth_file_rejects(
FILE: tests/bind.rs
function bind_fails (line 17) | fn bind_fails(tmpdir: TempDir, port: u16, #[case] args: &[&str]) -> Resu...
function bind_ipv4_ipv6 (line 35) | fn bind_ipv4_ipv6(
function validate_printed_urls (line 67) | fn validate_printed_urls(
FILE: tests/cli.rs
function help_shows (line 13) | fn help_shows() -> Result<(), Error> {
function version_shows (line 24) | fn version_shows() -> Result<(), Error> {
function print_completions (line 36) | fn print_completions() -> Result<(), Error> {
function print_completions_invalid_shell (line 50) | fn print_completions_invalid_shell() -> Result<(), Error> {
FILE: tests/create_directories.rs
function creating_directories_works (line 15) | fn creating_directories_works(
function creating_directories_is_prevented (line 62) | fn creating_directories_is_prevented(
function creating_directories_through_symlinks_is_prevented (line 109) | fn creating_directories_through_symlinks_is_prevented(
function prevent_path_transversal_attacks (line 154) | fn prevent_path_transversal_attacks(
FILE: tests/fixtures/mod.rs
type Error (line 16) | pub type Error = Box<dyn std::error::Error>;
function reqwest_client (line 66) | pub fn reqwest_client() -> Client {
function tmpdir (line 77) | pub fn tmpdir() -> TempDir {
function port (line 140) | pub fn port() -> u16 {
function server (line 147) | pub fn server<I>(#[default(&[] as &[&str])] args: I) -> TestServer
function wait_for_port (line 193) | fn wait_for_port(port: u16) {
type TestServer (line 205) | pub struct TestServer {
method new (line 214) | pub fn new(port: u16, tmpdir: TempDir, child: Child, is_tls: bool) -> ...
method url (line 223) | pub fn url(&self) -> Url {
method path (line 228) | pub fn path(&self) -> &std::path::Path {
method port (line 232) | pub fn port(&self) -> u16 {
method drop (line 238) | fn drop(&mut self) {
FILE: tests/header.rs
function custom_header_set (line 11) | fn custom_header_set(#[case] headers: Vec<String>, reqwest_client: Clien...
FILE: tests/navigation.rs
function index_gets_trailing_slash (line 21) | fn index_gets_trailing_slash(
function cant_navigate_up_the_root (line 35) | fn cant_navigate_up_the_root(server: TestServer) -> Result<(), Error> {
function can_navigate_into_dirs_and_back (line 54) | fn can_navigate_into_dirs_and_back(
function can_navigate_deep_into_dirs_and_back (line 87) | fn can_navigate_deep_into_dirs_and_back(
function can_navigate_using_breadcrumbs (line 136) | fn can_navigate_using_breadcrumbs(
function can_specify_default_sorting_order (line 174) | fn can_specify_default_sorting_order(
FILE: tests/paste.rs
function paste_entry_only_appears_with_flag (line 17) | fn paste_entry_only_appears_with_flag(
FILE: tests/qrcode.rs
function webpage_hides_qrcode_when_disabled (line 16) | fn webpage_hides_qrcode_when_disabled(
function webpage_shows_qrcode_when_enabled (line 31) | fn webpage_shows_qrcode_when_enabled(
function run_in_faketty_kill_and_get_stdout (line 53) | fn run_in_faketty_kill_and_get_stdout(template: &Command) -> Result<Stri...
function qrcode_hidden_in_tty_when_disabled (line 79) | fn qrcode_hidden_in_tty_when_disabled(tmpdir: TempDir, port: u16) -> Res...
function qrcode_shown_in_tty_when_enabled (line 92) | fn qrcode_shown_in_tty_when_enabled(tmpdir: TempDir, port: u16) -> Resul...
function qrcode_hidden_in_non_tty_when_enabled (line 107) | fn qrcode_hidden_in_non_tty_when_enabled(tmpdir: TempDir, port: u16) -> ...
FILE: tests/raw.rs
function ui_displays_wget_element (line 21) | fn ui_displays_wget_element(
function raw_mode_links_to_directories_end_with_raw_true (line 66) | fn raw_mode_links_to_directories_end_with_raw_true(
FILE: tests/readme.rs
function write_readme_contents (line 16) | fn write_readme_contents(path: PathBuf, filename: &str) -> PathBuf {
function assert_readme_contents (line 25) | fn assert_readme_contents(parsed_dom: &Document, filename: &str) {
function no_readme_contents (line 60) | fn no_readme_contents(server: TestServer, reqwest_client: Client) -> Res...
function show_root_readme_contents (line 90) | fn show_root_readme_contents(
function show_nested_readme_contents (line 123) | fn show_nested_readme_contents(
FILE: tests/rm_files.rs
constant NESTED_FILES_UNDER_SINGLE_ROOT (line 16) | const NESTED_FILES_UNDER_SINGLE_ROOT: &[&str] = &["someDir/alpha", "some...
function make_get_path (line 20) | fn make_get_path(unencoded_path: impl AsRef<Path>) -> String {
function make_del_path (line 38) | fn make_del_path(unencoded_path: impl AsRef<Path>) -> String {
function assert_rm_ok (line 44) | fn assert_rm_ok(
function assert_rm_err (line 76) | fn assert_rm_err(
function rm_disabled_by_default (line 118) | fn rm_disabled_by_default(
function rm_disabled_by_default_with_hidden (line 138) | fn rm_disabled_by_default_with_hidden(
function rm_works (line 154) | fn rm_works(
function cannot_rm_hidden_when_disallowed (line 167) | fn cannot_rm_hidden_when_disallowed(
function can_rm_hidden_when_allowed (line 180) | fn can_rm_hidden_when_allowed(
function rm_is_restricted (line 195) | fn rm_is_restricted(
function can_rm_allowed_dir (line 214) | fn can_rm_allowed_dir(
function can_rm_from_allowed_dir (line 237) | fn can_rm_from_allowed_dir(
function rm_from_symlinked_dir (line 249) | fn rm_from_symlinked_dir(
FILE: tests/serve_request.rs
function serves_requests_with_no_options (line 23) | fn serves_requests_with_no_options(reqwest_client: Client, tmpdir: TempD...
function serves_requests_with_non_default_port (line 49) | fn serves_requests_with_non_default_port(
function serves_requests_for_special_routes (line 109) | fn serves_requests_for_special_routes(
function serves_requests_hidden_files (line 123) | fn serves_requests_hidden_files(
function serves_requests_no_hidden_files_without_flag (line 167) | fn serves_requests_no_hidden_files_without_flag(
function serves_requests_nested_in_symlinks (line 194) | fn serves_requests_nested_in_symlinks(
function serves_requests_symlinks (line 224) | fn serves_requests_symlinks(
function serves_requests_with_randomly_assigned_port (line 290) | fn serves_requests_with_randomly_assigned_port(tmpdir: TempDir) -> Resul...
function serves_requests_custom_index_notice (line 314) | fn serves_requests_custom_index_notice(tmpdir: TempDir, port: u16) -> Re...
function index_fallback_to_listing (line 340) | fn index_fallback_to_listing(
function serve_index_instead_of_404_in_spa_mode (line 357) | fn serve_index_instead_of_404_in_spa_mode(
function serve_file_instead_of_404_in_pretty_urls_mode (line 381) | fn serve_file_instead_of_404_in_pretty_urls_mode(
function serves_requests_with_route_prefix (line 404) | fn serves_requests_with_route_prefix(
function serves_requests_static_file_check (line 423) | fn serves_requests_static_file_check(
function serves_no_directory_if_indexing_disabled (line 448) | fn serves_no_directory_if_indexing_disabled(
function serves_file_requests_when_indexing_disabled (line 486) | fn serves_file_requests_when_indexing_disabled(
FILE: tests/tls.rs
function tls_works (line 25) | fn tls_works(#[case] server: TestServer, reqwest_client: Client) -> Resu...
function wrong_path_cert (line 40) | fn wrong_path_cert() -> Result<(), Error> {
function wrong_path_key (line 52) | fn wrong_path_key() -> Result<(), Error> {
FILE: tests/upload_files.rs
function uploading_files_works (line 35) | fn uploading_files_works(
function uploading_files_is_prevented (line 88) | fn uploading_files_is_prevented(server: TestServer, reqwest_client: Clie...
function uploading_files_with_invalid_sha_func_is_prevented (line 144) | fn uploading_files_with_invalid_sha_func_is_prevented(
function uploading_files_is_restricted (line 198) | fn uploading_files_is_restricted(
function uploading_files_to_allowed_dir_works (line 238) | fn uploading_files_to_allowed_dir_works(
function uploading_duplicate_file_is_prevented (line 288) | fn uploading_duplicate_file_is_prevented(
function overwrite_duplicate_file (line 345) | fn overwrite_duplicate_file(
function rename_duplicate_file (line 398) | fn rename_duplicate_file(#[case] server: TestServer, reqwest_client: Cli...
function prevent_path_traversal_attacks (line 462) | fn prevent_path_traversal_attacks(
function upload_to_symlink_directory (line 504) | fn upload_to_symlink_directory(
function set_media_type (line 548) | fn set_media_type(
function assert_file_contents (line 565) | fn assert_file_contents(file_path: &Path, contents: &str) {
function chmod (line 576) | fn chmod(
FILE: tests/utils/mod.rs
function get_link_from_text (line 7) | pub fn get_link_from_text(document: &Document, text: &str) -> Option<Str...
function get_link_hrefs_with_prefix (line 15) | pub fn get_link_hrefs_with_prefix(document: &Document, prefix: &str) -> ...
FILE: tests/webdav.rs
function webdav_flag_works (line 24) | fn webdav_flag_works(
function webdav_advertised_in_options (line 40) | fn webdav_advertised_in_options(
function list_webdav (line 58) | fn list_webdav(url: url::Url, path: &str) -> Result<Vec<ListEntity>, req...
function webdav_respects_hidden_flag (line 75) | fn webdav_respects_hidden_flag(
function webdav_respects_no_symlink_flag (line 101) | fn webdav_respects_no_symlink_flag(#[case] server: TestServer, #[case] s...
function webdav_works_with_route_prefix (line 132) | fn webdav_works_with_route_prefix(
function webdav_single_file_refuses_starting (line 153) | fn webdav_single_file_refuses_starting(tmpdir: TempDir) {
Condensed preview — 65 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (402K chars).
[
{
"path": ".cargo/config.toml",
"chars": 161,
"preview": "[target.x86_64-pc-windows-msvc]\nrustflags = [\"-C\", \"target-feature=+crt-static\"]\n\n[target.i686-pc-windows-msvc]\nrustflag"
},
{
"path": ".dockerignore",
"chars": 7,
"preview": "target\n"
},
{
"path": ".editorconfig",
"chars": 44,
"preview": "[*.rs]\nindent_style = space\nindent_size = 4\n"
},
{
"path": ".github/FUNDING.yml",
"chars": 18,
"preview": "github: svenstaro\n"
},
{
"path": ".github/dependabot.yml",
"chars": 176,
"preview": "version: 2\nupdates:\n - package-ecosystem: cargo\n directory: \"/\"\n schedule:\n interval: monthly\n groups:\n "
},
{
"path": ".github/workflows/build-release.yml",
"chars": 8185,
"preview": "name: Build/publish release\n\non: [push, pull_request]\n\njobs:\n publish:\n name: Binary ${{ matrix.target }} (on ${{ ma"
},
{
"path": ".github/workflows/ci.yml",
"chars": 653,
"preview": "name: CI\n\non: [push, pull_request]\n\njobs:\n ci:\n name: ${{ matrix.os }}\n runs-on: ${{ matrix.os }}\n strategy:\n "
},
{
"path": ".gitignore",
"chars": 165,
"preview": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n\n# These are backup files generated by rustfmt\n"
},
{
"path": "CHANGELOG.md",
"chars": 20542,
"preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Change"
},
{
"path": "Cargo.toml",
"chars": 2811,
"preview": "[package]\nname = \"miniserve\"\nversion = \"0.33.0\"\ndescription = \"For when you really just want to serve some files over HT"
},
{
"path": "Containerfile",
"chars": 88,
"preview": "FROM debian:testing-slim\nCOPY --chmod=755 miniserve /app/\nENTRYPOINT [\"/app/miniserve\"]\n"
},
{
"path": "Containerfile.alpine",
"chars": 85,
"preview": "FROM docker.io/alpine\nCOPY --chmod=755 miniserve /app/\nENTRYPOINT [\"/app/miniserve\"]\n"
},
{
"path": "LICENSE",
"chars": 1075,
"preview": "MIT License\n\nCopyright (c) 2018 Sven-Hendrik Haase\n\nPermission is hereby granted, free of charge, to any person obtainin"
},
{
"path": "Makefile",
"chars": 852,
"preview": ".PHONY: local\nlocal:\n\tcargo build --release\n\n.PHONY: run\nrun:\nifndef ARGS\n\t@echo Run \"make run\" with ARGS set to pass ar"
},
{
"path": "README.md",
"chars": 19959,
"preview": "<p align=\"center\">\n <img src=\"data/logo.svg\" alt=\"miniserve - a CLI tool to serve files and dirs over HTTP\"><br>\n</p>\n\n"
},
{
"path": "data/style.scss",
"chars": 15655,
"preview": "@use \"themes/archlinux\" with ($generate_default: false);\n@use \"themes/ayu_dark\" with ($generate_default: false);\n@use \"t"
},
{
"path": "data/themes/archlinux.scss",
"chars": 2079,
"preview": "$generate_default: true !default;\n\n@mixin theme {\n --background: #383c4a;\n --text_color: #fefefe;\n --directory_link_c"
},
{
"path": "data/themes/ayu_dark.scss",
"chars": 2079,
"preview": "$generate_default: true !default;\n\n@mixin theme {\n --background: #0d1017;\n --text_color: #bfbdb6;\n --directory_link_c"
},
{
"path": "data/themes/monokai.scss",
"chars": 2079,
"preview": "$generate_default: true !default;\n\n@mixin theme {\n --background: #272822;\n --text_color: #f8f8f2;\n --directory_link_c"
},
{
"path": "data/themes/squirrel.scss",
"chars": 2077,
"preview": "$generate_default: true !default;\n\n@mixin theme {\n --background: #ffffff;\n --text_color: #323232;\n --directory_link_c"
},
{
"path": "data/themes/zenburn.scss",
"chars": 2079,
"preview": "$generate_default: true !default;\n\n@mixin theme {\n --background: #3f3f3f;\n --text_color: #efefef;\n --directory_link_c"
},
{
"path": "packaging/miniserve@.service",
"chars": 562,
"preview": "[Unit]\nDescription=miniserve for %i\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nExecStart=/usr/bi"
},
{
"path": "release.toml",
"chars": 841,
"preview": "sign-commit = true\nsign-tag = true\npre-release-replacements = [\n {file=\"CHANGELOG.md\", search=\"Unreleased\", replace=\"{{"
},
{
"path": "rustfmt.toml",
"chars": 145,
"preview": "# This empty config file ensures the default formatter settings are enforced for\n# all contributors, regardless of their"
},
{
"path": "src/archive.rs",
"chars": 10983,
"preview": "use std::fs::File;\nuse std::io::{Cursor, Read, Write};\nuse std::path::{Path, PathBuf};\n\nuse libflate::gzip::Encoder;\nuse"
},
{
"path": "src/args.rs",
"chars": 21255,
"preview": "use std::fmt::Display;\nuse std::net::IpAddr;\nuse std::path::PathBuf;\n\nuse actix_web::http::header::{HeaderMap, HeaderNam"
},
{
"path": "src/auth.rs",
"chars": 7622,
"preview": "use actix_web::{HttpMessage, dev::ServiceRequest, web};\nuse actix_web_httpauth::extractors::basic::BasicAuth;\nuse sha2::"
},
{
"path": "src/config.rs",
"chars": 13348,
"preview": "use std::{\n fs::File,\n io::{BufRead, BufReader},\n net::{IpAddr, Ipv4Addr, Ipv6Addr},\n path::{Path, PathBuf},"
},
{
"path": "src/consts.rs",
"chars": 215,
"preview": "use fast_qr::ECL;\n\n/// The error correction level to use for all QR code generation.\npub const QR_EC_LEVEL: ECL = ECL::L"
},
{
"path": "src/errors.rs",
"chars": 6534,
"preview": "use std::str::FromStr;\n\nuse actix_web::{\n HttpRequest, HttpResponse, ResponseError,\n body::{BoxBody, MessageBody},"
},
{
"path": "src/file_op.rs",
"chars": 22702,
"preview": "//! Handlers for file upload and removal\n\n#[cfg(target_family = \"unix\")]\nuse std::collections::HashSet;\n\nuse std::io::Er"
},
{
"path": "src/file_utils.rs",
"chars": 2769,
"preview": "#[cfg(unix)]\nuse rustix::{fs::Mode, process::umask};\nuse std::{\n io,\n path::{Component, Path, PathBuf},\n};\n\n/// Gu"
},
{
"path": "src/listing.rs",
"chars": 16266,
"preview": "#![allow(clippy::format_push_string)]\nuse std::io;\nuse std::path::{Component, Path};\nuse std::time::SystemTime;\n\nuse act"
},
{
"path": "src/main.rs",
"chars": 19154,
"preview": "use std::io::{self, IsTerminal, Write};\nuse std::net::{IpAddr, SocketAddr, TcpListener};\nuse std::thread;\nuse std::time:"
},
{
"path": "src/pipe.rs",
"chars": 1438,
"preview": "//! Define an adapter to implement `std::io::Write` on `Sender<Bytes>`.\nuse std::io::{self, Error, ErrorKind, Write};\n\nu"
},
{
"path": "src/renderer.rs",
"chars": 56128,
"preview": "use std::time::SystemTime;\n\nuse actix_web::http::{StatusCode, Uri};\nuse chrono::{DateTime, Local};\nuse chrono_humanize::"
},
{
"path": "src/webdav_fs.rs",
"chars": 6155,
"preview": "//! Helper types and functions to allow configuring hidden files visibility\n//! for WebDAV handlers\n\nuse dav_server::{\n "
},
{
"path": "tests/api.rs",
"chars": 1916,
"preview": "use std::collections::HashMap;\n\nuse percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};\nuse reqwest::{StatusCode,"
},
{
"path": "tests/archive.rs",
"chars": 7998,
"preview": "use std::io::Cursor;\n\nuse reqwest::{StatusCode, blocking::Client};\nuse rstest::rstest;\nuse select::{document::Document, "
},
{
"path": "tests/auth.rs",
"chars": 4928,
"preview": "use pretty_assertions::assert_eq;\nuse reqwest::{StatusCode, blocking::Client};\nuse rstest::rstest;\nuse select::{document"
},
{
"path": "tests/auth_file.rs",
"chars": 1468,
"preview": "use reqwest::{StatusCode, blocking::Client};\nuse rstest::rstest;\nuse select::{document::Document, predicate::Text};\n\nmod"
},
{
"path": "tests/bind.rs",
"chars": 2772,
"preview": "use std::io::{BufRead, BufReader};\nuse std::process::{Command, Stdio};\n\nuse assert_cmd::{cargo, prelude::*};\nuse assert_"
},
{
"path": "tests/cli.rs",
"chars": 1247,
"preview": "use std::process::Command;\n\nuse assert_cmd::{cargo, prelude::*};\nuse clap::{ValueEnum, crate_name, crate_version};\nuse c"
},
{
"path": "tests/create_directories.rs",
"chars": 5740,
"preview": "use reqwest::blocking::{Client, multipart};\nuse rstest::rstest;\nuse select::{\n document::Document,\n predicate::{At"
},
{
"path": "tests/data/auth1.txt",
"chars": 90,
"preview": "joe:123\nbob:sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3\nbill:\n"
},
{
"path": "tests/data/cert.pem",
"chars": 1805,
"preview": "-----BEGIN CERTIFICATE-----\nMIIFCTCCAvGgAwIBAgIUJUf2QS/pOdHEW4EHTfdXxeTvtM8wDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEAwwJbG9jYWx"
},
{
"path": "tests/data/cert_ec.pem",
"chars": 660,
"preview": "-----BEGIN CERTIFICATE-----\nMIIBujCCAUCgAwIBAgIUd5MqZqnOPFxMKaYipL6S6B3D3cswCgYIKoZIzj0EAwIw\nFDESMBAGA1UEAwwJbG9jYWxob3N"
},
{
"path": "tests/data/cert_rsa.pem",
"chars": 1805,
"preview": "-----BEGIN CERTIFICATE-----\nMIIFCTCCAvGgAwIBAgIUTUIU8j6S7RXbFFhW/yFftSQvUCYwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEAwwJbG9jYWx"
},
{
"path": "tests/data/generate_tls_certs.sh",
"chars": 326,
"preview": "#!/usr/bin/env bash\nopenssl req -subj '/CN=localhost' -x509 -newkey rsa:4096 -keyout key_pkcs8.pem -out cert_rsa.pem -no"
},
{
"path": "tests/data/key_ec.pem",
"chars": 306,
"preview": "-----BEGIN PRIVATE KEY-----\nMIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBZiwh8yRBpDgAx+Oa4\nqgvw0OOBiDnOHgY9+WuIA74dfGb"
},
{
"path": "tests/data/key_pkcs1.pem",
"chars": 3272,
"preview": "-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCtWxf+SrOokC+5\nOJeIllQDJjLevGjWSvAFjgp6PMr"
},
{
"path": "tests/data/key_pkcs8.pem",
"chars": 3272,
"preview": "-----BEGIN PRIVATE KEY-----\nMIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCtWxf+SrOokC+5\nOJeIllQDJjLevGjWSvAFjgp6PMr"
},
{
"path": "tests/fixtures/mod.rs",
"chars": 7188,
"preview": "use std::io::{BufRead, BufReader};\nuse std::process::{Child, Command, Stdio};\nuse std::thread;\nuse std::thread::sleep;\nu"
},
{
"path": "tests/header.rs",
"chars": 772,
"preview": "use reqwest::blocking::Client;\nuse rstest::rstest;\n\nmod fixtures;\n\nuse crate::fixtures::{Error, reqwest_client, server};"
},
{
"path": "tests/navigation.rs",
"chars": 6804,
"preview": "use std::path::{Component, Path};\nuse std::process::{Command, Stdio};\n\nuse pretty_assertions::{assert_eq, assert_ne};\nus"
},
{
"path": "tests/paste.rs",
"chars": 1180,
"preview": "use reqwest::blocking::Client;\nuse rstest::rstest;\nuse select::{document::Document, predicate::Attr};\n\nmod fixtures;\n\nus"
},
{
"path": "tests/qrcode.rs",
"chars": 3521,
"preview": "use std::process::{Command, Stdio};\nuse std::thread::sleep;\nuse std::time::Duration;\n\nuse assert_cmd::cargo;\nuse assert_"
},
{
"path": "tests/raw.rs",
"chars": 2953,
"preview": "use pretty_assertions::assert_eq;\nuse reqwest::blocking::Client;\nuse rstest::rstest;\nuse select::{\n document::Documen"
},
{
"path": "tests/readme.rs",
"chars": 4310,
"preview": "use std::fs::{File, remove_file};\nuse std::io::Write;\nuse std::path::PathBuf;\n\nuse reqwest::blocking::Client;\nuse rstest"
},
{
"path": "tests/rm_files.rs",
"chars": 9002,
"preview": "mod fixtures;\n\nuse assert_fs::fixture::TempDir;\nuse fixtures::{Error, TestServer, server, tmpdir};\nuse percent_encoding:"
},
{
"path": "tests/serve_request.rs",
"chars": 14743,
"preview": "use std::process::{Command, Stdio};\nuse std::thread::sleep;\nuse std::time::Duration;\n\nuse assert_cmd::cargo;\nuse assert_"
},
{
"path": "tests/tls.rs",
"chars": 1728,
"preview": "use assert_cmd::{Command, cargo};\nuse predicates::str::contains;\nuse reqwest::blocking::Client;\nuse rstest::rstest;\nuse "
},
{
"path": "tests/upload_files.rs",
"chars": 21228,
"preview": "use std::fs::create_dir_all;\nuse std::path::Path;\n\nuse assert_fs::fixture::TempDir;\nuse reqwest::blocking::{Client, mult"
},
{
"path": "tests/utils/mod.rs",
"chars": 904,
"preview": "use select::document::Document;\nuse select::node::Node;\nuse select::predicate::Name;\nuse select::predicate::Predicate;\n\n"
},
{
"path": "tests/webdav.rs",
"chars": 5041,
"preview": "use std::process::Command;\n\nuse assert_cmd::{cargo, prelude::*};\nuse assert_fs::TempDir;\nuse predicates::str::contains;\n"
}
]
About this extraction
This page contains the full source code of the svenstaro/miniserve GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 65 files (375.0 KB), approximately 100.3k tokens, and a symbol index with 277 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.