mirror of
https://gitlab.com/jessieh/simple-shortener.git
synced 2025-06-02 03:32:33 +00:00
176 lines
7.0 KiB
Fennel
176 lines
7.0 KiB
Fennel
;; 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))
|
|
|
|
;; -------------------------------------------------------------------------- ;;
|
|
;; Local modules
|
|
|
|
(local fs_utils (require :modules.fs_utils))
|
|
|
|
;; -------------------------------------------------------------------------- ;;
|
|
;; 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
|
|
|
|
;; ---------------------------------- ;;
|
|
;; Ensure 'db/' folder exists
|
|
|
|
(when (not (fs_utils.dir_exists? "db"))
|
|
(print "🚨 ERROR: No 'db/' folder to place SQLite database files in.\n❓ Did you forget to mount a volume at '/simple-shortener/db'?")
|
|
(os.exit -1))
|
|
|
|
;; ---------------------------------- ;;
|
|
;; Connect to SQLite 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
|