diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/database.nim | 79 | ||||
-rw-r--r-- | src/dbUtils.nim | 13 | ||||
-rw-r--r-- | src/short.nim | 115 | ||||
-rw-r--r-- | src/templates/error.html | 7 | ||||
-rw-r--r-- | src/templates/index.html | 25 | ||||
-rw-r--r-- | src/templates/noshort.html | 10 | ||||
-rw-r--r-- | src/templates/partials/footer.html | 5 | ||||
-rw-r--r-- | src/templates/partials/master.html | 17 | ||||
-rw-r--r-- | src/templates/short.html | 9 |
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> + © 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 %} |