Compare commits
97 Commits
Author | SHA1 | Date |
---|---|---|
Edward Shen | 0f27cfb788 | |
Edward Shen | 768f944b36 | |
Edward Shen | 1588deb073 | |
Edward Shen | 8920c57341 | |
Edward Shen | 0144cf5f50 | |
Edward Shen | 72e672bc73 | |
Edward Shen | 3561f488c1 | |
Edward Shen | 4055b9dee4 | |
Edward Shen | f1d7797637 | |
Edward Shen | 90ff4461a6 | |
Edward Shen | ce592985ce | |
Edward Shen | 0132d32507 | |
Edward Shen | dc216a80d5 | |
Edward Shen | 531a7da636 | |
Edward Shen | ce68f4dd42 | |
Edward Shen | 411854385c | |
Edward Shen | 49e1c8ce0c | |
Edward Shen | 15ab316963 | |
Edward Shen | 543c13a500 | |
Edward Shen | 72ab5f29d8 | |
Edward Shen | 2005c43066 | |
Edward Shen | 6b51a81679 | |
Edward Shen | a8f2e0cfd7 | |
Edward Shen | 60b7dc4219 | |
Edward Shen | 75fb530ba0 | |
Edward Shen | ce21a63f16 | |
Edward Shen | b1cdce7c85 | |
Edward Shen | 83a2ba0f8a | |
Edward Shen | 46261bdfa0 | |
Edward Shen | 0df0c60013 | |
Edward Shen | 7585687710 | |
Edward Shen | 7fdf451470 | |
Edward Shen | abbd1d9fea | |
Edward Shen | 5d7629487a | |
Edward Shen | 630f7f803a | |
Edward Shen | 50dce3a80b | |
Edward Shen | 7995babb64 | |
Edward Shen | 39a1037b33 | |
Edward Shen | 10731c22f4 | |
Edward Shen | 50360005f6 | |
Edward Shen | 529f6a1ade | |
Edward Shen | 1311cfa532 | |
Edward Shen | 9df8ea3558 | |
Edward Shen | dab4d52f4d | |
Edward Shen | c50b4493ab | |
Edward Shen | 323fa6ba71 | |
Edward Shen | a8fba09955 | |
Edward Shen | ed462ba67e | |
Edward Shen | 4226b75f18 | |
Edward Shen | 7a5910ce26 | |
Edward Shen | 7faf15889a | |
Edward Shen | 633a152f89 | |
Edward Shen | 9c81ff46e4 | |
Edward Shen | ae28ee8a54 | |
Edward Shen | 0ffa1419bc | |
Edward Shen | 73f25b9ae8 | |
Edward Shen | 843efc2e62 | |
Edward Shen | 9cf01f5991 | |
Edward Shen | 8cdd216b39 | |
Edward Shen | d6b3d4e143 | |
Edward Shen | 153512a800 | |
Edward Shen | 2835b8f646 | |
Edward Shen | f109734a32 | |
Edward Shen | 70c7747ca9 | |
Edward Shen | 9cc2d5952e | |
Edward Shen | ec04821462 | |
Edward Shen | 4e1db78ed9 | |
Edward Shen | fd941b014a | |
Edward Shen | 961cbd721b | |
Edward Shen | 1d7d547475 | |
Edward Shen | b8be3d8d53 | |
Edward Shen | cc7ac3e617 | |
Edward Shen | a16e874830 | |
Edward Shen | 68099c74fb | |
Edward Shen | e619b2cffc | |
Edward Shen | 6e2deccf24 | |
Edward Shen | 71df3394ad | |
Edward Shen | c990aef0e9 | |
Edward Shen | 1385045013 | |
Edward Shen | a4543c48ec | |
Edward Shen | cf85a6494a | |
Edward Shen | 5a122371da | |
Edward Shen | 97f5fa1455 | |
Edward Shen | 776d5ead9b | |
Edward Shen | 887d5bf3c2 | |
Edward Shen | 6e9233c21d | |
Edward Shen | 03918ea53e | |
Edward Shen | 4bbdb2f45f | |
Edward Shen | 335baed266 | |
Edward Shen | b835e5b2e9 | |
Edward Shen | 0a8af55f05 | |
Edward Shen | 062c8f5a2e | |
Edward Shen | b59cb4f9e3 | |
Edward Shen | 983ad3733e | |
Edward Shen | 0a19214f36 | |
Edward Shen | fb48d77a10 | |
Edward Shen | 5bfcb972c5 |
|
@ -0,0 +1,15 @@
|
|||
name: Rust
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
|
@ -1,3 +1,5 @@
|
|||
/target
|
||||
**/*.rs.bk
|
||||
bunbun.toml
|
||||
out
|
||||
tarpaulin-report.html
|
|
@ -1,10 +1,15 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"Deserialization",
|
||||
"Hotwatch",
|
||||
"Serializer",
|
||||
"actix",
|
||||
"bunbun",
|
||||
"bunbunsearch",
|
||||
"canonicalize",
|
||||
"itertools",
|
||||
"opensearchdescription"
|
||||
]
|
||||
"opensearchdescription",
|
||||
"tempfile"
|
||||
],
|
||||
"python.pythonPath": "/usr/bin/python3"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
|
@ -1,25 +1,34 @@
|
|||
[package]
|
||||
name = "bunbun"
|
||||
version = "0.4.0"
|
||||
version = "0.8.1"
|
||||
authors = ["Edward Shen <code@eddie.sh>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
description = "Re-implementation of bunny1 in Rust"
|
||||
license = "AGPL-3.0"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/edward-shen/bunbun"
|
||||
exclude = ["/aux/"]
|
||||
|
||||
[dependencies]
|
||||
actix-web = "1.0"
|
||||
serde = "1.0"
|
||||
serde_yaml = "0.8"
|
||||
handlebars = "2.0"
|
||||
anyhow = "1"
|
||||
arc-swap = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
axum = "0.6"
|
||||
clap = { version = "4", features = ["wrap_help", "derive", "cargo"] }
|
||||
dirs = "4"
|
||||
handlebars = "4"
|
||||
hotwatch = "0.4"
|
||||
percent-encoding = "2.1"
|
||||
itertools = "0.8"
|
||||
libc = "0.2"
|
||||
log = "0.4"
|
||||
simple_logger = "1.3"
|
||||
clap = { version = "2.33", features = ["yaml", "wrap_help"] }
|
||||
percent-encoding = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
# Maintainer: Edward Shen <code@eddie.sh>
|
||||
#
|
||||
# You should _always_ use the latest PKGBUILD from master, as each releases
|
||||
# PKGBUILD will contain the previous release's PKGBUILD. This is because one
|
||||
# cannot generate the sha512sum of the release until it's been created, and this
|
||||
# file would be part of said release.
|
||||
|
||||
pkgname=bunbun
|
||||
pkgver=0.8.0
|
||||
pkgrel=1
|
||||
depends=('gcc-libs')
|
||||
makedepends=('rust' 'cargo')
|
||||
arch=('i686' 'x86_64' 'armv6h' 'armv7h')
|
||||
pkgdesc="Re-implementation of bunny1 in Rust"
|
||||
url="https://github.com/edward-shen/bunbun"
|
||||
license=('AGPL')
|
||||
source=("$pkgname-$pkgver.tar.gz::https://github.com/edward-shen/$pkgname/archive/$pkgver.tar.gz")
|
||||
sha512sums=('55ecc42176e57863c87d7196e41f4971694eda7d74200214e2a64b6bb3b54c5990ab224301253317e38b079842318315891159113b6de754cd91171c808660bb')
|
||||
|
||||
build() {
|
||||
cd "$pkgname-$pkgver"
|
||||
|
||||
cargo build --release --locked
|
||||
strip "target/release/$pkgname" || true
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "$pkgname-$pkgver"
|
||||
|
||||
cargo test --release --locked
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$pkgname-$pkgver"
|
||||
|
||||
install -Dm755 "target/release/$pkgname" -t "$pkgdir/usr/bin"
|
||||
install -Dm644 "aux/systemd/$pkgname.service" -t "$pkgdir/usr/lib/systemd/system"
|
||||
install -Dm644 "$pkgname.default.yaml" "$pkgdir/etc/$pkgname.yaml"
|
||||
}
|
47
README.md
47
README.md
|
@ -3,7 +3,7 @@
|
|||
_Self-hostable, easy-to-configure, fast search/jump multiplexer service._
|
||||
|
||||
bunbun is a pure-[Rust][rust-lang] implementation of [bunny1][bunny1], providing
|
||||
a customizable search engine and quick-jump tool in one.
|
||||
a customizable search engine and quick-jump tool in one small binary.
|
||||
|
||||
After adding it to your web-browser and setting it as your default search
|
||||
engine, you'll gain the ability to quick-jump to a specific page or search from
|
||||
|
@ -18,12 +18,44 @@ foo bar // If foo is a defined command, do something with bar
|
|||
// query for the default route
|
||||
```
|
||||
|
||||
## Reasons to use bunbun
|
||||
|
||||
- Convenient: bunbun watches for config changes and refreshes its routes
|
||||
automatically, allowing for rapid development.
|
||||
- Extensible: supports simple route substitution or execution of arbitrary
|
||||
programs for complex route resolution.
|
||||
- Portable: bunbun runs off a single binary and config file.
|
||||
- Small: binary is 1.3MB (after running `strip` and `upx --lzma` on the release
|
||||
binary).
|
||||
- Memory-safe: Built with [Rust][rust-lang].
|
||||
|
||||
## Installation
|
||||
|
||||
If you have `cargo`, you can simply run `cargo install bunbun`.
|
||||
|
||||
Once installed, simply run it. A default config file will be created if one does
|
||||
not exist. You should model your own custom routes after the provided ones.
|
||||
not exist.
|
||||
|
||||
If you're looking to run this as a daemon (as most would do), you should put the
|
||||
binary in `/usr/bin` and copy `aux/systemd/bunbun.service` into your preferred
|
||||
systemd system folder. Then you may run `systemctl enable bunbun --now` to start
|
||||
a daemon instance of bunbun.
|
||||
|
||||
If running Arch Linux, you may use the provided `PKGBUILD` to install bunbun.
|
||||
Run `makepkg` followed by `sudo pacman -U bunbun.<version>.tar.gz`. This
|
||||
installs the systemd service for you. Run `systemctl enable bunbun --now` to
|
||||
start bunbun as a daemon.
|
||||
|
||||
### Building for production
|
||||
|
||||
If you're looking to build a release binary, here are the steps I use:
|
||||
|
||||
1. `cargo build --release`
|
||||
2. `upx --lzma target/release/bunbun`
|
||||
|
||||
LZMA provides the best level of compress for Rust binaries; it performs at the
|
||||
same level as `upx --ultra-brute` without the time cost and [without breaking
|
||||
the binary](https://github.com/upx/upx/issues/224).
|
||||
|
||||
### Configuration
|
||||
|
||||
|
@ -31,7 +63,7 @@ If configuring for development, no further configuration is required. If running
|
|||
this for production, you should edit the `public_address` field.
|
||||
|
||||
the config file is watched, so updates are immediate unless invalid, or if
|
||||
you're using certain programs such as nvim, which performs updating a file via
|
||||
you're using certain programs such as `nvim`, which performs updating a file via
|
||||
swapping rather than directly updating the file.
|
||||
|
||||
### Adding bunbun as a search engine
|
||||
|
@ -39,15 +71,6 @@ swapping rather than directly updating the file.
|
|||
bunbun supports the [OpenSearch Description Format][osdf]. Visit the root page
|
||||
of your desired instance of bunbun to learn more.
|
||||
|
||||
## Reasons to use bunbun
|
||||
|
||||
- Portable: bunbun runs off a single binary and config file.
|
||||
- Small: binary is 1.3MB (after running `strip` and `upx --lzma` on the release
|
||||
binary).
|
||||
- Convenient: bunbun watches for config changes and refreshes its routes
|
||||
automatically, allowing for rapid development.
|
||||
- Memory-safe: Built with [Rust][rust-lang].
|
||||
|
||||
[rust-lang]: https://www.rust-lang.org/
|
||||
[bunny1]: http://www.bunny1.org/
|
||||
[osdf]: https://developer.mozilla.org/en-US/docs/Web/OpenSearch
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description=Bunbun search multiplexer/jump service
|
||||
After=network.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
ExecStart=/usr/bin/bunbun
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p out
|
||||
|
||||
# shellcheck disable=SC2207
|
||||
SHASUM=($(curl -sL "https://github.com/edward-shen/bunbun/archive/$1.tar.gz" | sha512sum))
|
||||
HASH="${SHASUM[0]}"
|
||||
sed -i "s/^pkgver=.*$/pkgver=$1/; s/sha512sums=('\w*/sha512sums=('$HASH/" PKGBUILD
|
|
@ -15,21 +15,66 @@ default_route: "g"
|
|||
# contain "{{query}}", which will be populated by the user's search query. This
|
||||
# input is percent-escaped. If multiple routes are defined, then the later
|
||||
# defined route is used.
|
||||
#
|
||||
# You may provide an (absolute, recommended) path to an executable file to out-
|
||||
# source route resolution to a program. The program will receive the arguments
|
||||
# as space-separated words, without any shell parsing.
|
||||
#
|
||||
# These programs must return a JSON object with either one of the following
|
||||
# key-value pairs:
|
||||
# - "redirect": "some-path-to-redirect-to.com"
|
||||
# - "body": The actual body to return.
|
||||
# For example, to return a page that only prints out `3`, the function should
|
||||
# return `{"redirect": "3"}`.
|
||||
#
|
||||
# These programs must be developed defensively, as they accept arbitrary user
|
||||
# input. Improper handling of user input can easily lead to anywhere from simple
|
||||
# flakey responses to remote code execution.
|
||||
groups:
|
||||
-
|
||||
# This is a group with the name "Meta commands" with a short description.
|
||||
name: "Meta commands"
|
||||
description: "Commands for bunbun"
|
||||
routes:
|
||||
ls: &ls "/ls"
|
||||
help: *ls
|
||||
# /ls is the only page that comes with bunbun besides the homepage. This
|
||||
# page provides a full list of routes and their groups they're in.
|
||||
ls: &ls
|
||||
path: "/ls"
|
||||
# You can specify a maximum number of arguments, which are string
|
||||
# delimited strings.
|
||||
max_args: 0
|
||||
# You can also specify a minimum amount of arguments.
|
||||
# min_args: 1
|
||||
help:
|
||||
path: "/ls"
|
||||
max_args: 0
|
||||
# Paths can be hidden from the listings page if desired.
|
||||
hidden: true
|
||||
# Bunbun supports all standard YAML features, so things like YAML pointers
|
||||
# and references are supported.
|
||||
list: *ls
|
||||
-
|
||||
# This is another group without a description
|
||||
name: "Google"
|
||||
routes:
|
||||
# Routes can be quickly defined as a simple link, where {{query}} is where
|
||||
# your query to bunbun is forwarded to.
|
||||
g: "https://google.com/search?q={{query}}"
|
||||
yt: "https://www.youtube.com/results?search_query={{query}}"
|
||||
# Alternatively, you can provide a description instead, which provides
|
||||
# replaces the raw query string on the ls page with said description
|
||||
yt:
|
||||
path: "https://www.youtube.com/results?search_query={{query}}"
|
||||
description: "A way to quickly search youtube videos"
|
||||
-
|
||||
name: "Uncategorized routes"
|
||||
description: "One-off routes with no specific grouping"
|
||||
routes:
|
||||
r: "https://reddit.com/r/{{query}}"
|
||||
# Routes don't need the {{query}} tag, so links can just be shortcuts to
|
||||
# pages you'd like
|
||||
nice: "https://youtu.be/dQw4w9WgXcQ"
|
||||
-
|
||||
# This group is entirely hidden, so all routes under it are hidden.
|
||||
name: "Hidden group"
|
||||
hidden: true
|
||||
routes:
|
||||
sneaky: "https://nyan.cat"
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
if ! cargo tarpaulin -h &> /dev/null; then
|
||||
echo "Tarpaulin not installed, automatically installing in 3 seconds.";
|
||||
sleep 3;
|
||||
cargo install cargo-tarpaulin;
|
||||
fi;
|
||||
|
||||
cargo tarpaulin -o html && xdg-open "tarpaulin-report.html"
|
|
@ -1,3 +0,0 @@
|
|||
tab_spaces = 2
|
||||
use_field_init_shorthand = true
|
||||
max_width = 80
|
|
@ -0,0 +1,17 @@
|
|||
use clap::{crate_authors, crate_version, Parser};
|
||||
use std::path::PathBuf;
|
||||
use tracing_subscriber::filter::Directive;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[clap(version = crate_version!(), author = crate_authors!())]
|
||||
pub struct Opts {
|
||||
/// Set the logging directives
|
||||
#[clap(long, default_value = "info")]
|
||||
pub log: Vec<Directive>,
|
||||
/// Specify the location of the config file to read from. Needs read/write permissions.
|
||||
#[clap(short, long)]
|
||||
pub config: Option<PathBuf>,
|
||||
/// Allow config sizes larger than 100MB.
|
||||
#[clap(long)]
|
||||
pub large_config: bool,
|
||||
}
|
25
src/cli.yaml
25
src/cli.yaml
|
@ -1,25 +0,0 @@
|
|||
name: "bunbun"
|
||||
about: "Search/jump multiplexer service"
|
||||
|
||||
args:
|
||||
- verbose:
|
||||
short: "v"
|
||||
long: "verbose"
|
||||
multiple: true
|
||||
help: Increases the log level to info, debug, and trace, respectively.
|
||||
conflicts_with: "quiet"
|
||||
- quiet:
|
||||
short: "q"
|
||||
long: "quiet"
|
||||
multiple: true
|
||||
help: Decreases the log level to error or no logging at all, respectively.
|
||||
conflicts_with: "verbose"
|
||||
- daemon:
|
||||
short: "d"
|
||||
long: "daemon"
|
||||
help: "Run bunbun as a daemon."
|
||||
- config:
|
||||
short: "c"
|
||||
long: "config"
|
||||
default_value: "/etc/bunbun.yaml"
|
||||
help: Specify the location of the config file to read from. Needs read/write permissions.
|
|
@ -0,0 +1,426 @@
|
|||
use crate::BunBunError;
|
||||
use dirs::{config_dir, home_dir};
|
||||
use serde::{
|
||||
de::{self, Deserializer, MapAccess, Unexpected, Visitor},
|
||||
Deserialize, Serialize,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
const CONFIG_FILENAME: &str = "bunbun.yaml";
|
||||
const DEFAULT_CONFIG: &[u8] = include_bytes!("../bunbun.default.yaml");
|
||||
#[cfg(not(test))]
|
||||
const LARGE_FILE_SIZE_THRESHOLD: u64 = 100_000_000;
|
||||
#[cfg(test)]
|
||||
const LARGE_FILE_SIZE_THRESHOLD: u64 = 1_000_000;
|
||||
|
||||
#[derive(Deserialize, Debug, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
pub bind_address: String,
|
||||
pub public_address: String,
|
||||
pub default_route: Option<String>,
|
||||
pub groups: Vec<RouteGroup>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Eq, PartialEq, Clone)]
|
||||
pub struct RouteGroup {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
pub routes: HashMap<String, Route>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
|
||||
pub struct Route {
|
||||
pub route_type: RouteType,
|
||||
pub path: String,
|
||||
pub hidden: bool,
|
||||
pub description: Option<String>,
|
||||
pub min_args: Option<usize>,
|
||||
pub max_args: Option<usize>,
|
||||
}
|
||||
|
||||
impl From<String> for Route {
|
||||
fn from(s: String) -> Self {
|
||||
Self {
|
||||
route_type: get_route_type(&s),
|
||||
path: s,
|
||||
hidden: false,
|
||||
description: None,
|
||||
min_args: None,
|
||||
max_args: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&'static str> for Route {
|
||||
fn from(s: &'static str) -> Self {
|
||||
Self {
|
||||
route_type: get_route_type(s),
|
||||
path: s.to_string(),
|
||||
hidden: false,
|
||||
description: None,
|
||||
min_args: None,
|
||||
max_args: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialization of the route string into the enum requires us to figure out
|
||||
/// whether or not the string is valid to run as an executable or not. To
|
||||
/// determine this, we simply check if it exists on disk or assume that it's a
|
||||
/// web path. This incurs a disk check operation, but since users shouldn't be
|
||||
/// updating the config that frequently, it should be fine.
|
||||
impl<'de> Deserialize<'de> for Route {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(field_identifier, rename_all = "snake_case")]
|
||||
enum Field {
|
||||
Path,
|
||||
Hidden,
|
||||
Description,
|
||||
MinArgs,
|
||||
MaxArgs,
|
||||
}
|
||||
|
||||
struct RouteVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for RouteVisitor {
|
||||
type Value = Route;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("string")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, path: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(Self::Value::from(path.to_owned()))
|
||||
}
|
||||
|
||||
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
|
||||
where
|
||||
M: MapAccess<'de>,
|
||||
{
|
||||
let mut path = None;
|
||||
let mut hidden = None;
|
||||
let mut description = None;
|
||||
let mut min_args = None;
|
||||
let mut max_args = None;
|
||||
|
||||
while let Some(key) = map.next_key()? {
|
||||
match key {
|
||||
Field::Path => {
|
||||
if path.is_some() {
|
||||
return Err(de::Error::duplicate_field("path"));
|
||||
}
|
||||
path = Some(map.next_value::<String>()?);
|
||||
}
|
||||
Field::Hidden => {
|
||||
if hidden.is_some() {
|
||||
return Err(de::Error::duplicate_field("hidden"));
|
||||
}
|
||||
hidden = map.next_value()?;
|
||||
}
|
||||
Field::Description => {
|
||||
if description.is_some() {
|
||||
return Err(de::Error::duplicate_field("description"));
|
||||
}
|
||||
description = Some(map.next_value()?);
|
||||
}
|
||||
Field::MinArgs => {
|
||||
if min_args.is_some() {
|
||||
return Err(de::Error::duplicate_field("min_args"));
|
||||
}
|
||||
min_args = Some(map.next_value()?);
|
||||
}
|
||||
Field::MaxArgs => {
|
||||
if max_args.is_some() {
|
||||
return Err(de::Error::duplicate_field("max_args"));
|
||||
}
|
||||
max_args = Some(map.next_value()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(min_args), Some(max_args)) = (min_args, max_args) {
|
||||
if min_args > max_args {
|
||||
{
|
||||
return Err(de::Error::invalid_value(
|
||||
Unexpected::Other(&format!(
|
||||
"argument count range {min_args} to {max_args}",
|
||||
)),
|
||||
&"a valid argument count range",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let path = path.ok_or_else(|| de::Error::missing_field("path"))?;
|
||||
Ok(Route {
|
||||
route_type: get_route_type(&path),
|
||||
path,
|
||||
hidden: hidden.unwrap_or_default(),
|
||||
description,
|
||||
min_args,
|
||||
max_args,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(RouteVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Route {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self {
|
||||
route_type: RouteType::External,
|
||||
path,
|
||||
..
|
||||
} => write!(f, "raw ({path})"),
|
||||
Self {
|
||||
route_type: RouteType::Internal,
|
||||
path,
|
||||
..
|
||||
} => write!(f, "file ({path})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies the path depending on if the there exists a local file.
|
||||
fn get_route_type(path: &str) -> RouteType {
|
||||
if std::path::Path::new(path).exists() {
|
||||
debug!("Parsed {path} as a valid local path.");
|
||||
RouteType::Internal
|
||||
} else {
|
||||
debug!("{path} does not exist on disk, assuming web path.");
|
||||
RouteType::External
|
||||
}
|
||||
}
|
||||
|
||||
/// There exists two route types: an external path (e.g. a URL) or an internal
|
||||
/// path (to a file).
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Serialize)]
|
||||
pub enum RouteType {
|
||||
External,
|
||||
Internal,
|
||||
}
|
||||
|
||||
pub struct FileData {
|
||||
pub path: PathBuf,
|
||||
pub file: File,
|
||||
}
|
||||
|
||||
/// If a provided config path isn't found, this function checks known good
|
||||
/// locations for a place to write a config file to. In order, it checks the
|
||||
/// system-wide config location (`/etc/`, in Linux), followed by the config
|
||||
/// folder, followed by the user's home folder.
|
||||
pub fn get_config_data() -> Result<FileData, BunBunError> {
|
||||
// Locations to check, with highest priority first
|
||||
let locations: Vec<_> = {
|
||||
let mut folders = vec![PathBuf::from("/etc/")];
|
||||
|
||||
// Config folder
|
||||
if let Some(folder) = config_dir() {
|
||||
folders.push(folder);
|
||||
}
|
||||
|
||||
// Home folder
|
||||
if let Some(folder) = home_dir() {
|
||||
folders.push(folder);
|
||||
}
|
||||
|
||||
folders
|
||||
.iter_mut()
|
||||
.for_each(|folder| folder.push(CONFIG_FILENAME));
|
||||
|
||||
folders
|
||||
};
|
||||
|
||||
debug!("Checking locations for config file: {:?}", &locations);
|
||||
|
||||
for location in &locations {
|
||||
let file = OpenOptions::new().read(true).open(location);
|
||||
match file {
|
||||
Ok(file) => {
|
||||
debug!("Found file at {location:?}.");
|
||||
return Ok(FileData {
|
||||
path: location.clone(),
|
||||
file,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Tried to read '{location:?}' but failed due to error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Failed to find any config. Now trying to find first writable path");
|
||||
|
||||
// If we got here, we failed to read any file paths, meaning no config exists
|
||||
// yet. In that case, try to return the first location that we can write to,
|
||||
// after writing the default config
|
||||
for location in locations {
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(location.clone());
|
||||
match file {
|
||||
Ok(mut file) => {
|
||||
info!("Creating new config file at {location:?}.");
|
||||
file.write_all(DEFAULT_CONFIG)?;
|
||||
|
||||
let file = OpenOptions::new().read(true).open(location.clone())?;
|
||||
return Ok(FileData {
|
||||
path: location,
|
||||
file,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Tried to open a new file at '{location:?}' but failed due to error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(BunBunError::NoValidConfigPath)
|
||||
}
|
||||
|
||||
/// Assumes that the user knows what they're talking about and will only try
|
||||
/// to load the config at the given path.
|
||||
pub fn load_custom_file(path: impl Into<PathBuf>) -> Result<FileData, BunBunError> {
|
||||
let path = path.into();
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(&path)
|
||||
.map_err(|e| BunBunError::InvalidConfigPath(path.clone(), e))?;
|
||||
|
||||
Ok(FileData { path, file })
|
||||
}
|
||||
|
||||
pub fn load_file(mut config_file: File, large_config: bool) -> Result<Config, BunBunError> {
|
||||
trace!("Loading config file.");
|
||||
let file_size = config_file.metadata()?.len();
|
||||
|
||||
// 100 MB
|
||||
if file_size > LARGE_FILE_SIZE_THRESHOLD && !large_config {
|
||||
return Err(BunBunError::ConfigTooLarge(file_size));
|
||||
}
|
||||
|
||||
if file_size == 0 {
|
||||
return Err(BunBunError::ZeroByteConfig);
|
||||
}
|
||||
|
||||
let mut config_data = String::new();
|
||||
config_file.read_to_string(&mut config_data)?;
|
||||
// Reading from memory is faster than reading directly from a reader for some
|
||||
// reason; see https://github.com/serde-rs/json/issues/160
|
||||
Ok(serde_yaml::from_str(&config_data)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod route {
|
||||
use super::*;
|
||||
use anyhow::{Context, Result};
|
||||
use serde_yaml::{from_str, to_string};
|
||||
use std::path::Path;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn deserialize_relative_path() -> Result<()> {
|
||||
let tmpfile = NamedTempFile::new_in(".")?;
|
||||
let path = tmpfile.path().display().to_string();
|
||||
let path = path
|
||||
.get(path.rfind(".").context("While finding .")?..)
|
||||
.context("While getting the path")?;
|
||||
let path = Path::new(path);
|
||||
assert!(path.is_relative());
|
||||
let path = path.to_str().context("While stringifying path")?;
|
||||
assert_eq!(from_str::<Route>(path)?, Route::from(path.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_absolute_path() -> Result<()> {
|
||||
let tmpfile = NamedTempFile::new()?;
|
||||
let path = format!("{}", tmpfile.path().display());
|
||||
assert!(tmpfile.path().is_absolute());
|
||||
assert_eq!(from_str::<Route>(&path)?, Route::from(path));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_http_path() -> Result<()> {
|
||||
assert_eq!(
|
||||
from_str::<Route>("http://google.com")?,
|
||||
Route::from("http://google.com")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_https_path() -> Result<()> {
|
||||
assert_eq!(
|
||||
from_str::<Route>("https://google.com")?,
|
||||
Route::from("https://google.com")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize() -> Result<()> {
|
||||
assert_eq!(
|
||||
&to_string(&Route::from("hello world"))?,
|
||||
"---\nroute_type: External\npath: hello world\nhidden: false\ndescription: ~\nmin_args: ~\nmax_args: ~\n"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod read_config {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
|
||||
#[test]
|
||||
fn empty_file() -> Result<()> {
|
||||
let config_file = tempfile::tempfile()?;
|
||||
assert!(matches!(
|
||||
load_file(config_file, false),
|
||||
Err(BunBunError::ZeroByteConfig)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_too_large() -> Result<()> {
|
||||
let mut config_file = tempfile::tempfile()?;
|
||||
let size_to_write = (LARGE_FILE_SIZE_THRESHOLD + 1) as usize;
|
||||
config_file.write(&[0].repeat(size_to_write))?;
|
||||
match load_file(config_file, false) {
|
||||
Err(BunBunError::ConfigTooLarge(size)) if size as usize == size_to_write => {}
|
||||
Err(BunBunError::ConfigTooLarge(size)) => {
|
||||
panic!("Mismatched size: {size} != {size_to_write}")
|
||||
}
|
||||
res => panic!("Wrong result, got {res:#?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_config() -> Result<()> {
|
||||
assert!(load_file(File::open("bunbun.default.yaml")?, false).is_ok());
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub enum BunBunError {
|
||||
Io(std::io::Error),
|
||||
Parse(serde_yaml::Error),
|
||||
Watch(hotwatch::Error),
|
||||
CustomProgram(String),
|
||||
NoValidConfigPath,
|
||||
InvalidConfigPath(std::path::PathBuf, std::io::Error),
|
||||
ConfigTooLarge(u64),
|
||||
ZeroByteConfig,
|
||||
JsonParse(serde_json::Error),
|
||||
}
|
||||
|
||||
impl Error for BunBunError {}
|
||||
|
||||
impl fmt::Display for BunBunError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Self::Io(e) => e.fmt(f),
|
||||
Self::Parse(e) => e.fmt(f),
|
||||
Self::Watch(e) => e.fmt(f),
|
||||
Self::CustomProgram(msg) => msg.fmt(f),
|
||||
Self::NoValidConfigPath => write!(f, "No valid config path was found!"),
|
||||
Self::InvalidConfigPath(path, reason) => {
|
||||
write!(f, "Failed to access {path:?}: {reason}")
|
||||
}
|
||||
Self::ConfigTooLarge(size) => write!(f, "The config file was too large ({size} bytes)! Pass in --large-config to bypass this check."),
|
||||
Self::ZeroByteConfig => write!(f, "The config provided reported a size of 0 bytes. Please check your config path!"),
|
||||
Self::JsonParse(e) => e.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a from implementation from the specified type to the provided
|
||||
/// bunbun error.
|
||||
macro_rules! from_error {
|
||||
($from:ty, $to:ident) => {
|
||||
impl From<$from> for BunBunError {
|
||||
fn from(e: $from) -> Self {
|
||||
Self::$to(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
from_error!(std::io::Error, Io);
|
||||
from_error!(serde_yaml::Error, Parse);
|
||||
from_error!(hotwatch::Error, Watch);
|
||||
from_error!(serde_json::Error, JsonParse);
|
470
src/main.rs
470
src/main.rs
|
@ -1,250 +1,121 @@
|
|||
use actix_web::middleware::Logger;
|
||||
use actix_web::{App, HttpServer};
|
||||
use clap::{crate_authors, crate_version, load_yaml, App as ClapApp};
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(missing_docs)]
|
||||
#![warn(clippy::nursery, clippy::pedantic)]
|
||||
|
||||
//! Bunbun is a pure-Rust implementation of bunny1 that provides a customizable
|
||||
//! search engine and quick-jump tool in one small binary. For information on
|
||||
//! usage, please take a look at the readme.
|
||||
|
||||
use crate::config::{get_config_data, load_custom_file, load_file, FileData, Route, RouteGroup};
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use clap::Parser;
|
||||
use error::BunBunError;
|
||||
use handlebars::Handlebars;
|
||||
use hotwatch::{Event, Hotwatch};
|
||||
use libc::daemon;
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::min;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::fs::{read_to_string, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
mod cli;
|
||||
mod config;
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
mod error;
|
||||
mod routes;
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
mod template_args;
|
||||
|
||||
const DEFAULT_CONFIG: &[u8] = include_bytes!("../bunbun.default.yaml");
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
enum BunBunError {
|
||||
IoError(std::io::Error),
|
||||
ParseError(serde_yaml::Error),
|
||||
WatchError(hotwatch::Error),
|
||||
LoggerInitError(log::SetLoggerError),
|
||||
}
|
||||
|
||||
impl fmt::Display for BunBunError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
BunBunError::IoError(e) => e.fmt(f),
|
||||
BunBunError::ParseError(e) => e.fmt(f),
|
||||
BunBunError::WatchError(e) => e.fmt(f),
|
||||
BunBunError::LoggerInitError(e) => e.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a from implementation from the specified type to the provided
|
||||
/// bunbun error.
|
||||
macro_rules! from_error {
|
||||
($from:ty, $to:ident) => {
|
||||
impl From<$from> for BunBunError {
|
||||
fn from(e: $from) -> Self {
|
||||
BunBunError::$to(e)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
from_error!(std::io::Error, IoError);
|
||||
from_error!(serde_yaml::Error, ParseError);
|
||||
from_error!(hotwatch::Error, WatchError);
|
||||
from_error!(log::SetLoggerError, LoggerInitError);
|
||||
|
||||
/// Dynamic variables that either need to be present at runtime, or can be
|
||||
/// changed during runtime.
|
||||
pub struct State {
|
||||
public_address: String,
|
||||
default_route: Option<String>,
|
||||
groups: Vec<RouteGroup>,
|
||||
/// Cached, flattened mapping of all routes and their destinations.
|
||||
routes: HashMap<String, String>,
|
||||
renderer: Handlebars,
|
||||
public_address: String,
|
||||
default_route: Option<String>,
|
||||
groups: Vec<RouteGroup>,
|
||||
/// Cached, flattened mapping of all routes and their destinations.
|
||||
routes: HashMap<String, Route>,
|
||||
}
|
||||
|
||||
fn main() -> Result<(), BunBunError> {
|
||||
let yaml = load_yaml!("cli.yaml");
|
||||
let matches = ClapApp::from(yaml)
|
||||
.version(crate_version!())
|
||||
.author(crate_authors!())
|
||||
.get_matches();
|
||||
#[tokio::main]
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
async fn main() -> Result<()> {
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
init_logger(
|
||||
matches.occurrences_of("verbose"),
|
||||
matches.occurrences_of("quiet"),
|
||||
)?;
|
||||
let opts = cli::Opts::parse();
|
||||
|
||||
// config has default location provided, unwrapping is fine.
|
||||
let conf_file_location = String::from(matches.value_of("config").unwrap());
|
||||
let conf = read_config(&conf_file_location)?;
|
||||
let renderer = compile_templates();
|
||||
let state = Arc::from(RwLock::new(State {
|
||||
public_address: conf.public_address,
|
||||
default_route: conf.default_route,
|
||||
routes: cache_routes(&conf.groups),
|
||||
groups: conf.groups,
|
||||
renderer,
|
||||
}));
|
||||
|
||||
// Daemonize after trying to read from config and before watching; allow user
|
||||
// to see a bad config (daemon process sets std{in,out} to /dev/null)
|
||||
if matches.is_present("daemon") {
|
||||
unsafe {
|
||||
debug!("Daemon flag provided. Running as a daemon.");
|
||||
daemon(0, 0);
|
||||
let mut env_filter = EnvFilter::from_default_env();
|
||||
for directive in opts.log {
|
||||
env_filter = env_filter.add_directive(directive);
|
||||
}
|
||||
}
|
||||
|
||||
let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?;
|
||||
// TODO: keep retry watching in separate thread
|
||||
// Closures need their own copy of variables for proper lifecycle management
|
||||
let state_ref = state.clone();
|
||||
let conf_file_location_clone = conf_file_location.clone();
|
||||
let watch_result = watch.watch(&conf_file_location, move |e: Event| {
|
||||
if let Event::Write(_) = e {
|
||||
trace!("Grabbing writer lock on state...");
|
||||
let mut state = state.write().unwrap();
|
||||
trace!("Obtained writer lock on state!");
|
||||
match read_config(&conf_file_location_clone) {
|
||||
Ok(conf) => {
|
||||
state.public_address = conf.public_address;
|
||||
state.default_route = conf.default_route;
|
||||
state.routes = cache_routes(&conf.groups);
|
||||
state.groups = conf.groups;
|
||||
info!("Successfully updated active state");
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(env_filter)
|
||||
.init();
|
||||
|
||||
let conf_data = opts.config.map_or_else(get_config_data, load_custom_file)?;
|
||||
|
||||
let conf = load_file(conf_data.file.try_clone()?, opts.large_config)?;
|
||||
let state = Arc::from(ArcSwap::from_pointee(State {
|
||||
public_address: conf.public_address,
|
||||
default_route: conf.default_route,
|
||||
routes: cache_routes(conf.groups.clone()),
|
||||
groups: conf.groups,
|
||||
}));
|
||||
|
||||
// Cannot be named _ or Rust will immediately drop it.
|
||||
let _watch = start_watch(Arc::clone(&state), conf_data, opts.large_config);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(routes::index))
|
||||
.route("/bunbunsearch.xml", get(routes::opensearch))
|
||||
.route("/ls", get(routes::list))
|
||||
.route("/hop", get(routes::hop))
|
||||
.layer(Extension(compile_templates()?))
|
||||
.layer(Extension(state));
|
||||
|
||||
let bind_addr = conf.bind_address.parse()?;
|
||||
|
||||
info!("Starting server at {bind_addr}");
|
||||
|
||||
axum::Server::bind(&bind_addr)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generates a hashmap of routes from the data structure created by the config
|
||||
/// file. This should improve runtime performance and is a better solution than
|
||||
/// just iterating over the config object for every hop resolution.
|
||||
fn cache_routes(groups: Vec<RouteGroup>) -> HashMap<String, Route> {
|
||||
let mut mapping = HashMap::new();
|
||||
for group in groups {
|
||||
for (kw, dest) in group.routes {
|
||||
// This function isn't called often enough to not be a performance issue.
|
||||
if let Some(old_value) = mapping.insert(kw.clone(), dest.clone()) {
|
||||
trace!("Overriding {kw} route from {old_value} to {dest}.");
|
||||
} else {
|
||||
trace!("Inserting {kw} into mapping.");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to update config file: {}", e),
|
||||
}
|
||||
} else {
|
||||
debug!("Saw event {:#?} but ignored it", e);
|
||||
}
|
||||
});
|
||||
|
||||
match watch_result {
|
||||
Ok(_) => info!("Watcher is now watching {}", &conf_file_location),
|
||||
Err(e) => warn!(
|
||||
"Couldn't watch {}: {}. Changes to this file won't be seen!",
|
||||
&conf_file_location, e
|
||||
),
|
||||
}
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.data(state_ref.clone())
|
||||
.wrap(Logger::default())
|
||||
.service(routes::hop)
|
||||
.service(routes::list)
|
||||
.service(routes::index)
|
||||
.service(routes::opensearch)
|
||||
})
|
||||
.bind(&conf.bind_address)?
|
||||
.run()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initializes the logger based on the number of quiet and verbose flags passed
|
||||
/// in. Usually, these values are mutually exclusive, that is, if the number of
|
||||
/// verbose flags is non-zero then the quiet flag is zero, and vice versa.
|
||||
fn init_logger(
|
||||
num_verbose_flags: u64,
|
||||
num_quiet_flags: u64,
|
||||
) -> Result<(), BunBunError> {
|
||||
let log_level =
|
||||
match min(num_verbose_flags, 3) as i8 - min(num_quiet_flags, 2) as i8 {
|
||||
-2 => None,
|
||||
-1 => Some(log::Level::Error),
|
||||
0 => Some(log::Level::Warn),
|
||||
1 => Some(log::Level::Info),
|
||||
2 => Some(log::Level::Debug),
|
||||
3 => Some(log::Level::Trace),
|
||||
_ => unreachable!(), // values are clamped to [0, 3] - [0, 2]
|
||||
};
|
||||
|
||||
if let Some(level) = log_level {
|
||||
simple_logger::init_with_level(level)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Config {
|
||||
bind_address: String,
|
||||
public_address: String,
|
||||
default_route: Option<String>,
|
||||
groups: Vec<RouteGroup>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct RouteGroup {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
routes: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Attempts to read the config file. If it doesn't exist, generate one a
|
||||
/// default config file before attempting to parse it.
|
||||
fn read_config(config_file_path: &str) -> Result<Config, BunBunError> {
|
||||
trace!("Loading config file...");
|
||||
let config_str = match read_to_string(config_file_path) {
|
||||
Ok(conf_str) => {
|
||||
debug!("Successfully loaded config file into memory.");
|
||||
conf_str
|
||||
}
|
||||
Err(_) => {
|
||||
info!(
|
||||
"Unable to find a {} file. Creating default!",
|
||||
config_file_path
|
||||
);
|
||||
|
||||
let fd = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(config_file_path);
|
||||
|
||||
match fd {
|
||||
Ok(mut fd) => fd.write_all(DEFAULT_CONFIG)?,
|
||||
Err(e) => {
|
||||
error!("Failed to write to {}: {}. Default config will be loaded but not saved.", config_file_path, e);
|
||||
}
|
||||
};
|
||||
|
||||
String::from_utf8_lossy(DEFAULT_CONFIG).into_owned()
|
||||
}
|
||||
};
|
||||
|
||||
// Reading from memory is faster than reading directly from a reader for some
|
||||
// reason; see https://github.com/serde-rs/json/issues/160
|
||||
Ok(serde_yaml::from_str(&config_str)?)
|
||||
}
|
||||
|
||||
fn cache_routes(groups: &[RouteGroup]) -> HashMap<String, String> {
|
||||
let mut mapping = HashMap::new();
|
||||
for group in groups {
|
||||
for (kw, dest) in &group.routes {
|
||||
match mapping.insert(kw.clone(), dest.clone()) {
|
||||
None => trace!("Inserting {} into mapping.", kw),
|
||||
Some(old_value) => {
|
||||
debug!("Overriding {} route from {} to {}.", kw, old_value, dest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mapping
|
||||
mapping
|
||||
}
|
||||
|
||||
/// Returns an instance with all pre-generated templates included into the
|
||||
/// binary. This allows for users to have a portable binary without needed the
|
||||
/// templates at runtime.
|
||||
fn compile_templates() -> Handlebars {
|
||||
let mut handlebars = Handlebars::new();
|
||||
macro_rules! register_template {
|
||||
fn compile_templates() -> Result<Handlebars<'static>> {
|
||||
let mut handlebars = Handlebars::new();
|
||||
handlebars.set_strict_mode(true);
|
||||
handlebars.register_partial("bunbun_version", env!("CARGO_PKG_VERSION"))?;
|
||||
handlebars.register_partial("bunbun_src", env!("CARGO_PKG_REPOSITORY"))?;
|
||||
macro_rules! register_template {
|
||||
[ $( $template:expr ),* ] => {
|
||||
$(
|
||||
handlebars
|
||||
|
@ -252,12 +123,157 @@ fn compile_templates() -> Handlebars {
|
|||
$template,
|
||||
String::from_utf8_lossy(
|
||||
include_bytes!(concat!("templates/", $template, ".hbs")))
|
||||
)
|
||||
.unwrap();
|
||||
)?;
|
||||
debug!("Loaded {} template.", $template);
|
||||
)*
|
||||
};
|
||||
}
|
||||
register_template!["index", "list", "opensearch"];
|
||||
handlebars
|
||||
register_template!["index", "list", "opensearch"];
|
||||
Ok(handlebars)
|
||||
}
|
||||
|
||||
/// Starts the watch on a file, if possible. This will only return an Error if
|
||||
/// the notify library (used by Hotwatch) fails to initialize, which is
|
||||
/// considered to be a more serve error as it may be indicative of a low-level
|
||||
/// problem. If a watch was unsuccessfully obtained (the most common is due to
|
||||
/// the file not existing), then this will simply warn before returning a watch
|
||||
/// object.
|
||||
///
|
||||
/// This watch object should be kept in scope as dropping it releases all
|
||||
/// watches.
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn start_watch(
|
||||
state: Arc<ArcSwap<State>>,
|
||||
config_data: FileData,
|
||||
large_config: bool,
|
||||
) -> Result<Hotwatch> {
|
||||
let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?;
|
||||
let FileData { path, mut file } = config_data;
|
||||
let watch_result = watch.watch(&path, move |e: Event| {
|
||||
if let Event::Create(ref path) = e {
|
||||
file = load_custom_file(path).expect("file to exist at path").file;
|
||||
trace!("Getting new file handler as file was recreated.");
|
||||
}
|
||||
|
||||
match e {
|
||||
Event::Write(_) | Event::Create(_) => {
|
||||
trace!("Grabbing writer lock on state...");
|
||||
trace!("Obtained writer lock on state!");
|
||||
match load_file(
|
||||
file.try_clone().expect("Failed to clone file handle"),
|
||||
large_config,
|
||||
) {
|
||||
Ok(conf) => {
|
||||
state.store(Arc::new(State {
|
||||
public_address: conf.public_address,
|
||||
default_route: conf.default_route,
|
||||
routes: cache_routes(conf.groups.clone()),
|
||||
groups: conf.groups,
|
||||
}));
|
||||
info!("Successfully updated active state");
|
||||
}
|
||||
Err(e) => warn!("Failed to update config file: {e}"),
|
||||
}
|
||||
}
|
||||
_ => debug!("Saw event {e:#?} but ignored it"),
|
||||
}
|
||||
});
|
||||
|
||||
match watch_result {
|
||||
Ok(_) => info!("Watcher is now watching {path:?}"),
|
||||
Err(e) => {
|
||||
warn!("Couldn't watch {path:?}: {e}. Changes to this file won't be seen!");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(watch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cache_routes {
|
||||
use super::*;
|
||||
use std::iter::FromIterator;
|
||||
|
||||
fn generate_external_routes(routes: &[(&'static str, &'static str)]) -> HashMap<String, Route> {
|
||||
HashMap::from_iter(
|
||||
routes
|
||||
.into_iter()
|
||||
.map(|(key, value)| ((*key).to_owned(), Route::from(*value))),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_groups_yield_empty_routes() {
|
||||
assert_eq!(cache_routes(Vec::new()), HashMap::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_groups_yield_summed_routes() {
|
||||
let group1 = RouteGroup {
|
||||
name: String::from("x"),
|
||||
description: Some(String::from("y")),
|
||||
routes: generate_external_routes(&[("a", "b"), ("c", "d")]),
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
let group2 = RouteGroup {
|
||||
name: String::from("5"),
|
||||
description: Some(String::from("6")),
|
||||
routes: generate_external_routes(&[("1", "2"), ("3", "4")]),
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
cache_routes(vec![group1, group2]),
|
||||
generate_external_routes(&[("a", "b"), ("c", "d"), ("1", "2"), ("3", "4")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_groups_use_latter_routes() {
|
||||
let group1 = RouteGroup {
|
||||
name: String::from("x"),
|
||||
description: Some(String::from("y")),
|
||||
routes: generate_external_routes(&[("a", "b"), ("c", "d")]),
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
let group2 = RouteGroup {
|
||||
name: String::from("5"),
|
||||
description: Some(String::from("6")),
|
||||
routes: generate_external_routes(&[("a", "1"), ("c", "2")]),
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
cache_routes(vec![group1.clone(), group2]),
|
||||
generate_external_routes(&[("a", "1"), ("c", "2")])
|
||||
);
|
||||
|
||||
let group3 = RouteGroup {
|
||||
name: String::from("5"),
|
||||
description: Some(String::from("6")),
|
||||
routes: generate_external_routes(&[("a", "1"), ("b", "2")]),
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
cache_routes(vec![group1, group3]),
|
||||
generate_external_routes(&[("a", "1"), ("b", "2"), ("c", "d")])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compile_templates {
|
||||
use super::compile_templates;
|
||||
|
||||
/// Successful compilation of the binary guarantees that the templates will be
|
||||
/// present to be registered to. Thus, we only really need to see that
|
||||
/// compilation of the templates don't panic, which is just making sure that
|
||||
/// the function can be successfully called.
|
||||
#[test]
|
||||
fn templates_compile() {
|
||||
let _ = compile_templates();
|
||||
}
|
||||
}
|
||||
|
|
495
src/routes.rs
495
src/routes.rs
|
@ -1,60 +1,144 @@
|
|||
use crate::template_args;
|
||||
use crate::State;
|
||||
use actix_web::get;
|
||||
use actix_web::http::header;
|
||||
use actix_web::web::{Data, Query};
|
||||
use actix_web::{HttpResponse, Responder};
|
||||
use itertools::Itertools;
|
||||
use log::debug;
|
||||
use crate::config::{Route as ConfigRoute, RouteType};
|
||||
use crate::{template_args, BunBunError, Route, State};
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::body::{boxed, Bytes, Empty, Full};
|
||||
use axum::extract::Query;
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
use axum::Extension;
|
||||
use handlebars::Handlebars;
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use serde::Deserialize;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error};
|
||||
|
||||
/// https://url.spec.whatwg.org/#fragment-percent-encode-set
|
||||
// https://url.spec.whatwg.org/#fragment-percent-encode-set
|
||||
const FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'`')
|
||||
.add(b'+');
|
||||
.add(b' ')
|
||||
.add(b'"')
|
||||
.add(b'<')
|
||||
.add(b'>')
|
||||
.add(b'`')
|
||||
.add(b'+')
|
||||
.add(b'&') // Interpreted as a GET query
|
||||
.add(b'#') // Interpreted as a hyperlink section target
|
||||
.add(b'\'');
|
||||
|
||||
#[get("/ls")]
|
||||
pub fn list(data: Data<Arc<RwLock<State>>>) -> impl Responder {
|
||||
let data = data.read().unwrap();
|
||||
HttpResponse::Ok().body(data.renderer.render("list", &data.groups).unwrap())
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn index(
|
||||
Extension(data): Extension<Arc<ArcSwap<State>>>,
|
||||
Extension(handlebars): Extension<Handlebars<'static>>,
|
||||
) -> impl IntoResponse {
|
||||
handlebars
|
||||
.render(
|
||||
"index",
|
||||
&template_args::hostname(&data.load().public_address),
|
||||
)
|
||||
.map(Html)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn opensearch(
|
||||
Extension(data): Extension<Arc<ArcSwap<State>>>,
|
||||
Extension(handlebars): Extension<Handlebars<'static>>,
|
||||
) -> impl IntoResponse {
|
||||
handlebars
|
||||
.render(
|
||||
"opensearch",
|
||||
&template_args::hostname(&data.load().public_address),
|
||||
)
|
||||
.map(|body| {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
"application/opensearchdescription+xml",
|
||||
)],
|
||||
body,
|
||||
)
|
||||
})
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn list(
|
||||
Extension(data): Extension<Arc<ArcSwap<State>>>,
|
||||
Extension(handlebars): Extension<Handlebars<'static>>,
|
||||
) -> impl IntoResponse {
|
||||
handlebars
|
||||
.render("list", &data.load().groups)
|
||||
.map(Html)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SearchQuery {
|
||||
to: String,
|
||||
to: String,
|
||||
}
|
||||
|
||||
#[get("/hop")]
|
||||
pub fn hop(
|
||||
data: Data<Arc<RwLock<State>>>,
|
||||
query: Query<SearchQuery>,
|
||||
) -> impl Responder {
|
||||
let data = data.read().unwrap();
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn hop(
|
||||
Extension(data): Extension<Arc<ArcSwap<State>>>,
|
||||
Extension(handlebars): Extension<Handlebars<'static>>,
|
||||
Query(query): Query<SearchQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.load();
|
||||
|
||||
match resolve_hop(&query.to, &data.routes, &data.default_route) {
|
||||
(Some(path), args) => HttpResponse::Found()
|
||||
.header(
|
||||
header::LOCATION,
|
||||
data
|
||||
.renderer
|
||||
.render_template(
|
||||
&path,
|
||||
&template_args::query(
|
||||
utf8_percent_encode(&args, FRAGMENT_ENCODE_SET).to_string(),
|
||||
),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.finish(),
|
||||
(None, _) => HttpResponse::NotFound().body("not found"),
|
||||
}
|
||||
match resolve_hop(&query.to, &data.routes, data.default_route.as_deref()) {
|
||||
RouteResolution::Resolved { route: path, args } => {
|
||||
let resolved_template = match path {
|
||||
ConfigRoute {
|
||||
route_type: RouteType::Internal,
|
||||
path,
|
||||
..
|
||||
} => resolve_path(Path::new(path), &args),
|
||||
ConfigRoute {
|
||||
route_type: RouteType::External,
|
||||
path,
|
||||
..
|
||||
} => Ok(HopAction::Redirect(Cow::Borrowed(path))),
|
||||
};
|
||||
|
||||
match resolved_template {
|
||||
Ok(HopAction::Redirect(path)) => {
|
||||
let rendered = handlebars
|
||||
.render_template(
|
||||
&path,
|
||||
&template_args::query(utf8_percent_encode(&args, FRAGMENT_ENCODE_SET)),
|
||||
)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Response::builder()
|
||||
.status(StatusCode::FOUND)
|
||||
.header(header::LOCATION, rendered)
|
||||
.body(boxed(Empty::new()))
|
||||
}
|
||||
Ok(HopAction::Body(body)) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(boxed(Full::new(Bytes::from(body)))),
|
||||
Err(e) => {
|
||||
error!("Failed to redirect user for {path}: {e}");
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(boxed(Full::from("Something went wrong :(\n")))
|
||||
}
|
||||
}
|
||||
}
|
||||
RouteResolution::Unresolved => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(boxed(Full::from("not found\n"))),
|
||||
}
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum RouteResolution<'a> {
|
||||
Resolved { route: &'a Route, args: String },
|
||||
Unresolved,
|
||||
}
|
||||
|
||||
/// Attempts to resolve the provided string into its route and its arguments.
|
||||
|
@ -63,80 +147,265 @@ pub fn hop(
|
|||
///
|
||||
/// The first element in the tuple describes the route, while the second element
|
||||
/// returns the remaining arguments. If none remain, an empty string is given.
|
||||
fn resolve_hop(
|
||||
query: &str,
|
||||
routes: &HashMap<String, String>,
|
||||
default_route: &Option<String>,
|
||||
) -> (Option<String>, String) {
|
||||
let mut split_args = query.split_ascii_whitespace().peekable();
|
||||
let command = match split_args.peek() {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
debug!("Found empty query, returning no route.");
|
||||
return (None, String::new());
|
||||
}
|
||||
};
|
||||
fn resolve_hop<'a>(
|
||||
query: &str,
|
||||
routes: &'a HashMap<String, Route>,
|
||||
default_route: Option<&str>,
|
||||
) -> RouteResolution<'a> {
|
||||
let mut split_args = query.split_ascii_whitespace().peekable();
|
||||
let maybe_route = if let Some(command) = split_args.peek() {
|
||||
routes.get(*command)
|
||||
} else {
|
||||
debug!("Found empty query, returning no route.");
|
||||
return RouteResolution::Unresolved;
|
||||
};
|
||||
|
||||
match (routes.get(*command), default_route) {
|
||||
// Found a route
|
||||
(Some(resolved), _) => (
|
||||
Some(resolved.clone()),
|
||||
match split_args.next() {
|
||||
// Discard the first result, we found the route using the first arg
|
||||
Some(_) => {
|
||||
let args = split_args.join(" ");
|
||||
debug!("Resolved {} with args {}", resolved, args);
|
||||
args
|
||||
let args = split_args.collect::<Vec<_>>();
|
||||
let arg_count = args.len();
|
||||
|
||||
// Try resolving with a matched command
|
||||
if let Some(route) = maybe_route {
|
||||
let args = if args.is_empty() { &[] } else { &args[1..] }.join(" ");
|
||||
let arg_count = arg_count - 1;
|
||||
if check_route(route, arg_count) {
|
||||
debug!("Resolved {route} with args {args}");
|
||||
return RouteResolution::Resolved { route, args };
|
||||
}
|
||||
None => {
|
||||
debug!("Resolved {} with no args", resolved);
|
||||
String::new()
|
||||
}
|
||||
|
||||
// Try resolving with the default route, if it exists
|
||||
if let Some(route) = default_route.and_then(|route| routes.get(route)) {
|
||||
if check_route(route, arg_count) {
|
||||
let args = args.join(" ");
|
||||
debug!("Using default route {route} with args {args}");
|
||||
return RouteResolution::Resolved { route, args };
|
||||
}
|
||||
},
|
||||
),
|
||||
// Unable to find route, but had a default route
|
||||
(None, Some(route)) => {
|
||||
let args = split_args.join(" ");
|
||||
debug!("Using default route {} with args {}", route, args);
|
||||
(routes.get(route).cloned(), args)
|
||||
}
|
||||
// No default route and no match
|
||||
(None, None) => {
|
||||
debug!("Failed to resolve route!");
|
||||
(None, String::new())
|
||||
}
|
||||
}
|
||||
|
||||
RouteResolution::Unresolved
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
pub fn index(data: Data<Arc<RwLock<State>>>) -> impl Responder {
|
||||
let data = data.read().unwrap();
|
||||
HttpResponse::Ok().body(
|
||||
data
|
||||
.renderer
|
||||
.render(
|
||||
"index",
|
||||
&template_args::hostname(data.public_address.clone()),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
/// Checks if the user provided string has the correct properties required by
|
||||
/// the route to be successfully matched.
|
||||
const fn check_route(route: &Route, arg_count: usize) -> bool {
|
||||
if let Some(min_args) = route.min_args {
|
||||
if arg_count < min_args {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max_args) = route.max_args {
|
||||
if arg_count > max_args {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[get("/bunbunsearch.xml")]
|
||||
pub fn opensearch(data: Data<Arc<RwLock<State>>>) -> impl Responder {
|
||||
let data = data.read().unwrap();
|
||||
HttpResponse::Ok()
|
||||
.header(
|
||||
header::CONTENT_TYPE,
|
||||
"application/opensearchdescription+xml",
|
||||
)
|
||||
.body(
|
||||
data
|
||||
.renderer
|
||||
.render(
|
||||
"opensearch",
|
||||
&template_args::hostname(data.public_address.clone()),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
#[derive(Deserialize, Debug, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum HopAction<'a> {
|
||||
Redirect(Cow<'a, str>),
|
||||
Body(String),
|
||||
}
|
||||
|
||||
/// Runs the executable with the user's input as a single argument. Returns Ok
|
||||
/// so long as the executable was successfully executed. Returns an Error if the
|
||||
/// file doesn't exist or bunbun did not have permission to read and execute the
|
||||
/// file.
|
||||
fn resolve_path(path: &Path, args: &str) -> Result<HopAction<'static>, BunBunError> {
|
||||
let output = Command::new(path.canonicalize()?)
|
||||
.args(args.split(' '))
|
||||
.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(serde_json::from_slice(&output.stdout)?)
|
||||
} else {
|
||||
error!(
|
||||
"Program exit code for {} was not 0! Dumping standard error!",
|
||||
path.display(),
|
||||
);
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
Err(BunBunError::CustomProgram(error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_hop {
|
||||
use super::*;
|
||||
use anyhow::Result;
|
||||
|
||||
fn generate_route_result<'a>(keyword: &'a Route, args: &str) -> RouteResolution<'a> {
|
||||
RouteResolution::Resolved {
|
||||
route: keyword,
|
||||
args: String::from(args),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_routes_no_default_yields_failed_hop() {
|
||||
assert_eq!(
|
||||
resolve_hop("hello world", &HashMap::new(), None),
|
||||
RouteResolution::Unresolved
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_routes_some_default_yields_failed_hop() {
|
||||
assert_eq!(
|
||||
resolve_hop("hello world", &HashMap::new(), Some(&"google")),
|
||||
RouteResolution::Unresolved
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_default_routes_some_default_yields_default_hop() -> Result<()> {
|
||||
let mut map: HashMap<String, Route> = HashMap::new();
|
||||
map.insert("google".into(), Route::from("https://example.com"));
|
||||
assert_eq!(
|
||||
resolve_hop("hello world", &map, Some("google")),
|
||||
generate_route_result(&Route::from("https://example.com"), "hello world"),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_default_routes_some_default_yields_non_default_hop() -> Result<()> {
|
||||
let mut map: HashMap<String, Route> = HashMap::new();
|
||||
map.insert("google".into(), Route::from("https://example.com"));
|
||||
assert_eq!(
|
||||
resolve_hop("google hello world", &map, Some("a")),
|
||||
generate_route_result(&Route::from("https://example.com"), "hello world"),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_default_routes_no_default_yields_non_default_hop() -> Result<()> {
|
||||
let mut map: HashMap<String, Route> = HashMap::new();
|
||||
map.insert("google".into(), Route::from("https://example.com"));
|
||||
assert_eq!(
|
||||
resolve_hop("google hello world", &map, None),
|
||||
generate_route_result(&Route::from("https://example.com"), "hello world"),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod check_route {
|
||||
use super::*;
|
||||
|
||||
fn create_route(
|
||||
min_args: impl Into<Option<usize>>,
|
||||
max_args: impl Into<Option<usize>>,
|
||||
) -> Route {
|
||||
Route {
|
||||
description: None,
|
||||
hidden: false,
|
||||
max_args: max_args.into(),
|
||||
min_args: min_args.into(),
|
||||
path: String::new(),
|
||||
route_type: RouteType::External,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_min_arg_no_max_arg_counts() {
|
||||
assert!(check_route(&create_route(None, None), 0));
|
||||
assert!(check_route(&create_route(None, None), usize::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_arg_no_max_arg_counts() {
|
||||
assert!(!check_route(&create_route(3, None), 0));
|
||||
assert!(!check_route(&create_route(3, None), 2));
|
||||
assert!(check_route(&create_route(3, None), 3));
|
||||
assert!(check_route(&create_route(3, None), 4));
|
||||
assert!(check_route(&create_route(3, None), usize::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_min_arg_max_arg_counts() {
|
||||
assert!(check_route(&create_route(None, 3), 0));
|
||||
assert!(check_route(&create_route(None, 3), 2));
|
||||
assert!(check_route(&create_route(None, 3), 3));
|
||||
assert!(!check_route(&create_route(None, 3), 4));
|
||||
assert!(!check_route(&create_route(None, 3), usize::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_arg_max_arg_counts() {
|
||||
assert!(!check_route(&create_route(2, 3), 1));
|
||||
assert!(check_route(&create_route(2, 3), 2));
|
||||
assert!(check_route(&create_route(2, 3), 3));
|
||||
assert!(!check_route(&create_route(2, 3), 4));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_path {
|
||||
use crate::error::BunBunError;
|
||||
|
||||
use super::{resolve_path, HopAction};
|
||||
use anyhow::Result;
|
||||
use std::borrow::Cow;
|
||||
use std::env::current_dir;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn invalid_path_returns_err() {
|
||||
assert!(resolve_path(&Path::new("/bin/aaaa"), "aaaa").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_path_returns_ok() {
|
||||
assert!(resolve_path(&Path::new("/bin/echo"), r#"{"body": "a"}"#).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_path_returns_ok() -> Result<()> {
|
||||
// How many ".." needed to get to /
|
||||
let nest_level = current_dir()?.ancestors().count() - 1;
|
||||
let mut rel_path = PathBuf::from("../".repeat(nest_level));
|
||||
rel_path.push("./bin/echo");
|
||||
assert!(resolve_path(&rel_path, r#"{"body": "a"}"#).is_ok());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_permissions_returns_err() {
|
||||
let result = match resolve_path(&Path::new("/root/some_exec"), "") {
|
||||
Err(BunBunError::Io(e)) => e.kind() == ErrorKind::PermissionDenied,
|
||||
_ => false,
|
||||
};
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_success_exit_code_yields_err() {
|
||||
// cat-ing a folder always returns exit code 1
|
||||
assert!(resolve_path(&Path::new("/bin/cat"), "/").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_body() -> Result<()> {
|
||||
assert_eq!(
|
||||
resolve_path(&Path::new("/bin/echo"), r#"{"body": "a"}"#)?,
|
||||
HopAction::Body("a".to_owned())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn return_redirect() -> Result<()> {
|
||||
assert_eq!(
|
||||
resolve_path(&Path::new("/bin/echo"), r#"{"redirect": "a"}"#)?,
|
||||
HopAction::Redirect(Cow::Borrowed("a"))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,22 @@
|
|||
use std::borrow::Cow;
|
||||
|
||||
use percent_encoding::PercentEncode;
|
||||
use serde::Serialize;
|
||||
|
||||
pub fn query(query: String) -> impl Serialize {
|
||||
#[derive(Serialize)]
|
||||
struct TemplateArgs {
|
||||
query: String,
|
||||
}
|
||||
TemplateArgs { query }
|
||||
pub fn query(query: PercentEncode<'_>) -> impl Serialize + '_ {
|
||||
#[derive(Serialize)]
|
||||
struct TemplateArgs<'a> {
|
||||
query: Cow<'a, str>,
|
||||
}
|
||||
TemplateArgs {
|
||||
query: query.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hostname(hostname: String) -> impl Serialize {
|
||||
#[derive(Serialize)]
|
||||
pub struct TemplateArgs {
|
||||
pub hostname: String,
|
||||
}
|
||||
TemplateArgs { hostname }
|
||||
pub fn hostname(hostname: &'_ str) -> impl Serialize + '_ {
|
||||
#[derive(Serialize)]
|
||||
pub struct TemplateArgs<'a> {
|
||||
pub hostname: &'a str,
|
||||
}
|
||||
TemplateArgs { hostname }
|
||||
}
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<title>Bunbun</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="Bunbun search multiplexer/jump service">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Bunbun search multiplexer/jump service" />
|
||||
<style type="text/css">
|
||||
body {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: #212121;
|
||||
|
@ -14,8 +16,19 @@
|
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
h1, p { margin: 0; }
|
||||
h1 { margin-top: 1rem; }
|
||||
main { display: flex; }
|
||||
a { color: white; }
|
||||
footer {
|
||||
display: flex;
|
||||
justify-self: end;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.spacer { flex-grow: 1; }
|
||||
footer p, footer p a {
|
||||
margin: 1rem 0.5rem;
|
||||
color: #555;
|
||||
}
|
||||
</style>
|
||||
<link rel="search"
|
||||
type="application/opensearchdescription+xml"
|
||||
|
@ -24,7 +37,7 @@
|
|||
</head>
|
||||
<body>
|
||||
<h1>Bunbun</h1>
|
||||
<p>Thanks for installing bunbun! To setup bunbun for your web browser, follow these steps:</p>
|
||||
<p>Welcome to bunbun! To setup bunbun for your web browser, follow these steps:</p>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Firefox</h2>
|
||||
|
@ -49,6 +62,11 @@
|
|||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
<p>To view a full list of commands, check out the <a href="/ls">command list</a>.</p>
|
||||
<p>To view a full list of commands currently available on this instance, check out the <a href="/ls">command list</a>.</p>
|
||||
<div class="spacer"></div>
|
||||
<footer>
|
||||
<p>{{> bunbun_version }}</p>
|
||||
<p><a href="{{> bunbun_src }}">Source Code</a></p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -20,23 +20,43 @@
|
|||
i { color: rgba(255, 255, 255, 0.5); }
|
||||
td, th { padding: 0 0.5rem; }
|
||||
.shortcut { text-align: right; }
|
||||
.target { text-align: left; width: 100%; }
|
||||
.description { text-align: left; width: 100%; }
|
||||
footer {
|
||||
margin-top: 1rem;
|
||||
color: #444;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Bunbun Command List</h1>
|
||||
<p><i>To edit this list, edit your <code>bunbun.toml</code> file.</i></p>
|
||||
<p><i>To edit this list, edit your <code>bunbun.yaml</code> file.</i></p>
|
||||
<main>
|
||||
{{#each this}} {{!-- Iterate over RouteGroup --}}
|
||||
<header><h2>{{this.name}}</h2><i>{{this.description}}</i></header>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Shortcut</th>
|
||||
<th class="target">Target</th>
|
||||
</tr>
|
||||
{{#each this.routes}}<tr><td class="shortcut">{{@key}}</td><td class="target">{{this}}</td></tr>{{/each}}
|
||||
</table>
|
||||
{{/each}}
|
||||
{{~#each this}} {{!-- Iterate over RouteGroup --}}
|
||||
{{~#unless this.hidden}}
|
||||
<header><h2>{{this.name}}</h2><i>{{this.description}}</i></header>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Shortcut</th>
|
||||
<th class="description">Description</th>
|
||||
</tr>
|
||||
{{~#each this.routes}} {{!-- Iterate over Route --}}
|
||||
{{~#unless this.hidden}}
|
||||
<tr>
|
||||
<td class="shortcut">{{@key}}</td>
|
||||
{{~#if this.description~}}
|
||||
<td class="description">{{this.description}}</td>
|
||||
{{~else~}}
|
||||
<td class="description">{{this.path}}</td>
|
||||
{{~/if}}
|
||||
</tr>
|
||||
{{~/unless}}
|
||||
{{~/each}}
|
||||
</table>
|
||||
{{~/unless}}
|
||||
{{~/each}}
|
||||
</main>
|
||||
<footer>
|
||||
<p>{{> bunbun_version}}</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
<Description>Hop to where you need to go</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<!--<Image width="16" height="16">data:image/x-icon;base64,</Image>-->
|
||||
<Url type="text/html" template="http://{{hostname}}/hop?to={searchTerms}"></Url>
|
||||
<Url type="application/x-moz-keywordsearch" template="http://{{hostname}}/hop?to={searchTerms}"></Url>
|
||||
<Url type="text/html" template="http://{{hostname}}/hop?to={searchTerms}" />
|
||||
<Url type="application/x-moz-keywordsearch" template="http://{{hostname}}/hop?to={searchTerms}" />
|
||||
</OpenSearchDescription>
|
||||
|
|
Loading…
Reference in New Issue