1
0
Fork 0
mirror of https://github.com/SinTan1729/chhoto-url synced 2025-02-05 13:52:33 -06:00

Improves API functionality

This commit is contained in:
Solninja A 2024-12-31 16:19:20 +10:00
parent e6eed2dd70
commit 2c56c68637
11 changed files with 265 additions and 44 deletions

1
.gitignore vendored
View file

@ -9,3 +9,4 @@ urls.sqlite
**/.directory **/.directory
.env .env
cookie* cookie*
.idea/

View file

@ -21,7 +21,7 @@ RUN cargo chef cook --release --target=$target --recipe-path recipe.json
COPY ./actix/Cargo.toml ./actix/Cargo.lock ./ COPY ./actix/Cargo.toml ./actix/Cargo.lock ./
COPY ./actix/src ./src COPY ./actix/src ./src
# Build application # 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 RUN cp /chhoto-url/target/$target/release/chhoto-url /chhoto-url/release
FROM scratch FROM scratch

View file

@ -151,7 +151,7 @@ below, replace `http://localhost:4567` with where your instance of `chhoto-url`
If you have set up If you have set up
a password, first do the following to get an authentication cookie and store it in a file. a password, first do the following to get an authentication cookie and store it in a file.
```bash ```bash
curl -X post -d "<your-password>" -c cookie.txt http://localhost:4567/api/login curl -X POST -d "<your-password>" -c cookie.txt http://localhost:4567/api/login
``` ```
You should receive "Correct password!" if the provided password was correct. For any subsequent You should receive "Correct password!" if the provided password was correct. For any subsequent
request, please add `-b cookie.txt` to provide authentication. request, please add `-b cookie.txt` to provide authentication.

37
actix/Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "actix-codec" name = "actix-codec"
@ -19,6 +19,21 @@ dependencies = [
"tracing", "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]] [[package]]
name = "actix-files" name = "actix-files"
version = "0.6.6" version = "0.6.6"
@ -225,6 +240,21 @@ dependencies = [
"syn", "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]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.24.2" version = "0.24.2"
@ -476,11 +506,13 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]] [[package]]
name = "chhoto-url" name = "chhoto-url"
version = "5.4.5" version = "5.4.6"
dependencies = [ dependencies = [
"actix-cors",
"actix-files", "actix-files",
"actix-session", "actix-session",
"actix-web", "actix-web",
"actix-web-httpauth",
"env_logger", "env_logger",
"nanoid", "nanoid",
"rand", "rand",
@ -736,6 +768,7 @@ dependencies = [
"futures-task", "futures-task",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab",
] ]
[[package]] [[package]]

View file

@ -29,9 +29,12 @@ categories = ["web-programming"]
[dependencies] [dependencies]
actix-web = "4.5.1" actix-web = "4.5.1"
actix-files = "0.6.5" actix-files = "0.6.5"
actix-cors = "0.7.0"
actix-web-httpauth = "0.8.2"
rusqlite = { version = "0.32.0", features = ["bundled"] } rusqlite = { version = "0.32.0", features = ["bundled"] }
regex = "1.10.3" regex = "1.10.3"
rand = "0.8.5" rand = "0.8.5"
passwords = "3.1.16"
actix-session = { version = "0.10.0", features = ["cookie-session"] } actix-session = { version = "0.10.0", features = ["cookie-session"] }
env_logger = "0.11.1" env_logger = "0.11.1"
nanoid = "0.4.0" nanoid = "0.4.0"

View file

@ -3,6 +3,53 @@
use actix_session::Session; use actix_session::Session;
use std::{env, time::SystemTime}; 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 // Validate a given password
pub fn validate(session: Session) -> bool { pub fn validate(session: Session) -> bool {

View file

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> // SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use rusqlite::Connection; use rusqlite::{Connection};
use serde::Serialize; use serde::Serialize;
// Struct for encoding a DB row // Struct for encoding a DB row
@ -91,5 +91,6 @@ pub fn open_db(path: String) -> Connection {
[], [],
) )
.expect("Unable to initialize empty database."); .expect("Unable to initialize empty database.");
db db
} }

View file

