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

Merge pull request #39 from SolninjaA/main

Improvements of the API system
This commit is contained in:
Sayantan Santra 2025-01-06 11:52:48 +05:30 committed by GitHub
commit f8f4dae457
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 367 additions and 25 deletions

1
.gitignore vendored
View file

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

View file

@ -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}" \ 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 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 slug_style="${SLUG_STYLE}" -e slug_length="${SLUG_LENGTH}" -e cache_control_header="${CACHE_CONTROL_HEADER}"\
-e api_key="${API_KEY}"\
-d chhoto-url -d chhoto-url
docker logs chhoto-url -f docker logs chhoto-url -f

View file

@ -128,6 +128,17 @@ docker run -p 4567:4567 \
-e site_url="https://www.example.com" \ -e site_url="https://www.example.com" \
-d chhoto-url:latest -d chhoto-url:latest
``` ```
1.c Further, 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 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 the `redirect_method` variable to `TEMPORARY` or `PERMANENT` (it's matched exactly). By
@ -148,8 +159,11 @@ served through a proxy.
The application can be used from the terminal using something like `curl`. In all the examples 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. below, replace `http://localhost:4567` with where your instance of `chhoto-url` is accessible.
If you have set up You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and
a password, first do the following to get an authentication cookie and store it in a file. 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.
```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
``` ```
@ -173,8 +187,33 @@ curl -X DELETE http://localhost:4567/api/del/<shortlink>
``` ```
The server will send a confirmation. The server will send a confirmation.
You can get the version of `chhoto-url` the server is running using `curl http://localhost:4567/api/version` and ### API key validation
get the siteurl using `curl http://localhost:4567/api/siteurl`. **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.
Example Linux command for generating a secure API key: `tr -dc A-Za-z0-9 </dev/urandom | head -c 128`
To add a link:
``` bash
curl -X POST -H "X-API-Key: <YOUR_API_KEY>" -d '{"shortlink":"<shortlink>", "longlink":"<longlink>"}' http://localhost:4567/api/new
```
Send an empty `<shortlink>` 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
curl -H "X-API-Key: <YOUR_API_KEY>" http://localhost:4567/api/all
```
To delete a link:
``` bash
curl -X DELETE -H "X-API-Key: <YOUR_API_KEY>" http://localhost:4567/api/del/<shortlink>
```
Where `<shortlink>` is name of the shortened link you would like to delete. For example, if the shortened link is
`http://localhost:4567/example`, `<shortlink>` would be `example`.
The server will output when the instance is accessed over API, when an incorrect API key is received, etc.
## Disable authentication ## Disable authentication
If you do not define a password environment variable when starting the docker image, authentication If you do not define a password environment variable when starting the docker image, authentication

51
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"
@ -476,13 +476,14 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]] [[package]]
name = "chhoto-url" name = "chhoto-url"
version = "5.4.5" version = "5.4.6"
dependencies = [ dependencies = [
"actix-files", "actix-files",
"actix-session", "actix-session",
"actix-web", "actix-web",
"env_logger", "env_logger",
"nanoid", "nanoid",
"passwords",
"rand", "rand",
"regex", "regex",
"rusqlite", "rusqlite",
@ -1227,6 +1228,15 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@ -1284,6 +1294,12 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "proc-macro-hack"
version = "0.5.20+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.89" version = "1.0.89"
@ -1332,6 +1348,37 @@ dependencies = [
"getrandom", "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]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.7" version = "0.5.7"

View file

@ -32,6 +32,7 @@ actix-files = "0.6.5"
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

@ -2,8 +2,55 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
use actix_session::Session; use actix_session::Session;
use actix_web::HttpRequest;
use std::{env, time::SystemTime}; use std::{env, time::SystemTime};
// API key generation and scoring
use passwords::{analyzer, scorer, PasswordGenerator};
// 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("X-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()));
score >= 90.0
}
// Validate a given password // Validate a given password
pub fn validate(session: Session) -> bool { pub fn validate(session: Session) -> bool {
// If there's no password provided, just return true // If there's no password provided, just return true

View file

@ -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,6 +24,7 @@ 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())
@ -38,6 +39,21 @@ 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())
} else {
eprintln!("Secure API key was provided.")
}
}
// 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 {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 // Actually start the server
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()

View file

@ -8,10 +8,13 @@ use actix_web::{
http::StatusCode, http::StatusCode,
post, post,
web::{self, Redirect}, web::{self, Redirect},
Either, HttpResponse, Responder, Either, HttpRequest, HttpResponse, Responder,
}; };
use std::env; use std::env;
// Serialize JSON data
use serde::Serialize;
use crate::auth; use crate::auth;
use crate::database; use crate::database;
use crate::utils; use crate::utils;
@ -20,12 +23,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,
}
// 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 // 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(
if env::var("public_mode") == Ok(String::from("Enable")) || auth::validate(session) { 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 {
let port = env::var("port")
.unwrap_or(String::from("4567"))
.parse::<u16>()
.expect("Supplied port is not an integer");
let url = format!(
"{}:{}",
env::var("site_url")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or(String::from("http://localhost")),
port
);
let response = CreatedURL {
success: true,
error: false,
shorturl: format!("{}/{}", url, out.1),
};
HttpResponse::Created().json(response)
} else {
let response = Response {
success: false,
error: true,
reason: out.1,
};
HttpResponse::Conflict().json(response)
}
} else if result.error {
HttpResponse::Unauthorized().json(result)
// 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); let out = utils::add_link(req, &data.db);
if out.0 { if out.0 {
HttpResponse::Created().body(out.1) HttpResponse::Created().body(out.1)
@ -39,8 +98,20 @@ pub async fn add_link(req: String, data: web::Data<AppState>, session: Session)
// 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(
if auth::validate(session) { 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 password authentication is used - keeps backwards compatibility
} else if auth::validate(session) {
HttpResponse::Ok().body(utils::getall(&data.db)) HttpResponse::Ok().body(utils::getall(&data.db))
} else { } else {
let body = if env::var("public_mode") == Ok(String::from("Enable")) { let body = if env::var("public_mode") == Ok(String::from("Enable")) {
@ -105,20 +176,48 @@ 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") { // Keep this function backwards compatible
if password != req { if env::var("api_key").is_ok() {
eprintln!("Failed login attempt!"); if let Ok(password) = env::var("password") {
return HttpResponse::Unauthorized().body("Wrong 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,8 +233,31 @@ 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) {
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)
}
} 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) { if utils::delete_link(shortlink.to_string(), &data.db) {
HttpResponse::Ok().body(format!("Deleted {shortlink}")) HttpResponse::Ok().body(format!("Deleted {shortlink}"))
} else { } else {

View file

@ -1,15 +1,15 @@
// 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 crate::{auth, database};
use actix_web::HttpRequest;
use nanoid::nanoid; 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 crate::database;
// Struct for reading link pairs sent during API call // Struct for reading link pairs sent during API call
#[derive(Deserialize)] #[derive(Deserialize)]
struct URLPair { struct URLPair {
@ -17,6 +17,68 @@ 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 exists
pub fn is_api_ok(http: HttpRequest) -> Response {
// If the api_key environment variable exists
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()) {
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: "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,
}
}
}
}
// 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

@ -24,7 +24,12 @@ services:
# - site_url=https://www.example.com # - site_url=https://www.example.com
- password=TopSecretPass - 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 output
# 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 # 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