feat: file uploads
This commit is contained in:
parent
f4514d823b
commit
e00bf57fbe
10 changed files with 924 additions and 3 deletions
6
.github/workflows/linux.yml
vendored
6
.github/workflows/linux.yml
vendored
|
@ -22,7 +22,7 @@ jobs:
|
||||||
name: ${{ matrix.version }} - x86_64-unknown-linux-gnu
|
name: ${{ matrix.version }} - x86_64-unknown-linux-gnu
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
services:
|
#services:
|
||||||
# postgres:
|
# postgres:
|
||||||
# image: postgres
|
# image: postgres
|
||||||
# env:
|
# env:
|
||||||
|
@ -125,8 +125,8 @@ jobs:
|
||||||
if: matrix.version == 'stable' && (github.repository == 'realaravinth/dumbserve')
|
if: matrix.version == 'stable' && (github.repository == 'realaravinth/dumbserve')
|
||||||
run: make doc
|
run: make doc
|
||||||
env:
|
env:
|
||||||
POSTGRES_DATABASE_URL: "${{ env.POSTGRES_DATABASE_URL }}"
|
# POSTGRES_DATABASE_URL: "${{ env.POSTGRES_DATABASE_URL }}"
|
||||||
MARIA_DATABASE_URL: "${{ env.MARIA_DATABASE_URL }}"
|
# MARIA_DATABASE_URL: "${{ env.MARIA_DATABASE_URL }}"
|
||||||
GIT_HASH: 8e77345f1597e40c2e266cb4e6dee74888918a61 # dummy value
|
GIT_HASH: 8e77345f1597e40c2e266cb4e6dee74888918a61 # dummy value
|
||||||
COMPILED_DATE: "2021-07-21"
|
COMPILED_DATE: "2021-07-21"
|
||||||
|
|
||||||
|
|
12
Cargo.toml
12
Cargo.toml
|
@ -24,8 +24,20 @@ serde = { version = "1", features=["derive"]}
|
||||||
tokio = { version = "1.20.1", features = ["fs"]}
|
tokio = { version = "1.20.1", features = ["fs"]}
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
sqlx = { version = "0.5.13", features = [ "runtime-actix-rustls", "postgres", "time", "offline" ] }
|
sqlx = { version = "0.5.13", features = [ "runtime-actix-rustls", "postgres", "time", "offline" ] }
|
||||||
|
actix-web-codegen-const-routes = { version = "0.1.0", tag = "0.1.0", git = "https://github.com/realaravinth/actix-web-codegen-const-routes" }
|
||||||
|
derive_builder = "0.11.2"
|
||||||
|
argon2-creds = { branch = "master", git = "https://github.com/realaravinth/argon2-creds"}
|
||||||
|
config = "0.11"
|
||||||
|
derive_more = "0.99.17"
|
||||||
|
url = { version = "2.2.2", features = ["serde"]}
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.5.13", features = [ "runtime-actix-rustls", "postgres", "time", "offline"] }
|
sqlx = { version = "0.5.13", features = [ "runtime-actix-rustls", "postgres", "time", "offline"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
actix-rt = "2.7.0"
|
||||||
|
base64 = "0.13.0"
|
||||||
|
|
17
src/api/mod.rs
Normal file
17
src/api/mod.rs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
pub mod v1;
|
264
src/api/v1/files.rs
Normal file
264
src/api/v1/files.rs
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
use actix_multipart::Multipart;
|
||||||
|
use actix_web::HttpMessage;
|
||||||
|
use actix_web::{web, Error, HttpRequest, HttpResponse, Responder};
|
||||||
|
use actix_web_httpauth::middleware::HttpAuthentication;
|
||||||
|
use futures_util::TryStreamExt as _;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
use super::httpauth;
|
||||||
|
use super::SignedInUser;
|
||||||
|
use super::API_V1_ROUTES;
|
||||||
|
use crate::AppCtx;
|
||||||
|
|
||||||
|
pub mod routes {
|
||||||
|
use super::*;
|
||||||
|
#[derive(Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Files {
|
||||||
|
pub delete_dir: &'static str,
|
||||||
|
pub upload_file: &'static str,
|
||||||
|
pub index: &'static str,
|
||||||
|
}
|
||||||
|
impl Files {
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
delete_dir: "/api/v1/files/delete",
|
||||||
|
upload_file: "/api/v1/files/upload",
|
||||||
|
index: "/api/v1/files/",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn services(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(delete_dir);
|
||||||
|
cfg.service(upload_file);
|
||||||
|
cfg.service(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||||||
|
struct Dir {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web_codegen_const_routes::delete(
|
||||||
|
path = "API_V1_ROUTES.files.delete_dir",
|
||||||
|
wrap = "HttpAuthentication::basic(httpauth)"
|
||||||
|
)]
|
||||||
|
async fn delete_dir(
|
||||||
|
req: HttpRequest,
|
||||||
|
ctx: AppCtx,
|
||||||
|
payload: web::Json<Dir>,
|
||||||
|
) -> Result<impl Responder, Error> {
|
||||||
|
let path = {
|
||||||
|
let ext = req.extensions();
|
||||||
|
let user = ext.get::<SignedInUser>().unwrap().clone();
|
||||||
|
ctx.settings.files.get_path(&user.0, &payload.path)
|
||||||
|
};
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
if path.is_dir() {
|
||||||
|
fs::remove_dir_all(path).await?;
|
||||||
|
Ok(HttpResponse::Ok().into())
|
||||||
|
} else {
|
||||||
|
Ok(HttpResponse::BadRequest().body("Path is not dir".to_string()))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(HttpResponse::NotFound().body("dir not found".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web_codegen_const_routes::post(
|
||||||
|
path = "API_V1_ROUTES.files.upload_file",
|
||||||
|
wrap = "HttpAuthentication::basic(httpauth)"
|
||||||
|
)]
|
||||||
|
async fn upload_file(
|
||||||
|
ctx: AppCtx,
|
||||||
|
mut payload: Multipart,
|
||||||
|
req: HttpRequest,
|
||||||
|
query: web::Query<Dir>,
|
||||||
|
) -> Result<HttpResponse, Error> {
|
||||||
|
let path = {
|
||||||
|
let ext = req.extensions();
|
||||||
|
let user = ext.get::<SignedInUser>().unwrap().clone();
|
||||||
|
ctx.settings.files.get_path(&user.0, &query.path)
|
||||||
|
};
|
||||||
|
if !path.exists() {
|
||||||
|
fs::create_dir_all(&path).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterate over multipart stream
|
||||||
|
while let Some(mut field) = payload.try_next().await? {
|
||||||
|
// A multipart/form-data stream has to contain `content_disposition`
|
||||||
|
let content_disposition = field.content_disposition();
|
||||||
|
|
||||||
|
let filename = content_disposition.get_filename();
|
||||||
|
|
||||||
|
if filename.is_none() {
|
||||||
|
return Ok(HttpResponse::BadRequest().body("Filename is not present".to_string()));
|
||||||
|
}
|
||||||
|
let filename = filename.unwrap();
|
||||||
|
let filepath = path.join(filename);
|
||||||
|
|
||||||
|
let mut f = fs::File::create(filepath).await?;
|
||||||
|
|
||||||
|
// Field in turn is stream of *Bytes* object
|
||||||
|
while let Some(chunk) = field.try_next().await? {
|
||||||
|
f.write_all(&chunk).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().into())
|
||||||
|
}
|
||||||
|
#[actix_web_codegen_const_routes::get(
|
||||||
|
path = "API_V1_ROUTES.files.index",
|
||||||
|
wrap = "HttpAuthentication::basic(httpauth)"
|
||||||
|
)]
|
||||||
|
async fn index() -> HttpResponse {
|
||||||
|
let html = r#"<html>
|
||||||
|
<head><title>Upload Test</title></head>
|
||||||
|
<body>
|
||||||
|
<form target="/" method="post" enctype="multipart/form-data">
|
||||||
|
<input type="file" multiple name="file"/>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>"#;
|
||||||
|
|
||||||
|
HttpResponse::Ok().body(html)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests {
|
||||||
|
use actix_web::{
|
||||||
|
http::{header, StatusCode},
|
||||||
|
test, App,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn index_works() {
|
||||||
|
// const USERNAME: &str = "index_works";
|
||||||
|
// const PASSWORD: &str = "23k4j;123k4j1;l23kj4";
|
||||||
|
let settings = Settings::new().unwrap();
|
||||||
|
let creds = settings.files.creds.get(0).unwrap().clone();
|
||||||
|
let auth = format!(
|
||||||
|
"Basic {}",
|
||||||
|
base64::encode(format!("{}:{}", creds.username, creds.password))
|
||||||
|
);
|
||||||
|
|
||||||
|
// let settings = Settings::new().unwrap();
|
||||||
|
let ctx = AppCtx::new(crate::ctx::Ctx::new(&settings).await);
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(ctx.clone())
|
||||||
|
.configure(crate::routes::services),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let index_resp = test::call_service(
|
||||||
|
&app,
|
||||||
|
test::TestRequest::get()
|
||||||
|
.append_header((header::AUTHORIZATION, auth))
|
||||||
|
.uri(API_V1_ROUTES.files.index)
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(index_resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn delete_dir_works() {
|
||||||
|
// const USERNAME: &str = "index_works";
|
||||||
|
// const PASSWORD: &str = "23k4j;123k4j1;l23kj4";
|
||||||
|
let settings = Settings::new().unwrap();
|
||||||
|
let creds = settings.files.creds.get(0).unwrap().clone();
|
||||||
|
let auth = format!(
|
||||||
|
"Basic {}",
|
||||||
|
base64::encode(format!("{}:{}", creds.username.clone(), creds.password))
|
||||||
|
);
|
||||||
|
|
||||||
|
const TEST_DIR_NAME: &str = "test-delete_dir_works";
|
||||||
|
const TEST_FILE_NAME: &str = "test-delete_dir_works--file";
|
||||||
|
const TEST_NON_EXIST_DIR: &str = "test-delete_dir_works--no-exist";
|
||||||
|
|
||||||
|
let test_dir = settings.files.get_path(&creds.username, TEST_DIR_NAME);
|
||||||
|
if !test_dir.exists() {
|
||||||
|
tokio::fs::create_dir_all(&test_dir).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let test_file = settings.files.get_path(&creds.username, TEST_FILE_NAME);
|
||||||
|
if !test_file.exists() {
|
||||||
|
let mut f = tokio::fs::File::create(test_file).await.unwrap();
|
||||||
|
f.write_all(b"foo").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = AppCtx::new(crate::ctx::Ctx::new(&settings).await);
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.app_data(ctx.clone())
|
||||||
|
.configure(crate::routes::services),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut payload = Dir {
|
||||||
|
path: TEST_FILE_NAME.into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let delete_dir_resp = test::call_service(
|
||||||
|
&app,
|
||||||
|
test::TestRequest::delete()
|
||||||
|
.append_header((header::AUTHORIZATION, auth.clone()))
|
||||||
|
.set_json(&payload)
|
||||||
|
.uri(API_V1_ROUTES.files.delete_dir)
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(delete_dir_resp.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
payload.path = TEST_NON_EXIST_DIR.into();
|
||||||
|
let delete_dir_resp = test::call_service(
|
||||||
|
&app,
|
||||||
|
test::TestRequest::delete()
|
||||||
|
.append_header((header::AUTHORIZATION, auth.clone()))
|
||||||
|
.set_json(&payload)
|
||||||
|
.uri(API_V1_ROUTES.files.delete_dir)
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(delete_dir_resp.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
payload.path = TEST_DIR_NAME.into();
|
||||||
|
let delete_dir_resp = test::call_service(
|
||||||
|
&app,
|
||||||
|
test::TestRequest::delete()
|
||||||
|
.append_header((header::AUTHORIZATION, auth))
|
||||||
|
.set_json(&payload)
|
||||||
|
.uri(API_V1_ROUTES.files.delete_dir)
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(delete_dir_resp.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
assert!(!test_dir.exists());
|
||||||
|
}
|
||||||
|
}
|
133
src/api/v1/meta.rs
Normal file
133
src/api/v1/meta.rs
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use actix_web::{web, HttpResponse, Responder};
|
||||||
|
use derive_builder::Builder;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::AppCtx;
|
||||||
|
use crate::{GIT_COMMIT_HASH, VERSION};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Builder, Serialize)]
|
||||||
|
pub struct BuildDetails {
|
||||||
|
pub version: &'static str,
|
||||||
|
pub git_commit_hash: &'static str,
|
||||||
|
pub source_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod routes {
|
||||||
|
pub struct Meta {
|
||||||
|
pub build_details: &'static str,
|
||||||
|
pub health: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Meta {
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
build_details: "/api/v1/meta/build",
|
||||||
|
health: "/api/v1/meta/health",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// emits build details of the bninary
|
||||||
|
#[actix_web_codegen_const_routes::get(path = "crate::API_V1_ROUTES.meta.build_details")]
|
||||||
|
async fn build_details(ctx: AppCtx) -> impl Responder {
|
||||||
|
let build = BuildDetails {
|
||||||
|
version: VERSION,
|
||||||
|
git_commit_hash: GIT_COMMIT_HASH,
|
||||||
|
source_code: ctx.source_code.clone(),
|
||||||
|
};
|
||||||
|
HttpResponse::Ok().json(build)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Builder, Serialize)]
|
||||||
|
/// Health check return datatype
|
||||||
|
pub struct Health {
|
||||||
|
db: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// checks all components of the system
|
||||||
|
#[actix_web_codegen_const_routes::get(path = "crate::API_V1_ROUTES.meta.health")]
|
||||||
|
async fn health() -> impl Responder {
|
||||||
|
// let mut resp_builder = HealthBuilder::default();
|
||||||
|
|
||||||
|
// resp_builder.db(data.db.ping().await);
|
||||||
|
|
||||||
|
HttpResponse::Ok() //.json(resp_builder.build().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn services(cfg: &mut web::ServiceConfig) {
|
||||||
|
cfg.service(build_details);
|
||||||
|
cfg.service(health);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod tests {
|
||||||
|
use actix_web::{http::StatusCode, test, App};
|
||||||
|
|
||||||
|
use crate::api::v1::services;
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn build_details_works() {
|
||||||
|
let settings = Settings::new().unwrap();
|
||||||
|
let ctx = AppCtx::new(crate::ctx::Ctx::new(&settings).await);
|
||||||
|
let app = test::init_service(App::new().app_data(ctx.clone()).configure(services)).await;
|
||||||
|
|
||||||
|
let resp = test::call_service(
|
||||||
|
&app,
|
||||||
|
test::TestRequest::get()
|
||||||
|
.uri(API_V1_ROUTES.meta.build_details)
|
||||||
|
.to_request(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// #[actix_rt::test]
|
||||||
|
// async fn health_works_pg() {
|
||||||
|
// let data = crate::tests::pg::get_data().await;
|
||||||
|
// health_works(data).await;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// #[actix_rt::test]
|
||||||
|
// async fn health_works_maria() {
|
||||||
|
// let data = crate::tests::maria::get_data().await;
|
||||||
|
// health_works(data).await;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// pub async fn health_works(data: ArcCtx) {
|
||||||
|
// println!("{}", API_V1_ROUTES.meta.health);
|
||||||
|
// let data = &data;
|
||||||
|
// let app = get_app!(data).await;
|
||||||
|
//
|
||||||
|
// let resp = test::call_service(
|
||||||
|
// &app,
|
||||||
|
// test::TestRequest::get()
|
||||||
|
// .uri(API_V1_ROUTES.meta.health)
|
||||||
|
// .to_request(),
|
||||||
|
// )
|
||||||
|
// .await;
|
||||||
|
// assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
//
|
||||||
|
// let health_resp: Health = test::read_body_json(resp).await;
|
||||||
|
// assert!(health_resp.db);
|
||||||
|
// assert_eq!(health_resp.redis, Some(true));
|
||||||
|
// }
|
||||||
|
}
|
76
src/api/v1/mod.rs
Normal file
76
src/api/v1/mod.rs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
use actix_web::dev::ServiceRequest;
|
||||||
|
use actix_web::web;
|
||||||
|
use actix_web::Error;
|
||||||
|
use actix_web::HttpMessage;
|
||||||
|
use actix_web_httpauth::extractors::basic::BasicAuth;
|
||||||
|
|
||||||
|
pub mod files;
|
||||||
|
pub mod meta;
|
||||||
|
|
||||||
|
use crate::errors::*;
|
||||||
|
use crate::AppCtx;
|
||||||
|
use crate::SETTINGS;
|
||||||
|
|
||||||
|
pub const API_V1_ROUTES: routes::Routes = routes::Routes::new();
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||||
|
pub struct SignedInUser(String);
|
||||||
|
|
||||||
|
pub async fn httpauth(
|
||||||
|
req: ServiceRequest,
|
||||||
|
credentials: BasicAuth,
|
||||||
|
) -> Result<ServiceRequest, (Error, ServiceRequest)> {
|
||||||
|
let _ctx: &AppCtx = req.app_data().unwrap();
|
||||||
|
let username = credentials.user_id();
|
||||||
|
let password = credentials.password().unwrap();
|
||||||
|
if SETTINGS.files.authenticate(username, password) {
|
||||||
|
{
|
||||||
|
let mut ext = req.extensions_mut();
|
||||||
|
ext.insert(SignedInUser(username.to_string()));
|
||||||
|
}
|
||||||
|
Ok(req)
|
||||||
|
} else {
|
||||||
|
let e = Error::from(ServiceError::Unauthorized);
|
||||||
|
Err((e, req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn services(cfg: &mut web::ServiceConfig) {
|
||||||
|
files::services(cfg);
|
||||||
|
meta::services(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod routes {
|
||||||
|
use crate::api::v1::files::routes::Files;
|
||||||
|
use crate::api::v1::meta::routes::Meta;
|
||||||
|
|
||||||
|
pub struct Routes {
|
||||||
|
pub files: Files,
|
||||||
|
pub meta: Meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Routes {
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
files: Files::new(),
|
||||||
|
meta: Meta::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
89
src/ctx.rs
Normal file
89
src/ctx.rs
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
//! App data: database connections, etc.
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use argon2_creds::{Config, ConfigBuilder, PasswordPolicy};
|
||||||
|
|
||||||
|
//use crate::errors::ServiceResult;
|
||||||
|
use crate::settings::Settings;
|
||||||
|
/// App data
|
||||||
|
pub struct Ctx {
|
||||||
|
// /// database ops defined by db crates
|
||||||
|
// pub db: BoxDB,
|
||||||
|
/// credential management configuration
|
||||||
|
pub creds: Config,
|
||||||
|
/// app settings
|
||||||
|
pub settings: Settings,
|
||||||
|
pub source_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ctx {
|
||||||
|
pub fn get_creds() -> Config {
|
||||||
|
ConfigBuilder::default()
|
||||||
|
.username_case_mapped(true)
|
||||||
|
.profanity(true)
|
||||||
|
.blacklist(true)
|
||||||
|
.password_policy(PasswordPolicy::default())
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
/// create new instance of app data
|
||||||
|
pub async fn new(s: &Settings) -> ArcCtx {
|
||||||
|
let creds = Self::get_creds();
|
||||||
|
let c = creds.clone();
|
||||||
|
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
let init = thread::spawn(move || {
|
||||||
|
log::info!("Initializing credential manager");
|
||||||
|
c.init();
|
||||||
|
log::info!("Initialized credential manager");
|
||||||
|
});
|
||||||
|
|
||||||
|
//let db = match s.database.database_type {
|
||||||
|
// crate::settings::DBType::Maria => db::maria::get_data(Some(s.clone())).await,
|
||||||
|
// crate::settings::DBType::Postgres => db::pg::get_data(Some(s.clone())).await,
|
||||||
|
//};
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
init.join().unwrap();
|
||||||
|
|
||||||
|
let source_code = {
|
||||||
|
let mut url = s.source_code.clone();
|
||||||
|
if !url.ends_with('/') {
|
||||||
|
url.push('/');
|
||||||
|
}
|
||||||
|
let mut base = url::Url::parse(&url).unwrap();
|
||||||
|
base = base.join("tree/").unwrap();
|
||||||
|
base = base.join(crate::GIT_COMMIT_HASH).unwrap();
|
||||||
|
base.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = Ctx {
|
||||||
|
creds,
|
||||||
|
// db,
|
||||||
|
settings: s.clone(),
|
||||||
|
source_code,
|
||||||
|
};
|
||||||
|
|
||||||
|
Arc::new(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ArcCtx = Arc<Ctx>;
|
189
src/errors.rs
Normal file
189
src/errors.rs
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::convert::From;
|
||||||
|
|
||||||
|
use argon2_creds::errors::CredsError;
|
||||||
|
//use db_core::errors::DBError;
|
||||||
|
use actix_web::http;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::HttpResponse;
|
||||||
|
use actix_web::HttpResponseBuilder;
|
||||||
|
use actix_web::ResponseError;
|
||||||
|
use derive_more::{Display, Error};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::oneshot::error::RecvError;
|
||||||
|
use url::ParseError;
|
||||||
|
|
||||||
|
//#[derive(Debug, Display, Error)]
|
||||||
|
//pub struct DBErrorWrapper(DBError);
|
||||||
|
//
|
||||||
|
//impl std::cmp::PartialEq for DBErrorWrapper {
|
||||||
|
// fn eq(&self, other: &Self) -> bool {
|
||||||
|
// format!("{}", self.0) == format!("{}", other.0)
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
#[derive(Debug, Display, PartialEq, Eq, Error)]
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum ServiceError {
|
||||||
|
#[display(fmt = "unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
#[display(fmt = "internal server error")]
|
||||||
|
InternalServerError,
|
||||||
|
|
||||||
|
#[display(
|
||||||
|
fmt = "This server is is closed for registration. Contact admin if this is unexpecter"
|
||||||
|
)]
|
||||||
|
ClosedForRegistration,
|
||||||
|
|
||||||
|
#[display(fmt = "The value you entered for email is not an email")] //405j
|
||||||
|
NotAnEmail,
|
||||||
|
#[display(fmt = "The value you entered for URL is not a URL")] //405j
|
||||||
|
NotAUrl,
|
||||||
|
|
||||||
|
#[display(fmt = "Wrong password")]
|
||||||
|
WrongPassword,
|
||||||
|
|
||||||
|
/// when the value passed contains profainity
|
||||||
|
#[display(fmt = "Can't allow profanity in usernames")]
|
||||||
|
ProfainityError,
|
||||||
|
/// when the value passed contains blacklisted words
|
||||||
|
/// see [blacklist](https://github.com/shuttlecraft/The-Big-Username-Blacklist)
|
||||||
|
#[display(fmt = "Username contains blacklisted words")]
|
||||||
|
BlacklistError,
|
||||||
|
/// when the value passed contains characters not present
|
||||||
|
/// in [UsernameCaseMapped](https://tools.ietf.org/html/rfc8265#page-7)
|
||||||
|
/// profile
|
||||||
|
#[display(fmt = "username_case_mapped violation")]
|
||||||
|
UsernameCaseMappedError,
|
||||||
|
|
||||||
|
#[display(fmt = "Passsword too short")]
|
||||||
|
PasswordTooShort,
|
||||||
|
#[display(fmt = "Username too long")]
|
||||||
|
PasswordTooLong,
|
||||||
|
#[display(fmt = "Passwords don't match")]
|
||||||
|
PasswordsDontMatch,
|
||||||
|
|
||||||
|
/// when the a username is already taken
|
||||||
|
#[display(fmt = "Username not available")]
|
||||||
|
UsernameTaken,
|
||||||
|
|
||||||
|
/// email is already taken
|
||||||
|
#[display(fmt = "Email not available")]
|
||||||
|
EmailTaken,
|
||||||
|
// #[display(fmt = "{}", _0)]
|
||||||
|
// DBError(DBErrorWrapper),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
pub struct ErrorToResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
impl ResponseError for ServiceError {
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
fn error_response(&self) -> HttpResponse {
|
||||||
|
HttpResponseBuilder::new(self.status_code())
|
||||||
|
.append_header((
|
||||||
|
http::header::CONTENT_TYPE,
|
||||||
|
"application/json; charset=UTF-8",
|
||||||
|
))
|
||||||
|
.body(
|
||||||
|
serde_json::to_string(&ErrorToResponse {
|
||||||
|
error: self.to_string(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
fn status_code(&self) -> StatusCode {
|
||||||
|
match self {
|
||||||
|
ServiceError::ClosedForRegistration => StatusCode::FORBIDDEN,
|
||||||
|
ServiceError::InternalServerError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
ServiceError::NotAUrl => StatusCode::BAD_REQUEST,
|
||||||
|
ServiceError::NotAnEmail => StatusCode::BAD_REQUEST,
|
||||||
|
ServiceError::WrongPassword => StatusCode::UNAUTHORIZED,
|
||||||
|
ServiceError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||||
|
|
||||||
|
ServiceError::ProfainityError => StatusCode::BAD_REQUEST,
|
||||||
|
ServiceError::BlacklistError => StatusCode::BAD_REQUEST,
|
||||||
|
ServiceError::UsernameCaseMappedError => StatusCode::BAD_REQUEST,
|
||||||
|
|
||||||
|
ServiceError::PasswordTooShort => StatusCode::BAD_REQUEST,
|
||||||
|
ServiceError::PasswordTooLong => StatusCode::BAD_REQUEST,
|
||||||
|
ServiceError::PasswordsDontMatch => StatusCode::BAD_REQUEST,
|
||||||
|
|
||||||
|
ServiceError::UsernameTaken => StatusCode::BAD_REQUEST,
|
||||||
|
ServiceError::EmailTaken => StatusCode::BAD_REQUEST,
|
||||||
|
// ServiceError::DBError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CredsError> for ServiceError {
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
fn from(e: CredsError) -> ServiceError {
|
||||||
|
match e {
|
||||||
|
CredsError::UsernameCaseMappedError => ServiceError::UsernameCaseMappedError,
|
||||||
|
CredsError::ProfainityError => ServiceError::ProfainityError,
|
||||||
|
CredsError::BlacklistError => ServiceError::BlacklistError,
|
||||||
|
CredsError::NotAnEmail => ServiceError::NotAnEmail,
|
||||||
|
CredsError::Argon2Error(_) => ServiceError::InternalServerError,
|
||||||
|
CredsError::PasswordTooLong => ServiceError::PasswordTooLong,
|
||||||
|
CredsError::PasswordTooShort => ServiceError::PasswordTooShort,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//impl From<DBError> for ServiceError {
|
||||||
|
// #[cfg(not(tarpaulin_include))]
|
||||||
|
// fn from(e: DBError) -> ServiceError {
|
||||||
|
// println!("from conversin: {}", e);
|
||||||
|
// match e {
|
||||||
|
// DBError::UsernameTaken => ServiceError::UsernameTaken,
|
||||||
|
// DBError::SecretTaken => ServiceError::InternalServerError,
|
||||||
|
// DBError::EmailTaken => ServiceError::EmailTaken,
|
||||||
|
// DBError::AccountNotFound => ServiceError::AccountNotFound,
|
||||||
|
// _ => ServiceError::DBError(DBErrorWrapper(e)),
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
impl From<ParseError> for ServiceError {
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
fn from(_: ParseError) -> ServiceError {
|
||||||
|
ServiceError::NotAUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
impl From<RecvError> for ServiceError {
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
fn from(e: RecvError) -> Self {
|
||||||
|
log::error!("{:?}", e);
|
||||||
|
ServiceError::InternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
pub type ServiceResult<V> = std::result::Result<V, ServiceError>;
|
120
src/main.rs
Normal file
120
src/main.rs
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use actix_files::Files;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::web::JsonConfig;
|
||||||
|
use actix_web::{error::InternalError, middleware, App, HttpServer};
|
||||||
|
use log::info;
|
||||||
|
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod ctx;
|
||||||
|
//mod db;
|
||||||
|
//mod docs;
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
mod errors;
|
||||||
|
//#[macro_use]
|
||||||
|
//mod pages;
|
||||||
|
//#[macro_use]
|
||||||
|
mod routes;
|
||||||
|
mod settings;
|
||||||
|
//mod static_assets;
|
||||||
|
//#[cfg(test)]
|
||||||
|
//#[macro_use]
|
||||||
|
//mod tests;
|
||||||
|
//
|
||||||
|
pub use crate::ctx::Ctx;
|
||||||
|
//pub use crate::static_assets::static_files::assets::*;
|
||||||
|
pub use api::v1::API_V1_ROUTES;
|
||||||
|
//pub use docs::DOCS;
|
||||||
|
//pub use pages::routes::ROUTES as PAGES;
|
||||||
|
pub use settings::Settings;
|
||||||
|
//use static_assets::FileMap;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref SETTINGS: Settings= Settings::new().unwrap();
|
||||||
|
// pub static ref S: String = env::var("S").unwrap();
|
||||||
|
// pub static ref FILES: FileMap = FileMap::new();
|
||||||
|
// pub static ref JS: &'static str =
|
||||||
|
// FILES.get("./static/cache/bundle/bundle.js").unwrap();
|
||||||
|
// pub static ref CSS: &'static str =
|
||||||
|
// FILES.get("./static/cache/bundle/css/main.css").unwrap();
|
||||||
|
// pub static ref MOBILE_CSS: &'static str =
|
||||||
|
// FILES.get("./static/cache/bundle/css/mobile.css").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const COMPILED_DATE: &str = env!("COMPILED_DATE");
|
||||||
|
pub const GIT_COMMIT_HASH: &str = env!("GIT_HASH");
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
pub const PKG_NAME: &str = env!("CARGO_PKG_NAME");
|
||||||
|
pub const PKG_DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
|
||||||
|
pub const PKG_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE");
|
||||||
|
|
||||||
|
pub const CACHE_AGE: u32 = 604800;
|
||||||
|
|
||||||
|
use ctx::ArcCtx;
|
||||||
|
pub type AppCtx = actix_web::web::Data<ArcCtx>;
|
||||||
|
|
||||||
|
#[actix_web::main]
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
async fn main() -> std::io::Result<()> {
|
||||||
|
env::set_var("RUST_LOG", "info");
|
||||||
|
|
||||||
|
pretty_env_logger::init();
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"{}: {}.\nFor more information, see: {}\nBuild info:\nVersion: {} commit: {}",
|
||||||
|
PKG_NAME, PKG_DESCRIPTION, PKG_HOMEPAGE, VERSION, GIT_COMMIT_HASH
|
||||||
|
);
|
||||||
|
|
||||||
|
let settings = Settings::new().unwrap();
|
||||||
|
let ctx = Ctx::new(&settings).await;
|
||||||
|
let ctx = actix_web::web::Data::new(ctx);
|
||||||
|
|
||||||
|
let ip = settings.server.get_ip();
|
||||||
|
println!("Starting server on: http://{ip}");
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.wrap(middleware::Logger::default())
|
||||||
|
.wrap(
|
||||||
|
middleware::DefaultHeaders::new().add(("Permissions-Policy", "interest-cohort=()")),
|
||||||
|
)
|
||||||
|
.wrap(middleware::Compress::default())
|
||||||
|
.app_data(ctx.clone())
|
||||||
|
.wrap(middleware::NormalizePath::new(
|
||||||
|
middleware::TrailingSlash::Trim,
|
||||||
|
))
|
||||||
|
.app_data(get_json_err())
|
||||||
|
.configure(routes::services)
|
||||||
|
.service(Files::new("/", "./tmp").show_files_listing())
|
||||||
|
})
|
||||||
|
.bind(ip)?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(tarpaulin_include))]
|
||||||
|
pub fn get_json_err() -> JsonConfig {
|
||||||
|
JsonConfig::default().error_handler(|err, _| {
|
||||||
|
//debug!("JSON deserialization error: {:?}", &err);
|
||||||
|
InternalError::new(err, StatusCode::BAD_REQUEST).into()
|
||||||
|
})
|
||||||
|
}
|
21
src/routes.rs
Normal file
21
src/routes.rs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
use actix_web::web;
|
||||||
|
|
||||||
|
pub fn services(cfg: &mut web::ServiceConfig) {
|
||||||
|
crate::api::v1::services(cfg);
|
||||||
|
}
|
Loading…
Reference in a new issue