Compare commits

...

68 Commits
5.4.3 ... 5.6.3

Author SHA1 Message Date
SinTan1729
828019998e build: Bumped version to 5.6.3 2025-04-07 22:24:49 -05:00
SinTan1729
49d910fb3c build: Updated deps to mitigate a tokio security issue 2025-04-07 22:24:10 -05:00
SinTan1729
c521ad1120 docs: Added some more options and info in the compose file 2025-04-02 17:17:59 -05:00
Sayantan Santra
d42a738861 docs: Updated README.md 2025-03-17 18:23:27 -05:00
SinTan1729
e3eaf5aba8 docs: Updated screenshots 2025-03-17 18:22:25 -05:00
SinTan1729
3b48ce7b5e chg: Simplify how wrong password text is shown 2025-03-05 15:56:12 -06:00
SinTan1729
5363a1b056 docs: Added info about dark mode 2025-03-05 15:22:58 -06:00
SinTan1729
0d58e626a4 fix: Hide the disabled wrong password text area in dialog 2025-03-04 00:11:34 -06:00
SinTan1729
e8faf660f4 build: Bumped version to 5.6.2 2025-03-03 18:45:08 -06:00
SinTan1729
67695da86b fix: Use changed methods for rand 2025-03-03 18:44:42 -06:00
SinTan1729
d50c183c9c build: Updated deps 2025-03-03 18:37:49 -06:00
SinTan1729
90b04b1f21 fix: Link colors for dark mode 2025-03-03 18:34:57 -06:00
SinTan1729
babf3d8911 new: Automatic dark mode support 2025-03-03 18:10:53 -06:00
SinTan1729
1ae00eb3a8 chg: Some cosmetic changes to login dialog 2025-03-03 14:16:38 -06:00
SinTan1729
6f419c7b3d new: Enforce ordering of data
Closes #46
Data is returned in order of id, which should match the order it was
inserted in. In the WebUI, the entries are shown in reverse, so the
latest link is at the top.
2025-03-03 12:27:59 -06:00
SinTan1729
c557b8b262 docs: Change to github link for extension 2025-01-30 01:03:06 -06:00
SinTan1729
a63222a71a docs: Add a few words 2025-01-30 00:59:05 -06:00
SinTan1729
86cea6278f docs: Added mention of extension 2025-01-28 01:39:44 -06:00
SinTan1729
f283991740 build: Bumped version to 5.6.1 2025-01-17 23:30:43 -06:00
Sayantan Santra
1775f71347 Merge pull request #42 from SolninjaA/main
Correctly output created link
2025-01-17 23:28:30 -06:00
SinTan1729
0b1224f8e5 docs: Improve clarification of the port variables 2025-01-17 23:27:33 -06:00
Solninja A
1047763285 chg: Bind server to port env variable 2025-01-18 12:52:19 +10:00
Solninja A
fc785c3eef Re-comment the API key in compose.yaml 2025-01-14 18:16:37 +10:00
Solninja A
17d0df943b Correctly output created link 2025-01-14 17:20:41 +10:00
SolomonTechnology
7b52bd60da Rewording 2025-01-14 00:33:01 +10:00
SolomonTechnology
db8417d919 Improve documentation for the "port" and "site_url" env variables 2025-01-14 00:25:49 +10:00
SinTan1729
af1685bb70 build: Bumped version to 5.6.0 2025-01-09 00:34:22 +05:30
Sayantan Santra
a5621acfe4 Merge pull request #40 from SinTan1729/get-longlink
Get longlink
2025-01-09 00:30:58 +05:30
SinTan1729
1be89db43b docs: Add info about expand route, and put API as preferred method 2025-01-09 00:27:05 +05:30
SinTan1729
a60853fd21 fix: Only pull hits when needed 2025-01-09 00:21:05 +05:30
SinTan1729
2b9fafe440 new: Got the expand API path working
It replies with info for a single shortlink. May be useful for
applications using json interface.
2025-01-08 20:09:24 +05:30
SinTan1729
f952cb88a0 build: Bumped version to 5.5.0 2025-01-06 11:59:41 +05:30
SinTan1729
9eec252fe2 build: Updated deps 2025-01-06 11:54:49 +05:30
Sayantan Santra
f8f4dae457 Merge pull request #39 from SolninjaA/main
Improvements of the API system
2025-01-06 11:52:48 +05:30
SinTan1729
16bc211f9f fix: Confirm when secure API key is provided 2025-01-06 11:48:18 +05:30
SinTan1729
cca5bcfa1a docs: Add example command to generate API key 2025-01-06 11:47:01 +05:30
SinTan1729
cba667ded8 chg: Small cosmetic change 2025-01-06 11:40:20 +05:30
SinTan1729
1d9a8c202d build: Add API_KEY variable in Makefile 2025-01-06 11:17:10 +05:30
SinTan1729
eb4f05a87b fix: Disregard empty Site URL 2025-01-06 11:11:09 +05:30
SinTan1729
5183279cab docs: Small changes to the README 2025-01-05 16:25:08 +05:30
SinTan1729
f1c1642976 chg: Small semantic changes 2025-01-05 16:20:38 +05:30
Solninja A
eed3c2292a Cleaned up code 2025-01-03 00:28:51 +10:00
Solninja A
4fb8d0cb5c Edited the API Key header to comply with OpenAPI v3 specs 2025-01-03 00:25:55 +10:00
Solninja A
9a0cdec646 Improved API error codes 2025-01-01 19:08:35 +10:00
Solninja A
818dadb84f Made code more Rust-like 2025-01-01 17:34:09 +10:00
Solninja A
247cfb0476 Fixed compose.yaml 2024-12-31 20:32:46 +10:00
Solninja A
6347a89725 Minor code clean up 2024-12-31 20:30:55 +10:00
Solninja A
9ddf043c17 Fix typos, etc 2024-12-31 20:17:13 +10:00
Solninja A
a1f8700664 Change README.md 2024-12-31 20:15:06 +10:00
Solninja A
aab7a9b3d1 Change README.md and remove unneeded dependencies 2024-12-31 20:13:37 +10:00
Solninja A
1ef5d539d5 Improve API error handling 2024-12-31 19:54:22 +10:00
Solninja A
5c2886f651 Changes the API to use JSON data and results 2024-12-31 19:11:47 +10:00
Solninja A
2c56c68637 Improves API functionality 2024-12-31 16:19:20 +10:00
SinTan1729
756d675f06 fix: Capitalization, fixes #37 2024-12-30 18:41:48 +05:30
SinTan1729
e6eed2dd70 build: Bumped version to 5.4.6 2024-11-07 19:35:42 -06:00
SinTan1729
37a5300015 fix: Disable copying to clipboard on WebKit, fixes #36
This disables clipboard copying and lets the user
manually copy the links.
2024-11-07 19:33:34 -06:00
SinTan1729
66d94634d9 build: Bumped version to 5.4.5 2024-11-06 22:11:36 -06:00
SinTan1729
03f5529c30 build: Updated deps 2024-11-06 22:11:05 -06:00
SinTan1729
f772475d96 fix: Do not autocapitalize shorturl on mobile devices 2024-11-06 21:57:56 -06:00
SinTan1729
8b8ceca313 chg: Remove lowercasing of shorturl from the CSS, fixes #35
This makes the behavior more uniform across different banned characters.
2024-11-03 01:17:53 -05:00
SinTan1729
201d0b319f chg: Move the font to assets 2024-10-25 14:47:15 -05:00
SinTan1729
733ef6ea67 docs: Added note about Dark Reader 2024-10-06 20:38:05 -05:00
SinTan1729
cf5909c888 fix: Use a simpler password to make the shell happy 2024-10-05 00:26:38 -05:00
SinTan1729
dcb3144b22 chg: Added a better compose file 2024-10-05 00:24:16 -05:00
SinTan1729
e0c61bdb93 build: Bumped version to 5.4.4 2024-10-03 00:02:48 -05:00
SinTan1729
06f7a33d5d fix: Do not consider empty password 2024-10-02 23:52:23 -05:00
SinTan1729
514e905299 chg: Updated instructions in the compose file 2024-10-02 23:46:56 -05:00
SinTan1729
3688692c7a chg: Default db location 2024-10-02 23:46:35 -05:00
17 changed files with 1190 additions and 337 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ urls.sqlite
**/.directory
.env
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}" \
-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

