Merge branch 'rust-backend' into 'main'

Complete rewrite of backend in Rust

See merge request SinTan1729/simply-shorten!3
This commit is contained in:
Sayantan Santra 2023-04-08 20:59:25 +00:00
commit c3aa7438e9
31 changed files with 1877 additions and 794 deletions

View File

@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/java">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/resources">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

17
.gitignore vendored
View File

@ -1,12 +1,9 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore cargo build outputs
actix/target
# Ignore Gradle build output directory
build
.idea/
local.properties
url.iml
urls.csv
.env
# Ignore SQLite file
urls.sqlite
# Ignore irrelevant dotfiles
.vscode/
.directory

View File

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>url</name>
<comment>Project url created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>
<filter>
<id>1667542035221</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

View File

@ -1,13 +0,0 @@
arguments=--init-script /home/sintan/.config/Code/User/globalStorage/redhat.java/1.12.0/config_linux/org.eclipse.osgi/51/0/.cp/gradle/init/init.gradle --init-script /home/sintan/.config/Code/User/globalStorage/redhat.java/1.12.0/config_linux/org.eclipse.osgi/51/0/.cp/gradle/protobuf/init.gradle
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/usr/lib/jvm/java-19-openjdk
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true

View File

@ -1,14 +1,27 @@
FROM gradle:jdk17-alpine AS build
COPY --chown=gradle:gradle . /home/gradle/src
WORKDIR /home/gradle/src
RUN gradle fatJar --no-daemon
FROM rust:1 as build
RUN cargo install cargo-build-deps
FROM openjdk:17-alpine
RUN cargo new --bin simply-shorten
WORKDIR /simply-shorten
EXPOSE 4567
COPY ./actix/Cargo.toml .
COPY ./actix/Cargo.lock .
RUN mkdir /app
RUN cargo build-deps --release
COPY --from=build /home/gradle/src/build/libs/*.jar /app/application.jar
COPY ./actix/src ./src
COPY ./actix/resources ./resources
ENTRYPOINT ["java", "-jar","/app/application.jar"]
RUN cargo build --release
FROM frolvlad/alpine-glibc:latest
EXPOSE 2000
RUN apk add sqlite-libs
WORKDIR /opt
COPY --from=build /simply-shorten/target/release/simply-shorten /opt/simply-shorten
COPY --from=build /simply-shorten/resources /opt/resources
CMD ["./simply-shorten"]

View File

@ -1,4 +1,4 @@
# ![Logo](src/main/resources/public/assets/favicon-32.png) <span style="font-size:42px">Simply Shorten</span>
# ![Logo](actix/resources/assets/favicon-32.png) <span style="font-size:42px">Simply Shorten</span>
# What is it?
A simple selfhosted URL shortener with no unnecessary features.
@ -28,7 +28,7 @@ unnecessary features, or they didn't have all the features I wanted.
generate short links locally.
- Links are stored in an SQLite database.
- Available as a Docker container.
- Backend written in Java using [Spark Java](http://sparkjava.com/), frontend
- Backend written in Rust using [Actix](https://actix.rs/), frontend
written in plain HTML and vanilla JS, using [Pure CSS](https://purecss.io/)
for styling.
@ -62,19 +62,10 @@ Clone this repository
```
git clone https://gitlab.com/SinTan1729/simply-shorten
```
Note that Gradle 6.x.x and JDK 11 are required. Other versions are not tested
### 1. Build the `.jar` file
```
gradle build --no-daemon
```
The `--no-daemon` option means that gradle should exit as soon as the build is
finished. Without it, gradle would still be running in the background
in order to speed up future builds.
### 2. Set environment variables
```bash
# Required for authentication
export username=<api username>
export password=<api password>
# Sets where the database exists. Can be local or remote (optional)
export db_url=<url> # Default: './urls.sqlite'
@ -83,9 +74,10 @@ export db_url=<url> # Default: './urls.sqlite'
export site_url=<url>
```
### 3. Run it
### 3. Build and run it
```
java -jar build/libs/url.jar
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.
@ -99,7 +91,6 @@ docker build . -t simply-shorten:latest
1. Run the image
```
docker run -p 4567:4567
-d url:latest
-e username="username"
-e password="password"
-d simply-shorten:latest
@ -127,20 +118,16 @@ docker run -p 4567:4567 \
```
## Disable authentication
As requested in #5, it is possible to completely disable the authentication.
This if not recommended, as it will allow anyone to create new links and delete
It's not possible to completely disable authentication. It's rather easy to implement
but there's literally no point. Rather, for testing purposes, you can omit the password
environment variable, and any provided password should work.
This if not recommended in actual use however, as it will allow anyone to create new links and delete
old ones. This might not seem like a bad idea, until you have hundreds of links
pointing to illegal content. Since there are no logs, it's impossible to prove
that those links aren't created by you.
If you still want to do it, then you need to set an environment variable to
an exact value:
```
INSECURE_DISABLE_PASSWORD=I_KNOW_ITS_BAD
```
Any other value will not work.
## Notes
- This is a fork of [this project](https://gitlab.com/draganczukp/simply-shorten).
- It started as a fork of [this project](https://gitlab.com/draganczukp/simply-shorten).
- 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).

3
actix/.directory Normal file
View File

@ -0,0 +1,3 @@
[Dolphin]
Timestamp=2023,4,2,17,52,37.922
Version=4

1487
actix/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
actix/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "simply-shorten"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4"
actix-files = "0.6.2"
rusqlite = "0.29.0"
regex = "1.7.3"
rand = "0.8.5"
actix-session = {version = "0.7.2", features = ["cookie-session"]}

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,4 +1,4 @@
const getSiteUrl = async () => await fetch("/api/site")
const getSiteUrl = async () => await fetch("/api/siteurl")
.then(res => res.text())
.then(text => {
if (text == "unset") {
@ -9,8 +9,22 @@ const getSiteUrl = async () => await fetch("/api/site")
}
});
const auth_fetch = async (link) => {
let reply = await fetch(link).then(res => res.text());
if (reply == "logged_out") {
pass = prompt("Please enter passkey to access this website");
await fetch("/api/login", {
method: "POST",
body: pass
});
return auth_fetch(link);
} else {
return reply;
}
}
const refreshData = async () => {
let data = await fetch("/api/all").then(res => res.text());
let data = await auth_fetch("/api/all");
data = data
.split("\n")
.filter(line => line !== "")
@ -108,7 +122,7 @@ const deleteButton = (shortUrl) => {
e.preventDefault();
if (confirm("Do you want to delete the entry " + shortUrl + "?")) {
document.getElementById("alertBox")?.remove();
fetch(`/api/${shortUrl}`, {
fetch(`/api/del/${shortUrl}`, {
method: "DELETE"
}).then(_ => refreshData());
}

47
actix/src/auth.rs Normal file
View File

@ -0,0 +1,47 @@
use actix_session::Session;
use std::time::SystemTime;
pub fn validate(session: Session) -> bool {
let token = session.get::<String>("session-token");
if token.is_err() {
false
} else if !check(token.unwrap()) {
false
} else {
true
}
}
fn check(token: Option<String>) -> bool {
if token.is_none() {
false
} else {
let token_body = token.unwrap();
let token_parts: Vec<&str> = token_body.split(";").collect();
if token_parts.len() < 2 {
false
} else {
let token_text = token_parts[0];
let token_time = token_parts[1].parse::<u64>().unwrap_or(0);
let time_now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("Time went backwards!")
.as_secs();
if token_text == "valid-session-token" && time_now < token_time + 1209600 {
// There are 1209600 seconds in 14 days
true
} else {
false
}
}
}
}
pub fn gen_token() -> String {
let token_text = "valid-session-token".to_string();
let time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("Time went backwards!")
.as_secs();
format!("{token_text};{time}")
}

73
actix/src/database.rs Normal file
View File

@ -0,0 +1,73 @@
use rusqlite::Connection;
pub fn find_url(shortlink: &str, db: &Connection) -> String {
let mut statement = db
.prepare_cached("SELECT long_url FROM urls WHERE short_url = ?1")
.unwrap();
let links = statement
.query_map([shortlink], |row| Ok(row.get("long_url")?))
.unwrap();
let mut longlink = "".to_string();
for link in links {
longlink = link.unwrap();
}
longlink
}
pub fn getall(db: &Connection) -> Vec<String> {
let mut statement = db.prepare_cached("SELECT * FROM urls").unwrap();
let mut data = statement.query([]).unwrap();
let mut links: Vec<String> = Vec::new();
while let Some(row) = data.next().unwrap() {
let short_url: String = row.get("short_url").unwrap();
let long_url: String = row.get("long_url").unwrap();
let hits: i64 = row.get("hits").unwrap();
links.push(format!("{short_url},{long_url},{hits}"));
}
links
}
pub fn add_hit(shortlink: &str, db: &Connection) -> () {
db.execute(
"UPDATE urls SET hits = hits + 1 WHERE short_url = ?1",
[shortlink],
)
.unwrap();
}
pub fn add_link(shortlink: String, longlink: String, db: &Connection) -> bool {
match db.execute(
"INSERT INTO urls (long_url, short_url, hits) VALUES (?1, ?2, ?3)",
(longlink, shortlink, 0),
) {
Ok(_) => true,
Err(_) => false,
}
}
pub fn delete_link(shortlink: String, db: &Connection) -> () {
db.execute("DELETE FROM urls WHERE short_url = ?1", [shortlink])
.unwrap();
}
pub fn open_db(path: String) -> Connection {
let db = Connection::open(path).expect("Unable to open database!");
// Create table if it doesn't exist
db.execute(
"CREATE TABLE IF NOT EXISTS urls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
long_url TEXT NOT NULL,
short_url TEXT NOT NULL,
hits INTEGER NOT NULL
)",
[],
)
.unwrap();
db
}

140
actix/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +0,0 @@
plugins {
// Apply the java plugin to add support for Java
id 'java'
// Apply the application plugin to add support for building a CLI application.
id 'application'
}
repositories {
jcenter()
}
task fatJar(type: Jar) {
manifest {
attributes 'Main-Class': 'tk.SinTan1729.url.App'
}
archiveBaseName = 'url'
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from { configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } }
with jar
}
dependencies {
implementation 'com.sparkjava:spark-core:2.9.4'
implementation 'com.qmetric:spark-authentication:1.4'
implementation 'org.slf4j:slf4j-simple:1.6.1'
implementation group: 'org.xerial', name: 'sqlite-jdbc', version: '3.30.1'
}
application {
mainClassName = 'tk.SinTan1729.url.App'
}

View File

@ -8,6 +8,7 @@ services:
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
# Change it in case you want to set the website name
# displayed in front of the shorturls, defaults to

Binary file not shown.

View File

@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

240
gradlew vendored

File diff suppressed because it is too large Load Diff

91
gradlew.bat vendored
View File

@ -1,91 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -1,10 +0,0 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
*
* Detailed information about configuring a multi-project build in Gradle can be found
* in the user manual at https://docs.gradle.org/6.1.1/userguide/multi_project_builds.html
*/
rootProject.name = 'url'

