summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJulien Dessaux2021-10-22 17:59:44 +0200
committerJulien Dessaux2021-10-25 15:22:24 +0200
commitad9b9c0f7bd4d95ddc54462970d33d92bab9392c (patch)
treeb8d130d728fda49c64b6e021da31afb2418b78a0
parentInitial import (diff)
downloadshort-ad9b9c0f7bd4d95ddc54462970d33d92bab9392c.tar.gz
short-ad9b9c0f7bd4d95ddc54462970d33d92bab9392c.tar.bz2
short-ad9b9c0f7bd4d95ddc54462970d33d92bab9392c.zip
Added a functionning url shortening service
-rw-r--r--config.nims4
-rw-r--r--short.nimble16
-rw-r--r--src/database.nim79
-rw-r--r--src/dbUtils.nim13
-rw-r--r--src/short.nim115
-rw-r--r--src/templates/error.html7
-rw-r--r--src/templates/index.html25
-rw-r--r--src/templates/noshort.html10
-rw-r--r--src/templates/partials/footer.html5
-rw-r--r--src/templates/partials/master.html17
-rw-r--r--src/templates/short.html9
-rw-r--r--static/all.css82
-rwxr-xr-xstatic/favicon.icobin0 -> 1150 bytes
-rw-r--r--tests/database.nim41
14 files changed, 423 insertions, 0 deletions
diff --git a/config.nims b/config.nims
new file mode 100644
index 0000000..298bb84
--- /dev/null
+++ b/config.nims
@@ -0,0 +1,4 @@
+switch("define", "release")
+switch("define", "flto")
+switch("opt", "size")
+switch("threads", "on")
diff --git a/short.nimble b/short.nimble
new file mode 100644
index 0000000..aed9b3a
--- /dev/null
+++ b/short.nimble
@@ -0,0 +1,16 @@
+# Package
+
+version = "0.1.0"
+author = "Julien Dessaux"
+description = "A simple, privacy friendly URL shortener"
+license = "EUPL-1.2"
+srcDir = "src"
+bin = @["short"]
+
+
+# Dependencies
+
+requires "nim >= 1.4.8",
+ "jester > 0.5.0",
+ "nimja >= 0.4.1",
+ "tiny_sqlite > 0.1.2"
diff --git a/src/database.nim b/src/database.nim
new file mode 100644
index 0000000..8094f31
--- /dev/null
+++ b/src/database.nim
@@ -0,0 +1,79 @@
+import tiny_sqlite
+import std / [options, times]
+
+import dbUtils
+
+const migrations = [
+ """
+ CREATE TABLE schema_version (
+ version INTEGER NOT NULL
+ );
+ CREATE TABLE url (
+ id INTEGER PRIMARY KEY,
+ token TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ url TEXT,
+ created DATE,
+ expires DATE
+ );
+ CREATE UNIQUE INDEX idx_url_token ON url(token);
+ """
+]
+const latestVersion = migrations.len
+
+proc Migrate*(db: DbConn): bool {.raises: [].} =
+ var currentVersion : int
+ try:
+ currentVersion = db.value("SELECT version FROM schema_version;").get().fromDbValue(int)
+ except SqliteError:
+ discard
+ if currentVersion != latestVersion:
+ try:
+ db.exec("BEGIN")
+ for v in currentVersion..<latestVersion:
+ db.execScript(migrations[v])
+ db.exec("DELETE FROM schema_version;")
+ db.exec("INSERT INTO schema_version (version) VALUES (?);", latestVersion)
+ db.exec("COMMIT;")
+ except:
+ let msg = getCurrentExceptionMsg()
+ echo msg
+ try:
+ db.exec("ROLLBACK")
+ except SqliteError:
+ discard
+ return false
+ return true
+
+type ShortUrl* = object
+ ID*: int
+ Token*: string
+ Title*: string
+ Url*: string
+ Created*: DateTime
+ Expires*: DateTime
+
+proc AddUrl*(db: DbConn, url: ShortUrl) {.raises: [SqliteError].} =
+ let stmt = db.stmt("""
+ INSERT INTO url(token, title, url, created, expires)
+ VALUES (?, ?, ?, ?, ?);
+ """)
+ stmt.exec(url.Token, url.Title, url.Url, url.Created, $url.Expires)
+
+proc GetUrl*(db: DbConn, token: string): ref ShortUrl {.raises: [SqliteError].} =
+ let stmt = db.stmt("SELECT id, title, url, created, expires FROM url WHERE token = ?")
+ for row in stmt.iterate(token):
+ new(result)
+ result.ID = row[0].fromDbValue(int)
+ result.Token = token
+ result.Title = row[1].fromDbValue(string)
+ result.Url = row[2].fromDbValue(string)
+ result.Created = row[3].fromDbValue(DateTime)
+ result.Expires = row[4].fromDbValue(DateTime)
+
+proc CleanExpired*(db: DbConn) {.raises: [].} =
+ try:
+ let stmt = db.stmt("DELETE FROM url WHERE expires < ?")
+ stmt.exec(times.now())
+ except:
+ discard
diff --git a/src/dbUtils.nim b/src/dbUtils.nim
new file mode 100644
index 0000000..bdbcf14
--- /dev/null
+++ b/src/dbUtils.nim
@@ -0,0 +1,13 @@
+import std/times
+import tiny_sqlite
+
+proc toDbValue*(t: DateTime): DbValue {.raises: [].}=
+ DbValue(kind: sqliteText, strVal: $t)
+
+proc fromDbValue*(value: DbValue, T: typedesc[DateTime]): DateTime {.raises: [].}=
+ try:
+ case value.kind:
+ of sqliteText: return times.parse(value.strVal, "yyyy-MM-dd'T'HH:mm:sszzz")
+ else: return
+ except TimeParseError:
+ return
diff --git a/src/short.nim b/src/short.nim
new file mode 100644
index 0000000..959e5a7
--- /dev/null
+++ b/src/short.nim
@@ -0,0 +1,115 @@
+import os, strutils
+import std/[hashes, oids, re, times, uri]
+
+import tiny_sqlite
+import jester
+import nimja/parser
+
+import database
+
+const allCss = staticRead("../static/all.css")
+const cssRoute = "/static/all.css." & $hash(allCss)
+const favicon = staticRead("../static/favicon.ico")
+
+var db {.threadvar.}: DbConn
+
+proc initDB() {.raises: [SqliteError].}=
+ if not db.isOpen():
+ db = openDatabase("short.db")
+ if not db.Migrate():
+ echo "Failed to migrate database schema"
+ quit 1
+
+func renderIndex(): string {.raises: [].} =
+ var req: ShortUrl
+ compileTemplateFile(getScriptDir() / "templates/index.html")
+
+func renderShort(req: ShortUrl): string {.raises: [].} =
+ compileTemplateFile(getScriptDir() / "templates/short.html")
+
+func renderNoShort(req: ShortUrl): string {.raises: [].} =
+ compileTemplateFile(getScriptDir() / "templates/noshort.html")
+
+func renderError(code: int, msg: string): string {.raises: [].} =
+ compileTemplateFile(getScriptDir() / "templates/error.html")
+
+proc handleToken(token:string): (HttpCode, string) {.raises: [].} =
+ try:
+ let tokenRegexp = re"^[\w]{24}$"
+ if not match(token, tokenRegexp):
+ return (Http400, renderError(400, "Bad Request"))
+ except RegexError:
+ return (Http500, renderError(500, "RegexError"))
+ db.CleanExpired()
+ try:
+ let req = db.GetUrl(token)
+ if req == nil:
+ return (Http404, renderNoShort(req[]))
+ return (Http200, renderShort(req[]))
+ except SqliteError:
+ return (Http500, renderError(500, "SqliteError"))
+
+proc handleIndexPost(params: Table[string, string]): (HttpCode, string) {.raises: [].} =
+ var input: ShortUrl
+ var exp: int
+ for k, v in params.pairs:
+ case k:
+ of "title":
+ try:
+ let titleRegexp = re"^[\w\s]{3,64}$"
+ if match(v, titleRegexp):
+ input.Title = v
+ else:
+ echo "title"
+ return (Http400, renderError(400, "Bad Request"))
+ except RegexError:
+ return (Http500, renderError(500, "RegexError"))
+ of "url":
+ try:
+ discard parseUri(v)
+ input.Url = v
+ except:
+ echo "url"
+ return (Http400, renderError(400, "Bad Request"))
+ of "expires":
+ try:
+ exp = parseInt(v)
+ except ValueError:
+ return (Http400, renderError(400, "Bad Request"))
+ if exp < 1 or exp > 527040:
+ echo "exp"
+ return (Http400, renderError(400, "Bad Request"))
+ of "shorten": discard
+ else: return (Http400, renderError(400, "Bad Request"))
+ if input.Title == "" or input.Url == "" or exp == 0:
+ echo "empty"
+ return (Http400, renderError(400, "Bad Request"))
+ input.Token = $genOid()
+ input.Created = times.now()
+ input.Expires = input.Created + initDuration(minutes=exp)
+ try:
+ db.AddUrl(input)
+ except SqliteError:
+ return (Http500, renderShort(input))
+ return (Http200, input.Token)
+
+routes:
+ get "/":
+ resp renderIndex()
+ post "/":
+ initDB()
+ var (code, content) = handleIndexPost(request.params)
+ if code != Http200:
+ resp code, content
+ else:
+ redirect("/" & content)
+ get "/static/favicon.ico":
+ resp Http200, {"content-type": "image/x-icon"}, favicon
+ get re"^/static/all\.css\.":
+ resp Http200, {"content-type": "text/css"}, allcss
+ get "/@token":
+ initDB()
+ var (code, content) = handleToken(@"token")
+ resp code, content
+
+runForever()
diff --git a/src/templates/error.html b/src/templates/error.html
new file mode 100644
index 0000000..5de1923
--- /dev/null
+++ b/src/templates/error.html
@@ -0,0 +1,7 @@
+{% extends "templates/partials/master.html" %}
+{% block content %}
+<h1>{{ $code }} - {{ $msg }}</h1>
+<p>
+ <a href="/">Go back</a>
+</p>
+{% endblock %}
diff --git a/src/templates/index.html b/src/templates/index.html
new file mode 100644
index 0000000..b89e381
--- /dev/null
+++ b/src/templates/index.html
@@ -0,0 +1,25 @@
+{% extends "templates/partials/master.html" %}
+{% block content %}
+<h1>URL shortener</h1>
+<p>
+The simple, open source and privacy friendly URL shortener : anonymous usage, no tracking.<br>
+This is a personal sharing service: Data may be deleted anytime. Don't share illegal, unethical or morally reprehensible content.
+</p>
+<form action="/" method="post">
+ <label for="title">Title:</label><input class="fullwidth" type="text" placeholder="Enter a title here" name="title" value="{{ $req.Title }}" minlength="3" maxlength="64" required autofocus><br>
+ <label for="url">URL:</label><input class="fullwidth" type="url" placeholder="Enter the URL to shorten here" name="url" value="{{ $req.Url }}" minlength="3" maxlength="512" required><br>
+ <label for="expires">Expires in:</label>
+ <select id="expires" name="expires">
+ <option value="5">5 minutes</option>
+ <option value="10">10 minutes</option>
+ <option value="60">1 hour</option>
+ <option value="1440">1 day</option>
+ <option value="10080" selected>1 week</option>
+ <option value="44640">1 month</option>
+ <option value="527040">1 year</option>
+ </select>
+ <input type="submit" value="shorten">
+</form>
+<ul>
+</ul>
+{% endblock %}
diff --git a/src/templates/noshort.html b/src/templates/noshort.html
new file mode 100644
index 0000000..e0be436
--- /dev/null
+++ b/src/templates/noshort.html
@@ -0,0 +1,10 @@
+{% extends "templates/partials/master.html" %}
+{% block content %}
+<h1>URL not found!</h1>
+<p>
+ This url does not exist or has expired, sorry!
+</p>
+<p>
+ <a href="/">Go back</a>
+</p>
+{% endblock %}
diff --git a/src/templates/partials/footer.html b/src/templates/partials/footer.html
new file mode 100644
index 0000000..6ae4459
--- /dev/null
+++ b/src/templates/partials/footer.html
@@ -0,0 +1,5 @@
+<footer>
+ <p>
+ &copy; 2021 | Julien (Adyxax) Dessaux | <a href="https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12" title="EUPL 1.2">Some rights reserved</a>
+ </p>
+</footer>
diff --git a/src/templates/partials/master.html b/src/templates/partials/master.html
new file mode 100644
index 0000000..82cedac
--- /dev/null
+++ b/src/templates/partials/master.html
@@ -0,0 +1,17 @@
+<!doctype html>
+<html class="no-js" lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="icon" href="/static/favicon.ico">
+ <link rel="stylesheet" href="{{ $cssRoute }}">
+
+ <title>short.adyxax.org</title>
+ </head>
+ <body>
+ <main id="main">
+ {% block content %}{% endblock %}
+ </main>
+ {% importnwt "templates/partials/footer.html" %}
+ </body>
+</html>
diff --git a/src/templates/short.html b/src/templates/short.html
new file mode 100644
index 0000000..1f36fdf
--- /dev/null
+++ b/src/templates/short.html
@@ -0,0 +1,9 @@
+{% extends "templates/partials/master.html" %}
+{% block content %}
+<h1>{{ $req.Title }}</h1>
+<p><a href="{{ $req.Url }}">{{ $req.Url }}</a></p>
+<p>
+Created on : {{ $req.Created }}<br>
+Expires on : {{ $req.Expires }}
+</p>
+{% endblock %}
diff --git a/static/all.css b/static/all.css
new file mode 100644
index 0000000..3e31e06
--- /dev/null
+++ b/static/all.css
@@ -0,0 +1,82 @@
+* {
+ box-sizing: border-box;
+}
+body {
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+
+ font-family: BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
+
+ font-feature-settings: "kern" 1;
+ font-kerning: normal;
+}
+#main {
+ padding-left:1em;
+ padding-right:1em;
+}
+p {
+ text-align: justify;
+ hyphens: auto;
+ text-justify: inter-character;
+ overflow-wrap: anywhere;
+}
+h1, h2, h3, h4, h5 {
+ font-family: open, serif;
+}
+@media only screen and (min-width: 60rem) {
+ body {
+ max-width:60rem;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+.fullwidth {
+ width: 100%;
+}
+footer {
+ background-color: #002b36;
+ padding: 10px;
+}
+footer p {
+ color: #859900;
+ text-align: center;
+}
+footer a {
+ color: #859900;
+}
+html {
+ background-color: #002b36;
+ color: #839496;
+}
+body, main {
+ background-color: #073642;
+}
+code {
+ background-color: #073642;
+}
+a {
+ color: #b58900;
+}
+a:visited {
+ color: #cb4b16;
+}
+a:hover {
+ color: #cb4b16;
+}
+h1 {
+ color: #cb4b16;
+}
+h2,
+h3,
+h4,
+h5,
+h6 {
+ color: #859900;
+}
+pre {
+ background-color: #002b36;
+ color: #839496;
+}
+pre, code {
+ background-color: #002b36;
+}
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100755
index 0000000..216330f
--- /dev/null
+++ b/static/favicon.ico
Binary files differ
diff --git a/tests/database.nim b/tests/database.nim
new file mode 100644
index 0000000..9e8de52
--- /dev/null
+++ b/tests/database.nim
@@ -0,0 +1,41 @@
+include ../src/database
+
+import unittest
+
+const someTime = initDuration(seconds=1)
+let testingNow = times.now() - 60 * someTime
+let later = testingNow + 30 * someTime
+
+suite "database":
+ test "url":
+ let db = openDatabase(":memory:")
+ check db.Migrate() == true
+ let u = ShortUrl(
+ Token: "token",
+ Title: "title",
+ Url: "url",
+ Created: testingNow,
+ Expires: later,
+ )
+ db.AddUrl(u)
+ try:
+ db.AddUrl(u)
+ check false
+ except SqliteError:
+ discard
+ var u2 = db.GetUrl(u.Token)
+ check u2.ID == 1
+ check u2.Token == "token"
+ check u2.Title == "title"
+ check u2.Url == "url"
+ check u2.Created - testingNow < someTime
+ check u2.Expires - later < someTime
+ db.CleanExpired()
+ try:
+ discard db.GetUrl("token")
+ except SqliteError:
+ check false
+ u2.Expires = testingNow + 120 * someTime
+ db.AddUrl(u2[])
+ db.CleanExpired()
+ check db.GetUrl("token") != nil