diff --git a/.gitignore b/.gitignore index 55be276..d8b95ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,154 +1,170 @@ -# ---> Python -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - +# Common Lisp Stuff +*.fasl +*.dx32fsl +*.dx64fsl +*.lx32fsl +*.lx64fsl +*.x86f +*~ +.#* + +# Python Stuff +-# Byte-compiled / optimized / DLL files +-__pycache__/ +-*.py[cod] +-*$py.class +- +-# C extensions +-*.so +- +-# Distribution / packaging +-.Python +-build/ +-develop-eggs/ +-dist/ +-downloads/ +-eggs/ +-.eggs/ +-lib/ +-lib64/ +-parts/ +-sdist/ +-var/ +-wheels/ +-share/python-wheels/ +-*.egg-info/ +-.installed.cfg +-*.egg +-MANIFEST +- +-# PyInstaller +-# Usually these files are written by a python script from a template +-# before PyInstaller builds the exe, so as to inject date/other infos into it. +-*.manifest +-*.spec +- +-# Installer logs +-pip-log.txt +-pip-delete-this-directory.txt +- +-# Unit test / coverage reports +-htmlcov/ +-.tox/ +-.nox/ +-.coverage +-.coverage.* +-.cache +-nosetests.xml +-coverage.xml +-*.cover +-*.py,cover +-.hypothesis/ +-.pytest_cache/ +-cover/ +- +-# Translations +-*.mo +-*.pot +- +-# Django stuff: +-*.log +-local_settings.py +-db.sqlite3 +-db.sqlite3-journal +- +-# Flask stuff: +-instance/ +-.webassets-cache +- +-# Scrapy stuff: +-.scrapy +- +-# Sphinx documentation +-docs/_build/ +- +-# PyBuilder +-.pybuilder/ +-target/ +- +-# Jupyter Notebook +-.ipynb_checkpoints +- +-# IPython +-profile_default/ +-ipython_config.py +- +-# pyenv +-# For a library or package, you might want to ignore these files since the code is +-# intended to run in multiple environments; otherwise, check them in: +-# .python-version +- +-# pipenv +-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +-# However, in case of collaboration, if having platform-specific dependencies or dependencies +-# having no cross-platform support, pipenv may install dependencies that don't work, or not +-# install all needed dependencies. +-#Pipfile.lock +- +-# poetry +-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +-# This is especially recommended for binary packages to ensure reproducibility, and is more +-# commonly ignored for libraries. +-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +-#poetry.lock +- +-# PEP 582; used by e.g. github.com/David-OConnor/pyflow +-__pypackages__/ +- +-# Celery stuff +-celerybeat-schedule +-celerybeat.pid +- +-# SageMath parsed files +-*.sage.py +- +-# Environments +-.env +-.venv +-env/ +-venv/ +-ENV/ +-env.bak/ +-venv.bak/ +hot-line-python/venv/ +- +-# Spyder project settings +-.spyderproject +-.spyproject +- +-# Rope project settings +-.ropeproject +- +-# mkdocs documentation +-/site +- +-# mypy +-.mypy_cache/ +-.dmypy.json +-dmypy.json +- +-# Pyre type checker +-.pyre/ +- +-# pytype static type analyzer +-.pytype/ +- +-# Cython debug symbols +-cython_debug/ +- +-# PyCharm +-# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can +-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +-# and can be added to the global gitignore or merged into this file. For a more nuclear +-# option (not recommended) you can uncomment the following to ignore the entire idea folder. +-#.idea/ + +# Craig's Custom Stuff +# hot-line is a sym-link to the non-dev project folder. +/hot-line +/storage/* +/db/*.db \ No newline at end of file diff --git a/README.markdown b/README.markdown new file mode 100644 index 0000000..39f2366 --- /dev/null +++ b/README.markdown @@ -0,0 +1,16 @@ +# hot-line + +A proof-of-concept website which generates interactive charts whilst integrating with Python and its Bokeh library. + +## Usage + +## Installation + +## Author + +* Craig Oates (craig@craigoates.net) + +## Copyright + +Copyright (c) 2022 Craig Oates (craig@craigoates.net) + diff --git a/app.lisp b/app.lisp new file mode 100644 index 0000000..9886106 --- /dev/null +++ b/app.lisp @@ -0,0 +1,41 @@ +(ql:quickload :hot-line) + +(defpackage hot-line.app + (:use :cl) + (:import-from :lack.builder + :builder) + (:import-from :ppcre + :scan + :regex-replace) + (:import-from :hot-line.web + :*web*) + (:import-from :hot-line.config + :config + :productionp + :*static-directory*)) +(in-package :hot-line.app) + +(funcall clack-errors:*clack-error-middleware* + (builder + (:static + :path (lambda (path) + (if (ppcre:scan "^(?:/images/|/css/|/js/|/robot\\.txt$|/favicon\\.ico$)" path) + path + nil)) + :root *static-directory*) + (if (productionp) + nil + :accesslog) + (if (getf (config) :error-log) + `(:backtrace + :output ,(getf (config) :error-log)) + nil) + :session + (if (productionp) + nil + (lambda (app) + (lambda (env) + (let ((datafly:*trace-sql* t)) + (funcall app env))))) + *web*) + :debug t) diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/hot-line-python/README.md similarity index 100% rename from README.md rename to hot-line-python/README.md diff --git a/hot-line-python/app/hot-line-python.py b/hot-line-python/app/hot-line-python.py new file mode 100755 index 0000000..89eca2d --- /dev/null +++ b/hot-line-python/app/hot-line-python.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python +from bokeh.plotting import figure, output_file, save, show +from bokeh.models import Legend, FactorRange +from bokeh.palettes import Spectral4 +import pandas as pd +import numpy as np +import os.path +from sys import stdin +import argparse +import logging +from pathlib import Path +from rich import print +from rich.console import Console +from rich.markdown import Markdown +from rich.logging import RichHandler +from rich.traceback import install +import random + +# Global Variables (Making things easy for myself) +# ==================================================================== +__version__ = "1.0.0" +logging.basicConfig(level="NOTSET", format="%(message)s", + datefmt="[%X] ", handlers=[RichHandler()]) +log = logging.getLogger("rich") +console = Console() +# ==================================================================== + +def get_extension(path): + return os.path.splitext(path)[1] + +def parse_arguments(): + parser = argparse.ArgumentParser("hot-line-python") + parser.add_argument("-v", "--version", action="version", + version='%(prog)s: {version}'.format(version=__version__)) + parser.add_argument("-V", "--verbose", + help="Provide info. on the current state of the chart creation.", + action="store_true") + parser.add_argument("input", + help="The path of the data file which hot-line will use to make the chart..") + parser.add_argument("output", + help="The location hot-line will write the newly created chart to.") + parser.add_argument("-t", "--title", + help="The title displayed on the chart.") + parser.add_argument("-x", "--xaxis", + help="The label used on the chart's x-axis.") + parser.add_argument("-y", "--yaxis", + help="The label used on the chart's y-axis.") + + return parser.parse_args() + +def get_y_values(data): + r = (data.loc[0]) + return r[1::1] + +def random_colour(): + r = random.randint(0,255) + g = random.randint(0,255) + b = random.randint(0,255) + rgb = [r,g,b] + return rgb + +# I could reduce the duplicated code by extracting the 'verbose' code out but I +# don't plan on touching this much after it's able to generate charts. So, I'm +# at a point where I don't care. If this script needs to expand, then I will +# worry about refactoring and making the code pretty then. +def build_chart(arguments): + column_data = [] + column_headers = [] + x_values = [] + legend_items = [] + if arguments.verbose is True: + tasks = ["Loading file: Complete", + "Parsing data: Complete", + "Building chart outline: Complete", + "Populating chart: Complete", + "All Done"] + tasks_total = len(tasks) + with console.status("[bold green]Performing magic...") as status: + try: + # 1. Load file... + extension = get_extension(arguments.input) + if extension == ".tsv": + data = pd.read_csv(arguments.input, sep="\t", header=None, + index_col=False, dtype='unicode') + elif extension == ".csv": + data = pd.read_csv(arguments.input, sep=",", header=None, + index_col=False, dtype='unicode') + else: + log.critical("File is neither a .csv or .tsv file. Unable to process.") + return + y_labels = get_y_values(data) + data = data[1:] + task = tasks.pop(0) + console.log(f"1/{tasks_total}. {task}") + + # 2. Parse data... + for x in data.columns: + values = data.iloc[:, x] + column_data.append(values) + x_values = column_data[0] + column_data.pop(0) + task = tasks.pop(0) + console.log(f"2/{tasks_total}. {task}") + + # If x_values contains duplicated values, they will + # need an 'ID' appended to each value. This part of + # the code checks for them so the code below will know + # to append it or not. If there are no duplicated + # entries, the appendaged is not needed. + dupes = False + for item in x_values.duplicated(): + if item is False: + dupes = True + break + + # Add an 'ID' tag to the x-axis values. This is done + # to stop duplicated values which causes errors in + # Bokeh. + x_range = [] + if dupes == True: + counter = 0 + for row_value in x_values: + x_range.append(f"{counter}: {row_value}") + counter = counter + 1 + else: + x_range = x_values + + # 3. Build chart outline... + p = figure(title=arguments.title, + x_range=FactorRange(* x_range), + x_axis_label=arguments.xaxis, + y_axis_label=arguments.yaxis, + sizing_mode="stretch_both") + p.axis.major_label_text_font_size = "12px" + p.axis.major_label_standoff = -10 + task = tasks.pop(0) + console.log(f"3/{tasks_total}. {task}") + + # 4. Populate chart... + # The reason for the '+ 1' and 'y - 1' when specifying + # an index value is to avoid O.B.O.B's. Program throws an + # error when y_label[0] is used, can only access from + # '1' or above. column_data starts at 0. So, the index + # (y) needs to drop down 1 from the current 'y' value + # -- which indicates where about in the loop the + # program is regarding the y_labels list. + for y in range (1, (len(y_labels) + 1)): + col = random_colour() + p.line(x_range, + column_data[y - 1], + legend_label=f"{y_labels[y]}", + line_color=random_colour(), + line_width=2) + task = tasks.pop(0) + console.log(f"4/{tasks_total}. {task}") + + # 5. Write chart to disk... + output_file(arguments.output) + save(p) + task = tasks.pop(0) + console.log(f"5/{tasks_total}. {task}") + except IOError: + log.critical("File cannot be found.") + except Exception as e: + log.critical("Unrecoverable system error.") + console.print_exception() + else: + try: + # 1. Load file... + extension = get_extension(arguments.input) + if extension == ".tsv": + data = pd.read_csv(arguments.input, sep="\t", header=None, + index_col=False, dtype='unicode') + elif extension == ".csv": + data = pd.read_csv(arguments.input, sep=",", header=None, + index_col=False, dtype='unicode') + else: + log.critical("File is neither a .csv or .tsv file. Unable to process.") + return + y_labels = get_y_values(data) + data = data[1:] + + # 2. Parse data... + for x in data.columns: + values = data.iloc[:, x] + column_data.append(values) + x_values = column_data[0] + column_data.pop(0) + # If x_values contains duplicated values, they will + # need an 'ID' appended to each value. This part of + # the code checks for them so the code below will know + # to append it or not. If there are no duplicated + # entries, the appendaged is not needed. + dupes = False + for item in x_values.duplicated(): + if item is False: + dupes = True + break + + # Add an 'ID' tag to the x-axis values. This is done + # to stop duplicated values which causes errors in + # Bokeh. + x_range = [] + if dupes == True: + counter = 0 + for row_value in x_values: + x_range.append(f"{counter}: {row_value}") + counter = counter + 1 + else: + x_range = x_values + + # 3. Build chart outline... + p = figure(title=arguments.title, + x_range=FactorRange(* x_range), + x_axis_label=arguments.xaxis, + y_axis_label=arguments.yaxis, + sizing_mode="stretch_both") + p.axis.major_label_text_font_size = "12px" + p.axis.major_label_standoff = 10 + + # 4. Populate chart... + # The reason for the '+ 1' and 'y - 1' when specifying + # an index value is to avoid O.B.O.B's. Program throws an + # error when y_label[0] is used, can only access from + # '1' or above. column_data starts at 0. So, the index + # (y) needs to drop down 1 from the current 'y' value + # -- which indicates where about in the loop the + # program is regarding the y_labels list. + for y in range (1, (len(y_labels) + 1)): + col = random_colour() + p.line(x_range, + column_data[y - 1], + legend_label=f"{y_labels[y]}", + line_color=random_colour(), + line_width=2) + + # 5. Write chart to disk... + output_file(arguments.output) + save(p) + except IOError: + log.critical("File cannot be found.") + except Exception as e: + log.critical("Unrecoverable system error.") + console.print_exception() + +def main(): + args = parse_arguments() + build_chart(args) + +if __name__ == "__main__": + main() diff --git a/hot-line-python/requirements.txt b/hot-line-python/requirements.txt new file mode 100644 index 0000000..758eb2c --- /dev/null +++ b/hot-line-python/requirements.txt @@ -0,0 +1,18 @@ +bokeh==2.4.3 +commonmark==0.9.1 +Jinja2==3.1.2 +MarkupSafe==2.1.1 +numpy==1.23.1 +packaging==21.3 +pandas==1.4.3 +pathlib==1.0.1 +Pillow==9.2.0 +Pygments==2.12.0 +pyparsing==3.0.9 +python-dateutil==2.8.2 +pytz==2022.1 +PyYAML==6.0 +rich==12.5.1 +six==1.16.0 +tornado==6.2 +typing-extensions==4.3.0 diff --git a/hot-line.asd b/hot-line.asd new file mode 100644 index 0000000..bb3bbbf --- /dev/null +++ b/hot-line.asd @@ -0,0 +1,67 @@ +(defsystem "hot-line" + :version "0.1.0" + :author "Craig Oates" + :license "MIT" + :depends-on ("clack" + "lack" + "caveman2" + "envy" + "cl-ppcre" + "uiop" + + ;; for @route annotation + "cl-syntax-annot" + + ;; HTML Template + "djula" + + ;; for DB + "datafly" + "sxql" + + ;; Additional Packages + #:clack-errors ; Error report (HTML/template views) + #:mito ; Database ORM + #:osicat ; Environment variables (dev/prod.) + #:ratify ; Utilites + #:sqlite ; Sqlite database ORM + #:hermetic ; Authentication + #:cl-fad ; Files and directories + #:serapeum ; Pagination + ) + :pathname "src/" + :components (;; Caveman Files + (:file "config") + (:file "main") + (:file "db") + (:file "view") + ;; hot-line Specific Files + (:file "models/app-constants") + (:file "models/user") + (:file "models/file") + (:file "services/storage") + (:file "services/authentication") + (:file "services/validation") + (:file "services/convert") + (:file "services/user-management") + (:file "services/db-management") + (:file "services/storage-management") + (:file "services/pagination") + (:file "services/routing") + ;; Caveman Files + (:file "web")) + :description "A proof-of-concept website testing Python and its Bokeh library." + :build-operation "program-op" ;; leave as is + :build-pathname "hot-line-nellis" + :entry-point "hot-line:main" + :in-order-to ((test-op (test-op "hot-line/test")))) + +(defsystem #:hot-line/tests + :author "Craig Oates" + :license "MIT" + :depends-on (#:hot-line + #:parachute) + :pathname "tests/" + :components ((:file "tests")) + :description "Test system for hot-line" + :perform (test-op (op s) (symbol-call :parachute :test :tests))) diff --git a/src/config.lisp b/src/config.lisp new file mode 100644 index 0000000..8ed7377 --- /dev/null +++ b/src/config.lisp @@ -0,0 +1,45 @@ +;; (in-package :cl-user) +(defpackage hot-line.config + (:use :cl) + (:import-from :envy + :config-env-var + :defconfig) + (:export :config + :*application-root* + :*static-directory* + :*template-directory* + :appenv + :developmentp + :productionp)) +(in-package :hot-line.config) + +(setf (config-env-var) "APP_ENV") + +(defparameter *application-root* (asdf:system-source-directory :hot-line)) +(defparameter *static-directory* (merge-pathnames #P"static/" *application-root*)) +(defparameter *template-directory* (merge-pathnames #P"templates/" *application-root*)) + +(defconfig :common + `(:application-root ,(asdf:component-pathname (asdf:find-system :hot-line)) + :databases ((:maindb :sqlite3 :database-name ,(merge-pathnames #P"db/hot-line.db"))))) + +(defconfig |development| + '()) + +(defconfig |production| + '()) + +(defconfig |test| + '()) + +(defun config (&optional key) + (envy:config #.(package-name *package*) key)) + +(defun appenv () + (uiop:getenv (config-env-var #.(package-name *package*)))) + +(defun developmentp () + (string= (appenv) "development")) + +(defun productionp () + (string= (appenv) "production")) diff --git a/src/db.lisp b/src/db.lisp new file mode 100644 index 0000000..3ed557e --- /dev/null +++ b/src/db.lisp @@ -0,0 +1,26 @@ +;; (in-package :cl-user) +(defpackage hot-line.db + (:use :cl) + (:import-from :hot-line.config + :config) + (:import-from :datafly + :*connection*) + (:import-from :cl-dbi + :connect-cached) + (:export :connection-settings + :db + :with-connection)) +(in-package :hot-line.db) + +(defun connection-settings (&optional (db :maindb)) + (cdr (assoc db (config :databases)))) + +(defun db (&optional (db :maindb)) + (apply #'connect-cached (connection-settings db))) + +(defmacro with-connection (conn &body body) + ;; Added 'mito.connection:' to 'let' binding. To return to default, + ;; just remove it so it just says '*connection*'. mito replaces + ;; datafly. + `(let ((mito.connection:*connection* ,conn)) + ,@body)) diff --git a/src/main.lisp b/src/main.lisp new file mode 100644 index 0000000..6a14c74 --- /dev/null +++ b/src/main.lisp @@ -0,0 +1,56 @@ +;; (in-package :cl-user) +(defpackage hot-line + (:use :cl) + (:import-from :hot-line.config + :config) + (:import-from :clack + :clackup) + (:export :start + :stop + #:main)) +(in-package :hot-line) + +(defvar *appfile-path* + (asdf:system-relative-pathname :hot-line #P"app.lisp")) + +(defvar *handler* nil) + +(defun start (&rest args &key server port debug &allow-other-keys) + (declare (ignore server port debug)) + (when *handler* + (restart-case (error "Server is already running.") + (restart-server () + :report "Restart the server" + (stop)))) + (setf *handler* + (apply #'clackup *appfile-path* args))) + +(defun stop () + (prog1 + (clack:stop *handler*) + (setf *handler* nil))) + +#| 'main' Function Used For Starting Server From Script (I.E. Live Deployment) +================================================================================ +https://lisp-journey.gitlab.io/web-dev/#building +The code below was taken from the URL above (with slight modifications). It's +main use is to make it easier to start the server via a script. +|# +(defun main () + (start :server :hunchentoot :port 3002 :debug nil) + ;; with bordeaux-threads + (handler-case (bt:join-thread + (find-if (lambda (th) + (search "hunchentoot" (bt:thread-name th))) + (bt:all-threads))) + (#+sbcl sb-sys:interactive-interrupt + #+ccl ccl:interrupt-signal-condition + #+clisp system::simple-interrupt-condition + #+ecl ext:interactive-interrupt + #+allegro excl:interrupt-signal + () (progn + (format *error-output* "Aborting.~&") + (clack:stop *handler*) + (uiop:quit 1)) ;; portable exit, included in ASDF, already loaded. + ;; for others, unhandled errors (we might want to do the same). + (error (c) (format t "Woops, an unknown error occured:~&~a~&" c))))) diff --git a/src/models/app-constants.lisp b/src/models/app-constants.lisp new file mode 100644 index 0000000..23ab1f7 --- /dev/null +++ b/src/models/app-constants.lisp @@ -0,0 +1,256 @@ +(defpackage #:app-constants + (:use #:cl) + (:export #:+false+ + #:+true+ + + ;; OTHER-ITEMS LIST SIZES + #:+default-other-items-size+ + #:+single-other-items-size+ + #:+small-other-items-size+ + #:+large-other-items-size+ + #:+extra-large-other-items-size+ + + ;; PAGINATION SIZES + #:+default-page-size+ + #:+extra-small-page-size+ + #:+small-page-size+ + #:+single-page-size+ + #:+extra-large-page-size+ + #:+large-page-size+ + + ;; DIRECTORIES + #:+media-directory+ + #:+uploads-directory+ + + ;; GENERAL/GENERIC MESSAGES + #:+generic-fail+ + #:+generic-success+ + #:+nil-or-empty-string-used+ + #:+undetermined-file-type+ + + ;; SESSION MANAGEMENT + #:+incorrect-login-details+ + #:+user-not-found+ + + ;; STORAGE SECTION + #:+storage-directory-not-found+ + #:+storage-directory-deleted+ + #:+storage-file-already-exists+ + #:+storage-file-deleted+ + #:+storage-file-not-found+ + #:+storage-file-successful-upload+ + #:+storage-file-successfully-updated+ + + ;; USER MANAGEMENT + #:+username-already-taken+ + #:+new-user-added+ + #:+user-deleted+ + #:+user-not-authorised+ + #:+user-role-updated+ + #:+display-name-updated+ + #:+password-updated+ + #:+old-password-incorrect+)) +(in-package #:app-constants) + +#| Switched to `DEFINE-CONSTANT' from `DEFCONSTANT'. +================================================================================ +Because this website uses Steel Bank Common Lisp (SBCL), I need to go through a +cycle of confirming changes to the constant values even though they have not +changed. This behaviour is explained in the SBCL Manual 2.1.3 2021-03 (Section +2.3.4 Defining Constants, page 5 (printed) page 13 (PDF)). The key part of the +section is, + +'ANSI says that doing `DEFCONSTANT' of the same symbol more than once is +undefined unless the new value is eql to the old value.' + +http://www.sbcl.org/manual/#Defining-Constants (this URL should provide the +latest information of the subject). + +A workaround, provided by the SBCL Manual is to use the `DEFINE-CONSTANT' macro +instead of `DEFCONST'. By doing this, I can use Quickload to reload the code +(after a big change for example) and not have to repeat the cycle of 'updating' +the constants when they have not changed. +|# + +(defmacro define-constant (name value &optional doc) + `(defconstant ,name (if (boundp ',name) (symbol-value ',name) ,value) + ,@(when doc (list doc)))) + +#| SQLite does not have Boolean value types. +================================================================================ +At the time of writing (February 2022), the website uses SQLite as its +database. So, I have made these constants to reduce hard-coded `1' +and/or `0' values when `TRUE' and `NIL'/`FALSE' values are want is +meant (in the code-base). +|# + +(define-constant +false+ 0 + "An integer representing 'false' (for SQLite mostly).") + +(define-constant +true+ 1 + "An integer representing 'true' (for SQLite mostly.") + +;; These refer to the size of the 'Other X' lists displayed in the +;; 'Other X' sections of the content.html templates. Examples of the +;; 'Other' lists are 'Other Software Projects' and 'Other Artworks'. +(define-constant +default-other-items-size+ 10 + "The default number of items to show in the 'other X' section of + content.html templates.") + +(define-constant +single-other-items-size+ 1 + "Display a single item in the 'other X' section of content.html + templates -- use for testing.") + +(define-constant +small-other-items-size+ 5 + "The number of items to show in the 'other X' section of + content.html templates.") + +(define-constant +large-other-items-size+ 20 + "The number of items to show in the 'other X' section of + content.html templates.") + +(define-constant +extra-large-other-items-size+ 30 + "The number of items to show in the 'other X' section of + content.html templates.") + +;; The pagination code has a default value of 200 built-in. That is +;; why the amounts seems odd. +(define-constant +default-page-size+ 100 + "The default value for the amount of items to display on a paginated + page.") + +(define-constant +single-page-size+ 1 + "Display only one item per paginated page -- use for testing.") + +(define-constant +extra-small-page-size+ 25 + "The value for a smaller than default amount of items on a paginated + page.") + +(define-constant +small-page-size+ 50 + "The value for a smaller than default amount of items on a paginated + page.") + +(define-constant +large-page-size+ 150 + "The value for a larger than default amount of items on a paginated + page.") + +(define-constant +extra-large-page-size+ 250 + "The value for a larger than default amount of items on a paginated + page.") + +#| Alert Messages (add '| safe' to djula filter in HTML templates) +================================================================================ +How the alerts are normally rendered in the HTML templates: + +{% if alert %}{{alert | safe}}{% endif %} + +The constants below are to be used with the `:ALERT' values when rendering an +HTML template as part of a 'defroute' in 'web.lisp'. The reason for these +string values being stored here instead of in-lining them when the :alert is +formatted is two-fold: + +1. These messages can be multiple lines long which can make the code the +defroute a little messy. +2. Reduce repetitive code. The amount of repetitive code is not that high. But, +this will help. + +The '| safe' filter (djula) is needed because the HTML tags are removed without +it. Refer to the djula manual for more information: + +https://mmontone.github.io/djula/djula/Filters.html#format +|# + +(define-constant +generic-success+ + "

