1
0
Fork 0
mirror of https://github.com/SinTan1729/chhoto-url synced 2025-04-19 19:30:01 -05:00

Compare commits

..

No commits in common. "main" and "5.0.0" have entirely different histories.
main ... 5.0.0

38 changed files with 1182 additions and 2694 deletions

View file

@ -1,44 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: 'bug'
assignees: 'SinTan1729'
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Which version of Chhoto-URL are you experiencing the problem on?**
e.g. v5.x.x
**Can you reproduce the issue in the latest version?**
Yes/No
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View file

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: 'feature-request'
assignees: 'SinTan1729'
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

6
.gitignore vendored
View file

@ -1,4 +1,4 @@
# Ignore build outputs # Ignore cargo build outputs
actix/target actix/target
# Ignore SQLite file # Ignore SQLite file
@ -7,7 +7,3 @@ urls.sqlite
# Ignore irrelevant dotfiles # Ignore irrelevant dotfiles
.vscode/ .vscode/
**/.directory **/.directory
.env
cookie*
.idea/
.DS_Store

View file

@ -1,30 +1,28 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> FROM rust:slim AS build
# SPDX-License-Identifier: MIT ENV TARGET x86_64-unknown-linux-musl
FROM lukemathwalker/cargo-chef:latest-rust-slim AS chef RUN apt-get update && apt-get install -y musl-tools
RUN rustup target add "$TARGET"
RUN cargo install cargo-build-deps
RUN cargo new --bin chhoto-url
WORKDIR /chhoto-url WORKDIR /chhoto-url
FROM chef AS planner RUN rustup target add x86_64-unknown-linux-musl
COPY ./actix/Cargo.toml ./actix/Cargo.lock ./
COPY ./actix/Cargo.toml .
COPY ./actix/Cargo.lock .
RUN cargo build-deps --release --target=x86_64-unknown-linux-musl
COPY ./actix/src ./src COPY ./actix/src ./src
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder RUN cargo build --release --locked --target "$TARGET"
ARG target=x86_64-unknown-linux-musl
RUN apt-get update && apt-get install -y musl-tools
RUN rustup target add $target
COPY --from=planner /chhoto-url/recipe.json recipe.json
# Build dependencies - this is the caching Docker layer
RUN cargo chef cook --release --target=$target --recipe-path recipe.json
COPY ./actix/Cargo.toml ./actix/Cargo.lock ./
COPY ./actix/src ./src
# Build application
RUN cargo build --release --target=$target --locked --bin chhoto-url
RUN cp /chhoto-url/target/$target/release/chhoto-url /chhoto-url/release
FROM scratch FROM scratch
COPY --from=builder /chhoto-url/release /chhoto-url
COPY ./resources /resources COPY --from=build /chhoto-url/target/x86_64-unknown-linux-musl/release/chhoto-url /chhoto-url
ENTRYPOINT ["/chhoto-url"] COPY ./actix/resources /resources
CMD ["/chhoto-url"]

View file

@ -1,18 +0,0 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
# SPDX-License-Identifier: MIT
FROM scratch AS builder-amd64
COPY ./actix/target/x86_64-unknown-linux-musl/release/chhoto-url /chhoto-url
FROM scratch AS builder-arm64
COPY ./actix/target/aarch64-unknown-linux-musl/release/chhoto-url /chhoto-url
FROM scratch AS builder-arm
COPY ./actix/target/armv7-unknown-linux-musleabihf/release/chhoto-url /chhoto-url
ARG TARGETARCH
FROM builder-$TARGETARCH
COPY ./resources /resources
ENTRYPOINT ["/chhoto-url"]

View file

@ -1,52 +0,0 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
# SPDX-License-Identifier: MIT
# .env file has the variables $DOCKER_USERNAME and $PASSWORD defined
include .env
setup:
cargo install cross
rustup target add x86_64-unknown-linux-musl
docker buildx create --use --platform=linux/arm64,linux/amd64 --name multi-platform-builder
docker buildx inspect --bootstrap
build-dev:
cargo build --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
docker-local: build-dev
docker build --tag chhoto-url --build-arg TARGETARCH=amd64 -f Dockerfile.multiarch .
docker-stop:
docker ps -q --filter "name=chhoto-url" | xargs -r docker stop
docker ps -aq --filter "name=chhoto-url" | xargs -r docker rm
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}" -e disable_frontend="${DISABLE_FRONTEND}"\
-d chhoto-url
docker logs chhoto-url -f
docker-dev: build-dev
docker build --push --tag ${DOCKER_USERNAME}/chhoto-url:dev --build-arg TARGETARCH=amd64 -f Dockerfile.multiarch .
build-release:
cross build --release --locked --manifest-path=actix/Cargo.toml --target aarch64-unknown-linux-musl
cross build --release --locked --manifest-path=actix/Cargo.toml --target armv7-unknown-linux-musleabihf
cross build --release --locked --manifest-path=actix/Cargo.toml --target x86_64-unknown-linux-musl
V_PATCH := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)"$$/\1/p')
V_MINOR := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)\..+"$$/\1/p')
V_MAJOR := $(shell cat actix/Cargo.toml | sed -rn 's/^version = "(.+)\..+\..+"$$/\1/p')
docker-release: build-release
docker buildx build --push --tag ${DOCKER_USERNAME}/chhoto-url:${V_MAJOR} --tag ${DOCKER_USERNAME}/chhoto-url:${V_MINOR} \
--tag ${DOCKER_USERNAME}/chhoto-url:${V_PATCH} --tag ${DOCKER_USERNAME}/chhoto-url:latest \
--platform linux/amd64,linux/arm64,linux/arm/v7 -f Dockerfile.multiarch .
clean:
docker ps -q --filter "name=chhoto-url" | xargs -r docker stop
docker ps -aq --filter "name=chhoto-url" | xargs -r docker rm
cargo clean --manifest-path=actix/Cargo.toml
.PHONY: build-dev docker-local docker-stop build-release

208
README.md
View file

@ -1,54 +1,36 @@
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> --> [![docker-pulls](https://img.shields.io/docker/pulls/sintan1729/chhoto-url)](https://hub.docker.com/r/sintan1729/chhoto-url)
<!-- SPDX-License-Identifier: MIT --> [![maintainer](https://img.shields.io/badge/maintainer-SinTan1729-blue)](https://github.com/SinTan1729)
![commit-since-latest-release](https://img.shields.io/github/commits-since/SinTan1729/chhoto-url/latest?sort=semver&label=commits%20since%20latest%20release)
[![docker-pulls-badge](https://img.shields.io/docker/pulls/sintan1729/chhoto-url)](https://hub.docker.com/r/sintan1729/chhoto-url) # ![Logo](actix/resources/assets/favicon-32.png) <span style="font-size:42px">Chhoto URL</span>
[![maintainer-badge](https://img.shields.io/badge/maintainer-SinTan1729-blue)](https://github.com/SinTan1729)
[![latest-release-badge](https://img.shields.io/github/v/release/SinTan1729/chhoto-url?label=latest%20release)](https://github.com/SinTan1729/chhoto-url/releases/latest)
![docker-image-size-badge](https://img.shields.io/docker/image-size/sintan1729/chhoto-url)
![commit-since-latest-release-badge](https://img.shields.io/github/commits-since/SinTan1729/chhoto-url/latest?sort=semver&label=commits%20since%20latest%20release)
[![license-badge](https://img.shields.io/github/license/SinTan1729/chhoto-url)](https://spdx.org/licenses/MIT.html)
# ![Logo](resources/assets/favicon-32.png) <span style="font-size:42px">Chhoto URL</span>
# What is it? # What is it?
A simple selfhosted URL shortener with no unnecessary features. Simplicity A simple selfhosted URL shortener with no unnecessary features.
and speed are the main foci of this project. The docker image is ~6 MB (compressed),
and it uses <5 MB of RAM under regular use.
Don't worry if you see no activity for a long time. I consider this project Don't worry if you see no activity for a long time. I consider this project
to be complete, not dead. I'm unlikely to add any new features, but I will try to be complete, not dead. I'm unlikely to add any new features, but I will try
and fix every bug you report. I will also try to keep it updated in terms of and fix every bug you report.
security vulnerabilities.
If you feel like a feature is missing, please let me know by creating an issue If you feel like a feature is missing, please let me know by creating an issue
using the "feature request" template. using the "feature request" template.
## But why another URL shortener? ## But why another URL shortener?
Most URL shorteners are either bloated with unnecessary features, or are a pain to set up. I've looked at a couple popular URL shorteners, however they either have
Even fewer are written with simplicity and lightness in mind. When I saw the `simply-shorten` unnecessary features, or they didn't have all the features I wanted.
project (linked below), I really liked the idea but thought that it missed some features. Also,
I didn't like the fact that a simple app like this had a ~200 MB docker image (mostly due to the
included java runtime). So, I decided to rewrite it in Rust and add some features to it that I
thought were essential (e.g. hit counting).
## What does the name mean? ## What does the name mean?
Chhoto (ছোট, [IPA](https://en.wikipedia.org/wiki/Help:IPA/Bengali): /tʃʰoʈo/) is the Bangla word Chhoto (ছোট) is the Bangla word for small. (IPA: /tʃʰoʈo/)
for small. URL means, well... URL. So the name simply means Small URL.
# Features # Features
- Shortens URLs of any length to a randomly generated link. - Shortens URLs of any length to a fixed length, randomly generated string.
- (Optional) Allows you to specify the shortened URL instead of the generated - (Optional) Allows you to specify the shortened URL instead of the generated
one. (It's surprisingly missing in a surprising number of alternatives.) one (Missing in a surprising number of alternatives).
- Opening the shortened URL in your browser will instantly redirect you - Opening the fixed length URL in your browser will instantly redirect you
to the correct long URL. (So no stupid redirecting pages.) to the correct long URL (you'd think that's a standard feature, but
- Super lightweight and snappy. (The docker image is only ~6MB and RAM uasge apparently it's not).
stays under 5MB under normal use.) - Provides a simple API for adding new short links.
- Counts number of hits for each short link in a privacy respecting way - Counts number of hits for each short link in a privacy respecting way
i.e. only the hit is recorded, and nothing else. i.e. only the hit is recorded, and nothing else.
- 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. It's also
possible to completely disable the frontend.
- Allows setting the URL of your website, in case you want to conveniently - Allows setting the URL of your website, in case you want to conveniently
generate short links locally. generate short links locally.
- Links are stored in an SQLite database. - Links are stored in an SQLite database.
@ -57,7 +39,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/) written in plain HTML and vanilla JS, using [Pure CSS](https://purecss.io/)
for styling. for styling.
- Uses very basic authentication using a provided password. It's not encrypted in transport. - Uses very basic authentication using a provided password. It's not encrypted in transport.
I recommend using a reverse proxy such as [caddy](https://caddyserver.com/) to I recommend using something like [Nginx Proxy Manager](https://nginxproxymanager.com/) to
encrypt the connection by SSL. encrypt the connection by SSL.
# Bloat that will not be implemented # Bloat that will not be implemented
@ -72,11 +54,8 @@ not needed here.
- Paywalls or messages begging for donations. If you want to support me (for - Paywalls or messages begging for donations. If you want to support me (for
whatever reason), you can message me through GitHub issues. whatever reason), you can message me through GitHub issues.
# Screenshots # Screenshot
<p align="middle"> ![Screenshot](screenshot.png)
<img src="screenshot-desktop.webp" height="250" alt="desktop screenshot" />
<img src="screenshot-mobile.webp" height="250" alt="mobile screenshot" />
</p>
# Usage # Usage
## Using `docker compose` (Recommended method) ## Using `docker compose` (Recommended method)
@ -89,20 +68,37 @@ docker compose up -d
If you're using a custom location for the `db_url`, make sure to make that file If you're using a custom location for the `db_url`, make sure to make that file
before running the docker image, as otherwise a directory will be created in its before running the docker image, as otherwise a directory will be created in its
place, resulting in possibly unwanted behavior. place, resulting in possibly unwanted behavior.
## Building from source
Clone this repository
```
git clone https://github.com/SinTan1729/chhoto-url
```
## Building and running with docker ### 2. Set environment variables
```bash
# Required for authentication
export password=<api password>
# Sets where the database exists. Can be local or remote (optional)
export db_url=<url> # Default: './urls.sqlite'
# Sets the url of website, so that it displays that even when accessed
# locally (optional, defaults to hostname you're accessing it on)
export site_url=<url>
```
### 3. Build and run it
```
cd actix
cargo run
```
You can optionally set the port the server listens on by appending `--port=[port]`.
### 4. Navigate to `http://localhost:4567` in your browser, add links as you wish.
## Running with docker
### `docker run` method ### `docker run` method
0. (Only if you really want to) Build the image for the default `x86_64-unknown-linux-musl` target: 0. (Only if you really want to) Build the image
``` ```
docker build . -t chhoto-url docker build . -t chhoto-url:latest
``` ```
For building on `arm64` or `arm/v7`, use the following:
```
docker build . -t chhoto-url --build-arg target=<desired-target>
```
Make sure that the desired target is a `musl` one, since the docker image is built from `scratch`.
For cross-compilation, take a look at the `Makefile`. It builds and pushes for `linux/amd64`, `linux/aarch64`
and `linux/arm/v7` architectures. For any other architectures, open a discussion, and I'll try to help you out.
1. Run the image 1. Run the image
``` ```
docker run -p 4567:4567 docker run -p 4567:4567
@ -128,113 +124,9 @@ docker run -p 4567:4567 \
-e site_url="https://www.example.com" \ -e site_url="https://www.example.com" \
-d chhoto-url:latest -d chhoto-url:latest
``` ```
1.c Further, set an API key to activate JSON result mode (optional)
``` You can also set the redirect method to Permanent 308 (default) or Temporary 307 by setting
docker run -p 4567:4567 \ the `redirect_method` variable to `TEMPORARY` or `PERMANENT` (it's matched exactly).
-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
default, the auto-generated links are adjective-name pairs. You can use UIDs by setting
the `slug_style` variable to `UID`. You can also set the length of those slug by setting
the `slug_length` variable. It defaults to 8, and a minimum of 4 is supported.
To enable public mode, set `public_mode` to `Enable`. With this, anyone will be able to add
links. Listing existing links or deleting links will need admin access using the password. To
completely disable the frontend, set `disable_frontend` to `True`.
By default, the server sends no Cache-Control headers. You can set custom `cache_control_header`
to send your desired headers. It must be a comma separated list of valid
[RFC 7234 §5.2](https://datatracker.ietf.org/doc/html/rfc7234#section-5.2) headers. For example,
you can set it to `no-cache, private` to disable caching. It might help during testing if
served through a proxy.
## Deploying in your Kubernetes cluster with Helm
The helm values are very sparse to keep it simple. If you need more values to be variable, feel free to adjust.
The PVC allocates 100Mi and the PV is using a host path volume.
The helm chart assumes you have [cert manager](https://github.com/jetstack/cert-manager) deployed to have TLS certificates managed easily in your cluster. Feel free to remove the issuer and adjust the ingress if you're on AWS with EKS for example. To install cert-manager, I recommend using the ["kubectl apply" way](https://cert-manager.io/docs/installation/kubectl/) to install cert-manager.
To get started, `cp helm-chart/values.yaml helm-chart/my-values.yaml` and adjust `password`, `fqdn` and `letsencryptmail` in your new `my-values.yaml`, then just run
``` bash
cd helm-chart
helm upgrade --install chhoto-url . -n chhoto-url --create-namespace -f my-values.yaml
```
## Instructions for CLI usage
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.
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
```
You should receive "Correct password!" if the provided password was correct. For any subsequent
request, please add `-b cookie.txt` to provide authentication.
To add a link, do
```bash
curl -X POST -d '{"shortlink":"<shortlink>", "longlink":"<longlink>"}' http://localhost:4567/api/new
```
Send an empty `<shortlink>` if you want it to be auto-generated. The server will reply with the generated shortlink.
To get a list of all the currently available links as `json`, do
```bash
curl http://localhost:4567/api/all
```
To delete a link, do
```bash
curl -X DELETE http://localhost:4567/api/del/<shortlink>
```
The server will send a confirmation.
## Disable authentication ## Disable authentication
If you do not define a password environment variable when starting the docker image, authentication If you do not define a password environment variable when starting the docker image, authentication
@ -246,8 +138,6 @@ pointing to illegal content. Since there are no logs, it's impossible to prove
that those links aren't created by you. that those links aren't created by you.
## Notes ## Notes
- It started as a fork of [`simply-shorten`](https://gitlab.com/draganczukp/simply-shorten). - It started as a fork of [this project](https://gitlab.com/draganczukp/chhoto-url).
- 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 - 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). version of [this list used by docker](https://github.com/moby/moby/blob/master/pkg/namesgenerator/names-generator.go).

1449
actix/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,6 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
# SPDX-License-Identifier: MIT
[package] [package]
name = "chhoto-url" name = "chhoto-url"
version = "5.6.3" version = "5.0.0"
edition = "2021" edition = "2021"
authors = ["Sayantan Santra <sayantan[dot]santra689[at]gmail[dot]com"] authors = ["Sayantan Santra <sayantan[dot]santra689[at]gmail[dot]com"]
license = "mit" license = "mit"
@ -29,12 +26,8 @@ categories = ["web-programming"]
[dependencies] [dependencies]
actix-web = "4.5.1" actix-web = "4.5.1"
actix-files = "0.6.5" actix-files = "0.6.5"
rusqlite = { version = "0.34.0", features = ["bundled"] } rusqlite = { version = "0.30.0", features = ["bundled"] }
regex = "1.10.3" regex = "1.10.3"
rand = "0.9.0" rand = "0.8.5"
passwords = "3.1.16" actix-session = { version = "0.9.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"
serde_json = "1.0.115"
serde = { version = "1.0.197", features = [ "derive" ] }

View file

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View file

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,27 +1,24 @@
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
<!-- SPDX-License-Identifier: MIT -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<meta name="viewport" <meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" /> content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge" /> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Chhoto URL</title> <title>Chhoto URL</title>
<meta name="description" content="A simple selfhosted URL shortener with no unnecessary features." /> <meta name="description" content="A simple selfhosted URL shortener with no unnecessary features.">
<meta name="keywords" content="url shortener, link shortener, self hosted, open source" /> <meta name="keywords" content="url shortener, link shortener, self hosted, open source">
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" sizes="any" /> <link rel="icon" type="image/x-icon" href="assets/favicon.ico" sizes="any">
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<link rel="icon" type="image/png" href="assets/favicon-32.png" sizes="32x32" /> <link rel="icon" type="image/png" href="assets/favicon-32.png" sizes="32x32">
<link rel="icon" type="image/png" href="assets/favicon-196.png" sizes="196x196" /> <link rel="icon" type="image/png" href="assets/favicon-196.png" sizes="196x196">
<script src="static/script.js"></script> <script src="static/script.js"></script>
<link rel="stylesheet" href="https://unpkg.com/purecss@1.0.1/build/pure-min.css" <link rel="stylesheet" href="https://unpkg.com/purecss@1.0.1/build/pure-min.css"
integrity="sha384-oAOxQR6DkCoMliIh8yFnu25d7Eq/PHS21PClpwjOTeU2jRSq11vu66rf90/cZr47" crossorigin="anonymous" /> integrity="sha384-oAOxQR6DkCoMliIh8yFnu25d7Eq/PHS21PClpwjOTeU2jRSq11vu66rf90/cZr47" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" target="_blank" href="static/styles.css" /> <link rel="stylesheet" type="text/css" target="_blank" href="static/styles.css">
</head> </head>
<body> <body>
@ -38,24 +35,24 @@
<div class=" pure-control-group"> <div class=" pure-control-group">
<label for="shortUrl">Short URL (optional)</label> <label for="shortUrl">Short URL (optional)</label>
<input type="text" name="shortUrl" id="shortUrl" placeholder="Only a-z, 0-9, - and _ are allowed" <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" autocapitalize="off"/> pattern="[A-Za-z0-9_-]+" />
</div> </div>
<div class="pure-controls" id="controls"> <div class="pure-controls">
<button class="pure-button pure-button-primary">Shorten!</button> <button class="pure-button pure-button-primary">Shorten!</button>
<p id="alert-box">&nbsp;</p> <p id="alert-box">&nbsp;</p>
</div> </div>
</fieldset> </fieldset>
</form> </form>
<p id="loading-text">Loading links table...</p> <p name="loading-text">Loading links table...</p>
<table class="pure-table" id="table-box" hidden> <table class="pure-table">
<caption>Active links</caption> <caption>Active links</caption>
<br /> <br>
<thead> <thead>
<tr> <tr>
<td id="short-url-header">Short URL (click to copy)</td> <td id="short-url-header">Short URL<br>(click to copy)</td>
<td>Long URL</td> <td>Long URL</td>
<td name="hitsColumn">Hits</td> <td>Hits</td>
<td name="deleteBtn">&times;</td> <td name="deleteBtn">&times;</td>
</tr> </tr>
</thead> </thead>
@ -65,9 +62,7 @@
</table> </table>
</div> </div>
<div name="links-div"> <div name="github-link">
<a id="admin-button" href="javascript:getLogin()" hidden>login</a>
&nbsp;
<a id="version-number" href="https://github.com/SinTan1729/chhoto-url" target="_blank" rel="noopener noreferrer" <a id="version-number" href="https://github.com/SinTan1729/chhoto-url" target="_blank" rel="noopener noreferrer"
hidden>Source Code</a> hidden>Source Code</a>
<!-- The version number would be inserted here --> <!-- The version number would be inserted here -->
@ -77,11 +72,11 @@
<form class="pure-form" name="login-form"> <form class="pure-form" name="login-form">
<p>Please enter password to access this website</p> <p>Please enter password to access this website</p>
<input type="password" id="password" /> <input type="password" id="password" />
<button class="pure-button pure-button-primary" value="default">Log in</button> <button class="pure-button pure-button-primary" value="default">Submit</button>
<p id="wrong-pass" hidden>Wrong password!</p> <p id="wrong-pass">&nbsp;</p>
</form> </form>
</dialog> </dialog>
</body> </body>
</html> </html>

View file

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<title>Error 404</title>
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
</head>
<style>
#quote {
text-indent: 4em;
}
</style>
<body style="text-align: center;">
<h1>Error 404!</h1>
<div style="display: inline-block; text-align:left;">
<p>You step in the stream,</p>
<p>But the water has moved on.</p>
<p>The page is not here.</p>
<p id="quote"> — Cass Whittington</p>
</div>
</body>
</html>

View file

@ -0,0 +1,220 @@
const getSiteUrl = async () => await fetch("/api/siteurl")
.then(res => res.text())
.then(text => {
if (text == "unset") {
return window.location.host;
}
else {
return text;
}
});
const getVersion = async () => await fetch("/api/version")
.then(res => res.text())
.then(text => {
return text;
});
const refreshData = async () => {
let reply = await fetch("/api/all").then(res => res.text());
if (reply == "logged_out") {
console.log("logged_out");
document.getElementById("container").style.filter = "blur(2px)"
document.getElementById("login-dialog").showModal();
document.getElementById("password").focus();
} else {
data = reply
.split("\n")
.filter(line => line !== "")
.map(line => line.split(","))
.map(arr => ({
short: arr[0],
long: arr[1],
hits: arr[2]
}));
displayData(data);
}
};
const displayData = async (data) => {
let version = await getVersion();
link = document.getElementById("version-number")
link.innerText = "v" + version;
link.href = "https://github.com/SinTan1729/chhoto-url/releases/tag/" + version;
link.hidden = false;
let site = await getSiteUrl();
table_box = document.querySelector(".pure-table");
loading_text = document.getElementsByName("loading-text")[0];
if (data.length == 0) {
table_box.style.visibility = "hidden";
loading_text.style.display = "block";
loading_text.innerHTML = "No active links.";
}
else {
loading_text.style.display = "none";
const table = document.querySelector("#url-table");
if (!window.isSecureContext) {
const shortUrlHeader = document.getElementById("short-url-header");
shortUrlHeader.innerHTML = "Short URL<br>(right click and copy)";
}
table_box.style.visibility = "visible";
table.innerHTML = ''; // Clear
data.forEach(tr => table.appendChild(TR(tr, site)));
}
};
const showAlert = async (text, col) => {
document.getElementById("alert-box")?.remove();
const controls = document.querySelector(".pure-controls");
const alertBox = document.createElement("p");
alertBox.id = "alert-box";
alertBox.style.color = col;
alertBox.innerHTML = text;
controls.appendChild(alertBox);
};
const TR = (row, site) => {
const tr = document.createElement("tr");
const longTD = TD(A_LONG(row.long), "Long URL");
var shortTD = null;
if (window.isSecureContext) {
shortTD = TD(A_SHORT(row.short, site), "Short URL");
}
else {
shortTD = TD(A_SHORT_INSECURE(row.short, site), "Short URL");
}
hitsTD = TD(row.hits);
hitsTD.setAttribute("label", "Hits");
const btn = deleteButton(row.short);
tr.appendChild(shortTD);
tr.appendChild(longTD);
tr.appendChild(hitsTD);
tr.appendChild(btn);
return tr;
};
const copyShortUrl = async (link) => {
const site = await getSiteUrl();
try {
navigator.clipboard.writeText(`${site}/${link}`);
showAlert(`Short URL ${link} was copied to clipboard!`, "green");
} catch (e) {
console.log(e);
showAlert("Could not copy short URL to clipboard, please do it manually.", "red");
}
};
const addProtocol = (input) => {
var url = input.value.trim();
if (url != "" && !~url.indexOf("://") && !~url.indexOf("magnet:")) {
url = "https://" + url;
}
input.value = url;
return input
}
const A_LONG = (s) => `<a href='${s}'>${s}</a>`;
const A_SHORT = (s, t) => `<a href="javascript:copyShortUrl('${s}');">${s}</a>`;
const A_SHORT_INSECURE = (s, t) => `<a href="${t}/${s}">${s}</a>`;
const deleteButton = (shortUrl) => {
const td = document.createElement("td");
const btn = document.createElement("button");
btn.innerHTML = "&times;";
btn.onclick = e => {
e.preventDefault();
if (confirm("Do you want to delete the entry " + shortUrl + "?")) {
document.getElementById("alert-box")?.remove();
showAlert("&nbsp;", "black");
fetch(`/api/del/${shortUrl}`, {
method: "DELETE"
}).then(_ => refreshData());
}
};
td.setAttribute("name", "deleteBtn");
td.setAttribute("label", "Delete");
td.appendChild(btn);
return td;
};
const TD = (s, u) => {
const td = document.createElement("td");
const div = document.createElement("div");
div.innerHTML = s;
td.appendChild(div);
td.setAttribute("label", u);
return td;
};
const submitForm = () => {
const form = document.forms.namedItem("new-url-form");
const longUrl = form.elements["longUrl"];
const shortUrl = form.elements["shortUrl"];
const url = `/api/new`;
fetch(url, {
method: "POST",
body: `${longUrl.value};${shortUrl.value}`
})
.then(res => {
if (!res.ok) {
showAlert("Short URL is not valid or it's already in use!", "red");
return "error";
}
else {
return res.text();
}
}).then(text => {
if (text != "error") {
copyShortUrl(text);
longUrl.value = "";
shortUrl.value = "";
refreshData();
}
});
};
const submitLogin = () => {
const password = document.getElementById("password");
fetch("/api/login", {
method: "POST",
body: password.value
}).then(res => {
if (res.ok) {
document.getElementById("container").style.filter = "blur(0px)"
document.getElementById("login-dialog").remove();
refreshData();
} else {
const wrongPassBox = document.getElementById("wrong-pass");
wrongPassBox.innerHTML = "Wrong password!";
wrongPassBox.style.color = "red";
password.focus();
}
})
}
(async () => {
await refreshData();
const form = document.forms.namedItem("new-url-form");
form.onsubmit = e => {
e.preventDefault();
submitForm();
}
const login_form = document.forms.namedItem("login-form");
login_form.onsubmit = e => {
e.preventDefault();
submitLogin();
}
})();

View file

@ -0,0 +1,100 @@
.container {
max-width: 950px;
margin: 20px auto auto;
}
table {
width: 100%;
}
table tr td div {
max-height: 75px;
line-height: 25px;
word-wrap: break-word;
max-width: 575px;
overflow: auto;
}
td[name="deleteBtn"] {
text-align: center;
}
td[name="deleteBtn"] button {
border-radius: 50%;
border-style: solid;
cursor: pointer;
background-color: transparent;
}
input {
width: 65%;
}
form input[name="shortUrl"] {
text-transform: lowercase;
}
form input[name="shortUrl"]::placeholder {
text-transform: none;
}
div[name="github-link"] {
position: absolute;
right: 0.5%;
top: 0.5%;
}
.pure-table {
visibility: hidden;
}
.pure-table caption {
font-size: 22px;
text-align: left;
font-style: normal;
}
#logo {
font-size: 32px;
}
#password {
width: 100%;
margin-bottom: 10px;
}
dialog form {
text-align: center;
}
/* Settings for mobile devices */
@media (pointer:none),
(pointer:coarse) {
table tr {
border-bottom: 1px solid #999;
}
table thead {
display: none;
}
table td {
display: flex;
}
table td::before {
content: attr(label);
font-weight: bold;
width: 120px;
min-width: 120px;
text-align: left;
}
table tr td div {
width: 63vw
}
.pure-table caption {
padding-top: 0px;
}
}

View file

@ -1,75 +1,16 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
use actix_session::Session; use actix_session::Session;
use actix_web::HttpRequest;
use std::{env, time::SystemTime}; use std::{env, time::SystemTime};
// API key generation and scoring
use passwords::{analyzer, scorer, PasswordGenerator};
// Validate API key
pub fn validate_key(key: String) -> bool {
if let Ok(api_key) = env::var("api_key") {
if api_key != key {
eprintln!("Incorrect API key was provided when connecting to Chhoto URL.");
false
} else {
eprintln!("Server accessed with API key.");
true
}
} else {
eprintln!("API was accessed with API key validation but no API key was specified. Set the 'api_key' environment variable.");
false
}
}
// Generate an API key if the user doesn't specify a secure key
// Called in main.rs
pub fn gen_key() -> String {
let key = PasswordGenerator {
length: 128,
numbers: true,
lowercase_letters: true,
uppercase_letters: true,
symbols: false,
spaces: false,
exclude_similar_characters: false,
strict: true,
};
key.generate_one().unwrap()
}
// Check if the API key header exists
pub fn api_header(req: &HttpRequest) -> Option<&str> {
req.headers().get("X-API-Key")?.to_str().ok()
}
// Determine whether the inputted API key is sufficiently secure
pub fn is_key_secure() -> bool {
let score = scorer::score(&analyzer::analyze(env::var("api_key").unwrap()));
score >= 90.0
}
// Validate a given password
pub fn validate(session: Session) -> bool { pub fn validate(session: Session) -> bool {
// If there's no password provided, just return true // If there's no password provided, just return true
if env::var("password") if env::var("password").is_err() {
.ok()
.filter(|s| !s.trim().is_empty())
.is_none()
{
return true; return true;
} }
if let Ok(token) = session.get::<String>("chhoto-url-auth") { let token = session.get::<String>("session-token");
check(token) token.is_ok() && check(token.unwrap())
} else {
false
}
} }
// Check a token cryptographically
fn check(token: Option<String>) -> bool { fn check(token: Option<String>) -> bool {
if let Some(token_body) = token { if let Some(token_body) = token {
let token_parts: Vec<&str> = token_body.split(';').collect(); let token_parts: Vec<&str> = token_body.split(';').collect();
@ -82,16 +23,15 @@ fn check(token: Option<String>) -> bool {
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.expect("Time went backwards!") .expect("Time went backwards!")
.as_secs(); .as_secs();
token_text == "chhoto-url-auth" && time_now < token_time + 1209600 // There are 1209600 seconds in 14 days token_text == "session-token" && time_now < token_time + 1209600 // There are 1209600 seconds in 14 days
} }
} else { } else {
false false
} }
} }
// Generate a new cryptographic token
pub fn gen_token() -> String { pub fn gen_token() -> String {
let token_text = String::from("chhoto-url-auth"); let token_text = String::from("session-token");
let time = SystemTime::now() let time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.expect("Time went backwards!") .expect("Time went backwards!")

View file

@ -1,72 +1,46 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize;
// Struct for encoding a DB row pub fn find_url(shortlink: &str, db: &Connection) -> String {
#[derive(Serialize)] let mut statement = db
pub struct DBRow { .prepare_cached("SELECT long_url FROM urls WHERE short_url = ?1")
shortlink: String, .unwrap();
longlink: String,
hits: i64, let links = statement
.query_map([shortlink], |row| row.get("long_url"))
.unwrap();
let mut longlink = String::new();
for link in links {
longlink = link.unwrap();
}
longlink
} }
// Find a single URL pub fn getall(db: &Connection) -> Vec<String> {
pub fn find_url(shortlink: &str, db: &Connection, needhits: bool) -> (Option<String>, Option<i64>) { let mut statement = db.prepare_cached("SELECT * FROM urls").unwrap();
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(query)
.expect("Error preparing SQL statement for find_url.");
let longlink = statement let mut data = statement.query([]).unwrap();
.query_row([shortlink], |row| row.get("long_url"))
.ok();
let hits = statement.query_row([shortlink], |row| row.get("hits")).ok();
(longlink, hits)
}
// Get all URLs in DB let mut links: Vec<String> = Vec::new();
pub fn getall(db: &Connection) -> Vec<DBRow> { while let Some(row) = data.next().unwrap() {
let mut statement = db let short_url: String = row.get("short_url").unwrap();
.prepare_cached("SELECT * FROM urls ORDER BY id ASC") let long_url: String = row.get("long_url").unwrap();
.expect("Error preparing SQL statement for getall."); let hits: i64 = row.get("hits").unwrap();
links.push(format!("{short_url},{long_url},{hits}"));
let mut data = statement
.query([])
.expect("Error executing query for getall.");
let mut links: Vec<DBRow> = Vec::new();
while let Some(row) = data.next().expect("Error reading fetched rows.") {
let row_struct = DBRow {
shortlink: row
.get("short_url")
.expect("Error reading shortlink from row."),
longlink: row
.get("long_url")
.expect("Error reading shortlink from row."),
hits: row.get("hits").expect("Error reading shortlink from row."),
};
links.push(row_struct);
} }
links links
} }
// Add a hit when site is visited
pub fn add_hit(shortlink: &str, db: &Connection) { pub fn add_hit(shortlink: &str, db: &Connection) {
db.execute( db.execute(
"UPDATE urls SET hits = hits + 1 WHERE short_url = ?1", "UPDATE urls SET hits = hits + 1 WHERE short_url = ?1",
[shortlink], [shortlink],
) )
.expect("Error updating hit count."); .unwrap();
} }
// Insert a new link
pub fn add_link(shortlink: String, longlink: String, db: &Connection) -> bool { pub fn add_link(shortlink: String, longlink: String, db: &Connection) -> bool {
db.execute( db.execute(
"INSERT INTO urls (long_url, short_url, hits) VALUES (?1, ?2, ?3)", "INSERT INTO urls (long_url, short_url, hits) VALUES (?1, ?2, ?3)",
@ -75,16 +49,11 @@ pub fn add_link(shortlink: String, longlink: String, db: &Connection) -> bool {
.is_ok() .is_ok()
} }
// Delete and existing link pub fn delete_link(shortlink: String, db: &Connection) {
pub fn delete_link(shortlink: String, db: &Connection) -> bool { db.execute("DELETE FROM urls WHERE short_url = ?1", [shortlink])
if let Ok(delta) = db.execute("DELETE FROM urls WHERE short_url = ?1", [shortlink]) { .unwrap();
delta > 0
} else {
false
}
} }
// Open the DB, and create schema if missing
pub fn open_db(path: String) -> Connection { pub fn open_db(path: String) -> Connection {
let db = Connection::open(path).expect("Unable to open database!"); let db = Connection::open(path).expect("Unable to open database!");
// Create table if it doesn't exist // Create table if it doesn't exist
@ -97,7 +66,6 @@ pub fn open_db(path: String) -> Connection {
)", )",
[], [],
) )
.expect("Unable to initialize empty database."); .unwrap();
db db
} }

View file

@ -1,16 +1,17 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> use actix_files::{Files, NamedFile};
// SPDX-License-Identifier: MIT use actix_session::{storage::CookieSessionStore, Session, SessionMiddleware};
use actix_web::{
use actix_files::Files; cookie::Key,
use actix_session::{storage::CookieSessionStore, SessionMiddleware}; delete, get,
use actix_web::{cookie::Key, middleware, web, App, HttpServer}; http::StatusCode,
middleware, post,
web::{self, Redirect},
App, HttpResponse, HttpServer, Responder,
};
use rusqlite::Connection; use rusqlite::Connection;
use std::{env, io::Result}; use std::env;
// Import modules
mod auth; mod auth;
mod database; mod database;
mod services;
mod utils; mod utils;
// This struct represents state // This struct represents state
@ -18,107 +19,144 @@ struct AppState {
db: Connection, db: Connection,
} }
// Store the version number
const VERSION: &str = env!("CARGO_PKG_VERSION");
// Define the routes
// Add new links
#[post("/api/new")]
async fn add_link(req: String, data: web::Data<AppState>, session: Session) -> HttpResponse {
if auth::validate(session) {
let out = utils::add_link(req, &data.db);
if out.0 {
HttpResponse::Ok().body(out.1)
} else {
HttpResponse::BadRequest().body(out.1)
}
} else {
HttpResponse::Forbidden().body("logged_out")
}
}
// Return all active links
#[get("/api/all")]
async fn getall(data: web::Data<AppState>, session: Session) -> HttpResponse {
if auth::validate(session) {
HttpResponse::Ok().body(utils::getall(&data.db))
} else {
HttpResponse::Forbidden().body("logged_out")
}
}
// Get the site URL
#[get("/api/siteurl")]
async fn siteurl(session: Session) -> HttpResponse {
if auth::validate(session) {
let site_url = env::var("site_url").unwrap_or(String::from("unset"));
HttpResponse::Ok().body(site_url)
} else {
HttpResponse::Forbidden().body("logged_out")
}
}
// Get the version number
#[get("/api/version")]
async fn version() -> HttpResponse {
HttpResponse::Ok().body(VERSION)
}
// 404 error page
async fn error404() -> impl Responder {
NamedFile::open_async("./resources/static/404.html")
.await
.customize()
.with_status(StatusCode::NOT_FOUND)
}
// Handle a given shortlink
#[get("/{shortlink}")]
async fn link_handler(shortlink: web::Path<String>, data: web::Data<AppState>) -> impl Responder {
let shortlink_str = shortlink.to_string();
let longlink = utils::get_longurl(shortlink_str, &data.db);
if longlink.is_empty() {
Redirect::to("/err/404")
} else {
let redirect_method = env::var("redirect_method").unwrap_or(String::from("PERMANENT"));
database::add_hit(shortlink.as_str(), &data.db);
if redirect_method == "TEMPORARY" {
Redirect::to(longlink)
} else {
// Defaults to permanent redirection
Redirect::to(longlink).permanent()
}
}
}
// Handle login
#[post("/api/login")]
async fn login(req: String, session: Session) -> HttpResponse {
if req == env::var("password").unwrap_or(req.clone()) {
// If no password was provided, match any password
session.insert("session-token", auth::gen_token()).unwrap();
HttpResponse::Ok().body("Correct password!")
} else {
eprintln!("Failed login attempt!");
HttpResponse::Forbidden().body("Wrong password!")
}
}
// Delete a given shortlink
#[delete("/api/del/{shortlink}")]
async fn delete_link(
shortlink: web::Path<String>,
data: web::Data<AppState>,
session: Session,
) -> HttpResponse {
if auth::validate(session) {
database::delete_link(shortlink.to_string(), &data.db);
HttpResponse::Ok().body("")
} else {
HttpResponse::Forbidden().body("Wrong password!")
}
}
#[actix_web::main] #[actix_web::main]
async fn main() -> Result<()> { async fn main() -> std::io::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("warn")); env_logger::init_from_env(env_logger::Env::new().default_filter_or("warn"));
// 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").unwrap_or(String::from("/urls.sqlite"));
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") let port = env::var("port")
.unwrap_or(String::from("4567")) .unwrap_or(String::from("4567"))
.parse::<u16>() .parse::<u16>()
.expect("Supplied port is not an integer"); .expect("Supplied port is not an integer");
let cache_control_header = env::var("cache_control_header")
.ok()
.filter(|s| !s.trim().is_empty());
let disable_frontend = env::var("disable_frontend").is_ok_and(|s| s.trim() == "True");
// 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 // Actually start the server
HttpServer::new(move || { HttpServer::new(move || {
let mut app = App::new() App::new()
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default())
.wrap( .wrap(
SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone()) SessionMiddleware::builder(CookieSessionStore::default(), secret_key.clone())
.cookie_same_site(actix_web::cookie::SameSite::Strict)
.cookie_secure(false) .cookie_secure(false)
.build(), .build(),
) )
// Maintain a single instance of database throughout // Maintain a single instance of database throughout
.app_data(web::Data::new(AppState { .app_data(web::Data::new(AppState {
db: database::open_db(db_location.clone()), db: database::open_db(env::var("db_url").unwrap_or(db_location.clone())),
})) }))
.wrap(if let Some(header) = &cache_control_header { .wrap(middleware::Logger::default())
middleware::DefaultHeaders::new().add(("Cache-Control", header.to_owned())) .wrap(middleware::Compress::default())
} else { .service(link_handler)
middleware::DefaultHeaders::new() .service(getall)
}) .service(siteurl)
.service(services::link_handler) .service(version)
.service(services::getall) .service(add_link)
.service(services::siteurl) .service(delete_link)
.service(services::version) .service(login)
.service(services::add_link) .service(Files::new("/", "./resources/").index_file("index.html"))
.service(services::delete_link) .default_service(web::get().to(error404))
.service(services::login)
.service(services::logout)
.service(services::expand);
if !disable_frontend {
app = app.service(Files::new("/", "./resources/").index_file("index.html"));
}
app.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))? .bind(("0.0.0.0", port))?
.run() .run()
.await .await

View file

@ -1,322 +0,0 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
use actix_files::NamedFile;
use actix_session::Session;
use actix_web::{
delete, get,
http::StatusCode,
post,
web::{self, Redirect},
Either, HttpRequest, HttpResponse, Responder,
};
use std::env;
// Serialize JSON data
use serde::Serialize;
use crate::auth;
use crate::database;
use crate::utils;
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,
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)
} else {
HttpResponse::Conflict().body(out.1)
}
} else {
HttpResponse::Unauthorized().body("Not logged in!")
}
}
// Return all active links
#[get("/api/all")]
pub async fn getall(
data: web::Data<AppState>,
session: Session,
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")) {
"Using public mode."
} else {
"Not logged in!"
};
HttpResponse::Unauthorized().body(body)
}
}
// 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 {
let site_url = env::var("site_url")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or(String::from("unset"));
HttpResponse::Ok().body(site_url)
}
// Get the version number
#[get("/api/version")]
pub async fn version() -> HttpResponse {
HttpResponse::Ok().body(VERSION)
}
// 404 error page
pub async fn error404() -> impl Responder {
NamedFile::open_async("./resources/static/404.html")
.await
.customize()
.with_status(StatusCode::NOT_FOUND)
}
// Handle a given shortlink
#[get("/{shortlink}")]
pub async fn link_handler(
shortlink: web::Path<String>,
data: web::Data<AppState>,
) -> impl Responder {
let shortlink_str = shortlink.to_string();
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" {
Either::Left(Redirect::to(longlink))
} else {
// Defaults to permanent redirection
Either::Left(Redirect::to(longlink).permanent())
}
} else {
Either::Right(
NamedFile::open_async("./resources/static/404.html")
.await
.customize()
.with_status(StatusCode::NOT_FOUND),
)
}
}
// Handle login
#[post("/api/login")]
pub async fn login(req: String, session: Session) -> HttpResponse {
// 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!")
}
}
// 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() {
HttpResponse::Ok().body("Logged out!")
} else {
HttpResponse::Unauthorized().body("You don't seem to be logged in.")
}
}
// Delete a given shortlink
#[delete("/api/del/{shortlink}")]
pub async fn delete_link(
shortlink: web::Path<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, 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 {
HttpResponse::NotFound().body("Not found!")
}
} else {
HttpResponse::Unauthorized().body("Not logged in!")
}
}

View file

@ -1,154 +1,51 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> use crate::database;
// SPDX-License-Identifier: MIT use rand::seq::SliceRandom;
use crate::{auth, database};
use actix_web::HttpRequest;
use nanoid::nanoid;
use rand::seq::IndexedRandom;
use regex::Regex; use regex::Regex;
use rusqlite::Connection; use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::env;
// Struct for reading link pairs sent during API call pub fn get_longurl(shortlink: String, db: &Connection) -> String {
#[derive(Deserialize)]
struct URLPair {
shortlink: String,
longlink: String,
}
// Define JSON struct for response
#[derive(Serialize)]
pub struct Response {
pub(crate) success: bool,
pub(crate) error: bool,
reason: String,
pass: bool,
}
// If the api_key environment variable exists
pub fn is_api_ok(http: HttpRequest) -> Response {
// If the api_key environment variable exists
if env::var("api_key").is_ok() {
// If the header exists
if let Some(header) = auth::api_header(&http) {
// If the header is correct
if auth::validate_key(header.to_string()) {
Response {
success: true,
error: false,
reason: "Correct API key".to_string(),
pass: false,
}
} else {
Response {
success: false,
error: true,
reason: "Incorrect API key".to_string(),
pass: false,
}
}
// The header may not exist when the user logs in through the web interface, so allow a request with no header.
// Further authentication checks will be conducted in services.rs
} else {
// Due to the implementation of this result in services.rs, this JSON object will not be outputted.
Response {
success: false,
error: false,
reason: "X-API-Key header was not found".to_string(),
pass: true,
}
}
} else {
// If the API key isn't set, but an API Key header is provided
if auth::api_header(&http).is_some() {
Response {
success: false,
error: true,
reason: "An API key was provided, but the 'api_key' environment variable is not configured in the Chhoto URL instance".to_string(),
pass: false
}
} else {
Response {
success: false,
error: false,
reason: "".to_string(),
pass: true,
}
}
}
}
// Request the DB for searching an URL
pub fn get_longurl(shortlink: String, db: &Connection, needhits: bool) -> (Option<String>, Option<i64>) {
if validate_link(&shortlink) { if validate_link(&shortlink) {
database::find_url(shortlink.as_str(), db, needhits) database::find_url(shortlink.as_str(), db)
} else { } else {
(None, None) String::new()
} }
} }
// Only have a-z, 0-9, - and _ as valid characters in a shortlink
fn validate_link(link: &str) -> bool { fn validate_link(link: &str) -> bool {
let re = Regex::new("^[a-z0-9-_]+$").expect("Regex generation failed."); let re = Regex::new("[a-z0-9-_]+").unwrap();
re.is_match(link) re.is_match(link)
} }
// Request the DB for all URLs
pub fn getall(db: &Connection) -> String { pub fn getall(db: &Connection) -> String {
let links = database::getall(db); let links = database::getall(db);
serde_json::to_string(&links).expect("Failure during creation of json from db.") links.join("\n")
} }
// Make checks and then request the DB to add a new URL entry
pub fn add_link(req: String, db: &Connection) -> (bool, String) { pub fn add_link(req: String, db: &Connection) -> (bool, String) {
let mut chunks: URLPair; let chunks: Vec<&str> = req.split(';').collect();
if let Ok(json) = serde_json::from_str(&req) { let longlink = String::from(chunks[0]);
chunks = json;
let mut shortlink;
if chunks.len() > 1 {
shortlink = chunks[1].to_string().to_lowercase();
if shortlink.is_empty() {
shortlink = random_name();
}
} else { } else {
// shorturl should always be supplied, even if empty shortlink = random_name();
return (false, String::from("Invalid request!"));
} }
let style = env::var("slug_style").unwrap_or(String::from("Pair")); if validate_link(shortlink.as_str()) && get_longurl(shortlink.clone(), db).is_empty() {
let mut len = env::var("slug_length")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(8);
if len < 4 {
len = 4;
}
if chunks.shortlink.is_empty() {
chunks.shortlink = gen_link(style, len);
}
if validate_link(chunks.shortlink.as_str())
&& get_longurl(chunks.shortlink.clone(), db, false).0.is_none()
{
( (
database::add_link(chunks.shortlink.clone(), chunks.longlink, db), database::add_link(shortlink.clone(), longlink, db),
chunks.shortlink, shortlink,
) )
} else { } else {
( (false, String::from("shortUrl not valid or already in use"))
false,
String::from("Short URL not valid or already in use!"),
)
} }
} }
// Check if link, and request DB to delete it if exists fn random_name() -> String {
pub fn delete_link(shortlink: String, db: &Connection) -> bool {
if validate_link(shortlink.as_str()) {
database::delete_link(shortlink, db)
} else {
false
}
}
// Generate a random link using either adjective-name pair (default) of a slug or a-z, 0-9
fn gen_link(style: String, len: usize) -> String {
#[rustfmt::skip] #[rustfmt::skip]
static ADJECTIVES: [&str; 108] = ["admiring", "adoring", "affectionate", "agitated", "amazing", "angry", "awesome", "beautiful", static ADJECTIVES: [&str; 108] = ["admiring", "adoring", "affectionate", "agitated", "amazing", "angry", "awesome", "beautiful",
"blissful", "bold", "boring", "brave", "busy", "charming", "clever", "compassionate", "competent", "condescending", "confident", "cool", "blissful", "bold", "boring", "brave", "busy", "charming", "clever", "compassionate", "competent", "condescending", "confident", "cool",
@ -180,21 +77,9 @@ fn gen_link(style: String, len: usize) -> String {
"taussig", "tesla", "tharp", "thompson", "torvalds", "tu", "turing", "varahamihira", "vaughan", "vaughn", "villani", "visvesvaraya", "volhard", "taussig", "tesla", "tharp", "thompson", "torvalds", "tu", "turing", "varahamihira", "vaughan", "vaughn", "villani", "visvesvaraya", "volhard",
"wescoff", "weierstrass", "wilbur", "wiles", "williams", "williamson", "wilson", "wing", "wozniak", "wright", "wu", "yalow", "yonath", "zhukovsky"]; "wescoff", "weierstrass", "wilbur", "wiles", "williams", "williamson", "wilson", "wing", "wozniak", "wright", "wu", "yalow", "yonath", "zhukovsky"];
#[rustfmt::skip] format!(
static CHARS: [char; 36] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', "{0}-{1}",
'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; ADJECTIVES.choose(&mut rand::thread_rng()).unwrap(),
NAMES.choose(&mut rand::thread_rng()).unwrap()
if style == "UID" { )
nanoid!(len, &CHARS)
} else {
format!(
"{0}-{1}",
ADJECTIVES
.choose(&mut rand::rng())
.expect("Error choosing random adjective."),
NAMES
.choose(&mut rand::rng())
.expect("Error choosing random name.")
)
}
} }

View file

@ -1,75 +1,35 @@
# SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
# SPDX-License-Identifier: MIT
services: services:
chhoto-url: chhoto-url:
image: sintan1729/chhoto-url:latest image: sintan1729/chhoto-url:latest
restart: unless-stopped restart: unless-stopped
container_name: chhoto-url 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: 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 - 4567:4567
environment: environment:
# Change if you want to mount the database somewhere else. # Change if you want to mount the database somewhere else
# In this case, you can get rid of the db volume below # In this case, you can get rid of the db volume below
# and instead do a mount manually by specifying the location. # and instead do a mount manually by specifying the location
# Make sure that you create an empty file with the correct name # - db_url=/urls.sqlite
# 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 this if your server URL is not "http://localhost" # Change it in case you want to set the website name
# This must not be surrounded by quotes. For example: # displayed in front of the shorturls, defaults to
# site_url="https://www.example.com" incorrect # the hostname you're accessing it from
# 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 # - site_url=https://www.example.com
# Change this if you are running Chhoto URL on a port which is not 4567. - password=$3CuReP4S$W0rD
# This is important to ensure Chhoto URL outputs the shortened link with the correct port.
# - port=4567
- password=TopSecretPass # Pass the redirect method, if needed TEMPORARY and PERMANENT
# are accepted values, defaults to PERMANENT
# 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 # - redirect_method=TEMPORARY
# By default, the auto-generated pairs are adjective-name pairs.
# If you want UIDs, please change slug_style to UID.
# Supported values for slug_style are Pair and UID.
# The length is 8 by default, and a minimum of 4 is allowed.
# - slug_style=Pair
# - slug_length=8
# In case you want to provide public access to adding links (and not
# delete, or listing), change the following option to Enable.
# - public_mode=Disable
# In case you want to completely disable the frontend, change the following
# to True.
# - disable_frontend=False
# By default, the server sends no Cache-Control headers. You can supply a
# comma separated list of valid header as per RFC 7234 §5.2 to send those
# headers instead.
# - cache_control_header=no-cache, private
volumes: volumes:
- db:/db - db:/urls.sqlite
networks:
- proxy
volumes: volumes:
db: db:
networks:
proxy:
external: true

View file

@ -1,24 +0,0 @@
apiVersion: v2
name: chhoto-url
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "1.16.0"

View file

@ -1,23 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: chhoto-url
annotations:
cert-manager.io/issuer: "letsencrypt"
acme.cert-manager.io/http01-edit-in-place: "true"
spec:
tls:
- hosts:
- {{ .Values.fqdn }}
secretName: my-tls
rules:
- host: {{ .Values.fqdn }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: chhoto-url
port:
number: 80

View file

@ -1,18 +0,0 @@
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: letsencrypt
spec:
acme:
# The ACME server URL
server: https://acme-v02.api.letsencrypt.org/directory
# Email address used for ACME registration
email: {{ .Values.letsencryptmail }}
# Name of a secret used to store the ACME account private key
privateKeySecretRef:
name: letsencrypt
# Enable the HTTP-01 challenge provider
solvers:
- http01:
ingress:
ingressClassName: nginx

View file

@ -1,13 +0,0 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: chhoto-pv
labels:
app: chhoto-url
spec:
capacity:
storage: 100Mi
accessModes:
- ReadWriteOnce
hostPath:
path: {{ .Values.persistence.hostPath.path }}

View file

@ -1,10 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: secret
type: Opaque
data:
password: {{ .Values.password }}
{{- if .Values.api_key }}
api_key: {{ .Values.api_key }}
{{- end }}

View file

@ -1,61 +0,0 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: chhoto-url
spec:
replicas: 1
selector:
matchLabels:
app: chhoto-url
template:
metadata:
labels:
app: chhoto-url
spec:
containers:
- name: chhoto-url
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: 4567
env:
- name: password
valueFrom:
secretKeyRef:
name: secret
key: password
{{- if .Values.api_key }}
- name: api_key
valueFrom:
secretKeyRef:
name: secret
key: api_key
{{- end }}
- name: db_url
value: /db/urls.sqlite
- name: site_url
value: "{{ .Values.protocol }}://{{ .Values.fqdn }}"
- name: redirect_method
value: {{ .Values.redirect_method }}
- name: slug_style
value: {{ .Values.slug_style }}
- name: slug_length
value: "{{ .Values.slug_length }}"
- name: public_mode
value: {{ .Values.public_mode }}
- name: disable_frontend
value: {{ .Values.disable_frontend }}
{{- if .Values.cache_control_header }}
- name: cache_control_header
value: {{ .Values.cache_control_header }}
{{- end }}
volumeMounts:
- name: data
mountPath: /db
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 100Mi

View file

@ -1,14 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: chhoto-url
labels:
app: chhoto-url
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 4567
protocol: TCP
selector:
app: chhoto-url

View file

@ -1,28 +0,0 @@
# Default values for chhoto-url.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
image:
repository: sintan1729/chhoto-url
pullPolicy: IfNotPresent
tag: "5.4.6"
# please use a better password in your values and base64 encode it
password: cGFzc3dvcmQ=
# if used, needs to be base64 encoded as well
# api_key: U0VDVVJFX0FQSV9LRVk=
persistence:
hostPath:
path: /mnt/data/chhoto-data
redirect_method: PERMANENT
slug_style: Pair
slug_length: 8
public_mode: Disable
disable_frontend: False
# cache_control_header: "no-cache, private"
protocol: https
fqdn: your.short.link.url.com
letsencryptmail: your.mail@address.com

Binary file not shown.

View file

@ -1,42 +0,0 @@
<!-- SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> -->
<!-- SPDX-License-Identifier: MIT -->
<!DOCTYPE html>
<html>
<head>
<title>Error 404</title>
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
</head>
<style>
body {
text-align: center;
}
#quote {
text-indent: 4em;
}
/* Settings for mobile devices */
@media (pointer:none),
(pointer:coarse) {
body {
text-align: left;
}
}
</style>
<body>
<h1>Error 404!</h1>
<div style="display: inline-block; text-align:left;">
<p>You step in the stream,</p>
<p>But the water has moved on.</p>
<p>The page is not here.</p>
<p id="quote"> — Cass Whittington</p>
</div>
</body>
</html>

View file

@ -1,268 +0,0 @@
// SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com>
// SPDX-License-Identifier: MIT
const prepSubdir = (link) => {
let thisPage = new URL(window.location.href);
let subdir = thisPage.pathname;
let out = (subdir + link).replace('//', '/');
console.log(out);
return (subdir + link).replace('//', '/');
}
const getSiteUrl = async () => {
let url = await fetch(prepSubdir("/api/siteurl"))
.then(res => res.text());
if (url == "unset") {
return window.location.host.replace(/\/$/, '');
}
else {
return url.replace(/\/$/, '').replace(/^"/, '').replace(/"$/, '');
}
}
const getVersion = async () => {
let ver = await fetch(prepSubdir("/api/version"))
.then(res => res.text());
return ver;
}
const showVersion = async () => {
let version = await getVersion();
link = document.getElementById("version-number");
link.innerText = "v" + version;
link.href = "https://github.com/SinTan1729/chhoto-url/releases/tag/" + version;
link.hidden = false;
}
const getLogin = async () => {
document.getElementById("container").style.filter = "blur(2px)";
document.getElementById("login-dialog").showModal();
document.getElementById("password").focus();
}
const refreshData = async () => {
let res = await fetch(prepSubdir("/api/all"));
if (!res.ok) {
let errorMsg = await res.text();
document.getElementById("url-table").innerHTML = '';
console.log(errorMsg);
if (errorMsg == "Using public mode.") {
document.getElementById("admin-button").hidden = false;
loading_text = document.getElementById("loading-text");
loading_text.hidden = true;
showVersion();
} else {
getLogin();
}
} else {
let data = await res.json();
displayData(data.reverse());
}
}
const displayData = async (data) => {
showVersion();
let site = await getSiteUrl();
admin_button = document.getElementById("admin-button");
admin_button.innerText = "logout";
admin_button.href = "javascript:logOut()";
admin_button.hidden = false;
table_box = document.getElementById("table-box");
loading_text = document.getElementById("loading-text");
const table = document.getElementById("url-table");
if (data.length == 0) {
table_box.hidden = true;
loading_text.innerHTML = "No active links.";
loading_text.hidden = false;
}
else {
loading_text.hidden = true;
if (!window.isSecureContext) {
const shortUrlHeader = document.getElementById("short-url-header");
shortUrlHeader.innerHTML = "Short URL<br>(right click and copy)";
}
table_box.hidden = false;
table.innerHTML = '';
data.forEach(tr => table.appendChild(TR(tr, site)));
}
}
const showAlert = async (text, col) => {
document.getElementById("alert-box")?.remove();
const controls = document.getElementById("controls");
const alertBox = document.createElement("p");
alertBox.id = "alert-box";
alertBox.style.color = col;
alertBox.innerHTML = text;
controls.appendChild(alertBox);
}
const TR = (row, site) => {
const tr = document.createElement("tr");
const longTD = TD(A_LONG(row["longlink"]), "Long URL");
var shortTD = null;
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 {
shortTD = TD(A_SHORT_INSECURE(row["shortlink"], site), "Short URL");
}
let hitsTD = TD(row["hits"]);
hitsTD.setAttribute("label", "Hits");
hitsTD.setAttribute("name", "hitsColumn");
const btn = deleteButton(row["shortlink"]);
tr.appendChild(shortTD);
tr.appendChild(longTD);
tr.appendChild(hitsTD);
tr.appendChild(btn);
return tr;
}
const copyShortUrl = async (link) => {
const site = await getSiteUrl();
try {
navigator.clipboard.writeText(`${site}/${link}`);
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>`, "light-dark(red, #ff1a1a)");
}
}
const addProtocol = (input) => {
var url = input.value.trim();
if (url != "" && !~url.indexOf("://") && !~url.indexOf("magnet:")) {
url = "https://" + url;
}
input.value = url;
return input;
}
const A_LONG = (s) => `<a href='${s}'>${s}</a>`;
const A_SHORT = (s, t) => `<a href="javascript:copyShortUrl('${s}');">${s}</a>`;
const A_SHORT_INSECURE = (s, t) => `<a href="${t}/${s}">${s}</a>`;
const deleteButton = (shortUrl) => {
const td = document.createElement("td");
const div = document.createElement("div");
const btn = document.createElement("button");
btn.innerHTML = "&times;";
btn.onclick = e => {
e.preventDefault();
if (confirm("Do you want to delete the entry " + shortUrl + "?")) {
document.getElementById("alert-box")?.remove();
showAlert("&nbsp;", "black");
fetch(prepSubdir(`/api/del/${shortUrl}`), {
method: "DELETE"
}).then(res => {
if (res.ok) {
console.log("Deleted " + shortUrl);
} else {
console.log("Unable to delete " + shortUrl);
}
refreshData();
});
}
};
td.setAttribute("name", "deleteBtn");
td.setAttribute("label", "Delete");
div.appendChild(btn);
td.appendChild(div);
return td;
}
const TD = (s, u) => {
const td = document.createElement("td");
const div = document.createElement("div");
div.innerHTML = s;
td.appendChild(div);
td.setAttribute("label", u);
return td;
}
const submitForm = () => {
const form = document.forms.namedItem("new-url-form");
const data = {
"longlink": form.elements["longUrl"].value,
"shortlink": form.elements["shortUrl"].value,
};
const url = prepSubdir("/api/new");
let ok = false;
fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
.then(res => {
ok = res.ok;
return res.text();
})
.then(text => {
if (!ok) {
showAlert(text, "light-dark(red, #ff1a1a)");
}
else {
copyShortUrl(text);
longUrl.value = "";
shortUrl.value = "";
refreshData();
}
})
}
const submitLogin = () => {
const password = document.getElementById("password");
fetch(prepSubdir("/api/login"), {
method: "POST",
body: password.value
}).then(res => {
if (res.ok) {
document.getElementById("container").style.filter = "blur(0px)"
document.getElementById("login-dialog").close();
password.value = '';
document.getElementById("wrong-pass").hidden = true;
refreshData();
} else {
document.getElementById("wrong-pass").hidden = false;
password.focus();
}
})
}
const logOut = async () => {
let reply = await fetch(prepSubdir("/api/logout"), {method: "DELETE"}).then(res => res.text());
console.log(reply);
document.getElementById("table-box").hidden = true;
document.getElementById("loading-text").hidden = false;
refreshData();
}
(async () => {
await refreshData();
const form = document.forms.namedItem("new-url-form");
form.onsubmit = e => {
e.preventDefault();
submitForm();
}
const login_form = document.forms.namedItem("login-form");
login_form.onsubmit = e => {
e.preventDefault();
submitLogin();
}
})()

View file

@ -1,183 +0,0 @@
/* SPDX-FileCopyrightText: 2023 Sayantan Santra <sayantan.santra689@gmail.com> */
/* SPDX-License-Identifier: MIT */
@font-face {
font-family: Montserrat;
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;
}
.container {
max-width: 1200px;
margin: 20px auto auto;
}
a {
color: light-dark(blue, #3391ff);
}
table tr td div {
max-height: 75px;
line-height: 25px;
word-wrap: break-word;
max-width: 575px;
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;
}
td[name="hitsColumn"] {
text-align: right;
}
td[name="deleteBtn"] div {
display: flex;
align-items: center;
justify-content: center;
}
td[name="deleteBtn"] {
text-align: center;
}
td[name="deleteBtn"] div button {
border-radius: 100%;
aspect-ratio: 1;
border-style: solid;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
background-color: transparent;
}
input {
width: 65%;
}
form input[name="shortUrl"]::placeholder {
text-transform: none;
}
div[name="links-div"] {
position: absolute;
right: 0.5%;
top: 0.5%;
}
.pure-table {
width: 98%;
}
.pure-table caption {
font-size: 22px;
text-align: left;
font-style: normal;
}
#logo {
font-size: 32px;
}
#password {
width: 100%;
margin-bottom: 10px;
}
#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) {
.container {
max-width: 100vw;
}
.pure-control-group input {
width: 98%;
}
table tr {
border-bottom: 1px solid #999;
}
table thead {
display: none;
}
table td {
display: flex;
justify-content: left !important;
width: 98vw;
padding: .5em .1em !important;
}
table td::before {
content: attr(label);
font-weight: bold;
width: 120px;
min-width: 120px;
text-align: left;
}
.pure-table caption {
padding-top: 0px;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB