From 2c56c686370f84bb7bbe9266390c5b8134387542 Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 16:19:20 +1000 Subject: [PATCH 01/19] Improves API functionality --- .gitignore | 1 + Dockerfile | 2 +- README.md | 2 +- actix/Cargo.lock | 37 ++++++++++- actix/Cargo.toml | 3 + actix/src/auth.rs | 47 ++++++++++++++ actix/src/database.rs | 3 +- actix/src/main.rs | 12 ++++ actix/src/services.rs | 148 +++++++++++++++++++++++++++++++++--------- actix/src/utils.rs | 42 +++++++++++- compose.yaml | 12 ++-- 11 files changed, 265 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index 1dbf157..5afbeb2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ urls.sqlite **/.directory .env cookie* +.idea/ diff --git a/Dockerfile b/Dockerfile index e8ac570..16bc272 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN cargo chef cook --release --target=$target --recipe-path recipe.json COPY ./actix/Cargo.toml ./actix/Cargo.lock ./ COPY ./actix/src ./src # Build application -RUN cargo build --release --target=$target --locked --bin chhoto-url +RUN cargo build --release --target=$target --offline --bin chhoto-url RUN cp /chhoto-url/target/$target/release/chhoto-url /chhoto-url/release FROM scratch diff --git a/README.md b/README.md index a023051..0474cfd 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ below, replace `http://localhost:4567` with where your instance of `chhoto-url` If you have set up a password, first do the following to get an authentication cookie and store it in a file. ```bash -curl -X post -d "" -c cookie.txt http://localhost:4567/api/login +curl -X POST -d "" -c cookie.txt http://localhost:4567/api/login ``` You should receive "Correct password!" if the provided password was correct. For any subsequent request, please add `-b cookie.txt` to provide authentication. diff --git a/actix/Cargo.lock b/actix/Cargo.lock index 0217de1..69d364f 100644 --- a/actix/Cargo.lock +++ b/actix/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "actix-codec" @@ -19,6 +19,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "actix-cors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more 0.99.18", + "futures-util", + "log", + "once_cell", + "smallvec", +] + [[package]] name = "actix-files" version = "0.6.6" @@ -225,6 +240,21 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-web-httpauth" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456348ed9dcd72a13a1f4a660449fafdecee9ac8205552e286809eb5b0b29bd3" +dependencies = [ + "actix-utils", + "actix-web", + "base64 0.22.1", + "futures-core", + "futures-util", + "log", + "pin-project-lite", +] + [[package]] name = "addr2line" version = "0.24.2" @@ -476,11 +506,13 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chhoto-url" -version = "5.4.5" +version = "5.4.6" dependencies = [ + "actix-cors", "actix-files", "actix-session", "actix-web", + "actix-web-httpauth", "env_logger", "nanoid", "rand", @@ -736,6 +768,7 @@ dependencies = [ "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] diff --git a/actix/Cargo.toml b/actix/Cargo.toml index c3d9e7e..a762fa1 100644 --- a/actix/Cargo.toml +++ b/actix/Cargo.toml @@ -29,9 +29,12 @@ categories = ["web-programming"] [dependencies] actix-web = "4.5.1" actix-files = "0.6.5" +actix-cors = "0.7.0" +actix-web-httpauth = "0.8.2" rusqlite = { version = "0.32.0", features = ["bundled"] } regex = "1.10.3" rand = "0.8.5" +passwords = "3.1.16" actix-session = { version = "0.10.0", features = ["cookie-session"] } env_logger = "0.11.1" nanoid = "0.4.0" diff --git a/actix/src/auth.rs b/actix/src/auth.rs index 3ca4495..fd8d8b8 100644 --- a/actix/src/auth.rs +++ b/actix/src/auth.rs @@ -3,6 +3,53 @@ use actix_session::Session; use std::{env, time::SystemTime}; +use actix_web::{HttpRequest}; + +// API key generation and scoring +use passwords::{PasswordGenerator, scorer, analyzer}; + +// Validate API key +pub fn validate_key(key: String) -> bool { + if let Ok(api_key) = env::var("api_key") { + if api_key != key { + eprintln!("Incorrect API key was provided when connecting to Chhoto URL."); + false + } else { + eprintln!("Server accessed with API key."); + true + } + } else { + eprintln!("API was accessed with API key validation but no API key was specified. Set the 'api_key' environment variable."); + false + } +} + +// Generate an API key if the user doesn't specify a secure key +// Called in main.rs +pub fn gen_key() -> String { + let key = PasswordGenerator { + length: 128, + numbers: true, + lowercase_letters: true, + uppercase_letters: true, + symbols: false, + spaces: false, + exclude_similar_characters: false, + strict: true, + }; + key.generate_one().unwrap() +} + +// Check if the API key header exists +pub fn api_header(req: &HttpRequest) -> Option<&str> { + req.headers().get("Chhoto-Api-Key")?.to_str().ok() +} + +// Determine whether the inputted API key is sufficiently secure +pub fn is_key_secure() -> bool { + let score = scorer::score(&analyzer::analyze(env::var("api_key").unwrap())); + if score < 90.0 { false } else { true } +} // Validate a given password pub fn validate(session: Session) -> bool { diff --git a/actix/src/database.rs b/actix/src/database.rs index 87ddec9..95d4a6e 100644 --- a/actix/src/database.rs +++ b/actix/src/database.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Sayantan Santra // SPDX-License-Identifier: MIT -use rusqlite::Connection; +use rusqlite::{Connection}; use serde::Serialize; // Struct for encoding a DB row @@ -91,5 +91,6 @@ pub fn open_db(path: String) -> Connection { [], ) .expect("Unable to initialize empty database."); + db } diff --git a/actix/src/main.rs b/actix/src/main.rs index 70d069e..ef9a618 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -24,11 +24,13 @@ async fn main() -> Result<()> { // Generate session key in runtime so that restart invalidates older logins let secret_key = Key::generate(); + let db_location = env::var("db_url") .ok() .filter(|s| !s.trim().is_empty()) .unwrap_or(String::from("urls.sqlite")); + let port = env::var("port") .unwrap_or(String::from("4567")) .parse::() @@ -38,6 +40,16 @@ async fn main() -> Result<()> { .ok() .filter(|s| !s.trim().is_empty()); + // If an API key is set, check the security + if let Ok(key) = env::var("api_key") { + if !auth::is_key_secure() { + eprintln!("API key is insecure! Please change it. Current key is: {}. Generated secure key which you may use: {}", key, auth::gen_key()) + } + } + + // Tell the user that the server has started, and where it is listening to, rather than simply outputting nothing + eprintln!("Server has started at 0.0.0.0 on port {}. Configured Site URL is: {}", port, env::var("site_url").unwrap_or(String::from("http://localhost"))); + // Actually start the server HttpServer::new(move || { App::new() diff --git a/actix/src/services.rs b/actix/src/services.rs index b01516f..172612c 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -3,14 +3,11 @@ use actix_files::NamedFile; use actix_session::Session; -use actix_web::{ - delete, get, - http::StatusCode, - post, - web::{self, Redirect}, - Either, HttpResponse, Responder, -}; -use std::env; +use actix_web::{delete, get, http::StatusCode, post, web::{self, Redirect}, Either, HttpRequest, HttpResponse, Responder}; +use std::{env}; + +// Serialize JSON data +use serde::Serialize; use crate::auth; use crate::database; @@ -20,35 +17,68 @@ use crate::AppState; // Store the version number const VERSION: &str = env!("CARGO_PKG_VERSION"); +// Define JSON struct for returning JSON data +#[derive(Serialize)] +struct Response { + success: bool, + error: bool, + reason: String, +} + // Define the routes // Add new links #[post("/api/new")] -pub async fn add_link(req: String, data: web::Data, session: Session) -> HttpResponse { - if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { +pub async fn add_link(req: String, data: web::Data, session: Session, http: HttpRequest) -> HttpResponse { + // Call is_api_ok() function, pass HttpRequest + let result = utils::is_api_ok(http); + // If success, add new link + if result.success { let out = utils::add_link(req, &data.db); if out.0 { HttpResponse::Created().body(out.1) } else { HttpResponse::Conflict().body(out.1) } + } else if result.error { + HttpResponse::Unauthorized().json(result) + // If "pass" is true - keeps backwards compatibility } else { - HttpResponse::Unauthorized().body("Not logged in!") + if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { + let out = utils::add_link(req, &data.db); + if out.0 { + HttpResponse::Created().body(out.1) + } else { + HttpResponse::Conflict().body(out.1) + } + } else { + HttpResponse::Unauthorized().body("Not logged in!") + } } } // Return all active links #[get("/api/all")] -pub async fn getall(data: web::Data, session: Session) -> HttpResponse { - if auth::validate(session) { +pub async fn getall(data: web::Data, session: Session, http: HttpRequest) -> HttpResponse { + // Call is_api_ok() function, pass HttpRequest + let result = utils::is_api_ok(http); + // If success, return all links + if result.success { HttpResponse::Ok().body(utils::getall(&data.db)) + } else if result.error { + HttpResponse::Unauthorized().json(result) + // If "pass" is true - keeps backwards compatibility } else { - let body = if env::var("public_mode") == Ok(String::from("Enable")) { - "Using public mode." + if auth::validate(session){ + HttpResponse::Ok().body(utils::getall(&data.db)) } else { - "Not logged in!" - }; - HttpResponse::Unauthorized().body(body) + let body = if env::var("public_mode") == Ok(String::from("Enable")) { + "Using public mode." + } else { + "Not logged in!" + }; + HttpResponse::Unauthorized().body(body) + } } } @@ -105,20 +135,51 @@ pub async fn link_handler( // Handle login #[post("/api/login")] pub async fn login(req: String, session: Session) -> HttpResponse { - if let Ok(password) = env::var("password") { - if password != req { - eprintln!("Failed login attempt!"); - return HttpResponse::Unauthorized().body("Wrong password!"); + // Someone's API may be listening for the plain HTML body response of "Correct password!" + // rather than a 200 OK HTTP response. Because of that, a check is performed to see whether + // the api_key environment variable is set. If it is set, then it is assumed the user will expect a JSON response for all API routes. + // *If this is not a concern, this can be removed.* + if let Ok(_) = env::var("api_key") { + if let Ok(password) = env::var("password") { + if password != req { + eprintln!("Failed login attempt!"); + let response = Response { + success: false, + error: true, + reason: "Wrong password!".to_string() + }; + return HttpResponse::Unauthorized().json(response); + } } + // Return Ok if no password was set on the server side + session + .insert("chhoto-url-auth", auth::gen_token()) + .expect("Error inserting auth token."); + + let response = Response { + success: true, + error: false, + reason: "Correct password!".to_string() + }; + HttpResponse::Ok().json(response) + } else { + if let Ok(password) = env::var("password") { + if password != req { + eprintln!("Failed login attempt!"); + return HttpResponse::Unauthorized().body("Wrong password!"); + } + } + // Return Ok if no password was set on the server side + session + .insert("chhoto-url-auth", auth::gen_token()) + .expect("Error inserting auth token."); + + HttpResponse::Ok().body("Correct password!") } - // Return Ok if no password was set on the server side - session - .insert("chhoto-url-auth", auth::gen_token()) - .expect("Error inserting auth token."); - HttpResponse::Ok().body("Correct password!") } // Handle logout +// There's no reason to be calling this route with an API key, so it is not necessary to check if the api_key env variable is set. #[delete("/api/logout")] pub async fn logout(session: Session) -> HttpResponse { if session.remove("chhoto-url-auth").is_some() { @@ -134,14 +195,39 @@ pub async fn delete_link( shortlink: web::Path, data: web::Data, session: Session, + http: HttpRequest, ) -> HttpResponse { - if auth::validate(session) { + // Call is_api_ok() function, pass HttpRequest + let result = utils::is_api_ok(http); + // If success, delete shortlink + if result.success { if utils::delete_link(shortlink.to_string(), &data.db) { - HttpResponse::Ok().body(format!("Deleted {shortlink}")) + let response = Response { + success: true, + error: false, + reason: format!("Deleted {}", shortlink) + }; + HttpResponse::Ok().json(response) } else { - HttpResponse::NotFound().body("Not found!") + let response = Response { + success: false, + error: true, + reason: "The short link was not found, and could not be deleted.".to_string() + }; + HttpResponse::NotFound().json(response) } + } else if result.error { + HttpResponse::Unauthorized().json(result) + // If "pass" is true - keeps backwards compatibility } else { - HttpResponse::Unauthorized().body("Not logged in!") + if auth::validate(session) { + if utils::delete_link(shortlink.to_string(), &data.db) { + HttpResponse::Ok().body(format!("Deleted {shortlink}")) + } else { + HttpResponse::NotFound().body("Not found!") + } + } else { + HttpResponse::Unauthorized().body("Not logged in!") + } } } diff --git a/actix/src/utils.rs b/actix/src/utils.rs index a72f36e..bd05325 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -5,10 +5,10 @@ use nanoid::nanoid; use rand::seq::SliceRandom; use regex::Regex; use rusqlite::Connection; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::env; - -use crate::database; +use actix_web::HttpRequest; +use crate::{auth, database}; // Struct for reading link pairs sent during API call #[derive(Deserialize)] @@ -17,6 +17,42 @@ struct URLPair { longlink: String, } +// Define JSON struct for response +#[derive(Serialize)] +pub struct Response { + pub(crate) success: bool, + pub(crate) error: bool, + reason: String, + pass: bool, +} + +// If the api_key environment variable eists +pub fn is_api_ok(http: HttpRequest) -> Response { + // If the api_key environment variable exists + if let Ok(_) = env::var("api_key") { + // If the header exists + if let Some(header) = auth::api_header(&http) { + // If the header is correct + if auth::validate_key(header.to_string()) { + let result = Response { success: true, error: false, reason: "".to_string(), pass: false }; + result + } else { + let result = Response { success: false, error: true, reason: "Incorrect API key".to_string(), pass: false }; + result + } + // The header may not exist when the user logs in through the web interface, so allow a request with no header. + // Further authentication checks will be conducted in services.rs + } else { + let result = Response { success: false, error: false, reason: "Chhoto-Api-Key header not found".to_string(), pass: true }; + result + } + } else { + let result = Response {success: false, error: false, reason: "".to_string(), pass: true}; + result + } +} + + // Request the DB for searching an URL pub fn get_longurl(shortlink: String, db: &Connection) -> Option { if validate_link(&shortlink) { diff --git a/compose.yaml b/compose.yaml index 75443e4..256b19d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,7 @@ services: chhoto-url: - image: sintan1729/chhoto-url:latest + image: chhoto-url restart: unless-stopped container_name: chhoto-url ports: @@ -24,7 +24,9 @@ services: # - site_url=https://www.example.com - password=TopSecretPass - + + - api_key=test + # Pass the redirect method, if needed. TEMPORARY and PERMANENT # are accepted values, defaults to PERMANENT. # - redirect_method=TEMPORARY @@ -33,12 +35,12 @@ services: # If you want UIDs, please change slug_style to UID. # Supported values for slug_style are Pair and UID. # The length is 8 by default, and a minimum of 4 is allowed. - # - slug_style=Pair - # - slug_length=8 + - slug_style=Pair + - slug_length=8 # In case you want to provide public access to adding links (and not # delete, or listing), change the following option to Enable. - # - public_mode=Disable + - public_mode=Disable # By default, the server sends no Cache-Control headers. You can supply a # comma separated list of valid header as per RFC 7234 §5.2 to send those From 5c2886f65101f888ec1a98cd3129b430f8c2d2d8 Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 19:11:47 +1000 Subject: [PATCH 02/19] Changes the API to use JSON data and results --- actix/Cargo.lock | 47 +++++++++++++++++++++++++++++++++++++++++++ actix/src/services.rs | 27 +++++++++++++++++++++++-- compose.yaml | 7 +++++-- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/actix/Cargo.lock b/actix/Cargo.lock index 69d364f..a7d72cb 100644 --- a/actix/Cargo.lock +++ b/actix/Cargo.lock @@ -515,6 +515,7 @@ dependencies = [ "actix-web-httpauth", "env_logger", "nanoid", + "passwords", "rand", "regex", "rusqlite", @@ -1260,6 +1261,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "passwords" +version = "3.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11407193a7c2bd14ec6b0ec3394da6fdcf7a4d5dcbc8c3cc38dfb17802c8d59c" +dependencies = [ + "random-pick", +] + [[package]] name = "paste" version = "1.0.15" @@ -1317,6 +1327,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.89" @@ -1365,6 +1381,37 @@ dependencies = [ "getrandom", ] +[[package]] +name = "random-number" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fc8cdd49be664772ffc3dbfa743bb8c34b78f9cc6a9f50e56ae878546796067" +dependencies = [ + "proc-macro-hack", + "rand", + "random-number-macro-impl", +] + +[[package]] +name = "random-number-macro-impl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5135143cb48d14289139e4615bffec0d59b4cbfd4ea2398a3770bd2abfc4aa2" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", +] + +[[package]] +name = "random-pick" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c179499072da789afe44127d5f4aa6012de2c2f96ef759990196b37387a2a0f8" +dependencies = [ + "random-number", +] + [[package]] name = "redox_syscall" version = "0.5.7" diff --git a/actix/src/services.rs b/actix/src/services.rs index 172612c..cbfbf26 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -25,6 +25,14 @@ struct Response { reason: String, } +// Needs to return the short URL to make it easier for programs leveraging the API +#[derive(Serialize)] +struct CreatedURL { + success: bool, + error: bool, + shorturl: String, +} + // Define the routes // Add new links @@ -36,9 +44,24 @@ pub async fn add_link(req: String, data: web::Data, session: Session, if result.success { let out = utils::add_link(req, &data.db); if out.0 { - HttpResponse::Created().body(out.1) + let port = env::var("port") + .unwrap_or(String::from("4567")) + .parse::() + .expect("Supplied port is not an integer"); + let url = format!("{}:{}", env::var("site_url").unwrap_or(String::from("http://localhost")), port); + let response = CreatedURL { + success: true, + error: false, + shorturl: format!("{}/{}", url, out.1) + }; + HttpResponse::Created().json(response) } else { - HttpResponse::Conflict().body(out.1) + let response = Response { + success: false, + error: true, + reason: out.1 + }; + HttpResponse::Conflict().json(response) } } else if result.error { HttpResponse::Unauthorized().json(result) diff --git a/compose.yaml b/compose.yaml index 256b19d..8e60c6f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,8 +24,11 @@ services: # - site_url=https://www.example.com - password=TopSecretPass - - - api_key=test + + # This needs to be set in order to use programs that use the JSON interface of Chhoto URL. + # You will get a warning if this is insecure, and a generated value will be outputted + # You may use that value if you can't think of a secure key + # - api_key=SECURE_API_KEY # Pass the redirect method, if needed. TEMPORARY and PERMANENT # are accepted values, defaults to PERMANENT. From 1ef5d539d58ad2e9251624469c898d64431ce6bb Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 19:54:22 +1000 Subject: [PATCH 03/19] Improve API error handling --- actix/src/utils.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/actix/src/utils.rs b/actix/src/utils.rs index bd05325..506e5cf 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -18,6 +18,7 @@ struct URLPair { } // Define JSON struct for response +// Named "ReturnResponse" rather than "Response" because of the previous import. #[derive(Serialize)] pub struct Response { pub(crate) success: bool, @@ -47,8 +48,14 @@ pub fn is_api_ok(http: HttpRequest) -> Response { result } } else { - let result = Response {success: false, error: false, reason: "".to_string(), pass: true}; - result + // If the API key isn't set, but an API Key header is provided + if let Some(_) = auth::api_header(&http) { + let result = Response {success: false, error: true, reason: "API key access was attempted, but no API key is configured".to_string(), pass: false}; + result + } else { + let result = Response {success: false, error: false, reason: "".to_string(), pass: true}; + result + } } } From aab7a9b3d12d1f3c4d51139b0b5e56241f7d1cee Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 20:13:37 +1000 Subject: [PATCH 04/19] Change README.md and remove unneeded dependencies --- README.md | 36 ++++++++++++++++++++++++++++++++++++ actix/Cargo.toml | 2 -- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0474cfd..77a68c9 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,17 @@ docker run -p 4567:4567 \ -e site_url="https://www.example.com" \ -d chhoto-url:latest ``` +1.c Optionally, set an API key to activate JSON result mode (optional) + +``` +docker run -p 4567:4567 \ + -e password="password" \ + -e api_key="SECURE_API_KEY" \ + -v ./urls.sqlite:/urls.sqlite \ + -e db_url=/urls.sqlite \ + -e site_url="https://www.example.com" \ + -d chhoto-url:latest +``` You can set the redirect method to Permanent 308 (default) or Temporary 307 by setting the `redirect_method` variable to `TEMPORARY` or `PERMANENT` (it's matched exactly). By @@ -148,6 +159,7 @@ served through a proxy. The application can be used from the terminal using something like `curl`. In all the examples below, replace `http://localhost:4567` with where your instance of `chhoto-url` is accessible. +### Cookie validation If you have set up a password, first do the following to get an authentication cookie and store it in a file. ```bash @@ -173,6 +185,30 @@ curl -X DELETE http://localhost:4567/api/del/ ``` The server will send a confirmation. +### API key validation +**This is required for programs that rely on a JSON response from Chhoto URL** +In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie validation (see section above). +If the API key is insecure, a warning will be outputted. Aditionally, in this situation, a generated API key will be outputted which may be used. + +To add a link: +``` bash +curl -X POST -H "Chhoto-Api-Key: " -d '{"shortlink":"", "longlink":""}' http://localhost:4567/api/new +``` + +To get a list of all the currently available links: +``` bash +curl -H "Chhoto-Api-Key: " http://localhost:4567/api/all +``` + +To delete a link: +``` bash +curl -X DELETE -H "Chhoto-Api-Key: http://localhost:4567/api/del/" +``` +Where `` is name of the shortened link you would like to delete. For example, if the shortened link is `http://localhost:4567/example`, `` would be `example`. + +The server will output when the instance is accessed over API, when an incorrect API key is received, etc. + + You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and get the siteurl using `curl http://localhost:4567/api/siteurl`. diff --git a/actix/Cargo.toml b/actix/Cargo.toml index a762fa1..7d09eeb 100644 --- a/actix/Cargo.toml +++ b/actix/Cargo.toml @@ -29,8 +29,6 @@ categories = ["web-programming"] [dependencies] actix-web = "4.5.1" actix-files = "0.6.5" -actix-cors = "0.7.0" -actix-web-httpauth = "0.8.2" rusqlite = { version = "0.32.0", features = ["bundled"] } regex = "1.10.3" rand = "0.8.5" From a1f87006640e7f2a3488ec832cfe52c099151f7a Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 20:15:06 +1000 Subject: [PATCH 05/19] Change README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 77a68c9..67404cf 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ The server will send a confirmation. ### API key validation **This is required for programs that rely on a JSON response from Chhoto URL** + In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie validation (see section above). If the API key is insecure, a warning will be outputted. Aditionally, in this situation, a generated API key will be outputted which may be used. @@ -209,6 +210,8 @@ Where `` is name of the shortened link you would like to delete. For The server will output when the instance is accessed over API, when an incorrect API key is received, etc. +In both modes, these routes are accessible: + You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and get the siteurl using `curl http://localhost:4567/api/siteurl`. From 9ddf043c17de65e40d060c94068838382db4d0a8 Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 20:17:13 +1000 Subject: [PATCH 06/19] Fix typos, etc --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 67404cf..a31b59f 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ The server will send a confirmation. **This is required for programs that rely on a JSON response from Chhoto URL** In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie validation (see section above). -If the API key is insecure, a warning will be outputted. Aditionally, in this situation, a generated API key will be outputted which may be used. +If the API key is insecure, a warning will be outputted along with a generated API key which may be used. To add a link: ``` bash @@ -203,7 +203,7 @@ curl -H "Chhoto-Api-Key: " http://localhost:4567/api/all To delete a link: ``` bash -curl -X DELETE -H "Chhoto-Api-Key: http://localhost:4567/api/del/" +curl -X DELETE -H "Chhoto-Api-Key: " http://localhost:4567/api/del/ ``` Where `` is name of the shortened link you would like to delete. For example, if the shortened link is `http://localhost:4567/example`, `` would be `example`. From 6347a897250933207896beda1249abf26103d321 Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 20:30:55 +1000 Subject: [PATCH 07/19] Minor code clean up --- actix/Cargo.lock | 33 --------------------------------- actix/src/database.rs | 2 +- actix/src/services.rs | 2 +- 3 files changed, 2 insertions(+), 35 deletions(-) diff --git a/actix/Cargo.lock b/actix/Cargo.lock index a7d72cb..29c7760 100644 --- a/actix/Cargo.lock +++ b/actix/Cargo.lock @@ -19,21 +19,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "actix-cors" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331" -dependencies = [ - "actix-utils", - "actix-web", - "derive_more 0.99.18", - "futures-util", - "log", - "once_cell", - "smallvec", -] - [[package]] name = "actix-files" version = "0.6.6" @@ -240,21 +225,6 @@ dependencies = [ "syn", ] -[[package]] -name = "actix-web-httpauth" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456348ed9dcd72a13a1f4a660449fafdecee9ac8205552e286809eb5b0b29bd3" -dependencies = [ - "actix-utils", - "actix-web", - "base64 0.22.1", - "futures-core", - "futures-util", - "log", - "pin-project-lite", -] - [[package]] name = "addr2line" version = "0.24.2" @@ -508,11 +478,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" name = "chhoto-url" version = "5.4.6" dependencies = [ - "actix-cors", "actix-files", "actix-session", "actix-web", - "actix-web-httpauth", "env_logger", "nanoid", "passwords", @@ -769,7 +737,6 @@ dependencies = [ "futures-task", "pin-project-lite", "pin-utils", - "slab", ] [[package]] diff --git a/actix/src/database.rs b/actix/src/database.rs index 95d4a6e..ab0277a 100644 --- a/actix/src/database.rs +++ b/actix/src/database.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2023 Sayantan Santra // SPDX-License-Identifier: MIT -use rusqlite::{Connection}; +use rusqlite::Connection; use serde::Serialize; // Struct for encoding a DB row diff --git a/actix/src/services.rs b/actix/src/services.rs index cbfbf26..fe66432 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -4,7 +4,7 @@ use actix_files::NamedFile; use actix_session::Session; use actix_web::{delete, get, http::StatusCode, post, web::{self, Redirect}, Either, HttpRequest, HttpResponse, Responder}; -use std::{env}; +use std::env; // Serialize JSON data use serde::Serialize; From 247cfb0476f6919e35e10d49523974c4bdd9702c Mon Sep 17 00:00:00 2001 From: Solninja A Date: Tue, 31 Dec 2024 20:32:46 +1000 Subject: [PATCH 08/19] Fixed compose.yaml --- compose.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index 8e60c6f..78a51d6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -3,7 +3,7 @@ services: chhoto-url: - image: chhoto-url + image: sintan1729/chhoto-url:latest restart: unless-stopped container_name: chhoto-url ports: @@ -38,12 +38,12 @@ services: # If you want UIDs, please change slug_style to UID. # Supported values for slug_style are Pair and UID. # The length is 8 by default, and a minimum of 4 is allowed. - - slug_style=Pair - - slug_length=8 + # - slug_style=Pair + # - slug_length=8 # In case you want to provide public access to adding links (and not # delete, or listing), change the following option to Enable. - - public_mode=Disable + # - public_mode=Disable # By default, the server sends no Cache-Control headers. You can supply a # comma separated list of valid header as per RFC 7234 §5.2 to send those From 818dadb84fbd720aef1f2a89eb57fd7fa29b4ddb Mon Sep 17 00:00:00 2001 From: Solninja A Date: Wed, 1 Jan 2025 17:34:09 +1000 Subject: [PATCH 09/19] Made code more Rust-like --- actix/src/auth.rs | 4 ++-- actix/src/main.rs | 1 - actix/src/services.rs | 7 ++----- actix/src/utils.rs | 20 +++++++------------- 4 files changed, 11 insertions(+), 21 deletions(-) diff --git a/actix/src/auth.rs b/actix/src/auth.rs index fd8d8b8..c6ab4bd 100644 --- a/actix/src/auth.rs +++ b/actix/src/auth.rs @@ -3,7 +3,7 @@ use actix_session::Session; use std::{env, time::SystemTime}; -use actix_web::{HttpRequest}; +use actix_web::HttpRequest; // API key generation and scoring use passwords::{PasswordGenerator, scorer, analyzer}; @@ -48,7 +48,7 @@ pub fn api_header(req: &HttpRequest) -> Option<&str> { // Determine whether the inputted API key is sufficiently secure pub fn is_key_secure() -> bool { let score = scorer::score(&analyzer::analyze(env::var("api_key").unwrap())); - if score < 90.0 { false } else { true } + score >= 90.0 } // Validate a given password diff --git a/actix/src/main.rs b/actix/src/main.rs index ef9a618..79dcfa7 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -30,7 +30,6 @@ async fn main() -> Result<()> { .filter(|s| !s.trim().is_empty()) .unwrap_or(String::from("urls.sqlite")); - let port = env::var("port") .unwrap_or(String::from("4567")) .parse::() diff --git a/actix/src/services.rs b/actix/src/services.rs index fe66432..4b4a573 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -158,11 +158,8 @@ pub async fn link_handler( // Handle login #[post("/api/login")] pub async fn login(req: String, session: Session) -> HttpResponse { - // Someone's API may be listening for the plain HTML body response of "Correct password!" - // rather than a 200 OK HTTP response. Because of that, a check is performed to see whether - // the api_key environment variable is set. If it is set, then it is assumed the user will expect a JSON response for all API routes. - // *If this is not a concern, this can be removed.* - if let Ok(_) = env::var("api_key") { + // Keep this function backwards compatible + if env::var("api_key").is_ok() { if let Ok(password) = env::var("password") { if password != req { eprintln!("Failed login attempt!"); diff --git a/actix/src/utils.rs b/actix/src/utils.rs index 506e5cf..c596603 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -18,7 +18,6 @@ struct URLPair { } // Define JSON struct for response -// Named "ReturnResponse" rather than "Response" because of the previous import. #[derive(Serialize)] pub struct Response { pub(crate) success: bool, @@ -30,31 +29,26 @@ pub struct Response { // If the api_key environment variable eists pub fn is_api_ok(http: HttpRequest) -> Response { // If the api_key environment variable exists - if let Ok(_) = env::var("api_key") { + if env::var("api_key").is_ok() { // If the header exists if let Some(header) = auth::api_header(&http) { // If the header is correct if auth::validate_key(header.to_string()) { - let result = Response { success: true, error: false, reason: "".to_string(), pass: false }; - result + Response { success: true, error: false, reason: "".to_string(), pass: false } } else { - let result = Response { success: false, error: true, reason: "Incorrect API key".to_string(), pass: false }; - result + Response { success: false, error: true, reason: "Incorrect API key".to_string(), pass: false } } // The header may not exist when the user logs in through the web interface, so allow a request with no header. // Further authentication checks will be conducted in services.rs } else { - let result = Response { success: false, error: false, reason: "Chhoto-Api-Key header not found".to_string(), pass: true }; - result + Response { success: false, error: false, reason: "Chhoto-Api-Key header not found".to_string(), pass: true } } } else { // If the API key isn't set, but an API Key header is provided - if let Some(_) = auth::api_header(&http) { - let result = Response {success: false, error: true, reason: "API key access was attempted, but no API key is configured".to_string(), pass: false}; - result + if auth::api_header(&http).is_some() { + Response {success: false, error: true, reason: "API key access was attempted, but no API key is configured".to_string(), pass: false} } else { - let result = Response {success: false, error: false, reason: "".to_string(), pass: true}; - result + Response {success: false, error: false, reason: "".to_string(), pass: true} } } } From 9a0cdec646e8976fc4eed1e04fbd980616ad4234 Mon Sep 17 00:00:00 2001 From: Solninja A Date: Wed, 1 Jan 2025 19:08:35 +1000 Subject: [PATCH 10/19] Improved API error codes --- actix/src/utils.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actix/src/utils.rs b/actix/src/utils.rs index c596603..6c90f9a 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -34,7 +34,7 @@ pub fn is_api_ok(http: HttpRequest) -> Response { if let Some(header) = auth::api_header(&http) { // If the header is correct if auth::validate_key(header.to_string()) { - Response { success: true, error: false, reason: "".to_string(), pass: false } + Response { success: true, error: false, reason: "Correct API key".to_string(), pass: false } } else { Response { success: false, error: true, reason: "Incorrect API key".to_string(), pass: false } } @@ -46,7 +46,7 @@ pub fn is_api_ok(http: HttpRequest) -> Response { } else { // If the API key isn't set, but an API Key header is provided if auth::api_header(&http).is_some() { - Response {success: false, error: true, reason: "API key access was attempted, but no API key is configured".to_string(), pass: false} + Response {success: false, error: true, reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(), pass: false} } else { Response {success: false, error: false, reason: "".to_string(), pass: true} } From 4fb8d0cb5c761e05b31133f2acf11891361f2dcb Mon Sep 17 00:00:00 2001 From: Solninja A Date: Fri, 3 Jan 2025 00:25:55 +1000 Subject: [PATCH 11/19] Edited the API Key header to comply with OpenAPI v3 specs --- README.md | 6 +++--- actix/src/auth.rs | 2 +- actix/src/utils.rs | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a31b59f..573a971 100644 --- a/README.md +++ b/README.md @@ -193,17 +193,17 @@ If the API key is insecure, a warning will be outputted along with a generated A To add a link: ``` bash -curl -X POST -H "Chhoto-Api-Key: " -d '{"shortlink":"", "longlink":""}' http://localhost:4567/api/new +curl -X POST -H "X-API-Key: " -d '{"shortlink":"", "longlink":""}' http://localhost:4567/api/new ``` To get a list of all the currently available links: ``` bash -curl -H "Chhoto-Api-Key: " http://localhost:4567/api/all +curl -H "X-API-Key: " http://localhost:4567/api/all ``` To delete a link: ``` bash -curl -X DELETE -H "Chhoto-Api-Key: " http://localhost:4567/api/del/ +curl -X DELETE -H "X-API-Key: " http://localhost:4567/api/del/ ``` Where `` is name of the shortened link you would like to delete. For example, if the shortened link is `http://localhost:4567/example`, `` would be `example`. diff --git a/actix/src/auth.rs b/actix/src/auth.rs index c6ab4bd..0e91da6 100644 --- a/actix/src/auth.rs +++ b/actix/src/auth.rs @@ -42,7 +42,7 @@ pub fn gen_key() -> String { // Check if the API key header exists pub fn api_header(req: &HttpRequest) -> Option<&str> { - req.headers().get("Chhoto-Api-Key")?.to_str().ok() + req.headers().get("X-API-Key")?.to_str().ok() } // Determine whether the inputted API key is sufficiently secure diff --git a/actix/src/utils.rs b/actix/src/utils.rs index 6c90f9a..7a0bcc4 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -29,7 +29,7 @@ pub struct Response { // If the api_key environment variable eists pub fn is_api_ok(http: HttpRequest) -> Response { // If the api_key environment variable exists - if env::var("api_key").is_ok() { + if let Ok(_) = env::var("api_key") { // If the header exists if let Some(header) = auth::api_header(&http) { // If the header is correct @@ -41,11 +41,12 @@ pub fn is_api_ok(http: HttpRequest) -> Response { // The header may not exist when the user logs in through the web interface, so allow a request with no header. // Further authentication checks will be conducted in services.rs } else { - Response { success: false, error: false, reason: "Chhoto-Api-Key header not found".to_string(), pass: true } + // Due to the implementation of this result in services.rs, this JSON object will not be outputted. + Response { success: false, error: false, reason: "X-API-Key header was not found".to_string(), pass: true } } } else { // If the API key isn't set, but an API Key header is provided - if auth::api_header(&http).is_some() { + if let Some(_) = auth::api_header(&http) { Response {success: false, error: true, reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(), pass: false} } else { Response {success: false, error: false, reason: "".to_string(), pass: true} From eed3c2292a1b5c9666bfacec6a158d0f78c070a6 Mon Sep 17 00:00:00 2001 From: Solninja A Date: Fri, 3 Jan 2025 00:28:51 +1000 Subject: [PATCH 12/19] Cleaned up code --- actix/src/utils.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actix/src/utils.rs b/actix/src/utils.rs index 7a0bcc4..9641577 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -29,7 +29,7 @@ pub struct Response { // If the api_key environment variable eists pub fn is_api_ok(http: HttpRequest) -> Response { // If the api_key environment variable exists - if let Ok(_) = env::var("api_key") { + if env::var("api_key").is_ok() { // If the header exists if let Some(header) = auth::api_header(&http) { // If the header is correct @@ -46,7 +46,7 @@ pub fn is_api_ok(http: HttpRequest) -> Response { } } else { // If the API key isn't set, but an API Key header is provided - if let Some(_) = auth::api_header(&http) { + if auth::api_header(&http).is_some() { Response {success: false, error: true, reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(), pass: false} } else { Response {success: false, error: false, reason: "".to_string(), pass: true} From f1c16429760c9577f49a0f95a088c44977db710f Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Sun, 5 Jan 2025 16:20:38 +0530 Subject: [PATCH 13/19] chg: Small semantic changes --- Dockerfile | 2 +- actix/src/auth.rs | 4 +- actix/src/main.rs | 6 ++- actix/src/services.rs | 95 ++++++++++++++++++++++++------------------- actix/src/utils.rs | 35 ++++++++++++---- compose.yaml | 2 +- 6 files changed, 90 insertions(+), 54 deletions(-) diff --git a/Dockerfile b/Dockerfile index 16bc272..e8ac570 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ RUN cargo chef cook --release --target=$target --recipe-path recipe.json COPY ./actix/Cargo.toml ./actix/Cargo.lock ./ COPY ./actix/src ./src # Build application -RUN cargo build --release --target=$target --offline --bin chhoto-url +RUN cargo build --release --target=$target --locked --bin chhoto-url RUN cp /chhoto-url/target/$target/release/chhoto-url /chhoto-url/release FROM scratch diff --git a/actix/src/auth.rs b/actix/src/auth.rs index 0e91da6..611d93d 100644 --- a/actix/src/auth.rs +++ b/actix/src/auth.rs @@ -2,11 +2,11 @@ // SPDX-License-Identifier: MIT use actix_session::Session; -use std::{env, time::SystemTime}; use actix_web::HttpRequest; +use std::{env, time::SystemTime}; // API key generation and scoring -use passwords::{PasswordGenerator, scorer, analyzer}; +use passwords::{analyzer, scorer, PasswordGenerator}; // Validate API key pub fn validate_key(key: String) -> bool { diff --git a/actix/src/main.rs b/actix/src/main.rs index 79dcfa7..da27c68 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -47,7 +47,11 @@ async fn main() -> Result<()> { } // Tell the user that the server has started, and where it is listening to, rather than simply outputting nothing - eprintln!("Server has started at 0.0.0.0 on port {}. Configured Site URL is: {}", port, env::var("site_url").unwrap_or(String::from("http://localhost"))); + eprintln!( + "Server has started at 0.0.0.0 on port {}. Configured Site URL is: {}", + port, + env::var("site_url").unwrap_or(String::from("http://localhost")) + ); // Actually start the server HttpServer::new(move || { diff --git a/actix/src/services.rs b/actix/src/services.rs index 4b4a573..4a10d4d 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -3,7 +3,13 @@ use actix_files::NamedFile; use actix_session::Session; -use actix_web::{delete, get, http::StatusCode, post, web::{self, Redirect}, Either, HttpRequest, HttpResponse, Responder}; +use actix_web::{ + delete, get, + http::StatusCode, + post, + web::{self, Redirect}, + Either, HttpRequest, HttpResponse, Responder, +}; use std::env; // Serialize JSON data @@ -20,9 +26,9 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); // Define JSON struct for returning JSON data #[derive(Serialize)] struct Response { - success: bool, - error: bool, - reason: String, + success: bool, + error: bool, + reason: String, } // Needs to return the short URL to make it easier for programs leveraging the API @@ -37,7 +43,12 @@ struct CreatedURL { // Add new links #[post("/api/new")] -pub async fn add_link(req: String, data: web::Data, session: Session, http: HttpRequest) -> HttpResponse { +pub async fn add_link( + req: String, + data: web::Data, + session: Session, + http: HttpRequest, +) -> HttpResponse { // Call is_api_ok() function, pass HttpRequest let result = utils::is_api_ok(http); // If success, add new link @@ -48,41 +59,47 @@ pub async fn add_link(req: String, data: web::Data, session: Session, .unwrap_or(String::from("4567")) .parse::() .expect("Supplied port is not an integer"); - let url = format!("{}:{}", env::var("site_url").unwrap_or(String::from("http://localhost")), port); + let url = format!( + "{}:{}", + env::var("site_url").unwrap_or(String::from("http://localhost")), + port + ); let response = CreatedURL { success: true, error: false, - shorturl: format!("{}/{}", url, out.1) + shorturl: format!("{}/{}", url, out.1), }; HttpResponse::Created().json(response) } else { let response = Response { success: false, error: true, - reason: out.1 + reason: out.1, }; HttpResponse::Conflict().json(response) } } else if result.error { HttpResponse::Unauthorized().json(result) - // If "pass" is true - keeps backwards compatibility - } else { - if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { - let out = utils::add_link(req, &data.db); - if out.0 { - HttpResponse::Created().body(out.1) - } else { - HttpResponse::Conflict().body(out.1) - } + // If password authentication or public mode is used - keeps backwards compatibility + } else if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { + let out = utils::add_link(req, &data.db); + if out.0 { + HttpResponse::Created().body(out.1) } else { - HttpResponse::Unauthorized().body("Not logged in!") + HttpResponse::Conflict().body(out.1) } + } else { + HttpResponse::Unauthorized().body("Not logged in!") } } // Return all active links #[get("/api/all")] -pub async fn getall(data: web::Data, session: Session, http: HttpRequest) -> HttpResponse { +pub async fn getall( + data: web::Data, + session: Session, + http: HttpRequest, +) -> HttpResponse { // Call is_api_ok() function, pass HttpRequest let result = utils::is_api_ok(http); // If success, return all links @@ -90,18 +107,16 @@ pub async fn getall(data: web::Data, session: Session, http: HttpReque HttpResponse::Ok().body(utils::getall(&data.db)) } else if result.error { HttpResponse::Unauthorized().json(result) - // If "pass" is true - keeps backwards compatibility + // If password authentication is used - keeps backwards compatibility + } else if auth::validate(session) { + HttpResponse::Ok().body(utils::getall(&data.db)) } else { - if auth::validate(session){ - HttpResponse::Ok().body(utils::getall(&data.db)) + let body = if env::var("public_mode") == Ok(String::from("Enable")) { + "Using public mode." } else { - let body = if env::var("public_mode") == Ok(String::from("Enable")) { - "Using public mode." - } else { - "Not logged in!" - }; - HttpResponse::Unauthorized().body(body) - } + "Not logged in!" + }; + HttpResponse::Unauthorized().body(body) } } @@ -166,7 +181,7 @@ pub async fn login(req: String, session: Session) -> HttpResponse { let response = Response { success: false, error: true, - reason: "Wrong password!".to_string() + reason: "Wrong password!".to_string(), }; return HttpResponse::Unauthorized().json(response); } @@ -179,7 +194,7 @@ pub async fn login(req: String, session: Session) -> HttpResponse { let response = Response { success: true, error: false, - reason: "Correct password!".to_string() + reason: "Correct password!".to_string(), }; HttpResponse::Ok().json(response) } else { @@ -225,29 +240,27 @@ pub async fn delete_link( let response = Response { success: true, error: false, - reason: format!("Deleted {}", shortlink) + reason: format!("Deleted {}", shortlink), }; HttpResponse::Ok().json(response) } else { let response = Response { success: false, error: true, - reason: "The short link was not found, and could not be deleted.".to_string() + reason: "The short link was not found, and could not be deleted.".to_string(), }; HttpResponse::NotFound().json(response) } } else if result.error { HttpResponse::Unauthorized().json(result) // If "pass" is true - keeps backwards compatibility - } else { - if auth::validate(session) { - if utils::delete_link(shortlink.to_string(), &data.db) { - HttpResponse::Ok().body(format!("Deleted {shortlink}")) - } else { - HttpResponse::NotFound().body("Not found!") - } + } else if auth::validate(session) { + if utils::delete_link(shortlink.to_string(), &data.db) { + HttpResponse::Ok().body(format!("Deleted {shortlink}")) } else { - HttpResponse::Unauthorized().body("Not logged in!") + HttpResponse::NotFound().body("Not found!") } + } else { + HttpResponse::Unauthorized().body("Not logged in!") } } diff --git a/actix/src/utils.rs b/actix/src/utils.rs index 9641577..ab5ef9f 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -1,14 +1,14 @@ // SPDX-FileCopyrightText: 2023 Sayantan Santra // SPDX-License-Identifier: MIT +use crate::{auth, database}; +use actix_web::HttpRequest; use nanoid::nanoid; use rand::seq::SliceRandom; use regex::Regex; use rusqlite::Connection; use serde::{Deserialize, Serialize}; use std::env; -use actix_web::HttpRequest; -use crate::{auth, database}; // Struct for reading link pairs sent during API call #[derive(Deserialize)] @@ -26,7 +26,7 @@ pub struct Response { pass: bool, } -// If the api_key environment variable eists +// If the api_key environment variable exists pub fn is_api_ok(http: HttpRequest) -> Response { // If the api_key environment variable exists if env::var("api_key").is_ok() { @@ -34,27 +34,46 @@ pub fn is_api_ok(http: HttpRequest) -> Response { if let Some(header) = auth::api_header(&http) { // If the header is correct if auth::validate_key(header.to_string()) { - Response { success: true, error: false, reason: "Correct API key".to_string(), pass: false } + Response { + success: true, + error: false, + reason: "Correct API key".to_string(), + pass: false, + } } else { - Response { success: false, error: true, reason: "Incorrect API key".to_string(), pass: false } + Response { + success: false, + error: true, + reason: "Incorrect API key".to_string(), + pass: false, + } } // The header may not exist when the user logs in through the web interface, so allow a request with no header. // Further authentication checks will be conducted in services.rs } else { // Due to the implementation of this result in services.rs, this JSON object will not be outputted. - Response { success: false, error: false, reason: "X-API-Key header was not found".to_string(), pass: true } + Response { + success: false, + error: false, + reason: "X-API-Key header was not found".to_string(), + pass: true, + } } } else { // If the API key isn't set, but an API Key header is provided if auth::api_header(&http).is_some() { Response {success: false, error: true, reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(), pass: false} } else { - Response {success: false, error: false, reason: "".to_string(), pass: true} + Response { + success: false, + error: false, + reason: "".to_string(), + pass: true, + } } } } - // Request the DB for searching an URL pub fn get_longurl(shortlink: String, db: &Connection) -> Option { if validate_link(&shortlink) { diff --git a/compose.yaml b/compose.yaml index 78a51d6..7236653 100644 --- a/compose.yaml +++ b/compose.yaml @@ -26,7 +26,7 @@ services: - password=TopSecretPass # This needs to be set in order to use programs that use the JSON interface of Chhoto URL. - # You will get a warning if this is insecure, and a generated value will be outputted + # You will get a warning if this is insecure, and a generated value will be output # You may use that value if you can't think of a secure key # - api_key=SECURE_API_KEY From 5183279cab799f410f5e4f10c6bfad0cf3c4b417 Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Sun, 5 Jan 2025 16:25:08 +0530 Subject: [PATCH 14/19] docs: Small changes to the README --- README.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 573a971..0bf6c80 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ docker run -p 4567:4567 \ -e site_url="https://www.example.com" \ -d chhoto-url:latest ``` -1.c Optionally, set an API key to activate JSON result mode (optional) +1.c Further, set an API key to activate JSON result mode (optional) ``` docker run -p 4567:4567 \ @@ -159,9 +159,11 @@ served through a proxy. The application can be used from the terminal using something like `curl`. In all the examples below, replace `http://localhost:4567` with where your instance of `chhoto-url` is accessible. +You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and +get the siteurl using `curl http://localhost:4567/api/siteurl`. These routes are accessible without any authentication. + ### Cookie validation -If you have set up -a password, first do the following to get an authentication cookie and store it in a file. +If you have set up a password, first do the following to get an authentication cookie and store it in a file. ```bash curl -X POST -d "" -c cookie.txt http://localhost:4567/api/login ``` @@ -188,8 +190,8 @@ The server will send a confirmation. ### API key validation **This is required for programs that rely on a JSON response from Chhoto URL** -In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie validation (see section above). -If the API key is insecure, a warning will be outputted along with a generated API key which may be used. +In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie +validation (see section above). If the API key is insecure, a warning will be outputted along with a generated API key which may be used. To add a link: ``` bash @@ -205,16 +207,11 @@ To delete a link: ``` bash curl -X DELETE -H "X-API-Key: " http://localhost:4567/api/del/ ``` -Where `` is name of the shortened link you would like to delete. For example, if the shortened link is `http://localhost:4567/example`, `` would be `example`. +Where `` is name of the shortened link you would like to delete. For example, if the shortened link is +`http://localhost:4567/example`, `` would be `example`. The server will output when the instance is accessed over API, when an incorrect API key is received, etc. - -In both modes, these routes are accessible: - -You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and -get the siteurl using `curl http://localhost:4567/api/siteurl`. - ## Disable authentication If you do not define a password environment variable when starting the docker image, authentication will be disabled. From eb4f05a87b3968fb335ae7a3f3db8a6dca4152ad Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Mon, 6 Jan 2025 11:11:09 +0530 Subject: [PATCH 15/19] fix: Disregard empty Site URL --- actix/src/main.rs | 9 ++++----- actix/src/services.rs | 5 ++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/actix/src/main.rs b/actix/src/main.rs index da27c68..5d41a4e 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -47,11 +47,10 @@ async fn main() -> Result<()> { } // Tell the user that the server has started, and where it is listening to, rather than simply outputting nothing - eprintln!( - "Server has started at 0.0.0.0 on port {}. Configured Site URL is: {}", - port, - env::var("site_url").unwrap_or(String::from("http://localhost")) - ); + eprintln!("Server has started at 0.0.0.0 on port {port}."); + if let Some(site_url) = env::var("site_url").ok().filter(|s| !s.trim().is_empty()) { + eprintln!("Configured Site URL is: {site_url}."); + } // Actually start the server HttpServer::new(move || { diff --git a/actix/src/services.rs b/actix/src/services.rs index 4a10d4d..a5c80db 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -61,7 +61,10 @@ pub async fn add_link( .expect("Supplied port is not an integer"); let url = format!( "{}:{}", - env::var("site_url").unwrap_or(String::from("http://localhost")), + env::var("site_url") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or(String::from("http://localhost")), port ); let response = CreatedURL { From 1d9a8c202d63f049d0deffa36a9e78b9dd466393 Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Mon, 6 Jan 2025 11:17:10 +0530 Subject: [PATCH 16/19] build: Add API_KEY variable in Makefile --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 85ae80d..8f5f8ae 100644 --- a/Makefile +++ b/Makefile @@ -24,6 +24,7 @@ docker-test: docker-local docker-stop docker run -p ${PORT}:${PORT} --name chhoto-url -e password="${PASSWORD}" -e public_mode="${PUBLIC_MODE}" \ -e site_url="${SITE_URL}" -e db_url="${DB_URL}" -e redirect_method="${REDIRECT_METHOD}" -e port="${PORT}"\ -e slug_style="${SLUG_STYLE}" -e slug_length="${SLUG_LENGTH}" -e cache_control_header="${CACHE_CONTROL_HEADER}"\ + -e api_key="${API_KEY}"\ -d chhoto-url docker logs chhoto-url -f From cba667ded87ce331be2d04db346b3bf86d9dbb83 Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Mon, 6 Jan 2025 11:40:20 +0530 Subject: [PATCH 17/19] chg: Small cosmetic change --- actix/src/utils.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/actix/src/utils.rs b/actix/src/utils.rs index ab5ef9f..c548907 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -62,7 +62,12 @@ pub fn is_api_ok(http: HttpRequest) -> Response { } else { // If the API key isn't set, but an API Key header is provided if auth::api_header(&http).is_some() { - Response {success: false, error: true, reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(), pass: false} + Response { + success: false, + error: true, + reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(), + pass: false + } } else { Response { success: false, From cca5bcfa1ad220e54e555b9652beb27be60e28ef Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Mon, 6 Jan 2025 11:47:01 +0530 Subject: [PATCH 18/19] docs: Add example command to generate API key --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 0bf6c80..3fc90ee 100644 --- a/README.md +++ b/README.md @@ -193,10 +193,13 @@ The server will send a confirmation. In order to use API key validation, set the `api_key` environment variable. If this is not set, the API will default to cookie validation (see section above). If the API key is insecure, a warning will be outputted along with a generated API key which may be used. +Example Linux command for generating a secure API key: `tr -dc A-Za-z0-9 " -d '{"shortlink":"", "longlink":""}' http://localhost:4567/api/new ``` +Send an empty `` if you want it to be auto-generated. The server will reply with the generated shortlink. To get a list of all the currently available links: ``` bash From 16bc211f9fcb4c4008d40fc897dd1fb771be2b2d Mon Sep 17 00:00:00 2001 From: SinTan1729 Date: Mon, 6 Jan 2025 11:48:18 +0530 Subject: [PATCH 19/19] fix: Confirm when secure API key is provided --- actix/src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/actix/src/main.rs b/actix/src/main.rs index 5d41a4e..825ab4a 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -43,6 +43,8 @@ async fn main() -> Result<()> { if let Ok(key) = env::var("api_key") { if !auth::is_key_secure() { eprintln!("API key is insecure! Please change it. Current key is: {}. Generated secure key which you may use: {}", key, auth::gen_key()) + } else { + eprintln!("Secure API key was provided.") } }