This commit is contained in:
Murphy 2024-12-23 10:39:16 -05:00
parent 5bd52bc7fd
commit de9cae795f
Signed by: freya
GPG key ID: 9FBC6FFD6D2DBF17
36 changed files with 2931 additions and 0 deletions

34
base.env Normal file
View file

@ -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"

41
build/db-init/Dockerfile Normal file
View file

@ -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"]

52
build/db-init/README.md Normal file
View file

@ -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).

86
build/db-init/base.sql Normal file
View file

@ -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;

168
build/db-init/db-init Executable file
View file

@ -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

28
build/db-init/ext.sql Normal file
View file

@ -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;

41
build/db-init/rev.sql Normal file
View file

@ -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();

22
build/init/Dockerfile Normal file
View file

@ -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"]

4
build/init/init Executable file
View file

@ -0,0 +1,4 @@
#!/bin/sh
# stamp assets
/usr/local/bin/stamp.sh

14
build/init/stamp.sh Executable file
View file

@ -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"

30
build/nginx/Dockerfile Normal file
View file

@ -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"]

8
build/nginx/entrypoint.sh Executable file
View file

@ -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

View file

@ -0,0 +1,3 @@
upstream postgrest {
server rest:3000;
}

View file

@ -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/;
}

54
build/nginx/nginx.conf Normal file
View file

@ -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;
}
}
}

17
build/php/Dockerfile Normal file
View file

@ -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

24
build/postgres/Dockerfile Normal file
View file

@ -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

View file

@ -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"]

23
build/postgrest/entrypoint.sh Executable file
View file

@ -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"

122
compose Executable file
View file

@ -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 "$@"

View file

@ -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

View file

@ -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

View file

@ -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

45
psql Executable file
View file

@ -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}"

309
src/_base.php Normal file
View file

@ -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];
}
}

66
src/_controller.php Normal file
View file

@ -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);
}
}

63
src/_model.php Normal file
View file

@ -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;
}
}

75
src/config.php Normal file
View file

@ -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));

68
src/index.php Normal file
View file

@ -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(),
);
}

337
src/lib/database.php Normal file
View file

@ -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);
}
}

218
src/lib/error.php Normal file
View file

@ -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']);
}

178
src/lib/hooks.php Normal file
View file

@ -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]);
}

95
src/lib/html.php Normal file
View file

@ -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));
}

186
src/lib/lang.php Normal file
View file

@ -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;
}

28
src/lib/meta.php Normal file
View file

@ -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;
}

236
src/router.php Normal file
View file

@ -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();
}
}