@ -24,11 +24,13 @@ async fn main() -> Result<()> {
// Generate session key in runtime so that restart invalidates older logins // Generate session key in runtime so that restart invalidates older logins
let secret_key = Key::generate(); let secret_key = Key::generate();
let db_location = env::var("db_url") let db_location = env::var("db_url")
.ok() .ok()
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.unwrap_or(String::from("urls.sqlite")); .unwrap_or(String::from("urls.sqlite"));
let port = env::var("port") let port = env::var("port")
.unwrap_or(String::from("4567")) .unwrap_or(String::from("4567"))
.parse::<u16>() .parse::<u16>()
@ -38,6 +40,16 @@ async fn main() -> Result<()> {
.ok() .ok()
.filter(|s| !s.trim().is_empty()); .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 // Actually start the server
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()

View file

@ -3,14 +3,11 @@
use actix_files::NamedFile; use actix_files::NamedFile;
use actix_session::Session; use actix_session::Session;
use actix_web::{ use actix_web::{delete, get, http::StatusCode, post, web::{self, Redirect}, Either, HttpRequest, HttpResponse, Responder};
delete, get, use std::{env};
http::StatusCode,
post, // Serialize JSON data
web::{self, Redirect}, use serde::Serialize;
Either, HttpResponse, Responder,
};
use std::env;
use crate::auth; use crate::auth;
use crate::database; use crate::database;
@ -20,35 +17,68 @@ use crate::AppState;
// Store the version number // Store the version number
const VERSION: &str = env!("CARGO_PKG_VERSION"); 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 // Define the routes
// Add new links // Add new links
#[post("/api/new")] #[post("/api/new")]
pub async fn add_link(req: String, data: web::Data<AppState>, session: Session) -> HttpResponse { pub async fn add_link(req: String, data: web::Data<AppState>, session: Session, http: HttpRequest) -> HttpResponse {
if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { // 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); let out = utils::add_link(req, &data.db);
if out.0 { if out.0 {
HttpResponse::Created().body(out.1) HttpResponse::Created().body(out.1)
} else { } else {
HttpResponse::Conflict().body(out.1) HttpResponse::Conflict().body(out.1)
} }
} else if result.error {
HttpResponse::Unauthorized().json(result)
// If "pass" is true - keeps backwards compatibility
} else { } 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 // Return all active links
#[get("/api/all")] #[get("/api/all")]
pub async fn getall(data: web::Data<AppState>, session: Session) -> HttpResponse { pub async fn getall(data: web::Data<AppState>, 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, return all links
if result.success {
HttpResponse::Ok().body(utils::getall(&data.db)) HttpResponse::Ok().body(utils::getall(&data.db))
} else if result.error {
HttpResponse::Unauthorized().json(result)
// If "pass" is true - keeps backwards compatibility
} else { } else {
let body = if env::var("public_mode") == Ok(String::from("Enable")) { if auth::validate(session){
"Using public mode." HttpResponse::Ok().body(utils::getall(&data.db))
} else { } else {
"Not logged in!" let body = if env::var("public_mode") == Ok(String::from("Enable")) {
}; "Using public mode."
HttpResponse::Unauthorized().body(body) } else {
"Not logged in!"
};
HttpResponse::Unauthorized().body(body)
}
} }
} }
@ -105,20 +135,51 @@ pub async fn link_handler(
// Handle login // Handle login
#[post("/api/login")] #[post("/api/login")]
pub async fn login(req: String, session: Session) -> HttpResponse { pub async fn login(req: String, session: Session) -> HttpResponse {
if let Ok(password) = env::var("password") { // Someone's API may be listening for the plain HTML body response of "Correct password!"
if password != req { // rather than a 200 OK HTTP response. Because of that, a check is performed to see whether
eprintln!("Failed login attempt!"); // 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.
return HttpResponse::Unauthorized().body("Wrong password!"); // *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 // 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")] #[delete("/api/logout")]
pub async fn logout(session: Session) -> HttpResponse { pub async fn logout(session: Session) -> HttpResponse {
if session.remove("chhoto-url-auth").is_some() { if session.remove("chhoto-url-auth").is_some() {
@ -134,14 +195,39 @@ pub async fn delete_link(
shortlink: web::Path<String>, shortlink: web::Path<String>,
data: web::Data<AppState>, data: web::Data<AppState>,
session: Session, session: Session,
http: HttpRequest,
) -> HttpResponse { ) -> 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) { 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 { } 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 { } 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!")
}
} }
} }

View file

@ -5,10 +5,10 @@ use nanoid::nanoid;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use regex::Regex; use regex::Regex;
use rusqlite::Connection; use rusqlite::Connection;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::env; use std::env;
use actix_web::HttpRequest;
use crate::database; use crate::{auth, database};
// Struct for reading link pairs sent during API call // Struct for reading link pairs sent during API call
#[derive(Deserialize)] #[derive(Deserialize)]
@ -17,6 +17,42 @@ struct URLPair {
longlink: String, 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 // Request the DB for searching an URL
pub fn get_longurl(shortlink: String, db: &Connection) -> Option<String> { pub fn get_longurl(shortlink: String, db: &Connection) -> Option<String> {
if validate_link(&shortlink) { if validate_link(&shortlink) {

View file

@ -3,7 +3,7 @@
services: services:
chhoto-url: chhoto-url:
image: sintan1729/chhoto-url:latest image: chhoto-url
restart: unless-stopped restart: unless-stopped
container_name: chhoto-url container_name: chhoto-url
ports: ports:
@ -24,7 +24,9 @@ services:
# - site_url=https://www.example.com # - site_url=https://www.example.com
- password=TopSecretPass - password=TopSecretPass
- api_key=test
# Pass the redirect method, if needed. TEMPORARY and PERMANENT # Pass the redirect method, if needed. TEMPORARY and PERMANENT
# are accepted values, defaults to PERMANENT. # are accepted values, defaults to PERMANENT.
# - redirect_method=TEMPORARY # - redirect_method=TEMPORARY
@ -33,12 +35,12 @@ services:
# If you want UIDs, please change slug_style to UID. # If you want UIDs, please change slug_style to UID.
# Supported values for slug_style are Pair and UID. # Supported values for slug_style are Pair and UID.
# The length is 8 by default, and a minimum of 4 is allowed. # The length is 8 by default, and a minimum of 4 is allowed.
# - slug_style=Pair - slug_style=Pair
# - slug_length=8 - slug_length=8
# In case you want to provide public access to adding links (and not # In case you want to provide public access to adding links (and not
# delete, or listing), change the following option to Enable. # 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 # 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 # comma separated list of valid header as per RFC 7234 §5.2 to send those