Task completed. Great success!

" + "Alert message. Intended as a basic or placeholder message.") + +(define-constant +media-directory+ "media" + "The /media directory holds the user's generic file uploads.") + +(define-constant +uploads-directory+ "uploads" + "The directory which holds the user's uploaded .csv and .tsv files.") + +(define-constant +generic-fail+ + "

Task failed.

" + "Alert message. Intended as a basic or placeholder message.") + +(define-constant +nil-or-empty-string-used+ + "

An 'empty-string' was provided by the user (most likely), or the +website's code came across a 'nil' value instead of a string.

" + "Alert message. Intended as an error message for all user-input sections.") + +(define-constant +undetermined-file-type+ + "

Cannot determine if file is a .csv or .tsv file.

" + "Alert message. Intended as an error message for all user-input sections.") + +(define-constant +incorrect-login-details+ + "

Name and password do not match.

" + "Use as an alert message, intented for the login routes.") + +(define-constant +user-not-found+ + "

No account found with those details.

" + "Use as an alert message, intented for the loging routes.") + +(define-constant +storage-directory-deleted+ + "

Directory deleted from /storage.

" + "Use as an alert message, intented for the /storage routes. +This is used for relaying a directory within the /storage directory is +deleted. It does not mean the /storage directory is deleted.") + +(define-constant +storage-directory-not-found+ + "

Directory not found in /storage.

" + "Use as an alert message, intented for the /storage routes. +Used to indicated a directory within /storage is not found and not the +/storage directory itself.") + +(define-constant +storage-file-already-exists+ + "

A file with that name already exists.

" + "Use as an alert message, intended for the /storage routes.") + +(define-constant +storage-file-deleted+ + "

File deleted.

" + "User as an alert message. Intended for the /storage routes.") + +(define-constant +storage-file-not-found+ + "

File(s) could not be found.

" + "Use as an alert message. Intended for the /storage routes.") + +(define-constant +storage-file-successful-upload+ + "

File uploaded. Great success!

" + "Use as an alert message. Intended for the /storage routes.") + +(define-constant +storage-file-successfully-updated+ + "

File updated!

" + "Use as an alert message. Intended for the /storage routes.") + +(define-constant +username-already-taken+ + "

Username already taken.

" + "Use as an alert message, intented for the /user routes.") + +(define-constant +new-user-added+ + "

New user added.

" + "Use as an alert message, intented for the /user routes.") + +(define-constant +user-deleted+ + "

User deleted.

" + "Use as an alert message, intented for the /user routes.") + +(define-constant +user-not-authorised+ + "

User is not authorised to make this change.

" + "Use as an alert message, intented for the /user routes.") + +(define-constant +user-role-updated+ + "

Role updated.

" + "Use as an alert message, intented for the /user routes.") + +(define-constant +display-name-updated+ + "

Display name changed.

" + "Use as an alert message, intented for the /user routes.") + +(define-constant +password-updated+ + "

Password updated.

" + "Use as an alert message, intented for the /user routes.") + +(define-constant +old-password-incorrect+ + "

Old password is incorrect.

