aboutsummaryrefslogtreecommitdiff
path: root/src/short.nim
blob: 9ab5f97beae45a9814b2e080390268bc9ebadc58 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import os, strutils
import std/[hashes, re, sequtils, times, uri]

import tiny_sqlite
import jester
import nimja/parser
import nanoid

import database

const nanoidAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const nanoidSize = 10

const allCss = staticRead("../static/all.css")
const cssRoute = "/static/all.css." & $hash(allCss)
const favicon = staticRead("../static/favicon.ico")
const faviconSvg = staticRead("../static/favicon.svg")

const secureHeaders = @[
  ("X-Frame-Options", "deny"),
  ("X-XSS-Protection", "1; mode=block"),
  ("X-Content-Type-Options", "nosniff"),
  ("Referrer-Policy", "strict-origin"),
  ("Cache-Control", "no-transform"),
  ("Content-Security-Policy", "script-src 'self'"),
  ("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"),
  ("Strict-Transport-Security", "max-age=16000000;"),
]
const nonCachingHeaders = concat(secureHeaders, @[("Cache-Control", "max-age=0" )])
const htmlHeaders = concat(nonCachingHeaders, @[("content-type", "text/html")])
const cachingHeaders = concat(secureHeaders, @[("Cache-Control", "public, max-age=31536000, immutable" )])
const cssHeaders = concat(cachingHeaders, @[("content-type", "text/css")])
const icoHeaders = concat(cachingHeaders, @[("content-type", "image/x-icon")])
const svgHeaders = concat(cachingHeaders, @[("content-type", "image/svg+xml")])

var db {.threadvar.}: DbConn

proc initDB() {.raises: [SqliteError].} =
  if not db.isOpen():
    db = openDatabase("data/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 renderAbout(): string {.raises: [].} =
  compileTemplateFile(getScriptDir() / "templates/about.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]{10}$"
    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:
            return (Http400, renderError(400, "Bad Request"))
        except RegexError:
          return (Http500, renderError(500, "RegexError"))
      of "url":
        try:
          discard parseUri(v)
          input.Url = v
        except:
          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:
          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:
    return (Http400, renderError(400, "Bad Request"))
  try:
    input.Token = generate(alphabet=nanoidAlphabet, size=nanoidSize)
  except IOError:
    return (Http500, renderError(500, "IOError on UUID generation"))
  except OSError:
    return (Http500, renderError(500, "OSError on UUID generation"))
  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 Http200, htmlHeaders, renderIndex()
  get "/about":
    resp Http200, htmlHeaders, renderAbout()
  post "/":
    initDB()
    var (code, content) = handleIndexPost(request.params)
    if code != Http200:
      resp code, htmlHeaders, content
    else:
      redirect("/" & content)
  get "/static/favicon.ico":
    resp Http200, icoHeaders, favicon
  get "/static/favicon.svg":
    resp Http200, svgHeaders, faviconSvg
  get re"^/static/all\.css\.":
    resp Http200, cssHeaders, allcss
  get "/@token":
    initDB()
    var (code, content) = handleToken(@"token")
    resp code, htmlHeaders, content

when isMainModule:
  runForever()