summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFreya Murphy <freya@freyacat.org>2024-12-23 10:39:16 -0500
committerFreya Murphy <freya@freyacat.org>2024-12-23 10:39:16 -0500
commitde9cae795f93d03e68d965c59af4b21d96df4ec7 (patch)
treead4f903c04630b3b92e2b9b5d06d5b8647d299bb
parentlicense (diff)
downloadcrimson-de9cae795f93d03e68d965c59af4b21d96df4ec7.tar.gz
crimson-de9cae795f93d03e68d965c59af4b21d96df4ec7.tar.bz2
crimson-de9cae795f93d03e68d965c59af4b21d96df4ec7.zip
initial
-rw-r--r--base.env34
-rw-r--r--build/db-init/Dockerfile41
-rw-r--r--build/db-init/README.md52
-rw-r--r--build/db-init/base.sql86
-rwxr-xr-xbuild/db-init/db-init168
-rw-r--r--build/db-init/ext.sql28
-rw-r--r--build/db-init/rev.sql41
-rw-r--r--build/init/Dockerfile22
-rwxr-xr-xbuild/init/init4
-rwxr-xr-xbuild/init/stamp.sh14
-rw-r--r--build/nginx/Dockerfile30
-rwxr-xr-xbuild/nginx/entrypoint.sh8
-rw-r--r--build/nginx/nginx.api.conf3
-rw-r--r--build/nginx/nginx.api.server.conf14
-rw-r--r--build/nginx/nginx.conf54
-rw-r--r--build/php/Dockerfile17
-rw-r--r--build/postgres/Dockerfile24
-rw-r--r--build/postgrest/Dockerfile30
-rwxr-xr-xbuild/postgrest/entrypoint.sh23
-rwxr-xr-xcompose122
-rw-r--r--docker/docker-compose.api.yml60
-rw-r--r--docker/docker-compose.base.yml72
-rw-r--r--docker/docker-compose.db.yml80
-rwxr-xr-xpsql45
-rw-r--r--src/_base.php309
-rw-r--r--src/_controller.php66
-rw-r--r--src/_model.php63
-rw-r--r--src/config.php75
-rw-r--r--src/index.php68
-rw-r--r--src/lib/database.php337
-rw-r--r--src/lib/error.php218
-rw-r--r--src/lib/hooks.php178
-rw-r--r--src/lib/html.php95
-rw-r--r--src/lib/lang.php186
-rw-r--r--src/lib/meta.php28
-rw-r--r--src/router.php236
36 files changed, 2931 insertions, 0 deletions
diff --git a/base.env b/base.env
new file mode 100644
index 0000000..8392c33
--- /dev/null
+++ b/base.env
@@ -0,0 +1,34 @@
+# ============================= GENERAL ==
+
+# project name
+PROJECT_NAME="crimson"
+
+# ================================= WEB ==
+
+# What port to listen on
+HTTP_BIND=127.0.0.1
+HTTP_PORT=80
+
+# ============================ DATABASE ==
+
+# Do we want the postgres database enabled
+POSTGRES_ENABLED=false
+
+# Database authentication
+POSTGRES_DB=postgres
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+
+# ================================= API ==
+
+# Do we want the postgrest api enabled
+API_ENABLED=false
+
+# API Jwt Secret
+API_SECRET="<base64>"
+
+# API Anonymous role
+API_ROLE="anonymous"
+
+# API schema
+API_SCHEMA="api"
diff --git a/build/db-init/Dockerfile b/build/db-init/Dockerfile
new file mode 100644
index 0000000..c5a4b59
--- /dev/null
+++ b/build/db-init/Dockerfile
@@ -0,0 +1,41 @@
+### CRIMSON --- A simple PHP framework.
+### Copyright © 2024 Freya Murphy <contact@freyacat.org>
+###
+### This file is part of CRIMSON.
+###
+### CRIMSON is free software; you can redistribute it and/or modify it
+### under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 3 of the License, or (at
+### your option) any later version.
+###
+### CRIMSON is distributed in the hope that it will be useful, but
+### WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+FROM alpine:latest
+
+# install packages
+RUN apk add --no-cache postgresql16-client tini shadow sed
+RUN rm -fr /var/cache/apk/*
+
+# setup main user
+RUN adduser -D db-init
+RUN groupmod --gid 1000 db-init
+RUN usermod --uid 1000 db-init
+
+# copy scripts
+COPY ./db-init /usr/local/bin/db-init
+COPY ./rev.sql /var/lib/rev.sql
+COPY ./ext.sql /var/lib/ext.sql
+COPY ./base.sql /var/lib/base.sql
+
+# remove build packages
+RUN apk del shadow
+
+# do the
+USER db-init
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["/usr/local/bin/db-init"]
diff --git a/build/db-init/README.md b/build/db-init/README.md
new file mode 100644
index 0000000..942090a
--- /dev/null
+++ b/build/db-init/README.md
@@ -0,0 +1,52 @@
+## db-init
+
+This script setups of the databse with the requrired baseline, runs migrations,
+and loads the api schema (if enabled).
+
+#### Migration script
+
+All migrations scrips MUST do ALL of the following:
+
+ - Placed in src/db/migrations
+ - Named with its migration number (0 indexed), and have four
+ numbers of padding. i.e. `0000.sql`, `0030.sql`, or `9999.sql`
+ - In numerical order with all other migrations (cannot go from migration
+ 0 to 2).
+ - A postgres transaction. `BEGIN TRANSACTION ... COMMIT TRANSACTION`.
+ - End with the following before COMMIT, where <rev> is the NEXT
+ revision number. (i.e. in `0000.sql` <rev> MUST be 1).
+
+```
+UPDATE sys.database_info SET curr_revision = <rev> WHERE name = current_database();
+```
+
+Example `0000.sql`:
+```sql
+BEGIN TRANSACTION;
+
+CREATE SCHEMA website;
+
+UPDATE sys.database_info SET curr_revision = 1 WHERE name = current_database();
+
+COMMIT TRANSACTION;
+```
+
+Migrations will ONLY EVER be ONCE, and will ALLWAYS be run in order. This means
+that you can assume all previous migrations have run successfully in any current
+migration, and there is NO other possible state in the database.
+
+### API
+
+Once all migrations have been completed, the api will be initalized (if enabled.
+
+If you opt to use postgrest which is builtin into crimson, you must create a
+sql file loads the api schema that MUST do ALL of the following:
+
+ - Placed in src/db/rest and named `rest.sql`
+ - A postgres transaction. `BEGIN TRANSACTION ... COMMIT TRANSACTION`.
+
+Within that transaction you can setup postgres with the api schema you want.
+See https://docs.postgrest.org/en/v12/. (crimson currently uses postgres 12).
+
+NOTE: If you want to load any sql file though an absolute path, src/db will be
+mounted as READ ONLY to /db. (i.e. src/db/rest/rest.sql => /db/rest/rest.sql).
diff --git a/build/db-init/base.sql b/build/db-init/base.sql
new file mode 100644
index 0000000..784eaaa
--- /dev/null
+++ b/build/db-init/base.sql
@@ -0,0 +1,86 @@
+--- CRIMSON --- A simple PHP framework.
+--- Copyright © 2024 Freya Murphy <contact@freyacat.org>
+---
+--- This file is part of CRIMSON.
+---
+--- CRIMSON is free software; you can redistribute it and/or modify it
+--- under the terms of the GNU General Public License as published by
+--- the Free Software Foundation; either version 3 of the License, or (at
+--- your option) any later version.
+---
+--- CRIMSON is distributed in the hope that it will be useful, but
+--- WITHOUT ANY WARRANTY; without even the implied warranty of
+--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+--- GNU General Public License for more details.
+---
+--- You should have received a copy of the GNU General Public License
+--- along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+-- Set's up crimsons baseline database. Can be run multiple times.
+
+BEGIN TRANSACTION;
+SET search_path = public;
+SET client_min_messages TO WARNING;
+
+-- Migration Start
+
+CREATE SCHEMA IF NOT EXISTS sys;
+
+-- sys
+
+ALTER SCHEMA sys OWNER TO POSTGRES_USER;
+
+-- sys.*/*
+
+DROP DOMAIN IF EXISTS sys."*/*" CASCADE;
+CREATE DOMAIN sys."*/*" AS BYTEA;
+
+-- sys.database_info
+
+CREATE TABLE IF NOT EXISTS sys.database_info (
+ name TEXT DEFAULT ''::text NOT NULL,
+ jwt_secret TEXT DEFAULT ''::text NOT NULL,
+ curr_revision INTEGER DEFAULT 0 NOT NULL
+);
+
+ALTER TABLE sys.database_info
+ DROP CONSTRAINT IF EXISTS database_info_pkey;
+
+ALTER TABLE sys.database_info
+ ADD CONSTRAINT database_info_pkey PRIMARY KEY (name);
+
+ALTER TABLE sys.database_info OWNER TO POSTGRES_USER;
+
+INSERT INTO sys.database_info
+ (name, curr_revision) VALUES (current_database(), 0)
+ ON CONFLICT DO NOTHING;
+
+-- sys.JWT
+
+DROP TYPE IF EXISTS sys.JWT CASCADE;
+CREATE TYPE sys.JWT AS (
+ token TEXT
+);
+
+-- authenticator
+DO
+$$
+BEGIN
+ IF NOT EXISTS (SELECT * FROM pg_user WHERE usename = 'authenticator')
+ THEN
+ CREATE ROLE authenticator
+ LOGIN NOINHERIT NOCREATEDB NOCREATEROLE NOSUPERUSER;
+ END IF;
+END
+$$;
+
+ALTER ROLE authenticator WITH PASSWORD 'postgrest';
+
+-- anonymous
+
+DROP ROLE IF EXISTS anonymous;
+CREATE ROLE anonymous NOLOGIN;
+
+-- Migration End;
+
+COMMIT TRANSACTION;
diff --git a/build/db-init/db-init b/build/db-init/db-init
new file mode 100755
index 0000000..5b62927
--- /dev/null
+++ b/build/db-init/db-init
@@ -0,0 +1,168 @@
+#!/bin/sh
+
+errors=$(mktemp)
+
+step() {
+ printf '\x1b[34;1m>> %s\x1b[0m\n' "$*"
+}
+
+error() {
+ {
+ printf '\x1b[31;1merror: \x1b[0m%s\n' "$*";
+ grep -v 'current transaction is aborted' < "$errors";
+ printf "\x1b[31m;1error: \x1b[0mAborting migrations, fix file(s) then restart process.";
+ } 1>&2;
+}
+
+try() {
+ "$@" 2> "$errors";
+ count=$(grep -c 'ERROR' < "$errors")
+ if [ "$count" -eq 0 ]; then
+ return 0;
+ else
+ return 1;
+ fi
+}
+
+export PGPASSWORD="$POSTGRES_PASSWORD"
+
+psql() {
+ /usr/bin/psql \
+ -h postgres \
+ -p 5432 \
+ -d "$POSTGRES_DB" \
+ -U "$POSTGRES_USER" \
+ "$@"
+}
+
+pg_isready() {
+ /usr/bin/pg_isready \
+ -h postgres \
+ -p 5432 \
+ -d "$POSTGRES_DB" \
+ -U "$POSTGRES_USER"
+}
+
+curr_revision() {
+ sed "s/POSTGRES_USER/$POSTGRES_USER/g" /var/lib/rev.sql > /tmp/rev.sql
+ psql -qtAX -f /tmp/rev.sql;
+}
+
+wait_until_ready() {
+ step 'Checking if the database is ready...';
+ while true; do
+ pg_isready;
+ code=$?;
+ if [ $code -eq 0 ]; then
+ break;
+ fi
+ sleep 3;
+ done
+}
+
+run_hook() {
+ hook="$1"
+ if [ ! -f "$hook" ]; then
+ printf '\x1b[31;1merror: \x1b[0m%s\n' "required hook not found: '$hook'";
+ return 1;
+ fi
+ if ! try psql -f "$hook"; then
+ error "An error occoured during a hook ($hook)"
+ return 1;
+ fi
+}
+
+run_baseline() {
+ sed "s/POSTGRES_USER/$POSTGRES_USER/g" /var/lib/base.sql > /tmp/base.sql
+ run_hook /tmp/base.sql
+}
+
+run_migrations() {
+ i="$1"
+ while true; do
+ name=$(printf "%04d" "$i");
+ file="/db/migrations/$name.sql"
+ if [ -f "$file" ]; then
+ if try psql -f "$file"; then
+ i=$((i+1));
+ continue;
+ else
+ error "An error occoured during a migration (rev $name)"
+ return 1;
+ fi
+ else
+ return 0;
+ fi
+ done
+}
+
+update_jwt() {
+ if try psql -c "UPDATE sys.database_info SET jwt_secret = '$API_SECRET' WHERE name = current_database();"; then
+ return 0;
+ else
+ error "Could not update JWT"
+ return 1;
+ fi
+}
+
+init_api () {
+ step 'Initalizing the api';
+ # reinit the api schema for
+ # postgrest
+ if ! run_hook "/db/rest/rest.sql"; then
+ return 1;
+ fi
+
+ step 'Updating JWT secret';
+ # make sure postgres has the corrent
+ # jwt secret
+ if ! update_jwt; then
+ return 1;
+ fi
+}
+
+init_db () {
+ # reomve ready status
+ # so php ignores requests
+ rm -f /var/run/crimson/db_ready
+
+ step 'Waiting for database';
+ # make sure the database is running
+ # before we run any requests
+ wait_until_ready;
+ step 'Database ready';
+
+ step 'Loading extensions';
+ # Make sure extensions are loaded
+ if ! run_hook "/var/lib/ext.sql"; then
+ return 1;
+ fi
+
+ step 'Checking baseline'
+ # make sure baseline sys schema exists
+ if ! run_baseline; then
+ return 1;
+ fi
+
+ step 'Peforming migrations';
+ # get the current revision
+ REV=$(curr_revision);
+ step "Database at revision: $REV"
+ # run each migration that is
+ # higher than our current revision
+ if ! run_migrations "$REV"; then
+ return 1;
+ fi
+
+ if [ "$API_ENABLED" = "true" ]; then
+ if ! init_api; then
+ return 1;
+ fi
+ fi
+
+ step 'Database is initialized'
+ # database is ready
+ touch /var/run/crimson/db_ready
+}
+
+init_db
diff --git a/build/db-init/ext.sql b/build/db-init/ext.sql
new file mode 100644
index 0000000..5ec4fea
--- /dev/null
+++ b/build/db-init/ext.sql
@@ -0,0 +1,28 @@
+--- CRIMSON --- A simple PHP framework.
+--- Copyright © 2024 Freya Murphy <contact@freyacat.org>
+---
+--- This file is part of CRIMSON.
+---
+--- CRIMSON is free software; you can redistribute it and/or modify it
+--- under the terms of the GNU General Public License as published by
+--- the Free Software Foundation; either version 3 of the License, or (at
+--- your option) any later version.
+---
+--- CRIMSON is distributed in the hope that it will be useful, but
+--- WITHOUT ANY WARRANTY; without even the implied warranty of
+--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+--- GNU General Public License for more details.
+---
+--- You should have received a copy of the GNU General Public License
+--- along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+-- Loads required postgres extensions.
+
+BEGIN TRANSACTION;
+SET search_path = public;
+SET client_min_messages TO WARNING;
+
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+CREATE EXTENSION IF NOT EXISTS pgjwt;
+
+COMMIT TRANSACTION;
diff --git a/build/db-init/rev.sql b/build/db-init/rev.sql
new file mode 100644
index 0000000..d8443c3
--- /dev/null
+++ b/build/db-init/rev.sql
@@ -0,0 +1,41 @@
+--- CRIMSON --- A simple PHP framework.
+--- Copyright © 2024 Freya Murphy <contact@freyacat.org>
+---
+--- This file is part of CRIMSON.
+---
+--- CRIMSON is free software; you can redistribute it and/or modify it
+--- under the terms of the GNU General Public License as published by
+--- the Free Software Foundation; either version 3 of the License, or (at
+--- your option) any later version.
+---
+--- CRIMSON is distributed in the hope that it will be useful, but
+--- WITHOUT ANY WARRANTY; without even the implied warranty of
+--- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+--- GNU General Public License for more details.
+---
+--- You should have received a copy of the GNU General Public License
+--- along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+-- Gets the current databse revision.
+
+CREATE OR REPLACE FUNCTION curr_revision()
+RETURNS INTEGER
+LANGUAGE plpgsql VOLATILE
+AS $BODY$
+DECLARE
+ _revision INTEGER;
+BEGIN
+ BEGIN
+ SELECT curr_revision INTO _revision
+ FROM sys.database_info
+ WHERE name = current_database();
+ RETURN _revision;
+ EXCEPTION WHEN OTHERS THEN
+ RETURN 0;
+ END;
+END
+$BODY$;
+
+GRANT EXECUTE ON FUNCTION curr_revision() TO POSTGRES_USER;
+
+SELECT curr_revision();
diff --git a/build/init/Dockerfile b/build/init/Dockerfile
new file mode 100644
index 0000000..cd2af92
--- /dev/null
+++ b/build/init/Dockerfile
@@ -0,0 +1,22 @@
+FROM alpine:latest
+
+# install packages
+RUN apk add --no-cache tini shadow coreutils findutils
+RUN rm -fr /var/cache/apk/*
+
+# setup main user
+RUN adduser -D init
+RUN groupmod --gid 1000 init
+RUN usermod --uid 1000 init
+
+# copy scripts
+COPY ./init /usr/local/bin/init
+COPY ./stamp.sh /usr/local/bin/stamp.sh
+
+# remove build packages
+RUN apk del shadow
+
+# do the
+USER init
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["/usr/local/bin/init"]
diff --git a/build/init/init b/build/init/init
new file mode 100755
index 0000000..a4de21f
--- /dev/null
+++ b/build/init/init
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+# stamp assets
+/usr/local/bin/stamp.sh
diff --git a/build/init/stamp.sh b/build/init/stamp.sh
new file mode 100755
index 0000000..6f71038
--- /dev/null
+++ b/build/init/stamp.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+out="/var/run/crimson/stamp.php"
+public="/opt/site/public"
+files=$(find "$public" -type f -printf %P\\n)
+
+printf "<?php\n\$__stamps = array();\n" > "$out"
+for file in $files; do
+ stamp=$(date +%s -r "$public/$file")
+ echo "\$__stamps['public/$file'] = $stamp;" >> "$out";
+done
+echo "define('FILE_TIMES', \$__stamps);" >> "$out"
+echo "unset(\$__stamps);" >> "$out"
+
diff --git a/build/nginx/Dockerfile b/build/nginx/Dockerfile
new file mode 100644
index 0000000..f74d555
--- /dev/null
+++ b/build/nginx/Dockerfile
@@ -0,0 +1,30 @@
+FROM alpine:latest
+
+# install packages
+RUN apk add --no-cache nginx shadow curl tini
+RUN rm -fr /var/cache/apk/*
+
+# update nginx user
+RUN groupmod --gid 1000 nginx
+RUN usermod --uid 1000 nginx
+
+# remove build packages
+RUN apk del shadow
+
+# make log syms
+RUN ln -sf /dev/stdout /var/log/nginx/access.log && \
+ ln -sf /dev/stderr /var/log/nginx/error.log
+
+# copy configs
+RUN mkdir -p /etc/nginx
+COPY ./*.conf /etc/nginx/
+RUN chown -R nginx:nginx /etc/nginx
+
+# copy entrypoint
+COPY ./entrypoint.sh /usr/local/bin/entrypoint
+RUN chmod +x /usr/local/bin/entrypoint
+
+# do the
+USER nginx
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["/usr/local/bin/entrypoint"]
diff --git a/build/nginx/entrypoint.sh b/build/nginx/entrypoint.sh
new file mode 100755
index 0000000..6dc7eec
--- /dev/null
+++ b/build/nginx/entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+if [ ! "$API_ENABLED" = "true" ]; then
+ echo "" > /etc/nginx/nginx.api.conf
+ echo "" > /etc/nginx/nginx.api.server.conf
+fi
+
+exec -a /usr/sbin/nginx /usr/sbin/nginx -c /etc/nginx/nginx.conf
diff --git a/build/nginx/nginx.api.conf b/build/nginx/nginx.api.conf
new file mode 100644
index 0000000..52190c8
--- /dev/null
+++ b/build/nginx/nginx.api.conf
@@ -0,0 +1,3 @@
+upstream postgrest {
+ server rest:3000;
+}
diff --git a/build/nginx/nginx.api.server.conf b/build/nginx/nginx.api.server.conf
new file mode 100644
index 0000000..5dd88a4
--- /dev/null
+++ b/build/nginx/nginx.api.server.conf
@@ -0,0 +1,14 @@
+location /api/ {
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $http_host;
+ proxy_set_header Accept-Encoding "";
+ proxy_redirect off;
+
+ default_type application/json;
+ add_header 'Access-Control-Allow-Origin' '*';
+ add_header Content-Location /api/$upstream_http_content_location;
+ proxy_set_header Connection "";
+ proxy_http_version 1.1;
+
+ proxy_pass http://postgrest/;
+}
diff --git a/build/nginx/nginx.conf b/build/nginx/nginx.conf
new file mode 100644
index 0000000..d3dc0ae
--- /dev/null
+++ b/build/nginx/nginx.conf
@@ -0,0 +1,54 @@
+worker_processes 4;
+daemon off;
+pid /tmp/nginx.pid;
+error_log /var/log/nginx/error.log;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+ sendfile on;
+ keepalive_timeout 70;
+ server_tokens off;
+ client_max_body_size 2m;
+
+ access_log /var/log/nginx/access.log;
+
+ include "nginx.api.conf";
+
+ server {
+ listen 8080;
+ root /opt/site;
+
+ gzip on;
+ gzip_vary on;
+ gzip_proxied any;
+ gzip_comp_level 6;
+ gzip_buffers 16 8k;
+ gzip_http_version 1.1;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml image/x-icon;
+
+ include "nginx.api.server.conf";
+
+ location /favicon.ico {
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ root /opt/site/public;
+ }
+
+ location /public {
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ try_files $uri =404;
+ }
+
+ location / {
+ add_header Content-Security-Policy "base-uri 'none'";
+ root /opt/crimson;
+ include fastcgi_params;
+ fastcgi_pass php:9000;
+ fastcgi_param SCRIPT_FILENAME $document_root/index.php;
+ }
+ }
+}
diff --git a/build/php/Dockerfile b/build/php/Dockerfile
new file mode 100644
index 0000000..5f4bdd5
--- /dev/null
+++ b/build/php/Dockerfile
@@ -0,0 +1,17 @@
+FROM php:fpm-alpine
+
+# install packages
+RUN apk add --no-cache postgresql-dev runuser shadow
+RUN rm -fr /var/cache/apk/*
+
+# update php user
+RUN groupmod --gid 1000 www-data
+RUN usermod --uid 1000 www-data
+
+# install php packages
+RUN docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql
+RUN docker-php-ext-install pdo pdo_pgsql
+
+# remove build packages
+RUN apk del shadow
+USER www-data
diff --git a/build/postgres/Dockerfile b/build/postgres/Dockerfile
new file mode 100644
index 0000000..8223800
--- /dev/null
+++ b/build/postgres/Dockerfile
@@ -0,0 +1,24 @@
+FROM postgres:16-alpine
+
+# install packages
+RUN apk add --no-cache make git shadow
+RUN rm -fr /var/cache/apk/*
+
+# install pgjwt
+RUN git clone https://github.com/michelp/pgjwt.git /tmp/pgjwt
+WORKDIR /tmp/pgjwt
+RUN make install
+
+# update postgres user
+RUN groupmod --gid 1000 postgres
+RUN usermod --uid 1000 postgres
+
+# remove build packages
+RUN apk del make git shadow
+
+# set perms
+RUN chown -R postgres:postgres /var/run/postgresql
+
+# fix workdir
+WORKDIR /
+USER postgres
diff --git a/build/postgrest/Dockerfile b/build/postgrest/Dockerfile
new file mode 100644
index 0000000..747052f
--- /dev/null
+++ b/build/postgrest/Dockerfile
@@ -0,0 +1,30 @@
+FROM alpine:latest
+
+# install packages
+RUN apk add --no-cache tini wget curl shadow
+RUN rm -fr /var/cache/apk/*
+
+# setup main user
+RUN adduser -D postgrest
+RUN groupmod --gid 1000 postgrest
+RUN usermod --uid 1000 postgrest
+
+# install postgrest
+RUN wget "https://github.com/PostgREST/postgrest/releases/download/v12.2.3/postgrest-v12.2.3-linux-static-x64.tar.xz" -O /tmp/postgrest.tar.xz
+RUN tar xJf /tmp/postgrest.tar.xz -C /usr/local/bin
+RUN rm /tmp/postgrest.tar.xz
+
+# copy scripts
+COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
+
+# remove build packages
+RUN apk del shadow
+
+# make the dirs
+RUN mkdir -p /etc/postgrest.d && \
+ chown postgrest:postgrest /etc/postgrest.d
+
+# do the
+USER postgrest
+ENTRYPOINT ["/sbin/tini", "--"]
+CMD ["/usr/local/bin/entrypoint.sh"]
diff --git a/build/postgrest/entrypoint.sh b/build/postgrest/entrypoint.sh
new file mode 100755
index 0000000..6a944f2
--- /dev/null
+++ b/build/postgrest/entrypoint.sh
@@ -0,0 +1,23 @@
+#!/bin/sh
+
+config=/etc/postgrest.d/postgrest.conf
+
+JWT_SECRET="$API_SECRET"
+
+PGRST_DB_URI="postgres://authenticator:postgrest@postgres:5432/$POSTGRES_DB"
+PGRST_ROLE="$API_ROLE"
+PGRST_SCHEMA="$API_SCHEMA"
+
+rm -fr "$config"
+touch "$config"
+{
+ printf 'db-uri = "%s"\n' "$PGRST_DB_URI";
+ printf 'db-anon-role = "%s"\n' "$PGRST_ROLE";
+ printf 'db-schemas = "%s"\n' "$PGRST_SCHEMA";
+ printf 'jwt-secret = "%s"\n' "$JWT_SECRET";
+ printf 'jwt-secret-is-base64 = false\n';
+ printf 'server-host = "*"\n';
+ printf 'server-port = 3000\n';
+} >> $config
+
+exec -a /usr/local/bin/postgrest /usr/local/bin/postgrest "$config"
diff --git a/compose b/compose
new file mode 100755
index 0000000..20126f4
--- /dev/null
+++ b/compose
@@ -0,0 +1,122 @@
+#!/bin/sh
+### CRIMSON --- A simple PHP framework.
+### Copyright © 2024 Freya Murphy <contact@freyacat.org>
+###
+### This file is part of CRIMSON.
+###
+### CRIMSON is free software; you can redistribute it and/or modify it
+### under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 3 of the License, or (at
+### your option) any later version.
+###
+### CRIMSON is distributed in the hope that it will be useful, but
+### WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+# `compose`
+# This script will provide a docker compose interface with all the CRIMSON
+# environment setup. This script may request root privilages since it needs all
+# docker containers to run with user 1000:1000. If you are not user 1000:1000,
+# root privlage is required to create volume folders with uid/gid 1000.
+
+# Make sure errors fail to avoid nasal demons
+set -e
+
+# ========================================================= PERMISSION CHECK ==
+# Make sure we are either root or user 1000:1000. This is required for crimson.
+
+args="$@"
+command="$0 $args"
+
+uid=$(id -u)
+gid=$(id -g)
+
+if [[ $uid -eq 1000 ]] && [[ $gid -eq 1000 ]]; then
+ true # all set
+elif [[ $uid -eq 0 ]] && [[ $gid -eq 0 ]]; then
+ true # all set
+else
+ # root required (1000:1000 may not exist)
+ exec sudo -E $command
+fi
+
+# ================================================================ CONSTANTS ==
+# ROOT: This is the folder the crimson project is located in. ROOT is used to
+# access docker-compose files, base.env, and other crimson files.
+# CALL_ROOT: This is the folder that the user who called `compose` is currently
+# in. For crimson to work this must be the folder that your project using
+# crimson is. This is because crimson loads `.env` here to load any user
+# specified environment. `.env` is needed for $DATA and $SOURCE. Read base.env
+# for more information.
+
+ROOT="$(realpath "$(dirname "$0")")"
+CALL_ROOT="$(pwd)"
+
+# ================================================================ FUNCTIONS ==
+# add_arg - adds arguments to the docker compose command to be run
+# include_env - loads a .env file
+# include - adds the docker compose file to be included called
+# docker-compose.<$1>.yml
+
+docker_args=""
+function add_arg {
+ docker_args="$docker_args $@"
+}
+
+function include_env {
+ local file
+ file="$1"
+ if [ -f "$file" ]; then
+ source "$file"
+ add_arg --env-file $file
+ fi
+}
+
+function include {
+ local file
+ if [ "$2" = "true" ]; then
+ file="$ROOT/docker/docker-compose.$1.yml"
+ add_arg -f $file
+ fi
+}
+
+# ================================================================ BOOTSTRAP ==
+# Enter the crimson project directory, load all .env files, and pick which
+# docker-compose.*.yml files are requested. Then make the docker volumes here
+# with the correct permissions. If we let docker do it, it will make them owned
+# by root (thanks) and break our containers.
+
+cd "$ROOT"
+
+# get docker file includes
+include_env "$ROOT/base.env"
+include_env "$CALL_ROOT/.env"
+include "base" "true"
+include "db" "$POSTGRES_ENABLED"
+include "api" "$API_ENABLED"
+
+# assert SOURCE and DATA are set
+if [ -z "$SOURCE" ]; then
+ printf "fatal: SOURCE is not set. See '$ROOT/base.env'\n"
+ exit 1
+fi
+
+if [ -z "$DATA" ]; then
+ printf "fatal: DATA is not set. See '$ROOT/base.env'\n"
+ exit 1
+fi
+
+# preset perms (postgres will crash if not)
+if [ ! -d "$DATA" ]; then
+ mkdir -p "$DATA"
+ mkdir -p "$DATA/crimson"
+ mkdir -p "$DATA/schemas"
+ chown -R 1000:1000 "$DATA"
+fi
+
+# run docker compose
+exec -a docker -- docker compose -p $PROJECT_NAME $docker_args "$@"
diff --git a/docker/docker-compose.api.yml b/docker/docker-compose.api.yml
new file mode 100644
index 0000000..5c7f6d8
--- /dev/null
+++ b/docker/docker-compose.api.yml
@@ -0,0 +1,60 @@
+### CRIMSON --- A simple PHP framework.
+### Copyright © 2024 Freya Murphy <contact@freyacat.org>
+###
+### This file is part of CRIMSON.
+###
+### CRIMSON is free software; you can redistribute it and/or modify it
+### under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 3 of the License, or (at
+### your option) any later version.
+###
+### CRIMSON is distributed in the hope that it will be useful, but
+### WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+services:
+ # `docker-compose-api.yml`
+ # This compose file creates the `rest` container running postgrest. This is
+ # the program that crimson uses for the default API functionality.
+ #
+ # This service stack is only enabled when API_ENABLED=true.
+ #
+ # WARNING: The postgres container must also be enabled `POSTGRES_ENABLED=true`
+ # otherwise the docker stack will be invalid.
+
+ rest:
+ # Postgrest uses postgres to store the api schema. To work properly, the
+ # database must be working (healthy) and all the api schema must be loaded.
+ # It is db-init's job to load the api schema, thus this container depends
+ # on both postgres and db-init.
+ build: ../build/postgrest
+ restart: unless-stopped
+ environment:
+ - API_SECRET
+ - API_ROLE
+ - API_SCHEMA
+ - POSTGRES_DB
+ - POSTGRES_USER
+ - POSTGRES_PASSWORD
+ healthcheck:
+ test: curl -I "http://localhost:3000/"
+ interval: 10s
+ timeout: 3s
+ retries: 10
+ start_period: 3s
+ depends_on:
+ postgres:
+ condition: service_healthy
+ db-init:
+ condition: service_completed_successfully
+
+ web:
+ # Nginx proxies requests to the API making it an added dependency.
+ depends_on:
+ rest:
+ condition: service_healthy
+
diff --git a/docker/docker-compose.base.yml b/docker/docker-compose.base.yml
new file mode 100644
index 0000000..3dcc7b8
--- /dev/null
+++ b/docker/docker-compose.base.yml
@@ -0,0 +1,72 @@
+### CRIMSON --- A simple PHP framework.
+### Copyright © 2024 Freya Murphy <contact@freyacat.org>
+###
+### This file is part of CRIMSON.
+###
+### CRIMSON is free software; you can redistribute it and/or modify it
+### under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 3 of the License, or (at
+### your option) any later version.
+###
+### CRIMSON is distributed in the hope that it will be useful, but
+### WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+services:
+ # `docker-compose-base.yml`
+ # This compose file creates the `web`, `php`, and `init` containers.
+ # This is the barebones stack required to run crimson.
+ #
+ # This service stack is ALWAYS enabled.
+
+ web:
+ # Crimson runs a nginx proxy to facilitate requets to PHP and requests
+ # to the API (if enabled). Since it needs PHP to be running to send
+ # requests, `php` is an added dependency.
+ #
+ # HTTP_PORT and HTTP_BIND sets what the external listen address will be for
+ # the entire crimson stack.
+ build: ../build/nginx
+ restart: unless-stopped
+ environment:
+ - API_ENABLED
+ ports:
+ - ${HTTP_BIND}:${HTTP_PORT}:8080
+ volumes:
+ - ${SOURCE}:/opt/site:ro
+ - ../src:/opt/crimson:ro
+ depends_on:
+ php:
+ condition: service_started
+
+ php:
+ # There exists some crimson functionaly that MAY be used which requires a
+ # stamp.php file to be auto generated. This is done in `init`, this `init`
+ # is an added dependency.
+ build: ../build/php
+ restart: unless-stopped
+ environment:
+ - POSTGRES_DB
+ - POSTGRES_USER
+ - POSTGRES_PASSWORD
+ volumes:
+ - ${SOURCE}:/opt/site:ro
+ - ../src:/opt/crimson:ro
+ - ${DATA}/crimson:/var/run/crimson
+ depends_on:
+ init:
+ condition: service_completed_successfully
+
+ init:
+ # Initalizes required files for php. Currently init only generates stamp.php.
+ # This file hols all file stamps for all public assets, which is used in
+ # crimsons `asset_stamp` controller function.
+ build: ../build/init
+ restart: no
+ volumes:
+ - ${SOURCE}:/opt/site
+ - ${DATA}/crimson:/var/run/crimson
diff --git a/docker/docker-compose.db.yml b/docker/docker-compose.db.yml
new file mode 100644
index 0000000..4979bbe
--- /dev/null
+++ b/docker/docker-compose.db.yml
@@ -0,0 +1,80 @@
+### CRIMSON --- A simple PHP framework.
+### Copyright © 2024 Freya Murphy <contact@freyacat.org>
+###
+### This file is part of CRIMSON.
+###
+### CRIMSON is free software; you can redistribute it and/or modify it
+### under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 3 of the License, or (at
+### your option) any later version.
+###
+### CRIMSON is distributed in the hope that it will be useful, but
+### WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+services:
+ # `docker-compose-db.yml`
+ # This compose file creates the `postgres`, and `db-init` containers.
+ # Crimson uses prostgres for its database and uses db-init to initalize the
+ # baseline required for all crimson components.
+ #
+ # This service stack is only enabled when API_ENABLED=true.
+
+ postgres:
+ build: ../build/postgres
+ restart: unless-stopped
+ environment:
+ - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
+ - POSTGRES_DB
+ - POSTGRES_USER
+ - POSTGRES_PASSWORD
+ volumes:
+ - ${DATA}/schemas:/var/lib/postgresql/data
+ - ${SOURCE}/db:/db:ro
+ healthcheck:
+ test: pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}
+ interval: 1s
+ timeout: 3s
+ retries: 10
+ start_period: 3s
+
+ db-init:
+ # => Guarantees the required crimson baseline in the database.
+ # i.e. sys schema used for storing migration meta data and other small
+ # things.
+ # => Runs all database migrations for you. All that is needed
+ # is that the migration file convention is followed.
+ # => If the API is enabled, also loads in the api schema for postgrest.
+ #
+ # Once db-init finishes, it will create a `db-ready` file in
+ # /var/run/crimson to tell crimson's php code its to further route requests.
+ #
+ # For information on databse conventions and layouts, see
+ # `build/db-init/README.md`.
+ build: ../build/db-init
+ restart: no
+ environment:
+ - POSTGRES_DB
+ - POSTGRES_USER
+ - POSTGRES_PASSWORD
+ - API_ENABLED
+ - API_SECRET
+ volumes:
+ - ${SOURCE}/db:/db:ro
+ - ${DATA}/crimson:/var/run/crimson
+ depends_on:
+ postgres:
+ condition: service_healthy
+
+ php:
+ # Crimson supports calling the postgres database inside php though the
+ # database library. Thus is in added dependency. Crimson does not need to
+ # depend on db-init since db-init has a ready file that crimons uses
+ # instead.
+ depends_on:
+ postgres:
+ condition: service_healthy
diff --git a/psql b/psql
new file mode 100755
index 0000000..71071a4
--- /dev/null
+++ b/psql
@@ -0,0 +1,45 @@
+#!/bin/sh
+### CRIMSON --- A simple PHP framework.
+### Copyright © 2024 Freya Murphy <contact@freyacat.org>
+###
+### This file is part of CRIMSON.
+###
+### CRIMSON is free software; you can redistribute it and/or modify it
+### under the terms of the GNU General Public License as published by
+### the Free Software Foundation; either version 3 of the License, or (at
+### your option) any later version.
+###
+### CRIMSON is distributed in the hope that it will be useful, but
+### WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+### GNU General Public License for more details.
+###
+### You should have received a copy of the GNU General Public License
+### along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+# `psql`
+# This is an alias for running
+# $ `compose` exec postgres psql -U $POSTGRES_USER $POSTGRES_DB.
+#
+# When working on a crimson project, you will likely have to lurk within the
+# database at times. This makes it nicer enter it since you dont have to type
+# the full command. :)
+
+# ================================================================ CONSTANTS ==
+# ROOT: This is the folder the crimson project is located in. ROOT is used to
+# load the crimson environment. We need this since that is where POSTGRES_USER
+# and POSTGRES_DB are stored.
+# CALL_ROOT: This is the folder that the user who called `compose` is currently
+# in. For crimson to work this must be the folder that your project using
+# crimson is. This is because crimson loads `.env` here to load any user
+# specified environment. `.env` is needed in cause you override POSTGRES_USER
+# and/or POSTGRES_DB.
+ROOT="$(dirname "$0")"
+CALL_ROOT="$(pwd)"
+
+# ================================================================ BOOTSTRAP ==
+# Load `base.env` and `.env`, then launch psql in docker.
+
+source "$ROOT/base.env"
+source "$CALL_ROOT/.env"
+$ROOT/compose exec postgres psql -U "${POSTGRES_USER}" "${POSTGRES_DB}"
diff --git a/src/_base.php b/src/_base.php
new file mode 100644
index 0000000..89df718
--- /dev/null
+++ b/src/_base.php
@@ -0,0 +1,309 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Gives access to imporant
+ * needed utility functions for
+ * accessing everything else!
+ */
+abstract class Base {
+
+// ======================================================== LOADABLE OBJECTS ==
+
+ // keep track of what has been loaded
+ private static array $loaded = array();
+
+ /**
+ * Loads a $type of object from a $dir with a given $name
+ * @param string $name - the name of the object to load
+ * @param string $dir - the directory theese objects are stored in
+ * @param string $type - the type of the object
+ */
+ private function load_type($name, $dir, $type): object|NULL
+ {
+
+ $path = $dir . '/' . $name . '.php';
+
+ // dont reload an ohject
+ if (array_key_exists($path, self::$loaded))
+ return self::$loaded[$path];
+
+ // only load a object if it exists
+ if (!file_exists($path))
+ return NULL;
+
+ $parts = explode('/', $name);
+ $part = end($parts);
+ $class = ucfirst($part) . '_' . $type;
+ require($path);
+
+ $ref = NULL;
+ try {
+ $ref = new ReflectionClass($class);
+ } catch (Exception $_e) {}
+
+ if ($ref === NULL)
+ return NULL;
+
+ $obj = $ref->newInstance();
+ self::$loaded[$path] = $obj;
+
+ return $obj;
+ }
+
+ /**
+ * Loads a model
+ * @param string $name - the name of the model to load
+ */
+ public function load_model($name): Model|NULL
+ {
+ $dir = WEB_ROOT . '/_model';
+ return $this->load_type($name, $dir, 'model');
+ }
+
+ /**
+ * Loads a controller
+ * @param string $name - the name of the controller to load
+ */
+ public function load_controller($name): Controller|NULL
+ {
+ $dir = WEB_ROOT . '/_controller';
+ return $this->load_type($name, $dir, 'controller');
+ }
+
+// ==================================================================== LANG ==
+
+ // current loaded language strings
+ private static array $loaded_lang = array();
+ private static array $loaded_files = array();
+
+ /**
+ * Loads a php lang file into the lang array
+ */
+ private function load_lang_file(string $file): void
+ {
+ if (isset(self::$loaded[$file]))
+ return;
+ self::$loaded[$file] = TRUE;
+
+ $lang = self::$loaded_lang;
+ require($file);
+ self::$loaded_lang = $lang;
+ }
+
+ /**
+ * Loads each php file lang strings in a directory
+ */
+ private function load_lang_dir(string $dir): void
+ {
+ if ($handle = opendir($dir)) {
+ while (false !== ($entry = readdir($handle))) {
+ if ($entry === '.' || $entry === '..')
+ continue;
+ $this->load_lang_file($entry);
+ }
+ }
+ }
+
+ /**
+ * Loads the given common lang
+ */
+ public function load_lang(string ...$langs): array
+ {
+ $root = WEB_ROOT . '/lang';
+
+ foreach ($langs as $lang) {
+ $file = "{$root}/{$lang}.php";
+ $dir = "{$root}/{$lang}";
+
+ if (file_exists($file))
+ $this->load_lang_file($file);
+ else if (is_dir($dir))
+ $this->load_lang_dir($dir);
+
+ }
+
+ return self::$loaded_lang;
+ }
+
+ /**
+ * Returns the currently loaded lang
+ */
+ public function get_lang(): array
+ {
+ return self::$loaded_lang;
+ }
+
+// ================================================================ DATABASE ==
+
+ // current database connection
+ private static ?DatabaseHelper $db = NULL;
+
+ /**
+ * Loads the database
+ */
+ public function db(): DatabaseHelper
+ {
+ if (!self::$db)
+ self::$db = new DatabaseHelper();
+ return self::$db;
+ }
+
+// ================================================================ METADATA ==
+
+ /**
+ * Gets the stamp for a asset path
+ * @param string $path
+ */
+ public function asset_stamp(string $path): int
+ {
+ if (ENVIRONMENT == 'devlopment')
+ return time();
+ if (isset(FILE_TIMES[$path]))
+ return FILE_TIMES[$path];
+ return 0;
+ }
+
+ /**
+ * Gets a full path url from a relative path
+ * @param string $path
+ * @param bool $timestamp
+ */
+ public function get_url(string $path, bool $timestamp = FALSE): string
+ {
+ $scheme = 'http';
+ if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']))
+ $scheme = $_SERVER['HTTP_X_FORWARDED_PROTO'];
+
+ $host = $_SERVER['HTTP_HOST'];
+
+ if (ENVIRONMENT == 'production') {
+ $default = CONFIG['domain'];
+ $allowed = CONFIG['allowed_hosts'];
+ if (!is_array($allowed))
+ $allowed = [$allowed];
+ if (!in_array($host, $allowed))
+ $host = $default;
+ }
+
+ $base = CONFIG['base_path'];
+ $url = "{$scheme}://{$host}{$base}{$path}";
+ if ($timestamp) {
+ $time = $this->asset_stamp($path);
+ $url .= "?timestamp={$time}";
+ }
+ return $url;
+ }
+
+ /**
+ * Loads a js html link
+ * @param string $path - the path to the js file
+ */
+ public function link_js(string $path): string
+ {
+ $stamp = $this->asset_stamp("public/$path");
+ $href = $this->get_url("public/{$path}?timestamp={$stamp}");
+ return '<script src="'. $href .'"></script>';
+ }
+
+ /**
+ * Loads a css html link
+ * @param string $path - the path to the css file
+ */
+ public function link_css(string $path): string
+ {
+ $stamp = $this->asset_stamp("public/$path");
+ $href = $this->get_url("public/{$path}?timestamp={$stamp}");
+ return '<link rel="stylesheet" href="'. $href .'">';
+ }
+
+ /**
+ * Loads a css html link
+ * @param string $path - the path to the css file
+ */
+ public function embed_css(string $path): string
+ {
+ $file = PUBLIC_ROOT . '/' . $path;
+ if (file_exists($file)) {
+ $text = file_get_contents($file);
+ return "<style>{$text}</style>";
+ } else {
+ return "";
+ }
+ }
+
+// =============================================================== HTTP POST ==
+
+ /**
+ * Gets http POST data from $_POST if x-url-encoded, or parsed from
+ * php://input if AJAX. Returns FALSE if input is not a post request,
+ * or NULL if unable to parse request body.
+ */
+ private function get_post_data()
+ {
+ static $data = NULL;
+
+ if (isset($data))
+ return $data;
+
+ // not a POST request
+ if ($_SERVER['REQUEST_METHOD'] != 'POST')
+ return NULL;
+
+ // ajax
+ if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
+ $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest') {
+ $data = json_decode(file_get_contents("php://input"),
+ true);
+ // on failure, make sure $data is FALSE and not NULL
+ if (!$data)
+ $data = FALSE;
+ return $data;
+ }
+
+ // x-url-encoded or form-data
+ $data = $_POST;
+ return $data;
+ }
+
+ /**
+ * Returns HTTP POST information if POST request.
+ * @returns $_POST if $key is not set and request is POST.
+ * @returns value at $key if $key is set and request is POST.
+ * @returns FALSE if value at $key is not set and request is POST.
+ * @returns FALSE if request is POST but has invalid body.
+ * @returns NULL if request is not POST.
+ */
+ public function post_data(?string $key = NULL)
+ {
+ $data = $this->get_post_data();
+
+ if (!$data)
+ return $data;
+
+ if (!$key)
+ return $data;
+
+ if (!isset($data[$key]))
+ return FALSE;
+
+ return $data[$key];
+ }
+
+}
diff --git a/src/_controller.php b/src/_controller.php
new file mode 100644
index 0000000..6c829a0
--- /dev/null
+++ b/src/_controller.php
@@ -0,0 +1,66 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+abstract class Controller extends Base {
+
+ // used by lib meta
+ public array $CRIMSON_data = array();
+
+ /**
+ * Default index for a app, empty
+ */
+ public function index(): void {}
+
+ /**
+ * Lodas a view
+ */
+ protected function view(string $CRIMSON_name, array $data = array()): void
+ {
+ $CRIMSON_path = WEB_ROOT . '/_views/' . $CRIMSON_name . '.php';
+ $this->CRIMSON_data = $data;
+
+ if (!is_file($CRIMSON_path)) {
+ CRIMSON_ERROR("View '{$CRIMSON_name}' does not exist!");
+ return;
+ }
+
+ extract($this->CRIMSON_data);
+ require($CRIMSON_path);
+ }
+
+ /**
+ * Redirectes to a link
+ */
+ protected function redirect(string $link): never
+ {
+ header('Location: '. $link, true, 301);
+ CRIMSON_DIE();
+ }
+
+ /**
+ * Loads a erorr page with a given
+ * error code
+ */
+ protected function error(int $code): never
+ {
+ // does not return
+ ROUTER->handle_error($code);
+ }
+
+}
diff --git a/src/_model.php b/src/_model.php
new file mode 100644
index 0000000..ce8dd82
--- /dev/null
+++ b/src/_model.php
@@ -0,0 +1,63 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+abstract class Model extends Base {
+
+ private function flatten_array($arr): array {
+ $fn = fn($e) => is_array($e) ? $e : [$e];
+ return array_merge(...array_map($fn, $arr));
+ }
+
+ protected function get_data(): ?array
+ {
+ /* return barebones base data */
+ $data = array();
+ $data['css'] = array();
+ $data['js'] = array();
+
+ $style = CONFIG['style'];
+ $js = CONFIG['js'];
+
+ $app = NULL;
+ try {
+ // get the class object of the child class, i.e. Blog_model
+ $cls = new ReflectionClass(get_class($this));
+ // the name of the route is the name of the file without .php
+ $path = $cls->getFileName();
+ $app = pathinfo($path, PATHINFO_FILENAME);
+ // sanity check
+ assert(is_string($app));
+ } catch (Exception $_e) {
+ $app = CONTEXT['app'];
+ }
+
+ $data['css'] = $this->flatten_array([
+ $style[''] ?? [],
+ $style[$app] ?? [],
+ ]);
+
+ $data['js'] = $this->flatten_array([
+ $js[''] ?? [],
+ $js[$app] ?? [],
+ ]);
+
+ return $data;
+ }
+
+}
diff --git a/src/config.php b/src/config.php
new file mode 100644
index 0000000..0d52d96
--- /dev/null
+++ b/src/config.php
@@ -0,0 +1,75 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+// ENVIRONMENT
+//
+// devlopment - do not cache any assets
+// - use http host provided by user
+//
+// production - use generated timestamps for each file
+// - hard code http host to 'domain' lang string
+//
+if (!defined('ENVIRONMENT')) {
+ if (getenv('ENVIRONMENT') !== FALSE)
+ define('ENVIRONMENT', getenv('ENVIRONMENT'));
+ else
+ define('ENVIRONMENT', 'devlopment');
+}
+
+// CONFIG
+// config values needed across the website
+//
+// domain - the default domain for the website
+//
+// allowed_hosts - accepted domains to use for the website
+//
+// base_path - the base path the website is located at
+//
+// theme_color - html hex color used for browser metadata
+//
+// routes - array of regex keys that match the request path and
+// - place it with the value if it matches
+// - e.g. '' => 'home' sends / to /home
+//
+// style - single or list of css styles to load on specific routes
+//
+// js - single or list of js script to load on specific routes
+//
+// autoload - list of directories to autoload all PHP files in them
+//
+define('BASE_CONFIG', array(
+ /* core settings */
+ 'domain' => 'localhost',
+ 'allowed_hosts' => ['localhost'],
+ 'base_path' => '/',
+ 'theme_color' => '#181818',
+ /* route overides */
+ 'routes' => array(),
+ /* css to load on each route */
+ 'style' => array(),
+ /* js to load on each route */
+ 'js' => array(),
+ /* directories to autoload php code */
+ 'autoload' => array(),
+));
+
+if (!defined('SITE_CONFIG'))
+ define('SITE_CONFIG', array());
+
+define('CONFIG', array_merge(BASE_CONFIG, SITE_CONFIG));
diff --git a/src/index.php b/src/index.php
new file mode 100644
index 0000000..98b8be7
--- /dev/null
+++ b/src/index.php
@@ -0,0 +1,68 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+// ============================================================= ENVIRONMENT ==
+
+// define folder paths
+define('CRIMSON_ROOT', '/opt/crimson');
+define('PHP_ROOT', '/opt/site');
+define('WEB_ROOT', PHP_ROOT . '/web');
+define('PUBLIC_ROOT', PHP_ROOT . '/public');
+
+// =============================================================== BOOTSTRAP ==
+
+// load the config
+@include(WEB_ROOT . '/config.php');
+require(CRIMSON_ROOT . '/config.php');
+
+// load all core files (order matters)
+require(CRIMSON_ROOT . '/lib/database.php');
+require(CRIMSON_ROOT . '/_base.php');
+require(CRIMSON_ROOT . '/_model.php');
+require(CRIMSON_ROOT . '/_controller.php');
+require(CRIMSON_ROOT . '/router.php');
+require(CRIMSON_ROOT . '/lib/error.php');
+require(CRIMSON_ROOT . '/lib/hooks.php');
+require(CRIMSON_ROOT . '/lib/lang.php');
+require(CRIMSON_ROOT . '/lib/meta.php');
+require(CRIMSON_ROOT . '/lib/html.php');
+
+// autoload requested directories
+foreach (CONFIG['autoload'] as $dir)
+ foreach (glob(WEB_ROOT . $dir . '/*.php') as $file)
+ require($file);
+
+// load file stamps on production
+if (ENVIRONMENT == 'production')
+ require('/var/run/crimson/stamp.php');
+
+// =================================================================== START ==
+
+try {
+ CRIMSON_HOOK('init');
+ (new Router())->handle_req();
+} catch (Error $e) {
+ CRIMSON_error_handler(
+ CRIMSON_E_FATAL_ERROR,
+ $e->getMessage(),
+ $e->getFile(),
+ $e->getLine(),
+ $e->getTrace(),
+ );
+}
diff --git a/src/lib/database.php b/src/lib/database.php
new file mode 100644
index 0000000..416ef8f
--- /dev/null
+++ b/src/lib/database.php
@@ -0,0 +1,337 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+function __nullify(mixed $val): mixed
+{
+ if (!$val) {
+ return NULL;
+ } else {
+ return $val;
+ }
+}
+
+class DatabaseQuery {
+
+ private \PDO $conn;
+ private string $query;
+
+ private bool $where;
+ private bool $set;
+
+ private array $param;
+
+ function __construct(\PDO $conn)
+ {
+ $this->conn = $conn;
+ $this->query = '';
+
+ $this->set = FALSE;
+ $this->where = FALSE;
+ $this->param = array();
+ }
+
+ ///
+ /// ARBITRARY QUERY
+ ///
+
+ public function query(string $query): DatabaseQuery
+ {
+ $this->query .= $query;
+ return $this;
+ }
+
+ ///
+ /// SELECT
+ ///
+
+ public function select(string $select): DatabaseQuery
+ {
+ $this->query .= "SELECT $select\n";
+ return $this;
+ }
+
+ public function from(string $from): DatabaseQuery
+ {
+ $this->query .= "FROM $from\n";
+ return $this;
+ }
+
+ ///
+ /// INSERT
+ ///
+
+ public function insert_into(string $insert, string ...$columns): DatabaseQuery
+ {
+ $this->query .= "INSERT INTO $insert\n (";
+ foreach ($columns as $idx => $column) {
+ if ($idx !== 0) {
+ $this->query .= ",";
+ }
+ $this->query .= $column;
+ }
+ $this->query .= ")\n";
+ return $this;
+ }
+
+ public function values(mixed ...$values): DatabaseQuery
+ {
+ $this->query .= "VALUES (";
+ foreach ($values as $idx => $value) {
+ if ($idx !== 0) {
+ $this->query .= ",";
+ }
+ $this->query .= "?";
+ array_push($this->param, $value);
+ }
+ $this->query .= ")\n";
+ return $this;
+ }
+
+ ///
+ /// WHERE
+ ///
+
+ public function where(string $cond): DatabaseQuery
+ {
+ if (!$this->where) {
+ $this->where = TRUE;
+ $this->query .= "WHERE ";
+ } else {
+ $this->query .= "AND ";
+ }
+ $this->query .= "$cond ";
+ return $this;
+ }
+
+ /**
+ * @param array<mixed> $array
+ */
+ public function where_in(string $column, array $array): DatabaseQuery
+ {
+ if (!$this->where) {
+ $this->where = TRUE;
+ $this->query .= "WHERE ";
+ } else {
+ $this->query .= "AND ";
+ }
+ if (empty($array)) {
+ $this->query .= "FALSE\n";
+ return $this;
+ }
+ $in = $this->in($array);
+ $this->query .= "$column $in\n";
+ return $this;
+ }
+
+ private function in(array $array): string
+ {
+ $in = 'IN (';
+ foreach ($array as $idx => $item) {
+ if ($idx != 0) {
+ $in .= ",";
+ }
+ $in .= "?";
+ array_push($this->param, $item);
+ }
+ $in .= ")";
+ return $in;
+ }
+
+ ///
+ /// OPERATORS
+ ///
+
+ public function like(mixed $item): DatabaseQuery
+ {
+ $this->query .= "LIKE ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ public function eq(mixed $item): DatabaseQuery
+ {
+ $this->query .= "= ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ public function ne(mixed $item): DatabaseQuery
+ {
+ $this->query .= "<> ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ public function lt(mixed $item): DatabaseQuery
+ {
+ $this->query .= "< ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ public function le(mixed $item): DatabaseQuery
+ {
+ $this->query .= "<= ?\n";
+ array_push($this->param, $item);
+ return $this;
+ }
+
+ ///
+ /// JOINS
+ ///
+
+ public function join(string $table, string $on, string $type = 'LEFT'): DatabaseQuery
+ {
+ $this->query .= "$type JOIN $table ON $on\n";
+ return $this;
+ }
+
+ ///
+ /// LIMIT, OFFSET, ORDER
+ ///
+
+ public function limit(int $limit): DatabaseQuery
+ {
+ $this->query .= "LIMIT ?\n";
+ array_push($this->param, $limit);
+ return $this;
+ }
+
+ public function offset(int $offset): DatabaseQuery
+ {
+ $this->query .= "OFFSET ?\n";
+ array_push($this->param, $offset);
+ return $this;
+ }
+
+ public function order_by(string $column, string $order = 'ASC'): DatabaseQuery
+ {
+ $this->query .= "ORDER BY " . $column . ' ' . $order . ' ';
+ return $this;
+ }
+
+ ///
+ /// COLLECT
+ ///
+
+ public function rows(mixed ...$params): array
+ {
+ $args = $this->param;
+ foreach ($params as $param) {
+ array_push($args, $param);
+ }
+ $stmt = $this->conn->prepare($this->query);
+ try {
+ $stmt->execute($args);
+ } catch (Exception $ex) {
+ echo $ex;
+ echo '<br> >> caused by <<<br>';
+ echo str_replace("\n", "<br>", $this->query);
+ }
+ return __nullify($stmt->fetchAll(PDO::FETCH_ASSOC)) ?? [];
+ }
+
+ public function row(mixed ...$params): ?array
+ {
+ $args = $this->param;
+ foreach ($params as $param) {
+ array_push($args, $param);
+ }
+ $stmt = $this->conn->prepare($this->query);
+ $stmt->execute($args);
+ return __nullify($stmt->fetch(PDO::FETCH_ASSOC));
+ }
+
+ public function execute(mixed ...$params): bool
+ {
+ $args = $this->param;
+ foreach ($params as $param) {
+ array_push($args, $param);
+ }
+ $stmt = $this->conn->prepare($this->query);
+ try {
+ $stmt->execute($args);
+ return TRUE;
+ } catch (Exception $_e) {
+ echo $_e;
+ echo '<br> >> caused by <<<br>';
+ echo str_replace("\n", "<br>", $this->query);
+ return FALSE;
+ }
+ }
+}
+
+/**
+ * DatabaseHelper
+ * allows queries on the
+ * postgres database
+ */
+class DatabaseHelper {
+
+ private ?\PDO $conn;
+
+ function __construct()
+ {
+ $this->conn = NULL;
+ }
+
+ private function connect(): \PDO
+ {
+ if ($this->conn === NULL) {
+ $user = getenv("POSTGRES_USER");
+ $pass = getenv("POSTGRES_PASSWORD");
+ $db = getenv("POSTGRES_DB");
+ $host = 'postgres';
+ $port = '5432';
+
+ $conn_str = sprintf("pgsql:host=%s;port=%d;dbname=%s;user=%s;password=%s",
+ $host,
+ $port,
+ $db,
+ $user,
+ $pass
+ );
+ $this->conn = new \PDO($conn_str);
+ $this->conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
+ }
+ return $this->conn;
+ }
+
+ public function select(string $select): DatabaseQuery
+ {
+ $conn = $this->connect();
+ $query = new DatabaseQuery($conn);
+ return $query->select($select);
+ }
+
+ public function insert_into(string $insert, string ...$columns): DatabaseQuery
+ {
+ $conn = $this->connect();
+ $query = new DatabaseQuery($conn);
+ return $query->insert_into($insert, ...$columns);
+ }
+
+ public function query(string $query_str): DatabaseQuery
+ {
+ $conn = $this->connect();
+ $query = new DatabaseQuery($conn);
+ return $query->query($query_str);
+ }
+}
+
diff --git a/src/lib/error.php b/src/lib/error.php
new file mode 100644
index 0000000..ae772d2
--- /dev/null
+++ b/src/lib/error.php
@@ -0,0 +1,218 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * CRIMSON ERROR FUNCTIONS
+ *
+ * Insead of using trigger_error, crimson has its own CRIMSON_<type> error
+ * functions. It is prefered to use this they hook more nicely into crimson.
+ * trigger_error is still okay to use since crimson creates its own error
+ * handler, and will catch it anyways.
+ *
+ * WARNING: DO NOT create your own error handler since this MAY interfere with
+ * crimson's control flow.
+ */
+
+// crimson's fatal error handler will set $errno to CRIMSON_E_FATAL_ERROR.
+define('CRIMSON_E_FATAL_ERROR', 0);
+
+function __CRIMSON_error_pretty_print_name(int $errno) {
+ switch ($errno) {
+ case CRIMSON_E_FATAL_ERROR: // 0 //
+ return 'Fatal Error';
+
+ case E_ERROR: // 1 //
+ case E_CORE_ERROR: // 16 //
+ case E_USER_ERROR: // 256 //
+ case E_RECOVERABLE_ERROR: // 4096 //
+ return 'Error';
+
+ case E_WARNING: // 2 //
+ case E_CORE_WARNING: // 32 //
+ case E_USER_WARNING: // 512 //
+ return 'Warning';
+
+ case E_PARSE: // 4 //
+ return 'Parse Error';
+
+ case E_NOTICE: // 8 //
+ case E_USER_NOTICE: // 1024 //
+ return 'Notice';
+
+ case E_COMPILE_ERROR: // 128 //
+ return 'Compile Error';
+
+ case E_DEPRECATED: // 8192 //
+ case E_USER_DEPRECATED: // 16384 //
+ return 'Deprecated';
+
+ default:
+ return 'Unknown Error';
+ }
+}
+
+function __CRIMSON_error_pretty_print_bg_color(int $errno) {
+ switch ($errno) {
+ case CRIMSON_E_FATAL_ERROR: // 0 //
+ case E_ERROR: // 1 //
+ case E_PARSE: // 4 //
+ case E_CORE_ERROR: // 16 //
+ case E_COMPILE_ERROR: // 128 //
+ case E_USER_ERROR: // 256 //
+ case E_RECOVERABLE_ERROR: // 4096 //
+ return '#dc143c';
+
+ case E_WARNING: // 2 //
+ case E_CORE_WARNING: // 32 //
+ case E_USER_WARNING: // 512 //
+ case E_DEPRECATED: // 8192 //
+ case E_USER_DEPRECATED: // 16384 //
+ return '#db6d13';
+
+ case E_NOTICE: // 8 //
+ case E_USER_NOTICE: // 1024 //
+ default:
+ return '#13a6db';
+ }
+}
+
+function __CRIMSON_error_pretty_print_text_color(int $errno) {
+ switch ($errno) {
+ case CRIMSON_E_FATAL_ERROR: // 0 //
+ case E_ERROR: // 1 //
+ case E_PARSE: // 4 //
+ case E_CORE_ERROR: // 16 //
+ case E_COMPILE_ERROR: // 128 //
+ case E_USER_ERROR: // 256 //
+ case E_RECOVERABLE_ERROR: // 4096 //
+ return '#fff';
+
+ case E_WARNING: // 2 //
+ case E_CORE_WARNING: // 32 //
+ case E_USER_WARNING: // 512 //
+ case E_DEPRECATED: // 8192 //
+ case E_USER_DEPRECATED: // 16384 //
+ return '#fff';
+
+ case E_NOTICE: // 8 //
+ case E_USER_NOTICE: // 1024 //
+ default:
+ return '#181818';
+ }
+}
+
+/**
+ * __CRIMSON_error_pretty_print
+ *
+ * Prints a pretty HTML error message using the same set of parameters
+ * from PHP's error handler.
+ *
+ * Unless CRIMSON detects that you are using a tty, crimson will opt to
+ * pretty print all errors.
+ */
+function __CRIMSON_error_pretty_print(
+ int $errno,
+ string $errstr,
+ ?string $errfile = NULL,
+ ?int $errline = NULL,
+ ?array $errcontext = NULL,
+): void {
+
+ $name = __CRIMSON_error_pretty_print_name($errno);
+ $bg = __CRIMSON_error_pretty_print_bg_color($errno);
+ $text = __CRIMSON_error_pretty_print_text_color($errno);
+
+ $root_style = "
+ all: unset !important;
+ display: block !important;
+ margin: .1em !important;
+ background: {$bg} !important;
+ color: {$text} !important;
+ font-family: Helvetica, Verdana, Courier, monospace !important;
+ font-size: 14px !important;";
+
+ $div_style = "
+ padding: .5em; !important;";
+
+ $span_style = "
+ display: block !important";
+
+ $title_style="
+ font-size: 1.2em !important;
+ font-weight: bold !important;
+ background: rgba(255,255,255,.2); !important";
+
+ $html = <<<EOF
+<div style="{$root_style}">
+ <div style="{$div_style}\n{$title_style}">
+ (!) {$name}
+ </div>
+ <div style="{$div_style}">
+ <span style="{$span_style}">
+ In file {$errfile}:{$errline}
+ </span>
+ <span style="{$span_style}">
+ >>> {$errstr}
+ </span>
+ </div>
+</div>
+EOF;
+ echo $html;
+}
+
+function __CRIMSON_error_print(
+ int $errno,
+ string $errstr,
+ ?string $errfile = NULL,
+ ?int $errline = NULL,
+ ?array $errcontext = NULL,
+): void {
+ $name = __CRIMSON_error_pretty_print_name($errno);
+ echo "{$name}: {$errstr}\n\tIn file {$errfile}:{$errline}\n";
+}
+
+function CRIMSON_error_handler(
+ int $errno,
+ string $errstr,
+ ?string $errfile = NULL,
+ ?int $errline = NULL,
+ ?array $errcontext = NULL,
+): void {
+ __CRIMSON_error_pretty_print($errno, $errstr, $errfile, $errline, $errcontext);
+}
+
+set_error_handler("CRIMSON_error_handler");
+
+function CRIMSON_ERROR(string $msg): void {
+ $frame = debug_backtrace()[1];
+ CRIMSON_error_handler(E_ERROR, $msg,
+ $frame['file'], $frame['line']);
+}
+
+function CRIMSON_FATAL_ERROR(string $msg): void {
+ $frame = debug_backtrace()[1];
+ CRIMSON_error_handler(CRIMSON_E_FATAL_ERROR, $msg,
+ $frame['file'], $frame['line']);
+}
+
+function CRIMSON_WARNING(string $msg): void {
+ $frame = debug_backtrace()[1];
+ CRIMSON_error_handler(E_WARNING, $msg,
+ $frame['file'], $frame['line']);
+}
diff --git a/src/lib/hooks.php b/src/lib/hooks.php
new file mode 100644
index 0000000..21854bd
--- /dev/null
+++ b/src/lib/hooks.php
@@ -0,0 +1,178 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * CRIMSON HOOKS
+ *
+ * Crimson supports hooks to allow the user to add functionality inside
+ * crimson's control flow. This can be used for handling errors, or having
+ * custom routing functionality.
+ *
+ * The current supported hooks are: 'error', 'pre_route', 'init', and 'die'.
+ *
+ * To run a hook yourself you can call CRIMSON_HOOK($name, [$args]);
+ *
+ * To create a handler, make a function in the global scope called:
+ * CRIMSON_<name>_hook. i.e. if you are making a init hook handler, make a
+ * function called CRIMSON_init_hook(...).
+ *
+ * If a hook is called and a user hook handeler does not exist, the crimson
+ * builtin hook will be run instead.
+ *
+ * NOTE: It is also allowed for you to create your own hook types, but you must
+ * also create your own handler for it. If you dont, it will result in a error
+ * since crimson will not have a builtin hook handler to fall back to.
+ *
+ * NOTE: CRIMSON_HOOK does support a third argument called $builtin, but this
+ * is only to be used by crimson itself, so please dont use it.
+ *
+ * WARNING: If a hook cannot find a handler to run, it will call
+ * CRIMSON_FATAL_ERROR and NEVER return.
+ */
+
+/**
+ * CRIMSON builtin ERROR hook.
+ *
+ * $req - The current request parsed by the Router. All fields are gurenteed
+ * to be set BESIDES 'uri' and 'uri_str', since parsing of the URI can fail
+ * and result to a call to this error handler.
+ *
+ * This hook is run when any Controller or the Router runs into an issue and
+ * fails with an HTTP Error.
+ *
+ * EXAMPLE: If the Router cannot find the requested Controller and Method
+ * based in the URI path, it will raise 404 Not Found.
+ *
+ * EXAMPLE: If the crimson postgres database is enabled, and db-init either
+ * failed or is still running (the database is not ready), crimson will raise
+ * 503 Service Unavaliable.
+ *
+ * EXAMPLE: If the provided URI to PHP is unable to be parsed, crimson will
+ * raise 400 Bad Request.
+ *
+ * This is hook is called in ROUTER->handle_error(int code) and
+ * Controller->error(int code).
+ *
+ * NOTE: Unlike CRIMSON's DIE hook, it is supported to re-enter crimson code.
+ * This error handler must never return, but it is okay to recall crimson to
+ * try to print an error page.
+ *
+ * WARNING: If the user tries to re-enter crimson from this handler and ends
+ * up raising another error, the user error handler WILL NOT be called again.
+ * To prevent recursion, a user error handle WILL only ever be called ONCE.
+ * This is due to that the handler has return type 'never', and is also not
+ * allowed to recurse.
+ */
+function CRIMSON_builtin_error_hook(array $req, int $code): never
+{
+ http_response_code($code);
+ CRIMSON_DIE("{$code} {$req['uri_str']}");
+}
+
+/**
+ * CRIMSON builtin PRE ROUTE hook.
+ *
+ * This hook does nothing by default since all required CRIMSON routing is done
+ * in router.php.
+ */
+function CRIMSON_builtin_pre_route_hook(Router $router): void
+{
+}
+
+/**
+ * CRIMSON builtin INIT hook.
+ *
+ * This hook does nothing by default since all required CRIMSON init work is
+ * run in index.php.
+ *
+ * This hook can be overridden to run any user required initalization code
+ * before CRIMSON launches and runs the router.
+ *
+ * WARNING: The ROUTER is NOT YET created when this hook is run. Do not call
+ * ROUTER or ANY CRIMSON code in a user specified init hook. Doing so is
+ * UNDEFINED BEHAVIOR.
+ */
+function CRIMSON_builtin_init_hook(): void
+{
+}
+
+/**
+ * CRIMSON builtin DIE hook.
+ *
+ * Calls die with $status, i.e. this is an alias for die().
+ */
+function CRIMSON_builtin_die_hook(string|int $status = 0): never
+{
+ die($status);
+}
+
+/**
+ * Executes a hook for crimson.
+ *
+ * $name - the name of the hook
+ * $args - the arguments to pass to the hook
+ * $builtin - force the use of the builtin hook
+ *
+ * Looks for CRIMSON_$name_hook first, which is the custom user defined hook.
+ * If it does not exist, it will run CRIMSON_builtin_$name_hook instead.
+ */
+function CRIMSON_HOOK(
+ string $name,
+ array $args = array(),
+ bool $builtin = FALSE,
+): void {
+ $names = array();
+ if (!$builtin)
+ $names[] = "CRIMSON_{$name}_hook";
+ $names[] = "CRIMSON_builtin_{$name}_hook";
+
+ // find handler
+ $handler = NULL;
+ foreach ($names as $name) {
+ try {
+ $handler = new ReflectionFunction($name);
+ if ($handler)
+ break;
+ } catch (Exception $_e) {};
+ }
+
+ // error in invalid hook
+ if (!$handler) {
+ CRIMSON_ERROR("Invalid hook: {$name}");
+ return;
+ }
+
+ // i dont care to argument check,
+ // if someone screws up a hook we have bigger problems
+ $handler->invoke(...$args);
+}
+
+/**
+ * Executes die() in php. But allows the user to add a hook to handle any
+ * loose resources before php kills itself.
+ *
+ * NOTE: A DIE hook should NEVER be handeled and return. A user provided hook
+ * must also have a return type of never and immediately die. Do NOT
+ * call any crimson code such as rerunning the ROUTER. Doing so will result in
+ * UNDEFINED BEHAVIOR and nasal demons showing up at your doorstep!!!
+ */
+function CRIMSON_DIE(string|int $status = 0): never
+{
+ CRIMSON_HOOK('die', [$status]);
+}
diff --git a/src/lib/html.php b/src/lib/html.php
new file mode 100644
index 0000000..fac7a94
--- /dev/null
+++ b/src/lib/html.php
@@ -0,0 +1,95 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+function esc(string $data, bool $string_esc = FALSE): string {
+ $flags = ENT_SUBSTITUTE | ENT_HTML401;
+ if ($string_esc)
+ $flags |= ENT_QUOTES;
+ return htmlspecialchars($data, $flags);
+}
+
+function status_code_msg(int $code): ?string {
+
+ static $status_code = array(
+ 100 => "Continue",
+ 101 => "Switching Protocols",
+ 200 => "OK",
+ 201 => "Created",
+ 202 => "Accepted",
+ 203 => "Non-Authoritative Information",
+ 204 => "No Content",
+ 205 => "Reset Content",
+ 206 => "Partial Content",
+ 300 => "Multiple Choices",
+ 301 => "Moved Permanently",
+ 302 => "Found",
+ 303 => "See Other",
+ 304 => "Not Modified",
+ //305 => "Use Proxy",
+ //306 => "unused",
+ 307 => "Temporary Redirect",
+ 308 => "Permanent Redirect",
+ 400 => "Bad Request",
+ 401 => "Unauthorized",
+ 402 => "Payment Required",
+ 403 => "Forbidden",
+ 404 => "Not Found",
+ 405 => "Method Not Allowed",
+ 406 => "Not Acceptable",
+ 407 => "Proxy Authentication Required",
+ 408 => "Request Timeout",
+ 409 => "Conflict",
+ 410 => "Gone",
+ 411 => "Length Required",
+ 412 => "Precondition Failed",
+ 413 => "Content Too Large",
+ 414 => "URI Too Long",
+ 415 => "Unsupported Media Type",
+ 416 => "Range Not Satisfiable",
+ 417 => "Expectation Failed",
+ 418 => "I'm a teapot",
+ 421 => "Misdirected Request",
+ 422 => "Unprocessable Content",
+ 423 => "Locked",
+ 424 => "Failed Dependency",
+ 425 => "Too Early",
+ 426 => "Upgrade Required",
+ 428 => "Precondition Required",
+ 429 => "Too Many Requests",
+ 431 => "Request Header Fields Too Large",
+ 451 => "Unavailable For Legal Reasons",
+ 500 => "Internal Server Error",
+ 501 => "Not Implemented",
+ 502 => "Bad Gateway",
+ 503 => "Service Unavailable",
+ 504 => "Gateway Timeout",
+ 505 => "HTTP Version Not Supported",
+ 506 => "Variant Also Negotiates",
+ 507 => "Insufficient Storage",
+ 508 => "Loop Detected",
+ 510 => "Not Extended",
+ 511 => "Network Authentication Required",
+ );
+
+ return $status_code[$code] ?? NULL;
+}
+
+function is_valid_status_code(int $code): bool {
+ return is_string(status_code_msg($code));
+}
diff --git a/src/lib/lang.php b/src/lib/lang.php
new file mode 100644
index 0000000..84a4215
--- /dev/null
+++ b/src/lib/lang.php
@@ -0,0 +1,186 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Returns the lang string for the provided $key
+ *
+ * $key - The key of the lang string
+ * $default - The string to use if lang string $key is undefined
+ * $sub - List of values to substitute using php's sprinf
+ */
+function lang(
+ string $key,
+ ?string $default = NULL,
+ ?array $sub = NULL
+): string {
+ $lang = ROUTER->get_lang();
+ $result = NULL;
+
+ // lookup lang key
+ if (isset($lang[$key]))
+ $result = $lang[$key];
+
+ // replace with $default if undefined
+ if ($result === NULL && $default !== NULL)
+ $result = $default;
+
+ // error if undefined
+ if ($result === NULL) {
+ CRIMSON_WARNING('Undefined lang string: ' . $key);
+ return $key;
+ }
+
+ // make substitutions
+ if ($sub) {
+ if (!is_array($sub))
+ $sub = [$sub];
+ $result = sprintf($result, ...$sub);
+ }
+
+ return $result;
+}
+
+/**
+ * Returns a html element (button, a, div, ...) containing content from
+ * the lang string, icon, aria tags, and/or tooltips. Text content and icon
+ * are contained in a seconed inner html element (span, h1, ...).
+ *
+ * ilang has up to four parts: text, tooltip, icon class, and icon content.
+ * Each part is loaded from a different lang string using $key as the prefix.
+ *
+ * == LANG_KEYS ==
+ *
+ * NAME | LANG KEY | REQUIRED | DESCRIPTION
+ * --------------------------------------------------------------------------
+ * text | $key_text | yes | The text content of the element. Text
+ * | | | content will always uppercase the first
+ * | | | letter.
+ * tip | $key_tip | no | The tool tip of the element.
+ * icon | $key_icon | no | Adds a <i> element with the class
+ * | | | <lang string>.
+ * content | $key_content | no | If icon, adds <lang string> as the
+ * | | | inner html of the icon.
+ *
+ * == ARGUMENTS ==
+ *
+ * NAME | REQUIRED | DEFAULT | DESCRIPTION
+ * ---------------------------------------------------------------------------
+ * $key | yes | | The key of the interface lang string.
+ * $class | no | | The class of the html element.
+ * $id | no | | The id of the html element.
+ * $sub | no | [] | Substitution arguments passed into lang()
+ * | | | in both $key_text and $key_tip.
+ * $type | no | 'a' | Sets the type of the html element.
+ * $subtype | no | 'span' | Sets the type of the inner html element.
+ * $attrs | no | array() | Sets html attributes using the key/value
+ * | | | pairs from $attrs. $class, $id, $href, and
+ * | | | and $onclick are all short hand for
+ * | | | $attrs['<name>']; Named attr arguments take
+ * | | | priority over any defined in $attrs.
+ * $style | no | | $attrs['style'] = $style.
+ * $href | no | | $attrs['href'] = $href. $type = 'a';
+ * $onclick | no | | $attrs['onclick'] = $onclick. $type = 'button'.
+ *
+ * NOTE: For any non required argument that is falsy, it is converted back to
+ * its default value.
+ *
+ * NOTE: For any non required argument that does not have a default value
+ * listed, falsy values turn off that attribute or functionality.
+ *
+ * WARNING: $href and $onclick also modify the default $type. If $type is
+ * passed to ilang, that type will be used instead.
+ *
+ * WARNING: Lang strings WILL be html escaped along with each atribute value.
+ * Everything else will not be sanitized by this function.
+ */
+function ilang(
+ string $key,
+ ?string $class = NULL,
+ ?string $id = NULL,
+ array $sub = [],
+ ?string $type = NULL,
+ string $subtype = 'span',
+ array $attrs = array(),
+ ?string $style = NULL,
+ ?string $href = NULL,
+ ?string $onclick = NULL,
+): string {
+ // read lang keys
+ $text = lang("{$key}_text", sub: $sub);
+ $tip = lang("{$key}_tip", '', sub: $sub);
+ $icon = lang("{$key}_icon", '');
+ $content = lang("{$key}_content", '');
+
+ // uppercase
+ $text = ucfirst($text);
+
+ // set $type if falsy
+ if (!$type) {
+ if ($href)
+ $type = 'a';
+ else if ($onclick)
+ $type = 'button';
+ else
+ $type = 'a';
+ }
+
+ // populate $attrs with named arguments
+ if ($tip) {
+ $attrs['title'] = $tip;
+ $attrs['aria-label'] = $tip;
+ }
+ if ($class)
+ $attrs['class'] = "{$class} ilang";
+ else
+ $attrs['class'] = "ilang";
+ if ($id)
+ $attrs['id'] = $id;
+ if ($style)
+ $attrs['style'] = $style;
+ if ($href)
+ $attrs['href'] = $href;
+ if ($onclick)
+ $attrs['onclick'] = $onclick;
+
+ $html = "";
+ // open tag
+ $html .= "<{$type}";
+ foreach ($attrs as $key => $value) {
+ $value = esc($value, TRUE); // html tag & string escape
+ $html .= " {$key}=\"{$value}\"";
+ }
+ $html .= ">";
+ // icon
+ if ($icon) {
+ $icon = esc($icon, TRUE); // html tag & string escape
+ $html .= "<i class=\"{$icon}\">";
+ if ($content) {
+ $content = esc($content); // html tag escape
+ $html .= "{$content}";
+ }
+ $html .= "</i>";
+ }
+ // content
+ $text = esc($text); // html tag escape
+ $html .= "<{$subtype}>{$text}</{$subtype}>";
+ // close tag
+ $html .= "</{$type}>";
+
+ return $html;
+}
diff --git a/src/lib/meta.php b/src/lib/meta.php
new file mode 100644
index 0000000..471d3e3
--- /dev/null
+++ b/src/lib/meta.php
@@ -0,0 +1,28 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+function CRIMSON_META(Controller $self): string {
+ $data = $self->CRIMSON_data;
+ $html = '';
+ foreach ($data['css'] as $css)
+ $html .= $self->link_css($css);
+ foreach ($data['js'] as $js)
+ $html .= $self->link_js($js);
+ return $html;
+}
diff --git a/src/router.php b/src/router.php
new file mode 100644
index 0000000..141e7cf
--- /dev/null
+++ b/src/router.php
@@ -0,0 +1,236 @@
+<?php
+/// CRIMSON --- A simple PHP framework.
+/// Copyright © 2024 Freya Murphy <contact@freyacat.org>
+///
+/// This file is part of CRIMSON.
+///
+/// CRIMSON is free software; you can redistribute it and/or modify it
+/// under the terms of the GNU General Public License as published by
+/// the Free Software Foundation; either version 3 of the License, or (at
+/// your option) any later version.
+///
+/// CRIMSON is distributed in the hope that it will be useful, but
+/// WITHOUT ANY WARRANTY; without even the implied warranty of
+/// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+/// GNU General Public License for more details.
+///
+/// You should have received a copy of the GNU General Public License
+/// along with CRIMSON. If not, see <http://www.gnu.org/licenses/>.
+
+class Router extends Base {
+
+ // if the website is ready to accept requests
+ // (protects against users requesting unready resources)
+ private bool $ready;
+ // if the router is currently in an error handler
+ private bool $in_err;
+ // The parsed request. Contains the following fields.
+ //
+ // NAME | TYPE | DEFAULT | DESCRIPTION
+ // ------------------------------------------------------------
+ // app | string | 'index' | The first part of the uri, selects which
+ // | | | controller will be called.
+ // slug | string | 'index' | The second part of the uri, slects
+ // | | | which method will be called.
+ // args | array | [] | Rest of uri parts. Arguments passed to
+ // | | | the method.
+ // uri | array? | | From PHP's parse_url($uri_str).
+ // uri_str | string | | The URI given in the HTML request.
+ // method | string | | The HTTP method.
+ // ip | string | | The requesting IP.
+ public final array $req;
+
+ /**
+ * Creates the crimson router.
+ *
+ * => Checks if crimson is ready to route requests.
+ * => Defines self as ROUTER.
+ */
+ function __construct()
+ {
+ // are we ready?
+ $this->ready = $this->ready_check();
+ // get request
+ $this->in_err = FALSE;
+ $this->req = $this->get_req();
+ // ROUTER is used by Controller in error($code) function
+ define('ROUTER', $this);
+ // fail if URI is invalid (get_req does not check this)
+ if (!$this->req['uri'])
+ $this->handle_error(400); // does not return!
+ }
+
+ /**
+ * Cheks if crimson is ready to route requests.
+ * => Checks if the database is ready (if enabled).
+ */
+ private function ready_check(): bool {
+ // assume we are ready unless told otherwise
+ $ready = TRUE;
+ // check db
+ if ($ready && getenv('POSTGRES_ENABLED') === 'true') {
+ $ready = file_exists('/var/run/crimson/db_ready');
+ }
+ // return result
+ return $ready;
+ }
+
+ /**
+ * @param string $path - the current request path
+ * Gets the current route
+ * @return array<string,mixed>
+ */
+ private function get_req_route($path): array
+ {
+ // trim the path
+ $path = trim($path);
+ // remove first '/'
+ $path = substr($path, 1);
+
+ // get modified route
+ $routes = CONFIG['routes'];
+ foreach ($routes as $key => $value) {
+ $key = "/^{$key}$/";
+ if (!preg_match($key, $path, $matches))
+ continue;
+
+ $path = $value;
+
+ for ($i = 1; $i < count($matches); $i++) {
+ $path = str_replace(
+ "\\{$i}",
+ $matches[$i],
+ $path);
+ }
+
+ break;
+ }
+
+ // get path parts
+ $parts = explode('/', $path);
+ if ($path == '')
+ $parts = [];
+ // get the length
+ $len = count($parts);
+ // get route info
+ $route = array();
+ $route['app'] = $len > 0 ? $parts[0] : 'index';
+ $route['slug'] = $len > 1 ? $parts[1] : 'index';
+ $route['args'] = array_slice($parts, 2);
+
+ return $route;
+ }
+
+ /**
+ * Gets the users ip
+ */
+ private function get_ip(): ?string
+ {
+ $headers = array (
+ 'HTTP_CLIENT_IP',
+ 'HTTP_X_FORWARDED_FOR',
+ 'HTTP_X_FORWARDED',
+ 'HTTP_FORWARDED_FOR',
+ 'HTTP_FORWARDED',
+ 'HTTP_X_REAL_IP',
+ 'REMOTE_ADDR'
+ );
+ foreach ($headers as $header) {
+ if (isset($_SERVER[$header]))
+ return $_SERVER[$header];
+ }
+ return NULL;
+ }
+
+ /**
+ * Gets the curret request info.
+ * Does not fail in invalid uri. Must be handeled by
+ * caller function.
+ * @return array<string,mixed>
+ */
+ private function get_req(): array
+ {
+ $method = $_SERVER['REQUEST_METHOD'];
+ $uri_str = $_SERVER['REQUEST_URI'];
+ $uri = parse_url($uri_str);
+
+ $path = '';
+ if ($uri && array_key_exists('path', $uri))
+ $path = $uri['path'];
+
+ return array_merge(
+ array(
+ 'uri' => $uri,
+ 'uri_str' => $uri_str,
+ 'method' => $method,
+ 'ip' => $this->get_ip()
+ ),
+ $this->get_req_route($path),
+ );
+ }
+
+ /**
+ * Handles a router error code
+ * @param int $code - the http error code
+ */
+ public function handle_error(int $code): never
+ {
+ // if in_err is set RIGHT NOW, this means the user specified
+ // error hook errored itself. To prevent error recursion we do
+ // not want to run it again!
+ $force_builtin = $this->in_err;
+ // Sets the in_err catch to true, read comment above to why.
+ $this->in_err = TRUE;
+ CRIMSON_HOOK('error', [$this->req, $code], $force_builtin);
+ // error hook is type never, but CRIMSON_HOOK is type void
+ CRIMSON_DIE();
+ }
+
+ /**
+ * @param array $req
+ * @param array<int,mixed> $req
+ */
+ public function handle_req(): never
+ {
+ // block requests if we are not ready
+ if ($this->ready === FALSE)
+ $this->handle_error(503);
+
+ // run pre route hook
+ CRIMSON_HOOK('pre_route', [$this]);
+
+ // load the controller
+ $controller = $this->load_controller($this->req['app']);
+ if (!$controller)
+ $this->handle_error(404);
+
+ // find the function that matches our request
+ // format: /controller/fn_name/fn_arg1/fn_arg2/.../fn_argN
+ $handler = NULL;
+ try {
+ $cls = new ReflectionClass($controller);
+ $mds = $cls->getMethods(ReflectionMethod::IS_PUBLIC);
+ foreach ($mds as $md) {
+ if ($md->name !== $this->req['slug'])
+ continue;
+ if (count($md->getParameters()) !=
+ count($this->req['args']))
+ continue;
+ $handler = $md;
+ break;
+ }
+ } catch (Exception $_e) {}
+
+ // return 404 if no handler found
+ if (!$handler)
+ $this->handle_error(404);
+
+ try {
+ $handler->invokeArgs($controller, $this->req['args']);
+ } catch (Exception $_e) {};
+
+ // sanity check
+ CRIMSON_DIE();
+ }
+
+}