start database (user and post), and initial barebones home page
This commit is contained in:
commit
944b6b0526
76 changed files with 3211 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/data
|
5
build/dbinit/Dockerfile
Normal file
5
build/dbinit/Dockerfile
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
FROM alpine:3.19
|
||||||
|
RUN apk add --no-cache postgresql16-client tini
|
||||||
|
COPY ./dbinit /usr/local/bin/dbinit
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["/usr/local/bin/dbinit"]
|
151
build/dbinit/dbinit
Executable file
151
build/dbinit/dbinit
Executable file
|
@ -0,0 +1,151 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
step() {
|
||||||
|
printf '\x1b[34;1m>> %s\x1b[0m\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
printf '\x1b[31;1merror: \x1b[0m%s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
export PGPASSWORD=$POSTGRES_PASSWORD
|
||||||
|
|
||||||
|
psql() {
|
||||||
|
/usr/bin/psql \
|
||||||
|
-h db \
|
||||||
|
-p 5432 \
|
||||||
|
-d $POSTGRES_DB \
|
||||||
|
-U $POSTGRES_USER \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_isready() {
|
||||||
|
/usr/bin/pg_isready \
|
||||||
|
-h db \
|
||||||
|
-p 5432 \
|
||||||
|
-d $POSTGRES_DB \
|
||||||
|
-U $POSTGRES_USER \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
curr_revision() {
|
||||||
|
psql -qtAX -f /db/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_migrations() {
|
||||||
|
i="$1"
|
||||||
|
while true; do
|
||||||
|
name=$(printf "%04d" "$i");
|
||||||
|
file="/db/migrations/$name.sql"
|
||||||
|
|
||||||
|
if [ -f $file ]; then
|
||||||
|
psql -f $file 2> /errors
|
||||||
|
errors=$(cat /errors | grep 'ERROR' | wc -l)
|
||||||
|
if [ "$errors" -eq 0 ]; then
|
||||||
|
i=$((i+1));
|
||||||
|
continue;
|
||||||
|
else
|
||||||
|
error "An error occoured during a migration (rev $name)"
|
||||||
|
cat /errors | grep -v 'current transaction is aborted';
|
||||||
|
error "Aborting migrations, fix file(s) then restart process."
|
||||||
|
return 1;
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
return 0;
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
init_api() {
|
||||||
|
psql -f /db/rest/rest.sql 2> /errors;
|
||||||
|
errors=$(cat /errors | grep 'ERROR' | wc -l)
|
||||||
|
if [ "$errors" -eq 0 ]; then
|
||||||
|
return 0;
|
||||||
|
else
|
||||||
|
error "An error occoured during api initialization"
|
||||||
|
cat /errors | grep -v 'current transaction is aborted';
|
||||||
|
error "Aborting api initialization, fix file(s) then restart process."
|
||||||
|
return 1;
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
update_jwt() {
|
||||||
|
psql -c "UPDATE sys.database_info SET jwt_secret = '$JWT_SECRET' WHERE name = current_database();"
|
||||||
|
errors=$(cat /errors | grep 'ERROR' | wc -l)
|
||||||
|
if [ "$errors" -eq 0 ]; then
|
||||||
|
return 0;
|
||||||
|
else
|
||||||
|
return 1;
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
load_ext() {
|
||||||
|
psql -qtAX -f /db/ext.sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
init () {
|
||||||
|
# reomve ready status
|
||||||
|
# so php ignores requests
|
||||||
|
rm -fr /status/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
|
||||||
|
load_ext;
|
||||||
|
|
||||||
|
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
|
||||||
|
run_migrations "$REV"
|
||||||
|
CODE=$?;
|
||||||
|
|
||||||
|
if [ $CODE -ne 0 ]; then
|
||||||
|
return $CODE;
|
||||||
|
fi
|
||||||
|
|
||||||
|
step 'Initalizing the api';
|
||||||
|
# reinit the api schema for
|
||||||
|
# postgrest
|
||||||
|
init_api;
|
||||||
|
CODE=$?;
|
||||||
|
|
||||||
|
if [ $CODE -ne 0 ]; then
|
||||||
|
return $CODE;
|
||||||
|
fi
|
||||||
|
|
||||||
|
step 'Updating JWT secret';
|
||||||
|
# make sure postgres has the corrent
|
||||||
|
# jwt secret
|
||||||
|
update_jwt;
|
||||||
|
CODE=$?;
|
||||||
|
|
||||||
|
if [ $CODE -ne 0 ]; then
|
||||||
|
return $CODE;
|
||||||
|
fi
|
||||||
|
|
||||||
|
step 'Database is initialized'
|
||||||
|
# database is ready
|
||||||
|
touch /status/ready
|
||||||
|
}
|
||||||
|
|
||||||
|
init
|
4
build/php/Dockerfile
Normal file
4
build/php/Dockerfile
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
FROM php:fpm-alpine
|
||||||
|
RUN apk add --no-cache postgresql-dev
|
||||||
|
RUN docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql
|
||||||
|
RUN docker-php-ext-install pdo pdo_pgsql
|
6
build/postgres/Dockerfile
Normal file
6
build/postgres/Dockerfile
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
FROM postgres:16-alpine
|
||||||
|
RUN apk add --no-cache make git
|
||||||
|
RUN git clone https://github.com/michelp/pgjwt.git /tmp/pgjwt
|
||||||
|
WORKDIR /tmp/pgjwt
|
||||||
|
RUN make install
|
||||||
|
WORKDIR /
|
9
build/postgrest/Dockerfile
Normal file
9
build/postgrest/Dockerfile
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
FROM alpine:3.19
|
||||||
|
COPY ./postgrest.tar.xz /tmp/postgrest.tar.xz
|
||||||
|
RUN tar xJf /tmp/postgrest.tar.xz -C /tmp
|
||||||
|
RUN mv /tmp/postgrest /usr/local/bin/postgrest
|
||||||
|
RUN rm /tmp/postgrest.tar.xz
|
||||||
|
COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
CMD ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
|
||||||
|
|
20
build/postgrest/entrypoint.sh
Executable file
20
build/postgrest/entrypoint.sh
Executable file
|
@ -0,0 +1,20 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
mkdir /etc/postgrest.d
|
||||||
|
config=/etc/postgrest.d/postgrest.conf
|
||||||
|
|
||||||
|
PGRST_DB_URI="postgres://authenticator:postgrest@db:5432/$POSTGRES_DB"
|
||||||
|
PGRST_ROLE="rest_anon"
|
||||||
|
PGRST_SCHEMA="api"
|
||||||
|
|
||||||
|
rm -fr "$config"
|
||||||
|
touch "$config"
|
||||||
|
printf 'db-uri = "%s"\n' "$PGRST_DB_URI" >> $config
|
||||||
|
printf 'db-anon-role = "%s"\n' "$PGRST_ROLE" >> $config
|
||||||
|
printf 'db-schemas = "%s"\n' "$PGRST_SCHEMA" >> $config
|
||||||
|
printf 'jwt-secret = "%s"\n' "$JWT_SECRET" >> $config
|
||||||
|
printf 'jwt-secret-is-base64 = false\n' >> $config
|
||||||
|
printf 'server-host = "*"\n' >> $config
|
||||||
|
printf 'server-port = 3000\n' >> $config
|
||||||
|
|
||||||
|
exec /usr/local/bin/postgrest $config
|
BIN
build/postgrest/postgrest.tar.xz
Normal file
BIN
build/postgrest/postgrest.tar.xz
Normal file
Binary file not shown.
95
conf/nginx/site.conf
Normal file
95
conf/nginx/site.conf
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
upstream postgrest {
|
||||||
|
server rest:3000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream swagger {
|
||||||
|
server swagger:3000 fail_timeout=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
keepalive_timeout 70;
|
||||||
|
sendfile on;
|
||||||
|
client_max_body_size 2m;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
|
||||||
|
root /opt/xssbook;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header 'Access-Control-Allow-Origin $http_origin' always;
|
||||||
|
# Om nom nom cookies
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
# Custom headers and headers various browsers *should* be OK with but aren't
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
|
||||||
|
# Tell client that this pre-flight info is valid for 20 days
|
||||||
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request_method = 'POST') {
|
||||||
|
add_header 'Access-Control-Allow-Origin $http_origin' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request_method = 'GET') {
|
||||||
|
add_header 'Access-Control-Allow-Origin $http_origin' always;
|
||||||
|
add_header 'Access-Control-Allow-Credentials' 'true';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Content-Location /api/$upstream_http_content_location;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass http://postgrest/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /apidocs {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_pass http://swagger;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /favicon.ico {
|
||||||
|
root /opt/xssbook/public;
|
||||||
|
add_header Cache-Control "public, max-age=108000";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /public {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Cache-Control "public, max-age=108000";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass php:9000;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
17
conf/postgres/database.env
Normal file
17
conf/postgres/database.env
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
#
|
||||||
|
# Dynamic env vars
|
||||||
|
# Can be changed
|
||||||
|
#
|
||||||
|
POSTGRES_PASSWORD=xssbook
|
||||||
|
|
||||||
|
# CHANGE THIS
|
||||||
|
# do not use the default provided jwt secret key
|
||||||
|
JWT_SECRET="809d5fca7979a5fbe184fae76fc53c5c587fd521bf06eb34585fd5d6a3fcbc10573aaf88a2a52fa5457223f78e3a6df4cd3135e0d0b60e84d49b28f634cb19c5ca29c9da88bd46b8f1b639bfde4b9515829031503debce3e85cf07bb224a9a3efa29b26adfeec42abf345eaf2fe828648a14b9e6b7e9a48a9bdc8607b8172251d3bdfa2dcf0c7f4368350e00a7b4bc3c975d37e5f8db031d32ec329c869f562c39358748926a9e26efd28bb15085c14de8b064ea2f8f8c58606c7dfe19721a1a0ad7deb2d5f5cc6f96fa0bdaf624cdfa196fcb802fc508b9e0328207897a7071857f87f89147d065d5c712fc7c7cc4dd1743a55a42dade16e3e86f905cf2ca28"
|
||||||
|
|
||||||
|
#
|
||||||
|
# Static env vars
|
||||||
|
# do not change unless you
|
||||||
|
# know what you are doign
|
||||||
|
#
|
||||||
|
POSTGRES_USER=xssbook
|
||||||
|
POSTGRES_DB=xssbook
|
7
db/ext.sql
Normal file
7
db/ext.sql
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
SET search_path = public;
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgjwt;
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
171
db/migrations/0000.sql
Normal file
171
db/migrations/0000.sql
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
SET search_path = public;
|
||||||
|
|
||||||
|
-- Migration Start
|
||||||
|
|
||||||
|
CREATE SCHEMA sys;
|
||||||
|
|
||||||
|
ALTER SCHEMA sys OWNER TO xssbook;
|
||||||
|
|
||||||
|
CREATE DOMAIN sys."*/*" AS BYTEA;
|
||||||
|
|
||||||
|
CREATE TABLE 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
|
||||||
|
ADD CONSTRAINT database_info_pkey PRIMARY KEY (name);
|
||||||
|
|
||||||
|
ALTER TABLE sys.database_info OWNER TO xssbook;
|
||||||
|
|
||||||
|
INSERT INTO sys.database_info
|
||||||
|
(name, curr_revision) VALUES (current_database(), 0);
|
||||||
|
|
||||||
|
CREATE TYPE sys.JWT AS (
|
||||||
|
token TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE SCHEMA admin;
|
||||||
|
|
||||||
|
ALTER SCHEMA admin OWNER TO xssbook;
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS sys.user_id_seq
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
ALTER TABLE sys.user_id_seq OWNER TO xssbook;
|
||||||
|
|
||||||
|
CREATE TABLE admin.user (
|
||||||
|
id INTEGER DEFAULT nextval('sys.user_id_seq'::regclass) NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role NAME DEFAULT 'rest_user'::text NOT NULL,
|
||||||
|
first_name TEXT DEFAULT ''::text NOT NULL,
|
||||||
|
last_name TEXT DEFAULT ''::text NOT NULL,
|
||||||
|
middle_name TEXT DEFAULT ''::text NOT NULL,
|
||||||
|
email TEXT DEFAULT ''::text NOT NULL,
|
||||||
|
gender TEXT DEFAULT ''::text NOT NULL,
|
||||||
|
join_date TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
|
||||||
|
birth_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
profile_avatar BYTEA,
|
||||||
|
profile_banner BYTEA,
|
||||||
|
profile_bio TEXT DEFAULT ''::text NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE admin.user OWNER TO xssbook;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.user
|
||||||
|
ADD CONSTRAINT user_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.user
|
||||||
|
ADD CONSTRAINT user_username_unique UNIQUE (username);
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS sys.post_id_seq
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
ALTER TABLE sys.post_id_seq OWNER TO xssbook;
|
||||||
|
|
||||||
|
CREATE TABLE admin.post (
|
||||||
|
id INTEGER DEFAULT nextval('sys.post_id_seq'::regclass) NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
content TEXT DEFAULT ''::text NOT NULL,
|
||||||
|
date TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE admin.post OWNER TO xssbook;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.post
|
||||||
|
ADD CONSTRAINT post_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.post
|
||||||
|
ADD CONSTRAINT post_user_id_fkey FOREIGN KEY (user_id) REFERENCES admin.user (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS sys.comment_id_seq
|
||||||
|
START WITH 1
|
||||||
|
INCREMENT BY 1
|
||||||
|
NO MINVALUE
|
||||||
|
NO MAXVALUE
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
|
CREATE TABLE admin.comment (
|
||||||
|
id INTEGER DEFAULT nextval('sys.comment_id_seq'::regclass) NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
post_id INTEGER NOT NULL,
|
||||||
|
content TEXT DEFAULT ''::text NOT NULL,
|
||||||
|
date TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE admin.comment OWNER TO xssbook;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.comment
|
||||||
|
ADD CONSTRAINT comment_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.comment
|
||||||
|
ADD CONSTRAINT comment_user_id_fkey FOREIGN KEY (user_id) REFERENCES admin.user (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.comment
|
||||||
|
ADD CONSTRAINT comment_post_id_fkey FOREIGN KEY (post_id) REFERENCES admin.post (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE admin.like (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
post_id INTEGER,
|
||||||
|
comment_id INTEGER,
|
||||||
|
date TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE admin.like OWNER TO xssbook;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.like
|
||||||
|
ADD CONSTRAINT like_user_id_fkey FOREIGN KEY (user_id) REFERENCES admin.user (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.like
|
||||||
|
ADD CONSTRAINT like_post_id_fkey FOREIGN KEY (post_id) REFERENCES admin.post (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.like
|
||||||
|
ADD CONSTRAINT like_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES admin.comment (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE admin.follow (
|
||||||
|
follower_id INTEGER NOT NULL,
|
||||||
|
followee_id INTEGER NOT NULL,
|
||||||
|
date TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE admin.follow OWNER TO xssbook;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.follow
|
||||||
|
ADD CONSTRAINT follow_pkey PRIMARY KEY (follower_id, followee_id);
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.follow
|
||||||
|
ADD CONSTRAINT follow_follower_id FOREIGN KEY (follower_id) REFERENCES admin.user (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.follow
|
||||||
|
ADD CONSTRAINT follow_followee_id FOREIGN KEY (followee_id) REFERENCES admin.user (id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE TABLE admin.media (
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
content BYTEA NOT NULL,
|
||||||
|
type TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE admin.media OWNER TO xssbook;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY admin.media
|
||||||
|
ADD CONSTRAINT media_pkey PRIMARY KEY (name);
|
||||||
|
|
||||||
|
ALTER DATABASE xssbook SET search_path = admin,public;
|
||||||
|
ALTER DATABASE xssbook SET bytea_output = 'hex';
|
||||||
|
-- Migration End;
|
||||||
|
|
||||||
|
-- Set Current Revision
|
||||||
|
UPDATE sys.database_info SET curr_revision = 1 WHERE name = current_database();
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
17
db/migrations/0001.sql
Normal file
17
db/migrations/0001.sql
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
SET search_path = public;
|
||||||
|
|
||||||
|
-- Migration Start
|
||||||
|
CREATE ROLE authenticator LOGIN NOINHERIT NOCREATEDB NOCREATEROLE NOSUPERUSER;
|
||||||
|
-- scary, make sure the db is not public!!!
|
||||||
|
ALTER ROLE authenticator WITH PASSWORD 'postgrest';
|
||||||
|
CREATE ROLE rest_anon NOLOGIN;
|
||||||
|
CREATE ROLE rest_user NOLOGIN;
|
||||||
|
GRANT rest_anon TO authenticator;
|
||||||
|
GRANT rest_user TO authenticator;
|
||||||
|
-- Migration End;
|
||||||
|
|
||||||
|
-- Set Current Revision
|
||||||
|
UPDATE sys.database_info SET curr_revision = 2 WHERE name = current_database();
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
134
db/migrations/0002.sql
Normal file
134
db/migrations/0002.sql
Normal file
File diff suppressed because one or more lines are too long
33
db/rest/login/_api_sign_jwt.sql
Normal file
33
db/rest/login/_api_sign_jwt.sql
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
CREATE FUNCTION _api.sign_jwt(
|
||||||
|
_role TEXT,
|
||||||
|
_user_id INTEGER
|
||||||
|
)
|
||||||
|
RETURNS sys.JWT
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_jwt_secret TEXT;
|
||||||
|
_token sys.JWT;
|
||||||
|
BEGIN
|
||||||
|
SELECT jwt_secret INTO _jwt_secret
|
||||||
|
FROM sys.database_info
|
||||||
|
WHERE name = current_database();
|
||||||
|
|
||||||
|
SELECT public.sign(
|
||||||
|
row_to_json(r), _jwt_secret
|
||||||
|
) INTO _token
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
_role AS role,
|
||||||
|
_user_id AS user_id,
|
||||||
|
extract(epoch FROM now())::integer + (60 * 60 * 24) AS exp
|
||||||
|
) r;
|
||||||
|
|
||||||
|
RETURN _token;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.sign_jwt(TEXT, INTEGER)
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE sys.database_info
|
||||||
|
TO rest_anon, rest_user;
|
30
db/rest/login/_api_validate_role.sql
Normal file
30
db/rest/login/_api_validate_role.sql
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
CREATE FUNCTION _api.validate_role()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT TRUE
|
||||||
|
FROM pg_catalog.pg_roles AS r
|
||||||
|
WHERE r.rolname = NEW.role
|
||||||
|
) THEN
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_err => 500
|
||||||
|
);
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
CREATE CONSTRAINT TRIGGER api_validate_role_trgr
|
||||||
|
AFTER INSERT OR UPDATE
|
||||||
|
ON admin.user
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE _api.validate_role();
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.validate_role()
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE pg_catalog.pg_roles
|
||||||
|
TO rest_anon, rest_user;
|
38
db/rest/login/_api_verify_jwt.sql
Normal file
38
db/rest/login/_api_verify_jwt.sql
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
CREATE FUNCTION _api.verify_jwt(
|
||||||
|
_token TEXT
|
||||||
|
)
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_payload JSON;
|
||||||
|
_valid BOOLEAN;
|
||||||
|
_jwt_secret TEXT;
|
||||||
|
BEGIN
|
||||||
|
SELECT jwt_secret INTO _jwt_secret
|
||||||
|
FROM sys.database_info
|
||||||
|
WHERE name = current_database();
|
||||||
|
|
||||||
|
SELECT payload, valid
|
||||||
|
INTO _payload, _valid
|
||||||
|
FROM public.verify(
|
||||||
|
_token,
|
||||||
|
_jwt_secret
|
||||||
|
);
|
||||||
|
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF _valid <> TRUE THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN _payload->>'user_id';
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.verify_jwt(TEXT)
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE sys.database_info
|
||||||
|
TO rest_anon, rest_user;
|
41
db/rest/login/api_login.sql
Normal file
41
db/rest/login/api_login.sql
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
CREATE FUNCTION api.login(
|
||||||
|
username TEXT,
|
||||||
|
password TEXT
|
||||||
|
)
|
||||||
|
RETURNS sys.JWT
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_role NAME;
|
||||||
|
_user_id INTEGER;
|
||||||
|
_token sys.JWT;
|
||||||
|
BEGIN
|
||||||
|
SELECT role INTO _role
|
||||||
|
FROM admin.user u
|
||||||
|
WHERE u.username = login.username
|
||||||
|
AND u.password = login.password;
|
||||||
|
|
||||||
|
IF _role IS NULL THEN
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_msg => 'api_invalid_login'
|
||||||
|
);
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT id INTO _user_id
|
||||||
|
FROM admin.user u
|
||||||
|
WHERE u.username = login.username;
|
||||||
|
|
||||||
|
_token = _api.sign_jwt(
|
||||||
|
_role,
|
||||||
|
_user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN _token;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION api.login(TEXT, TEXT)
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE admin.user
|
||||||
|
TO rest_anon, rest_user;
|
13
db/rest/post/api_post.sql
Normal file
13
db/rest/post/api_post.sql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
CREATE VIEW api.post AS
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.user_id,
|
||||||
|
p.content,
|
||||||
|
p.date
|
||||||
|
FROM
|
||||||
|
admin.post p;
|
||||||
|
|
||||||
|
GRANT SELECT ON TABLE api.post
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE admin.post
|
||||||
|
TO rest_anon, rest_user;
|
31
db/rest/post/api_post_delete.sql
Normal file
31
db/rest/post/api_post_delete.sql
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
CREATE FUNCTION _api.post_delete()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_user_id INTEGER;
|
||||||
|
BEGIN
|
||||||
|
_user_id = _api.get_user_id();
|
||||||
|
|
||||||
|
IF OLD.user_id <> _user_id THEN
|
||||||
|
PERFORM _api.raise_deny();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM admin.post
|
||||||
|
WHERE user_id = _user_id
|
||||||
|
AND id = OLD.id;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.post_delete()
|
||||||
|
TO rest_user;
|
||||||
|
GRANT DELETE ON TABLE api.post
|
||||||
|
TO rest_user;
|
||||||
|
GRANT DELETE ON TABLE admin.post
|
||||||
|
TO rest_user;
|
||||||
|
|
||||||
|
CREATE TRIGGER api_post_delete_trgr
|
||||||
|
INSTEAD OF DELETE
|
||||||
|
ON api.post
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE _api.post_delete();
|
40
db/rest/post/api_post_insert.sql
Normal file
40
db/rest/post/api_post_insert.sql
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
CREATE FUNCTION _api.post_insert()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_user_id INTEGER;
|
||||||
|
BEGIN
|
||||||
|
_user_id = _api.get_user_id();
|
||||||
|
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.content,
|
||||||
|
_column => 'content',
|
||||||
|
_min => 1,
|
||||||
|
_max => 4096
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO admin.post (
|
||||||
|
user_id,
|
||||||
|
content
|
||||||
|
) VALUES (
|
||||||
|
_user_id,
|
||||||
|
NEW.content
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.post_insert()
|
||||||
|
TO rest_user;
|
||||||
|
GRANT INSERT ON TABLE api.post
|
||||||
|
TO rest_user;
|
||||||
|
GRANT INSERT ON TABLE admin.post
|
||||||
|
TO rest_user;
|
||||||
|
|
||||||
|
CREATE TRIGGER api_post_insert_trgr
|
||||||
|
INSTEAD OF INSERT
|
||||||
|
ON api.post
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE _api.post_insert();
|
18
db/rest/post/api_post_update.sql
Normal file
18
db/rest/post/api_post_update.sql
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE FUNCTION _api.post_update()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_length INTEGER;
|
||||||
|
BEGIN
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.post_update() TO rest_user;
|
||||||
|
|
||||||
|
CREATE TRIGGER api_post_update_trgr
|
||||||
|
INSTEAD OF UPDATE
|
||||||
|
ON api.post
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE _api.post_update();
|
43
db/rest/rest.sql
Normal file
43
db/rest/rest.sql
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
SET search_path = public;
|
||||||
|
|
||||||
|
DROP SCHEMA IF EXISTS api CASCADE;
|
||||||
|
CREATE SCHEMA api;
|
||||||
|
|
||||||
|
DROP SCHEMA IF EXISTS _api CASCADE;
|
||||||
|
CREATE SCHEMA _api;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA admin TO rest_anon, rest_user;
|
||||||
|
GRANT USAGE ON SCHEMA sys TO rest_anon, rest_user;
|
||||||
|
|
||||||
|
GRANT USAGE ON SCHEMA api TO rest_anon, rest_user;
|
||||||
|
GRANT USAGE ON SCHEMA _api TO rest_anon, rest_user;
|
||||||
|
|
||||||
|
-- util
|
||||||
|
\i /db/rest/util/_api_serve_media.sql;
|
||||||
|
\i /db/rest/util/_api_raise.sql;
|
||||||
|
\i /db/rest/util/_api_raise_null.sql;
|
||||||
|
\i /db/rest/util/_api_raise_unique.sql;
|
||||||
|
\i /db/rest/util/_api_validate_text.sql;
|
||||||
|
\i /db/rest/util/_api_get_user_id.sql;
|
||||||
|
|
||||||
|
-- user
|
||||||
|
\i /db/rest/user/api_user.sql;
|
||||||
|
\i /db/rest/user/api_user_insert.sql;
|
||||||
|
\i /db/rest/user/api_user_update.sql;
|
||||||
|
\i /db/rest/user/api_user_delete.sql;
|
||||||
|
\i /db/rest/user/api_avatar.sql;
|
||||||
|
|
||||||
|
-- post
|
||||||
|
\i /db/rest/post/api_post.sql;
|
||||||
|
\i /db/rest/post/api_post_insert.sql;
|
||||||
|
\i /db/rest/post/api_post_update.sql;
|
||||||
|
\i /db/rest/post/api_post_delete.sql;
|
||||||
|
|
||||||
|
-- login
|
||||||
|
\i /db/rest/login/_api_sign_jwt.sql;
|
||||||
|
\i /db/rest/login/_api_verify_jwt.sql;
|
||||||
|
\i /db/rest/login/_api_validate_role.sql;
|
||||||
|
\i /db/rest/login/api_login.sql;
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
22
db/rest/user/api_avatar.sql
Normal file
22
db/rest/user/api_avatar.sql
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
CREATE FUNCTION api.avatar(
|
||||||
|
user_id INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
RETURNS sys."*/*"
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_mod INTEGER;
|
||||||
|
_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
_mod = MOD(user_id, 24);
|
||||||
|
_name = 'default_avatar_' || _mod || '.png';
|
||||||
|
RETURN _api.serve_media(_name);
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION api.avatar(INTEGER)
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE admin.user
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE admin.media
|
||||||
|
TO rest_anon, rest_user;
|
23
db/rest/user/api_user.sql
Normal file
23
db/rest/user/api_user.sql
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
CREATE VIEW api.user AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
NULL AS password,
|
||||||
|
u.role,
|
||||||
|
u.first_name,
|
||||||
|
u.last_name,
|
||||||
|
u.middle_name,
|
||||||
|
u.email,
|
||||||
|
u.gender,
|
||||||
|
u.join_date,
|
||||||
|
u.birth_date,
|
||||||
|
u.profile_avatar,
|
||||||
|
u.profile_banner,
|
||||||
|
u.profile_bio
|
||||||
|
FROM
|
||||||
|
admin.user u;
|
||||||
|
|
||||||
|
GRANT SELECT ON TABLE api.user
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE admin.user
|
||||||
|
TO rest_anon, rest_user;
|
30
db/rest/user/api_user_delete.sql
Normal file
30
db/rest/user/api_user_delete.sql
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
CREATE FUNCTION _api.user_delete()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_user_id INTEGER;
|
||||||
|
BEGIN
|
||||||
|
_user_id = _api.get_user_id();
|
||||||
|
|
||||||
|
IF OLD.id <> _user_id THEN
|
||||||
|
PERFORM _api.raise_deny();
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM admin.user
|
||||||
|
WHERE id = _user_id;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.user_delete()
|
||||||
|
TO rest_user;
|
||||||
|
GRANT DELETE ON TABLE api.user
|
||||||
|
TO rest_user;
|
||||||
|
GRANT DELETE ON TABLE admin.user
|
||||||
|
TO rest_user;
|
||||||
|
|
||||||
|
CREATE TRIGGER api_user_delete_trgr
|
||||||
|
INSTEAD OF DELETE
|
||||||
|
ON api.user
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE _api.user_delete();
|
121
db/rest/user/api_user_insert.sql
Normal file
121
db/rest/user/api_user_insert.sql
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
CREATE FUNCTION _api.user_insert()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_length INTEGER;
|
||||||
|
BEGIN
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.username,
|
||||||
|
_column => 'username',
|
||||||
|
_min => 1,
|
||||||
|
_max => 24
|
||||||
|
);
|
||||||
|
|
||||||
|
PERFORM TRUE FROM admin.user
|
||||||
|
WHERE username = NEW.username;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
PERFORM _api.raise_unique('username');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.password,
|
||||||
|
_column => 'password',
|
||||||
|
_max => 256
|
||||||
|
);
|
||||||
|
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.first_name,
|
||||||
|
_nullable => TRUE,
|
||||||
|
_column => 'first_name',
|
||||||
|
_max => 256
|
||||||
|
);
|
||||||
|
NEW.first_name = COALESCE(NEW.first_name, ''::text);
|
||||||
|
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.last_name,
|
||||||
|
_nullable => TRUE,
|
||||||
|
_column => 'last_name',
|
||||||
|
_max => 256
|
||||||
|
);
|
||||||
|
NEW.last_name = COALESCE(NEW.last_name, ''::text);
|
||||||
|
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.middle_name,
|
||||||
|
_nullable => TRUE,
|
||||||
|
_column => 'middle_name',
|
||||||
|
_max => 256
|
||||||
|
);
|
||||||
|
NEW.middle_name = COALESCE(NEW.middle_name, ''::text);
|
||||||
|
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.email,
|
||||||
|
_column => 'email',
|
||||||
|
_max => 256
|
||||||
|
);
|
||||||
|
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.gender,
|
||||||
|
_column => 'gender',
|
||||||
|
_max => 256
|
||||||
|
);
|
||||||
|
|
||||||
|
IF NEW.birth_date IS NULL THEN
|
||||||
|
PERFORM _api.raise_null('birth_date');
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM _api.validate_text(
|
||||||
|
_text => NEW.profile_bio,
|
||||||
|
_nullable => TRUE,
|
||||||
|
_column => 'profile_bio',
|
||||||
|
_max => 2048
|
||||||
|
);
|
||||||
|
NEW.profile_bio = COALESCE(NEW.profile_bio, ''::text);
|
||||||
|
|
||||||
|
INSERT INTO admin.user (
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
middle_name,
|
||||||
|
email,
|
||||||
|
gender,
|
||||||
|
birth_date,
|
||||||
|
profile_avatar,
|
||||||
|
profile_banner,
|
||||||
|
profile_bio
|
||||||
|
) VALUES (
|
||||||
|
NEW.username,
|
||||||
|
NEW.password,
|
||||||
|
NEW.first_name,
|
||||||
|
NEW.last_name,
|
||||||
|
NEW.middle_name,
|
||||||
|
NEW.email,
|
||||||
|
NEW.gender,
|
||||||
|
NEW.birth_date,
|
||||||
|
NEW.profile_avatar,
|
||||||
|
NEW.profile_banner,
|
||||||
|
NEW.profile_bio
|
||||||
|
);
|
||||||
|
|
||||||
|
NEW.password := NULL;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.user_insert()
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT INSERT ON TABLE api.user
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT INSERT ON TABLE admin.user
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT UPDATE ON TABLE sys.user_id_seq
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
|
||||||
|
CREATE TRIGGER api_user_insert_trgr
|
||||||
|
INSTEAD OF INSERT
|
||||||
|
ON api.user
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE _api.user_insert();
|
21
db/rest/user/api_user_update.sql
Normal file
21
db/rest/user/api_user_update.sql
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
CREATE FUNCTION _api.user_update()
|
||||||
|
RETURNS TRIGGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_length INTEGER;
|
||||||
|
BEGIN
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.user_update()
|
||||||
|
TO rest_user;
|
||||||
|
GRANT DELETE ON TABLE api.user
|
||||||
|
TO rest_user;
|
||||||
|
|
||||||
|
CREATE TRIGGER api_user_update_trgr
|
||||||
|
INSTEAD OF UPDATE
|
||||||
|
ON api.user
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE PROCEDURE _api.user_update();
|
11
db/rest/util/_api_get_user_id.sql
Normal file
11
db/rest/util/_api_get_user_id.sql
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
CREATE FUNCTION _api.get_user_id()
|
||||||
|
RETURNS INTEGER
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
BEGIN
|
||||||
|
RETURN CURRENT_SETTING(
|
||||||
|
'request.jwt.claims',
|
||||||
|
TRUE
|
||||||
|
)::JSON->>'user_id';
|
||||||
|
END
|
||||||
|
$BODY$;
|
50
db/rest/util/_api_raise.sql
Normal file
50
db/rest/util/_api_raise.sql
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
CREATE TABLE _api.err_map (
|
||||||
|
err INTEGER,
|
||||||
|
pg_err TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE _api.err_map OWNER TO xssbook;
|
||||||
|
|
||||||
|
ALTER TABLE ONLY _api.err_map
|
||||||
|
ADD CONSTRAINT err_map_pkey PRIMARY KEY (err);
|
||||||
|
|
||||||
|
INSERT INTO _api.err_map (err, pg_err)
|
||||||
|
VALUES
|
||||||
|
(400, 'P0001'),
|
||||||
|
(401, '42501'),
|
||||||
|
(403, '42501'),
|
||||||
|
(404, '42883'),
|
||||||
|
(409, '23505'),
|
||||||
|
(500, 'XX001');
|
||||||
|
|
||||||
|
CREATE FUNCTION _api.raise(
|
||||||
|
_msg TEXT DEFAULT '',
|
||||||
|
_detail TEXT DEFAULT '',
|
||||||
|
_hint TEXT DEFAULT '',
|
||||||
|
_err INTEGER DEFAULT 400
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_pg_err TEXT;
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
SELECT pg_err INTO _pg_err
|
||||||
|
FROM _api.err_map
|
||||||
|
WHERE err = _err;
|
||||||
|
|
||||||
|
RAISE EXCEPTION USING
|
||||||
|
MESSAGE := _msg,
|
||||||
|
DETAIL := _detail,
|
||||||
|
HINT := _hint,
|
||||||
|
ERRCODE := _pg_err;
|
||||||
|
|
||||||
|
RETURN FALSE;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT SELECT ON TABLE _api.err_map
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.raise(TEXT, TEXT, TEXT, INTEGER)
|
||||||
|
TO rest_anon, rest_user;
|
16
db/rest/util/_api_raise_deny.sql
Normal file
16
db/rest/util/_api_raise_deny.sql
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
CREATE FUNCTION _api.raise_deny()
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
BEGIN
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_msg => 'api_denied',
|
||||||
|
_err => 403
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.raise_null(TEXT)
|
||||||
|
TO rest_anon, rest_user;
|
18
db/rest/util/_api_raise_null.sql
Normal file
18
db/rest/util/_api_raise_null.sql
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE FUNCTION _api.raise_null(
|
||||||
|
_column TEXT DEFAULT ''
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
BEGIN
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_msg => 'api_null_value',
|
||||||
|
_detail => _column
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.raise_null(TEXT)
|
||||||
|
TO rest_anon, rest_user;
|
18
db/rest/util/_api_raise_unique.sql
Normal file
18
db/rest/util/_api_raise_unique.sql
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE FUNCTION _api.raise_unique(
|
||||||
|
_column TEXT DEFAULT ''
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
BEGIN
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_msg => 'api_unique_value',
|
||||||
|
_detail => _column
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.raise_unique(TEXT)
|
||||||
|
TO rest_anon, rest_user;
|
41
db/rest/util/_api_serve_media.sql
Normal file
41
db/rest/util/_api_serve_media.sql
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
CREATE FUNCTION _api.serve_media(
|
||||||
|
_name TEXT
|
||||||
|
)
|
||||||
|
RETURNS sys."*/*"
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_headers TEXT;
|
||||||
|
_data BYTEA;
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
SELECT FORMAT(
|
||||||
|
'[{"Content-Type": "%s"},'
|
||||||
|
'{"Content-Disposition": "inline; filename=\"%s\""},'
|
||||||
|
'{"Cache-Control": "max-age=259200"}]'
|
||||||
|
, m.type, m.name)
|
||||||
|
FROM admin.media m
|
||||||
|
WHERE m.name = _name INTO _headers;
|
||||||
|
|
||||||
|
PERFORM SET_CONFIG('response.headers', _headers, true);
|
||||||
|
|
||||||
|
SELECT m.content
|
||||||
|
FROM admin.media m
|
||||||
|
WHERE m.name = _name
|
||||||
|
INTO _data;
|
||||||
|
|
||||||
|
IF FOUND THEN
|
||||||
|
RETURN(_data);
|
||||||
|
ELSE
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_msg => 'api_not_found',
|
||||||
|
_err => 404
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.serve_media(TEXT)
|
||||||
|
TO rest_anon, rest_user;
|
||||||
|
GRANT SELECT ON TABLE admin.media
|
||||||
|
TO rest_anon, rest_user;
|
51
db/rest/util/_api_validate_text.sql
Normal file
51
db/rest/util/_api_validate_text.sql
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
CREATE FUNCTION _api.validate_text(
|
||||||
|
_column TEXT DEFAULT '',
|
||||||
|
_text TEXT DEFAULT NULL,
|
||||||
|
_min INTEGER DEFAULT NULL,
|
||||||
|
_max INTEGER DEFAULT NULL,
|
||||||
|
_nullable BOOLEAN DEFAULT FALSE
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN
|
||||||
|
LANGUAGE plpgsql VOLATILE
|
||||||
|
AS $BODY$
|
||||||
|
DECLARE
|
||||||
|
_length INTEGER;
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
-- make sure that text can only be null
|
||||||
|
-- when we allow it
|
||||||
|
IF _text IS NULL AND NOT _nullable THEN
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_msg => 'api_null_value',
|
||||||
|
_detail => _column
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF _text IS NULL THEN
|
||||||
|
RETURN TRUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
_length = LENGTH(_text);
|
||||||
|
|
||||||
|
IF _min IS NOT NULL AND _length < _min THEN
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_msg => 'api_text_min',
|
||||||
|
_detail => _column,
|
||||||
|
_hint => _min || ''
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF _max IS NOT NULL AND _length > _max THEN
|
||||||
|
PERFORM _api.raise(
|
||||||
|
_msg => 'api_text_max',
|
||||||
|
_detail => _column,
|
||||||
|
_hint => _max || ''
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END
|
||||||
|
$BODY$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION _api.validate_text(TEXT, TEXT, INTEGER, INTEGER, BOOLEAN)
|
||||||
|
TO rest_anon, rest_user;
|
21
db/rev.sql
Normal file
21
db/rev.sql
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
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 xssbook;
|
||||||
|
|
||||||
|
SELECT curr_revision();
|
62
docker-compose.yml
Normal file
62
docker-compose.yml
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
volumes:
|
||||||
|
- ./web:/opt/xssbook
|
||||||
|
- ./conf/nginx:/etc/nginx/conf.d:ro
|
||||||
|
depends_on:
|
||||||
|
- rest
|
||||||
|
- swagger
|
||||||
|
- php
|
||||||
|
|
||||||
|
php:
|
||||||
|
build: ./build/php
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./conf/postgres/database.env
|
||||||
|
volumes:
|
||||||
|
- ./web:/opt/xssbook
|
||||||
|
- ./data/status:/status
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
#image: postgres:16-alpine
|
||||||
|
build: ./build/postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./conf/postgres/database.env
|
||||||
|
environment:
|
||||||
|
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
|
||||||
|
volumes:
|
||||||
|
- './data/schemas:/var/lib/postgresql/data'
|
||||||
|
- ./db:/db:ro
|
||||||
|
|
||||||
|
rest:
|
||||||
|
build: ./build/postgrest
|
||||||
|
env_file:
|
||||||
|
- ./conf/postgres/database.env
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
init:
|
||||||
|
build: ./build/dbinit
|
||||||
|
env_file:
|
||||||
|
- ./conf/postgres/database.env
|
||||||
|
volumes:
|
||||||
|
- ./db:/db:ro
|
||||||
|
- ./data/status:/status
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
swagger:
|
||||||
|
image: swaggerapi/swagger-ui
|
||||||
|
environment:
|
||||||
|
SWAGGER_JSON_URL: '/api'
|
||||||
|
BASE_URL: '/apidocs'
|
||||||
|
PORT: 3000
|
||||||
|
depends_on:
|
||||||
|
- db
|
55
web/core/aesthetic.php
Normal file
55
web/core/aesthetic.php
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
class Aesthetic {
|
||||||
|
|
||||||
|
private $config;
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
$this->config = array(
|
||||||
|
'_common' => array(
|
||||||
|
'js' => [
|
||||||
|
'js/jquery-3.7.1.min.js',
|
||||||
|
'js/lib.js',
|
||||||
|
'js/modal.js',
|
||||||
|
],
|
||||||
|
'css' => [
|
||||||
|
'css/common.css'
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'error' => array(
|
||||||
|
'css' => [
|
||||||
|
'css/error.css'
|
||||||
|
],
|
||||||
|
),
|
||||||
|
'home' => array(
|
||||||
|
'js' => [
|
||||||
|
'js/post.js',
|
||||||
|
],
|
||||||
|
'css' => [
|
||||||
|
'css/home.css',
|
||||||
|
'css/post.css'
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_files($route) {
|
||||||
|
$js_files = $this->config['_common']['js'];
|
||||||
|
$css_files = $this->config['_common']['css'];
|
||||||
|
|
||||||
|
if (array_key_exists($route, $this->config)) {
|
||||||
|
$config = $this->config[$route];
|
||||||
|
if (array_key_exists('js', $config)) {
|
||||||
|
$js_files = array_merge($js_files, $config['js']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('css', $config)) {
|
||||||
|
$css_files = array_merge($css_files, $config['css']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'js_files' => $js_files,
|
||||||
|
'css_files' => $css_files,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
55
web/core/controller.php
Normal file
55
web/core/controller.php
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
abstract class Controller {
|
||||||
|
|
||||||
|
// the main model
|
||||||
|
public $main;
|
||||||
|
|
||||||
|
// the loader
|
||||||
|
public $load;
|
||||||
|
|
||||||
|
// the database
|
||||||
|
public $db;
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
$this->main = $GLOBALS['__vars']['main'];
|
||||||
|
$this->load = $GLOBALS['__vars']['load'];
|
||||||
|
$this->db = $this->main->db;
|
||||||
|
|
||||||
|
$info = $this->main->info;
|
||||||
|
$lang_code = $info['lang'];
|
||||||
|
$route_name = $info['route'];
|
||||||
|
$this->load->lang($lang_code);
|
||||||
|
$this->load->route_lang($lang_code, $route_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index() {}
|
||||||
|
|
||||||
|
protected function view($__name, $data = array()) {
|
||||||
|
$__root = $GLOBALS['webroot'];
|
||||||
|
$__path = $__root . '/views/' . $__name . '.php';
|
||||||
|
if (is_file($__path)) {
|
||||||
|
extract($data);
|
||||||
|
require($__path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function app_view($__name, $data = array()) {
|
||||||
|
$__root = $GLOBALS['webroot'];
|
||||||
|
$__route = $this->main->info['route'];
|
||||||
|
$__path = $__root . '/routes/' . $__route . '/views/' . $__name . '.php';
|
||||||
|
if (is_file($__path)) {
|
||||||
|
extract($data);
|
||||||
|
require($__path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function modal($title, $content, $data = array()) {
|
||||||
|
$data['title'] = $title;
|
||||||
|
$data['content'] = $content;
|
||||||
|
$this->view('template/modal', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
?>
|
173
web/core/database.php
Normal file
173
web/core/database.php
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
|
||||||
|
class DatabaseQuery {
|
||||||
|
|
||||||
|
private $conn;
|
||||||
|
private $query;
|
||||||
|
|
||||||
|
private $where;
|
||||||
|
private $set;
|
||||||
|
|
||||||
|
private $param;
|
||||||
|
|
||||||
|
function __construct($conn) {
|
||||||
|
$this->conn = $conn;
|
||||||
|
$this->query = '';
|
||||||
|
|
||||||
|
$this->set = FALSE;
|
||||||
|
$this->where = FALSE;
|
||||||
|
$this->param = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function in($array) {
|
||||||
|
$in = 'IN (';
|
||||||
|
foreach ($array as $idx => $item) {
|
||||||
|
if ($idx != 0) {
|
||||||
|
$in .= ",";
|
||||||
|
}
|
||||||
|
$in .= "?";
|
||||||
|
array_push($this->param, $item);
|
||||||
|
}
|
||||||
|
$in .= ")";
|
||||||
|
return $in;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function select($select) {
|
||||||
|
$this->query .= "SELECT $select\n";
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function from($from) {
|
||||||
|
$this->query .= "FROM $from\n";
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function where($cond) {
|
||||||
|
if (!$this->where) {
|
||||||
|
$this->where = TRUE;
|
||||||
|
$this->query .= "WHERE ";
|
||||||
|
}
|
||||||
|
$this->query .= "$cond ";
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function like($item) {
|
||||||
|
$this->query .= "LIKE ?\n";
|
||||||
|
array_push($this->param, $item);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function eq($item) {
|
||||||
|
$this->query .= "= ?\n";
|
||||||
|
array_push($this->param, $item);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ne($item) {
|
||||||
|
$this->query .= "<> ?\n";
|
||||||
|
array_push($this->param, $item);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function where_in($column, $array) {
|
||||||
|
if (!$this->where) {
|
||||||
|
$this->where = TRUE;
|
||||||
|
$this->query .= "WHERE ";
|
||||||
|
}
|
||||||
|
if (empty($array)) {
|
||||||
|
$this->query .= "FALSE\n";
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
$in = $this->in($array);
|
||||||
|
$this->query .= "$column $in\n";
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function and() {
|
||||||
|
$this->query .= "AND ";
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function or() {
|
||||||
|
$this->query .= "OR ";
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function join($table, $on, $type = 'LEFT') {
|
||||||
|
$this->query .= "$type JOIN $table ON $on\n";
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function limit($limit) {
|
||||||
|
$this->query .= "LIMIT ?\n";
|
||||||
|
array_push($this->param, $limit);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offset($offset) {
|
||||||
|
$this->query .= "OFFSET ?\n";
|
||||||
|
array_push($this->param, $offset);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rows() {
|
||||||
|
$stmt = $this->conn->prepare($this->query);
|
||||||
|
try {
|
||||||
|
$stmt->execute($this->param);
|
||||||
|
} catch (Exception $ex) {
|
||||||
|
echo $ex;
|
||||||
|
echo '<br> >> caused by <<<br>';
|
||||||
|
echo str_replace("\n", "<br>", $this->query);
|
||||||
|
}
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function row() {
|
||||||
|
$stmt = $this->conn->prepare($this->query);
|
||||||
|
$stmt->execute($this->param);
|
||||||
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DatabaseHelper
|
||||||
|
* allows queries on the
|
||||||
|
* xssbook postgres database
|
||||||
|
*/
|
||||||
|
class DatabaseHelper {
|
||||||
|
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
$this->conn = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connect() {
|
||||||
|
if ($this->conn === NULL) {
|
||||||
|
$user = getenv("POSTGRES_USER");
|
||||||
|
$pass = getenv("POSTGRES_PASSWORD");
|
||||||
|
$db = getenv("POSTGRES_DB");
|
||||||
|
$host = 'db';
|
||||||
|
$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($select) {
|
||||||
|
$conn = $this->connect();
|
||||||
|
$query = new DatabaseQuery($conn);
|
||||||
|
return $query->select($select);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
?>
|
12
web/core/error.php
Normal file
12
web/core/error.php
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title><?=$code . ' - ' . $msg?></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<center>
|
||||||
|
<h1><?=$code . ' ' . $msg?></h1>
|
||||||
|
</center>
|
||||||
|
<hr>
|
||||||
|
</body>
|
||||||
|
</html>
|
0
web/core/helper.php
Normal file
0
web/core/helper.php
Normal file
38
web/core/loader.php
Normal file
38
web/core/loader.php
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
class Loader {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the given common lang
|
||||||
|
* @param lang_code - the language code
|
||||||
|
*/
|
||||||
|
public function lang($lang_code) {
|
||||||
|
$dir = $GLOBALS['webroot'] . '/lang/' . $lang_code . '/';
|
||||||
|
$lang = $GLOBALS['lang'];
|
||||||
|
if ($handle = opendir($dir)) {
|
||||||
|
while (false !== ($entry = readdir($handle))) {
|
||||||
|
if ($entry === '.' || $entry === '..' || $entry === 'routes') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $dir . $entry;
|
||||||
|
require($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$GLOBALS['lang'] = $lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a given route specific lang
|
||||||
|
* @param lang_coed - the language code
|
||||||
|
* #param name - the name of the route
|
||||||
|
*/
|
||||||
|
public function route_lang($lang_code, $name) {
|
||||||
|
$dir = $GLOBALS['webroot'] . '/lang/' . $lang_code . '/routes/';
|
||||||
|
$file = $dir . $name . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
$lang = $GLOBALS['lang'];
|
||||||
|
require($dir . $name . '.php');
|
||||||
|
$GLOBALS['lang'] = $lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
123
web/core/main.php
Normal file
123
web/core/main.php
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
class MainModel {
|
||||||
|
|
||||||
|
// loaded route infomation
|
||||||
|
public $info;
|
||||||
|
public $db;
|
||||||
|
public $user_id;
|
||||||
|
|
||||||
|
private $users;
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
$this->info = NULL;
|
||||||
|
$this->db = new DatabaseHelper();
|
||||||
|
$this->users = array();
|
||||||
|
$_SESSION['jwt'] = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoicmVzdF91c2VyIiwidXNlcl9pZCI6MSwiZXhwIjoxNzExODUxMDUzfQ.FUcFO44SWV--YtVOy7NftTF8OeeOYGZDaDHigygQxsY';
|
||||||
|
if (array_key_exists('jwt', $_SESSION)) {
|
||||||
|
$this->get_session($_SESSION['jwt']);
|
||||||
|
} else {
|
||||||
|
$this->user_id = NULL;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function get_session($jwt) {
|
||||||
|
$query = $this->db
|
||||||
|
->select("_api.verify_jwt('" . $jwt . "') AS user_id;");
|
||||||
|
$result = $query->row();
|
||||||
|
$user_id = $result['user_id'];
|
||||||
|
if ($user_id) {
|
||||||
|
$this->user_id = $user_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function link_css($path) {
|
||||||
|
return '<link rel="stylesheet" href="/public/' . $path . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function link_js($path) {
|
||||||
|
return '<script src="/public/'. $path . '"></script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user() {
|
||||||
|
if ($this->user_id) {
|
||||||
|
return $this->db
|
||||||
|
->select('*')
|
||||||
|
->from('api.user')
|
||||||
|
->where('id')
|
||||||
|
->eq($this->user_id)
|
||||||
|
->row();
|
||||||
|
} else {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_num($key, $default = NULL) {
|
||||||
|
if (!array_key_exists($key, $_GET)) {
|
||||||
|
if ($default !== NULL) {
|
||||||
|
return $default;
|
||||||
|
} else {
|
||||||
|
error_page(400, lang('error_400'));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$val = $_GET[$key];
|
||||||
|
$val = intval($val);
|
||||||
|
if ($val < 0) {
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_users($objs) {
|
||||||
|
$ids = array();
|
||||||
|
foreach ($objs as $obj) {
|
||||||
|
$id = $obj['user_id'];
|
||||||
|
if (!array_key_exists($id, $this->users)) {
|
||||||
|
array_push($ids, intval($id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($ids)) {
|
||||||
|
$result = $this->db
|
||||||
|
->select('*')
|
||||||
|
->from('api.user')
|
||||||
|
->where_in('id', $ids)
|
||||||
|
->rows();
|
||||||
|
foreach ($result as $user) {
|
||||||
|
$id = $user['id'];
|
||||||
|
$this->users[$id] = $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->users;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function display_name($user) {
|
||||||
|
$name = '';
|
||||||
|
if ($user['first_name']) {
|
||||||
|
$name .= $user['first_name'];
|
||||||
|
}
|
||||||
|
if ($user['middle_name']) {
|
||||||
|
if ($name != '') {
|
||||||
|
$name .= ' ';
|
||||||
|
}
|
||||||
|
$name .= $user['middle_name'];
|
||||||
|
}
|
||||||
|
if ($user['last_name']) {
|
||||||
|
if ($name != '') {
|
||||||
|
$name .= ' ';
|
||||||
|
}
|
||||||
|
$name .= $user['last_name'];
|
||||||
|
}
|
||||||
|
if ($name == '') {
|
||||||
|
$name = '@' . $user['username'];
|
||||||
|
}
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function display_date($date) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
29
web/core/model.php
Normal file
29
web/core/model.php
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
abstract class Model {
|
||||||
|
// the main model
|
||||||
|
// shared by all controllers and models
|
||||||
|
public $main;
|
||||||
|
public $load;
|
||||||
|
|
||||||
|
// the database
|
||||||
|
public $db;
|
||||||
|
|
||||||
|
private $config;
|
||||||
|
|
||||||
|
function __construct() {
|
||||||
|
$this->main = $GLOBALS['__vars']['main'];
|
||||||
|
$this->load = $GLOBALS['__vars']['load'];
|
||||||
|
$this->db = $this->main->db;
|
||||||
|
$this->config = new Aesthetic();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_data() {
|
||||||
|
$data = array();
|
||||||
|
$route = $this->main->info['route'];
|
||||||
|
$files = $this->config->get_files($route);
|
||||||
|
$data = array_merge($data, $files);
|
||||||
|
$data['self'] = $this->main->user();
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
127
web/core/router.php
Normal file
127
web/core/router.php
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
class Router {
|
||||||
|
|
||||||
|
private $main;
|
||||||
|
private $load;
|
||||||
|
private $routes;
|
||||||
|
|
||||||
|
function load_route($route) {
|
||||||
|
$name = $route['name'];
|
||||||
|
$controller_cls = $route['controller'];
|
||||||
|
$model_cls = $route['model'];
|
||||||
|
|
||||||
|
$root = $GLOBALS['webroot'];
|
||||||
|
$dir = $root . '/routes/' . $name;
|
||||||
|
require($dir . '/model.php');
|
||||||
|
require($dir . '/controller.php');
|
||||||
|
|
||||||
|
$model_ref = new ReflectionClass($model_cls);
|
||||||
|
$model = $model_ref->newInstance();
|
||||||
|
|
||||||
|
$controller_ref = new ReflectionClass($controller_cls);
|
||||||
|
$controller = $controller_ref->newInstance($model);
|
||||||
|
|
||||||
|
return $controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
function __construct($main, $load) {
|
||||||
|
|
||||||
|
$routes = array(
|
||||||
|
'home' => array(
|
||||||
|
'slugs' => ['', 'home'],
|
||||||
|
'model' => 'HomeModel',
|
||||||
|
'controller' => 'HomeController',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->routes = array();
|
||||||
|
foreach ($routes as $name => $route) {
|
||||||
|
foreach ($route['slugs'] as $slug) {
|
||||||
|
$this->routes[$slug] = $route;
|
||||||
|
$this->routes[$slug]['name'] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->main = $main;
|
||||||
|
$this->load = $load;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_info() {
|
||||||
|
$uri = parse_url($_SERVER['REQUEST_URI']);
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$parts = explode('/', $uri['path']);
|
||||||
|
|
||||||
|
$slug = sizeof($parts) > 1 ? $parts[1] : '';
|
||||||
|
$path = sizeof($parts) > 2 ? $parts[2] : 'index';
|
||||||
|
|
||||||
|
if (sizeof($parts) > 3) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'method' => $method,
|
||||||
|
'uri' => $uri,
|
||||||
|
|
||||||
|
'slug' => $slug,
|
||||||
|
'path' => $path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_error($code) {
|
||||||
|
$route = array(
|
||||||
|
'name' => 'error',
|
||||||
|
'model' => 'ErrorModel',
|
||||||
|
'controller' => 'ErrorController'
|
||||||
|
);
|
||||||
|
$this->main->info = array(
|
||||||
|
'slug' => 'error',
|
||||||
|
'lang' => 'en_US',
|
||||||
|
'route' => 'error'
|
||||||
|
);
|
||||||
|
$controller = $this->load_route($route);
|
||||||
|
$_GET['code'] = $code;
|
||||||
|
http_response_code($code);
|
||||||
|
$controller->index();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle_request() {
|
||||||
|
$request = $this->get_info();
|
||||||
|
|
||||||
|
if ($request === NULL) {
|
||||||
|
$this->handle_error(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = $request['slug'];
|
||||||
|
if (!array_key_exists($slug, $this->routes)) {
|
||||||
|
$this->handle_error(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$route = $this->routes[$slug];
|
||||||
|
$this->main->info = array(
|
||||||
|
'lang' => 'en_US',
|
||||||
|
'slug' => $slug,
|
||||||
|
'route' => $route['name'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$controller = $this->load_route($route);
|
||||||
|
|
||||||
|
$path = $request['path'];
|
||||||
|
$ref = NULL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ref = new ReflectionMethod($controller, $path);
|
||||||
|
} catch (Exception $_e) {}
|
||||||
|
|
||||||
|
if ($ref === NULL || !$ref->isPublic()) {
|
||||||
|
$this->handle_error(404);
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$ref->invoke($controller);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
122
web/index.php
Normal file
122
web/index.php
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$lang = array();
|
||||||
|
$__vars = array();
|
||||||
|
$webroot = dirname(__FILE__);
|
||||||
|
|
||||||
|
function error_page($code, $msg) {
|
||||||
|
$root = $GLOBALS['webroot'];
|
||||||
|
error_reporting(E_ERROR | E_PARSE);
|
||||||
|
http_response_code($code);
|
||||||
|
require($root . '/core/error.php');
|
||||||
|
die();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lang($key, $default = NULL, $sub = NULL) {
|
||||||
|
$lang = $GLOBALS['lang'];
|
||||||
|
if(array_key_exists($key, $lang)) {
|
||||||
|
if ($sub) {
|
||||||
|
return sprintf($lang[$key], ...$sub);
|
||||||
|
} else {
|
||||||
|
return $lang[$key];
|
||||||
|
}
|
||||||
|
} else if ($default !== NULL) {
|
||||||
|
return $default;
|
||||||
|
} else {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ilang($key,
|
||||||
|
$class = NULL,
|
||||||
|
$id = NULL,
|
||||||
|
$href = NULL,
|
||||||
|
$click = NULL,
|
||||||
|
$attrs = array(),
|
||||||
|
$sub = NULL,
|
||||||
|
$button = FALSE,
|
||||||
|
) {
|
||||||
|
$text = lang($key . "_text", FALSE, sub: $sub);
|
||||||
|
$tip = lang($key . "_tip", FALSE);
|
||||||
|
$icon = lang($key . "_icon", FALSE);
|
||||||
|
$content = lang($key . "_content", FALSE);
|
||||||
|
|
||||||
|
if ($click || $button) {
|
||||||
|
echo '<button ';
|
||||||
|
} else {
|
||||||
|
echo '<a ';
|
||||||
|
}
|
||||||
|
if ($tip) {
|
||||||
|
echo 'title="' . $tip . '" ';
|
||||||
|
echo 'aria-label="' . $tip . '" ';
|
||||||
|
}
|
||||||
|
if ($class) {
|
||||||
|
echo 'class="' . $class . '" ';
|
||||||
|
}
|
||||||
|
if ($id) {
|
||||||
|
echo 'id="' . $id . '" ';
|
||||||
|
}
|
||||||
|
if ($click) {
|
||||||
|
echo 'onclick="' . $click . '" ';
|
||||||
|
}
|
||||||
|
if ($href) {
|
||||||
|
echo 'href="' . $href . '" ';
|
||||||
|
}
|
||||||
|
foreach ($attrs as $key => $attr) {
|
||||||
|
echo $key . '="' . $attr . '" ';
|
||||||
|
}
|
||||||
|
echo '> ';
|
||||||
|
if ($icon) {
|
||||||
|
echo '<i class="' . $icon . '">';
|
||||||
|
if ($content) {
|
||||||
|
echo $content;
|
||||||
|
}
|
||||||
|
echo '</i>';
|
||||||
|
}
|
||||||
|
if ($text) {
|
||||||
|
echo '<span';
|
||||||
|
if ($icon) {
|
||||||
|
echo ' class="ml-sm"';
|
||||||
|
}
|
||||||
|
echo '>' . $text . '</span>';
|
||||||
|
}
|
||||||
|
if ($click) {
|
||||||
|
echo '</button>';
|
||||||
|
} else {
|
||||||
|
echo '</a>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function __init() {
|
||||||
|
|
||||||
|
$root = $GLOBALS['webroot'];
|
||||||
|
|
||||||
|
// load all core files
|
||||||
|
require($root . '/core/database.php');
|
||||||
|
require($root . '/core/aesthetic.php');
|
||||||
|
require($root . '/core/controller.php');
|
||||||
|
require($root . '/core/model.php');
|
||||||
|
require($root . '/core/loader.php');
|
||||||
|
require($root . '/core/main.php');
|
||||||
|
require($root . '/core/router.php');
|
||||||
|
|
||||||
|
$main = new MainModel();
|
||||||
|
$load = new Loader();
|
||||||
|
$router = new Router($main, $load);
|
||||||
|
|
||||||
|
$GLOBALS['__vars']['main'] = $main;
|
||||||
|
$GLOBALS['__vars']['load'] = $load;
|
||||||
|
$GLOBALS['__vars']['router'] = $router;
|
||||||
|
|
||||||
|
$router->handle_request();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!file_exists('/status/ready')) {
|
||||||
|
error_page(503, 'Service Unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
__init();
|
||||||
|
|
||||||
|
?>
|
42
web/lang/en_US/common_lang.php
Normal file
42
web/lang/en_US/common_lang.php
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// Navigation Bar Lang
|
||||||
|
$lang['action_home_text'] = 'Home';
|
||||||
|
$lang['action_home_tip'] = 'Goto your home page.';
|
||||||
|
$lang['action_people_text'] = 'People';
|
||||||
|
$lang['action_people_tip'] = 'View xssbook users.';
|
||||||
|
$lang['action_chat_text'] = 'Chat';
|
||||||
|
$lang['action_chat_tip'] = 'Goto your chat center.';
|
||||||
|
$lang['action_profile_tip'] = 'View account options.';
|
||||||
|
$lang['action_hamburger_tip'] = 'View header dropdown.';
|
||||||
|
$lang['action_login_text'] = 'Login';
|
||||||
|
$lang['action_login_tip'] = 'Login or signup';
|
||||||
|
|
||||||
|
// Post lang
|
||||||
|
$lang['action_like_text'] = 'Like';
|
||||||
|
$lang['action_like_tip'] = 'Like this post.';
|
||||||
|
$lang['action_like_icon'] = 'mi mi-sm';
|
||||||
|
$lang['action_like_content'] = 'thumb_up';
|
||||||
|
$lang['action_comment_text'] = 'Comment';
|
||||||
|
$lang['action_comment_tip'] = 'Focus the comment box.';
|
||||||
|
$lang['action_comment_icon'] = 'mi mi-sm';
|
||||||
|
$lang['action_comment_content'] = 'comment';
|
||||||
|
$lang['action_new_comment_text'] = 'Write a comment';
|
||||||
|
$lang['action_new_comment_tip'] = 'Write a comment, then press enter to submit.';
|
||||||
|
$lang['action_load_comments_text'] = 'Load more comments';
|
||||||
|
$lang['action_load_comments_tip'] = 'Load more comments';
|
||||||
|
|
||||||
|
// General
|
||||||
|
$lang['action_submit_text'] = 'Submit';
|
||||||
|
$lang['action_submit_tip'] = 'Submit';
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
$lang['action_modal_close_text'] = '';
|
||||||
|
$lang['action_modal_close_tip'] = 'Close modal.';
|
||||||
|
$lang['action_modal_close_icon'] = 'mi mi-sm';
|
||||||
|
$lang['action_modal_close_content'] = 'close';
|
||||||
|
|
||||||
|
// Words
|
||||||
|
$lang['now'] = 'Now';
|
||||||
|
|
||||||
|
?>
|
8
web/lang/en_US/error_lang.php
Normal file
8
web/lang/en_US/error_lang.php
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$lang['error_400'] = 'Bad request';
|
||||||
|
$lang['error_404'] = 'Resource not found';
|
||||||
|
$lang['error_500'] = 'Whoops! Server error :(';
|
||||||
|
$lang['error'] = 'An unknown error has occoured';
|
||||||
|
|
||||||
|
?>
|
14
web/lang/en_US/routes/home.php
Normal file
14
web/lang/en_US/routes/home.php
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$lang['title'] = 'Home';
|
||||||
|
|
||||||
|
// actions
|
||||||
|
$lang['action_new_post_text'] = 'What\'s on your mind, %s';
|
||||||
|
$lang['action_new_post_tip'] = 'Author a new post.';
|
||||||
|
$lang['action_load_posts_text'] = 'Load more posts';
|
||||||
|
$lang['action_load_posts_tip'] = 'Load more posts';
|
||||||
|
|
||||||
|
// modals
|
||||||
|
$lang['new_post_modal_title'] = 'Author New Post';
|
||||||
|
|
||||||
|
?>
|
404
web/public/css/common.css
Normal file
404
web/public/css/common.css
Normal file
|
@ -0,0 +1,404 @@
|
||||||
|
:root {
|
||||||
|
--primary: #242424 !important;
|
||||||
|
--secondary: #181818 !important;
|
||||||
|
--hover: #1b1b1b !important;
|
||||||
|
--light: #3e4042 !important;
|
||||||
|
--mild: #1b1b1b !important;
|
||||||
|
--medium: #e2ded6 !important;
|
||||||
|
--extreme: #e2ded6 !important;
|
||||||
|
--logo: #1778f2 !important;
|
||||||
|
--error: #f02849 !important;
|
||||||
|
--success: #30ab5a !important;
|
||||||
|
--text: #ffffff !important;
|
||||||
|
--banner: #6b6b6b !important;
|
||||||
|
--popup: #242424cc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url("/public/font/MaterialIcons-Regular.ttf") format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: facebook;
|
||||||
|
src: url("/public/font/facebook.otf") format("opentype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: sfpro;
|
||||||
|
src: url("/public/font/sfpro.otf") format("opentype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: sfprobold;
|
||||||
|
src: url("/public/font/sfprobold.otf") format("opentype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: sfpro;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
height: 3.5rem;
|
||||||
|
background-color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .logo {
|
||||||
|
font-family: facebook;
|
||||||
|
color: var(--logo);
|
||||||
|
font-size: 2.25rem;
|
||||||
|
height: 100%;
|
||||||
|
line-height: 2rem;
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, button, input {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
font-family: sfprobold;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
form button {
|
||||||
|
padding: .5rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-entry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
text-decoration: none;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav .header-entry {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center .header-entry:hover {
|
||||||
|
background-color: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: .35rem;
|
||||||
|
margin: .25rem;
|
||||||
|
border-radius: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:hover {
|
||||||
|
background-color: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-blue:hover {
|
||||||
|
color: var(--logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .btn-blue {
|
||||||
|
border-bottom: 1px solid var(--logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-line:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav,
|
||||||
|
.nav-left,
|
||||||
|
.nav-center,
|
||||||
|
.nav-right {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-right {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
.header-entry > span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center .header-entry {
|
||||||
|
padding: 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#action-hamburger {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.nav-center {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
flex-direction: column;
|
||||||
|
top: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
background-color: var(--primary);
|
||||||
|
width: 100%;
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(0%);
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center.visible {
|
||||||
|
display: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center .header-entry {
|
||||||
|
width: calc(100% - 3rem);
|
||||||
|
padding: .75rem 0rem !important;
|
||||||
|
padding-left: 3rem !important;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center .header-entry > span {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-center .header-entry.active {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-right .image-loading {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-right .header-entry {
|
||||||
|
padding: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
to {
|
||||||
|
background-position-x: 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pfp, .pfp img {
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 2.5rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pfp-sm, .pfp-sm img {
|
||||||
|
height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-loading {
|
||||||
|
background: linear-gradient(-45deg, var(--secondary) 0%, var(--primary) 25%, var(--secondary) 50%);
|
||||||
|
background-size: 500%;
|
||||||
|
background-position-x: 150%;
|
||||||
|
animation: shimmer 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-radius: .5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card form {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .sub-card {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
border-radius: .5rem;
|
||||||
|
padding: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
background-color: var(--secondary);
|
||||||
|
font-family: sfpro;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:hover {
|
||||||
|
background-color: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-sm {
|
||||||
|
margin-left: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr-sm {
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mr {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb {
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
color: var(--medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, .1);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background-color: var(--primary);
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
min-width: 40rem;
|
||||||
|
min-height: 24rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal>form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
font-family: sfprobold;
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--light);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 1rem;
|
||||||
|
border-radius: .5rem .5rem 0 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 1rem;
|
||||||
|
cursor: grab;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-right {
|
||||||
|
position: absolute;
|
||||||
|
transform: translate(0%, -50%);
|
||||||
|
top: 45%;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi {
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-sm {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mi-lg {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"] {
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--logo);
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"]:hover {
|
||||||
|
background-color: var(--logo);
|
||||||
|
}
|
||||||
|
|
16
web/public/css/error.css
Normal file
16
web/public/css/error.css
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
#error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#error h1 {
|
||||||
|
color: var(--logo);
|
||||||
|
font-family: Facebook;
|
||||||
|
font-size: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#error span {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
26
web/public/css/home.css
Normal file
26
web/public/css/home.css
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
#main-content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 40rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-post-modal textarea {
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: sfpro;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 70%;
|
||||||
|
flex-grow: 1;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
12
web/public/css/post.css
Normal file
12
web/public/css/post.css
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
.post hr {
|
||||||
|
color: var(--light);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post hr:nth-of-type(1) {
|
||||||
|
margin-top: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-load-comments {
|
||||||
|
margin-left: 4rem;
|
||||||
|
}
|
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
BIN
web/public/font/MaterialIcons-Regular.ttf
Normal file
BIN
web/public/font/MaterialIcons-Regular.ttf
Normal file
Binary file not shown.
BIN
web/public/font/facebook.otf
Normal file
BIN
web/public/font/facebook.otf
Normal file
Binary file not shown.
BIN
web/public/font/sfpro.otf
Normal file
BIN
web/public/font/sfpro.otf
Normal file
Binary file not shown.
BIN
web/public/font/sfprobold.otf
Normal file
BIN
web/public/font/sfprobold.otf
Normal file
Binary file not shown.
2
web/public/js/jquery-3.7.1.min.js
vendored
Normal file
2
web/public/js/jquery-3.7.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
45
web/public/js/lib.js
Normal file
45
web/public/js/lib.js
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
|
$(function() {
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var r$ = function(callback) {
|
||||||
|
if (ready) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
$(function() {
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function observe(containerSelector, elementSelector, callback) {
|
||||||
|
r$(() => {
|
||||||
|
|
||||||
|
$(containerSelector + ' ' + elementSelector).each(function (_, e) {
|
||||||
|
let me = $(e);
|
||||||
|
callback(me);
|
||||||
|
});
|
||||||
|
|
||||||
|
var onMutationsObserved = function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
if (mutation.addedNodes.length) {
|
||||||
|
var elements = $(mutation.addedNodes).find(elementSelector);
|
||||||
|
for (var i = 0, len = elements.length; i < len; i++) {
|
||||||
|
let me = elements[i];
|
||||||
|
me = $(me);
|
||||||
|
callback(me);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var target = $(containerSelector)[0];
|
||||||
|
var config = { childList: true, subtree: true };
|
||||||
|
var MutationObserver = window.MutationObserver;
|
||||||
|
var observer = new MutationObserver(onMutationsObserved);
|
||||||
|
observer.observe(target, config);
|
||||||
|
});
|
||||||
|
}
|
64
web/public/js/modal.js
Normal file
64
web/public/js/modal.js
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
$(document).on('click', ".modal-close", (o) => {
|
||||||
|
$(o.target).closest('.modal-container').remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
const initDrag = (header, modal, container) => {
|
||||||
|
let drag = false;
|
||||||
|
|
||||||
|
let mouseX, mouseY, modalX, modalY;
|
||||||
|
|
||||||
|
const onStart = (e) => {
|
||||||
|
e = e || window.event;
|
||||||
|
e.preventDefault();
|
||||||
|
mouseX = e.clientX;
|
||||||
|
mouseY = e.clientY;
|
||||||
|
drag = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrag = (e) => {
|
||||||
|
e = e || window.event;
|
||||||
|
e.preventDefault();
|
||||||
|
if (!drag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalX = mouseX - e.clientX;
|
||||||
|
modalY = mouseY - e.clientY;
|
||||||
|
mouseX = e.clientX;
|
||||||
|
mouseY = e.clientY;
|
||||||
|
|
||||||
|
let posX = (modal.offsetLeft - modalX),
|
||||||
|
posY = (modal.offsetTop - modalY);
|
||||||
|
|
||||||
|
let minX = modal.offsetWidth / 2,
|
||||||
|
minY = modal.offsetHeight / 2;
|
||||||
|
|
||||||
|
let maxX = container.offsetWidth - minX,
|
||||||
|
maxY = container.offsetHeight - minY;
|
||||||
|
|
||||||
|
posX = Math.max(minX, Math.min(maxX, posX));
|
||||||
|
posY = Math.max(minY, Math.min(maxY, posY));
|
||||||
|
|
||||||
|
posX = posX / container.offsetWidth * 100;
|
||||||
|
posY = posY / container.offsetHeight * 100;
|
||||||
|
|
||||||
|
modal.style.left = posX + "%";
|
||||||
|
modal.style.top = posY + "%";
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = () => {
|
||||||
|
drag = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
header.onmousedown = onStart;
|
||||||
|
container.onmousemove = onDrag;
|
||||||
|
container.onmouseup = onEnd;
|
||||||
|
};
|
||||||
|
|
||||||
|
observe('body', '.modal-header', function(el) {
|
||||||
|
let header = $(el);
|
||||||
|
let modal = header.closest('.modal');
|
||||||
|
let container = header.closest('.modal-container');
|
||||||
|
initDrag(
|
||||||
|
header[0], modal[0], container[0]
|
||||||
|
);
|
||||||
|
});
|
38
web/public/js/post.js
Normal file
38
web/public/js/post.js
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
observe('.post', '.action-load-comments', function(me) {
|
||||||
|
me.on('click', function() {
|
||||||
|
let page = me.attr('page');
|
||||||
|
if (!page) {
|
||||||
|
page = '1';
|
||||||
|
}
|
||||||
|
let newPage = Number(page) + 1;
|
||||||
|
let id = me.attr('postId');
|
||||||
|
me.attr('page', newPage + '');
|
||||||
|
let url = '/home/comments?page=' + page + '&id=' + id;
|
||||||
|
$.get(url, function (data) {
|
||||||
|
if (data === '') {
|
||||||
|
me.remove();
|
||||||
|
} else {
|
||||||
|
$(me).prepend(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observe('#main-content', '#action-load-posts', function(me) {
|
||||||
|
me.on('click', function () {
|
||||||
|
let page = me.attr('page');
|
||||||
|
if (!page) {
|
||||||
|
page = '1';
|
||||||
|
}
|
||||||
|
let newPage = Number(page) + 1;
|
||||||
|
me.attr('page', newPage + '');
|
||||||
|
let url = '/home/posts?page=' + page;
|
||||||
|
$.get(url, function (data) {
|
||||||
|
if (data === '') {
|
||||||
|
me.remove();
|
||||||
|
} else {
|
||||||
|
$('#post-container').append(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
20
web/routes/error/controller.php
Normal file
20
web/routes/error/controller.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
class ErrorController extends Controller {
|
||||||
|
|
||||||
|
private $model;
|
||||||
|
|
||||||
|
function __construct($model) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index() {
|
||||||
|
parent::index();
|
||||||
|
$data = $this->model->get_data();
|
||||||
|
$this->view('header', $data);
|
||||||
|
$this->app_view('error', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
31
web/routes/error/model.php
Normal file
31
web/routes/error/model.php
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
class ErrorModel extends Model {
|
||||||
|
|
||||||
|
private function get_msg(&$data) {
|
||||||
|
if (!array_key_exists('code', $_GET)) {
|
||||||
|
$data['msg'] = lang('error');
|
||||||
|
$data['title'] = '500';
|
||||||
|
} else {
|
||||||
|
$code = $_GET['code'];
|
||||||
|
$data['title'] = $code;
|
||||||
|
switch ($code) {
|
||||||
|
case '404':
|
||||||
|
$data['msg'] = lang('error_404');
|
||||||
|
break;
|
||||||
|
case '500':
|
||||||
|
$data['msg'] = lang('error_500');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$data['msg'] = lang('error');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_data() {
|
||||||
|
$data = parent::get_data();
|
||||||
|
$this->get_msg($data);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
4
web/routes/error/views/error.php
Normal file
4
web/routes/error/views/error.php
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div id="error">
|
||||||
|
<h1><?=$title?></h1>
|
||||||
|
<span><?=$msg?></span>
|
||||||
|
</div>
|
84
web/routes/home/controller.php
Normal file
84
web/routes/home/controller.php
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
class HomeController extends Controller {
|
||||||
|
|
||||||
|
private $model;
|
||||||
|
|
||||||
|
function __construct($model) {
|
||||||
|
parent::__construct();
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index() {
|
||||||
|
parent::index();
|
||||||
|
$data = $this->model->get_data();
|
||||||
|
$this->view('header', $data);
|
||||||
|
$this->app_view('main', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function posts() {
|
||||||
|
$page = $this->main->get_num('page', 0);
|
||||||
|
$page_size = 20;
|
||||||
|
$offset = $page * $page_size;
|
||||||
|
|
||||||
|
$user = $this->main->user();
|
||||||
|
|
||||||
|
$query = $this->db;
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$query = $query->select('p.*, l.post_id IS NOT NULL as liked');
|
||||||
|
} else {
|
||||||
|
$query = $query->select('p.*, FALSE as liked');
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $query->from('admin.post p');
|
||||||
|
|
||||||
|
if ($user) {
|
||||||
|
$query = $query->join('admin.like l', 'p.id = l.post_id')
|
||||||
|
->where('l.user_id')->eq($user['id'])
|
||||||
|
->or()->where('l.user_id IS NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = $query->limit($page_size)
|
||||||
|
->offset($offset)
|
||||||
|
->rows();
|
||||||
|
|
||||||
|
$users = $this->main->get_users($posts);
|
||||||
|
|
||||||
|
foreach ($posts as $post) {
|
||||||
|
$data = array();
|
||||||
|
$data['user'] = $users[$post['user_id']];
|
||||||
|
$data['post'] = $post;
|
||||||
|
$this->view('template/post', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function comments() {
|
||||||
|
$page = $this->main->get_num('page', 0);
|
||||||
|
$id = $this->main->get_num('id');
|
||||||
|
$page_size = 20;
|
||||||
|
$offset = $page * $page_size;
|
||||||
|
|
||||||
|
$comments = $this->db
|
||||||
|
->select('*')
|
||||||
|
->from('admin.comment')
|
||||||
|
->limit($page_size)
|
||||||
|
->offset($offset)
|
||||||
|
->rows();
|
||||||
|
|
||||||
|
$users = $this->main->get_users($comments);
|
||||||
|
|
||||||
|
foreach ($comments as $comment) {
|
||||||
|
$data = array();
|
||||||
|
$data['user'] = $users[$comment['user_id']];
|
||||||
|
$data['comment'] = $comment;
|
||||||
|
$this->view('template/comment', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function new_post_modal() {
|
||||||
|
$this->modal(lang('new_post_modal_title'), 'new-post');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
19
web/routes/home/model.php
Normal file
19
web/routes/home/model.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php /* Copyright (c) 2024 Freya Murphy */
|
||||||
|
class HomeModel extends Model {
|
||||||
|
|
||||||
|
private function get_posts() {
|
||||||
|
return $this->db
|
||||||
|
->select('*')
|
||||||
|
->from('admin.post')
|
||||||
|
->limit(20)
|
||||||
|
->rows();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_data() {
|
||||||
|
$data = parent::get_data();
|
||||||
|
$data['title'] = lang('title');
|
||||||
|
$data['posts'] = $this->get_posts();
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
29
web/routes/home/views/main.php
Normal file
29
web/routes/home/views/main.php
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?php // vi: syntax=php ?>
|
||||||
|
<div id="main-content">
|
||||||
|
<?php if ($self): ?>
|
||||||
|
<div id="new-post" class="card">
|
||||||
|
<div class="row grow">
|
||||||
|
<?php $this->view('template/pfp', array('user' => $self))?>
|
||||||
|
<a
|
||||||
|
id="action-new-post"
|
||||||
|
class="input btn-fake ml"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="<?=lang('action_new_post_tip')?>"
|
||||||
|
>
|
||||||
|
<?=lang('action_new_post_text', sub: [$self['first_name']])?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
$('#action-new-post').on('click', function() {
|
||||||
|
$.get( "/home/new_post_modal", function (data) {
|
||||||
|
$(document.body).append(data);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div id="post-container">
|
||||||
|
<?=$this->posts()?>
|
||||||
|
</div>
|
||||||
|
<?=ilang('action_load_posts', id: 'action-load-posts', class: 'btn btn-line')?>
|
||||||
|
</div>
|
2
web/views/footer.php
Normal file
2
web/views/footer.php
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
<body>
|
||||||
|
</html>
|
74
web/views/header.php
Normal file
74
web/views/header.php
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<?php // vi: syntax=php ?>
|
||||||
|
<?php
|
||||||
|
$self = $this->main->user();
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<?php
|
||||||
|
foreach ($js_files as $js) {
|
||||||
|
echo $this->main->link_js($js);
|
||||||
|
}
|
||||||
|
foreach ($css_files as $css) {
|
||||||
|
echo $this->main->link_css($css);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<title><?=$title?></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="nav">
|
||||||
|
<div class="nav-left">
|
||||||
|
<span class="logo">xssbook</span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-center" :class="{hidden: !visible}">
|
||||||
|
<a
|
||||||
|
id="action-home"
|
||||||
|
class="header-entry btn btn-hover btn-action btn-blue"
|
||||||
|
href="/home"
|
||||||
|
title="<?=lang('action_home_tip')?>"
|
||||||
|
>
|
||||||
|
<i class="mi mi-lg">home</i>
|
||||||
|
<span><?=lang('action_home_text')?></span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
id="action-people"
|
||||||
|
class="header-entry btn btn-hover btn-action btn-blue"
|
||||||
|
href="/people"
|
||||||
|
title="<?=lang('action_people_tip')?>"
|
||||||
|
>
|
||||||
|
<i class="mi mi-lg">people</i>
|
||||||
|
<span><?=lang('action_people_text')?></span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
id="action-chat"
|
||||||
|
class="header-entry btn btn-hover btn-action btn-blue"
|
||||||
|
href="/chat"
|
||||||
|
title="<?=lang('action_chat_tip')?>"
|
||||||
|
>
|
||||||
|
<i class="mi mi-lg">chat</i>
|
||||||
|
<span><?=lang('action_chat_text')?></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-right">
|
||||||
|
<button
|
||||||
|
id="action-hamburger"
|
||||||
|
title="<?=lang('action_hamburger_tip')?>"
|
||||||
|
>
|
||||||
|
<i class="mi mi-lg">menu</i>
|
||||||
|
</button>
|
||||||
|
<?php if($self): ?>
|
||||||
|
<?php $this->view('template/pfp', array(
|
||||||
|
'user' => $self,
|
||||||
|
'class' => 'pfp-sm ml',
|
||||||
|
)); ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<?=ilang('action_login', class: 'btn btn-action', href: '/auth/login')?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
$('#action-hamburger').on('click', function() {
|
||||||
|
let menu = $('.nav-center');
|
||||||
|
menu.toggleClass('visible');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</header>
|
28
web/views/modal/new-post.php
Normal file
28
web/views/modal/new-post.php
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
$user = $this->main->user();
|
||||||
|
?>
|
||||||
|
<form>
|
||||||
|
<div class="modal-content new-post-modal">
|
||||||
|
<div class="row">
|
||||||
|
<?php $this->view('template/pfp', array('user' => $user))?>
|
||||||
|
<div class="col ml">
|
||||||
|
<strong><?=$user['first_name'] . ' ' . $user['last_name']?></strong>
|
||||||
|
<span class="dim"><?=lang('now')?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
type="text"
|
||||||
|
name="text"
|
||||||
|
id="text"
|
||||||
|
placeholder="<?=lang('action_new_post_text', sub: [$user['first_name']])?>"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<?=ilang('action_submit',
|
||||||
|
id: 'new-post-submit',
|
||||||
|
class: 'btn-action',
|
||||||
|
attrs: array('type' => 'submit'),
|
||||||
|
button: TRUE
|
||||||
|
)?>
|
||||||
|
</div>
|
||||||
|
</form>
|
10
web/views/template/comment.php
Normal file
10
web/views/template/comment.php
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<div class="comment row mt">
|
||||||
|
<?php $this->view('template/pfp', array('user' => $user))?>
|
||||||
|
<div class="ml col sub-card">
|
||||||
|
<div class="row">
|
||||||
|
<strong><?=$this->main->display_name($user)?></strong>
|
||||||
|
<span class="dim ml"><?=$this->main->display_date($comment['date'])?></span>
|
||||||
|
</div>
|
||||||
|
<?=$comment['content']?>
|
||||||
|
</div>
|
||||||
|
</div>
|
12
web/views/template/modal.php
Normal file
12
web/views/template/modal.php
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header row">
|
||||||
|
<?=$title?>
|
||||||
|
<?=ilang(
|
||||||
|
'action_modal_close',
|
||||||
|
class: 'float-right btn btn-action modal-close',
|
||||||
|
)?>
|
||||||
|
</div>
|
||||||
|
<?php $this->view('modal/' . $content) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
6
web/views/template/pfp.php
Normal file
6
web/views/template/pfp.php
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
$class = isset($class) ? $class : '';
|
||||||
|
?>
|
||||||
|
<a class="image-loading pfp <?=$class?>" href="/profile?id=<?=$user['id']?>">
|
||||||
|
<img src="/api/rpc/avatar?user_id=<?=$user['id']?>" />
|
||||||
|
</a>
|
58
web/views/template/post.php
Normal file
58
web/views/template/post.php
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
<div class="post card">
|
||||||
|
<div class="row">
|
||||||
|
<?php $this->view('template/pfp', array('user' => $user))?>
|
||||||
|
<div class="col ml">
|
||||||
|
<strong><?=$user['first_name'] . ' ' . $user['last_name']?></strong>
|
||||||
|
<span class="dim"><?=$post['date']?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<?=$post['content']?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
$self = $this->main->user();
|
||||||
|
?>
|
||||||
|
<?php if ($self): ?>
|
||||||
|
<hr>
|
||||||
|
<div class="row">
|
||||||
|
<?=ilang('action_like', class: 'grow btn btn-hover btn-action')?>
|
||||||
|
<?=ilang('action_comment', class: 'grow btn btn-hover btn-action action-comment',
|
||||||
|
click: '$(\'#new-comment-' . $post['id'] . '\').focus()'
|
||||||
|
)?>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<?php else: ?>
|
||||||
|
<hr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="col comments">
|
||||||
|
<?php
|
||||||
|
$_GET['id'] = $post['id'];
|
||||||
|
$this->comments();
|
||||||
|
ilang('action_load_comments',
|
||||||
|
class: 'action-load-comments btn btn-line mt',
|
||||||
|
attrs: array('postId' => $post['id'])
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php if ($self): ?>
|
||||||
|
<div class="row grow mt">
|
||||||
|
<?php $this->view('template/pfp', array('user' => $user))?>
|
||||||
|
<form class="ml">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="id"
|
||||||
|
value="<?=$post['id']?>"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="new-comment-<?=$post['id']?>"
|
||||||
|
class="input"
|
||||||
|
autocomplete="off"
|
||||||
|
type="text"
|
||||||
|
name="text"
|
||||||
|
placeholder="<?=lang('action_new_comment_text')?>"
|
||||||
|
aria-label="<?=lang('action_new_comment_tip')?>"
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
Loading…
Reference in a new issue