mirror of
https://github.com/SinTan1729/chhoto-url.git
synced 2025-12-06 05:24:25 -08:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
828019998e | ||
|
|
49d910fb3c | ||
|
|
c521ad1120 | ||
|
|
d42a738861 | ||
|
|
e3eaf5aba8 | ||
|
|
3b48ce7b5e | ||
|
|
5363a1b056 | ||
|
|
0d58e626a4 | ||
|
|
e8faf660f4 | ||
|
|
67695da86b | ||
|
|
d50c183c9c | ||
|
|
90b04b1f21 | ||
|
|
babf3d8911 | ||
|
|
1ae00eb3a8 | ||
|
|
6f419c7b3d | ||
|
|
c557b8b262 | ||
|
|
a63222a71a | ||
|
|
86cea6278f | ||
|
|
f283991740 | ||
|
|
1775f71347 | ||
|
|
0b1224f8e5 | ||
|
|
1047763285 | ||
|
|
fc785c3eef | ||
|
|
17d0df943b | ||
|
|
7b52bd60da | ||
|
|
db8417d919 | ||
|
|
af1685bb70 | ||
|
|
a5621acfe4 | ||
|
|
1be89db43b | ||
|
|
a60853fd21 | ||
|
|
2b9fafe440 | ||
|
|
f952cb88a0 | ||
|
|
9eec252fe2 | ||
|
|
f8f4dae457 | ||
|
|
16bc211f9f | ||
|
|
cca5bcfa1a | ||
|
|
cba667ded8 | ||
|
|
1d9a8c202d | ||
|
|
eb4f05a87b | ||
|
|
5183279cab | ||
|
|
f1c1642976 | ||
|
|
eed3c2292a | ||
|
|
4fb8d0cb5c | ||
|
|
9a0cdec646 | ||
|
|
818dadb84f | ||
|
|
247cfb0476 | ||
|
|
6347a89725 | ||
|
|
9ddf043c17 | ||
|
|
a1f8700664 | ||
|
|
aab7a9b3d1 | ||
|
|
1ef5d539d5 | ||
|
|
5c2886f651 | ||
|
|
2c56c68637 | ||
|
|
756d675f06 | ||
|
|
e6eed2dd70 | ||
|
|
37a5300015 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ urls.sqlite
|
||||
**/.directory
|
||||
.env
|
||||
cookie*
|
||||
.idea/
|
||||
|
||||
1
Makefile
1
Makefile
@@ -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}" \
|
||||
-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 api_key="${API_KEY}"\
|
||||
-d chhoto-url
|
||||
docker logs chhoto-url -f
|
||||
|
||||
|
||||
64
README.md
64
README.md
@@ -45,7 +45,7 @@ for small. URL means, well... URL. So the name simply means Small URL.
|
||||
stays under 5MB under normal use.)
|
||||
- Counts number of hits for each short link in a privacy respecting way
|
||||
i.e. only the hit is recorded, and nothing else.
|
||||
- Has a mobile friendly UI.
|
||||
- Has a mobile friendly UI, and automatic dark mode.
|
||||
- Has a public mode, where anyone can add links without authentication. Deleting
|
||||
or listing available links will need admin access using the password.
|
||||
- Allows setting the URL of your website, in case you want to conveniently
|
||||
@@ -56,7 +56,7 @@ for small. URL means, well... URL. So the name simply means Small URL.
|
||||
written in plain HTML and vanilla JS, using [Pure CSS](https://purecss.io/)
|
||||
for styling.
|
||||
- Uses very basic authentication using a provided password. It's not encrypted in transport.
|
||||
I recommend using something like [caddy](https://caddyserver.com/) to
|
||||
I recommend using a reverse proxy such as [caddy](https://caddyserver.com/) to
|
||||
encrypt the connection by SSL.
|
||||
|
||||
# Bloat that will not be implemented
|
||||
@@ -72,7 +72,6 @@ not needed here.
|
||||
whatever reason), you can message me through GitHub issues.
|
||||
|
||||
# Screenshots
|
||||
#### Note: I'm using Dark Reader here to get the dark theme.
|
||||
<p align="middle">
|
||||
<img src="screenshot-desktop.webp" height="250" alt="desktop screenshot" />
|
||||
<img src="screenshot-mobile.webp" height="250" alt="mobile screenshot" />
|
||||
@@ -128,6 +127,17 @@ docker run -p 4567:4567 \
|
||||
-e site_url="https://www.example.com" \
|
||||
-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
|
||||
the `redirect_method` variable to `TEMPORARY` or `PERMANENT` (it's matched exactly). By
|
||||
@@ -148,10 +158,47 @@ served through a proxy.
|
||||
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.
|
||||
|
||||
If you have set up
|
||||
a password, first do the following to get an authentication cookie and store it in a file.
|
||||
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`. These routes are accessible without any authentication.
|
||||
|
||||
### 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.
|
||||
|
||||
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 information about a single shortlink:
|
||||
``` bash
|
||||
curl -H "X-API-Key: <YOUR_API_KEY>" -d '<shortlink>' http://localhost:4567/api/expand
|
||||
```
|
||||
(This route is not accessible using cookie validation.)
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
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.
|
||||
@@ -173,9 +220,6 @@ curl -X DELETE http://localhost:4567/api/del/<shortlink>
|
||||
```
|
||||
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
|
||||
get the siteurl using `curl http://localhost:4567/api/siteurl`.
|
||||
|
||||
## Disable authentication
|
||||
If you do not define a password environment variable when starting the docker image, authentication
|
||||
will be disabled.
|
||||
@@ -187,5 +231,7 @@ that those links aren't created by you.
|
||||
|
||||
## Notes
|
||||
- It started as a fork of [`simply-shorten`](https://gitlab.com/draganczukp/simply-shorten).
|
||||
- There's an (unofficial) extension maintained by for shortening URLs easily using Chhoto URL.
|
||||
[You can take a look at it here.](https://github.com/SolninjaA/Chhoto-URL-Extension)
|
||||
- The list of adjectives and names used for random short url generation is a modified
|
||||
version of [this list used by docker](https://github.com/moby/moby/blob/master/pkg/namesgenerator/names-generator.go).
|
||||
|
||||
582
actix/Cargo.lock
generated
582
actix/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
|
||||
[package]
|
||||
name = "chhoto-url"
|
||||
version = "5.4.5"
|
||||
version = "5.6.3"
|
||||
edition = "2021"
|
||||
authors = ["Sayantan Santra <sayantan[dot]santra689[at]gmail[dot]com"]
|
||||
license = "mit"
|
||||
@@ -29,9 +29,10 @@ categories = ["web-programming"]
|
||||
[dependencies]
|
||||
actix-web = "4.5.1"
|
||||
actix-files = "0.6.5"
|
||||
rusqlite = { version = "0.32.0", features = ["bundled"] }
|
||||
rusqlite = { version = "0.34.0", features = ["bundled"] }
|
||||
regex = "1.10.3"
|
||||
rand = "0.8.5"
|
||||
rand = "0.9.0"
|
||||
passwords = "3.1.16"
|
||||
actix-session = { version = "0.10.0", features = ["cookie-session"] }
|
||||
env_logger = "0.11.1"
|
||||
nanoid = "0.4.0"
|
||||
|
||||
@@ -2,8 +2,55 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use actix_session::Session;
|
||||
use actix_web::HttpRequest;
|
||||
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
|
||||
pub fn validate(session: Session) -> bool {
|
||||
// If there's no password provided, just return true
|
||||
|
||||
@@ -13,20 +13,27 @@ pub struct DBRow {
|
||||
}
|
||||
|
||||
// Find a single URL
|
||||
pub fn find_url(shortlink: &str, db: &Connection) -> Option<String> {
|
||||
pub fn find_url(shortlink: &str, db: &Connection, needhits: bool) -> (Option<String>, Option<i64>) {
|
||||
let query = if needhits {
|
||||
"SELECT long_url,hits FROM urls WHERE short_url = ?1"
|
||||
} else {
|
||||
"SELECT long_url FROM urls WHERE short_url = ?1"
|
||||
};
|
||||
let mut statement = db
|
||||
.prepare_cached("SELECT long_url FROM urls WHERE short_url = ?1")
|
||||
.prepare_cached(query)
|
||||
.expect("Error preparing SQL statement for find_url.");
|
||||
|
||||
statement
|
||||
let longlink = statement
|
||||
.query_row([shortlink], |row| row.get("long_url"))
|
||||
.ok()
|
||||
.ok();
|
||||
let hits = statement.query_row([shortlink], |row| row.get("hits")).ok();
|
||||
(longlink, hits)
|
||||
}
|
||||
|
||||
// Get all URLs in DB
|
||||
pub fn getall(db: &Connection) -> Vec<DBRow> {
|
||||
let mut statement = db
|
||||
.prepare_cached("SELECT * FROM urls")
|
||||
.prepare_cached("SELECT * FROM urls ORDER BY id ASC")
|
||||
.expect("Error preparing SQL statement for getall.");
|
||||
|
||||
let mut data = statement
|
||||
@@ -91,5 +98,6 @@ pub fn open_db(path: String) -> Connection {
|
||||
[],
|
||||
)
|
||||
.expect("Unable to initialize empty database.");
|
||||
|
||||
db
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
// Get the port environment variable
|
||||
let port = env::var("port")
|
||||
.unwrap_or(String::from("4567"))
|
||||
.parse::<u16>()
|
||||
@@ -38,6 +40,46 @@ 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!("WARN: 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.")
|
||||
}
|
||||
}
|
||||
|
||||
// If the site_url env variable exists
|
||||
if let Some(site_url) = env::var("site_url").ok().filter(|s| !s.trim().is_empty()) {
|
||||
// Get first and last characters of the site_url
|
||||
let mut chars = site_url.chars();
|
||||
let first = chars.next();
|
||||
let last = chars.next_back();
|
||||
let url = chars.as_str();
|
||||
// If the site_url is encapsulated by quotes (i.e. invalid)
|
||||
if first == Option::from('"') || first == Option::from('\'') && first == last {
|
||||
// Set the site_url without the quotes
|
||||
env::set_var("site_url", url);
|
||||
eprintln!("WARN: The site_url environment variable is encapsulated by quotes. Automatically adjusting to {}", url);
|
||||
|
||||
// Tell the user what URI the server will respond with
|
||||
eprintln!("INFO: Public URI is: {url}:{port}.")
|
||||
} else {
|
||||
// No issues
|
||||
eprintln!("INFO: Configured Site URL is: {site_url}.");
|
||||
|
||||
// Tell the user what URI the server will respond with
|
||||
eprintln!("INFO: Public URI is: {site_url}:{port}.")
|
||||
}
|
||||
} else {
|
||||
// Site URL is not configured
|
||||
eprintln!("WARN: The site_url environment variable is not configured. Defaulting to http://localhost");
|
||||
eprintln!("INFO: Public URI is: http://localhost:{port}.")
|
||||
}
|
||||
|
||||
// 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}.");
|
||||
|
||||
// Actually start the server
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
@@ -66,9 +108,11 @@ async fn main() -> Result<()> {
|
||||
.service(services::delete_link)
|
||||
.service(services::login)
|
||||
.service(services::logout)
|
||||
.service(services::expand)
|
||||
.service(Files::new("/", "./resources/").index_file("index.html"))
|
||||
.default_service(actix_web::web::get().to(services::error404))
|
||||
})
|
||||
// Hardcode the port the server listens to. Allows for more intuitive Docker Compose port management
|
||||
.bind(("0.0.0.0", port))?
|
||||
.run()
|
||||
.await
|
||||
|
||||
@@ -8,9 +8,11 @@ use actix_web::{
|
||||
http::StatusCode,
|
||||
post,
|
||||
web::{self, Redirect},
|
||||
Either, HttpResponse, Responder,
|
||||
Either, HttpRequest, HttpResponse, Responder,
|
||||
};
|
||||
use std::env;
|
||||
// Serialize JSON data
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth;
|
||||
use crate::database;
|
||||
@@ -20,12 +22,93 @@ 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,
|
||||
}
|
||||
|
||||
// Needed to return the short URL to make it easier for programs leveraging the API
|
||||
#[derive(Serialize)]
|
||||
struct CreatedURL {
|
||||
success: bool,
|
||||
error: bool,
|
||||
shorturl: String,
|
||||
}
|
||||
|
||||
// Struct for returning information about a shortlink
|
||||
#[derive(Serialize)]
|
||||
struct LinkInfo {
|
||||
success: bool,
|
||||
error: bool,
|
||||
longurl: String,
|
||||
hits: i64,
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let port = env::var("port")
|
||||
.unwrap_or(String::from("4567"))
|
||||
.parse::<u16>()
|
||||
.expect("Supplied port is not an integer");
|
||||
let mut url = format!(
|
||||
"{}:{}",
|
||||
env::var("site_url")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(String::from("http://localhost")),
|
||||
port
|
||||
);
|
||||
// If the port is 80, remove the port from the returned URL (better for copying and pasting)
|
||||
// Return http://
|
||||
if port == 80 {
|
||||
url = env::var("site_url")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(String::from("http://localhost"));
|
||||
}
|
||||
// If the port is 443, remove the port from the returned URL (better for copying and pasting)
|
||||
// Return https://
|
||||
if port == 443 {
|
||||
url = env::var("site_url")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(String::from("https://localhost"));
|
||||
}
|
||||
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);
|
||||
if out.0 {
|
||||
HttpResponse::Created().body(out.1)
|
||||
@@ -39,8 +122,20 @@ pub async fn add_link(req: String, data: web::Data<AppState>, session: Session)
|
||||
|
||||
// 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 password authentication is used - keeps backwards compatibility
|
||||
} else if auth::validate(session) {
|
||||
HttpResponse::Ok().body(utils::getall(&data.db))
|
||||
} else {
|
||||
let body = if env::var("public_mode") == Ok(String::from("Enable")) {
|
||||
@@ -52,6 +147,35 @@ pub async fn getall(data: web::Data<AppState>, session: Session) -> HttpResponse
|
||||
}
|
||||
}
|
||||
|
||||
// Get information about a single shortlink
|
||||
#[post("/api/expand")]
|
||||
pub async fn expand(req: String, data: web::Data<AppState>, http: HttpRequest) -> HttpResponse {
|
||||
let result = utils::is_api_ok(http);
|
||||
if result.success {
|
||||
let linkinfo = utils::get_longurl(req, &data.db, true);
|
||||
if let Some(longlink) = linkinfo.0 {
|
||||
let body = LinkInfo {
|
||||
success: true,
|
||||
error: false,
|
||||
longurl: longlink,
|
||||
hits: linkinfo
|
||||
.1
|
||||
.expect("Error getting hit count 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::Unauthorized().json(body)
|
||||
}
|
||||
} else {
|
||||
HttpResponse::Unauthorized().json(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the site URL
|
||||
#[get("/api/siteurl")]
|
||||
pub async fn siteurl() -> HttpResponse {
|
||||
@@ -83,7 +207,7 @@ pub async fn link_handler(
|
||||
data: web::Data<AppState>,
|
||||
) -> impl Responder {
|
||||
let shortlink_str = shortlink.to_string();
|
||||
if let Some(longlink) = utils::get_longurl(shortlink_str, &data.db) {
|
||||
if let Some(longlink) = utils::get_longurl(shortlink_str, &data.db, false).0 {
|
||||
let redirect_method = env::var("redirect_method").unwrap_or(String::from("PERMANENT"));
|
||||
database::add_hit(shortlink.as_str(), &data.db);
|
||||
if redirect_method == "TEMPORARY" {
|
||||
@@ -105,20 +229,48 @@ 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!");
|
||||
// Keep this function backwards compatible
|
||||
if env::var("api_key").is_ok() {
|
||||
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,8 +286,31 @@ 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) {
|
||||
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) {
|
||||
HttpResponse::Ok().body(format!("Deleted {shortlink}"))
|
||||
} else {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
use crate::{auth, database};
|
||||
use actix_web::HttpRequest;
|
||||
use nanoid::nanoid;
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::seq::IndexedRandom;
|
||||
use regex::Regex;
|
||||
use rusqlite::Connection;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
|
||||
use crate::database;
|
||||
|
||||
// Struct for reading link pairs sent during API call
|
||||
#[derive(Deserialize)]
|
||||
struct URLPair {
|
||||
@@ -17,12 +17,74 @@ struct URLPair {
|
||||
longlink: String,
|
||||
}
|
||||
|
||||
// Request the DB for searching an URL
|
||||
pub fn get_longurl(shortlink: String, db: &Connection) -> Option<String> {
|
||||
if validate_link(&shortlink) {
|
||||
database::find_url(shortlink.as_str(), db)
|
||||
// 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 {
|
||||
None
|
||||
// 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
|
||||
pub fn get_longurl(shortlink: String, db: &Connection, needhits: bool) -> (Option<String>, Option<i64>) {
|
||||
if validate_link(&shortlink) {
|
||||
database::find_url(shortlink.as_str(), db, needhits)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +124,7 @@ pub fn add_link(req: String, db: &Connection) -> (bool, String) {
|
||||
}
|
||||
|
||||
if validate_link(chunks.shortlink.as_str())
|
||||
&& get_longurl(chunks.shortlink.clone(), db).is_none()
|
||||
&& get_longurl(chunks.shortlink.clone(), db, false).0.is_none()
|
||||
{
|
||||
(
|
||||
database::add_link(chunks.shortlink.clone(), chunks.longlink, db),
|
||||
@@ -128,10 +190,10 @@ fn gen_link(style: String, len: usize) -> String {
|
||||
format!(
|
||||
"{0}-{1}",
|
||||
ADJECTIVES
|
||||
.choose(&mut rand::thread_rng())
|
||||
.choose(&mut rand::rng())
|
||||
.expect("Error choosing random adjective."),
|
||||
NAMES
|
||||
.choose(&mut rand::thread_rng())
|
||||
.choose(&mut rand::rng())
|
||||
.expect("Error choosing random name.")
|
||||
)
|
||||
}
|
||||
|
||||
28
compose.yaml
28
compose.yaml
@@ -6,7 +6,16 @@ services:
|
||||
image: sintan1729/chhoto-url:latest
|
||||
restart: unless-stopped
|
||||
container_name: chhoto-url
|
||||
# You may enable the next two options if you want, but it may break the program if the db is bind
|
||||
# mounted from the system. It does add extra security, but I don't know enough about docker
|
||||
# to help in case it breaks something.
|
||||
# read_only: true
|
||||
# cap_drop:
|
||||
# - ALL
|
||||
ports:
|
||||
# If you changed the "port" environment variable, adjust accordingly
|
||||
# The number AFTER the colon should match the "port" variable and the number
|
||||
# before the colon is the port where you would access the container from outside.
|
||||
- 4567:4567
|
||||
environment:
|
||||
# Change if you want to mount the database somewhere else.
|
||||
@@ -18,13 +27,24 @@ services:
|
||||
# a copy of your database.)
|
||||
- db_url=/db/urls.sqlite
|
||||
|
||||
# Change it in case you want to set the website name
|
||||
# displayed in front of the shorturls, defaults to
|
||||
# the hostname you're accessing it from.
|
||||
# Change this if your server URL is not "http://localhost"
|
||||
# This must not be surrounded by quotes. For example:
|
||||
# site_url="https://www.example.com" incorrect
|
||||
# site_url=https://www.example.com correct
|
||||
# This is important to ensure Chhoto URL outputs the shortened link with the correct URL.
|
||||
# - site_url=https://www.example.com
|
||||
|
||||
- password=TopSecretPass
|
||||
# Change this if you are running Chhoto URL on a port which is not 4567.
|
||||
# This is important to ensure Chhoto URL outputs the shortened link with the correct port.
|
||||
# - port=4567
|
||||
|
||||
- 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
|
||||
# are accepted values, defaults to PERMANENT.
|
||||
# - redirect_method=TEMPORARY
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<p>Please enter password to access this website</p>
|
||||
<input type="password" id="password" />
|
||||
<button class="pure-button pure-button-primary" value="default">Log in</button>
|
||||
<p id="wrong-pass"> </p>
|
||||
<p id="wrong-pass" hidden>Wrong password!</p>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ const refreshData = async () => {
|
||||
}
|
||||
} else {
|
||||
let data = await res.json();
|
||||
displayData(data);
|
||||
displayData(data.reverse());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,10 @@ const TR = (row, site) => {
|
||||
const tr = document.createElement("tr");
|
||||
const longTD = TD(A_LONG(row["longlink"]), "Long URL");
|
||||
var shortTD = null;
|
||||
if (window.isSecureContext) {
|
||||
var isSafari = /Safari/.test(navigator.userAgent) && /Apple Computer/.test(navigator.vendor);
|
||||
// For now, we disable copying on WebKit due to a possible bug. Manual copying is enabled instead.
|
||||
// Take a look at https://github.com/SinTan1729/chhoto-url/issues/36
|
||||
if (window.isSecureContext && !(isSafari)) {
|
||||
shortTD = TD(A_SHORT(row["shortlink"], site), "Short URL");
|
||||
}
|
||||
else {
|
||||
@@ -126,10 +129,10 @@ const copyShortUrl = async (link) => {
|
||||
const site = await getSiteUrl();
|
||||
try {
|
||||
navigator.clipboard.writeText(`${site}/${link}`);
|
||||
showAlert(`Short URL ${link} was copied to clipboard!`, "green");
|
||||
showAlert(`Short URL ${link} was copied to clipboard!`, "light-dark(green, #72ff72)");
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
showAlert(`Could not copy short URL to clipboard, please do it manually: <a href=${site}/${link}>${site}/${link}</a>`, "red");
|
||||
showAlert(`Could not copy short URL to clipboard, please do it manually: <a href=${site}/${link}>${site}/${link}</a>`, "light-dark(red, #ff1a1a)");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -210,7 +213,7 @@ const submitForm = () => {
|
||||
})
|
||||
.then(text => {
|
||||
if (!ok) {
|
||||
showAlert(text, "red");
|
||||
showAlert(text, "light-dark(red, #ff1a1a)");
|
||||
}
|
||||
else {
|
||||
copyShortUrl(text);
|
||||
@@ -231,11 +234,10 @@ const submitLogin = () => {
|
||||
document.getElementById("container").style.filter = "blur(0px)"
|
||||
document.getElementById("login-dialog").close();
|
||||
password.value = '';
|
||||
document.getElementById("wrong-pass").hidden = true;
|
||||
refreshData();
|
||||
} else {
|
||||
const wrongPassBox = document.getElementById("wrong-pass");
|
||||
wrongPassBox.innerHTML = "Wrong password!";
|
||||
wrongPassBox.style.color = "red";
|
||||
document.getElementById("wrong-pass").hidden = false;
|
||||
password.focus();
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,33 @@
|
||||
src: url('/assets/Montserrat-VF.woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
color: light-dark(black, #e8e6e3);
|
||||
background-color: light-dark(white, #181a1b);
|
||||
}
|
||||
|
||||
.pure-button {
|
||||
background-color: light-dark(#0078e7, #0060b9);
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: light-dark(#cccccc, #3e4446) !important;
|
||||
box-shadow: light-dark(#dddddd, #2b2f31) 0px 1px 3px inset !important;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: light-dark(#757575, #636061);
|
||||
}
|
||||
|
||||
legend {
|
||||
color: light-dark(#333333, #c8c3bc) !important;
|
||||
border-bottom-color: light-dark(#e5e5e5 ,#373c3e) !important;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: Montserrat;
|
||||
}
|
||||
@@ -15,6 +42,10 @@
|
||||
margin: 20px auto auto;
|
||||
}
|
||||
|
||||
a {
|
||||
color: light-dark(blue, #3391ff);
|
||||
}
|
||||
|
||||
table tr td div {
|
||||
max-height: 75px;
|
||||
line-height: 25px;
|
||||
@@ -23,6 +54,19 @@ table tr td div {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pure-table {
|
||||
border-color: light-dark(black, #867d6e);
|
||||
}
|
||||
|
||||
.pure-table caption {
|
||||
color: light-dark(black, #e8e6e3);
|
||||
}
|
||||
|
||||
.pure-table thead {
|
||||
color: light-dark(black, #e8e6e3);
|
||||
background-color: light-dark(#e0e0e0, #2a2d2f);
|
||||
}
|
||||
|
||||
.pure-table td {
|
||||
border-left: none;
|
||||
}
|
||||
@@ -86,10 +130,19 @@ div[name="links-div"] {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
dialog form {
|
||||
#login-dialog {
|
||||
border-radius: 10px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
#login-dialog form {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#wrong-pass {
|
||||
color: light-dark(red, #ff1a1a);
|
||||
}
|
||||
|
||||
/* Settings for mobile devices */
|
||||
@media (pointer:none),
|
||||
(pointer:coarse) {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 66 KiB |
Reference in New Issue
Block a user