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