1
mirror of https://gitlab.com/jessieh/simple-shortener.git synced 2025-01-23 10:01:46 +00:00

Initial commit

This commit is contained in:
Jessie Hildebrandt 2021-12-20 22:06:53 -05:00
commit 4552d0d205
77 changed files with 48721 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# simple-shortener .gitignore
# Temp files
*~
\#*\#
.\#*
# Assorted log files
.log/
*.log
# Database files (and any accompanying journals)
src/db/*
# Configuration file
config.env

49
Dockerfile Normal file
View File

@ -0,0 +1,49 @@
# simple-shortener Dockerfile
# Produces an image (Debian-based) with all required facilities to run simple-shortener
FROM debian:buster-slim
LABEL maintainer='Jessie Hildebrandt <jessieh@jessieh.net>'
ENV PORT 80
WORKDIR /usr/simple-shortener
# Install required packages for building luarocks and lua rocks
RUN apt-get update && apt-get install --no-install-recommends -y \
ca-certificates \
cmake \
gcc \
git \
luajit \
liblua5.1-0-dev \
libqrencode-dev \
libsqlite3-dev \
libssl-dev \
make \
tar \
unzip \
wget \
&& rm -rf /var/lib/apt/lists/*
# Fetch, build, and install luarocks
RUN wget https://luarocks.org/releases/luarocks-3.7.0.tar.gz && \
tar xf luarocks-3.7.0.tar.gz && \
cd luarocks-3.7.0 && \
./configure && make && make install && \
cd .. && \
rm -rf luarocks-3.7.0.tar.gz luarocks-3.7.0/
# Install required rocks
RUN luarocks install basexx 0.4.1-1 && \
luarocks install ezenv 1.2-0 && \
luarocks install fennel 0.10.0-1 && \
luarocks install hashids 1.0.5-1 && \
luarocks install lume 2.3.0-0 && \
luarocks install otp 0.1-6 && \
luarocks install qrprinter 1.1-0 && \
luarocks install sqlite v1.2.0-0 && \
luarocks install turbo 2.1-2
COPY ./src .
ENTRYPOINT ["luajit", "init.lua"]

11
README.md Normal file
View File

@ -0,0 +1,11 @@
# simple-shortener
# To Document:
* Docker deployment
* docker-compose deployment
* Update landing page deployment examples
* API endpoints and response codes

67
config_template.env Normal file
View File

@ -0,0 +1,67 @@
# config_template.env
# simple-shortener configuration template file
# auth_secret
# string
# This is used to authenticate sessions for the shortener control panel and the REST API
# Depending on configuration, this can either be used as a TOTP secret or as a password
# (! THIS VALUE NEEDS TO BE SET BEFORE THE SERVER WILL START !)
#auth_secret=
# enable_control_panel
# boolean
# Whether or not a static control panel page for interacting with the shortening service will be exposed at "/shorten"
#enable_control_panel=true
# enable_landing_page
# boolean
# Whether or not a static landing page will be served when not visiting shortened links or the control panel
#enable_landing_page=true
# landing_page_index
# string
# The path to the file to use as the index for the landing page
# (This only has an effect when `enable_landing_page` is set to true)
#landing_page_index="index.html"
# listen_port
# number
# The port that the HTTP server will listen on
#listen_port=80
# query_limit
# number
# The maximum number of database entries that the API will return in a single query
#query_limit=100
# session_max_length_hours
# number
# The duration (in hours) that an API session will be valid for once signed in (regardless of activity)
#session_max_length_hours=24
# session_max_idle_length_hours
# number
# The duration (in hours) that an API session will be valid for without any activity
#session_max_idle_length_hours=1
# use_secure_cookies
# boolean
# Whether or not to tag API session cookies with the "Secure" and "SameSite=Strict" attributes
# Secure cookies expect your server to have HTTPS enabled, and will prevent the control panel and API from functioning over unsecured connections
#use_secure_cookies=true
# use_totp
# boolean
# Whether or not `auth_secret` will be used as a TOTP secret instead of a password
# (TOTP is recommended, but you may want to disable it if (e.g) your system cannot be synchronized to global time)
#use_totp=true

View File

@ -0,0 +1,96 @@
;; handlers/LinksAPIHandler.fnl
;; Provides a RequestHandler that handles service logic for the /links API endpoint
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local turbo (require :turbo))
;; -------------------------------------------------------------------------- ;;
;; Local modules
(local config (require :modules.config))
(local link_utils (require :modules.link_utils))
(local links_db (require :modules.links_db))
(local sessions_db (require :modules.sessions_db))
;; -------------------------------------------------------------------------- ;;
;; Handler definition
(local LinksAPIHandler (class "LinksAPIHandler" turbo.web.RequestHandler))
(fn LinksAPIHandler.prepare [self]
"Check that the client session is authorized before handling the request.
Responds with 401 Unauthorized and finished the request if the client session is not authorized."
(let [session_id (self:get_cookie "session_id")]
(when (not (and session_id
(sessions_db.is_session_active? session_id)))
(self:set_status 401)
(self:finish))))
;; ---------------------------------- ;;
;; GET
(fn LinksAPIHandler.get [self ?query]
"Return up to config.query_limit most recent links from the database that contain `?query` in link_id or link_dest.
If `?query` is not a valid base62 string, return up to config.query_limit most recent links from the database.
If custom (provided in GET body) is set, return only links with custom IDs.
Responds with 200 OK and an array of links."
(let [custom (self:get_argument "custom" false true)]
(if (link_utils.valid_link_id? ?query)
(self:write (links_db.search_links ?query config.query_limit custom))
(self:write (links_db.fetch_links config.query_limit custom)))))
;; ---------------------------------- ;;
;; POST
(fn LinksAPIHandler.post [self ?link_id]
"Insert a link to link_dest (provided in POST body) with optional custom ID `?link_id` into the database.
Responds with 200 OK and the ID of the new link on success.
Responds with 409 Conflict if the ID is already taken.
Responds with 400 Bad Request if the provided link destination is not a valid URI."
(let [link_id (if (link_utils.valid_link_id? ?link_id) ?link_id nil)
link_dest (link_utils.normalize_uri (self:get_argument "link_dest" nil true))]
(if (link_utils.valid_link_dest? link_dest)
(let [result (links_db.insert_link link_id link_dest)]
(if result
(self:write result)
(self:set_status 409)))
(self:set_status 400))))
;; ---------------------------------- ;;
;; PUT
(fn LinksAPIHandler.put [self link_id]
"Redirect the link with ID `link_id` to new destination link_dest (provided in POST body) in the database.
Responds with 204 No Content on success.
Responds with 500 Internal Server Error on failure.
Responds with 404 Not Found if the specified link cannot be found.
Responds with 400 Bad Request if the provided link destination is not a valid URI."
(let [link_dest (link_utils.normalize_uri (self:get_argument "link_dest" nil true))]
(if (link_utils.valid_link_dest? link_dest)
(if (links_db.fetch_link_dest link_id)
(if (links_db.redirect_link link_id link_dest)
(self:set_status 204)
(self:set_status 500))
(self:set_status 404))
(self:set_status 400))))
;; ---------------------------------- ;;
;; DELETE
(fn LinksAPIHandler.delete [self link_id]
"Delete the link with ID `link_id` from the database.
Responds with 204 No Content on success.
Responds with 500 Internal Server Error on failure.
Responds with 404 Not Found if the specified link cannot be found."
(if (links_db.fetch_link_dest link_id)
(if (links_db.delete_link link_id)
(self:set_status 204)
(self:set_status 500))
(self:set_status 404)))
;; -------------------------------------------------------------------------- ;;
;; Return handler
LinksAPIHandler

View File

@ -0,0 +1,34 @@
;; handlers/RedirectHandler.fnl
;; Provides a RequestHandler that handles redirects for shortened links
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local turbo (require :turbo))
;; -------------------------------------------------------------------------- ;;
;; Local modules
(local links_db (require :modules.links_db))
;; -------------------------------------------------------------------------- ;;
;; Handler definition
(local RedirectHandler (class "RedirectHandler" turbo.web.RequestHandler))
;; ---------------------------------- ;;
;; GET
(fn RedirectHandler.get [self link_id]
"Redirect the client to the destination of the link with ID `link_id` in the database.
If `link_id` is found, the link will also have its visit count incremented by one.
If `link_id` is not found, the client will be redirected to root.
Responds with 302 Found and a redirect header."
(let [dest (links_db.fetch_link_dest link_id)]
(when dest (links_db.increment_link_visit_count link_id))
(self:redirect (or dest "/"))))
;; -------------------------------------------------------------------------- ;;
;; Return handler
RedirectHandler

View File

@ -0,0 +1,85 @@
;; handlers/SessionAPIHandler.fnl
;; Provides a RequestHandler that handles service logic for the /session API endpoint
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local basexx (require :basexx))
(local turbo (require :turbo))
(local otp (require :otp))
;; -------------------------------------------------------------------------- ;;
;; Local modules
(local auth_utils (require :modules.auth_utils))
(local config (require :modules.config))
(local sessions_db (require :modules.sessions_db))
;; -------------------------------------------------------------------------- ;;
;; Helper functions
(fn generate_cookie_header [name value expire_hours]
"Return a header string containing a cookie definition.
Cookie will have name `name` and value `value`, and will be set to expire in `expire_hours` hours."
(let [max_age_seconds (* expire_hours 60 60)]
(string.format "%s=%s; Path=/; Max-Age=%d; HttpOnly; %s"
(turbo.escape.escape name)
(turbo.escape.escape value)
max_age_seconds
(if config.use_secure_cookies
"SameSite=Strict; Secure;"
"SameSite=Lax;"))))
;; -------------------------------------------------------------------------- ;;
;; Handler definition
(local SessionAPIHandler (class "SessionAPIHandler" turbo.web.RequestHandler))
;; ---------------------------------- ;;
;; GET
(fn SessionAPIHandler.get [self]
"Inform the client of the status of their session.
Responds with 204 No Content if there is an active session.
Responds with 401 Unauthorized if there is no active session."
(let [session_id (self:get_cookie "session_id")]
(if (and session_id
(sessions_db.is_session_active? session_id))
(self:set_status 204)
(self:set_status 401))))
;; ---------------------------------- ;;
;; POST
(fn SessionAPIHandler.post [self]
"Start a new session for the client after verifying password (provided in POST body).
Responds with 200 OK and a cookie containing the new session ID on success.
Responds with 500 Internal Server Error on failure.
Responds with 401 Unauthorized if an incorrect password was provided."
(let [password (self:get_argument "password" nil true)]
(if (auth_utils.verify_password password)
(let [session_id (sessions_db.start_session)]
(if session_id
(self:add_header "Set-Cookie" (generate_cookie_header "session_id" session_id config.session_max_length_hours))
(self:set_status 500)))
(self:set_status 401))))
;; ---------------------------------- ;;
;; DELETE
(fn SessionAPIHandler.delete [self]
"End the client's current active session, if there is one.
Responds with 205 Reset Content on success.
Responds with 500 Internal Server Error on failure.
Responds with 404 Not Found if there is no active session."
(let [session_id (self:get_cookie "session_id")]
(if session_id
(if (sessions_db.end_session session_id)
(self:set_status 205)
(self:set_status 500))
(self:set_status 404))))
;; -------------------------------------------------------------------------- ;;
;; Return handler
SessionAPIHandler

View File

@ -0,0 +1,52 @@
;; handlers/ValidateAPIHandler.fnl
;; Provides a ValidateAPIHandler that handles service logic for the /validate API endpoint
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local turbo (require :turbo))
;; -------------------------------------------------------------------------- ;;
;; Local modules
(local link_utils (require :modules.link_utils))
(local links_db (require :modules.links_db))
(local sessions_db (require :modules.sessions_db))
;; -------------------------------------------------------------------------- ;;
;; Handler definition
(local ValidateAPIHandler (class "ValidateHandler" turbo.web.RequestHandler))
(fn ValidateAPIHandler.prepare [self]
"Check that the client session is authorized before handling the request.
Responds with 401 Unauthorized and finished the request if the client session is not authorized."
(let [session_id (self:get_cookie "session_id")]
(when (not (and session_id
(sessions_db.is_session_active? session_id)))
(self:set_status 401)
(self:finish))))
;; ---------------------------------- ;;
;; GET
(fn ValidateAPIHandler.get [self data_type]
"Validate data (provided in GET body) against the standard for `data_type`.
Responds with 204 No Content if the data is valid for `data_type`.
Responds with 406 Not Acceptable if the data is not valid for `data_type`.
Responds with 400 Bad Request if `data_type` is not recognized."
(let [data (self:get_argument "data" nil true)]
(match data_type
"link_id" (if (and (link_utils.valid_link_id? data)
(links_db.link_id_available? data))
(self:set_status 204)
(self:set_status 406))
"link_dest" (if (link_utils.valid_link_dest? (link_utils.normalize_uri data))
(self:set_status 204)
(self:set_status 406))
_ (self:set_status 400))))
;; -------------------------------------------------------------------------- ;;
;; Return handler
ValidateAPIHandler

30
src/init.lua Normal file
View File

@ -0,0 +1,30 @@
-- init.lua
-- Program entry point
--------------------------------------------------------------------------------
-- Bootstrap Fennel compiler
local fennel = require( 'fennel' )
table.insert( package.loaders, fennel.make_searcher( { correlate = true } ) )
--------------------------------------------------------------------------------
-- Initialize server
----------------------------------------
-- Disable turbo's static file caching functionality if requested
if ( os.getenv( 'disable_cache' ) ) then
print( 'WARNING: Static file caching is disabled!' )
_G.TURBO_STATIC_MAX = -1
end
----------------------------------------
-- Seed RNG (Lua... why...)
math.randomseed( os.time() )
----------------------------------------
-- Load server code
print( 'Initializing server...' )
require( 'server' )

View File

@ -0,0 +1,44 @@
;; modules/auth_utils.fnl
;; Provides a collection of utilities for working with passwords and TOTP passcodes
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local basexx (require :basexx))
(local otp (require :otp))
(local qrprinter (require :qrprinter))
;; -------------------------------------------------------------------------- ;;
;; Local modules
(local config (require :modules.config))
;; -------------------------------------------------------------------------- ;;
;; TOTP generator/validator
(local totp (otp.new_totp_from_key (basexx.to_base32 config.auth_secret)))
;; -------------------------------------------------------------------------- ;;
;; Module definition
(local auth_utils {})
(fn auth_utils.verify_password [password]
"Return true if `password` is valid.
Compares `password` to config.auth_secret if config.use_totp is set to false.
Compares `password` to the current valid TOTP (generated with config.auth_secret as the key)."
(if config.use_totp
(totp:verify password)
(= password config.auth_secret)))
(fn auth_utils.print_totp_qr []
"Print a QR code containing the TOTP configuration URL to stdout."
(let [totp_url (totp:get_url "simple-shortener" "User")
qr (qrprinter.encode_string totp_url)]
(print "TOTP configuration:")
(qrprinter.print_qr qr)))
;; -------------------------------------------------------------------------- ;;
;; Return module
auth_utils

36
src/modules/config.fnl Normal file
View File

@ -0,0 +1,36 @@
;; modules/config.fnl
;; Loads and provides configuration values from environment variables
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local ezenv (require :ezenv))
;; -------------------------------------------------------------------------- ;;
;; Configuration structure
(local config_structure {:auth_secret {:type "string"
:required true}
:enable_control_panel {:type "boolean"
:default true}
:enable_landing_page {:type "boolean"
:default true}
:landing_page_index {:type "string"
:default "index.html"}
:listen_port {:type "number"
:default 80}
:query_limit {:type "number"
:default 100}
:session_max_length_hours {:type "number"
:default 24}
:session_max_idle_length_hours {:type "number"
:default 1}
:use_secure_cookies {:type "boolean"
:default true}
:use_totp {:type "boolean"
:default true}})
;; -------------------------------------------------------------------------- ;;
;; Load and provide config
(ezenv.load config_structure)

View File

@ -0,0 +1,47 @@
;; modules/link_utils.fnl
;; Provides a collection of utilities for working with link IDs and URIs
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local hashids (require :hashids))
;; -------------------------------------------------------------------------- ;;
;; Local modules
(local config (require :modules.config))
(local links_db (require :modules.links_db))
;; -------------------------------------------------------------------------- ;;
;; Accepted character sets
(local base62_accepted_char_set "[%a%d]+")
(local scheme_accepted_char_set "[%a%d%.%-]+")
;; -------------------------------------------------------------------------- ;;
;; Module definition
(local link_utils {})
(fn link_utils.normalize_uri [uri]
"Return `uri` normalized. This will add a scheme (default http) if one is not present.)"
(if (string.find uri (.. "^" scheme_accepted_char_set "://"))
uri
(.. "http://" uri)))
(fn link_utils.valid_link_id? [link_id]
"Return true if `link_id` is a valid base62 link ID, otherwise return false."
(if (string.find link_id (.. "^" base62_accepted_char_set "$"))
true
false))
(fn link_utils.valid_link_dest? [link_dest]
"Return true if 'link_dest' is a valid URI, otherwise return false."
(if (string.find link_dest (.. "^" scheme_accepted_char_set "://.+$"))
true
false))
;; -------------------------------------------------------------------------- ;;
;; Return module
link_utils

160
src/modules/links_db.fnl Normal file
View File

@ -0,0 +1,160 @@
;; modules/links_db.fnl
;; Provides a simple interface for storing and retrieving shortened links with SQLite
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local hashids (require :hashids))
(local lume (require :lume))
(local sqlite (require :sqlite))
;; -------------------------------------------------------------------------- ;;
;; Constants
(local sql_glob_links_query "
SELECT link_id, link_dest, visit_count FROM links
WHERE (link_id GLOB :glob_pattern OR link_dest GLOB :glob_pattern)
AND is_custom BETWEEN :custom AND 1
ORDER BY created DESC")
;; -------------------------------------------------------------------------- ;;
;; Helper functions
(fn random_whole_number [num_digits]
"Return a random whole number with `num_digits` digits."
(math.floor (* (math.random) (^ 10 num_digits))))
(fn generate_link_id []
"Return a random 6 character base62 link ID."
(let [seed_length 10
id_length 6
encoder (hashids.new (tostring (random_whole_number seed_length)) id_length)]
(encoder:encode (random_whole_number id_length))))
(fn encode_glob_string [string]
"Return `string` encoded as a case-insensitive glob string."
(let [encode_char_fn (fn [char] (.. "[" (string.lower char) (string.upper char) "]"))]
(string.gsub string "%a" encode_char_fn)))
;; -------------------------------------------------------------------------- ;;
;; Initialize database
;; Tables:
;; links | Stores information about shortened links
;; link_id - The unique base62 identifying string for the shortened link
;; link_dest - The fully qualified URI that the shortened link points at
;; is_custom - Whether `link_id` was a custom value provided by the user
;; created - When the link was created (stored as Unix time)
;; deleted - When the link was deleted (stored as Unix time)
;; (if zero, the link has not been deleted)
(local db (sqlite {:uri "db/links.db"
:links {:link_id {:type "text"
:primary true}
:link_dest {:type "text"
:required true}
:is_custom {:type "number"
:default 0}
:visit_count {:type "number"
:default 0}
:created {:type "number"
:default (sqlite.lib.strftime "%s" "now")}
:deleted {:type "number"
:default 0}}}))
;; ---------------------------------- ;;
;; Database method overrides
(fn db.links.id_available? [self link_id]
"Return true if a row with primary key `link_id` is not yet in the database, otherwise return false."
(= (length (self:__get {:where {: link_id}})) 0))
(fn db.links.where [self where]
"Return first match against `where` that has not been flagged as deleted.
Returns a table containing the row data on success, otherwise nil."
(let [where (lume.merge (or where {}) {:deleted 0})]
(self:__where where)))
(fn db.links.get [self query]
"Return rows matching `query` that have not been flagged as deleted.
Returns a sequential table of rows."
(let [where (lume.merge (or query.where {}) {:deleted 0})
query (lume.merge query {:where where})]
(self:__get query)))
;; -------------------------------------------------------------------------- ;;
;; Module definition
(local links_db {})
(fn links_db.insert_link [?link_id link_dest]
"Insert a link to `link_dest` and with optional custom ID `link_id` into the database.
Returns the ID of the link after insertion, or nil if the operation failed."
(let [link_id (or ?link_id (generate_link_id))
is_custom (if ?link_id 1 0)]
(if (and (db.links:id_available? link_id)
(db.links:insert {: link_id : link_dest : is_custom}))
link_id
nil)))
(fn links_db.link_id_available? [link_id]
"Return true if a link with ID `link_id` is not yet in the database, otherwise return false."
(db.links:id_available? link_id))
(fn links_db.redirect_link [link_id link_dest]
"Redirect the link with ID `link_id` to new destination `link_dest` in the database.
Returns true on success, otherwise returns false."
(and (db.links:where {: link_id})
(db.links:update {:where {: link_id}
:set {: link_dest}})))
(fn links_db.delete_link [link_id]
"Flag the link with ID `link_id` as deleted in the database.
If the link has a non-zero is_custom column, then the row will deleted so that the ID may be reused.
Returns true on success, otherwise returns false."
(let [current_time (os.time)
link (db.links:where {: link_id})]
(if (= link.is_custom 0)
(db.links:update {:where {: link_id}
:set {:deleted current_time}})
(db.links:remove {: link_id}))))
(fn links_db.increment_link_visit_count [link_id]
"Increment the recorded visit count of the link with ID `link_id` in the database.
Returns true on success, otherwise returns false."
(let [link (db.links:where {: link_id})]
(db.links:update {:where {: link_id}
:set {:visit_count (+ link.visit_count 1)}})))
(fn links_db.fetch_link_dest [link_id]
"Return the destination of the link with ID `link_id` from the database.
Returns a URI string on success, otherwise returns nil."
(let [link (db.links:where {: link_id})]
(?. link :link_dest)))
(fn links_db.fetch_links [limit ?custom]
"Return up to `limit` of the most recent link_id/link_dest pairs from the database.
If `?custom` is non-nil, return only links with custom IDs.
Returns a sequential table of links."
(db.links:get {:select [:link_id :link_dest :visit_count]
:where (when ?custom {:is_custom 1})
:order_by {:desc :created}
: limit}))
(fn links_db.search_links [query limit ?custom]
"Return up to `limit` of the most recent link_id/link_dest pairs from the database that contain `query`.
Both the ID and the destination URI of each link are searched for `query`.
If `?custom` is non-nil, return only links with custom IDs.
Returns a sequential table of links."
(let [glob_pattern (.. "*" (encode_glob_string query) "*")
custom (if ?custom 1 0)
search_fn #(db:eval sql_glob_links_query {: glob_pattern : custom})
results (db:with_open search_fn)]
(if (= (type results) "table")
results
[])))
;; -------------------------------------------------------------------------- ;;
;; Return module
links_db

View File

@ -0,0 +1,99 @@
;; modules/load_config.fnl
;; Loads, validates, and provides the configuration values in 'config.fnl'
;; -------------------------------------------------------------------------- ;;
;; Load user configuration file
(var config nil)
(let [(ok err) (pcall #(set config (require :config)))]
(when (not ok)
(print "Stopping server: Missing or invalid config file. See `config.fnl.example` for an example config file.")
(print err)
(os.exit)))
;; -------------------------------------------------------------------------- ;;
;; Expected configuration structure
(local expected_config_structure {:auth_secret {:type "string"
:default "example auth secret"
:not_default true}
:landing_page_index {:type "string"
:default "index.html"}
:listen_port {:type "number"
:default 80}
:provide_api? {:type "boolean"
:default true}
:provide_control_panel? {:type "boolean"
:default true
:depends_on {:provide_api? true}}
:provide_landing_page? {:type "boolean"
:default true}
:query_limit {:type "number"
:default 100}
:session_max_length_hours {:type "number"
:default 24}
:session_max_idle_length_hours {:type "number"
:default 1}
:use_secure_cookies? {:type "boolean"
:default false}
:use_totp? {:type "boolean"
:default false}})
;; -------------------------------------------------------------------------- ;;
;; Sanity checks
;; ---------------------------------- ;;
;; Ensure that the configured values are of the appropriate types
;; (This also checks for missing values: they will be reported as being of type "nil")
(fn assert_config_value_type [key type_str]
"Assert that the value at `key` in the config file is of type `type_str`."
(when (not (= (type (. config key)) type_str))
(print "Stopping server: Invalid value type in your config file.")
(print " - Key name: " key)
(print " - Expected type: " type_str)
(print " - Actual type: " (type (. config key)))
(os.exit)))
(each [setting_key setting_properties (pairs expected_config_structure)]
(when (?. setting_properties :type)
(assert_config_value_type setting_key setting_properties.type)))
;; ---------------------------------- ;;
;; Ensure that the configured values have their dependencies satisfied
(fn assert_config_value_dependencies_satisfied [key dependencies]
"Assert that the value at `key` in the config file has its dependencies `dependencies` satisfied.
`dependencies` is a table of config file keys and their respective expected values."
(each [dependency_key dependency_value (pairs dependencies)]
(when (not (= (. config dependency_key) dependency_value))
(print "Stopping server: Invalid configuration in your config file.")
(print " - Key name: " key)
(print " - Depends on: " dependency_key dependency_value)
(print " - Your config: " dependency_key (. config dependency_key))
(os.exit))))
(each [setting_key setting_properties (pairs expected_config_structure)]
(when (?. setting_properties :depends_on)
(assert_config_value_dependencies_satisfied setting_key setting_properties.depends_on)))
;; ---------------------------------- ;;
;; Ensure that defaults aren't used for security-critical settings
(fn assert_config_value_not_default [key default]
"Assert that the value at `key` in the config file has been changed from its default value of `default`."
(when (= (. config key) default)
(print "Stopping server: Do not use the default values for security-critical settings in your config file!")
(print " - Key name: " key )
(print " - Default value: " default)
(print " - Found value: " (. config key))
(os.exit)))
(each [setting_key setting_properties (pairs expected_config_structure)]
(when (?. setting_properties :not_default)
(assert_config_value_not_default setting_key setting_properties.not_default)))
;; -------------------------------------------------------------------------- ;;
;; Return loaded config
config

120
src/modules/sessions_db.fnl Normal file
View File

@ -0,0 +1,120 @@
;; modules/sessions_db.fnl
;; Provides a simple interface for storing and retrieving client sessions with SQLite
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local hashids (require :hashids))
(local sqlite (require :sqlite))
;; -------------------------------------------------------------------------- ;;
;; Local modules
(local config (require :modules.config))
;; -------------------------------------------------------------------------- ;;
;; Constants
(local sql_trim_sessions_query (.. "
DELETE FROM sessions
WHERE started <= strftime('%s', 'now', '-" config.session_max_length_hours " hours')
OR last_active <= strftime('%s', 'now', '-" config.session_max_idle_length_hours " hours')
"))
;; -------------------------------------------------------------------------- ;;
;; Helper functions
(fn random_whole_number [num_digits]
"Return a random whole number with `num_digits` digits."
(math.floor (* (math.random) (^ 10 num_digits))))
(fn generate_session_id []
"Return a random 24 character base62 session ID."
(let [seed_length 10
id_length 24
encoder (hashids.new (tostring (random_whole_number seed_length)) id_length)]
(encoder:encode (random_whole_number id_length))))
;; -------------------------------------------------------------------------- ;;
;; Initialize database
;; Tables:
;; sessions | Stores information about current (authorized) client sessions
;; session_id - The unique base62 identifying string for the client session
;; started - When the session was initiated (stored as Unix time)
;; last_active - When the session last saw activity (stored as Unix time)
(local db (sqlite {:uri "db/sessions.db"
:sessions {:session_id {:type "text"
:primary true}
:started {:type "number"
:default (sqlite.lib.strftime "%s" "now")}
:last_active {:type "number"
:default (sqlite.lib.strftime "%s" "now")}}}))
;; ---------------------------------- ;;
;; Database method overrides
(fn db.sessions.trim [self]
"Trim expired sessions from the database."
(let [trim_fn #(db:execute sql_trim_sessions_query)]
(db:with_open trim_fn)))
(fn db.sessions.where [self where]
"Return first match against `where`.
Returns a table containing the row data on success, otherwise returns nil."
(self:trim)
(self:__where where))
(fn db.sessions.insert [self rows]
"Insert `rows` into the table.
Returns true on success, otherwise returns false."
(self:trim)
(self:__insert rows))
(fn db.sessions.update [self specs]
"Update rows in the table according to `specs`.
Returns true on success, otherwise returns false."
(self:trim)
(self:__update specs))
(fn db.sessions.remove [self where]
"Delete rows that match against `where`.
Returns true on success, otherwise returns false."
(self:trim)
(self:__remove where))
;; -------------------------------------------------------------------------- ;;
;; Module definition
(local sessions_db {})
(fn sessions_db.start_session []
"Start a session and insert it into the database.
Returns the ID of the new session, or nil if the operation failed."
(let [session_id (generate_session_id)]
(if (and (not (db.sessions:where {: session_id}))
(db.sessions:insert {: session_id}))
session_id
nil)))
(fn sessions_db.end_session [session_id]
"End the session with session ID `session_id` by removing it from the database.
Returns true on success, otherwise returns false."
(db.sessions:remove {: session_id}))
(fn sessions_db.is_session_active? [session_id]
"Check the database for the presence of an active (non-expired) session with ID `session_id`.
Also updates the last_active column of the session to the current time.
Returns true if an active session is found, otherwise returns false."
(let [current_time (os.time)]
(if (and (db.sessions:where {: session_id})
(db.sessions:update {:where {: session_id}
:set {:last_active current_time}}))
true
false)))
;; -------------------------------------------------------------------------- ;;
;; Return module
sessions_db

71
src/server.fnl Normal file
View File

@ -0,0 +1,71 @@
;; simple-shortener.fnl
;; Top-level server logic
;; -------------------------------------------------------------------------- ;;
;; Dependencies
(local lume (require :lume))
(local turbo (require :turbo))
;; -------------------------------------------------------------------------- ;;
;; Local modules
(local auth_utils (require :modules.auth_utils))
(local config (require :modules.config))
;; -------------------------------------------------------------------------- ;;
;; Request handlers
(local LinksAPIHandler (require :handlers.LinksAPIHandler))
(local RedirectHandler (require :handlers.RedirectHandler))
(local SessionAPIHandler (require :handlers.SessionAPIHandler))
(local ValidateAPIHandler (require :handlers.ValidateAPIHandler))
;; -------------------------------------------------------------------------- ;;
;; Assemble route list
(local route_list (lume.concat
;; Static control panel app (if enabled)
(when config.enable_control_panel
[["^/shorten/?$" turbo.web.StaticFileHandler "static/control_panel/index.html"]
["^/shorten/static/(.*)$" turbo.web.StaticFileHandler "static/control_panel/"]])
;; API endpoints
[["^/shorten/api/links/?$" LinksAPIHandler]
["^/shorten/api/links/([%a%d]+)$" LinksAPIHandler]
["^/shorten/api/session/?$" SessionAPIHandler]
["^/shorten/api/session/([%a%d]+)$" SessionAPIHandler]
["^/shorten/api/validate/([%a_]+)$" ValidateAPIHandler]]
;; Redirect handler
[["^/([%a%d]+)$" RedirectHandler]]
;; Static landing page (if enabled)
(when config.enable_landing_page
[["^/$" turbo.web.StaticFileHandler (.. "static/landing_page/" config.landing_page_index)]
["^/(.*)$" turbo.web.StaticFileHandler "static/landing_page/"]])))
;; -------------------------------------------------------------------------- ;;
;; Server initialization
;; ---------------------------------- ;;
;; Print TOTP QR (if TOTP auth is enabled)
(when config.use_totp
(auth_utils.print_totp_qr))
;; ---------------------------------- ;;
;; Print security warning (if secure cookies are disabled)
(when (not config.use_secure_cookies)
(print "WARNING: Secure cookies are disabled!"))
;; ---------------------------------- ;;
;; Start server
(let [app (turbo.web.Application:new route_list)
ioloop (turbo.ioloop.instance)]
(app:listen config.listen_port)
(print (.. "Listening on port " (tostring config.listen_port) "."))
(ioloop:start))

View File

@ -0,0 +1,308 @@
/*
* css/index.css
*
* App-wide style declarations
*/
/* -------------------------------------------------------------------------- */
/* Page layout */
html,
body
{
font-family: Open Sans, sans-serif;
font-size: 16px;
line-height: 1.5;
}
main
{
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2.5rem 2.5rem;
min-height: 100vh;
min-width: 100%;
animation: main-fade-in 0.15s;
}
@keyframes main-fade-in
{
from
{
transform: translate( -25px, 0 );
opacity: 0;
}
to
{
transform: translate( 0, 0 );
opacity: 1;
}
}
main .columns
{
width: 100%;
max-width: 1000px;
}
main .title
{
font-weight: 700;
margin-bottom: 2rem;
}
/* -------------------------------------------------------------------------- */
/* Generic component setup */
.box
{
margin: 0 0 0 0.75rem;
}
.box,
.card,
.modal-card,
.panel
{
box-shadow: none;
border: 1px solid #ddd;
border-radius: 0.75rem;
}
.button
{
font-weight: 700;
}
progress
{
max-width: 200px;
}
.control.is-loading::after
{
top: .75em;
}
/* -------------------------------------------------------------------------- */
/* Modal components */
.modal-card
{
width: 500px;
max-width: 90%;
animation: modal-slide-up 0.15s;
}
@keyframes modal-slide-up
{
from
{
transform: translate( 0, 25px );
opacity: 0;
}
to
{
transform: translate( 0, 0 );
opacity: 1;
}
}
.modal-card header
{
background: #fff;
border: none;
padding: 0.75rem 1rem;
}
.modal-card .modal-card-title
{
font-size: 16px;
line-height: inherit;
font-weight: 700;
}
.modal-card .delete
{
background: #485FC7;
}
.modal-card .delete:hover,
.modal-card .delete:focus
{
background: #363636;
}
.modal-card .delete:disabled
{
opacity: 0.5;
}
.modal-card-body,
.card-content
{
padding: 0.75rem 1.5rem 1.5rem 1.5rem;
}
.modal-background
{
background: rgba( 0, 0, 0, 0.1 );
}
.modal-card-body button
{
width: 8rem;
}
/* -------------------------------------------------------------------------- */
/* Auth view */
.Auth .shake
{
animation: shake 0.5s;
}
@keyframes shake {
0%, 100%
{
transform: translateX( 0 );
}
14%
{
transform: translateX( -1rem );
}
29%
{
transform: translateX( 1rem );
}
43%
{
transform: translateX( -0.75rem );
}
57%
{
transform: translateX( 0.75rem );
}
71%
{
transform: translateX( -0.5rem );
}
86%
{
transform: translateX( 0.5rem );
}
}
/* -------------------------------------------------------------------------- */
/* LinkList component */
.LinkList
{
min-height: 20rem;
max-height: 20rem;
overflow-y: scroll;
}
.LinkList .panel-block
{
display: block;
}
.LinkList .link-name,
.LinkList .link-dest
{
font-family: monospace;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.LinkList .link-name
{
font-weight: 600;
}
.LinkList .link-dest
{
font-weight: 300;
}
.LinkList .link-name .query-match,
.LinkList .link-dest .query-match
{
color: #23D160;
font-weight: 700;
}
.LinkList .nothing-here
{
display: block;
width: max-content;
margin: 9.25rem auto;
}
/* ---------------------------------- */
/* LinkOptionsModal component */
.LinkOptionsModal .visit-counter
{
font-weight: 700;
}
/* ---------------------------------- */
/* LogOutButton component */
.LogOutButton
{
position: absolute;
top: 0.25rem;
right: 0.25rem;
/* Bulma overrides: */
background: none !important;
border: none !important;
box-shadow: none !important;
}
.LogOutButton:hover,
.LogOutButton:focus
{
/* Bulma overrides: */
color: #363636 !important;
}
/* -------------------------------------------------------------------------- */
/* ShortenLinkCard component */
.ShortenLinkCard .card-header
{
box-shadow: none;
}
.ShortenLinkCard button
{
width: 8rem;
}
/* -------------------------------------------------------------------------- */
/* ShortenedLinksPanel component */
.ShortenedLinksPanel .panel-heading
{
font-size: inherit;
line-height: inherit;
background: none;
}
.ShortenedLinksPanel .panel-tabs a
{
font-weight: 700;
}
.ShortenedLinksPanel .panel-search
{
padding: .75rem 1.5rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@ -0,0 +1,30 @@
<!doctype html>
<!-- Want to host your own link shortening service? -->
<!-- Check out simple-shortener: -->
<!-- gitlab.com/jessieh/simple-shortener -->
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/shorten/static/favicon.ico" type="image/x-icon" />
<link type="text/css" rel="stylesheet" href="/shorten/static/lib/fontawesome/css/all.min.css" />
<link type="text/css" rel="stylesheet" href="/shorten/static/lib/bulma/css/bulma.min.css" />
<link type="text/css" rel="stylesheet" href="/shorten/static/css/index.css" />
<title>Link Shortener Control Panel</title>
</head>
<body>
<main>
<progress title="Initializing" class="progress is-small is-light"></progress>
</main>
<script src="/shorten/static/lib/mithril/mithril.min.js"></script>
<script type="module" src="/shorten/static/js/index.js"></script>
</body>
</html>

View File

@ -0,0 +1,141 @@
/**
* components/LinkDeletionModal.js
*
* @file Provides a "Link deletion" modal component that allows the user to delete a link
*
* @author Jessie Hildebrandt
*/
/* -------------------------------------------------------------------------- */
/* Local module imports */
import { LinkDeletionModal as Controller } from '../controllers/LinkDeletionModal.js';
/* -------------------------------------------------------------------------- */
/* Helper functions */
/* ---------------------------------- */
/* formatCountdownValue */
/**
* Format and return a countdown timer value into a pretty number
*
* @param {number} value - The countdown timer value to format
*
* @returns {string} The pretty formatted number
*/
function formatCountdownValue( value )
{
switch ( value )
{
case 2:
return '➌ ';
case 1:
return '➋ ';
case 0:
return '➊ ';
default:
return '… ';
}
}
/* -------------------------------------------------------------------------- */
/* Component implementation */
const LinkDeletionModal = {
/* ---------------------------------- */
/* oninit */
/**
* Called when the component is initialized
*
* @param {m.Vnode} vnode - The Vnode representing the component
*/
oninit: ( { attrs } ) => {
Controller.init();
Controller.load( attrs.linkID );
},
/* ---------------------------------- */
/* onremove */
/**
* Called before component element is removed from the DOM
*/
onremove: () => {
Controller.reset();
},
/* ---------------------------------- */
/* view */
/**
* Called whenever the component is drawn
*
* @returns {m.Vnode} - The Vnode or Vnode tree to be rendered
*/
view: ( { attrs } ) => {
return m( '.LinkDeletionModal.modal-card', [
m( 'header.modal-card-head', [
m( 'p.modal-card-title', 'Link deletion' ),
m( 'button.delete', {
disabled: Controller.isWaiting,
onclick: Controller.close
} )
] ),
m( 'section.modal-card-body', [
m( '.field', [
m( '.control.has-icons-left', [
m( 'input.input.is-static', {
type: 'text',
readonly: true,
value: attrs.linkID
} ),
m( 'span.icon.is-left', [
m( 'i.fa.fa-tag' )
] )
] )
] ),
m( '.field', [
m( '.control.has-icons-left', {
class: Controller.isWaiting ? 'is-loading' : null
}, [
m( 'input.input.is-static', {
type: 'text',
placeholder: 'Destination',
readonly: true,
value: Controller.linkDest
} ),
m( 'span.icon.is-left', [
m( 'i.fa.fa-link' )
] )
] )
] ),
m( '.field.has-text-centered', [
m( 'small', 'Are you sure that you want to delete this link?' )
] ),
m( '.field.is-grouped.is-justify-content-center', [
m( '.control', [
m( 'button.button.is-link.is-outlined', {
disabled: Controller.isWaiting,
onclick: () => Controller.openOptionsModal( attrs.linkID )
}, 'Go back' )
] ),
m( '.control', [
m( 'button.button.is-danger.is-outlined', {
disabled: Controller.isWaiting || Controller.isCountingDown,
onclick: () => Controller.tryDelete( attrs.linkID )
}, `${ Controller.isCountingDown ? formatCountdownValue( Controller.deletionCountdown ) : '' }Confirm` )
] )
] )
] )
] );
}
}
/* -------------------------------------------------------------------------- */
/* Export */
export { LinkDeletionModal };

View File

@ -0,0 +1,88 @@
/**
* components/LinkList.js
*
* @file Provides a link list component that displays a list of shortened links
*
* @author Jessie Hildebrandt
*/
/* -------------------------------------------------------------------------- */
/* Helper functions */
/* ---------------------------------- */
/* highlightInText */
/**
* Return `text` with the first instance of `query` wrapped in a highlighting span component
* Returns `text` completely wrapped in a highlighting span component if `text` === `query`.
* Or, returns `text` if no instance of `query` was found.
*
* @returns {string} text - The text in which to search for the query
* @param {string} query - The query to highlight
*
* @returns {(m.Vnode|Array|string)} `text` with the first instance of `query` highlighted
*/
function highlightInText( text, query )
{
const regexp = new RegExp( `${ query }`, 'i' );
const matchIndex = text.search( regexp );
if ( matchIndex !== -1 )
{
if ( query.length === text.length )
{
return m( 'span.query-match', text );
}
const before = text.substring( 0, matchIndex );
const match = text.match( regexp );
const after = text.substring( matchIndex + query.length );
return [ before, m( 'span.query-match', match ), after ];
}
return text;
};
/* -------------------------------------------------------------------------- */
/* Component implementation */
const LinkList = {
/* ---------------------------------- */
/* view */
/**
* Called whenever the component is drawn
*
* @param {m.Vnode} vnode - The Vnode representing the component
*
* @returns {m.Vnode} - The Vnode or Vnode tree to be rendered
*/
view: ( { attrs } ) => {
return m( 'section.LinkList', [
attrs.links.length === 0
? [ m( '.nothing-here.has-text-grey-light', [ m( 'i.fa.fa-folder-open.mr-2' ) ], 'Nothing here' ) ]
: attrs.links.map( ( link ) => {
return m( 'a.panel-block', {
onclick: () => m.route.set( '/app/options/:linkID', { linkID: link.link_id } )
}, [
m( '.link-name', [
m( 'i.fa.fa-tag.has-text-grey-light.mr-2' ),
m( 'span', {
title: link.link_id
}, highlightInText( link.link_id, attrs.highlight ) )
] ),
m( '.link-dest', [
m( 'i.fa.fa-link.has-text-grey-light.mr-2' ),
m( 'span', {
title: link.link_dest
}, highlightInText( link.link_dest, attrs.highlight ) )
] )
] )
} )
] );
}
}
/* -------------------------------------------------------------------------- */
/* Export */
export { LinkList };

View File

@ -0,0 +1,199 @@
/**
* components/LinkOptionsModal.js
*
* @file Provides a "Link options" modal component that allows the user to edit a link
*
* @author Jessie Hildebrandt
*/
/* -------------------------------------------------------------------------- */
/* Local module imports */
import { LinkOptionsModal as Controller } from '../controllers/LinkOptionsModal.js';
/* -------------------------------------------------------------------------- */
/* Helper functions */
/* ---------------------------------- */
/* controlClassFromFieldState */
/**
* Return the appropriate CSS class name for a control element to reflect `fieldState`
*
* @param {string} fieldState - The state of the field wrapped by the control element
*
* @returns {?string} The CSS class name for the control element
*/
function controlClassFromFieldState( fieldState )
{
switch( fieldState )
{
case Controller.FIELD_STATE.VALIDATING:
return 'is-loading';