start database (user and post), and initial barebones home page

This commit is contained in:
Freya Murphy 2024-03-29 22:29:56 -04:00
commit 944b6b0526
Signed by: freya
GPG key ID: 744AB800E383AE52
76 changed files with 3211 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/data

5
build/dbinit/Dockerfile Normal file
View 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
View 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
View 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

View 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 /

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

Binary file not shown.

95
conf/nginx/site.conf Normal file
View 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;
}
}

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

File diff suppressed because one or more lines are too long

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

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

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

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

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

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

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

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

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

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

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

View 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$;

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

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

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

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

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

38
web/core/loader.php Normal file
View 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
View 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
View 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
View 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
View 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();
?>

View 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';
?>

View 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';
?>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Binary file not shown.

BIN
web/public/font/sfpro.otf Normal file

Binary file not shown.

Binary file not shown.

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

View 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);
}
}
?>

View 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;
}
}
?>

View file

@ -0,0 +1,4 @@
<div id="error">
<h1><?=$title?></h1>
<span><?=$msg?></span>
</div>

View 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
View 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;
}
}
?>

View 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
View file

@ -0,0 +1,2 @@
<body>
</html>

74
web/views/header.php Normal file
View 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>

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

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

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

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

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