diff --git a/actix/src/auth.rs b/actix/src/auth.rs index 4559576..ced60ae 100644 --- a/actix/src/auth.rs +++ b/actix/src/auth.rs @@ -6,12 +6,74 @@ use actix_web::HttpRequest; use argon2::{password_hash::PasswordHash, Argon2, PasswordVerifier}; use log::{debug, warn}; use passwords::PasswordGenerator; +use serde::Serialize; use std::{rc::Rc, time::SystemTime}; use crate::config::Config; +// Define JSON struct for error response +#[derive(Serialize)] +pub struct APIResponse { + pub success: bool, + pub error: bool, + reason: String, + pass: bool, +} + +// If the api_key environment variable exists +pub fn is_api_ok(http: HttpRequest, config: &Config) -> APIResponse { + // If the api_key environment variable exists + if config.api_key.is_some() { + // If the header exists + if let Some(header) = get_api_header(&http) { + // If the header is correct + if is_key_valid(header, config) { + APIResponse { + success: true, + error: false, + reason: "Correct API key".to_string(), + pass: false, + } + } else { + APIResponse { + 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. + APIResponse { + success: false, + error: false, + reason: "No valid authentication was found".to_string(), + pass: true, + } + } + } else { + // If the API key isn't set, but an API Key header is provided + if get_api_header(&http).is_some() { + APIResponse { + 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 { + APIResponse { + success: false, + error: false, + reason: "".to_string(), + pass: true, + } + } + } +} // Validate API key -pub fn validate_key(key: &str, config: &Config) -> bool { +pub fn is_key_valid(key: &str, config: &Config) -> bool { if let Some(api_key) = &config.api_key { // Check if API Key is hashed using Argon2. More algorithms maybe added later. let authorized = if config.hash_algorithm.is_some() { @@ -54,26 +116,26 @@ pub fn gen_key() -> String { } // Check if the API key header exists -pub fn api_header(req: &HttpRequest) -> Option<&str> { +pub fn get_api_header(req: &HttpRequest) -> Option<&str> { req.headers().get("X-API-Key")?.to_str().ok() } // Validate a session -pub fn validate(session: Session, config: &Config) -> bool { +pub fn is_session_valid(session: Session, config: &Config) -> bool { // If there's no password provided, just return true if config.password.is_none() { return true; } if let Ok(token) = session.get::("chhoto-url-auth") { - check(token.as_deref()) + is_token_valid(token.as_deref()) } else { false } } // Check a token cryptographically -fn check(token: Option<&str>) -> bool { +fn is_token_valid(token: Option<&str>) -> bool { if let Some(token_body) = token { let token_parts: Rc<[&str]> = token_body.split(';').collect(); if token_parts.len() < 2 { diff --git a/actix/src/config.rs b/actix/src/config.rs index c3f6b58..16f827e 100644 --- a/actix/src/config.rs +++ b/actix/src/config.rs @@ -175,7 +175,7 @@ pub fn read() -> Config { if use_wal_mode { info!("Using WAL journaling mode for database."); } else { - warn!("Using DELETE journaling mode for database. WAL mode is recommended. (Please read the docs.)"); + warn!("Using DELETE journaling mode for database. WAL mode is recommended."); } let ensure_acid = !var("ensure_acid").is_ok_and(|s| s.trim() == "False"); if ensure_acid { diff --git a/actix/src/database.rs b/actix/src/database.rs index edb1739..c9a2836 100644 --- a/actix/src/database.rs +++ b/actix/src/database.rs @@ -1,11 +1,13 @@ // SPDX-FileCopyrightText: 2023 Sayantan Santra // SPDX-License-Identifier: MIT -use log::info; -use rusqlite::{fallible_iterator::FallibleIterator, Connection, Error}; +use log::{error, info}; +use rusqlite::{fallible_iterator::FallibleIterator, Connection}; use serde::Serialize; use std::rc::Rc; +use crate::services::ChhotoError::{self, ClientError, ServerError}; + // Struct for encoding a DB row #[derive(Serialize)] pub struct DBRow { @@ -16,23 +18,27 @@ pub struct DBRow { } // Find a single URL for /api/expand -pub fn find_url(shortlink: &str, db: &Connection) -> (Option, Option, Option) { +pub fn find_url(shortlink: &str, db: &Connection) -> Result<(String, i64, i64), ChhotoError> { // Long link, hits, expiry time let now = chrono::Utc::now().timestamp(); let query = "SELECT long_url, hits, expiry_time FROM urls WHERE short_url = ?1 AND (expiry_time = 0 OR expiry_time > ?2)"; - let mut statement = db - .prepare_cached(query) - .expect("Error preparing SQL statement for find_url."); + let Ok(mut statement) = db.prepare_cached(query) else { + error!("Error preparing SQL statement for find_url."); + return Err(ServerError); + }; statement .query_row((shortlink, now), |row| { - let longlink = row.get("long_url").ok(); - let hits = row.get("hits").ok(); - let expiry_time = row.get("expiry_time").ok(); - Ok((longlink, hits, expiry_time)) + Ok(( + row.get("long_url")?, + row.get("hits")?, + row.get("expiry_time")?, + )) + }) + .map_err(|_| ChhotoError::ClientError { + reason: "The shortlink does not exist on the server!".to_string(), }) - .unwrap_or_default() } // Get all URLs in DB @@ -67,28 +73,26 @@ pub fn getall( FROM urls WHERE expiry_time = 0 OR expiry_time > ?1 ORDER BY id ASC" }; - let mut statement = db - .prepare_cached(query) - .expect("Error preparing SQL statement for getall."); + let Ok(mut statement) = db.prepare_cached(query) else { + error!("Error preparing SQL statement for getall."); + return [].into(); + }; - let data = if let Some(pos) = page_after { + let raw_data = if let Some(pos) = page_after { let size = page_size.unwrap_or(10); - statement - .query((pos, now, size)) - .expect("Error executing query for getall: curson pagination.") + statement.query((pos, now, size)) } else if let Some(num) = page_no { let size = page_size.unwrap_or(10); - statement - .query((now, size, (num - 1) * size)) - .expect("Error executing query for getall: offset pagination.") + statement.query((now, size, (num - 1) * size)) } else if let Some(size) = page_size { - statement - .query((now, size)) - .expect("Error executing query for getall: offset pagination (default).") + statement.query((now, size)) } else { - statement - .query([now]) - .expect("Error executing query for getall: no pagination.") + statement.query([now]) + }; + + let Ok(data) = raw_data else { + error!("Error running SQL statement for getall: {query}"); + return [].into(); }; let links: Rc<[DBRow]> = data @@ -102,25 +106,29 @@ pub fn getall( Ok(row_struct) }) .collect() - .expect("Error procecssing fetched row."); + .unwrap_or({ + error!("Error processing fetched rows."); + [].into() + }); links } // Add a hit when site is visited during link resolution -pub fn find_and_add_hit(shortlink: &str, db: &Connection) -> Option { +pub fn find_and_add_hit(shortlink: &str, db: &Connection) -> Result { let now = chrono::Utc::now().timestamp(); - let mut statement = db - .prepare_cached( - "UPDATE urls + let Ok(mut statement) = db.prepare_cached( + "UPDATE urls SET hits = hits + 1 WHERE short_url = ?1 AND (expiry_time = 0 OR expiry_time > ?2) RETURNING long_url", - ) - .expect("Error preparing SQL statement for add_hit."); + ) else { + error!("Error preparing SQL statement for add_hit."); + return Err(()); + }; statement .query_one((shortlink, now), |row| row.get("long_url")) - .ok() + .map_err(|_| ()) } // Insert a new link @@ -129,7 +137,7 @@ pub fn add_link( longlink: &str, expiry_delay: i64, db: &Connection, -) -> Option { +) -> Result { let now = chrono::Utc::now().timestamp(); let expiry_time = if expiry_delay == 0 { 0 @@ -137,23 +145,26 @@ pub fn add_link( now + expiry_delay }; - let mut statement = db - .prepare_cached( - "INSERT INTO urls + let Ok(mut statement) = db.prepare_cached( + "INSERT INTO urls (long_url, short_url, hits, expiry_time) VALUES (?1, ?2, 0, ?3) ON CONFLICT(short_url) DO UPDATE SET long_url = ?1, hits = 0, expiry_time = ?3 WHERE short_url = ?2 AND expiry_time <= ?4 AND expiry_time > 0", - ) - .expect("Error preparing SQL statement for add_link."); - let delta = statement - .execute((longlink, shortlink, expiry_time, now)) - .expect("There was an unexpected error while inserting link."); - if delta == 1 { - Some(expiry_time) - } else { - None + ) else { + error!("Error preparing SQL statement for add_link."); + return Err(ServerError); + }; + match statement.execute((longlink, shortlink, expiry_time, now)) { + Ok(1) => Ok(expiry_time), + Ok(_) => Err(ClientError { + reason: "Short URL is already in use!".to_string(), + }), + Err(e) => { + error!("There was some error while adding the link ({shortlink}, {longlink}, {expiry_delay}): {e}"); + Err(ServerError) + } } } @@ -163,7 +174,7 @@ pub fn edit_link( longlink: &str, reset_hits: bool, db: &Connection, -) -> Result { +) -> Result { let now = chrono::Utc::now().timestamp(); let query = if reset_hits { "UPDATE urls @@ -174,14 +185,23 @@ pub fn edit_link( SET long_url = ?1 WHERE short_url = ?2 AND (expiry_time = 0 OR expiry_time > ?3)" }; - let mut statement = db - .prepare_cached(query) - .expect("Error preparing SQL statement for edit_link."); - statement.execute((longlink, shortlink, now)) + let Ok(mut statement) = db.prepare_cached(query) else { + error!("Error preparing SQL statement for edit_link."); + return Err(()); + }; + + statement + .execute((longlink, shortlink, now)) + .inspect_err(|err| { + error!( + "Got an error while editing link ({shortlink}, {longlink}, {reset_hits}): {err}" + ); + }) + .map_err(|_| ()) } // Clean expired links -pub fn cleanup(db: &Connection) { +pub fn cleanup(db: &Connection, use_wal_mode: bool) { let now = chrono::Utc::now().timestamp(); info!("Starting database cleanup."); @@ -197,24 +217,36 @@ pub fn cleanup(db: &Connection) { }) .expect("Error cleaning expired links."); + if use_wal_mode { + let mut pragma_statement = db + .prepare_cached("PRAGMA wal_checkpoint(TRUNCATE)") + .expect("Error preparing SQL statement for pragma: wal_checkpoint."); + pragma_statement + .query_one([], |row| row.get::(1)) + .ok() + .filter(|&v| v != -1) + .expect("Unable to create WAL checkpoint."); + } let mut pragma_statement = db .prepare_cached("PRAGMA optimize") - .expect("Error preparing SQL statement for pragma optimize."); + .expect("Error preparing SQL statement for pragma: optimize."); pragma_statement .execute([]) - .expect("Unable to optimize database"); + .expect("Unable to optimize database."); info!("Optimized database.") } // Delete an existing link -pub fn delete_link(shortlink: &str, db: &Connection) -> bool { - let mut statement = db - .prepare_cached("DELETE FROM urls WHERE short_url = ?1") - .expect("Error preparing SQL statement for delete_link."); - if let Ok(delta) = statement.execute([shortlink]) { - delta > 0 - } else { - false +pub fn delete_link(shortlink: &str, db: &Connection) -> Result<(), ChhotoError> { + let Ok(mut statement) = db.prepare_cached("DELETE FROM urls WHERE short_url = ?1") else { + error!("Error preparing SQL statement for delete_link."); + return Err(ServerError); + }; + match statement.execute([shortlink]) { + Ok(delta) if delta > 0 => Ok(()), + _ => Err(ClientError { + reason: "The shortlink was not found, and could not be deleted.".to_string(), + }), } } diff --git a/actix/src/main.rs b/actix/src/main.rs index 0c489cc..1f3a0b8 100644 --- a/actix/src/main.rs +++ b/actix/src/main.rs @@ -97,7 +97,7 @@ async fn main() -> Result<()> { let mut interval = time::interval(time::Duration::from_secs(3600)); loop { interval.tick().await; - database::cleanup(&db); + database::cleanup(&db, conf.use_wal_mode); } }); diff --git a/actix/src/services.rs b/actix/src/services.rs index cc01d36..8d6742c 100644 --- a/actix/src/services.rs +++ b/actix/src/services.rs @@ -17,11 +17,18 @@ use std::env; use crate::AppState; use crate::{auth, database}; -use crate::{auth::validate, utils}; +use crate::{auth::is_session_valid, utils}; +use ChhotoError::{ClientError, ServerError}; // Store the version number const VERSION: &str = env!("CARGO_PKG_VERSION"); +// Error types +pub enum ChhotoError { + ServerError, + ClientError { reason: String }, +} + // Define JSON struct for returning success/error data #[derive(Serialize)] struct Response { @@ -82,53 +89,64 @@ pub async fn add_link( ) -> HttpResponse { let config = &data.config; // Call is_api_ok() function, pass HttpRequest - let result = utils::is_api_ok(http, config); + let result = auth::is_api_ok(http, config); // If success, add new link if result.success { - let (success, reply, expiry_time) = utils::add_link(&req, &data.db, config, false); - if success { - let site_url = config.site_url.clone(); - let shorturl = if let Some(url) = site_url { - format!("{url}/{reply}") - } else { - let protocol = if config.port == 443 { "https" } else { "http" }; - let port_text = if [80, 443].contains(&config.port) { - String::new() + match utils::add_link(&req, &data.db, config, false) { + Ok((shorturl, expiry_time)) => { + let site_url = config.site_url.clone(); + let shorturl = if let Some(url) = site_url { + format!("{url}/{shorturl}") } else { - format!(":{}", config.port) + let protocol = if config.port == 443 { "https" } else { "http" }; + let port_text = if [80, 443].contains(&config.port) { + String::new() + } else { + format!(":{}", config.port) + }; + format!("{protocol}://localhost{port_text}/{shorturl}") }; - format!("{protocol}://localhost{port_text}/{reply}") - }; - let response = CreatedURL { - success: true, - error: false, - shorturl, - expiry_time, - }; - HttpResponse::Created().json(response) - } else { - let response = Response { - success: false, - error: true, - reason: reply, - }; - HttpResponse::Conflict().json(response) + let response = CreatedURL { + success: true, + error: false, + shorturl, + expiry_time, + }; + HttpResponse::Created().json(response) + } + Err(ServerError) => { + let response = Response { + success: false, + error: true, + reason: "Something went wrong when adding the link.".to_string(), + }; + HttpResponse::InternalServerError().json(response) + } + Err(ClientError { reason }) => { + let response = Response { + success: false, + error: true, + reason, + }; + HttpResponse::Conflict().json(response) + } } } else if result.error { HttpResponse::Unauthorized().json(result) // If password authentication or public mode is used - keeps backwards compatibility } else { - let (success, reply, _) = if auth::validate(session, config) { + let result = if auth::is_session_valid(session, config) { utils::add_link(&req, &data.db, config, false) } else if config.public_mode { utils::add_link(&req, &data.db, config, true) } else { return HttpResponse::Unauthorized().body("Not logged in!"); }; - if success { - HttpResponse::Created().body(reply) - } else { - HttpResponse::Conflict().body(reply) + match result { + Ok((shorturl, _)) => HttpResponse::Created().body(shorturl), + Err(ServerError) => HttpResponse::InternalServerError() + .body("Something went wrong when adding the link.".to_string()), + Err(ClientError { reason }) => HttpResponse::Conflict().body(reason), } } } @@ -143,14 +161,14 @@ pub async fn getall( ) -> HttpResponse { let config = &data.config; // Call is_api_ok() function, pass HttpRequest - let result = utils::is_api_ok(http, config); + let result = auth::is_api_ok(http, config); // If success, return all links if result.success { HttpResponse::Ok().body(utils::getall(&data.db, params.into_inner())) } else if result.error { HttpResponse::Unauthorized().json(result) // If password authentication is used - keeps backwards compatibility - } else if auth::validate(session, config) { + } else if auth::is_session_valid(session, config) { HttpResponse::Ok().body(utils::getall(&data.db, params.into_inner())) } else { HttpResponse::Unauthorized().body("Not logged in!") @@ -160,26 +178,35 @@ pub async fn getall( // Get information about a single shortlink #[post("/api/expand")] pub async fn expand(req: String, data: web::Data, http: HttpRequest) -> HttpResponse { - let result = utils::is_api_ok(http, &data.config); + let result = auth::is_api_ok(http, &data.config); if result.success { - let (longurl, hits, expiry_time) = database::find_url(&req, &data.db); - if let Some(longlink) = longurl { - let body = LinkInfo { - success: true, - error: false, - longurl: longlink, - hits: hits.expect("Error getting hit count for existing shortlink."), - expiry_time: expiry_time - .expect("Error getting expiry time for existing shortlink."), - }; - HttpResponse::Ok().json(body) - } else { - let body = Response { - success: false, - error: true, - reason: "The shortlink does not exist on the server.".to_string(), - }; - HttpResponse::BadRequest().json(body) + match database::find_url(&req, &data.db) { + Ok((longurl, hits, expiry_time)) => { + let body = LinkInfo { + success: true, + error: false, + longurl, + hits, + expiry_time, + }; + HttpResponse::Ok().json(body) + } + Err(ServerError) => { + let body = Response { + success: false, + error: true, + reason: "Something went wrong when finding the link.".to_string(), + }; + HttpResponse::BadRequest().json(body) + } + Err(ClientError { reason }) => { + let body = Response { + success: false, + error: true, + reason, + }; + HttpResponse::BadRequest().json(body) + } } } else { HttpResponse::Unauthorized().json(result) @@ -195,26 +222,33 @@ pub async fn edit_link( http: HttpRequest, ) -> HttpResponse { let config = &data.config; - let result = utils::is_api_ok(http, config); - if result.success || validate(session, config) { - if let Some((server_error, error_msg)) = utils::edit_link(&req, &data.db, config) { - let body = Response { - success: false, - error: true, - reason: error_msg, - }; - if server_error { + let result = auth::is_api_ok(http, config); + if result.success || is_session_valid(session, config) { + match utils::edit_link(&req, &data.db, config) { + Ok(()) => { + let body = Response { + success: true, + error: false, + reason: String::from("Edit was successful."), + }; + HttpResponse::Created().json(body) + } + Err(ServerError) => { + let body = Response { + success: false, + error: true, + reason: "Something went wrong when editing the link.".to_string(), + }; HttpResponse::InternalServerError().json(body) - } else { + } + Err(ClientError { reason }) => { + let body = Response { + success: false, + error: true, + reason, + }; HttpResponse::BadRequest().json(body) } - } else { - let body = Response { - success: true, - error: false, - reason: String::from("Edit was successful."), - }; - HttpResponse::Created().json(body) } } else { HttpResponse::Unauthorized().json(result) @@ -249,8 +283,8 @@ pub async fn whoami( http: HttpRequest, ) -> HttpResponse { let config = &data.config; - let result = utils::is_api_ok(http, config); - let acting_user = if result.success || validate(session, config) { + let result = auth::is_api_ok(http, config); + let acting_user = if result.success || is_session_valid(session, config) { "admin" } else if config.public_mode { "public" @@ -268,8 +302,8 @@ pub async fn getconfig( http: HttpRequest, ) -> HttpResponse { let config = &data.config; - let result = utils::is_api_ok(http, config); - if result.success || validate(session, config) || data.config.public_mode { + let result = auth::is_api_ok(http, config); + if result.success || is_session_valid(session, config) || data.config.public_mode { let backend_config = BackendConfig { version: VERSION.to_string(), allow_capital_letters: config.allow_capital_letters, @@ -301,7 +335,7 @@ pub async fn link_handler( data: web::Data, ) -> impl Responder { let shortlink_str = shortlink.as_str(); - if let Some(longlink) = database::find_and_add_hit(shortlink_str, &data.db) { + if let Ok(longlink) = database::find_and_add_hit(shortlink_str, &data.db) { if data.config.use_temp_redirect { Either::Left(Redirect::to(longlink)) } else { @@ -403,29 +437,40 @@ pub async fn delete_link( ) -> HttpResponse { let config = &data.config; // Call is_api_ok() function, pass HttpRequest - let result = utils::is_api_ok(http, config); + let result = auth::is_api_ok(http, config); // If success, delete shortlink if result.success { - if utils::delete_link(&shortlink, &data.db, data.config.allow_capital_letters) { - let response = Response { - success: true, - error: false, - 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(), - }; - HttpResponse::NotFound().json(response) + match utils::delete_link(&shortlink, &data.db, data.config.allow_capital_letters) { + Ok(()) => { + let response = Response { + success: true, + error: false, + reason: format!("Deleted {shortlink}"), + }; + HttpResponse::Ok().json(response) + } + Err(ServerError) => { + let response = Response { + success: false, + error: true, + reason: "Something went wrong when deleting the link.".to_string(), + }; + HttpResponse::InternalServerError().json(response) + } + Err(ClientError { reason }) => { + let response = Response { + success: false, + error: true, + reason, + }; + HttpResponse::NotFound().json(response) + } } } else if result.error { HttpResponse::Unauthorized().json(result) - // If "pass" is true - keeps backwards compatibility - } else if auth::validate(session, config) { - if utils::delete_link(&shortlink, &data.db, data.config.allow_capital_letters) { + // If using password - keeps backwards compatibility + } else if auth::is_session_valid(session, config) { + if utils::delete_link(&shortlink, &data.db, data.config.allow_capital_letters).is_ok() { HttpResponse::Ok().body(format!("Deleted {shortlink}")) } else { HttpResponse::NotFound().body("Not found!") diff --git a/actix/src/utils.rs b/actix/src/utils.rs index f8d40cd..0623478 100644 --- a/actix/src/utils.rs +++ b/actix/src/utils.rs @@ -1,14 +1,21 @@ // SPDX-FileCopyrightText: 2023 Sayantan Santra // SPDX-License-Identifier: MIT -use actix_web::HttpRequest; +use log::error; use nanoid::nanoid; use rand::seq::IndexedRandom; use regex::Regex; use rusqlite::Connection; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; -use crate::{auth, config::Config, database, services::GetReqParams}; +use crate::{ + config::Config, + database, + services::{ + ChhotoError::{self, ClientError, ServerError}, + GetReqParams, + }, +}; // Struct for reading link pairs sent during API call for new link #[derive(Deserialize)] @@ -28,70 +35,8 @@ struct EditURLRequest { reset_hits: bool, } -// Define JSON struct for error response -#[derive(Serialize)] -pub struct Response { - pub(crate) success: bool, - pub(crate) error: bool, - reason: String, - pass: bool, -} - -// If the api_key environment variable exists -pub fn is_api_ok(http: HttpRequest, config: &Config) -> Response { - // If the api_key environment variable exists - if config.api_key.is_some() { - // If the header exists - if let Some(header) = auth::api_header(&http) { - // If the header is correct - if auth::validate_key(header, config) { - 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, - } - } - // 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: "No valid authentication was 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, - } - } - } -} - // Only have a-z, 0-9, - and _ as valid characters in a shortlink -fn validate_link(link: &str, allow_capital_letters: bool) -> bool { +fn is_link_valid(link: &str, allow_capital_letters: bool) -> bool { let re = if allow_capital_letters { Regex::new("^[A-Za-z0-9-_]+$").expect("Regex generation failed.") } else { @@ -115,13 +60,15 @@ pub fn add_link( db: &Connection, config: &Config, using_public_mode: bool, -) -> (bool, String, i64) { - // Success status, response string, expiry time +) -> Result<(String, i64), ChhotoError> { + // Ok : shortlink, expiry_time let mut chunks: NewURLRequest; if let Ok(json) = serde_json::from_str(req) { chunks = json; } else { - return (false, String::from("Invalid request!"), 0); + return Err(ClientError { + reason: "Invalid request!".to_string(), + }); } let style = &config.slug_style; @@ -147,65 +94,75 @@ pub fn add_link( chunks.expiry_delay = chunks.expiry_delay.min(157784760); chunks.expiry_delay = chunks.expiry_delay.max(0); - if validate_link(chunks.shortlink.as_str(), allow_capital_letters) { - if let Some(expiry_time) = - database::add_link(&chunks.shortlink, &chunks.longlink, chunks.expiry_delay, db) - { - (true, chunks.shortlink, expiry_time) - } else if shortlink_provided { - (false, String::from("Short URL is already in use!"), 0) - } else if config.slug_style == "UID" && config.try_longer_slug { - // Optionally, retry with a longer slug length - chunks.shortlink = gen_link(style, len + 4, allow_capital_letters); - if let Some(expiry_time) = - database::add_link(&chunks.shortlink, &chunks.longlink, chunks.expiry_delay, db) - { - (true, chunks.shortlink, expiry_time) - } else { - (false, String::from("Something went very wrong!"), 0) + if is_link_valid(chunks.shortlink.as_str(), allow_capital_letters) { + match database::add_link(&chunks.shortlink, &chunks.longlink, chunks.expiry_delay, db) { + Ok(expiry_time) => Ok((chunks.shortlink, expiry_time)), + Err(ClientError { reason }) => { + if shortlink_provided { + Err(ClientError { reason }) + } else if config.slug_style == "UID" && config.try_longer_slug { + // Optionally, retry with a longer slug length + chunks.shortlink = gen_link(style, len + 4, allow_capital_letters); + match database::add_link( + &chunks.shortlink, + &chunks.longlink, + chunks.expiry_delay, + db, + ) { + Ok(expiry_time) => Ok((chunks.shortlink, expiry_time)), + Err(_) => Err(ServerError), + } + } else { + error!("Something went wrong while adding a link: {reason}"); + Err(ServerError) + } } - } else { - (false, String::from("Something went wrong!"), 0) + Err(ServerError) => Err(ServerError), } } else { - (false, String::from("Short URL is not valid!"), 0) + Err(ClientError { + reason: "Short URL is not valid!".to_string(), + }) } } // Make checks and then request the DB to edit an URL entry -pub fn edit_link(req: &str, db: &Connection, config: &Config) -> Option<(bool, String)> { - // None means success - // The boolean is true when it's a server error and false when it's a client error - // The string is the error message - +pub fn edit_link(req: &str, db: &Connection, config: &Config) -> Result<(), ChhotoError> { let chunks: EditURLRequest; if let Ok(json) = serde_json::from_str(req) { chunks = json; } else { - return Some((false, String::from("Malformed request!"))); + return Err(ClientError { + reason: "Malformed request!".to_string(), + }); } - if !validate_link(&chunks.shortlink, config.allow_capital_letters) { - return Some((false, String::from("Invalid shortlink!"))); + if !is_link_valid(&chunks.shortlink, config.allow_capital_letters) { + return Err(ClientError { + reason: "Invalid shortlink!".to_string(), + }); } let result = database::edit_link(&chunks.shortlink, &chunks.longlink, chunks.reset_hits, db); - if Ok(0) == result { + match result { // Zero rows returned means no updates - Some(( - false, - "The short link was not found, and could not be edited.".to_string(), - )) - } else if result.is_ok() { - None - } else { - Some((true, String::from("Something went wrong!"))) // Should not really happen + Ok(0) => Err(ClientError { + reason: "The shortlink was not found, and could not be edited.".to_string(), + }), + Ok(_) => Ok(()), + Err(()) => Err(ServerError), } } // Check if link, and request DB to delete it if exists -pub fn delete_link(shortlink: &str, db: &Connection, allow_capital_letters: bool) -> bool { - if validate_link(shortlink, allow_capital_letters) { +pub fn delete_link( + shortlink: &str, + db: &Connection, + allow_capital_letters: bool, +) -> Result<(), ChhotoError> { + if is_link_valid(shortlink, allow_capital_letters) { database::delete_link(shortlink, db) } else { - false + Err(ClientError { + reason: "The shortlink is invalid.".to_string(), + }) } } diff --git a/chhoto-url.container b/chhoto-url.container index c2c5b9a..7d4dbdb 100644 --- a/chhoto-url.container +++ b/chhoto-url.container @@ -1,4 +1,8 @@ +# SPDX-FileCopyrightText: 2025 Sayantan Santra +# SPDX-License-Identifier: MIT +# # chhoto-url.container +# # To be used with rootless quadlets. Put inside your $XDG_CONFIG_HOME/containers/systemd/ # Take a look at README for the explanation of the configs. # The commented out configs are optional.