View File

@@ -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
@@ -71,7 +71,7 @@ not needed here.
- Paywalls or messages begging for donations. If you want to support me (for
whatever reason), you can message me through GitHub issues.
# Screenshots
# Screenshots
<p align="middle">
<img src="screenshot-desktop.webp" height="250" alt="desktop screenshot" />
<img src="screenshot-mobile.webp" height="250" alt="mobile screenshot" />
@@ -127,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
@@ -147,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.
@@ -172,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.
@@ -186,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).

918
actix/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
[package]
name = "chhoto-url"
version = "5.4.3"
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"

View File

@@ -2,12 +2,63 @@
// 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
if env::var("password").is_err() {
if env::var("password")
.ok()
.filter(|s| !s.trim().is_empty())
.is_none()
{
return true;
}

View File

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

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("unset"));
.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

View File

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

View File

@@ -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.")
)
}

View File

@@ -6,21 +6,45 @@ 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.
# In this case, you can get rid of the db volume below
# and instead do a mount manually by specifying the location.
# - db_url=/urls.sqlite
# Make sure that you create an empty file with the correct name
# before starting the container if you do make any changes.
# (In fact, I'd suggest that you do that so that you can keep
# 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=$3CuReP4S$W0rD
# 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
@@ -41,14 +65,8 @@ services:
# headers instead.
# - cache_control_header=no-cache, private
volumes:
- db:/urls.sqlite
networks:
- proxy
- db:/db
volumes:
db:
networks:
proxy:
external: true

View File

@@ -38,7 +38,7 @@
<div class=" pure-control-group">
<label for="shortUrl">Short URL (optional)</label>
<input type="text" name="shortUrl" id="shortUrl" placeholder="Only a-z, 0-9, - and _ are allowed"
pattern="[a-z0-9\-_]+" title="Only a-z, 0-9, - and _ are allowed"/>
pattern="[a-z0-9\-_]+" title="Only a-z, 0-9, - and _ are allowed" autocapitalize="off"/>
</div>
<div class="pure-controls" id="controls">
<button class="pure-button pure-button-primary">Shorten!</button>
@@ -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">&nbsp;</p>
<p id="wrong-pass" hidden>Wrong password!</p>
</form>
</dialog>

View File

@@ -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();
}
})

View File

@@ -3,7 +3,34 @@
@font-face {
font-family: Montserrat;
src: url('Montserrat-VF.woff2');
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;
}
* {
@@ -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;
}
@@ -57,10 +101,6 @@ input {
width: 65%;
}
form input[name="shortUrl"] {
text-transform: lowercase;
}
form input[name="shortUrl"]::placeholder {
text-transform: none;
}
@@ -90,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