mirror of
https://github.com/meilisearch/MeiliSearch
synced 2025-07-03 11:57:07 +02:00
feat(auth): API keys
implements: https://github.com/meilisearch/specifications/blob/develop/text/0085-api-keys.md - Add tests on API keys management route (meilisearch-http/tests/auth/api_keys.rs) - Add tests checking authorizations on each meilisearch routes (meilisearch-http/tests/auth/authorization.rs) - Implement API keys management routes (meilisearch-http/src/routes/api_key.rs) - Create module to manage API keys and authorizations (meilisearch-auth) - Reimplement GuardedData to extend authorizations (meilisearch-http/src/extractors/authentication/mod.rs) - Change X-MEILI-API-KEY by Authorization Bearer (meilisearch-http/src/extractors/authentication/mod.rs) - Change meilisearch routes to fit to the new authorization feature (meilisearch-http/src/routes/) - close #1867
This commit is contained in:
parent
fa196986c2
commit
ffefd0caf2
44 changed files with 3155 additions and 361 deletions
|
@ -259,7 +259,7 @@ impl Segment {
|
|||
}
|
||||
|
||||
async fn tick(&mut self, meilisearch: MeiliSearch) {
|
||||
if let Ok(stats) = meilisearch.get_all_stats().await {
|
||||
if let Ok(stats) = meilisearch.get_all_stats(&None).await {
|
||||
let _ = self
|
||||
.batcher
|
||||
.push(Identify {
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
use actix_web as aweb;
|
||||
use aweb::error::{JsonPayloadError, QueryPayloadError};
|
||||
use meilisearch_error::{Code, ErrorCode, ResponseError};
|
||||
|
@ -32,23 +29,18 @@ impl From<MeilisearchHttpError> for aweb::Error {
|
|||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PayloadError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
PayloadError::Json(e) => e.fmt(f),
|
||||
PayloadError::Query(e) => e.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PayloadError {
|
||||
#[error("{0}")]
|
||||
Json(JsonPayloadError),
|
||||
#[error("{0}")]
|
||||
Query(QueryPayloadError),
|
||||
#[error("The json payload provided is malformed. `{0}`.")]
|
||||
MalformedPayload(serde_json::error::Error),
|
||||
#[error("A json payload is missing.")]
|
||||
MissingPayload,
|
||||
}
|
||||
|
||||
impl Error for PayloadError {}
|
||||
|
||||
impl ErrorCode for PayloadError {
|
||||
fn error_code(&self) -> Code {
|
||||
match self {
|
||||
|
@ -58,7 +50,8 @@ impl ErrorCode for PayloadError {
|
|||
JsonPayloadError::Payload(aweb::error::PayloadError::Overflow) => {
|
||||
Code::PayloadTooLarge
|
||||
}
|
||||
JsonPayloadError::Deserialize(_) | JsonPayloadError::Payload(_) => Code::BadRequest,
|
||||
JsonPayloadError::Payload(_) => Code::BadRequest,
|
||||
JsonPayloadError::Deserialize(_) => Code::BadRequest,
|
||||
JsonPayloadError::Serialize(_) => Code::Internal,
|
||||
_ => Code::Internal,
|
||||
},
|
||||
|
@ -66,13 +59,29 @@ impl ErrorCode for PayloadError {
|
|||
QueryPayloadError::Deserialize(_) => Code::BadRequest,
|
||||
_ => Code::Internal,
|
||||
},
|
||||
PayloadError::MissingPayload => Code::MissingPayload,
|
||||
PayloadError::MalformedPayload(_) => Code::MalformedPayload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<JsonPayloadError> for PayloadError {
|
||||
fn from(other: JsonPayloadError) -> Self {
|
||||
Self::Json(other)
|
||||
match other {
|
||||
JsonPayloadError::Deserialize(e)
|
||||
if e.classify() == serde_json::error::Category::Eof
|
||||
&& e.line() == 1
|
||||
&& e.column() == 0 =>
|
||||
{
|
||||
Self::MissingPayload
|
||||
}
|
||||
JsonPayloadError::Deserialize(e)
|
||||
if e.classify() != serde_json::error::Category::Data =>
|
||||
{
|
||||
Self::MalformedPayload(e)
|
||||
}
|
||||
_ => Self::Json(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,15 +2,13 @@ use meilisearch_error::{Code, ErrorCode};
|
|||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthenticationError {
|
||||
#[error("The X-MEILI-API-KEY header is missing.")]
|
||||
#[error("The Authorization header is missing. It must use the bearer authorization method.")]
|
||||
MissingAuthorizationHeader,
|
||||
#[error("The provided API key is invalid.")]
|
||||
InvalidToken(String),
|
||||
// Triggered on configuration error.
|
||||
#[error("An internal error has occurred. `Irretrievable state`.")]
|
||||
IrretrievableState,
|
||||
#[error("An internal error has occurred. `Unknown authentication policy`.")]
|
||||
UnknownPolicy,
|
||||
}
|
||||
|
||||
impl ErrorCode for AuthenticationError {
|
||||
|
@ -19,7 +17,6 @@ impl ErrorCode for AuthenticationError {
|
|||
AuthenticationError::MissingAuthorizationHeader => Code::MissingAuthorizationHeader,
|
||||
AuthenticationError::InvalidToken(_) => Code::InvalidToken,
|
||||
AuthenticationError::IrretrievableState => Code::Internal,
|
||||
AuthenticationError::UnknownPolicy => Code::Internal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
mod error;
|
||||
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::HashMap;
|
||||
use std::marker::PhantomData;
|
||||
use std::ops::Deref;
|
||||
|
||||
|
@ -11,73 +9,20 @@ use futures::future::{ok, Ready};
|
|||
use meilisearch_error::ResponseError;
|
||||
|
||||
use error::AuthenticationError;
|
||||
|
||||
macro_rules! create_policies {
|
||||
($($name:ident), *) => {
|
||||
pub mod policies {
|
||||
use std::collections::HashSet;
|
||||
use crate::extractors::authentication::Policy;
|
||||
|
||||
$(
|
||||
#[derive(Debug, Default)]
|
||||
pub struct $name {
|
||||
inner: HashSet<Vec<u8>>
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub fn new() -> Self {
|
||||
Self { inner: HashSet::new() }
|
||||
}
|
||||
|
||||
pub fn add(&mut self, token: Vec<u8>) {
|
||||
self.inner.insert(token);
|
||||
}
|
||||
}
|
||||
|
||||
impl Policy for $name {
|
||||
fn authenticate(&self, token: &[u8]) -> bool {
|
||||
self.inner.contains(token)
|
||||
}
|
||||
}
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
create_policies!(Public, Private, Admin);
|
||||
|
||||
/// Instanciate a `Policies`, filled with the given policies.
|
||||
macro_rules! init_policies {
|
||||
($($name:ident), *) => {
|
||||
{
|
||||
let mut policies = crate::extractors::authentication::Policies::new();
|
||||
$(
|
||||
let policy = $name::new();
|
||||
policies.insert(policy);
|
||||
)*
|
||||
policies
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Adds user to all specified policies.
|
||||
macro_rules! create_users {
|
||||
($policies:ident, $($user:expr => { $($policy:ty), * }), *) => {
|
||||
{
|
||||
$(
|
||||
$(
|
||||
$policies.get_mut::<$policy>().map(|p| p.add($user.to_owned()));
|
||||
)*
|
||||
)*
|
||||
}
|
||||
};
|
||||
}
|
||||
use meilisearch_auth::{AuthController, AuthFilter};
|
||||
|
||||
pub struct GuardedData<T, D> {
|
||||
data: D,
|
||||
filters: AuthFilter,
|
||||
_marker: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T, D> GuardedData<T, D> {
|
||||
pub fn filters(&self) -> &AuthFilter {
|
||||
&self.filters
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, D> Deref for GuardedData<T, D> {
|
||||
type Target = D;
|
||||
|
||||
|
@ -86,56 +31,6 @@ impl<T, D> Deref for GuardedData<T, D> {
|
|||
}
|
||||
}
|
||||
|
||||
pub trait Policy {
|
||||
fn authenticate(&self, token: &[u8]) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Policies {
|
||||
inner: HashMap<TypeId, Box<dyn Any>>,
|
||||
}
|
||||
|
||||
impl Policies {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert<S: Policy + 'static>(&mut self, policy: S) {
|
||||
self.inner.insert(TypeId::of::<S>(), Box::new(policy));
|
||||
}
|
||||
|
||||
pub fn get<S: Policy + 'static>(&self) -> Option<&S> {
|
||||
self.inner
|
||||
.get(&TypeId::of::<S>())
|
||||
.and_then(|p| p.downcast_ref::<S>())
|
||||
}
|
||||
|
||||
pub fn get_mut<S: Policy + 'static>(&mut self) -> Option<&mut S> {
|
||||
self.inner
|
||||
.get_mut(&TypeId::of::<S>())
|
||||
.and_then(|p| p.downcast_mut::<S>())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Policies {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AuthConfig {
|
||||
NoAuth,
|
||||
Auth(Policies),
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self::NoAuth
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Policy + 'static, D: 'static + Clone> FromRequest for GuardedData<P, D> {
|
||||
type Config = AuthConfig;
|
||||
|
||||
|
@ -152,32 +47,113 @@ impl<P: Policy + 'static, D: 'static + Clone> FromRequest for GuardedData<P, D>
|
|||
AuthConfig::NoAuth => match req.app_data::<D>().cloned() {
|
||||
Some(data) => ok(Self {
|
||||
data,
|
||||
filters: AuthFilter::default(),
|
||||
_marker: PhantomData,
|
||||
}),
|
||||
None => err(AuthenticationError::IrretrievableState.into()),
|
||||
},
|
||||
AuthConfig::Auth(policies) => match policies.get::<P>() {
|
||||
Some(policy) => match req.headers().get("x-meili-api-key") {
|
||||
Some(token) => {
|
||||
if policy.authenticate(token.as_bytes()) {
|
||||
match req.app_data::<D>().cloned() {
|
||||
Some(data) => ok(Self {
|
||||
data,
|
||||
_marker: PhantomData,
|
||||
}),
|
||||
None => err(AuthenticationError::IrretrievableState.into()),
|
||||
AuthConfig::Auth => match req.app_data::<AuthController>().cloned() {
|
||||
Some(auth) => match req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.map(|type_token| type_token.to_str().unwrap_or_default().splitn(2, ' '))
|
||||
{
|
||||
Some(mut type_token) => match type_token.next() {
|
||||
Some("Bearer") => {
|
||||
// TODO: find a less hardcoded way?
|
||||
let index = req.match_info().get("index_uid");
|
||||
let token = type_token.next().unwrap_or("unknown");
|
||||
match P::authenticate(auth, token, index) {
|
||||
Some(filters) => match req.app_data::<D>().cloned() {
|
||||
Some(data) => ok(Self {
|
||||
data,
|
||||
filters,
|
||||
_marker: PhantomData,
|
||||
}),
|
||||
None => err(AuthenticationError::IrretrievableState.into()),
|
||||
},
|
||||
None => {
|
||||
let token = token.to_string();
|
||||
err(AuthenticationError::InvalidToken(token).into())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let token = token.to_str().unwrap_or("unknown").to_string();
|
||||
err(AuthenticationError::InvalidToken(token).into())
|
||||
}
|
||||
}
|
||||
_otherwise => {
|
||||
err(AuthenticationError::MissingAuthorizationHeader.into())
|
||||
}
|
||||
},
|
||||
None => err(AuthenticationError::MissingAuthorizationHeader.into()),
|
||||
},
|
||||
None => err(AuthenticationError::UnknownPolicy.into()),
|
||||
None => err(AuthenticationError::IrretrievableState.into()),
|
||||
},
|
||||
},
|
||||
None => err(AuthenticationError::IrretrievableState.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Policy {
|
||||
fn authenticate(auth: AuthController, token: &str, index: Option<&str>) -> Option<AuthFilter>;
|
||||
}
|
||||
|
||||
pub mod policies {
|
||||
use crate::extractors::authentication::Policy;
|
||||
use meilisearch_auth::{Action, AuthController, AuthFilter};
|
||||
// reexport actions in policies in order to be used in routes configuration.
|
||||
pub use meilisearch_auth::actions;
|
||||
|
||||
pub struct MasterPolicy;
|
||||
|
||||
impl Policy for MasterPolicy {
|
||||
fn authenticate(
|
||||
auth: AuthController,
|
||||
token: &str,
|
||||
_index: Option<&str>,
|
||||
) -> Option<AuthFilter> {
|
||||
if let Some(master_key) = auth.get_master_key() {
|
||||
if master_key == token {
|
||||
return Some(AuthFilter::default());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ActionPolicy<const A: u8>;
|
||||
|
||||
impl<const A: u8> Policy for ActionPolicy<A> {
|
||||
fn authenticate(
|
||||
auth: AuthController,
|
||||
token: &str,
|
||||
index: Option<&str>,
|
||||
) -> Option<AuthFilter> {
|
||||
// authenticate if token is the master key.
|
||||
if let Some(master_key) = auth.get_master_key() {
|
||||
if master_key == token {
|
||||
return Some(AuthFilter::default());
|
||||
}
|
||||
}
|
||||
|
||||
// authenticate if token is allowed.
|
||||
if let Some(action) = Action::from_repr(A) {
|
||||
let index = index.map(|i| i.as_bytes());
|
||||
if let Ok(true) = auth.authenticate(token.as_bytes(), action, index) {
|
||||
return auth.get_key_filters(token).ok();
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
pub enum AuthConfig {
|
||||
NoAuth,
|
||||
Auth,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self::NoAuth
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,8 +22,8 @@ pub use option::Opt;
|
|||
|
||||
use actix_web::{web, HttpRequest};
|
||||
|
||||
use extractors::authentication::policies::*;
|
||||
use extractors::payload::PayloadConfig;
|
||||
use meilisearch_auth::AuthController;
|
||||
use meilisearch_lib::MeiliSearch;
|
||||
use sha2::Digest;
|
||||
|
||||
|
@ -80,12 +80,14 @@ pub fn setup_meilisearch(opt: &Opt) -> anyhow::Result<MeiliSearch> {
|
|||
pub fn configure_data(
|
||||
config: &mut web::ServiceConfig,
|
||||
data: MeiliSearch,
|
||||
auth: AuthController,
|
||||
opt: &Opt,
|
||||
analytics: Arc<dyn Analytics>,
|
||||
) {
|
||||
let http_payload_size_limit = opt.http_payload_size_limit.get_bytes() as usize;
|
||||
config
|
||||
.app_data(data)
|
||||
.app_data(auth)
|
||||
.app_data(web::Data::from(analytics))
|
||||
.app_data(
|
||||
web::JsonConfig::default()
|
||||
|
@ -112,30 +114,13 @@ pub fn configure_data(
|
|||
}
|
||||
|
||||
pub fn configure_auth(config: &mut web::ServiceConfig, opts: &Opt) {
|
||||
let mut keys = ApiKeys {
|
||||
master: opts.master_key.clone(),
|
||||
private: None,
|
||||
public: None,
|
||||
};
|
||||
|
||||
keys.generate_missing_api_keys();
|
||||
|
||||
let auth_config = if let Some(ref master_key) = keys.master {
|
||||
let private_key = keys.private.as_ref().unwrap();
|
||||
let public_key = keys.public.as_ref().unwrap();
|
||||
let mut policies = init_policies!(Public, Private, Admin);
|
||||
create_users!(
|
||||
policies,
|
||||
master_key.as_bytes() => { Admin, Private, Public },
|
||||
private_key.as_bytes() => { Private, Public },
|
||||
public_key.as_bytes() => { Public }
|
||||
);
|
||||
AuthConfig::Auth(policies)
|
||||
let auth_config = if opts.master_key.is_some() {
|
||||
AuthConfig::Auth
|
||||
} else {
|
||||
AuthConfig::NoAuth
|
||||
};
|
||||
|
||||
config.app_data(auth_config).app_data(keys);
|
||||
config.app_data(auth_config);
|
||||
}
|
||||
|
||||
#[cfg(feature = "mini-dashboard")]
|
||||
|
@ -177,7 +162,7 @@ pub fn dashboard(config: &mut web::ServiceConfig, _enable_frontend: bool) {
|
|||
|
||||
#[macro_export]
|
||||
macro_rules! create_app {
|
||||
($data:expr, $enable_frontend:expr, $opt:expr, $analytics:expr) => {{
|
||||
($data:expr, $auth:expr, $enable_frontend:expr, $opt:expr, $analytics:expr) => {{
|
||||
use actix_cors::Cors;
|
||||
use actix_web::middleware::TrailingSlash;
|
||||
use actix_web::App;
|
||||
|
@ -188,7 +173,7 @@ macro_rules! create_app {
|
|||
use meilisearch_http::{configure_auth, configure_data, dashboard};
|
||||
|
||||
App::new()
|
||||
.configure(|s| configure_data(s, $data.clone(), &$opt, $analytics))
|
||||
.configure(|s| configure_data(s, $data.clone(), $auth.clone(), &$opt, $analytics))
|
||||
.configure(|s| configure_auth(s, &$opt))
|
||||
.configure(routes::configure)
|
||||
.configure(|s| dashboard(s, $enable_frontend))
|
||||
|
|
|
@ -2,6 +2,7 @@ use std::env;
|
|||
use std::sync::Arc;
|
||||
|
||||
use actix_web::HttpServer;
|
||||
use meilisearch_auth::AuthController;
|
||||
use meilisearch_http::analytics;
|
||||
use meilisearch_http::analytics::Analytics;
|
||||
use meilisearch_http::{create_app, setup_meilisearch, Opt};
|
||||
|
@ -46,6 +47,8 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let meilisearch = setup_meilisearch(&opt)?;
|
||||
|
||||
let auth_controller = AuthController::new(&opt.db_path, &opt.master_key)?;
|
||||
|
||||
#[cfg(all(not(debug_assertions), feature = "analytics"))]
|
||||
let (analytics, user) = if !opt.no_analytics {
|
||||
analytics::SegmentAnalytics::new(&opt, &meilisearch).await
|
||||
|
@ -57,22 +60,30 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
print_launch_resume(&opt, &user);
|
||||
|
||||
run_http(meilisearch, opt, analytics).await?;
|
||||
run_http(meilisearch, auth_controller, opt, analytics).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_http(
|
||||
data: MeiliSearch,
|
||||
auth_controller: AuthController,
|
||||
opt: Opt,
|
||||
analytics: Arc<dyn Analytics>,
|
||||
) -> anyhow::Result<()> {
|
||||
let _enable_dashboard = &opt.env == "development";
|
||||
let opt_clone = opt.clone();
|
||||
let http_server =
|
||||
HttpServer::new(move || create_app!(data, _enable_dashboard, opt_clone, analytics.clone()))
|
||||
// Disable signals allows the server to terminate immediately when a user enter CTRL-C
|
||||
.disable_signals();
|
||||
let http_server = HttpServer::new(move || {
|
||||
create_app!(
|
||||
data,
|
||||
auth_controller,
|
||||
_enable_dashboard,
|
||||
opt_clone,
|
||||
analytics.clone()
|
||||
)
|
||||
})
|
||||
// Disable signals allows the server to terminate immediately when a user enter CTRL-C
|
||||
.disable_signals();
|
||||
|
||||
if let Some(config) = opt.get_ssl_config()? {
|
||||
http_server
|
||||
|
|
126
meilisearch-http/src/routes/api_key.rs
Normal file
126
meilisearch-http/src/routes/api_key.rs
Normal file
|
@ -0,0 +1,126 @@
|
|||
use std::str;
|
||||
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Utc};
|
||||
use log::debug;
|
||||
use meilisearch_auth::{generate_key, Action, AuthController, Key};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use meilisearch_error::ResponseError;
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::resource("")
|
||||
.route(web::post().to(create_api_key))
|
||||
.route(web::get().to(list_api_keys)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{api_key}")
|
||||
.route(web::get().to(get_api_key))
|
||||
.route(web::patch().to(patch_api_key))
|
||||
.route(web::delete().to(delete_api_key)),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn create_api_key(
|
||||
auth_controller: GuardedData<MasterPolicy, AuthController>,
|
||||
body: web::Json<Value>,
|
||||
_req: HttpRequest,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let key = auth_controller.create_key(body.into_inner()).await?;
|
||||
let res = KeyView::from_key(key, auth_controller.get_master_key());
|
||||
|
||||
debug!("returns: {:?}", res);
|
||||
Ok(HttpResponse::Created().json(res))
|
||||
}
|
||||
|
||||
pub async fn list_api_keys(
|
||||
auth_controller: GuardedData<MasterPolicy, AuthController>,
|
||||
_req: HttpRequest,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let keys = auth_controller.list_keys().await?;
|
||||
let res: Vec<_> = keys
|
||||
.into_iter()
|
||||
.map(|k| KeyView::from_key(k, auth_controller.get_master_key()))
|
||||
.collect();
|
||||
|
||||
debug!("returns: {:?}", res);
|
||||
Ok(HttpResponse::Ok().json(res))
|
||||
}
|
||||
|
||||
pub async fn get_api_key(
|
||||
auth_controller: GuardedData<MasterPolicy, AuthController>,
|
||||
path: web::Path<AuthParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
// keep 8 first characters that are the ID of the API key.
|
||||
let key = auth_controller.get_key(&path.api_key).await?;
|
||||
let res = KeyView::from_key(key, auth_controller.get_master_key());
|
||||
|
||||
debug!("returns: {:?}", res);
|
||||
Ok(HttpResponse::Ok().json(res))
|
||||
}
|
||||
|
||||
pub async fn patch_api_key(
|
||||
auth_controller: GuardedData<MasterPolicy, AuthController>,
|
||||
body: web::Json<Value>,
|
||||
path: web::Path<AuthParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let key = auth_controller
|
||||
// keep 8 first characters that are the ID of the API key.
|
||||
.update_key(&path.api_key, body.into_inner())
|
||||
.await?;
|
||||
let res = KeyView::from_key(key, auth_controller.get_master_key());
|
||||
|
||||
debug!("returns: {:?}", res);
|
||||
Ok(HttpResponse::Ok().json(res))
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(
|
||||
auth_controller: GuardedData<MasterPolicy, AuthController>,
|
||||
path: web::Path<AuthParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
// keep 8 first characters that are the ID of the API key.
|
||||
auth_controller.delete_key(&path.api_key).await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().json(()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthParam {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeyView {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
key: String,
|
||||
actions: Vec<Action>,
|
||||
indexes: Vec<String>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl KeyView {
|
||||
fn from_key(key: Key, master_key: Option<&String>) -> Self {
|
||||
let key_id = str::from_utf8(&key.id).unwrap();
|
||||
let generated_key = match master_key {
|
||||
Some(master_key) => generate_key(master_key.as_bytes(), key_id),
|
||||
None => generate_key(&[], key_id),
|
||||
};
|
||||
|
||||
KeyView {
|
||||
description: key.description,
|
||||
key: generated_key,
|
||||
actions: key.actions,
|
||||
indexes: key.indexes,
|
||||
expires_at: key.expires_at,
|
||||
created_at: key.created_at,
|
||||
updated_at: key.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||
}
|
||||
|
||||
pub async fn create_dump(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DUMPS_CREATE }>, MeiliSearch>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
|
@ -38,7 +38,7 @@ struct DumpParam {
|
|||
}
|
||||
|
||||
async fn get_dump_status(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DUMPS_GET }>, MeiliSearch>,
|
||||
path: web::Path<DumpParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let res = meilisearch.dump_info(path.dump_uid.clone()).await?;
|
||||
|
|
|
@ -86,7 +86,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||
}
|
||||
|
||||
pub async fn get_document(
|
||||
meilisearch: GuardedData<Public, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_GET }>, MeiliSearch>,
|
||||
path: web::Path<DocumentParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let index = path.index_uid.clone();
|
||||
|
@ -99,7 +99,7 @@ pub async fn get_document(
|
|||
}
|
||||
|
||||
pub async fn delete_document(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_DELETE }>, MeiliSearch>,
|
||||
path: web::Path<DocumentParam>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let DocumentParam {
|
||||
|
@ -121,7 +121,7 @@ pub struct BrowseQuery {
|
|||
}
|
||||
|
||||
pub async fn get_all_documents(
|
||||
meilisearch: GuardedData<Public, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_GET }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Query<BrowseQuery>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
|
@ -156,7 +156,7 @@ pub struct UpdateDocumentsQuery {
|
|||
}
|
||||
|
||||
pub async fn add_documents(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_ADD }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Query<UpdateDocumentsQuery>,
|
||||
body: Payload,
|
||||
|
@ -187,7 +187,7 @@ pub async fn add_documents(
|
|||
}
|
||||
|
||||
pub async fn update_documents(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_ADD }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Query<UpdateDocumentsQuery>,
|
||||
body: Payload,
|
||||
|
@ -218,7 +218,7 @@ pub async fn update_documents(
|
|||
|
||||
async fn document_addition(
|
||||
mime_type: Option<Mime>,
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_ADD }>, MeiliSearch>,
|
||||
index_uid: String,
|
||||
primary_key: Option<String>,
|
||||
body: Payload,
|
||||
|
@ -259,7 +259,7 @@ async fn document_addition(
|
|||
}
|
||||
|
||||
pub async fn delete_documents(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_DELETE }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
body: web::Json<Vec<Value>>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
|
@ -284,7 +284,7 @@ pub async fn delete_documents(
|
|||
}
|
||||
|
||||
pub async fn clear_all_documents(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::DOCUMENTS_DELETE }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let update = Update::ClearDocuments;
|
||||
|
|
|
@ -39,9 +39,17 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||
}
|
||||
|
||||
pub async fn list_indexes(
|
||||
data: GuardedData<Private, MeiliSearch>,
|
||||
data: GuardedData<ActionPolicy<{ actions::INDEXES_GET }>, MeiliSearch>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let indexes = data.list_indexes().await?;
|
||||
let filters = data.filters();
|
||||
let mut indexes = data.list_indexes().await?;
|
||||
if let Some(indexes_filter) = filters.indexes.as_ref() {
|
||||
indexes = indexes
|
||||
.into_iter()
|
||||
.filter(|i| indexes_filter.contains(&i.uid))
|
||||
.collect();
|
||||
}
|
||||
|
||||
debug!("returns: {:?}", indexes);
|
||||
Ok(HttpResponse::Ok().json(indexes))
|
||||
}
|
||||
|
@ -54,7 +62,7 @@ pub struct IndexCreateRequest {
|
|||
}
|
||||
|
||||
pub async fn create_index(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::INDEXES_ADD }>, MeiliSearch>,
|
||||
body: web::Json<IndexCreateRequest>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
|
@ -94,7 +102,7 @@ pub struct UpdateIndexResponse {
|
|||
}
|
||||
|
||||
pub async fn get_index(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::INDEXES_GET }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let meta = meilisearch.get_index(path.into_inner()).await?;
|
||||
|
@ -103,7 +111,7 @@ pub async fn get_index(
|
|||
}
|
||||
|
||||
pub async fn update_index(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::INDEXES_UPDATE }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
body: web::Json<UpdateIndexRequest>,
|
||||
req: HttpRequest,
|
||||
|
@ -131,7 +139,7 @@ pub async fn update_index(
|
|||
}
|
||||
|
||||
pub async fn delete_index(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::INDEXES_DELETE }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let uid = path.into_inner();
|
||||
|
@ -142,7 +150,7 @@ pub async fn delete_index(
|
|||
}
|
||||
|
||||
pub async fn get_index_stats(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::STATS_GET }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let response = meilisearch.get_index_stats(path.into_inner()).await?;
|
||||
|
|
|
@ -106,7 +106,7 @@ fn fix_sort_query_parameters(sort_query: &str) -> Vec<String> {
|
|||
}
|
||||
|
||||
pub async fn search_with_url_query(
|
||||
meilisearch: GuardedData<Public, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SEARCH }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Query<SearchQueryGet>,
|
||||
req: HttpRequest,
|
||||
|
@ -134,7 +134,7 @@ pub async fn search_with_url_query(
|
|||
}
|
||||
|
||||
pub async fn search_with_post(
|
||||
meilisearch: GuardedData<Public, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SEARCH }>, MeiliSearch>,
|
||||
path: web::Path<String>,
|
||||
params: web::Json<SearchQuery>,
|
||||
req: HttpRequest,
|
||||
|
|
|
@ -27,7 +27,7 @@ macro_rules! make_setting_route {
|
|||
use meilisearch_error::ResponseError;
|
||||
|
||||
pub async fn delete(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SETTINGS_UPDATE }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let settings = Settings {
|
||||
|
@ -48,7 +48,7 @@ macro_rules! make_setting_route {
|
|||
}
|
||||
|
||||
pub async fn update(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SETTINGS_UPDATE }>, MeiliSearch>,
|
||||
index_uid: actix_web::web::Path<String>,
|
||||
body: actix_web::web::Json<Option<$type>>,
|
||||
req: HttpRequest,
|
||||
|
@ -80,7 +80,7 @@ macro_rules! make_setting_route {
|
|||
}
|
||||
|
||||
pub async fn get(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SETTINGS_GET }>, MeiliSearch>,
|
||||
index_uid: actix_web::web::Path<String>,
|
||||
) -> std::result::Result<HttpResponse, ResponseError> {
|
||||
let settings = meilisearch.settings(index_uid.into_inner()).await?;
|
||||
|
@ -243,7 +243,7 @@ generate_configure!(
|
|||
);
|
||||
|
||||
pub async fn update_all(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::SETTINGS_UPDATE }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
body: web::Json<Settings<Unchecked>>,
|
||||
req: HttpRequest,
|
||||
|
@ -286,7 +286,7 @@ pub async fn update_all(
|
|||
}
|
||||
|
||||
pub async fn get_all(
|
||||
data: GuardedData<Private, MeiliSearch>,
|
||||
data: GuardedData<ActionPolicy<{ actions::SETTINGS_GET }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let settings = data.settings(index_uid.into_inner()).await?;
|
||||
|
@ -295,7 +295,7 @@ pub async fn get_all(
|
|||
}
|
||||
|
||||
pub async fn delete_all(
|
||||
data: GuardedData<Private, MeiliSearch>,
|
||||
data: GuardedData<ActionPolicy<{ actions::SETTINGS_UPDATE }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let settings = Settings::cleared().into_unchecked();
|
||||
|
|
|
@ -32,7 +32,7 @@ pub struct UpdateParam {
|
|||
}
|
||||
|
||||
pub async fn get_task_status(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::TASKS_GET }>, MeiliSearch>,
|
||||
index_uid: web::Path<UpdateParam>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
|
@ -52,7 +52,7 @@ pub async fn get_task_status(
|
|||
}
|
||||
|
||||
pub async fn get_all_tasks_status(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::TASKS_GET }>, MeiliSearch>,
|
||||
index_uid: web::Path<String>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
|
|
0
meilisearch-http/src/routes/indexes/updates.rs
Normal file
0
meilisearch-http/src/routes/indexes/updates.rs
Normal file
|
@ -8,8 +8,8 @@ use meilisearch_lib::index::{Settings, Unchecked};
|
|||
use meilisearch_lib::MeiliSearch;
|
||||
|
||||
use crate::extractors::authentication::{policies::*, GuardedData};
|
||||
use crate::ApiKeys;
|
||||
|
||||
mod api_key;
|
||||
mod dump;
|
||||
pub mod indexes;
|
||||
mod tasks;
|
||||
|
@ -17,8 +17,8 @@ mod tasks;
|
|||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/tasks").configure(tasks::configure))
|
||||
.service(web::resource("/health").route(web::get().to(get_health)))
|
||||
.service(web::scope("/keys").configure(api_key::configure))
|
||||
.service(web::scope("/dumps").configure(dump::configure))
|
||||
.service(web::resource("/keys").route(web::get().to(list_keys)))
|
||||
.service(web::resource("/stats").route(web::get().to(get_stats)))
|
||||
.service(web::resource("/version").route(web::get().to(get_version)))
|
||||
.service(web::scope("/indexes").configure(indexes::configure));
|
||||
|
@ -125,9 +125,11 @@ pub async fn running() -> HttpResponse {
|
|||
}
|
||||
|
||||
async fn get_stats(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::STATS_GET }>, MeiliSearch>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
let response = meilisearch.get_all_stats().await?;
|
||||
let filters = meilisearch.filters();
|
||||
|
||||
let response = meilisearch.get_all_stats(&filters.indexes).await?;
|
||||
|
||||
debug!("returns: {:?}", response);
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
|
@ -141,7 +143,9 @@ struct VersionResponse {
|
|||
pkg_version: String,
|
||||
}
|
||||
|
||||
async fn get_version(_meilisearch: GuardedData<Private, MeiliSearch>) -> HttpResponse {
|
||||
async fn get_version(
|
||||
_meilisearch: GuardedData<ActionPolicy<{ actions::VERSION }>, MeiliSearch>,
|
||||
) -> HttpResponse {
|
||||
let commit_sha = option_env!("VERGEN_GIT_SHA").unwrap_or("unknown");
|
||||
let commit_date = option_env!("VERGEN_GIT_COMMIT_TIMESTAMP").unwrap_or("unknown");
|
||||
|
||||
|
@ -158,108 +162,6 @@ struct KeysResponse {
|
|||
public: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_keys(meilisearch: GuardedData<Admin, ApiKeys>) -> HttpResponse {
|
||||
let api_keys = (*meilisearch).clone();
|
||||
HttpResponse::Ok().json(&KeysResponse {
|
||||
private: api_keys.private,
|
||||
public: api_keys.public,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_health() -> Result<HttpResponse, ResponseError> {
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({ "status": "available" })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::extractors::authentication::GuardedData;
|
||||
|
||||
/// A type implemented for a route that uses a authentication policy `Policy`.
|
||||
///
|
||||
/// This trait is used for regression testing of route authenticaton policies.
|
||||
trait Is<Policy, Data, T> {}
|
||||
|
||||
macro_rules! impl_is_policy {
|
||||
($($param:ident)*) => {
|
||||
impl<Policy, Func, Data, $($param,)* Res> Is<Policy, Data, (($($param,)*), Res)> for Func
|
||||
where Func: Fn(GuardedData<Policy, Data>, $($param,)*) -> Res {}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
impl_is_policy! {}
|
||||
impl_is_policy! {A}
|
||||
impl_is_policy! {A B}
|
||||
impl_is_policy! {A B C}
|
||||
impl_is_policy! {A B C D}
|
||||
impl_is_policy! {A B C D E}
|
||||
|
||||
/// Emits a compile error if a route doesn't have the correct authentication policy.
|
||||
///
|
||||
/// This works by trying to cast the route function into a Is<Policy, _> type, where Policy it
|
||||
/// the authentication policy defined for the route.
|
||||
macro_rules! test_auth_routes {
|
||||
($($policy:ident => { $($route:expr,)*})*) => {
|
||||
#[test]
|
||||
fn test_auth() {
|
||||
$($(let _: &dyn Is<$policy, _, _> = &$route;)*)*
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test_auth_routes! {
|
||||
Public => {
|
||||
indexes::search::search_with_url_query,
|
||||
indexes::search::search_with_post,
|
||||
|
||||
indexes::documents::get_document,
|
||||
indexes::documents::get_all_documents,
|
||||
}
|
||||
Private => {
|
||||
get_stats,
|
||||
get_version,
|
||||
|
||||
indexes::create_index,
|
||||
indexes::list_indexes,
|
||||
indexes::get_index_stats,
|
||||
indexes::delete_index,
|
||||
indexes::update_index,
|
||||
indexes::get_index,
|
||||
|
||||
dump::create_dump,
|
||||
|
||||
indexes::settings::filterable_attributes::get,
|
||||
indexes::settings::displayed_attributes::get,
|
||||
indexes::settings::searchable_attributes::get,
|
||||
indexes::settings::stop_words::get,
|
||||
indexes::settings::synonyms::get,
|
||||
indexes::settings::distinct_attribute::get,
|
||||
indexes::settings::filterable_attributes::update,
|
||||
indexes::settings::displayed_attributes::update,
|
||||
indexes::settings::searchable_attributes::update,
|
||||
indexes::settings::stop_words::update,
|
||||
indexes::settings::synonyms::update,
|
||||
indexes::settings::distinct_attribute::update,
|
||||
indexes::settings::filterable_attributes::delete,
|
||||
indexes::settings::displayed_attributes::delete,
|
||||
indexes::settings::searchable_attributes::delete,
|
||||
indexes::settings::stop_words::delete,
|
||||
indexes::settings::synonyms::delete,
|
||||
indexes::settings::distinct_attribute::delete,
|
||||
indexes::settings::delete_all,
|
||||
indexes::settings::get_all,
|
||||
indexes::settings::update_all,
|
||||
|
||||
indexes::documents::clear_all_documents,
|
||||
indexes::documents::delete_documents,
|
||||
indexes::documents::update_documents,
|
||||
indexes::documents::add_documents,
|
||||
indexes::documents::delete_document,
|
||||
|
||||
indexes::tasks::get_all_tasks_status,
|
||||
indexes::tasks::get_task_status,
|
||||
}
|
||||
Admin => { list_keys, }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||
}
|
||||
|
||||
async fn get_tasks(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::TASKS_GET }>, MeiliSearch>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
) -> Result<HttpResponse, ResponseError> {
|
||||
|
@ -36,7 +36,7 @@ async fn get_tasks(
|
|||
}
|
||||
|
||||
async fn get_task(
|
||||
meilisearch: GuardedData<Private, MeiliSearch>,
|
||||
meilisearch: GuardedData<ActionPolicy<{ actions::TASKS_GET }>, MeiliSearch>,
|
||||
task_id: web::Path<TaskId>,
|
||||
req: HttpRequest,
|
||||
analytics: web::Data<dyn Analytics>,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue