aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to '')
-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
9 files changed, 280 insertions, 0 deletions
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 %}