" + "Use as an alert message, intented for the /user routes.") diff --git a/src/models/file.lisp b/src/models/file.lisp new file mode 100644 index 0000000..9fe67da --- /dev/null +++ b/src/models/file.lisp @@ -0,0 +1,63 @@ +(defpackage #:file + (:use #:cl + #:user) + (:export #:file)) +(in-package #:file) + +(defclass file () + ( + ;; ((id + ;; :documentation "The row Id. of the file stored in the database." + ;; :col-type :integer + ;; :initarg :id + ;; :accessor id-of) + (filename + :documentation "The name of the file." + :col-type (:varchar 128) + :initarg :filename + :accessor filename-of) + (username :col-type user) + (slug + :documentation "The dev. friendly way to refer to the file. It + mostly is about giving options to shorten the URL needed to get a + file if the `NAME' is long and makes it cumbersome to refer to it + in the back-end/admin. controls or URL." + :col-type (:varchar 128) + :initarg :slug + :accessor slug-of) + (content-type + :documentation "The type of the file -- not the file + extension. `CONTENT-TYPE' refers to the file being more of a 'text' + file or 'image' file than it being a '.md' file or '.png' file. The + main reason for this property is so I can represent the text files + with a graphic when managing the uploaded files to /storage." + :col-type (:varchar 128) + :initarg :content-type + :accessor content-type-of) + ;; (created_at + ;; :documentation "The date this meta-data was added to the + ;; database. This does not automatically mean it is the same time the + ;; data-file was uploaded to the /storage directory (within the + ;; website)." + ;; :col-type (:varchar 64) + ;; :initform (local-time:now) + ;; :initarg :created_at + ;; :accessor created_at) + ;; (updated_at + ;; :documentation "The last time the file was modified in the + ;; database. This does not automatically align with the actual + ;; data-file associated with this meta-data file. So, be careful when + ;; making assumption about this property." + ;; :col-type (:varchar 64) + ;; :initarg :updated_at + ;; :accessor updated_at)) + ) + (:documentation "The model used to describe `FILE' table in the + database. Essentially, this is the meta-data for the files uploaded + to the website and stored in the /storage directory. This class is + for keeping track of the files in /storage via the website's + database. It does not represent the actual file it is paired + with. The intended usage for this class is to provide ways to manage + the files in /storage from the website's (admin.) back-end.") + (:metaclass mito:dao-table-class)) + diff --git a/src/models/user.lisp b/src/models/user.lisp new file mode 100644 index 0000000..9261741 --- /dev/null +++ b/src/models/user.lisp @@ -0,0 +1,59 @@ +(defpackage #:user + (:use #:cl + #:hot-line.db + #:mito + #:app-constants) + ;;(:import-from) + (:export #:user + #:seeds)) +(in-package #:user) + +(defclass user () + ( + ;; ((id :col-type (:varchar 36) + ;; :primary-key t) + (username + :documentation "The name the user uses to log into the website." + :col-type (:varchar 64) + :initarg :username + :accessor username-of) + (display-name + :documentation "The name used in the website GUI (the pretty name)." + :col-type (or (:varchar 128) :null) + :initarg :display-name + :accessor display-name-of) + (administrator + :documentation "States if user has admin. priveledges. At the time + of writing (21/01/2022), SQLite is the current database and it + does not have a Boolean datatype so '0' represents 'false' and '1' + represents 'true'. You will not come across '0' or '1' in the code + because of how mito maps the code to the database. But, you will + see it in the database if you view it directly." + :col-type :integer + :initarg :administrator + :initform app-constants:+true+ ; SQLite: 0 -> false 1 -> true. + :accessor is-administrator-p) + (password + :documentation "The user's password. It is hashed using + cl-pass. The password is 'deflated' into the database. This means + the password is storted as a hash and returned as the value the + user provided." + :col-type :text + :initarg :password + :accessor password-of + :inflate #'cl-pass:hash + ;; :deflate #'cl-pass:hash + ) + ;; (created_at + ;; :documentation "The date the user's account was created." + ;; :col-type (:varchar 64) + ;; :initarg :created_at + ;; :accessor created_at) + ;; (updated_at + ;; :documentation "The last time the user logged into the website." + ;; :col-type (:varchar 64) + ;; :initarg :last-log-in + ;; :accessor updated_at) + ) + (:documentation "The model used to describe the `USER' table in the database") + (:metaclass mito:dao-table-class)) diff --git a/src/services/authentication.lisp b/src/services/authentication.lisp new file mode 100644 index 0000000..50e9847 --- /dev/null +++ b/src/services/authentication.lisp @@ -0,0 +1,85 @@ +(defpackage #:authentication + (:use #:cl + #:hermetic + #:sxql + ;; #:datafly + #:ningle + #:mito + #:user) + (:import-from #:hot-line.db + #:connection-settings + #:db + #:with-connection) + (:export #:csrf-token + #:get-user-roles + #:request-params + #:get-current-user + #:get-user-id + #:flash-gethash)) +(in-package #:authentication) + +(defun csrf-token () + "Cross-Site Request Forgery (CSRF) token." + (cdr (assoc "lack.session" + (lack.request:request-cookies ningle:*request*) + :test #'string=))) + +(hermetic:setup + ;; #' is needed. (hermetic:roles) generates infinite-loop when called + ;; otherwise -- 'roles' called in other parts of code-base. + + ;; #' is shorthand for the 'function' operator (returns the function + ;; object associated with the name of the function which is supplied + ;; as an argument. Keep forgetting that. + :user-p #'(lambda (username) + (with-connection (db) + (mito:find-dao 'user::user :username username))) + :user-pass #'(lambda (username) + (user::password-of + (with-connection (db) + (mito:find-dao 'user::user :username username)))) + :user-roles #'(lambda (username) + (cons :logged-in + (let ((user (with-connection (db) + (mito:find-dao + 'user::user :username username)))) + (and user + (= (user::is-administrator-p user) app-constants:+true+) + '(:administrator))))) + :Session ningle:*session* + :denied (constantly '(400 (:content-type "text/plain") ("Authentication denied")))) + +(defun get-current-user() + "Returns the currently logged in user from the browser session." + (with-connection (db) + (mito:find-dao 'user::user + :id (gethash :id ningle:*session*)))) + +(defun get-user-id (username) + "Returns the Id. number of the specified `USERNAME' in the database." + (with-connection (db) + (mito:object-id + (mito:find-dao 'user::user :username username)))) + +(defun request-params (request) + "Loops through the HTTP `REQUEST' and creates a key-value pairing." + (loop :for (key . value) :in request + :collect (let ((*package* (find-package :keyword))) + (read-from-string key)) + :collect value)) + +(defun get-user-roles() + "Returns a list of roles the current user has assigned to them. +This is mostly to check if the user is logged-in or has administration +privileges. You can then create if-blocks in the HTML templates and +control what the user can and cannot see or do." + (loop :for role :in (hermetic:roles) + :collect role + :collect t)) + +;; Copied over from rails-to-caveman project. I don't know what it +;; does exactly. +(defun flash-gethash (key table) + (let ((value (gethash key table))) + (remhash key table) + value)) diff --git a/src/services/convert.lisp b/src/services/convert.lisp new file mode 100644 index 0000000..ea82d9c --- /dev/null +++ b/src/services/convert.lisp @@ -0,0 +1,52 @@ +(defpackage #:convert + (:use #:cl + #:app-constants) + (:export #:bool-to-checkbox + #:checkbox-to-bool + #:dimension-to-clean-string + #:universal-time-to-prefix)) +(in-package #:convert) + +(defun bool-to-checkbox (value) + "Converts `VALUE' so it can populate an HTML checkbox. +It is assumed you are converting a SQLite version of a Boolean so +either 1 (true) or 0 (false). If you need a traditional Boolean value, +DO NOT USE THIS FUNCTION." + (cond ((= value 0) "off") + ((null value) "off") + (t "on"))) + +(defun checkbox-to-bool (value) + "Converts a HTML Checkbox `VALUE' to a Boolean. +The `VALUE' will either be 'on' or 'off'. 'Boolean' in this instance +is assuming you are using SQLite and need to convert `VALUE' to an +integer/number. If you are needing a traditional Boolean value, DO NOT USE +THIS FUNCTION." + (cond ((string= "on" value) +true+) + ((string= "off" value) +false+) + ((null value) +false+))) + +(defun dimension-to-clean-string (dimension) + "Takes a string like '148.0d0' and changes to '148.00'. +This funciton is needed because of how the dimensions in the /art +section are rendered in the /art/edit.html template. The djula +template renders the dimensions as strings but the database stores +them as numbers. This means they are capable of possessing letters in +them -- like '148.0d0' -- which breaks when these values come into +contact with mito." + (if (not (null dimension)) + (format nil "~,2F" dimension) + (format nil ""))) + +(defun universal-time-to-prefix () + "Converts an encoded universal time to something a human can make sense of." + (multiple-value-bind + (second minute hour day month year) + (get-decoded-time) + (format nil "~d~2,'0d~d_~2,'0d~2,'0d~2,'0d" + year + month + day + hour + minute + second))) diff --git a/src/services/db-management.lisp b/src/services/db-management.lisp new file mode 100644 index 0000000..1020e69 --- /dev/null +++ b/src/services/db-management.lisp @@ -0,0 +1,60 @@ +(defpackage #:db-management + (:use #:cl + #:mito + #:local-time + #:storage + #:user + #:file) + (:import-from #:hot-line.db + #:connection-settings + #:db + #:with-connection) + (:export #:get-distinct-column-totals + #:get-random-selection + #:seed-database)) +(in-package #:db-management) + +(defun seed-database() + "A quick way to reset the database." + (hot-line.db:with-connection (db) + (mito:ensure-table-exists 'user) + (mito:create-dao 'user:user + :username "admin" + :display-name "Dave from Accounting" + :administrator 1 + :password "password") + (mito:ensure-table-exists 'file) + (mito:create-dao 'file:file + :filename "Testing.png" + :username (mito:find-dao 'user:user :username "admin") + :slug "testing" + :content-type "image/png"))) + +(defun get-distinct-column-totals (table column) + "Creates a list of distinct values and their totals in `COLUMN' in DB `TABLE'." + (with-connection (db) + (mito:retrieve-by-sql + (format nil + "SELECT ~S, COUNT(~S) AS col_totals FROM ~S GROUP BY ~S;" + column column table column)))) + +(defun get-random-selection (table amount exclude-id) + "Returns a random selection of rows from the `TABLE' in the database. +`AMOUNT' specifies how many rows to return and the `EXCLUDE-ID' is the row which +should NOT be returned. The intented use for this function is to generate a list +of 'other articles/artworks' Etc. when viewing a 'content.html' page. The need +for 'raw SQL' is because I couldn't find a way to apply the 'RANDOM()' function +using mito and sxql. + +Please note, this function does not map the results to their classes like mito +usually does, especially when used with sxql. Because this functions main reason +to be is to provide a list for populating the 'Other X' columns in the +content.html templates, you will need to use the 'raw-date' djula filter (custom +made and in view.lisp) to render the date-time stamp." + (with-connection (db) + (mito:retrieve-by-sql + (format nil + "SELECT * FROM ~a WHERE ID != ~a ORDER BY RANDOM() LIMIT ~a" + table + exclude-id + amount)))) diff --git a/src/services/pagination.lisp b/src/services/pagination.lisp new file mode 100644 index 0000000..0277f1c --- /dev/null +++ b/src/services/pagination.lisp @@ -0,0 +1,76 @@ +(defpackage #:pagination + (:use #:cl) + (:export #:make-pagination)) +(in-package #:pagination) + +#| Found This Code on The Internet +================================================================================ +Original Blog Post: +https://lisp-journey.gitlab.io/blog/lisp-for-the-web-pagination-and-cleaning-up-html/ + +Pagination Source Code: +https://gitlab.com/vindarel/abstock/-/blob/master/src/pagination.lisp + +Djula HTML Template Code: +https://gitlab.com/vindarel/abstock/-/blob/master/src/templates/includes/pagination.html + +I've modified the code a little bit to fit my needs but it's, for the most part, +as I found it (`BUTTON-RANGE' is the main thing I've added). +|# + +(defun make-pagination (&key (page 1) (nb-elements 0) (page-size 200) + (max-nb-buttons 5)) + "From a current page number, a total number of elements, a page size, + return a dict with all of that, and the total number of pages. + + Example: + +(get-pagination :nb-elements 1001) +;; => + (dict + :PAGE 1 + :NB-ELEMENTS 1001 + :PAGE-SIZE 200 + :NB-PAGES 6 + :TEXT-LABEL \"Page 1 / 6\" + ) +" + (let* ((nb-pages (get-nb-pages nb-elements page-size)) + (max-nb-buttons (min nb-pages max-nb-buttons)) + (button-list (build-pagination-list page max-nb-buttons))) + (serapeum:dict :page page + :nb-elements nb-elements + :page-size page-size + :nb-pages nb-pages + :button-range button-list + :max-nb-buttons max-nb-buttons + :text-label (format nil + "Page ~a / ~a" page nb-pages)))) + +(defun get-nb-pages (length page-size) + "Given a total number of elements and a page size, compute how many pages fit in there. + (if there's a remainder, add 1 page)" + (multiple-value-bind (nb-pages remainder) + (floor length page-size) + (if (plusp remainder) + (1+ nb-pages) + nb-pages))) + +(defun build-pagination-list (current-page max-nb-buttons) + "Creates a list of numbers with values before and after `CURRENT-PAGE'. +This is a bit hacky because the list needs to have checks in the HTML +template (djula) to make sure it doesn't render negative numbers or +display page number beyond the maximun number of pages available, +`MAX-NB-BUTTONS'. With that said, it allows you to create a 'floating +list' of page numbers which updates itself based on what page the +viewer is on." + (let* ((start (- current-page max-nb-buttons)) + (end (+ current-page max-nb-buttons))) + (range end :min start :step 1))) + +(defun range (max &key (min 0) (step 1)) + "Creates a list of number starting from `MIN' upto `MAX'. +Use `STEP' to specify the increment value for each entry. This is +basically the range function from Python." + (loop for n from min below max by step + collect n)) diff --git a/src/services/routing.lisp b/src/services/routing.lisp new file mode 100644 index 0000000..35126cd --- /dev/null +++ b/src/services/routing.lisp @@ -0,0 +1,562 @@ +(defpackage #:routing + (:use #:cl + #:caveman2 + #:hot-line.config + #:hot-line.view + #:hot-line.db + #:app-constants + #:datafly + #:sxql + #:local-time + #:sqlite + #:cl-pass + #:storage + #:validation + #:authentication + #:user-management + #:storage-management + #:convert + #:hermetic + #:pagination) + (:export + ;; CHARTING SECTION + #:create-chart + #:create-chart1 + + ;; STORAGE SECTION + #:add-storage-file + #:delete-storage-file + #:update-storage-file + + ;; SESSION MANAGEMENT + #:attempt-login + #:log-out + + ;; USER MANAGEMENT + #:add-user + #:delete-user + #:sign-up-user + #:update-role + #:update-display-name + #:update-password)) +(in-package #:routing) + +#| Routing.lisp (The overflow file for web.lisp) +================================================================================ +In here is essentially the overflow of code from the web.lisp file. When the +code in one of 'defroute' macros needs breaking out into its own +function (defined with a 'defun'), I add that function into this file. The +reason why is so I can keep the website routes free from clutter (as much as I +can help it). By using something like the origami package in Emacs, I can reduce +the routes to their first line so I an quickly scan through the file and +navigate to the function I need to work next. + +You will find, the functions in here tend to deal the HTTP POST requests almost +exclusively. At the time of writing (August 2022), I have not had any need to +include a function which deals with a HTTP GET request. But, I intend to add +them to this file unless this file becomes too unwieldy. +|# + +;; CHARTING SECTION + +(defun create-chart (request) + "Builds the chart by calling out to Python and returns the file created by it." + (destructuring-bind + (&key title content-file x-axis y-axis &allow-other-keys) + (authentication:request-params request) + (let* ((current-user (authentication:get-current-user)) + (username (user::username-of current-user)) + (alert-message nil) + (checks-failed? nil) + (status-code nil) + (python-output nil) + (file-type (car (last content-file))) + (data-bag `(:token ,(authentication:csrf-token) + :user ,current-user + :roles ,(authentication:get-user-roles)))) + (cond ((null content-file) + (setf alert-message +storage-file-not-found+) + (setf checks-failed? t)) + ((and (not (string-equal "text/csv" file-type)) + (not (string-equal "text/tsv" file-type)) + (not (string-equal "text/tab-separated-values" file-type))) + (setf alert-message +undetermined-file-type+) + (setf checks-failed? t) + (setf status-code 301)) + ((or (null (directory-exists-p username +uploads-directory+)) + (null (directory-exists-p username ""))) + (setf alert-message +storage-directory-not-found+) + (setf checks-failed? t) + (setf status-code 301)) + (t (setf checks-failed? nil) + (setf status-code 201))) + (if (not checks-failed?) + (progn + ;; File is stored on server so it available to hot-line-python. + ;; hot-line-python is called from this website but is run in + ;; seperate process to this website. + (let* ((sanitised-filename (clean-filename (second content-file))) + (sanitised-filename-root (pathname-name sanitised-filename)) + (prefixed-filename (format nil "line_~a--~a.html" + (convert:universal-time-to-prefix) + sanitised-filename-root)) + (temp-file-path ; For input .csv/.tsv file uploaded to server. + (make-path username + +uploads-directory+ + sanitised-filename)) + (output-path + (make-path username "" prefixed-filename))) + (storage:store-file username + +uploads-directory+ + (format nil sanitised-filename) + content-file) + (setf python-output (uiop:run-program + (list (merge-pathnames + "hot-line-python/venv/bin/python" + hot-line.config::*application-root*) + (format nil "~a" + (merge-pathnames + "hot-line-python/app/hot-line-python.py" + hot-line.config::*application-root*)) + ;; "-V" ; For verbose output. + (format nil "-t ~a" title) + (format nil "-x ~a" x-axis) + (format nil "-y ~a" y-axis) + (format nil "~a" temp-file-path) + (format nil "~a" output-path)) + :output :string + :error-output :string)) + (storage:remove-file username +uploads-directory+ + sanitised-filename)))) + (if (and (not (search "CRITICAL" python-output)) + (null alert-message)) + (setf alert-message +generic-success+) + (setf alert-message +generic-fail+)) + (format t "~@s" python-output) + `(,status-code () (,(hot-line.view:render + "user/dashboard.html" + (append data-bag + `(:alert ,alert-message + :storage-files + ,(reverse + (storage:get-file-names + (storage:get-files-in-directory + username ""))) + :python-output ,python-output)))))))) + +;; Storage Section + +(defun add-storage-file (request) + "Stores the meta-data and data-file of an uploaded file (to the website). +The CSRF-Token and Logged-in checks should already be completed before calling +this function." + (destructuring-bind + (&key filename slug content-type content-file &allow-other-keys) + (authentication:request-params request) + (cond ((or (storage-management:get-file-from-db :slug slug) + (storage-management:get-file-from-db :filename filename)) + `(303 () (,(hot-line.view:render + "storage/add.html" + `(:alert + ,app-constants:+storage-file-already-exists+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :roles ,(authentication:get-user-roles)))))) + (t (let* ((current-user (authentication:get-current-user)) + (username (user::username-of current-user)) + (file-count (storage-management:get-file-count username))) + (storage-management:add-file-to-db filename username ; content-type + (caddr content-file) + slug) + (storage:store-file username app-constants:+media-directory+ + filename content-file) + `(201 () (,(hot-line.view:render + "storage/manage.html" + `(:alert + ,app-constants:+storage-file-successful-upload+ + :token ,(authentication:csrf-token) + :root-url ,(format nil "storage/manage/~a" username) + :user ,current-user + :roles ,(authentication:get-user-roles) + :storage-count + ,(storage-management:get-file-count username) + :categories + ,(db-management:get-distinct-column-totals + "file" "content_type") + :storage-files + ,(storage-management:get-paginated-files + username 1 +default-page-size+ :filename) + :pagination ,(pagination:make-pagination + :page 1 + :page-size +default-page-size+ + :nb-elements file-count)))))))))) + +(defun delete-storage-file (request) + "Deletes the data-file in the logged-in user's /storage directory." + (destructuring-bind + (&key filename &allow-other-keys) + (authentication:request-params request) + (let* ((current-user (authentication:get-current-user)) + (username (user::username-of current-user)) + (token (authentication:csrf-token)) + (roles (authentication:get-user-roles)) + (alert-message nil)) + (if (null (storage:file-exists-p username "" filename)) + (setf alert-message +storage-file-not-found+) + (progn + (setf alert-message +storage-file-deleted+) + (storage:remove-file username "" filename))) + `(303 () (,(hot-line.view:render + "user/dashboard.html" + `(:alert ,alert-message + :token ,token + :user ,current-user + :roles ,roles + :storage-files ,(reverse + (storage:get-file-names + (storage:get-files-in-directory + username "")))))))))) + +(defun update-storage-file (request) + "Updates the meta-data of a data-file stored in the /storage directory. +The function does not alter the actual data-file. The thinking is if you want to +edit the actual data-file, you should just delete the file stored on the +server (in /storage) and upload a new one. This function is for changing the +`FILENAME', `CONTENT-TYPE' and `SLUG' (I.E. the things you use to reference the +files in /storage when logged into the site)." + (destructuring-bind + (&key id filename content-type slug &allow-other-keys) + (authentication:request-params request) + (let* ((current-user (authentication:get-current-user)) + (username (user::username-of current-user)) + (original-db-file-name (file::filename-of + (storage-management:get-file-from-db :id id)))) + (cond ((null (storage-management:get-file-from-db :id id)) + `(303 () (,(hot-line.view:render "storage/manage.html" + `(:alert ,app-constants:+storage-file-not-found+ + :token ,(authentication:csrf-token) + :user ,current-user + :root-url ,(format nil "storage/manage/~a" username) + :roles ,(authentication:get-user-roles) + :storage-count + ,(storage-management:get-file-count username) + :categories + ,(db-management:get-distinct-column-totals + "file" "content_type") + :storage-files + ,(storage-management:get-all-files-from-db + username :filename) + :pagination ,(make-pagination + :page 1 + :page-size +default-page-size+ + :nb-elements + (storage-management:get-file-count + username))))))) + (t ;; Read as 'ignore inner code-block of UNLESS the file exists'. + (unless (storage:file-exists-p username app-constants:+media-directory+ filename) + (storage:rename-content-file username app-constants:+media-directory+ + original-db-file-name filename)) + (storage-management:edit-file-in-db id filename content-type slug) + `(201 () (,(hot-line.view:render "storage/manage.html" + `(:alert + ,app-constants:+storage-file-successfully-updated+ + :token ,(authentication:csrf-token) + :user ,current-user + :root-url ,(format nil "storage/manage/~a" username) + :roles ,(authentication:get-user-roles) + :storage-count + ,(storage-management:get-file-count username) + :categories + ,(db-management:get-distinct-column-totals + "file" "content_type") + :storage-files + ,(storage-management:get-all-files-from-db + username :filename) + :pagination ,(make-pagination + :page 1 + :page-size +default-page-size+ + :nb-elements + (storage-management:get-file-count + username))))))))))) + +;; Session Management + +(defun attempt-login (request) + "Attempts to log the user into the website. +Redirects the user depending on how successful the log-in attempt +is. Updates the session id and password if log-in is +successful. `REQUEST' consists of the body parameters of the log-in +attempt Derived from the log-in form comprised in /session and +/login." + (destructuring-bind + (&key username password authenticity-token &allow-other-keys) + (authentication:request-params request) + (if (not (string= authenticity-token (authentication:csrf-token))) + `(403 (:content-type "text/plain") ("Denied")) + (let ((params (list :|username| username :|password| password))) + (hermetic:login params + ;; Successful log-in attempt. + (progn + (setf + ;; Set session Id. to the logged in user. + (gethash :id ningle:*session*) + (authentication:get-user-id username) + ;; Set the users password (for session) + (gethash :password ningle:*session*) password) + `(303 (:location "/dashboard"))) + ;; Failed log-in attempt. + (hot-line.view:render "user/log-in.html" + `(:alert ,+incorrect-login-details+ + :token ,(authentication:csrf-token))) + ;; No user found. + (hot-line.view:render "user/log-in.html" + `(:alert ,+user-not-found+ + :token ,(authentication:csrf-token)))))))) + +(defun log-out (request) + "Logs the current user out of the browsing session." + (destructuring-bind + (&key username authenticity-token &allow-other-keys) + (authentication:request-params request) + (if (not (string= authenticity-token (authentication:csrf-token))) + `(403 (:content-type "text/plain") ("Denied")) + (hermetic::logout + ;; Successful log-out. + (progn (authentication::flash-gethash :id ningle:*session*) + '(303 (:location "/"))) + ;; Failed log-out + '(303 (:location "/")))))) + +;; User Management +(defun sign-up-user (request) + "Creates a new user via the sign-up section of site." + (destructuring-bind + (&key username display-name password &allow-other-keys) + (authentication:request-params request) + (let* ((alert-message nil) + (can-create-user? nil)) + (cond ((user-management:user-in-db-p :username username) + (setf alert-message +username-already-taken+)) + ((find t (mapcar #'validation:string-is-nil-or-empty-p + `(,username ,display-name ,password))) + (setf alert-message +nil-or-empty-string-used+)) + (t (setf alert-message +new-user-added+) + (setf can-create-user? t))) + (if (equal can-create-user? t) + (progn + (user-management:add-user-to-db + username display-name +false+ + password) + (storage:ensure-directory-exists username "uploads") + (attempt-login request)) + `(303 () (, (hot-line.view:render + "sign-up.html" + `(:alert ,+username-already-taken+ + :username ,username + :display-name ,display-name + :token ,(authentication:csrf-token))))))))) + +(defun add-user (request) + "Adds a new user to the database by a currently logged in (I.E. admin.) user. +`REQUEST' contains the data provided by the HTML form used to specify +the new user's data." + (destructuring-bind + (&key authenticity-token username display-name administrator + password &allow-other-keys) + (authentication:request-params request) + (cond ((if (user-management:user-in-db-p :username username) + `(303 () (,(hot-line.view:render + "user/add.html" + `(:alert ,+username-already-taken+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :roles ,(authentication:get-user-roles))))))) + ;; TODO: Add validation for add-user arguments. + (t (progn + (user-management:add-user-to-db + username display-name + (convert:checkbox-to-bool administrator) password) + (storage:ensure-directory-exists username "uploads") + `(201 () (,(hot-line.view:render + "user/index.html" + `(:alert ,+new-user-added+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator")))))))))) + +(defun delete-user (request) + "Deletes a user from the database. +`REQUEST' contains the data which specifies which user should be +deleted. It typically is provided by a HTML form." + (destructuring-bind + (&key username &allow-other-keys) + (authentication:request-params request) + (cond ((not (user-management:user-in-db-p :username username)) + `(303 () (,(hot-line.view:render + "user/index.html" + `(:alert ,+user-not-found+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator")))))) + ((or (and (equal +true+ (user::is-administrator-p (authentication:get-current-user))) + (user-management:user-in-db-p :username username)) + (string= username (user::username-of (authentication:get-current-user)))) + (let ((username-being-deleted (user::username-of (authentication:get-current-user)))) + (user-management:delete-user-from-db :username username) + (storage:remove-directory username "") + (if (string= username username-being-deleted) + (log-out request) + `(201 () (,(hot-line.view:render + "user/index.html" + `(:alert ,app-constants:+user-deleted+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator")))))))) + (t (format nil "Well then, it looks like you managed to the + website into a right old pickle. For you to have got here, + you must have been snooping around. Fair play, you broke + the website."))))) + +(defun update-role (request) + "Give or remove admin. privileges to user specified in `REQUEST'." + (destructuring-bind (&key username administrator &allow-other-keys) + (authentication:request-params request) + (cond ((null (user-management:user-in-db-p :username username)) + `(303 () (,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,+user-not-found+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles)))))) + ((not (= (user::is-administrator-p (authentication:get-current-user)) + +true+)) + `(303 () ,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,+user-not-authorised+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator"))))) + (t (user-management:update-user-administration-role + username (convert:checkbox-to-bool administrator)) + `(201 () (,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,+user-role-updated+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator"))))))))) + +(defun update-display-name (request) + "Change the username of the user specified in `REQUEST'." + (destructuring-bind (&key username display-name &allow-other-keys) + (authentication:request-params request) + (format t "[INFO] SESSION: ~A" (gethash :password ningle:*session*)) + (cond ((equal nil (user-management:user-in-db-p :username username)) + `(303 () (,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,app-constants:+user-not-found+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator")))))) + (t (user-management:update-user-display-name username display-name) + `(201 () (,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,+display-name-updated+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator"))))))))) + +(defun update-password (request) + "Change the password of the user specified in `REQUEST'." + (destructuring-bind (&key username old-password new-password &allow-other-keys) + (authentication:request-params request) + (cond ((equal nil (user-management:user-in-db-p :username username)) + `(303 () (,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,app-constants:+user-not-found+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator")))))) + ((if (and (not old-password) (hermetic:role-p :administrator)) + (progn + (user-management:update-user-password username new-password) + `(201 () (,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,app-constants:+password-updated+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator")))))))) + ((equal nil + (cl-pass:check-password old-password + (user::password-of (user-management:user-in-db-p + :username username)))) + `(303 () (,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,app-constants:+old-password-incorrect+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator")))))) + (t (user-management:update-user-password username new-password) + `(201 () (,(hot-line.view:render (user-management:get-crud-redirect-url) + `(:alert ,app-constants:+password-updated+ + :token ,(authentication:csrf-token) + :user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :roles ,(authentication:get-user-roles) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator"))))))))) diff --git a/src/services/storage-management.lisp b/src/services/storage-management.lisp new file mode 100644 index 0000000..c27fc10 --- /dev/null +++ b/src/services/storage-management.lisp @@ -0,0 +1,127 @@ +(defpackage #:storage-management + (:use #:cl + #:file + #:mito + #:local-time + #:storage) + (:import-from #:hot-line.db + #:connection-settings + #:db + #:with-connection) + (:export #:add-file-to-db + #:delete-file-from-db + #:edit-file-in-db + #:get-all-files-from-db + #:get-file-count + #:get-files-from-db + #:get-file-from-db + #:get-paginated-files)) +(in-package #:storage-management) + +(defun add-file-to-db (filename owner content-type slug) + "Adds an entry to the 'file' table in the database. +`CONTENT-TYPE' is more like a file extension (png, jpg, pdf, md Etc.) than +anything else. I've added to so I can differentiate the different types of files +in the website's GUI -- when managing the files in the Admin./backend parts. + +NOTE: This only stores the meta-data of the uploaded file. You must use the +'storage' package to deal with the data-file associated with the database +entry." + (with-connection (db) + (mito:create-dao 'file + :filename filename + :owner owner + :content-type content-type + :slug slug))) + +(defun delete-file-from-db (&key (id nil) (filename nil) (slug nil)) + "Deletes the specified entry in the 'file' database. +It does not delete the actual data-file. You will need to use the 'storage' +package to do that. The database entry is the meta-data used by the +Admin./backend of the website to manage the functionality for the /storage +section." + (with-connection (db) + (cond ((and (not filename) (not slug)) + (mito:delete-dao + (mito:find-dao 'file:file :id id))) + ((and (not id) (not slug)) + (mito:delete-dao + (mito:find-dao 'file:file :filename filename))) + ((and (not id) (not filename)) + (mito:delete-dao + (mito:find-dao 'file:file :slug slug)))))) + +(defun edit-file-in-db (id filename content-type slug) + "Updates the specified entry in the 'file' table in the database. +The entry is identified via the `ID'. That is the only argument which will not +change." + (with-connection (db) + (let ((file-to-update + (mito:find-dao 'file:file :id id))) + (setf (file::filename-of file-to-update) filename + (file::content-type-of file-to-update) content-type + (file::slug-of file-to-update) slug) + (mito:save-dao file-to-update)))) + +(defun get-file-count (&optional (owner nil)) + "Returns the total number of entries in the 'storage' database table." + (with-connection (db) + (if (null owner) + (mito:count-dao 'file:file) + (mito:count-dao 'file:file :owner owner)))) + + +(defun get-all-files-from-db (username order) + "Retrieves all the entries in the 'file' database for `USERNAME'. +It does not retrieve the data-files associated with the entries in the +database. You will need to use the 'storage' package alongside this to do +that. This function/package only deals with the meta-data for the files stored +in the /storage directory. + +NOTE: `USERNAME' is used here (column name in the DB) but it refers to 'owner' +in the actual DB table I have used `USERNAME' here because it easier to think of +it that way from a programming point-of-view From the database's point-of-view, +'owner' can refer to more than just `USERNAME' I've just settled on that from a +programming perspective." + (with-connection (db) + (mito:select-dao 'file:file + (sxql:where (:= :owner username)) + (sxql:order-by order)))) + +(defun get-files-from-db (username order amount &key reverse) + "Returns all the entries in the 'file' table in the database. +These files are the meta-data of the files stored in the /storage +directory. They are not the actual file. You would use this when you want to +manage the files in the /storage directory (admin./backend of website)." + (with-connection (db) + (mito:select-dao 'file:file + (sxql:where (:= :owner username)) + (if reverse + (sxql:order-by (:desc order)) + (sxql:order-by order)) + (sxql:limit amount)))) + +(defun get-file-from-db (&key (id nil) (filename nil) (slug nil)) + "Retrieves the specified entry from the 'file' table in the database. +It only returns the meta-data for the file stored in the /storage directory. Use +the 'storage' package alongside this one to work with the data-file and the +meta-data of the data-file." + (with-connection (db) + (cond ((and (not filename) (not slug)) + (mito:find-dao 'file:file :id id)) + ((and (not id) (not slug)) + (mito:find-dao 'file:file :filename filename)) + ((and (not id) (not filename)) + (mito:find-dao 'file:file :slug slug)) + (t nil)))) + +(defun get-paginated-files (username page page-amount order) + "Retrieves a list of rows from the 'file' database with an offset. +The offset is determined by watch page the viewer is on and `PAGE' and +`PAGE-AMOUNT'." + (with-connection (db) + (mito:select-dao 'file:file + (sxql:order-by order) + (sxql:where (:= :owner username)) + (sxql:offset (* (- page 1) page-amount)) + (sxql:limit page-amount)))) diff --git a/src/services/storage.lisp b/src/services/storage.lisp new file mode 100644 index 0000000..dca2d97 --- /dev/null +++ b/src/services/storage.lisp @@ -0,0 +1,167 @@ +(defpackage #:storage + (:use #:cl) + (:export #:directory-exists-p + #:ensure-directory-exists + #:file-exists-p + #:get-files-in-directory + #:get-file-names + #:get-latest-file-type + #:make-path + #:open-file + #:open-binary-file + #:open-text-file + #:remove-directory + #:remove-file + #:rename-content-file + #:rename-directory + #:store-file)) +(in-package #:storage) + +(defun directory-exists-p (username directory) + "Checks to see if the specified diretory exists. +The directories path is returned if it does exist and `NIL' is +returned if the directory cannot be found." + (cl:probe-file (make-path username directory ""))) + +(defun ensure-directory-exists (username directory) + "The project's standardised way to call `ENSURE-DIRECTORY-EXISTS'. +If the directory exists, the full (absolute) path is +returned (equating to `T', otherwiser `NIL' it returned." + + ;; The empty string for `SLUG' (3rd arg.) is used because + ;; `MAKE-PATH' can form paths for files. In this instance, only the + ;; directory needs to be formed. The empty string kinda acts like + ;; `NIL' but it is a bit of a hack, I will admit. + (ensure-directories-exist (make-path username directory ""))) + + +(defun file-exists-p (username subdirectory slug) + "This project's standardised way to call `CL:PROBE-FILE'. +If the file exists, the full (absolute) path is returned (equates to +`T', otherwise `NIL' is returned." + (cl:probe-file (make-path username subdirectory slug))) + +(defun get-files-in-directory (username directory) + "" + (uiop:directory-files (make-path username directory ""))) + +(defun get-file-names (filenames) + "" + (mapcar #'(lambda (x) (file-namestring x)) filenames)) + +(defun get-latest-file-type (old-file new-file) + "Gets the file type of the specified `FILE', return `NIL' if no file found" + (if (string= "" (cadr new-file)) + (file::content-type-of old-file) + (caddr new-file))) + +(defun make-path (username subdirectory slug) + "Forms the path used to save a file. +Storage path: +`*APPLICATION-ROOT*'/storage/`USERNAME'/`SUBDIRECTORY'/`SLUG' + +Each user has their own directory in /storage. This is so I can build a media +manager at a later date -- I had not got around to writing it at the time I +implemented this function/feature. I decided to go with `USERNAME' and +not (user-)`ID' is because I wanted to easily identify the directories in +/storage." + (merge-pathnames (format nil "storage/~A/~A/~A" + username subdirectory slug) + hot-line.config::*application-root*)) + +(defun open-binary-file (username subdirectory slug) + "Reads the file stored in the /storage directory." + (with-open-file (stream + (make-path username subdirectory slug) + :element-type '(unsigned-byte 8)) + (let* ((length (file-length stream)) + (buffer (make-array length + :element-type '(unsigned-byte 8)))) + (read-sequence buffer stream) + (values buffer length)))) + +(defun open-text-file (username subdirectory slug) + "Reads the text (.md) file stored in the /storage directory." + (with-open-file (stream (make-path username subdirectory slug)) + (let ((data (make-string (file-length stream)))) + (read-sequence data stream) + data))) + +(defun remove-directory (username subdirectory) + "Deletes an entire sketchbook directory in /storage (not database). +Path template: `*APPLICATION-ROOT*'/storage/`USERNAME'/`SUBDIRECTORY'/' + +- https://edicl.github.io/cl-fad/#delete-directory-and-files +- https://stackoverflow.com/questions/24350183/how-do-i-delete-a-directory-in-common-lisp + +'cl-fad' (files and directories) is a wrapper package over the various +Common Lisp implementions to aid in keeping your Common Lisp code +portable. At the time of writing (February 2022), the website is using +Steel Bank Common Lisp (SBCL) but this allow you to use something else +if you want or need to switch." + (cl-fad:delete-directory-and-files (make-path username subdirectory ""))) + +(defun rename-directory (username original-directory new-directory) + "Renames a sub-directory in the /storage directory. +`USERNAME' is the directory holding the one which is to be +changed. `ORIGINAL-DIRECTORY' is the 'source' (in the usual Linux/Bash +CLI sense). `NEW-DIRECTORY' is the name the `ORIGINAL-DIRECTORY' will +be changed to. There are various examples of the path +structure/template in other comments in this file. Have a look +around (don't want to repeat myself)." + (rename-file (make-path username original-directory "") + (make-path username new-directory ""))) + +(defun remove-file (username subdirectory slug) + "Deletes the specified file, stored in the /storage directory. +Before calling this function, make sure the file exists. You should have +'file-exists-p' available to you -- within this (storage) package." + (delete-file (make-path username subdirectory slug))) + +(defun rename-content-file (username subdirectory old-slug new-slug) + "This project's standardised way to call `RENAME-FILE'." + (rename-file (make-path username subdirectory old-slug) + (make-path username subdirectory new-slug))) + +(defun store-file (username subdirectory filename data) + "Stores the uploaded file to the /storage directory. +Storage path: `*APPLICATION-ROOT*'/storage/`USERNAME'/`SUBDIRECTORY'/`FILENAME' +`DATA' is the actual contents which will be written to the said path." + (let ((path (ensure-directories-exist + (make-path username subdirectory filename)))) + (cond ((or (string= (caddr data) "application/gzip") + (string= (caddr data) "application/zip") + (string= (caddr data) "application/epub+zip")) + (uiop:copy-file (slot-value (car data) 'pathname) path)) + (t + (with-open-file (stream + path + :direction :output + :if-does-not-exist :create + :element-type '(unsigned-byte 8) + :if-exists :supersede) + (write-sequence (slot-value (car data) 'vector) stream)))))) + +;;; PORTED FROM RAILS-TO-CAVEMAN PROJECT (expect it to be deleted) +;;; ============================================================================= + +;; (defun prin1-to-base64-string (object) +;; (cl-base64:string-to-base64-string (prin1-to-string object))) + +;; (defun read-from-base64-string(string) +;; (values (read-from-string +;; (cl-base64:base64-string-to-string string)))) + +;;; This function requires ImageMagick so you will need to install it +;;; with 'sudo apt install imagemagick' (assuming you are on a +;;; Debian-based system). +(defun convert (id subdirectory original-file converted-file) + (let ((command (format nil "convert -geometry ~A ~A ~A" + (file-size converted-file) + (make-storage-pathname id subdirectory original-file) + (make-storage-pathname id subdirectory converted-file)))) + (let ((message (nth-value 1 + (uiop:run-program command + :ignore-error-status t + :error-output :string)))) + (when message (error message))))) diff --git a/src/services/user-management.lisp b/src/services/user-management.lisp new file mode 100644 index 0000000..f55805c --- /dev/null +++ b/src/services/user-management.lisp @@ -0,0 +1,111 @@ +(defpackage #:user-management + (:use #:cl + #:user + #:mito + #:local-time) + (:import-from #:hot-line.db + #:connection-settings + #:db + #:with-connection) + (:export #:add-user-to-db + #:user-in-db-p + #:validate-user + #:get-all-users + #:get-crud-redirect-url + #:update-user-display-name + #:update-user-password + #:delete-user-from-db + #:get-total-user-count + #:update-user-administration-role)) +(in-package #:user-management) + +(defun user-in-db-p (&key (id nil) username) + "Looks for the user with either the specified `ID' or `USERNAME'. +Make sure you specify which argument you want to use with either :id or +:username. The `USER' information will be returned if found. `NIL' will be +returned if no entry with either the specified `ID' or `USERNAME' is found." + (if (not id) + (with-connection (db) + (mito:find-dao 'user:user :username username)) + (with-connection (db) + (mito:find-dao 'user:user :id id)))) + +(defun add-user-to-db (username display-name administrator password) + "Add a new user to the database. +Make sure you have validated the data before calling this function." + (with-connection (db) + (mito:create-dao 'user + :username username + :display-name display-name + :administrator administrator + :password password + ;; :created_at (local-time:now) + ;; :updated_at (local-time:now) + ))) + +(defun get-all-users () + "Returns a list of all registered users in the database." + (with-connection (db) + (mito:select-dao 'user:user + (sxql:order-by (:asc :display-name))))) + +(defun update-user-display-name (username display-name) + "Updates the specified user's display name. +`USERNAME' is used to specify which user to update in the +database. `DISPLAY-NAME' is used to replace the old one. The old 'display-name' +is not needed here as no validation check is done. This function simply +overwrites the old 'display-name' with the new `DISPLAY-NAME'." + (with-connection (db) + (mito:execute-sql + (format nil "UPDATE user SET display_name = ~S WHERE username = ~S;" + display-name username)))) + +(defun update-user-administration-role (username is-administrator) + "Adds or removes the specified user's administrator privileges. +You can specify the user via `USERNAME'. At the time of writing, the +website uses SQLite as it database so the `IS-ADMINISTRATOR' value is +determined via a '0' or '1'. You should use the 'APP-CONSTANTS' +package (E.G. 'app-constants:+true+') to specify (or help convert) +when specifying this 'make-shift' Boolean value." + (with-connection (db) + (mito:execute-sql + (format nil "UPDATE user SET administrator = ~D WHERE username = ~S;" + is-administrator username)))) + +(defun update-user-password (username new-password) + "Updates the password of the specified user. +Use `USERNAME' to specify which user your want from the database. `NEW-PASSWORD' +will overwrite the old password. Make sure you have validated the user and they +have entered the old password as a validation check before you call this +function." + (with-connection (db) + (let ((user-to-update + (mito:find-dao 'user:user :username username))) + (setf (user::password-of user-to-update) new-password) + (mito:save-dao user-to-update)))) + +(defun delete-user-from-db (&key (id nil) username) + "Deletes the user from the database with the specified `ID' or `USERNAME'. +Make sure your specify which argument you want to user when calling the function +with either :id or :username." + (if (not id) + (with-connection (db) + (mito:delete-by-values 'user:user :username username)) + (with-connection (db) + (mito:delete-by-values 'user:user :id id)))) + +(defun get-crud-redirect-url () + "Returns the URL the user is redirected to after a CRUD-based HTTP POST. +The `USERNAME' is the username of the currently logged in user. The URL is +formed based on the type of roles the user has asigned to him/her. If the user +is an administrator, the URL returns 'user/index.html' and 'user/dashboard.html' +otherwise." + (let ((user (authentication:get-current-user))) + (if (= (user::is-administrator-p user) app-constants:+true+) + "user/index.html" ; Admin. + "user/dashboard.html"))) ; Regular User + +(defun get-total-user-count () + "Returns the total number of registered users in the database." + (with-connection (db) + (mito:count-dao 'user:user))) diff --git a/src/services/validation.lisp b/src/services/validation.lisp new file mode 100644 index 0000000..2a3013a --- /dev/null +++ b/src/services/validation.lisp @@ -0,0 +1,45 @@ +(defpackage #:validation + (:use #:cl) + (:export + #:clean-filename + #:string-is-nil-or-empty-p + #:separate-files-in-web-request)) +(in-package #:validation) + +(defun validation-test () + (format t "~&[INFO] Validation package reached.")) + +(defun request-params (request) + (loop :for (key . value) :in request + :collect (let ((*package* (find-package :keyword))) + (read-from-string key)) + :collect value)) + +(defun string-is-nil-or-empty-p (string-to-test) + "Tests to see if `STRING-TO-TEST' is empty of just whitespace. +This is essentially the 'IsNullOrWhiteSpace' function I use in C#. It +expands the 'empty string' check to include a check to see if there is +string with just a '(white) space' in it." + (if (or (string= string-to-test " ") + (zerop (length string-to-test)) + (null string-to-test)) + t + nil)) + +(defun separate-files-in-web-request (request &optional request-value) + "Creates a new list of 'upload' files from a web `REQUEST'. +You will mostly use this for processing a multi-file upload (HTML) +form. The standard value for the 'name' attribute in (file) input tag +in the HTML form is `CONTENT-FILES' but you can use a different +name. Just specify it in this function's `REQUEST-VALUE' argument." + (loop :for item :in request + if (or (string= "CONTENT-FILES" (car item)) + (string= request-value (car item))) + collect item)) + +(defun clean-filename (filename) + "Basically, slugifies it, removes whitespace, trims it Etc." + (let* ((lowercased (string-downcase filename)) + (trimmed (string-trim '(#\Space #\Tab) lowercased)) + (whitespaced (substitute #\- #\Space trimmed))) + whitespaced)) diff --git a/src/view.lisp b/src/view.lisp new file mode 100644 index 0000000..9de6b9e --- /dev/null +++ b/src/view.lisp @@ -0,0 +1,73 @@ +;; (in-package :cl-user) +(defpackage hot-line.view + (:use :cl) + (:import-from :hot-line.config + :*template-directory*) + (:import-from :caveman2 + :*response* + :response-headers) + (:import-from :djula + :add-template-directory + :compile-template* + :render-template* + :*djula-execute-package*) + (:import-from :datafly + :encode-json) + (:export :render + :render-json)) +(in-package :hot-line.view) + +(djula:add-template-directory *template-directory*) + +(defparameter *template-registry* (make-hash-table :test 'equal)) + +(defun render (template-path &optional env) + (let ((template (gethash template-path *template-registry*))) + (unless template + (setf template (djula:compile-template* (princ-to-string template-path))) + (setf (gethash template-path *template-registry*) template)) + (apply #'djula:render-template* + template nil + env))) + +(defun render-json (object) + (setf (getf (response-headers *response*) :content-type) "application/json") + (encode-json object)) + + +;; +;; Execute package definition + +(defpackage hot-line.djula + (:use :cl) + (:import-from :hot-line.config + :config + :appenv + :developmentp + :productionp) + (:import-from :caveman2 + :url-for)) + +;;; '(in-package' line added after default Caveman set-up. Needed for +;;; custom filters and functions. Not part of default Caveman set-up. +(in-package #:hot-line.djula) + +(setf djula:*djula-execute-package* (find-package :hot-line.djula)) + + +(defun insert-umami-script () + "Outputs the script for my Umami instance (stats.abbether.net). +It provides either the dev. or prod. tracker depending on the +environment this website is running in." + (cond ((equal t (hot-line.config:productionp)) + (format + nil + "")) + (t (format nil "")))) + +(djula::def-filter :chart-icon (filename) + (if (null filename) + (format nil "/images/file.png") + (let ((chart-type + (subseq filename 0 (search "_" filename)))) + (format nil "/images/~a.png" chart-type)))) diff --git a/src/web.lisp b/src/web.lisp new file mode 100644 index 0000000..ce4f2a3 --- /dev/null +++ b/src/web.lisp @@ -0,0 +1,256 @@ +;; (in-package :cl-user) +(defpackage hot-line.web + (:use #:cl + #:caveman2 + #:hot-line.config + #:hot-line.view + #:hot-line.db + #:datafly + #:sxql + #:app-constants + #:local-time + #:sqlite + #:cl-pass + ;; #:validation + #:authentication + #:user-management + #:hermetic + #:storage-management + #:convert + #:pagination + #:routing + #:storage) + (:export :*web*)) +(in-package :hot-line.web) + +;; for @route annotation +(syntax:use-syntax :annot) + +;; +;; Application + +(defclass () ()) +(defvar *web* (make-instance ')) +(clear-routing-rules *web*) + +;; +;; Routing rules + +(defroute "/" () + (cond ((not (hermetic:logged-in-p)) + (render "/index.html" + `(:token ,(authentication:csrf-token)))) + (t (render "/index.html" + `(:user ,(authentication:get-current-user) + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles)))))) + +(defroute "/about" () + (cond ((not (hermetic:logged-in-p)) + (render "about.html" + `(:token ,(authentication:csrf-token)))) + (t (render "about.html" + `(:user ,(authentication:get-current-user) + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles)))))) + +(defroute "/privacy" () + (cond ((not (hermetic:logged-in-p)) + (render "privacy.html" + `(:token ,(authentication:csrf-token)))) + (t (render "privacy.html" + `(:user ,(authentication:get-current-user) + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles)))))) + +(defroute "/navigation" () + (cond ((not (hermetic:logged-in-p)) + (render "/nav-menu.html" + `(:token ,(authentication:csrf-token)))) + (t (render "/nav-menu.html" + `(:user ,(authentication:get-current-user) + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles)))))) + +(defroute ("/sign-up" :method :get) () + (if (hermetic:logged-in-p) + `(301 (:location "/dashboard")) + (render "sign-up.html" `(:token ,(authentication:csrf-token))))) + +(defroute ("/sign-up" :method :post) (&key method) + (destructuring-bind (&key authenticity-token &allow-other-keys) + (authentication:request-params + (lack.request:request-body-parameters ningle:*request*)) + (cond ((not (string= authenticity-token (authentication:csrf-token))) + '(403 (:content-type "text/plain") ("Denied"))) + ((hermetic:logged-in-p) + '(301 (:location "/dashboard"))) + ((string= "sign-up-user" method) + (routing:sign-up-user + (lack.request:request-body-parameters ningle:*request*))) + (t `(400 (:content-type "text/plain") + (,(format nil "Unknown method ~S" method))))))) + +;; Admin/User Section +(defroute "/login" () + (if (hermetic:logged-in-p) + `(301 (:location "/dashboard")) + (render "user/log-in.html" + `(:token ,(authentication:csrf-token))))) + +(defroute ("/login" :method :post) (&key method) + (cond ((string= "login" method) + (routing:attempt-login (lack.request:request-body-parameters ningle:*request*))) + (t `(400 (:content-type "text/plain") + (,(format nil "Unknown method ~S" method)))))) + +(defroute ("/logout" :method :post) (&key method) + (cond ((string= "logout" method) + (routing:log-out (lack.request:request-body-parameters ningle:*request*))) + (t `(400 (:content-type "text/plain") + (,(format nil "Unknown method ~S" method)))))) + +(defroute ("/users") () + (cond ((not (hermetic:logged-in-p)) + (on-exception *web* 404)) + ((equal +true+ (user::is-administrator-p + (authentication:get-current-user))) + (render "user/index.html" + `(:user ,(authentication:get-current-user) + :users ,(user-management:get-all-users) + :user-count + ,(user-management:get-total-user-count) + :categories + ,(db-management:get-distinct-column-totals + "user" "administrator") + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles)))) + (t (on-exception *web* 404)))) + + + +(defroute ("/user/add") () + (cond ((not (hermetic:logged-in-p)) + (on-exception *web* 404)) + ((equal +true+ (user::is-administrator-p + (authentication:get-current-user))) + (render "user/add.html" + `(:user ,(authentication:get-current-user) + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles)))) + (t (on-exception *web* 404)))) + +(defroute ("/user/edit/:username") (&key username) + (cond ((or (not (hermetic:logged-in-p)) + (null (user-management:user-in-db-p :username username))) + (on-exception *web* 404)) + ((or (and (not (null (user-management:user-in-db-p + :username username))) + (equal +true+ (user::is-administrator-p + (authentication:get-current-user)))) + (string= username (user::username-of + (authentication:get-current-user)))) + `(200 () (, (render "user/edit.html" + `(:user-to-edit ,(user-management:user-in-db-p + :username username) + :user ,(authentication:get-current-user) + :roles ,(authentication:get-user-roles) + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles) + :session ,ningle:*session*))))) + (t (on-exception *web* 404)))) + +(defroute ("/user" :method :post) (&key method) + (destructuring-bind + (&key authenticity-token &allow-other-keys) + (authentication:request-params + (lack.request:request-body-parameters ningle:*request*)) + (cond ((not (string= authenticity-token (authentication:csrf-token))) + '(403 (:content-type "text/plain") ("Denied"))) + ((not (hermetic:logged-in-p)) + '(303 (:location "/login"))) + ((string= "add" method) + (routing:add-user (lack.request:request-body-parameters ningle:*request*))) + ((string= "update-role" method) + (routing:update-role (lack.request:request-body-parameters ningle:*request*))) + ((string= "update-display-name" method) + (routing:update-display-name (lack.request:request-body-parameters ningle:*request*))) + ((string= "update-password" method) + (routing:update-password (lack.request:request-body-parameters ningle:*request*))) + ((string= "delete-user" method) + (routing:delete-user (lack.request:request-body-parameters ningle:*request*))) + (t `(400 (:content-type "text/plain") + (,(format nil "Unknown method ~S" method))))))) + +(defroute "/dashboard" () + (if (not (hermetic:logged-in-p)) + '(303 (:location "/login")) + (let* ((current-user (authentication:get-current-user)) + (username (user::username-of current-user))) + (render "user/dashboard.html" + `(:user ,current-user + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles) + :storage-files ,(reverse + (storage:get-file-names + (storage:get-files-in-directory + username "")))))))) + +(defroute "/storage/download/:username/:filename" (&key username filename) + (if (and (hermetic:logged-in-p) + (string= username (user::username-of (authentication:get-current-user)))) + (if (storage:file-exists-p username "" filename) + `(200 (:content-type "octet/stream") + ,(storage:open-binary-file username "" filename))) + (on-exception *web* 404))) + +(defroute ("/storage" :method :POST) (&key method) + (destructuring-bind + (&key filename authenticity-token &allow-other-keys) + (authentication:request-params + (lack.request:request-body-parameters ningle:*request*)) + (cond ((not (string= authenticity-token (authentication:csrf-token))) + '(403 (:content-type "text/plain") ("Denied"))) + ((validation:string-is-nil-or-empty-p filename) + (hot-line.web::on-exception hot-line.web:*web* 404)) + ((not (hermetic:logged-in-p)) + '(303 (:location "/login"))) + ((string= "delete-storage-file" method) + (routing:delete-storage-file + (lack.request:request-body-parameters ningle:*request*))) + (t (on-exception *web* 404))))) + +;; This is where the chart stuff starts... + +(defroute "/chart/add" () + (if (not (hermetic:logged-in-p)) + '(303 (:location "/login")) + (progn + (let* ((current-user (authentication:get-current-user)) + (username (user::username-of current-user))) + (render "chart/add.html" + `(:user ,current-user + :token ,(authentication:csrf-token) + :roles ,(authentication:get-user-roles))))))) + +(defroute ("/chartify" :method :post) (&key method) + (destructuring-bind + (&key authenticity-token &allow-other-keys) + (authentication:request-params + (lack.request:request-body-parameters ningle:*request*)) + (cond ((not (string= authenticity-token (authentication:csrf-token))) + '(403 (:content-type "text/plain") ("Denied"))) + ((not (hermetic:logged-in-p)) + '(303 (:location "/login"))) + ((string= "create-chart" method) + (routing:create-chart + (lack.request:request-body-parameters ningle:*request*))) + (t `(400 (:content-type "text/plain") + (,(format nil "Unknown method ~S" method))))))) +;; +;; Error pages + +(defmethod on-exception ((app ) (code (eql 404))) + (declare (ignore app)) + (merge-pathnames #P"_errors/404.html" + *template-directory*)) diff --git a/static/css/avenir-book.otf b/static/css/avenir-book.otf new file mode 100644 index 0000000..52ab53e Binary files /dev/null and b/static/css/avenir-book.otf differ diff --git a/static/css/avenir.otf b/static/css/avenir.otf new file mode 100755 index 0000000..15e47a6 Binary files /dev/null and b/static/css/avenir.otf differ diff --git a/static/css/firacode-retina.ttf b/static/css/firacode-retina.ttf new file mode 100644 index 0000000..5bbb74b Binary files /dev/null and b/static/css/firacode-retina.ttf differ diff --git a/static/css/full-search.css b/static/css/full-search.css new file mode 100644 index 0000000..23a8d3d --- /dev/null +++ b/static/css/full-search.css @@ -0,0 +1,418 @@ +.search-dashboard { + margin: 0px; + padding: 0px; + display: flex; + flex-direction: column; +} + +.refinements-panel h2, +.refinements-panel p { + padding: 0px; + margin: 0px; +} +.refinements-panel p { + color : silver; + font-size: 12px; +} + +#clear-refinements { + margin-bottom: 20px; +} + +.ais-ClearRefinements-button { + font-size: 16px; + height: 40px; + width: 100%; + margin: 0px; + font-family: 'main', sans-serif; + color: white; + background-color: #0094ff; + box-shadow: 2px 2px 1px black; + border-radius: 4px; + padding: 10px; + cursor: pointer; + text-transform: uppercase; + border: none; +} + +.ais-ClearRefinements-button:hover { + background: lightblue; + color: #0094ff; + text-decoration: none; + box-shadow: none; +} + +.ais-ClearRefinements-button--disabled { + text-align: center; + padding: 10px; + border: 2px solid silver; + border-radius: 4px; + text-transform: uppercase; + color: silver; + background: transparent; + box-shadow: none; +} + +.ais-ClearRefinements-button--disabled:hover { + background: transparent; + color: silver; +} + +.search-refinement-list { + margin: 20px 0px; + border-bottom: 2px solid black; +} + +#searchbox { + width: 100%; + margin-top: 20px; + margin-bottom: 20px; + margin-left: auto; + margin-right: auto; +} + +.ais-SearchBox, +.ais-ClearRefinements-button { + max-width: 600px; +} + +.search-results-container { + display: flex; + flex-direction: column; +} + +.ais-SearchBox-form { + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: center; +} + +.ais-SearchBox-input { + width: 100%; + height: 40px; + padding: 10px; + border: 2px solid #0094ff; + border-radius: 4px; + font-family: 'main', sans-serif; + font-size: 16px; +} + +.ais-SearchBox-input::placeholder { + font-family: 'main', sans-serif; + font-size: 16px; +} + +.ais-SearchBox-form input:focus-visible { + outline: none; +} + +.ais-SearchBox-submitIcon, +.ais-SearchBox-submit, +.ais-SearchBox-reset { + display: none; +} + +.ais-RefinementList-list { + list-style: none; + padding: 0px; + margin: 0px 0px 40px 0px; + display: flex; + flex-direction: row; + width: 100%; + overflow: scroll; +} + +.ais-RefinementList-label { + display: flex; + align-items: center ; + width: max-content; + margin-right: 6px; +} + +.ais-RefinementList-label > span { + padding: 0px 2px; +} + +.ais-RefinementList-checkbox { + width: 30px; + height: 30px; + margin: 0px; +} + +.search-hits-panel { + width: -webkit-fill-available; +} + +#hits { + width: 100%; + min-width: 350px; + display: flex; + flex-direction: column; +} + +.ais-Hits-list { + padding: 0px; + margin: 0px; + display: flex; + flex-direction: column; + list-style: none; +} + +.ais-Hits-item { + margin: 12px 0px 12px 0px; + max-height: 400px; + border: 2px #0094ff solid; + border-radius: 4px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 3px 3px 3px black; +} + +.ais-Hits-item:hover { + border-color: lightblue; + box-shadow: none; +} + +#pagination { + display: block; + margin: 40px 0px 40px 0px; + overflow: auto; + width: 100%; +} + +.ais-Pagination-list { + list-style: none; + padding: 0px; + margin: 0px; + display: flex; + justify-content: center; + align-items: center; +} + +.ais-Pagination-item { + margin: 0px; + display: flex; + justify-content: center; + align-items: center; + max-width: 64px; + height: 50px; +} + +/* Hack to change content of pagination links +================================================================================ +https://stackoverflow.com/questions/48907242/how-can-i-remove-and-replace-content-in-a-html-tag-using-css + +Because Meilsearch adds the pagination stuff via JavaScript, I can't set the +contents of the various HTML elements via the djula templates. This means I have +to update the content after it's loaded. I didn't want to do this via JavaScript +because I want to keep the JavaScript to a minimum. So, to fix the problem, I +came across a hack (see Stack Overflow URL above). You need to change the +visibility (to hidden) of the element(s) you want to change and then use the +'content' property to set the new text value. When that is done, you adjust the +element's visibility back to 'visible'. + +I ACKNOWLEDGE THIS IS JACKY BUT IT WORKS. +*/ + +.ais-Pagination-item--firstPage a, +.ais-Pagination-item--previousPage a, +.ais-Pagination-item--nextPage a { + visibility: hidden; +} + +/* Meilisearch not designed for extended pagination use (disabled 'Last' link) +================================================================================ +https://github.com/meilisearch/documentation/issues/561 +Meilisearch adds a 'Last Page' link with its built-in pagination features. With +that said, the people developing the project acknowledge Meilisearch's +pagination features are limited. They recommend on not using or relying on it +too much. See the URL above for more information. + +Whilst developing this part of the site, I found the 'Last Page' link was +behaving inconsistently. You would click the link and it would jump ahead +(I.E. more than one page) but it took several clicks to get to the 'final/last +page'. Because of that, I've just turned it off by hiding it with the +style-rule below. Again, see URL above for more info. on this behaviour. +*/ + +.ais-Pagination-item.ais-Pagination-item--lastPage { + display: none; +} + +.ais-Pagination-item--firstPage.ais-Pagination-item--disabled span, +.ais-Pagination-item--previousPage.ais-Pagination-item--disabled span, +.ais-Pagination-item--nextPage.ais-Pagination-item--disabled span { + visibility: hidden; +} + +/* The new text is set here: See 'Hack to change content of pagination links' + note above for more information. */ +.ais-Pagination-item--firstPage.ais-Pagination-item--disabled span:before { + content: "First"; + visibility: visible; +} + +.ais-Pagination-item--previousPage.ais-Pagination-item--disabled span:before { + content: "Prev."; + visibility: visible; +} +.ais-Pagination-item--nextPage.ais-Pagination-item--disabled span:before { + content: "Next"; + visibility: visible; + margin-left: 8px; +} + +.ais-Pagination-item--firstPage.ais-Pagination-item--disabled span:before, +.ais-Pagination-item--previousPage.ais-Pagination-item--disabled span:before, +.ais-Pagination-item--nextPage.ais-Pagination-item--disabled span:before { + width: 70px; + height: 39px; + text-transform: uppercase; + border: 2px solid silver; + border-radius: 4px; + text-align: center; + padding: 8px; + color: silver; +} + +.ais-Pagination-item--firstPage a:before { + content: "First"; + visibility: visible; +} + +.ais-Pagination-item--previousPage a:before { + content: "Prev."; + visibility: visible; +} + +.ais-Pagination-item--nextPage a:before { + content: "Next"; + visibility: visible; + margin-left: 8px; +} + +.ais-Pagination-item--firstPage a:link::before, +.ais-Pagination-item--previousPage a:link::before, +.ais-Pagination-item--nextPage a:link::before { + width: 100%; + height: 39px; + text-transform: uppercase; + background: #0094ff; + color: white; + border: 2px solid #0094ff; + border-radius: 4px; + text-align: center; + padding: 8px; + text-decoration: none; + box-shadow: 2px 2px 3px black; +} + +.ais-Pagination-item--page a:link { + background: #0094ff; + color: white; + padding: 10px; + border-radius: 4px; + text-transform: uppercase; + text-decoration: none; + margin: 0px 2px; + box-shadow: 2px 2px 3px black; +} + +.ais-Pagination-item--selected a:link, +.ais-Pagination-item--selected a:hover { + background: silver !important; + color: white; + box-shadow: none; +} + +.ais-Pagination-item--page a:hover { + background: lightblue; + text-decoration: none; + box-shadow: none; +} + +.ais-Pagination-item--firstPage a:hover::before, +.ais-Pagination-item--previousPage a:hover::before, +.ais-Pagination-item--nextPage a:hover::before { + background: lightblue; + border: 2px solid lightblue; + border-radius: 4px; + text-align: center; + color: #0094ff; + box-shadow: none; +} + +.ais-Pagination-item--firstPage a:hover, +.ais-Pagination-item--previousPage a:hover, +.ais-Pagination-item--nextPage a:hover { + text-decoration: none !important; +} + +@media (min-width:600px) { + .ais-ClearRefinements-button { + max-width: 200px; + } +} + +@media (min-width:961px) { + .search-results-container { + flex-direction: row; + } + + .refinements-panel { + max-width: 300px; + width: 100%; + margin-right: 20px; + } + + .ais-RefinementList-list { + font-size: 12px; + flex-direction: column; + overflow: auto; + } + + .ais-RefinementList-item { + margin: 2px 0px; + } + + .ais-RefinementList-checkbox { + width: 20px; + margin-right: 4px; + } + + #hits { + flex-direction: row; + flex-wrap: wrap; + } + + .ais-Hits-list { + padding: 0px; + margin: 0px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + list-style: none; + } + + .ais-Hits-item { + width: 200px; + height: 280px; + margin: 12px; + height: 100%; + display: flex; + flex-direction: column; + } + + .ais-Hits-item .ui-link-card .ui-card-text { + height: 75px; + /* white-space: nowrap; */ + text-overflow: ellipsis; + } + + .ais-Hits-item .ui-link-card .ui-card-text .ui-card-secondary { + text-overflow: ellipsis; + } + +} diff --git a/static/css/iAWriterQuattroV.ttf b/static/css/iAWriterQuattroV.ttf new file mode 100644 index 0000000..6d50c14 Binary files /dev/null and b/static/css/iAWriterQuattroV.ttf differ diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..f175719 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,1087 @@ +@charset "UTF-8"; + +/* FONTS STORED IN THE /CSS DIRECTORY. +================================================================================ +Fonts are stored in the /css directory. This is because of how Caveman controls +the /static directory. It limits the routes from /static to /css, /images, /js, +robot.txt and favicon.ico by default. Apparently to can change this in app.lisp +but I couldn't work out how to get a /fonts directory to work. Because of this, +I have reverted to putting the fonts directly in the /css so Caveman knows +where/how to find them. +*/ + +@font-face { + font-family: "main"; + src: url("avenir.otf") format("opentype"); +} + +@font-face { + font-family: "main-content"; + src: url("avenir-book.ttf"); +} + +@font-face { + font-family: "code"; + src: url("firacode-retina.ttf"); +} + +/* COLOUR PALLET AND GENERALISED VALUES +================================================================================ +The values below are notes of commonly used values. I've put them here to make +it easier to find/refer to them when this file gets to out of hand. + +Main: #0094ff +Secondary: lightblue +Warning: darkorange +Warning Secondary: orangered +Confirm/Add: lightseagreen +Confirm/Add Sec: lightgreen +Admin: darkviolet +Admin Secondary: violet +Error: salmon +Danger: crimson +*/ + +/* smartphones, iPhone, portrait 480x320 phones */ +html { + background: white; +} + +body { + font-family: 'main', sans-serif; +} + +header { + border-bottom: 2px solid black; + display: flex; +} + +nav form { + display: inline-block; +} + +nav { + display: block; + width: 100%; + padding-top: 0px; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 9px; +} + +nav div { + display: initial; +} + +nav .nav-header { + display: inline-flex; + align-items: center; + margin-left: 4px; + font-size: 1.5em; +} + +nav .nav-header img { + display: initial; + height: 45px; + margin: 0px 4px 0px 0px; +} + +nav .nav-header span { + font-size: 1.5em; +} + +nav a { + margin: 0px 3px; +} + +main { + margin: 0px; + padding: 0px; +} + +article { + max-width: 800px; +} + +code { + white-space: pre; + margin: auto; + max-width: 300px; + width: 100%; + display: block; + white-space: pre; + -webkit-overflow-scrolling: touch; + overflow-x: scroll; + padding: 12px; + font-family: 'code', monospace; + font-weight: bold; +} + +a:link { + color: #0094ff; +} + +a:visited { + color: purple; +} + +a:hover { + background: lightblue; + color: #0094ff; + text-decoration-thickness:4px; + border-radius: 4px; +} + +table { + width: 100%; + border-spacing: 0; +} + +th { + text-align: left; + border-bottom: 2px solid black; + padding: 12px; + margin: 0px; +} + +td { + text-align: left; + padding-left: 4px; +} + +td img { + margin: 0px 10px 0px 10px; +} + +tbody td { + border-bottom: 1px solid silver; + padding: 4px; +} + +tbody tr:last-child td { + border-bottom: 2px solid black; +} + +tbody tr:hover { + background-color: silver; +} + +legend { + text-transform: uppercase; +} + +.ui-form-add fieldset { + border: 2px solid lightseagreen; +} + +.ui-form-edit fieldset { + border: 2px solid darkorange; +} + +footer { + border-top: 2px solid black; +} + +footer p { + margin-top: 20px; + font-size: 9px; +} + +input[type=button], input[type=submit], input[type=reset] { + font-family: 'main', sans-serif; + color: white; + background-color: transparent; + border-radius: 4px; + padding: 10px; + cursor: pointer; + text-transform: uppercase; + border: none; + font-size: 9px; +} + +.ui-h2 { + padding: 60px 0px 0px 0px; + border-top: 2px solid black; +} + +.ui-message-success { + font-size: 1.5em; + color: black; + padding: 6px; + border-radius: 4px; + background-size: 400% 100%; + background-image: linear-gradient(270deg, lightgreen, white); + animation: alert-message-animation 20s infinite linear; +} + +.ui-message-warning { + font-size: 1.5em; + color: black; + padding: 6px; + border-radius: 4px; + background-size: 400% 100%; + background-image: linear-gradient(270deg, darkorange, white); + animation: alert-message-animation 20s infinite linear; +} + +.ui-message-error { + font-size: 1.5em; + color: black; + padding: 6px; + border-radius: 4px; + background-size: 400% 100%; + background-image: linear-gradient(270deg, salmon, white); + animation: alert-message-animation 20s infinite linear; +} + +.ui-message-danger { + font-size: 1.5em; + color: black; + padding: 6px; + border-radius: 4px; + background-size: 400% 100%; + background-image: linear-gradient(270deg, crimson, white); + animation: alert-message-animation 20s infinite linear; +} + +.ui-link:link { + box-shadow: 2px 2px 1px black; + background: #0094ff; + color: white; + padding: 10px; + border-radius: 4px; + text-transform: uppercase; + text-decoration: none; +} + +.pagination { + font-size: 1em; +} + +.ui-link:visited { + color: white; +} + +.ui-link:hover { + box-shadow: none; + background: lightblue; + color: #0094ff; + text-decoration: none; +} + +.ui-link-add { + background: lightseagreen; + color: white; + box-shadow: 2px 2px 1px black; + padding: 10px; + border-radius: 4px; + text-transform: uppercase; + text-decoration: none; +} + +.ui-link-add:link { + color: white; +} + +.ui-link-add:visited { + color: white; +} + +.ui-link-add:hover { + background: lightgreen; + color: lightseagreen; + box-shadow: none; + text-decoration: none; +} + +.ui-link-admin, +.ui-button-admin[type=submit] { + background: darkviolet; + color: white; + box-shadow: 2px 2px 1px black; + padding: 10px; + border-radius: 4px; + text-transform: uppercase; + text-decoration: none; +} + +.ui-link-admin:link { + color: white; +} + +.ui-link-admin:visited { + color: white; +} + +.ui-link-admin:hover, +.ui-button-admin[type=submit]:hover{ + background: violet; + box-shadow: none; + color: white; + text-decoration: none; +} + +.ui-link-edit:link { + background: darkorange; + color: white; + box-shadow: 2px 2px 1px black; + padding: 10px; + border-radius: 4px; + text-transform: uppercase; + text-decoration: none; +} + +.ui-link-edit:visited { + color: white; +} + +.ui-link-edit:hover { + background: orangered; + border-top: none; + text-decoration: none; +} + +.ui-button-add[type=submit] { + background: lightseagreen; + box-shadow: 2px 2px 1px black; + color: white; +} + +.ui-button-add[type=submit]:visited { + color:white; +} + +.ui-button-add[type=submit]:hover { + background: lightgreen; + color: white; + box-shadow: none; +} + +.ui-button-edit[type=submit] { + background: darkorange; + box-shadow: 2px 2px 1px black; +} + +.ui-button-edit[type=submit]:visited { + color:white; +} + +.ui-button-edit[type=submit]:hover { + background: orangered; + color: white; + box-shadow: none; +} + +.ui-button-danger[type=submit] { + background: crimson; + box-shadow: 2px 2px 1px black; +} + +.ui-button-danger[type=submit]:visited { + color:white; +} + +.ui-button-danger[type=submit]:hover { + background: salmon; + color: crimson; + box-shadow: none; +} + +.ui-form, +.ui-form-wrap { + padding: 60px 0 0 0; + margin: 60px 0 40px 0; + display: flex; + border-top: 2px solid black; +} + +.ui-form { + justify-content: center; +} + +.ui-form-wrap { + justify-content: center; + flex-direction: column; +} + +.ui-form h1, +.ui-form-wrap h1 { + + padding-bottom: 60px; +} + +.ui-form form, +.ui-form-wrap form { + max-width: 800px; + width: 100%; + margin: 0px; +} + +.ui-form form fieldset, +.ui-form-wrap form fieldset { + border-radius: 4px; + margin-bottom: 12px; +} + +.ui-form label, +.ui-form-wrap label { + display: block; + text-transform: uppercase; +} + +.ui-form label em { + text-transform: none; +} + +.ui-form legend, +.ui-form-wrap legend{ + margin-bottom: 20px; + font-size: 1.5em; +} + +.ui-form form input[type=text], +.ui-form form input[type=file], +.ui-form form input[type=date], +.ui-form form input[type=number], +.ui-form form input[type=password], +.ui-form form select, +.ui-form-wrap form input[type=text], +.ui-form-wrap form input[type=file], +.ui-form-wrap form input[type=date], +.ui-form-wrap form input[type=number], +.ui-form-admin input[type=password], +.ui-form-wrap form select { + width: 98%; + margin: 0px 0px 40px 0px; +} + +.ui-form-add input[type=text], +.ui-form-add input[type=date], +.ui-form-add input[type=number], +.ui-form-add input[type=password], +.ui-form-add select { + height: 40px; + font-size: 16px; + padding: 0px 0px 0px 4px; + display: block; + border-radius: 4px; + border: 2px solid lightseagreen; +} + +.ui-form-add input[type=file] { + padding: 0px; + border-bottom: 2px solid lightseagreen; +} + +.ui-form-admin input[type=text], +.ui-form-admin input[type=date], +.ui-form-admin input[type=number], +.ui-form-admin input[type=password], +.ui-form-admin select { + height: 40px; + font-size: 16px; + padmining: 0px 0px 0px 4px; + display: block; + border-radius: 4px; + border: 2px solid darkviolet; +} + +.ui-muted-text, +td .ui-muted-text { + color: silver; +} + +.ui-form-checkbox-fieldset div { + display: flex; + align-items: center; +} + +.ui-form-checkbox-fieldset label { + display: inline; + margin-left: 12px; + text-transform: none; +} + +.ui-form-checkbox-fieldset input[type=checkbox], +.ui-form-add input[type=checkbox] { + width: 40px; + height: 40px; +} + + +.ui-form-edit input[type=text], +.ui-form-edit input[type=date], +.ui-form-edit input[type=number], +.ui-form-edit input[type=password], +.ui-form-edit select { + height: 40px; + font-size: 1em; + padding: 0px 0px 0px 4px; + display: block; + border-radius: 4px; + border: 2px solid darkorange; +} + +.ui-form-edit input[type=file] { + padding: 0px; + border-bottom: 2px solid darkorange; +} + +.ui-form-edit img, +.ui-form-edit video { + max-height: 200px; +} + +.ui-form-edit video { + max-width: 300px; +} + +.ui-form-edit fieldset { + display: block; +} + +nav div { + display: flex; + justify-content: space-around; + align-items: center; +} + +nav div form { + color: silver; + font-size: 10px; + text-align: right; + padding: 0px; + margin: 0px 4px 0px 0px; + float:right; +} + +nav div form input[type=submit] { + border: 2px solid darkorange; + color: black; + box-shadow: 2px 2px 1px black; + display: inline-block; + background-color: white; +} + +nav div form input[type=submit]:hover { + background: darkorange; + color: white; + box-shadow: none; +} + +.flex-center { + display: flex; + justify-content: center; +} + +.flex-column { + display: flex; + flex-direction: column; +} + +.flex-end { + display: flex; + justify-content: end; + align-items: center; +} + +.logged-in-info { + padding: 0px; + margin: 0px; + text-align: end; +} + +.logged-in-info span { + color: silver; +} + +.logged-in-options { + padding: 20px 0px 20px 0px; +} + +.ui-mobile-menu { + text-transform: uppercase; + width: 100%; + display: flex; + justify-content: space-between; + padding-bottom: 10px; +} + +.ui-mobile-menu div { + display: flex; + align-items: center; + font-size: 1.5em; + text-transform: initial; +} + +.ui-mobile-menu img { + height: 40px; + margin-right: 4px; +} + +.ui-card, +.ui-card-other { + box-shadow: 3px 3px 3px black; + width: 350px; + max-height: 100px; + margin: 12px; + border: 2px #0094ff solid; + border-radius: 4px; + overflow: hidden; +} + +.ui-card-other { + width: 100%; + margin: 12px 0px 12px 0px; +} + +.ui-link-card:link, +.ui-link-card-other:link { + text-decoration: none; +} + +.ui-link-card:link div, +.ui-link-card-other:link div { + background: #0094ff; + color: white; + display: flex; + justify-content: flex-start; +} + +.ui-link-card:visited div, +.ui-link-card:hover, +.ui-link-card-other:visited div, +.ui-link-card-other:hover { + text-decoration: none; + box-shadow: none; +} + +.ui-card:hover, +.ui-card-other:hover { + border: 2px solid lightblue; + box-shadow: none; +} + +.ui-link-card:hover div, +.ui-link-card-other:hover div { + background: lightblue; + color: #0094ff; + box-shadow: none; +} + +.ui-link-card:visited, +.ui-link-card-other:visited { + color: white; +} + +.ui-link-card:link img, +.ui-link-card-other:link img { + width: 100px; + display: inline-block; +} + +.ui-card-text, +.ui-card-text-other { + display: flex; + flex-direction: column; + padding: 6px; +} + +.ui-card-title, +.ui-card-secondary { + display: block; +} + +.ui-card-title { + margin-bottom: 6px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.ui-card-secondary, +.ui-card-keywords { + font-size: 9px; + display: inline-block; + overflow-wrap: break-word; +} + +.ui-card-audio, +.ui-card-audio audio { + width: 100%; +} + +.ui-file-edit-table { + margin-bottom: 120px; +} + +.ui-file-edit-table th:nth-child(2), +.ui-file-edit-table tr td:nth-child(2) { + display: none; +} + +.ui-file-edit-table .ui-form-edit { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: row; +} + +.ui-file-edit-table .ui-form-edit input[type=text] { + margin-right: 4px; + width: 100%; +} + +.ui-table-column-small { + width: 50px; +} + +.ui-table-column-small img { + max-width: 50px; + max-height: 50px; +} + +.mobile-nav { + display: flex; + flex-direction: column; + align-items: stretch; + text-align: center; + margin: 0px 0px 40px 0px; + padding-top: 40px; +} + +.mobile-nav .ui-link { + margin: 4px 0px 4px 0px; +} + +.dashboard section { + margin: 0px 0px 0px 0px; + padding-bottom: 6em; +} + +/* This should be the Account Settings. Try to keep the links/buttons away from + other GUI elements to avoid clumsy button clicks.*/ +.dashboard section:last-child { + margin-bottom: 50px; +} + +.dashboard section, +.dashboard section input[type=submit]{ + font-size: 9px; +} + +.dashboard section div { + display: flex; + justify-content: space-between; + align-items: center; + margin: 10px 0; +} + +.dashboard section div a { + margin: 0px 4px 0px 0px; +} + +.dashboard-header { + border-bottom: 2px solid black; + /* padding-bottom: 120px; */ +} + +.dashboard h1, +.dashboard h2 { + text-align: left; +} + +.dashboard h1 { + font-size: 4em; + margin-bottom: 0px; + padding-top: 20px; +} + +.dashboard h2 { + font-size: 2em; + margin: 0px 0px 8px 0px; + color: silver; +} + +.dashboard h3 { + font-size: 2.2em; + margin: 0; + padding-right: 1em; +} + +.dashboard-header ul { + list-style: none; + padding: 0px; + margin: 0px; +} + +.dashboard h1 span, +.dashboard h3 span{ + color: steelblue; +} + +.dashboard img { + width: 40px; +} + +.storage-dashboard-table { + border-top: 2px solid black; +} + +.storage-dashboard-table thead tr th:nth-child(1) { + width: 60px; +} + +.storage-dashboard-table thead tr th:nth-child(3) { + width: 100px; + text-align: center; +} + +.storage-dashboard-table tbody tr td:nth-child(3) { + min-height: 45px; + width: auto; +} + +.storage-dashboard-table tbody tr td:nth-child(3) form { + margin-left: 12px; +} + +.storage-dashboard-table form { + display: inline; +} + +.storage-dashboard-table th { + font-size: 15px; +} + + + +.content-header, +.content-image-header { + border-bottom: 2px solid black; + padding: 30px 0px 40px 0px; +} + +.content-image-header { + display: flex; + align-items: center; +} + +.content-image-header img { + display: inline-block; + width: 75px; + border-radius: 36px; +} + +.content-image-header div { + display: flex; + flex-direction: column; + margin: 0px 0px 0px 12px; +} + +.content-header h1, +.content-image-header h1 { + margin-bottom: 4px; +} + +.content-header p, +.content-image-header p { + color: silver; + margin: 0; + padding: 0; + font-size: 12px; +} + +.article-stage article, +.content-stage .sketchbook-content { + margin-bottom: 60px; + padding-top: 10px; +} + +.article-stage figure { + display: table; + text-align: center; + width: 100%; + padding: 0px; + margin: 0px; + font-style: italic; +} + +.article-stage figure img, +.article-stage figure video{ + width: 100%; + max-width: 600px; +} + +.article-stage div h2 { + border-bottom: 2px solid black; + padding: 60px 0px 60px 0px; + margin: 0; +} + +.article-stage div { + /* border-top: 2px solid black; */ +} + +.article-stage article { + font-family: "main", sans-serif; +} + +.article-stage div p { + width: 100%; +} + +.content-options { + margin-top: 20px; +} + +.article-stage div a:link { + margin: 12px 0px 12px 0px; + display: block; + text-transform: initial; +} + +.article-stage div .ui-link .ui-link-secondary { + padding: 0; + margin: 0; + font-size:10px; +} + +.article-stage div .ui-link h2 { + margin: 0; + padding: 0; + border: none; +} + +.content-stage { + display: flex; + justify-content: center; + flex-direction: row; + flex-wrap: wrap; + margin-top: 40px; + width: 100%; +} + +.content-other-stage { + width: 100%; +} + +.content-other-stage h2 { + padding: 70px 0px 70px 0px; + border-bottom: 2px solid black; + border-top: 2px solid black; +} + +.index-dashboard { + width: 100%; + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + padding: 0px; + margin: 0px; +} + +.index-header { + border-bottom: 2px solid black; + padding: 40px 0px 60px 0px; +} + +.ui-muted-text, +td .ui-muted-text { + color: silver; +} + +.console-output { + white-space: pre-wrap; + color: salmon; +} + +.pagination { + display: block; + margin-bottom: 20px; +} + +.pagination-list { + display: flex; + flex-direction: column; +} + +.pagination-list div { + display: flex; + justify-content: center; + align-items: center; + margin: 10px 0px 10px 0px; +} + +.pagination-list div a { + margin: 4px; +} + +.ui-disabled { + color: silver; + text-align: center; + padding: 8px 10px; + margin: 4px; + border: 2px solid silver; + border-radius: 4px; + text-transform: uppercase; +} + +.pagination-list div div:nth-child(2){ + display: none; +} + +.ui-is-current, +.ui-is-current:hover { + background: silver; + box-shadow: none !important; + color: white; +} + +@keyframes alert-message-animation { + 0% { background-position: 400% 0%} + 100% { background-position: 0% 0} +} + +/* portrait e-readers (Nook/Kindle), smaller tablets @ 600 or @ 640 wide.*/ +@media (min-width:481px) { +} + +/* portrait tablets, portrait iPad, landscape e-readers, landscape 800x480 or + 854x480 phones */ +@media (min-width:641px) { +} + +/* tablet, landscape iPad, lo-res laptops ands desktops */ +@media (min-width:961px) { + +} + +/* big landscape tablets, laptops, and desktops */ +@media (min-width:1025px) { + +} + +/* hi-res laptops and desktops */ +@media (min-width:1281px) { + + nav a, + nav .nav-header { + font-size: 1em; + } + + nav, + nav input[type=submit], + footer p, + .dashboard section, + .dashboard section input[type=submit] { + font-size: 1em; + } +} +@media (min-width:2100px) { + + +} diff --git a/static/css/search.css b/static/css/search.css new file mode 100644 index 0000000..ba2ebde --- /dev/null +++ b/static/css/search.css @@ -0,0 +1,121 @@ +.ui-search-container { + margin: 0px; + display: flex; + position: abosolute; + flex-direction: column; + align-items: center; + height: 120px; + border-bottom: 2px solid black; +} + +#searchbox { + position: relative; + top: 40px; + width: 100%; + max-width: 600px; +} + +.ais-SearchBox-form { + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: center; +} + +.ais-SearchBox-input { + width: 100%; + height: 40px; + padding: 10px; + border: 2px solid #0094ff; + border-radius: 4px 4px 0px 0px; + font-family: 'main', sans-serif; + font-size: 16px; +} + +.ais-SearchBox-input::placeholder { + font-family: 'main', sans-serif; + font-size: 16px; +} + +.ais-SearchBox-form input:focus-visible { + outline: none; +} + +.ais-SearchBox-submit { + font-size: 16px; + height: 40px; + margin: 0px; + font-family: 'main', sans-serif; + color: white; + background-color: #0094ff; + border-radius: 4px; + padding: 10px; + cursor: pointer; + text-transform: uppercase; + border: none; +} + +.ais-SearchBox-submitIcon, +.ais-SearchBox-submit, +.ais-SearchBox-reset, +#hits { + display: none; +} + +#hits { + position: relative; + top:40px; + background: white; + width: calc(100% - 4px); + max-width: calc(600px - 4px); + border-right: 2px solid #0094ff; + border-bottom: 2px solid #0094ff; + border-left: 2px solid #0094ff; + border-radius: 0px 0px 4px 4px; +} + +.ais-Hits-list { + list-style: none; + padding: 0px 2px; + margin: 0px; +} + +.ais-Hits-list:first-child { + display: flex; + flex-wrap: nowrap; + flex-direction: column; + justify-content: flex-start; +} + + +.ais-Hits-item { + margin: 4px 0px 0px 0px; +} + +.ui-link-search-card { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + width: 100%; +} + +.ui-link-search-card:link { + text-decoration: none; + float: left; + border-radius: 0px; + padding: 4px 0px; +} + +.ui-link-search-card img { + width: 30px; + height: 30px; + display: inline; + float: left; + padding: 0px 6px; +} + +.ui-link-search-card span { + vertical-align: middle; +} + + diff --git a/static/images/audio-file.png b/static/images/audio-file.png new file mode 100644 index 0000000..592251b Binary files /dev/null and b/static/images/audio-file.png differ diff --git a/static/images/bar-line.png b/static/images/bar-line.png new file mode 100644 index 0000000..dc648b5 Binary files /dev/null and b/static/images/bar-line.png differ diff --git a/static/images/bar.png b/static/images/bar.png new file mode 100644 index 0000000..eba7d33 Binary files /dev/null and b/static/images/bar.png differ diff --git a/static/images/bokeh-logo.png b/static/images/bokeh-logo.png new file mode 100644 index 0000000..067ae80 Binary files /dev/null and b/static/images/bokeh-logo.png differ diff --git a/static/images/bokeh-simple.png b/static/images/bokeh-simple.png new file mode 100644 index 0000000..c9fdf09 Binary files /dev/null and b/static/images/bokeh-simple.png differ diff --git a/static/images/email.png b/static/images/email.png new file mode 100644 index 0000000..b9eb698 Binary files /dev/null and b/static/images/email.png differ diff --git a/static/images/favicon.png b/static/images/favicon.png new file mode 100644 index 0000000..deb47c4 Binary files /dev/null and b/static/images/favicon.png differ diff --git a/static/images/file.png b/static/images/file.png new file mode 100644 index 0000000..1cab6ff Binary files /dev/null and b/static/images/file.png differ diff --git a/static/images/git.png b/static/images/git.png new file mode 100644 index 0000000..51f4ae5 Binary files /dev/null and b/static/images/git.png differ diff --git a/static/images/gravatar.png b/static/images/gravatar.png new file mode 100644 index 0000000..a8a96a4 Binary files /dev/null and b/static/images/gravatar.png differ diff --git a/static/images/hero.png b/static/images/hero.png new file mode 100644 index 0000000..3ffb05a Binary files /dev/null and b/static/images/hero.png differ diff --git a/static/images/instagram.png b/static/images/instagram.png new file mode 100644 index 0000000..c4b1540 Binary files /dev/null and b/static/images/instagram.png differ diff --git a/static/images/line-chart-overview.png b/static/images/line-chart-overview.png new file mode 100644 index 0000000..1a1bea9 Binary files /dev/null and b/static/images/line-chart-overview.png differ diff --git a/static/images/line.png b/static/images/line.png new file mode 100644 index 0000000..632b64e Binary files /dev/null and b/static/images/line.png differ diff --git a/static/images/logo b/static/images/logo new file mode 100644 index 0000000..417e030 Binary files /dev/null and b/static/images/logo differ diff --git a/static/images/network-effect.png b/static/images/network-effect.png new file mode 100644 index 0000000..b9ef782 Binary files /dev/null and b/static/images/network-effect.png differ diff --git a/static/images/nuget.png b/static/images/nuget.png new file mode 100644 index 0000000..a76ab93 Binary files /dev/null and b/static/images/nuget.png differ diff --git a/static/images/screen-video-1.mp4 b/static/images/screen-video-1.mp4 new file mode 100644 index 0000000..bce63ea Binary files /dev/null and b/static/images/screen-video-1.mp4 differ diff --git a/static/images/screenshot-1.png b/static/images/screenshot-1.png new file mode 100644 index 0000000..0e3e8bb Binary files /dev/null and b/static/images/screenshot-1.png differ diff --git a/static/images/screenshot-2.1.png b/static/images/screenshot-2.1.png new file mode 100644 index 0000000..c793ce0 Binary files /dev/null and b/static/images/screenshot-2.1.png differ diff --git a/static/images/screenshot-2.png b/static/images/screenshot-2.png new file mode 100644 index 0000000..8f565d5 Binary files /dev/null and b/static/images/screenshot-2.png differ diff --git a/static/images/site-logo.png b/static/images/site-logo.png new file mode 100644 index 0000000..417e030 Binary files /dev/null and b/static/images/site-logo.png differ diff --git a/static/images/tiktok.png b/static/images/tiktok.png new file mode 100644 index 0000000..ef6ae15 Binary files /dev/null and b/static/images/tiktok.png differ diff --git a/templates/_errors/404.html b/templates/_errors/404.html new file mode 100644 index 0000000..5d0d4ae --- /dev/null +++ b/templates/_errors/404.html @@ -0,0 +1,47 @@ + + + + + 404 NOT FOUND + + + +
+
+
404
+
NOT FOUND
+
+
+ + diff --git a/templates/about.html b/templates/about.html new file mode 100644 index 0000000..f866920 --- /dev/null +++ b/templates/about.html @@ -0,0 +1,83 @@ +{% extends "layouts/base.html" %} +{% block title %}About Craig Oates{% endblock %} +{% block content %} + +
+

