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
.env
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/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

View file

@ -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 "<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
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.
# 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]]

View file

@ -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"

View file

@ -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 {

View file

@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// 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
}

View file

@ -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::<u16>()
@ -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()

View file

@ -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<AppState>, 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<AppState>, 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<AppState>, session: Session) -> HttpResponse {
if auth::validate(session) {
pub async fn getall(data: web::Data<AppState>, 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<String>,
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, 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!")
}
}
}

View file

@ -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<String> {
if validate_link(&shortlink) {

View file

@ -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