View File

@ -1,44 +0,0 @@
package tk.SinTan1729.url;
import static spark.Spark.*;
public class App {
public static void main(String[] args) {
// Useful for developing the frontend
// http://sparkjava.com/documentation#examples-and-faq -> How do I enable automatic refresh of static files?
if (System.getenv("dev") != null) {
String projectDir = System.getProperty("user.dir");
String staticDir = "/src/main/resources/public";
staticFiles.externalLocation(projectDir + staticDir);
} else {
staticFiles.location("/public");
}
port(Integer.parseInt(System.getenv().getOrDefault("port", "4567")));
// Add GZIP compression
after(Filters::addGZIP);
// No need to auth in dev
if (System.getenv("dev") == null && Utils.isPasswordEnabled()) {
// Authenticate
before("/api/*", Filters.createAuthFilter());
}
get("/", (req, res) -> {
res.redirect("/index.html");
return "Redirect";
});
path("/api", () -> {
get("/all", Routes::getAll);
post("/new", Routes::addUrl);
delete("/:shortUrl", Routes::delete);
get("/site", Routes::getSiteUrl);
});
get("/:shortUrl", Routes::goToLongUrl);
}
}

View File

@ -1,20 +0,0 @@
package tk.SinTan1729.url;
import com.qmetric.spark.authentication.AuthenticationDetails;
import com.qmetric.spark.authentication.BasicAuthenticationFilter;
import spark.Filter;
import spark.Request;
import spark.Response;
public class Filters {
public static void addGZIP(Request request, Response response) {
response.header("Content-Encoding", "gzip");
}
public static Filter createAuthFilter() {
String username = System.getenv("username");
String password = System.getenv("password");
return new BasicAuthenticationFilter(new AuthenticationDetails(username, password));
}
}

