rewrite readme
This commit is contained in:
parent
a02ea0d9d4
commit
2a029c4dd7
20
Cargo.lock
generated
20
Cargo.lock
generated
@ -90,9 +90,9 @@ checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.6"
|
version = "0.4.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a116f46a969224200a0a97f29cfd4c50e7534e4b4826bd23ea2c3c533039c82c"
|
checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"brotli",
|
"brotli",
|
||||||
"flate2",
|
"flate2",
|
||||||
@ -275,9 +275,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "3.4.0"
|
version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f"
|
checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@ -286,9 +286,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli-decompressor"
|
name = "brotli-decompressor"
|
||||||
version = "2.5.1"
|
version = "4.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f"
|
checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
@ -422,9 +422,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.4.0"
|
version = "1.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa"
|
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
@ -564,9 +564,9 @@ checksum = "09078d60b5387e99317a3ecadd61b5a521deab55186e9dab76d7f0ff66838670"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.0.28"
|
version = "1.0.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e"
|
checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
|
79
README
79
README
@ -1,25 +1,72 @@
|
|||||||
a cute metasearch engine
|
==========
|
||||||
|
metasearch
|
||||||
|
==========
|
||||||
|
|
||||||
it sources from google, bing, brave, and a few others.
|
https://github.com/mat-1/metasearch2
|
||||||
|
|
||||||
it's written in rust, using as little client-side javascript as possible.
|
----
|
||||||
|
INFO
|
||||||
|
----
|
||||||
|
|
||||||
there's a demo instance at https://s.matdoes.dev, but don't use it as your
|
metasearch (aka metasearch2) is a cute metasearch engine. It sources its results
|
||||||
default or rely on it, please (so i don't get ratelimited by google).
|
from Google, Bing, Brave, and several others. It's designed to be as lightweight
|
||||||
|
as possible, both on the server and client. There is no required client-side
|
||||||
|
JavaScript.
|
||||||
|
|
||||||
USAGE
|
There's a public demo instance at https://s.matdoes.dev, but please do not use
|
||||||
|
it as your default or rely on it. This is so I don't get ratelimited by Google
|
||||||
|
or other engines. Run your own instance instead!
|
||||||
|
|
||||||
build it with `cargo b -r`, the resulting binary will be at
|
------------
|
||||||
`target/release/metasearch2`.
|
INSTALLATION
|
||||||
|
------------
|
||||||
|
|
||||||
the config.toml file is created in your current working directory on the first
|
The easiest way to install metasearch is with `cargo install metasearch`. To get
|
||||||
run of metasearch2. alternatively, you can copy the config-default.toml in the
|
the unstable version with the latest features, you can install it with
|
||||||
repo and rename it to config.toml.
|
`cargo install --git https://github.com/mat-1/metasearch2`.
|
||||||
|
|
||||||
the default port is 28019.
|
Usage: `metasearch [config_file]`
|
||||||
|
|
||||||
API
|
The config_file argument is optional; if it's not specified then it'll be
|
||||||
|
checked at the following locations:
|
||||||
|
|
||||||
metasearch has a JSON API that can be enabled by setting `api = true` in your config. to use it,
|
- $XDG_CONFIG_HOME/metasearch/config.toml
|
||||||
just set the `Accept: application/json` header. as the api works by serializing internal structs,
|
- $HOME/.config/metasearch/config.toml
|
||||||
it's not guaranteed to be stable across metasearch versions.
|
- ./config.toml
|
||||||
|
|
||||||
|
If no config file exists, it'll be created at the first valid path in the list.
|
||||||
|
|
||||||
|
By default, metasearch runs on the port 28019. You are recommended to use a
|
||||||
|
reverse proxy.
|
||||||
|
|
||||||
|
-------------
|
||||||
|
CONFIGURATION
|
||||||
|
-------------
|
||||||
|
|
||||||
|
You can see all the default config options at `src/config.rs`. Some interesting
|
||||||
|
options you may want to change are:
|
||||||
|
|
||||||
|
- bind - the host and port that the web server runs on, for example
|
||||||
|
`0.0.0.0:28019`.
|
||||||
|
- api - whether your instance is accessible through a JSON API. See below for
|
||||||
|
more details.
|
||||||
|
- ui.stylesheet_url - a link to a stylesheet that will be loaded alongside the
|
||||||
|
main one, for example `/themes/catppuccin-mocha.css`.
|
||||||
|
- image_search.enabled - add a tab for viewing image results for your query.
|
||||||
|
this is disabled by default as the image proxy could be used to make GET
|
||||||
|
requests to arbitrary URLs from your server.
|
||||||
|
- engines.google.weight - the ranking score multiplier for an engine, you can
|
||||||
|
modify this if you prefer the results from certain engines.
|
||||||
|
|
||||||
|
--------
|
||||||
|
JSON API
|
||||||
|
--------
|
||||||
|
|
||||||
|
metasearch has a JSON API that can be enabled by setting `api = true` in your
|
||||||
|
config. To use it, set the `Accept: application/json` header in your requests.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
curl 'http://localhost:28019/search?q=sandcats' -H 'Accept: application/json'
|
||||||
|
|
||||||
|
The structure of the API is not guaranteed to be stable, as it relies on
|
||||||
|
serializing internal structs. It may break across versions!
|
||||||
|
258
src/config.rs
258
src/config.rs
@ -11,6 +11,134 @@ use tracing::info;
|
|||||||
|
|
||||||
use crate::engines::Engine;
|
use crate::engines::Engine;
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Config {
|
||||||
|
bind: "0.0.0.0:28019".parse().unwrap(),
|
||||||
|
api: false,
|
||||||
|
ui: UiConfig {
|
||||||
|
show_engine_list_separator: false,
|
||||||
|
show_version_info: false,
|
||||||
|
site_name: "metasearch".to_string(),
|
||||||
|
show_settings_link: true,
|
||||||
|
stylesheet_url: "".to_string(),
|
||||||
|
stylesheet_str: "".to_string(),
|
||||||
|
},
|
||||||
|
image_search: ImageSearchConfig {
|
||||||
|
enabled: false,
|
||||||
|
show_engines: true,
|
||||||
|
proxy: ImageProxyConfig {
|
||||||
|
enabled: true,
|
||||||
|
max_download_size: 10_000_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
engines: Arc::new(EnginesConfig::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EnginesConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
use toml::value::Value;
|
||||||
|
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
// engines are enabled by default, so engines that aren't listed here are
|
||||||
|
// enabled
|
||||||
|
|
||||||
|
// main search engines
|
||||||
|
map.insert(Engine::Google, EngineConfig::new().with_weight(1.05));
|
||||||
|
map.insert(Engine::Bing, EngineConfig::new().with_weight(1.0));
|
||||||
|
map.insert(Engine::Brave, EngineConfig::new().with_weight(1.25));
|
||||||
|
map.insert(
|
||||||
|
Engine::Marginalia,
|
||||||
|
EngineConfig::new().with_weight(0.15).with_extra(
|
||||||
|
vec![(
|
||||||
|
"args".to_string(),
|
||||||
|
Value::Table(
|
||||||
|
vec![
|
||||||
|
("profile".to_string(), Value::String("corpo".to_string())),
|
||||||
|
("js".to_string(), Value::String("default".to_string())),
|
||||||
|
("adtech".to_string(), Value::String("default".to_string())),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// additional search engines
|
||||||
|
map.insert(
|
||||||
|
Engine::GoogleScholar,
|
||||||
|
EngineConfig::new().with_weight(0.50).disabled(),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
Engine::RightDao,
|
||||||
|
EngineConfig::new().with_weight(0.10).disabled(),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
Engine::Stract,
|
||||||
|
EngineConfig::new().with_weight(0.15).disabled(),
|
||||||
|
);
|
||||||
|
map.insert(
|
||||||
|
Engine::Yep,
|
||||||
|
EngineConfig::new().with_weight(0.10).disabled(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// calculators (give them a high weight so they're always the first thing in
|
||||||
|
// autocomplete)
|
||||||
|
map.insert(Engine::Numbat, EngineConfig::new().with_weight(10.0));
|
||||||
|
map.insert(
|
||||||
|
Engine::Fend,
|
||||||
|
EngineConfig::new().with_weight(10.0).disabled(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// other engines
|
||||||
|
map.insert(
|
||||||
|
Engine::Mdn,
|
||||||
|
EngineConfig::new().with_extra(
|
||||||
|
vec![("max_sections".to_string(), Value::Integer(1))]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self { map }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EngineConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
weight: 1.0,
|
||||||
|
extra: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static DEFAULT_ENGINE_CONFIG_REF: LazyLock<EngineConfig> = LazyLock::new(EngineConfig::default);
|
||||||
|
impl EngineConfig {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
pub fn with_weight(self, weight: f64) -> Self {
|
||||||
|
Self { weight, ..self }
|
||||||
|
}
|
||||||
|
pub fn disabled(self) -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn with_extra(self, extra: toml::Table) -> Self {
|
||||||
|
Self { extra, ..self }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub bind: SocketAddr,
|
pub bind: SocketAddr,
|
||||||
@ -214,133 +342,3 @@ impl Config {
|
|||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// DEFAULTS
|
|
||||||
//
|
|
||||||
|
|
||||||
impl Default for Config {
|
|
||||||
fn default() -> Self {
|
|
||||||
Config {
|
|
||||||
bind: "0.0.0.0:28019".parse().unwrap(),
|
|
||||||
api: false,
|
|
||||||
ui: UiConfig {
|
|
||||||
show_engine_list_separator: false,
|
|
||||||
show_version_info: false,
|
|
||||||
site_name: "metasearch".to_string(),
|
|
||||||
show_settings_link: true,
|
|
||||||
stylesheet_url: "".to_string(),
|
|
||||||
stylesheet_str: "".to_string(),
|
|
||||||
},
|
|
||||||
image_search: ImageSearchConfig {
|
|
||||||
enabled: false,
|
|
||||||
show_engines: true,
|
|
||||||
proxy: ImageProxyConfig {
|
|
||||||
enabled: true,
|
|
||||||
max_download_size: 10_000_000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
engines: Arc::new(EnginesConfig::default()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EngineConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: true,
|
|
||||||
weight: 1.0,
|
|
||||||
extra: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
static DEFAULT_ENGINE_CONFIG_REF: LazyLock<EngineConfig> = LazyLock::new(EngineConfig::default);
|
|
||||||
impl EngineConfig {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
pub fn with_weight(self, weight: f64) -> Self {
|
|
||||||
Self { weight, ..self }
|
|
||||||
}
|
|
||||||
pub fn disabled(self) -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: false,
|
|
||||||
..self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn with_extra(self, extra: toml::Table) -> Self {
|
|
||||||
Self { extra, ..self }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for EnginesConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
use toml::value::Value;
|
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
// engines are enabled by default, so engines that aren't listed here are
|
|
||||||
// enabled
|
|
||||||
|
|
||||||
// main search engines
|
|
||||||
map.insert(Engine::Google, EngineConfig::new().with_weight(1.05));
|
|
||||||
map.insert(Engine::Bing, EngineConfig::new().with_weight(1.0));
|
|
||||||
map.insert(Engine::Brave, EngineConfig::new().with_weight(1.25));
|
|
||||||
map.insert(
|
|
||||||
Engine::Marginalia,
|
|
||||||
EngineConfig::new().with_weight(0.15).with_extra(
|
|
||||||
vec![(
|
|
||||||
"args".to_string(),
|
|
||||||
Value::Table(
|
|
||||||
vec![
|
|
||||||
("profile".to_string(), Value::String("corpo".to_string())),
|
|
||||||
("js".to_string(), Value::String("default".to_string())),
|
|
||||||
("adtech".to_string(), Value::String("default".to_string())),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
)]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// additional search engines
|
|
||||||
map.insert(
|
|
||||||
Engine::GoogleScholar,
|
|
||||||
EngineConfig::new().with_weight(0.50).disabled(),
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
Engine::RightDao,
|
|
||||||
EngineConfig::new().with_weight(0.10).disabled(),
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
Engine::Stract,
|
|
||||||
EngineConfig::new().with_weight(0.15).disabled(),
|
|
||||||
);
|
|
||||||
map.insert(
|
|
||||||
Engine::Yep,
|
|
||||||
EngineConfig::new().with_weight(0.10).disabled(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// calculators (give them a high weight so they're always the first thing in
|
|
||||||
// autocomplete)
|
|
||||||
map.insert(Engine::Numbat, EngineConfig::new().with_weight(10.0));
|
|
||||||
map.insert(
|
|
||||||
Engine::Fend,
|
|
||||||
EngineConfig::new().with_weight(10.0).disabled(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// other engines
|
|
||||||
map.insert(
|
|
||||||
Engine::Mdn,
|
|
||||||
EngineConfig::new().with_extra(
|
|
||||||
vec![("max_sections".to_string(), Value::Integer(1))]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Self { map }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
58
src/main.rs
58
src/main.rs
@ -1,4 +1,7 @@
|
|||||||
use std::path::Path;
|
use std::{
|
||||||
|
env,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
@ -13,10 +16,13 @@ pub mod web;
|
|||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
let config_path = std::env::args().nth(1).unwrap_or("config.toml".into());
|
if env::args().any(|arg| arg == "--help" || arg == "-h" || arg == "help" || arg == "h") {
|
||||||
let config_path = Path::new(&config_path);
|
println!("Usage: metasearch [config_path]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let config = match Config::read_or_create(config_path) {
|
let config_path = config_path();
|
||||||
|
let config = match Config::read_or_create(&config_path) {
|
||||||
Ok(config) => config,
|
Ok(config) => config,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Couldn't parse config:\n{err}");
|
error!("Couldn't parse config:\n{err}");
|
||||||
@ -25,3 +31,47 @@ async fn main() {
|
|||||||
};
|
};
|
||||||
web::run(config).await;
|
web::run(config).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
if let Some(config_path) = env::args().nth(1) {
|
||||||
|
return PathBuf::from(config_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_name = env!("CARGO_PKG_NAME");
|
||||||
|
|
||||||
|
let mut default_config_dir = None;
|
||||||
|
|
||||||
|
// $XDG_CONFIG_HOME/metasearch/config.toml
|
||||||
|
if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") {
|
||||||
|
let path = PathBuf::from(xdg_config_home)
|
||||||
|
.join(app_name)
|
||||||
|
.join("config.toml");
|
||||||
|
if path.is_file() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if default_config_dir.is_none() {
|
||||||
|
default_config_dir = Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// $HOME/.config/metasearch/config.toml
|
||||||
|
if let Ok(home) = env::var("HOME") {
|
||||||
|
let path = PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join(app_name)
|
||||||
|
.join("config.toml");
|
||||||
|
if path.is_file() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if default_config_dir.is_none() {
|
||||||
|
default_config_dir = Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ./config.toml
|
||||||
|
let path = Path::new("config.toml");
|
||||||
|
if path.exists() {
|
||||||
|
return path.to_path_buf();
|
||||||
|
}
|
||||||
|
default_config_dir.unwrap_or(PathBuf::from("config.toml"))
|
||||||
|
}
|
||||||
|
@ -448,6 +448,7 @@ h3.answer-thesaurus-category-title {
|
|||||||
.image-result {
|
.image-result {
|
||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
.image-result-img-container {
|
.image-result-img-container {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@ -459,7 +460,7 @@ h3.answer-thesaurus-category-title {
|
|||||||
}
|
}
|
||||||
.image-result-page-anchor {
|
.image-result-page-anchor {
|
||||||
display: block;
|
display: block;
|
||||||
height: 2em;
|
height: 2.25em;
|
||||||
}
|
}
|
||||||
.image-result-page-url {
|
.image-result-page-url {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
Loading…
Reference in New Issue
Block a user