diff options
author | Julien Dessaux | 2021-10-22 17:59:44 +0200 |
---|---|---|
committer | Julien Dessaux | 2021-10-25 15:22:24 +0200 |
commit | ad9b9c0f7bd4d95ddc54462970d33d92bab9392c (patch) | |
tree | b8d130d728fda49c64b6e021da31afb2418b78a0 | |
parent | Initial import (diff) | |
download | short-ad9b9c0f7bd4d95ddc54462970d33d92bab9392c.tar.gz short-ad9b9c0f7bd4d95ddc54462970d33d92bab9392c.tar.bz2 short-ad9b9c0f7bd4d95ddc54462970d33d92bab9392c.zip |
Added a functionning url shortening service
-rw-r--r-- | config.nims | 4 | ||||
-rw-r--r-- | short.nimble | 16 | ||||
-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 | ||||
-rw-r--r-- | static/all.css | 82 | ||||
-rwxr-xr-x | static/favicon.ico | bin | 0 -> 1150 bytes | |||
-rw-r--r-- | tests/database.nim | 41 |
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> + © 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 Binary files differnew file mode 100755 index 0000000..216330f --- /dev/null +++ b/static/favicon.ico 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 |