View File

@ -1,76 +0,0 @@
package tk.SinTan1729.url;
import org.eclipse.jetty.http.HttpStatus;
import spark.Request;
import spark.Response;
public class Routes {
private static final UrlRepository urlRepository;
static {
urlRepository = new UrlRepository();
}
public static String getAll(Request req, Response res) {
return String.join("\n", urlRepository.getAll());
}
public static String addUrl(Request req, Response res) {
var body = req.body();
var split = body.split(";");
String longUrl = split[0];
boolean unique = false;
String shortUrl;
try {
shortUrl = split[1];
shortUrl = shortUrl.toLowerCase();
if (urlRepository.findForShortUrl(shortUrl).isEmpty()) {
unique = true;
}
} catch (ArrayIndexOutOfBoundsException e) {
do {
shortUrl = Utils.randomName();
if (urlRepository.findForShortUrl(shortUrl).isEmpty()) {
unique = true;
}
} while (unique == false);
}
if (unique && Utils.validate(shortUrl)) {
return urlRepository.addUrl(longUrl, shortUrl);
} else {
res.status(HttpStatus.BAD_REQUEST_400);
return "shortUrl not valid or already in use";
}
}
public static String getSiteUrl(Request req, Response res) {
return System.getenv().getOrDefault("site_url", "unset");
}
public static String goToLongUrl(Request req, Response res) {
String shortUrl = req.params("shortUrl");
shortUrl = shortUrl.toLowerCase();
var longUrlOpt = urlRepository
.findForShortUrl(shortUrl);
if (longUrlOpt.isEmpty()) {
res.redirect("404.html");
return "";
}
urlRepository.addHit(shortUrl);
res.redirect(longUrlOpt.get(), HttpStatus.PERMANENT_REDIRECT_308);
return "";
}
public static String delete(Request req, Response res) {
String shortUrl = req.params("shortUrl");
urlRepository.deleteEntry(shortUrl);
return "";
}
}

File diff suppressed because it is too large Load Diff