About

+
+ +
+
+

+ This website's aim is to help people take their spreadsheets and generate + interactive charts with them. + + The charts are created using a plotting/visualisation library written in + Python called Bokeh. +

+

+ + About Bokeh +

+

+ The following it taken from Bokeh's website, +

+
+ Bokeh is a Python library for creating interactive visualizations for modern + web browsers. It helps you build beautiful graphics, ranging from simple + plots to complex dashboards with streaming datasets. With Bokeh, you can + create JavaScript-powered visualizations without writing any JavaScript + yourself. +
+

+ If you would like to know more about Bokeh, please use the following links: +

+ +

The Site's Intended Users

+

+ The main group of people I had in mind when I developed this website were + people with little to no programming experience but worked with + spreadsheets. Because of this, the type of charts a user can create + utilises only a sub-set of Bokeh's functionality. This trade-off, though, + allows non-programmers to make interactive charts quickly and easily. From + there, they can explore and share said charts like it was another file on + your computer. +

+

Accepted File Types

+

+ So far, this website only accepts the following file types: +

+
    +
  • Comma-Seperate values (.csv)
  • +
  • Tab-Seperated values (.tsv)
  • +
+

+ These types of files quite common and can be viewed in standard + spreadsheet software such as: +

+ +

+ Note: + Because .csv and .tsv are 'plain text' files, you should be able to open them + using Notepad (Windows) or TextEdit (Mac OS). +

+

About Craig

+

+ The short version is on my personal website you will find artworks I've + made, blog posts I've written, 3D models I've concocted, graphic design + projects I've put together and software I've developed. For the longer + version, + visit craigoates.net/about. +

+
+
+ +{% endblock %} diff --git a/templates/chart/add.html b/templates/chart/add.html new file mode 100644 index 0000000..ebf7681 --- /dev/null +++ b/templates/chart/add.html @@ -0,0 +1,62 @@ +{% extends "layouts/base.html" %} +{% block title %}{{user.display-name}}: Create a new chart{% endblock %} + +{% block content %} + +
+

Create Chart

+
+ +
+

Preparing Your Data

+
+
+
+

+ For this website to build interactive charts for you, without you + needing writing any code, it needs to make some assumptions about + how the data is organised. Below is an example of how your + spreadsheet should look. +

+
+ +
+

+ The 'Title', 'X-Axis' and 'Y-Axis' sections in the form are what + the chart uses to label the chart. As the form says, the are + optional. The only thing you need to submit is the actual + spreadsheet. +

+

+ If your files are stores in Microsoft Excel's native format (.xlsx + or .xls), you will need to export them to either a Comma-Separated + Value (.csv) or Tab-Separated Value (.tsv) file. This website is + unable to process 'Excel files' at this moment in time. +

+
+
+ +
+
+ + + +
+ Chart Details + + + + + + + + + + + +
+ + +
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..d6b17ca --- /dev/null +++ b/templates/index.html @@ -0,0 +1,83 @@ +{% extends "layouts/base.html" %} +{% block title %} +Turn your spreadsheets into interactive charts, without writing a single line of code. +{% endblock %} + +{% block content %} + +
+
+
+ +
+

Turn your spreadsheets into interactive charts, without writing a single line of code.

+ +
+
+ +
+

Your newly created charts are accessible from anywhere on the web.

+
+ +
+

Fill out a simple form and start exploring your data.

+
+ +
+

Use Bokeh's interactive controls to explore your data.

+ +
+ +
+

Sharing your charts is as easy as attaching a file to an email.

+ +
+
+{% endblock %} diff --git a/templates/layouts/base.html b/templates/layouts/base.html new file mode 100644 index 0000000..19d1c02 --- /dev/null +++ b/templates/layouts/base.html @@ -0,0 +1,46 @@ + + + + + + + {% block title %}{% endblock %} + + + + + + + {% lisp (insert-umami-script) %} + + +
+ {% include "shared/header.html" %} +
+ +
+ {% if roles.logged-in == True %} +

Logged in as: {{ user.username }}

+ {% endif %} + + {% if roles.logged-in == True and roles.administrator == True %} +

Account Type: Admininstrator

+ {% elif roles.logged-in == True and roles.administrator == False %} +

Account Type: Basic

+ {% endif %} +
+ +
+ + {% if alert %}

{{alert | safe}}

{% endif %} + {% block content %}{% endblock %} + +
+
+ {% include "shared/footer.html" %} +
+ + + diff --git a/templates/nav-menu.html b/templates/nav-menu.html new file mode 100644 index 0000000..3de6722 --- /dev/null +++ b/templates/nav-menu.html @@ -0,0 +1,18 @@ +{% extends "layouts/base.html" %} +{% block title %}Craig Oates: Navigation{% endblock %} + +{% block content %} + + + +{% endblock %} diff --git a/templates/privacy.html b/templates/privacy.html new file mode 100644 index 0000000..6bf28ac --- /dev/null +++ b/templates/privacy.html @@ -0,0 +1,58 @@ +{% extends "layouts/base.html" %} {% block title %}Craig Oates: Privacy Policy{% +endblock %} + +{% block content %} + +
+

Privacy Information

+
+ +
+
+

+ Basically, this website aims to keep as little of your personal + information as possible. It does not contain any advertisements with + tracking cookies or any social media trackers either. +

+

What Data this Website Stores

+

+ This website stores your Username, Display Name, Password and the HTML + files (I.E. the charts) generated by the files you upload (.csv and .tsv + files) to the server. You are not required to provide any personal + information as part of your Username or Display Name. For the sake + completeness, the same applies for the Password. +

