1
0
Fork 0
mirror of https://github.com/SinTan1729/chhoto-url synced 2025-04-19 19:30:01 -05:00
This commit is contained in:
SolninjaA 2025-01-01 17:38:22 +10:00 committed by GitHub
commit 497051cdb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 334 additions and 39 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

@ -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 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 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,6 +159,7 @@ 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.
### Cookie validation
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
@ -173,6 +185,33 @@ curl -X DELETE http://localhost:4567/api/del/<shortlink>
``` ```
The server will send a confirmation. 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.
To add a link:
``` bash
curl -X POST -H "Chhoto-Api-Key: <YOUR_API_KEY>" -d '{"shortlink":"<shortlink>", "longlink":"<longlink>"}' http://localhost:4567/api/new
```
To get a list of all the currently available links:
``` bash
curl -H "Chhoto-Api-Key: <YOUR_API_KEY>" http://localhost:4567/api/all
```
To delete a link:
``` bash
curl -X DELETE -H "Chhoto-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.
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 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`. get the siteurl using `curl http://localhost:4567/api/siteurl`.

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

@ -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()));
score >= 90.0
}
// Validate a given password // Validate a given password
pub fn validate(session: Session) -> bool { pub fn validate(session: Session) -> bool {

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,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,15 +3,12 @@
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,
http::StatusCode,
post,
web::{self, Redirect},
Either, 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,35 +17,91 @@ 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(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) 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").unwrap_or(String::from("http://localhost")), port);
let response = CreatedURL {
success: true,
error: false,
shorturl: format!("{}/{}", url, out.1)
};
HttpResponse::Created().json(response)
} else { } 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)
// 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 +158,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,14 +215,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,43 @@ 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 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: "".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 {
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 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 {
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 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 # 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