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:
commit
4552d0d205
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
49
Dockerfile
Normal 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
11
README.md
Normal 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
67
config_template.env
Normal 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
|
96
src/handlers/LinksAPIHandler.fnl
Normal file
96
src/handlers/LinksAPIHandler.fnl
Normal 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
|
34
src/handlers/RedirectHandler.fnl
Normal file
34
src/handlers/RedirectHandler.fnl
Normal 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
|
85
src/handlers/SessionAPIHandler.fnl
Normal file
85
src/handlers/SessionAPIHandler.fnl
Normal 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
|
52
src/handlers/ValidateAPIHandler.fnl
Normal file
52
src/handlers/ValidateAPIHandler.fnl
Normal 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
30
src/init.lua
Normal 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' )
|
44
src/modules/auth_utils.fnl
Normal file
44
src/modules/auth_utils.fnl
Normal 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
36
src/modules/config.fnl
Normal 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)
|
47
src/modules/link_utils.fnl
Normal file
47
src/modules/link_utils.fnl
Normal 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
160
src/modules/links_db.fnl
Normal 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
|
99
src/modules/load_config.fnl
Normal file
99
src/modules/load_config.fnl
Normal 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
120
src/modules/sessions_db.fnl
Normal 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
71
src/server.fnl
Normal 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))
|
308
src/static/control_panel/css/index.css
Normal file
308
src/static/control_panel/css/index.css
Normal 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;
|
||||
}
|
BIN
src/static/control_panel/favicon.ico
Normal file
BIN
src/static/control_panel/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 88 KiB |
30
src/static/control_panel/index.html
Normal file
30
src/static/control_panel/index.html
Normal 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>
|
141
src/static/control_panel/js/components/LinkDeletionModal.js
Normal file
141
src/static/control_panel/js/components/LinkDeletionModal.js
Normal 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 };
|
88
src/static/control_panel/js/components/LinkList.js
Normal file
88
src/static/control_panel/js/components/LinkList.js
Normal 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 };
|
199
src/static/control_panel/js/components/LinkOptionsModal.js
Normal file
199
src/static/control_panel/js/components/LinkOptionsModal.js
Normal 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';
|
||||