+

What is Not Stored on this Website

+

+ This website temporarily stores any files (I.E. spreadsheets) you upload + so it can generate the charts (stored as HTML files). When the server has + finished generating the chart and stored it as an HTML file, the uploaded + file is deleted. None of your personally uploaded files are stored + long-term. +

+

What is Viewable to the Public

+

+ Nothing is viewable to the general public. The only thing logged in users + can access is their own Dashboard and the charts they have created. +

+

What is Deleted when a User Deletes their Account

+

+ The website removes the account's details from the database (Username, + Display Name and Password) and all the files, attached to that account, on + the server at that moment in time. + + When a user deletes a HTML file (via the Dashboard), that file is + permanently deleted. It is not reversible. The same applies when deleting + an account. When a file or account is deleted, that is it. There is no way + to retrieve/restore it. +

+

What is Shared Between this Website and craigoates.net

+

+ In terms of data, nothing is shared. This website shares the + 'craigoates.net' domain name but this website uses a seperate database and + stores all its files in a different location. +

+
+
+ +{% endblock %} diff --git a/templates/shared/footer.html b/templates/shared/footer.html new file mode 100644 index 0000000..58857b6 --- /dev/null +++ b/templates/shared/footer.html @@ -0,0 +1,7 @@ +

+ Home + About + Privacy + Main Site +

+

© Craig Oates 2011-{% lisp (local-time:timestamp-year (local-time:now)) %}

diff --git a/templates/shared/header.html b/templates/shared/header.html new file mode 100644 index 0000000..0b44268 --- /dev/null +++ b/templates/shared/header.html @@ -0,0 +1,19 @@ + diff --git a/templates/shared/mime-select-alt.html b/templates/shared/mime-select-alt.html new file mode 100644 index 0000000..ae02c6d --- /dev/null +++ b/templates/shared/mime-select-alt.html @@ -0,0 +1,646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +.jpe + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/shared/mime-select.html b/templates/shared/mime-select.html new file mode 100644 index 0000000..3a5a428 --- /dev/null +++ b/templates/shared/mime-select.html @@ -0,0 +1,646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/templates/shared/pagination.html b/templates/shared/pagination.html new file mode 100644 index 0000000..a701ba3 --- /dev/null +++ b/templates/shared/pagination.html @@ -0,0 +1,57 @@ + diff --git a/templates/sign-up.html b/templates/sign-up.html new file mode 100644 index 0000000..a45b6fa --- /dev/null +++ b/templates/sign-up.html @@ -0,0 +1,28 @@ +{% extends "layouts/base.html" %} +{% block title %}Create your account and start turning your spreadsheets into interactive charts.{% endblock %} + +{% block content %} + +

Create Account

+ +
+
+ + + +
+ Account Details + + + + + + + + +
+ + +
+
+{% endblock %} diff --git a/templates/user/add.html b/templates/user/add.html new file mode 100644 index 0000000..9e7b2ad --- /dev/null +++ b/templates/user/add.html @@ -0,0 +1,44 @@ +{% extends "layouts/base.html" %} +{% block title %}Craig Oates: Add New User{% endblock %} + +{% block content %} + +{% if roles.logged-in %} +
+ Manage +
+{% endif %} + +

Add User

+ +

The username can not be changed after the account has been created.

+ +
+
+ + + +
+ User Details + + + + + + + + +
+ +
+ User Privileges +
+ + +
+
+ + +
+
+{% endblock %} diff --git a/templates/user/dashboard.html b/templates/user/dashboard.html new file mode 100644 index 0000000..c3041f2 --- /dev/null +++ b/templates/user/dashboard.html @@ -0,0 +1,80 @@ +{% extends "layouts/base.html" %} +{% block title %}{{user.display-name}}: Dashboard{% endblock %} + +{% block content %} + +{% if roles.administrator == true %} +{{python-output}} +{% endif %} + + +
+
+

Dashboard

+

{{user.display-name}}

+
+ +
+
+

{{storage-files | length}} Files

+
+ New Chart +
+
+ + + + + + + + + + + {% for file in storage-files %} + + + + + + {% endfor %} + +
TypeFile NameOptions
+ + + {{file}} + + + Download + +
+ + + + +
+
+
+ +
+

Account Options

+

+ Warning: Deleting your account is permanent and cannot be undone. +

+
+ {% if roles.administrator == true %} + Add User + Manage Users + {% endif %} + Edit Account +
+ + + + +
+
+
+ +
+{% endblock %} diff --git a/templates/user/edit.html b/templates/user/edit.html new file mode 100644 index 0000000..1026633 --- /dev/null +++ b/templates/user/edit.html @@ -0,0 +1,66 @@ +{% extends "layouts/base.html" %} +{% block title %}{{user.display-name}}: Edit Account{% endblock %} + +{% block content %} + +{% if roles.administrator == True %} +
+ Add + Manage +
+{% endif %} + +

Edit Account

+ +{% if roles.logged-in and roles.administrator == true %} +
+
+ + + + +
+ User Privileges +
+ + +
+
+ + +
+
+{% endif %} + +
+
+ + + + + + + + +
+
+ +
+
+ + + + + {% ifequal user-to-edit.username user.username %} + + + {% endifequal %} + + + + +
+
+ +{% endblock %} diff --git a/templates/user/index.html b/templates/user/index.html new file mode 100644 index 0000000..5d1c5a2 --- /dev/null +++ b/templates/user/index.html @@ -0,0 +1,87 @@ +{% extends "layouts/base.html" %} +{% block title %}Craig Oates: Users{% endblock %} + +{% block content %} + +
+ Add +
+ +
+
+

{{user-count}} Users

+ + + + + + + + + + {% for item in categories %} + + + + + {% endfor %} + + +
TotalCategory
{{item.col-totals}} + {% if item.administrator == 0 %} + Basic + {% else %} + Administrator + {% endif %} +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
Display NameUsernameAccount TypeCreatedUpdatedOptions
{{user.display-name}}{{user.username}} + {% if user.administrator == 0 %} + Basic + {% else %} + Administrator + {% endif %} + {{user.created-at + | date: ((:year 4)"/"(:month 2)"/"(:day 2)" "(:hour 2)":"(:min 2))}} + + {% ifnotequal user.updated-at null %} + {{user.updated-at + | date: ((:year 4)"/"(:month 2)"/"(:day 2)" "(:hour 2)":"(:min 2))}} + {% else %} + N/A + {% endifnotequal %} + Edit +
+ + + + +
+
+
+
+ +{% endblock %} diff --git a/templates/user/log-in.html b/templates/user/log-in.html new file mode 100644 index 0000000..85b91b3 --- /dev/null +++ b/templates/user/log-in.html @@ -0,0 +1,20 @@ +{% extends "layouts/base.html" %} +{% block title%} +Log-in to charts.craigoates.net and start turning your spreadsheets into interactive charts. +{%endblock%} + +{% block content %} +

Login

+ +
+
+ + + + + + + +
+
+{% endblock %} diff --git a/tests/tests.lisp b/tests/tests.lisp new file mode 100644 index 0000000..c5e7411 --- /dev/null +++ b/tests/tests.lisp @@ -0,0 +1,21 @@ +(in-package :cl-user) +(defpackage #:tests + (:use #:cl + #:parachute)) +(in-package #:tests) + +#| parachute: https://shinmera.github.io/parachute/ +================================================================================ +Use the URL to access the documentation for parachute. +|# + + +;; This was an example taken from the doc's for parachute. I'm going to keep it +;; here as a reference until I get comfortable with parachute. +(define-test reference-tests + (of-type integer 5) + (true (numberp 2/3)) + (false (numberp :keyword)) + (is-values (values 0 "1") + (= 0) + (equal "1")))