Compare commits

...

3 commits

109 changed files with 1712 additions and 333 deletions

View file

@ -1,4 +1,4 @@
FROM php:fpm-alpine
RUN apk add --no-cache postgresql-dev
RUN apk add --no-cache postgresql-dev runuser
RUN docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql
RUN docker-php-ext-install pdo pdo_pgsql

View file

@ -87,6 +87,7 @@ server {
}
location / {
root /opt/xssbook/web;
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;

View file

@ -1,22 +0,0 @@
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;

View file

@ -1,11 +0,0 @@
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

@ -5,7 +5,7 @@ services:
ports:
- '80:80'
volumes:
- ./web:/opt/xssbook
- ./src:/opt/xssbook:ro
- ./conf/nginx:/etc/nginx/conf.d:ro
depends_on:
- rest
@ -18,8 +18,9 @@ services:
env_file:
- ./conf/postgres/database.env
volumes:
- ./web:/opt/xssbook
- ./data/status:/status
- ./src:/opt/xssbook:ro
- ./data/status:/status:ro
- ./data/session:/var/lib/php/session
depends_on:
- db
@ -33,7 +34,7 @@ services:
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
volumes:
- './data/schemas:/var/lib/postgresql/data'
- ./db:/db:ro
- ./src/db:/db:ro
rest:
build: ./build/postgrest
@ -47,7 +48,7 @@ services:
env_file:
- ./conf/postgres/database.env
volumes:
- ./db:/db:ro
- ./src/db:/db:ro
- ./data/status:/status
depends_on:
- db

View file

@ -50,11 +50,12 @@ CREATE TABLE admin.user (
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
profile_bio TEXT DEFAULT ''::text NOT NULL,
created TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
modified TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
seen TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
deleted BOOLEAN DEFAULT FALSE NOT NULL
);
ALTER TABLE admin.user OWNER TO xssbook;
@ -75,10 +76,12 @@ CREATE SEQUENCE IF NOT EXISTS sys.post_id_seq
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
id INTEGER DEFAULT nextval('sys.post_id_seq'::regclass) NOT NULL,
user_id INTEGER NOT NULL,
content TEXT DEFAULT ''::text NOT NULL,
created TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
modified TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
deleted BOOLEAN DEFAULT FALSE NOT NULL
);
ALTER TABLE admin.post OWNER TO xssbook;
@ -97,11 +100,13 @@ CREATE SEQUENCE IF NOT EXISTS sys.comment_id_seq
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
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,
created TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
modified TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
deleted BOOLEAN DEFAULT FALSE NOT NULL
);
ALTER TABLE admin.comment OWNER TO xssbook;
@ -115,15 +120,28 @@ ALTER TABLE ONLY admin.comment
ALTER TABLE ONLY admin.comment
ADD CONSTRAINT comment_post_id_fkey FOREIGN KEY (post_id) REFERENCES admin.post (id) ON DELETE CASCADE;
CREATE SEQUENCE IF NOT EXISTS sys.like_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
CREATE TABLE admin.like (
id INTEGER DEFAULT nextval('sys.like_id_seq'::regclass) NOT NULL,
user_id INTEGER NOT NULL,
post_id INTEGER,
comment_id INTEGER,
date TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL
value BOOLEAN NOT NULL DEFAULT TRUE,
created TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
modified TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL
);
ALTER TABLE admin.like OWNER TO xssbook;
ALTER TABLE ONLY admin.like
ADD CONSTRAINT like_pkey PRIMARY KEY (id);
ALTER TABLE ONLY admin.like
ADD CONSTRAINT like_user_id_fkey FOREIGN KEY (user_id) REFERENCES admin.user (id) ON DELETE CASCADE;
@ -133,16 +151,32 @@ ALTER TABLE ONLY admin.like
ALTER TABLE ONLY admin.like
ADD CONSTRAINT like_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES admin.comment (id) ON DELETE CASCADE;
ALTER TABLE ONLY admin.like
ADD CONSTRAINT like_post_id_unique UNIQUE (user_id, post_id);
ALTER TABLE ONLY admin.like
ADD CONSTRAINT like_comment_id_unique UNIQUE (user_id, comment_id);
CREATE SEQUENCE IF NOT EXISTS sys.follow_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
CREATE TABLE admin.follow (
id INTEGER DEFAULT nextval('sys.follow_id_seq'::regclass) NOT NULL,
follower_id INTEGER NOT NULL,
followee_id INTEGER NOT NULL,
date TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL
value BOOLEAN NOT NULL DEFAULT TRUE,
created TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
modified 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);
ADD CONSTRAINT follow_pkey PRIMARY KEY (id);
ALTER TABLE ONLY admin.follow
ADD CONSTRAINT follow_follower_id FOREIGN KEY (follower_id) REFERENCES admin.user (id) ON DELETE CASCADE;
@ -150,16 +184,64 @@ ALTER TABLE ONLY admin.follow
ALTER TABLE ONLY admin.follow
ADD CONSTRAINT follow_followee_id FOREIGN KEY (followee_id) REFERENCES admin.user (id) ON DELETE CASCADE;
ALTER TABLE ONLY admin.follow
ADD CONSTRAINT follow_follower_unique UNIQUE (follower_id, followee_id);
CREATE SEQUENCE IF NOT EXISTS sys.media_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
CREATE TABLE admin.media (
name TEXT NOT NULL,
content BYTEA NOT NULL,
type TEXT NOT NULL
id INTEGER DEFAULT nextval('sys.media_id_seq'::regclass) NOT NULL,
name TEXT NOT NULL,
content BYTEA NOT NULL,
type TEXT NOT NULL,
created TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL,
modified TIMESTAMP WITH TIME ZONE DEFAULT clock_timestamp() NOT NULL
);
ALTER TABLE admin.media OWNER TO xssbook;
ALTER TABLE ONLY admin.media
ADD CONSTRAINT media_pkey PRIMARY KEY (name);
ADD CONSTRAINT media_pkey PRIMARY KEY (id);
ALTER TABLE ONLY admin.media
ADD CONSTRAINT media_name_unique UNIQUE (name);
CREATE SEQUENCE IF NOT EXISTS sys.user_media_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
CREATE TYPE admin.user_media_type AS ENUM (
'avatar', 'banner'
);
CREATE TABLE admin.user_media (
id INTEGER DEFAULT nextval('sys.user_media_id_seq'::regclass) NOT NULL,
media_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
type admin.user_media_type NOT NULL
);
ALTER TABLE admin.user_media OWNER TO xssbook;
ALTER TABLE ONLY admin.user_media
ADD CONSTRAINT user_media_pkey PRIMARY KEY (id);
ALTER TABLE ONLY admin.user_media
ADD CONSTRAINT user_media_media_id_fkey FOREIGN KEY (media_id) REFERENCES admin.media (id) ON DELETE CASCADE;
ALTER TABLE ONLY admin.user_media
ADD CONSTRAINT user_media_user_id_fkey FOREIGN KEY (user_id) REFERENCES admin.user (id) ON DELETE CASCADE;
ALTER TABLE ONLY admin.user_media
ADD CONSTRAINT user_media_type_unique UNIQUE (user_id, type);
ALTER DATABASE xssbook SET search_path = admin,public;
ALTER DATABASE xssbook SET bytea_output = 'hex';

View file

@ -4,10 +4,24 @@ CREATE VIEW api.comment AS
c.user_id,
c.post_id,
c.content,
c.date
c.created,
c.modified
FROM
admin.comment c
ORDER BY id ASC;
LEFT JOIN
admin.post p
ON
p.id = c.post_id
LEFT JOIN
admin.user u
ON
u.id = c.user_id
WHERE
c.deleted <> TRUE AND
p.deleted <> TRUE AND
u.deleted <> TRUE
ORDER BY
id ASC;
GRANT SELECT ON TABLE api.comment
TO rest_anon, rest_user;

View file

@ -11,9 +11,10 @@ BEGIN
PERFORM _api.raise_deny();
END IF;
DELETE FROM admin.comment
WHERE user_id = _user_id
AND id = OLD.id;
UPDATE admin.comment SET
deleted = TRUE,
modified = clock_timestamp()
WHERE id = OLD.id;
END
$BODY$;
@ -21,7 +22,7 @@ GRANT EXECUTE ON FUNCTION _api.comment_delete()
TO rest_user;
GRANT DELETE ON TABLE api.comment
TO rest_user;
GRANT DELETE ON TABLE admin.comment
GRANT UPDATE ON TABLE admin.comment
TO rest_user;
CREATE TRIGGER api_comment_delete_trgr

View file

@ -34,7 +34,9 @@ BEGIN
_user_id,
NEW.post_id,
NEW.content
);
)
RETURNING id
INTO NEW.id;
RETURN NEW;
END

View file

@ -27,8 +27,9 @@ BEGIN
END IF;
IF _changed THEN
UPDATE admin.comment
SET content = NEW.content
UPDATE admin.comment SET
content = NEW.content,
modified = clock_timestamp()
WHERE id = OLD.id;
END IF;

View file

@ -0,0 +1,16 @@
CREATE VIEW api.like AS
SELECT
l.id,
l.user_id,
l.post_id,
l.comment_id,
l.value,
l.created,
l.modified
FROM
admin.like l;
GRANT SELECT ON TABLE api.like
TO rest_anon, rest_user;
GRANT SELECT ON TABLE admin.like
TO rest_anon, rest_user;

View file

@ -0,0 +1,32 @@
CREATE FUNCTION _api.like_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;
UPDATE admin.like SET
value = FALSE,
modified = clock_timestamp()
WHERE id = OLD.id;
END
$BODY$;
GRANT EXECUTE ON FUNCTION _api.like_delete()
TO rest_user;
GRANT DELETE ON TABLE api.like
TO rest_user;
GRANT UPDATE ON TABLE admin.like
TO rest_user;
CREATE TRIGGER api_like_delete_trgr
INSTEAD OF DELETE
ON api.like
FOR EACH ROW
EXECUTE PROCEDURE _api.like_delete();

View file

@ -0,0 +1,51 @@
CREATE FUNCTION _api.like_insert()
RETURNS TRIGGER
LANGUAGE plpgsql VOLATILE
AS $BODY$
DECLARE
_user_id INTEGER;
BEGIN
_user_id = _api.get_user_id();
IF
NEW.post_id IS NULL AND
NEW.comment_id IS NULL
THEN
-- for now
PERFORM _api.raise_deny();
END IF;
NEW.value := COALESCE(NEW.value, TRUE);
INSERT INTO admin.like (
user_id,
post_id,
comment_id,
value
) VALUES (
_user_id,
NEW.post_id,
NEW.comment_id,
NEW.value
)
RETURNING id
INTO NEW.id;
RETURN NEW;
END
$BODY$;
GRANT EXECUTE ON FUNCTION _api.like_insert()
TO rest_user;
GRANT INSERT ON TABLE api.like
TO rest_user;
GRANT INSERT ON TABLE admin.like
TO rest_user;
GRANT UPDATE ON TABLE sys.like_id_seq
TO rest_user;
CREATE TRIGGER api_like_insert_trgr
INSTEAD OF INSERT
ON api.like
FOR EACH ROW
EXECUTE PROCEDURE _api.like_insert();

View file

@ -0,0 +1,44 @@
CREATE FUNCTION _api.like_update()
RETURNS TRIGGER
LANGUAGE plpgsql VOLATILE
AS $BODY$
DECLARE
_user_id INTEGER;
_changed BOOLEAN;
BEGIN
_user_id = _api.get_user_id();
_changed = FALSE;
IF OLD.user_id <> _user_id THEN
PERFORM _api.raise_deny();
END IF;
NEW.value = COALESCE(NEW.value, OLD.value);
IF NEW.value IS DISTINCT FROM OLD.value THEN
_changed = TRUE;
END IF;
IF _changed THEN
UPDATE admin.like SET
value = NEW.value,
modified = clock_timestamp()
WHERE id = OLD.id;
END IF;
RETURN NEW;
END
$BODY$;
GRANT EXECUTE ON FUNCTION _api.like_update()
TO rest_user;
GRANT UPDATE ON TABLE api.like
TO rest_user;
GRANT UPDATE ON TABLE admin.like
TO rest_user;
CREATE TRIGGER api_like_update_trgr
INSTEAD OF UPDATE
ON api.like
FOR EACH ROW
EXECUTE PROCEDURE _api.like_update();

View file

@ -8,6 +8,7 @@ DECLARE
_payload JSON;
_valid BOOLEAN;
_jwt_secret TEXT;
_user_id INTEGER;
BEGIN
SELECT jwt_secret INTO _jwt_secret
FROM sys.database_info
@ -28,7 +29,13 @@ BEGIN
RETURN NULL;
END IF;
RETURN _payload->>'user_id';
_user_id = _payload->>'user_id';
UPDATE admin.user
SET seen = clock_timestamp()
WHERE id = _user_id;
RETURN _user_id;
END
$BODY$;
@ -36,3 +43,5 @@ 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;
GRANT UPDATE ON TABLE admin.user
TO rest_anon, rest_user;

View file

@ -0,0 +1,36 @@
CREATE FUNCTION api.profile_avatar(
user_id INTEGER DEFAULT 0
)
RETURNS sys."*/*"
LANGUAGE plpgsql VOLATILE
AS $BODY$
DECLARE
_id INTEGER;
_mod INTEGER;
_name TEXT;
BEGIN
SELECT media_id INTO _id
FROM admin.user_media m
WHERE m.user_id = profile_avatar.user_id
AND type = 'avatar'::admin.user_media_type;
-- get default if not exists
IF NOT FOUND THEN
_mod = MOD(user_id, 24);
_name = 'default_avatar_' || _mod || '.png';
SELECT id INTO _id
FROM admin.media
WHERE name = _name;
END IF;
RETURN _api.serve_media(_id);
END
$BODY$;
GRANT EXECUTE ON FUNCTION api.profile_avatar(INTEGER)
TO rest_anon, rest_user;
GRANT SELECT ON TABLE admin.user_media
TO rest_anon, rest_user;
GRANT SELECT ON TABLE admin.media
TO rest_anon, rest_user;

View file

@ -0,0 +1,13 @@
CREATE FUNCTION api.profile_banner(
user_id INTEGER DEFAULT 0
)
RETURNS sys."*/*"
LANGUAGE plpgsql VOLATILE
AS $BODY$
BEGIN
PERFORM _api.raise_deny();
END
$BODY$;
GRANT EXECUTE ON FUNCTION api.profile_banner(INTEGER)
TO rest_anon, rest_user;

View file

@ -3,7 +3,8 @@ CREATE VIEW api.post AS
p.id,
p.user_id,
p.content,
p.date,
p.created,
p.modified,
COALESCE(c.cc, 0)
AS comment_count
FROM
@ -16,8 +17,19 @@ CREATE VIEW api.post AS
admin.comment c
GROUP BY
c.post_id
) c ON p.id = c.post_id
ORDER BY p.id DESC;
) c
ON
p.id = c.post_id
LEFT JOIN
admin.user u
ON
u.id = p.user_id
WHERE
p.deleted <> TRUE
AND
u.deleted <> TRUE
ORDER BY
p.id DESC;
GRANT SELECT ON TABLE api.post
TO rest_anon, rest_user;

View file

@ -11,9 +11,10 @@ BEGIN
PERFORM _api.raise_deny();
END IF;
DELETE FROM admin.post
WHERE user_id = _user_id
AND id = OLD.id;
UPDATE admin.post SET
deleted = TRUE,
modified = clock_timestamp()
WHERE id = OLD.id;
END
$BODY$;
@ -21,7 +22,7 @@ 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
GRANT UPDATE ON TABLE admin.post
TO rest_user;
CREATE TRIGGER api_post_delete_trgr

View file

@ -22,7 +22,9 @@ BEGIN
) VALUES (
_user_id,
NEW.content
);
)
RETURNING id
INTO NEW.id;
RETURN NEW;
END

View file

@ -27,8 +27,9 @@ BEGIN
END IF;
IF _changed THEN
UPDATE admin.post
SET content = NEW.content
UPDATE admin.post SET
content = NEW.content,
modified = clock_timestamp()
WHERE id = OLD.id;
END IF;

View file

@ -27,7 +27,6 @@ GRANT USAGE ON SCHEMA _api TO rest_anon, rest_user;
\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;
@ -41,6 +40,16 @@ GRANT USAGE ON SCHEMA _api TO rest_anon, rest_user;
\i /db/rest/comment/api_comment_update.sql;
\i /db/rest/comment/api_comment_delete.sql;
-- like
\i /db/rest/like/api_like.sql;
\i /db/rest/like/api_like_insert.sql;
\i /db/rest/like/api_like_update.sql;
\i /db/rest/like/api_like_delete.sql;
-- media
\i /db/rest/media/api_profile_avatar.sql;
\i /db/rest/media/api_profile_banner.sql;
-- login
\i /db/rest/login/_api_sign_jwt.sql;
\i /db/rest/login/_api_verify_jwt.sql;

View file

@ -9,13 +9,15 @@ CREATE VIEW api.user AS
u.middle_name,
u.email,
u.gender,
u.join_date,
u.birth_date,
u.profile_avatar,
u.profile_banner,
u.profile_bio
u.profile_bio,
u.created,
u.modified,
u.seen
FROM
admin.user u;
admin.user u
WHERE
u.deleted <> TRUE;
GRANT SELECT ON TABLE api.user
TO rest_anon, rest_user;

View file

@ -11,8 +11,10 @@ BEGIN
PERFORM _api.raise_deny();
END IF;
DELETE FROM admin.user
WHERE id = _user_id;
UPDATE admin.user SET
deleted = TRUE,
modified = clock_timestamp()
WHERE id = _user_id;
END
$BODY$;
@ -20,7 +22,7 @@ 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
GRANT UPDATE ON TABLE admin.user
TO rest_user;
CREATE TRIGGER api_user_delete_trgr

View file

@ -104,7 +104,9 @@ BEGIN
NEW.gender,
NEW.birth_date,
NEW.profile_bio
);
)
RETURNING id
INTO NEW.id;
NEW.password := NULL;

View file

@ -145,7 +145,8 @@ BEGIN
email = NEW.email,
gender = NEW.gender,
birth_date = NEW.birth_date,
profile_bio = NEW.profile_bio
profile_bio = NEW.profile_bio,
modified = clock_timestamp()
WHERE id = OLD.id;
END IF;

View file

@ -0,0 +1,22 @@
CREATE FUNCTION _api.get_user_id()
RETURNS INTEGER
LANGUAGE plpgsql VOLATILE
AS $BODY$
DECLARE
_user_id INTEGER;
BEGIN
_user_id = CURRENT_SETTING(
'request.jwt.claims',
TRUE
)::JSON->>'user_id';
UPDATE admin.user
SET seen = clock_timestamp()
WHERE id = _user_id;
RETURN _user_id;
END
$BODY$;
GRANT UPDATE ON TABLE admin.user
TO rest_anon, rest_user;

View file

@ -1,5 +1,5 @@
CREATE FUNCTION _api.serve_media(
_name TEXT
_media_id INTEGER
)
RETURNS sys."*/*"
LANGUAGE plpgsql VOLATILE
@ -15,13 +15,13 @@ BEGIN
'{"Cache-Control": "max-age=259200"}]'
, m.type, m.name)
FROM admin.media m
WHERE m.name = _name INTO _headers;
WHERE m.id = _media_id INTO _headers;
PERFORM SET_CONFIG('response.headers', _headers, true);
SELECT m.content
FROM admin.media m
WHERE m.name = _name
WHERE m.id = _media_id
INTO _data;
IF FOUND THEN
@ -35,7 +35,7 @@ BEGIN
END
$BODY$;
GRANT EXECUTE ON FUNCTION _api.serve_media(TEXT)
GRANT EXECUTE ON FUNCTION _api.serve_media(INTEGER)
TO rest_anon, rest_user;
GRANT SELECT ON TABLE admin.media
TO rest_anon, rest_user;

45
src/public/css/auth.css Normal file
View file

@ -0,0 +1,45 @@
#main-content {
padding-top: 20rem;
padding-bottom: 5rem;
display: flex;
flex-direction: row;
justify-content: center;
}
.branding {
max-width: 30rem;
margin-right: 5rem;
}
.branding h1 {
color: var(--blue);
font-family: facebook;
font-size: 3.5rem;
}
.branding span {
font-size: 1.5rem;
}
.form {
display: flex;
flex-direction: column;
width: 30rem;
}
@media(max-width: 1200px) {
#main-content {
flex-direction: column;
align-items: center;
width: 100%;
padding: 10rem 0;
}
.branding {
margin: 0;
}
.form {
margin-top: 4rem;
}
}

View file

@ -1,17 +1,44 @@
: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;
--white: #E4E6EB;
--blue: #1778f2;
--red: #f02849;
--green: #30ab5a;
--blue-alt: #1D85FC;
--green-alt: #39B463;
--font: Helvetica;
}
:root {
--base :#18191A;
--surface0: #242526;
--surface1: #3A3B3C;
--surface2: #4E4F50;
--text: #E4E6EB;
--subtext: #B0B3B8;
--btntext: #E4E6EB;
}
/**
:root {
--base: #f0f2f5;
--surface0: #ffffff;
--surface1: #f0f2f5;
--surface2: #dadde1;
--text: #000000;
--subtext: #1d2129;
--btntext: #606770;
}
*/
@font-face {
font-family: 'Helvetica Neue';
font-style: normal;
src: url("/public/font/helvetica-neue.otf") format("opentype");
font-display: swap;
}
@font-face {
@ -33,14 +60,8 @@
font-display: swap;
}
@font-face {
font-family: sfprobold;
src: url("/public/font/sfprobold.otf") format("opentype");
font-display: swap;
}
body {
background-color: var(--secondary);
background-color: var(--surface0);
width: 100%;
height: 100%;
margin: 0;
@ -48,93 +69,132 @@ body {
display: flex;
flex-direction: column;
color: var(--text);
font-family: sfpro;
font-family: var(--font);
}
#main-content {
background-color: var(--base);
padding-top: 1rem;
}
header {
top: 0;
position: sticky;
height: 3.5rem;
background-color: var(--primary);
background-color: var(--surface0);
display: flex;
flex-direction: row;
align-items: center;
padding: 0 1rem;
border-bottom: 1px solid var(--surface1);
}
header .logo {
font-family: facebook;
color: var(--logo);
color: var(--blue);
font-size: 2.25rem;
height: 100%;
line-height: 2rem;
margin-top: .75rem;
}
footer {
text-align: center;
padding: 1rem;
color: var(--subtext);
font-size: .75rem;
}
hr {
color: var(--surface2);
background-color: var(--surface2);
width: 100%;
height: 1px;
border: none;
}
a, button, input, div {
box-sizing: border-box;
}
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;
.btn {
color: var(--btntext);
display: flex;
align-items: center;
align-content: center;
flex-direction: row;
font-weight: bold;
font-size: 1rem;
text-decoration: none;
padding: .4rem .6rem;
border-radius: .25rem;
background-color: transparent;
width: fit-content;
}
input:focus {
.btn:hover {
background-color: var(--surface1);
}
.btn-alt {
background-color: var(--surface1);
}
.btn-alt:hover {
background-color: var(--surface2);
}
.btn-wide {
width: auto;
flex-grow: 1;
justify-content: center;
}
.btn-line:hover {
background-color: inherit;
text-decoration: underline;
}
.btn-blue {
color: var(--blue-alt);
}
input.btn:focus {
border: none;
outline: none;
}
.header-entry {
display: flex;
flex-direction: row;
text-decoration: none;
align-items: center;
color: var(--text);
.btn-submit {
color: var(--white);
background-color: var(--blue);
flex-grow: 1;
padding: .5rem;
}
.nav .header-entry {
height: 100%;
.btn-submit:hover {
background-color: var(--blue-alt);
}
.nav-center .header-entry:hover {
background-color: var(--hover);
.btn-success {
color: var(--white);
background-color: var(--green);
flex-grow: 1;
padding: .5rem;
}
.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;
.btn-success:hover {
background-color: var(--green-alt);
}
.nav,
@ -167,12 +227,13 @@ input:focus {
}
@media (min-width: 800px) {
.header-entry > span {
.nav-center .btn > span {
display: none;
}
.nav-center .header-entry {
.nav-center .btn {
padding: 0 3rem;
height: 100%;
}
#action-hamburger {
@ -187,7 +248,7 @@ input:focus {
flex-direction: column;
top: 100%;
height: fit-content;
background-color: var(--primary);
background-color: var(--surface0);
width: 100%;
left: 0;
transform: translateX(0%);
@ -198,18 +259,18 @@ input:focus {
display: inherit !important;
}
.nav-center .header-entry {
.nav-center .btn {
width: calc(100% - 3rem);
padding: .75rem 0rem !important;
padding-left: 3rem !important;
justify-content: flex-start;
}
.nav-center .header-entry > span {
.nav-center .btn > span {
margin-left: 1rem;
}
.nav-center .header-entry.active {
.nav-center .btn.active {
border-bottom: none;
}
}
@ -218,11 +279,6 @@ input:focus {
display: block;
}
.nav-right .header-entry {
padding: 0;
padding-left: 1.5rem;
}
@keyframes shimmer {
to {
background-position-x: 0%;
@ -242,40 +298,32 @@ input:focus {
}
.image-loading {
background: linear-gradient(-45deg, var(--secondary) 0%, var(--primary) 25%, var(--secondary) 50%);
background: linear-gradient(-45deg, var(--surface0) 0%, var(--base) 25%, var(--surface0) 50%);
background-size: 500%;
background-position-x: 150%;
animation: shimmer 1s linear infinite;
}
.card {
background-color: var(--primary);
background-color: var(--surface0);
border-radius: .5rem;
padding: 1rem;
}
.card p {
margin-bottom: 0;
}
.card form {
flex-grow: 1;
}
.card .sub-card {
background-color: var(--secondary);
background-color: var(--surface1);
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;
@ -314,8 +362,12 @@ input:focus {
margin-bottom: .75rem;
}
.pb {
padding-bottom: 1rem;
}
.dim {
color: var(--medium);
color: var(--subtext);
}
.modal-container {
@ -329,7 +381,7 @@ input:focus {
}
.modal {
background-color: var(--primary);
background-color: var(--surface0);
position: absolute;
top: 50%;
left: 50%;
@ -372,18 +424,14 @@ input:focus {
}
.modal-header {
font-family: sfprobold;
font-weight: bold;
position: relative;
border-bottom: 1px solid var(--light);
text-align: center;
margin: 0 1rem;
border-radius: .5rem .5rem 0 0;
border-bottom: 1px solid var(--surface1);
display: flex;
justify-content: center;
align-items: center;
padding-left: 1rem;
cursor: grab;
padding: 1rem;
padding: 1rem 0;
}
.modal-content {
@ -402,9 +450,9 @@ input:focus {
.float-right {
position: absolute;
transform: translate(0%, -50%);
top: 45%;
right: 0;
top: 50%;
left: 100%;
transform: translate(-125%, -50%);
}
.mi {
@ -421,17 +469,6 @@ input:focus {
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);
}
#toast-container {
position: fixed;
top: 4rem;
@ -442,20 +479,92 @@ button[type="submit"]:hover {
}
.toast {
color: var(--white);
padding: .75rem;
margin: .5rem;
border-radius: .5rem;
min-width: 15rem;
font-family: sfpro;
animation: fadeIn .1s, slideIn .25s linear;
display: flex;
justify-content: space-between;
}
.toast.error {
background-color: var(--error);
background-color: var(--red);
}
.toast.success {
background-color: var(--success);
background-color: var(--green);
}
form input:not(.btn) {
display: block;
font-size: 1.1rem;
outline: 2px solid var(--surface2);
border-radius: .25rem;
padding: .75rem;
}
form input:not(.btn):focus {
outline-color: var(--blue);
}
form .rel label:not(.static) {
position: absolute;
top: 50%;
transform: translate(.5rem, -40%);
color: var(--subtext);
transition: all 0.2s ease-out;
pointer-events: none;
width: fit-content;
font-size: 1.1rem;
}
input:focus + label:not(.static),
input:not(:placeholder-shown) + label:not(.static) {
color: var(--text);
top: 0;
padding: .5rem;
padding-top: 0;
font-size: .75rem;
transform: translate(.5rem, -25%);
background-color: var(--surface0);
}
.rel {
position: relative;
}
.rel input {
width: 100%;
flex-grow: 1;
}
input[type=radio] {
padding: 3rem !important;
}
.radio {
display: flex;
flex-direction: row;
width: auto;
flex-grow: 1;
}
.radio label {
border: 1px solid var(--surface2);
height: fit-content;
width: 100%;
padding: .75rem;
border-radius: .25rem;
cursor: pointer;
}
.radio input {
position: absolute;
top: 50%;
left: 100%;
transform: translate(-250%, -70%);
width: fit-content;
outline: none !important;
}

View file

@ -1,16 +1,16 @@
#error {
#main-content {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 10rem;
padding: 10rem 0;
}
#error h1 {
color: var(--logo);
#main-content h1 {
color: var(--blue);
font-family: Facebook;
font-size: 5rem;
}
#error span {
#main-content span {
font-size: 2rem;
}

View file

@ -1,9 +1,7 @@
#main-content {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin-top: 1rem;
}
.card {
@ -15,7 +13,6 @@
border: none;
resize: none;
outline: none;
font-family: sfpro;
font-size: 1.5rem;
margin: 1rem 0;
width: 100%;
@ -23,4 +20,5 @@
flex-grow: 1;
background-color: transparent;
color: var(--text);
font-family: var(--font);
}

69
src/public/css/people.css Normal file
View file

@ -0,0 +1,69 @@
.title {
margin-top: 2rem;
margin-left: 3rem;
font-size: 3rem;
margin-bottom: 0;
}
.desc {
margin-left: 3rem;
}
#people-container {
margin-left: auto;
margin-right: auto;
padding: 1rem 2rem;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-row-gap: 2rem;
grid-auto-rows: 1fr;
flex-direction: column;
align-items: center;
max-width: 90rem;
}
.profile {
margin: 1rem;
text-decoration: none;
height: 100%;
}
.profile:hover {
outline: 1px solid var(--blue);
}
.profile strong {
font-size: 2rem;
}
.profile .pfp, .profile .pfp img {
padding: none;
margin: none;
height: 6rem;
border-radius: .3rem;
}
@media(max-width: 1400px) {
#people-container {
max-width: 90rem;
grid-template-columns: 1fr 1fr;
}
}
@media(max-width: 1000px) {
#people-container {
max-width: 50rem;
grid-template-columns: 1fr;
}
}
td:nth-child(1) {
font-weight: bold;
color: var(--subtext);
padding-right: 1rem;
}
td:nth-child(2) {
color: var(--text);
}

View file

@ -1,16 +1,13 @@
.post hr {
color: var(--light);
margin: 0;
}
.post hr:nth-of-type(1) {
margin-top: .5rem;
}
.action-load-comments {
margin-left: 4rem;
}
#action-load-posts {
width: 100%;
justify-content: center;
}
.post {
padding-bottom: 0;
}

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

View file

@ -12,7 +12,8 @@ var $$ = (selector) => {
'on',
'click',
'submit',
'each'
'each',
'error'
];
let vtable = {};
@ -58,11 +59,19 @@ var $$ = (selector) => {
/// ajax error handle
///
var errorToast = (xhr) => {
var errorToastAjax = (xhr) => {
let data = xhr.responseJSON;
let msg = data.message;
let detail = data.details;
let hint = data.hint;
let msg, detail, hint;
if (data) {
msg = data.message;
detail = data.details;
hint = data.hint;
} else {
msg = 'api_unknown';
}
let query = '?msg=' + msg;
if (detail) {
@ -77,6 +86,13 @@ var errorToast = (xhr) => {
})
}
var errorToast = (msg) => {
let url = '/template/toast?msg=' + msg;
$.get(url, function (data) {
$('#toast-container').prepend(data);
})
}
$$('.action-close-toast').on('click', function() {
$(this).parent().remove();
});
@ -94,12 +110,14 @@ $$('.action-close-toast').each(function() {
$.ajaxSetup({
headers: (() => {
let ajaxHeaders = {};
ajaxHeaders['Content-Type'] = 'application/json';
let ajaxHeaders = {
'Content-Type': 'application/json',
'Prefer': 'return=representation'
};
if (jwtStr) {
ajaxHeaders['Authorization'] = 'Bearer ' + jwtStr
}
return ajaxHeaders;
})(),
error: errorToast
error: errorToastAjax
})

View file

@ -70,12 +70,59 @@ $$('.action-new-comment-form').on('submit', function(e) {
let input = me.find('.action-new-comment');
let content = input.val();
let post_id = input.attr('postId');
const getComment = function(data) {
if (data) {
let container = me.closest('.post').find('.comments');
container.prepend(data);
}
input.val('');
}
const onComment = function(data) {
let id = data[0].id;
$.get({
url: '/_util/post/comment?id=' + id,
success: getComment
});
}
$.ajax({
url: '/api/comment',
method: 'POST',
data: JSON.stringify({ post_id, content }),
success: function(_data) {
window.location.reload();
},
success: onComment
});
});
$$('.action-like').on('click', function() {
let me = $(this);
let liked = me.hasClass('btn-blue');
let like_id = me.attr('likeId');
let post_id = me.attr('postId');
const onPatch = () => {
me.toggleClass('btn-blue');
}
const onPost = (data) => {
me.attr('likeId', data[0].id + '');
me.toggleClass('btn-blue');
}
if (like_id) {
$.ajax({
url: '/api/like?id=eq.' + like_id,
method: 'PATCH',
data: JSON.stringify({ value: !liked }),
success: onPatch
});
} else {
$.ajax({
url: '/api/like',
method: 'POST',
data: JSON.stringify({ post_id, value: true }),
success: onPost,
});
}
});

View file

@ -14,7 +14,7 @@ class _index_controller extends Controller {
if ($this->main->session) {
$this->redirect('/home');
} else {
$this->redirect('/login');
$this->redirect('/auth/login');
}
}

View file

@ -21,6 +21,39 @@ class Post_controller extends Controller {
$this->view('template/posts');
}
public function post(): void {
$pid = $this->request_model->get_int('id', 0);
$post = $this->db
->select('p.*, l.id as like_id')
->from('api.post p')
->join('api.like l', 'p.id = l.post_id AND l.user_id')
->eq($pid)
->where('p.id')
->eq($pid)
->row();
if (!$post) {
return;
}
$users = $this->cache_model->get_users([$post]);
$uid = $post['user_id'];
if (!array_key_exists($uid, $users)) {
return;
}
$user = $users[$uid];
$data = array(
'user' => $user,
'page_size' => $this->page_size,
'post' => $post
);
$this->view('template/post', $data);
}
/**
* @return array<string,mixed>
*/
@ -30,28 +63,23 @@ class Post_controller extends Controller {
$offset = $page * $this->page_size;
$user = $this->main->user();
$uid = isset($user) ? $user['id'] : NULL;
$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('api.post p');
if ($user) {
$query = $query->join('admin.like l', 'p.id = l.post_id AND l.user_id')
->eq($user['id']);
}
$query = $this->db
->select('p.*, l.id as like_id')
->from('api.post p')
->join('api.like l', 'p.id = l.post_id AND l.user_id')
->eq($uid);
if ($max) {
$query = $query
->where('id')->le($max);
->where('p.id')->le($max);
}
$posts = $query
->order_by('p.id', 'DESC')
->limit($this->page_size)
->offset($offset)
->rows();
@ -73,7 +101,6 @@ class Post_controller extends Controller {
->from('api.post p')
->row()['pc'];
return array(
'loaded' => count($posts),
'total' => $pc,
@ -82,6 +109,36 @@ class Post_controller extends Controller {
);
}
public function comment(): void {
$cid = $this->request_model->get_int('id', 0);
$comment = $this->db
->select('*')
->from('api.comment')
->where('id')
->eq($cid)
->row();
if (!$comment) {
return;
}
$users = $this->cache_model->get_users([$comment]);
$uid = $comment['user_id'];
if (!array_key_exists($uid, $users)) {
return;
}
$user = $users[$uid];
$data = array(
'user' => $user,
'comment' => $comment
);
$this->view('template/comment', $data);
}
/**
* @return array<string,mixed>
*/
@ -105,6 +162,7 @@ class Post_controller extends Controller {
}
$comments = $query
->order_by('id', 'ASC')
->limit($this->page_size)
->offset($offset)
->rows();
@ -112,6 +170,17 @@ class Post_controller extends Controller {
$users = $this->cache_model->get_users($comments);
$max = 0;
// only add this hr when not logged in
// otherwise its added automatically by
// the like and comment buttons
if (
count($comments) &&
$page == 0 &&
$this->main->session === NULL
) {
echo '<hr>';
}
foreach ($comments as $comment) {
$max = max($max, $comment['id']);
$data = array();

View file

@ -0,0 +1,56 @@
<?php /* Copyright (c) 2024 Freya Murphy */
class Auth_controller extends Controller {
// the home model
private $auth_model;
// the post controller
protected $post_controller;
function __construct($load) {
parent::__construct($load);
$this->auth_model = $this->load->model('apps/auth');
}
public function index(): void {
if ($this->main->session) {
$this->redirect('/home');
} else {
$this->redirect('/auth/login');
}
}
public function login(): void {
if ($this->main->session) {
$this->redirect('/home');
}
parent::index();
$data = $this->auth_model->get_data();
$this->view('header_empty', $data);
$this->view('apps/auth/login', $data);
$this->view('footer', $data);
}
public function logout(): void {
if ($this->main->session) {
$_SESSION['jwt'] = NULL;
}
$this->redirect('/auth/login');
}
public function update(): void {
if (!$this->is_ajax()) {
$this->error(400);
}
if (!isset($_POST['key']) || !isset($_POST['value'])) {
$this->error(400);
}
$key = $_POST['key'];
$value = $_POST['value'];
$_SESSION[$key] = $value;
}
}
?>

View file

@ -8,11 +8,12 @@ class Error_controller extends Controller {
$this->error_model = $this->load->model('apps/error');
}
public function index() {
public function index(): void {
parent::index();
$data = $this->error_model->get_data();
$this->view('header', $data);
$this->view('apps/error/main', $data);
$this->view('footer', $data);
}
}

View file

@ -18,6 +18,7 @@ class Home_controller extends Controller {
$data = $this->home_model->get_data();
$this->view('header', $data);
$this->view('apps/home/main', $data);
$this->view('footer', $data);
}
}

View file

@ -0,0 +1,41 @@
<?php /* Copyright (c) 2024 Freya Murphy */
class People_controller extends Controller {
// the people model
private $people_model;
// format model
protected $format_model;
function __construct($load) {
parent::__construct($load);
$this->people_model = $this->load->model('apps/people');
$this->format_model = $this->load->model('format');
}
public function index(): void {
parent::index();
$data = $this->people_model->get_data();
$this->view('header', $data);
$this->view('apps/people/main', $data);
$this->view('footer', $data);
}
/**
* @return array<string,mixed>
*/
public function people(): array {
$data = $this->people_model->get_users();
$this->view('apps/people/people', $data);
$max = 0;
foreach ($data['users'] as $user) {
$max = max($max, $user['id']);
}
return $data;
}
}
?>

View file

@ -20,6 +20,14 @@ class Modal_controller extends Controller {
public function new_post(): void {
$this->modal('new_post');
}
public function register(): void {
$this->load->app_lang(
$this->main->info['lang'],
'auth'
);
$this->modal('register');
}
}
?>

View file

@ -0,0 +1,13 @@
<?php /* Copyright (c) 2024 Freya Murphy */
class Auth_model extends Model {
function __construct($load) {
parent::__construct($load);
}
public function get_data(): array {
$data = parent::get_data();
$data['title'] = lang('login');
return $data;
}
}

View file

@ -7,22 +7,18 @@ class Error_model extends Model {
private function get_msg(&$data) {
if (!array_key_exists('code', $_GET)) {
http_response_code(500);
$data['msg'] = lang('error');
$data['title'] = '500';
} else {
$code = $_GET['code'];
http_response_code($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;
$msg = lang('error_' . $code, FALSE);
if (!$msg) {
$msg = lang('error');
}
$data['msg'] = $msg;
}
}

View file

@ -0,0 +1,88 @@
<?php /* Copyright (c) 2024 Freya Murphy */
class People_model extends Model {
private $request_model;
function __construct($load) {
parent::__construct($load);
$this->request_model = $this->load->model('request');
}
private function get_filted_query($select) {
$filter_username = $this->request_model->get_str('filter_username', FALSE);
$filter_fisrt_name = $this->request_model->get_str('filter_first_name', FALSE);
$filter_last_name = $this->request_model->get_str('filter_last_name', FALSE);
$filter_email = $this->request_model->get_str('filter_email', FALSE);
$max = $this->request_model->get_int('max', FALSE);
$query = $this->db
->select($select)
->from('api.user u');
if ($filter_username) {
$query = $query
->where('u.username')
->like('%' . $filter_username . '%');
}
if ($filter_fisrt_name) {
$query = $query
->where('u.first_name')
->like('%'. $filter_fisrt_name . '%');
}
if ($filter_last_name) {
$query = $query
->where('u.last_name')
->like('%' . $filter_last_name . '%');
}
if ($filter_email) {
$query = $query
->where('u.email')
->like('%' . $filter_email . '%');
}
if ($max) {
$query = $query
->where('u.id')
->le($max);
}
return $query;
}
public function get_users(): array {
$page = $this->request_model->get_int('page', 0);
$page_size = 24;
$offset = $page_size * $page;
$users = $this->get_filted_query('*')
->order_by('u.id', 'DESC')
->offset($offset)
->limit($page_size)
->rows();
$count = $this->get_filted_query('COUNT(u.id) AS count')
->row()['count'];
$max = 0;
foreach ($users as $user) {
$max = max($max, $user['id']);
}
return array(
'users' => $users,
'count' => $count,
'page_size' => $page_size,
'max_id' => $max
);
}
public function get_data(): array {
$data = parent::get_data();
$data['title'] = lang('title');
return $data;
}
}

View file

@ -39,7 +39,8 @@ class Format_model extends Model {
* @returns the formatted date
*/
public function date($date) {
return $date;
$date=date_create($date);
return date_format($date, "Y-m-d H:i");
}
}

View file

@ -53,7 +53,7 @@ class Main_model {
*/
private function asset_stamp($path): int {
$root = $GLOBALS['webroot'];
$path = $root . '/public/' . $path;
$path = $root . '/../public/' . $path;
return filemtime($path);
}

View file

@ -0,0 +1,86 @@
<?php /* Copyright (c) 2024 Freya Murphy */ ?>
<?php /* vi: syntax=php */ ?>
<div id="main-content">
<div class="branding col">
<h1>xssbook</h1>
<span><?=lang('login_branding')?></span>
</div>
<div class="form card col">
<form id="action-login" class="col" action="">
<div class="rel mb">
<input
type="text"
name="username"
id="login-username"
placeholder=" "
>
<label for="username">
<?=lang('ph_username')?>
</label>
</div>
<div class="rel mb">
<input
type="password"
name="password"
id="login-password"
placeholder=" "
>
<label for="password">
<?=lang('ph_password')?>
</label>
</div>
<?=ilang('action_login',
class: 'btn btn-submit btn-wide',
button: TRUE,
attrs: array('type' => 'submit')
)?>
<?=ilang('action_forgot_passwd',
class: 'btn btn-line btn-blue btn-wide mt'
)?>
</form>
<hr>
<?=ilang('action_create_account',
id: 'action-register',
class: 'btn btn-success btn-wide',
button: TRUE,
attrs: array('type' => 'submit')
)?>
</div>
<script>
var onLogin = function(data) {
let jwt = data.token;
$.ajax({
url: '/auth/update',
method: 'POST',
data: JSON.stringify({
key: 'jwt',
value: jwt
}),
success: function (_) {
window.location = '/home';
}
})
};
$('#action-login').on('submit', function(e) {
e.preventDefault();
let username = $('#login-username').val();
let password = $('#login-password').val();
$.ajax({
url: '/api/rpc/login',
method: 'POST',
data: JSON.stringify({ username, password }),
success: onLogin
});
});
$('#action-register').on('click', function() {
$.get( "/modal/register", function (data) {
$(document.body).append(data);
});
})
</script>
</div>

View file

@ -1,6 +1,6 @@
<?php /* Copyright (c) 2024 Freya Murphy */ ?>
<?php /* vi: syntax=php */ ?>
<div id="error">
<div id="main-content">
<h1><?=$title?></h1>
<span><?=$msg?></span>
</div>

View file

@ -7,7 +7,7 @@
<?php $this->view('template/pfp', array('user' => $self))?>
<a
id="action-new-post"
class="input btn-fake ml"
class="btn btn-alt btn-wide ml"
autocomplete="off"
aria-label="<?=lang('action_new_post_tip')?>"
>

View file

@ -0,0 +1,35 @@
<?php /* Copyright (c) 2024 Freya Murphy */ ?>
<?php /* vi: syntax=php */ ?>
<a
class="card profile"
href="/profile?id=<?=$user['id']?>"
>
<div class="row">
<?php $this->view('template/pfp', array('user' => $user, 'link' => FALSE)); ?>
<div class="col ml">
<strong class=""><?=$this->format_model->name($user)?></strong>
<span class="dim"><?=lang('joined') . ' ' . $this->format_model->date($user['created'])?></span>
<span class="dim"><?=lang('seen') . ' ' . $this->format_model->date($user['seen'])?></span>
</div>
</div>
<hr>
<table>
<tr>
<td><?=lang('tbl_username')?></td>
<td><?=$user['username']?></td>
<tr>
<tr>
<td><?=lang('tbl_email')?></td>
<td><?=$user['email']?></td>
<tr>
<tr>
<td><?=lang('tbl_gender')?></td>
<td><?=$user['gender']?></td>
<tr>
<tr>
<td><?=lang('tbl_uid')?></td>
<td><?=$user['id']?></td>
<tr>
</table>
</a>
<?

View file

@ -0,0 +1,67 @@
<?php /* Copyright (c) 2024 Freya Murphy */ ?>
<?php /* vi: syntax=php */ ?>
<div id="main-content" class="col">
<h1 class="title"><?=lang('title')?></h1>
<h3 class="desc"><?=lang('desc')?></h3>
<hr>
<div id="people-container" class="col">
<?php
$pdata = $this->people();
?>
</div>
<?php
$loaded = count($pdata['users']);
$page_size = $pdata['page_size'];
$total = $pdata['count'];
$max = $pdata['max_id'];
?>
<?php if ($loaded >= $page_size && $page_size < $total): ?>
<?=ilang('action_load_users',
id: 'action-load-users',
class: 'btn btn-line btn-wide mt mb',
attrs: array(
'loaded' => $loaded,
'pageSize' => $page_size,
'userCount' => $total,
'userMax' => $max
)
)?>
<script>
var urlParams = new URLSearchParams(window.location.search).toString();
$('#action-load-users').on('click', function() {
let me = $(this);
let page = me.attr('page');
if (!page) {
page = '1';
}
let newPage = Number(page) + 1;
me.attr('page', newPage + '');
let loaded = Number(me.attr('loaded'));
let pageSize = Number(me.attr('pageSize'));
let userCount = Number(me.attr('userCount'));
let userMax = Number(me.attr('userMax'));
let url = '/people/people?page=' + page + '&max=' + userMax + '&' + urlParams;
$.get(url, function (data) {
if (data === '') {
me.remove();
return;
}
let container = $('#people-container');
container.append(data);
loaded += pageSize;
if (loaded >= userCount) {
me.remove();
} else {
me.attr('loaded', loaded + '');
}
});
});
</script>
<?php endif ?>
</div>

View file

@ -0,0 +1,7 @@
<?php /* Copyright (c) 2024 Freya Murphy */ ?>
<?php /* vi: syntax=php */ ?>
<?php
foreach($users as $user) {
$this->view('apps/people/card', array('user' => $user));
}
?>

View file

@ -1,4 +1,8 @@
<?php /* Copyright (c) 2024 Freya Murphy */ ?>
<?php /* vi: syntax=php */ ?>
<footer>
Freya Murphy © 2023 | <a href="https://freya.cat">freya.cat</a>
</footer>
<body>
</html>

View file

@ -2,28 +2,8 @@
<?php /* vi: syntax=php */ ?>
<?php
$self = $this->main->user();
$this->view('header_empty', $data);
?>
<!DOCTYPE html>
<html>
<head>
<script>
<?php if ($this->main->session): ?>
var jwtStr = <?=json_encode($this->main->session['jwt'])?>;
<?php else: ?>
var jwtStr = null;
<?php endif; ?>
</script>
<?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>
@ -31,7 +11,7 @@
<div class="nav-center" :class="{hidden: !visible}">
<a
id="action-home"
class="header-entry btn btn-hover btn-action btn-blue"
class="btn"
href="/home"
title="<?=lang('action_home_tip')?>"
>
@ -40,7 +20,7 @@
</a>
<a
id="action-people"
class="header-entry btn btn-hover btn-action btn-blue"
class="btn"
href="/people"
title="<?=lang('action_people_tip')?>"
>
@ -49,7 +29,7 @@
</a>
<a
id="action-chat"
class="header-entry btn btn-hover btn-action btn-blue"
class="btn"
href="/chat"
title="<?=lang('action_chat_tip')?>"
>
@ -70,7 +50,7 @@
'class' => 'pfp-sm ml',
)); ?>
<?php else: ?>
<?=ilang('action_login', class: 'btn btn-action', href: '/auth/login')?>
<?=ilang('action_login', class: 'btn', href: '/auth/login')?>
<?php endif; ?>
</div>
<script>
@ -80,5 +60,3 @@
});
</script>
</header>
<div id="toast-container">
</div>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html>
<head>
<script>
<?php if ($this->main->session): ?>
var jwtStr = <?=json_encode($this->main->session['jwt'])?>;
<?php else: ?>
var jwtStr = null;
<?php endif; ?>
</script>
<?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>
<div id="toast-container">
</div>

View file

@ -22,7 +22,7 @@
<div class="modal-footer">
<?=ilang('action_submit',
id: 'new-post-submit',
class: 'btn-action',
class: 'btn btn-wide btn-submit',
attrs: array('type' => 'submit'),
button: TRUE
)?>
@ -32,14 +32,28 @@
$('#new-post-form').submit(function(e) {
e.preventDefault();
let content = $('#new-post-content').val();
let me = $(this);
const getPost = function(data) {
if (data) {
$('#post-container').prepend(data);
}
me.closest('.modal-container').remove();
}
const onPost = function(data) {
let id = data[0].id;
$.get({
url: '/_util/post/post?id=' + id,
success: getPost
});
}
$.ajax({
url: '/api/post',
method: 'POST',
data: JSON.stringify({ content }),
success: function(data) {
window.location.reload();
},
success: onPost
});
});
</script>

View file

@ -0,0 +1,173 @@
<?php /* Copyright (c) 2024 Freya Murphy */ ?>
<?php /* vi: syntax=php */ ?>
<form id="register-form">
<div class="modal-content register-modal col">
<label class="static">
<?=lang('ph_basic_info')?>
</label>
<div class="row mt">
<div class="rel btn-wide">
<input
type="text"
name="first_name"
id="register-first-name"
placeholder=" "
>
<label for="first_name">
<?=lang('ph_first_name')?>
</label>
</div>
<div class="rel ml btn-wide">
<input
type="text"
name="last_name"
id="register-last-name"
placeholder=" "
>
<label for="last_name">
<?=lang('ph_last_name')?>
</label>
</div>
</div>
<div class="rel mt">
<input
type="text"
name="username"
id="register-username"
placeholder=" "
>
<label for="username">
<?=lang('ph_username')?>
</label>
</div>
<div class="rel mt">
<input
type="password"
name="password"
id="register-password"
placeholder=" "
>
<label for="password">
<?=lang('ph_password')?>
</label>
</div>
<div class="rel mt">
<input
type="text"
name="email"
id="register-email"
placeholder=" "
>
<label for="email">
<?=lang('ph_email')?>
</label>
</div>
<label for="birth_date" class="mt static">
<?=lang('ph_birth_date')?>
</label>
<input
class="mt"
type="date"
name="birth_date"
id="register-birth-date"
>
<label for="gender" class="mt static">
<?=lang('ph_gender')?>
</label>
<div class="row mt" data-type="radio" data-name="gender-wrapper">
<div class="rel radio mr">
<input
type="radio"
id="register-gender-male"
name="gender"
value="male"
>
<label
for="register-gender-male"
class="static"
>
<?=lang('ph_gender_male')?>
</label>
</div>
<div class="rel radio mr">
<input
type="radio"
id="register-gender-female"
name="gender"
value="female"
>
<label
for="register-gender-female"
class="static"
>
<?=lang('ph_gender_female')?>
</label>
</div>
<div class="rel radio">
<input
type="radio"
id="register-gender-lettuce"
name="gender"
value="lettuce"
>
<label
for="register-gender-lettuce"
class="static"
>
<?=lang('ph_gender_lettuce')?>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<?=ilang('action_register',
id: 'register-submit',
class: 'btn btn-wide btn-success',
attrs: array('type' => 'submit'),
button: TRUE
)?>
</div>
</form>
<script>
$('#register-form').submit(function(e) {
e.preventDefault();
const form = event.target;
const formFields = form.elements;
let first_name = formFields.first_name.value.trim();
let last_name = formFields.last_name.value.trim();
let username = formFields.username.value.trim();
let password = formFields.password.value.trim();
let email = formFields.email.value.trim();
let birth_date = formFields.birth_date.value.trim();
let gender = formFields.gender.value.trim();
if(birth_date === '') {
errorToast('toast_date_empty');
return;
}
const onSuccess = () => {
$.ajax({
url: '/api/rpc/login',
method: 'POST',
data: JSON.stringify({
username, password
}),
success: onLogin
});
};
$.ajax({
url: '/api/user',
method: 'POST',
data: JSON.stringify({
first_name, last_name, username, password,
email, birth_date, gender
}),
success: onSuccess
});
});
</script>

View file

@ -8,7 +8,7 @@
<div class="ml col sub-card">
<div class="row">
<strong><?=$format_model->name($user)?></strong>
<span class="dim ml"><?=$format_model->date($comment['date'])?></span>
<span class="dim ml"><?=$format_model->date($comment['created'])?></span>
</div>
<?=$comment['content']?>
</div>

View file

@ -0,0 +1,17 @@
<?php /* Copyright (c) 2024 Freya Murphy */ ?>
<?php /* vi: syntax=php */ ?>
<?php
$class = isset($class) ? $class : '';
$link = isset($link) ? $link : TRUE;
?>
<?php if($link): ?>
<a class="image-loading pfp <?=$class?>" href="/profile?id=<?=$user['id']?>">
<?php else: ?>
<div class="image-loading pfp <?=$class?>">
<?php endif; ?>
<img src="/api/rpc/profile_avatar?user_id=<?=$user['id']?>"/>
<?php if ($link): ?>
</a>
<?php else: ?>
</div>
<?php endif; ?>

View file

@ -5,7 +5,7 @@
<?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>
<span class="dim"><?=$post['created']?></span>
</div>
</div>
<p>
@ -13,20 +13,28 @@
</p>
<?php
$self = $this->main->user();
$liked = $post['like_id'] ? 'btn-blue' : '';
$post_attrs = array(
'postId' => $post['id']
);
if ($post['like_id'] !== NULL) {
$post_attrs['likeId'] = $post['like_id'];
}
?>
<?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()'
<?=ilang('action_like',
class: 'btn btn-wide action-like ' . $liked,
attrs: $post_attrs
)?>
<?=ilang('action_comment', class: 'btn btn-wide action-comment',
click: '$(\'#action-new-comment-' . $post['id'] . '\').focus()'
)?>
</div>
<hr>
<?php else: ?>
<hr>
<?php endif; ?>
<div class="col comments">
<div class="col comments pb">
<?php
$_GET = array('id' => $post['id']);
$cdata = $this->comments();
@ -52,16 +60,17 @@
?>
</div>
<?php if ($self): ?>
<div class="row grow mt">
<?php $this->view('template/pfp', array('user' => $user))?>
<form class="ml action-new-comment-form">
<div class="row pb">
<?php $this->view('template/pfp', array('user' => $self))?>
<form class="ml action-new-comment-form row">
<input
type="hidden"
name="id"
value="<?=$post['id']?>"
>
<input
class="action-new-comment input"
id="action-new-comment-<?=$post['id']?>"
class="action-new-comment btn btn-wide btn-alt"
postId="<?=$post['id']?>"
autocomplete="off"
type="text"
@ -73,3 +82,5 @@
</div>
<?php endif; ?>
</div>

View file

@ -10,7 +10,7 @@
if ($loaded >= $page_size && $page_size < $total) {
ilang('action_load_posts',
id: 'action-load-posts',
class: 'btn btn-line mb',
class: 'btn btn-line btn-wide mb',
attrs: array(
'loaded' => $loaded,
'pageSize' => $page_size,

View file

@ -11,9 +11,16 @@
array_push($params, $hint);
}
$msg = lang($msg, sub: $params);
$lang_msg = lang($msg, FALSE, sub: $params);
if(!$lang_msg) {
$lang_msg = $msg;
} else {
$lang_msg = ucfirst($lang_msg);
}
?>
<div class="toast error">
<?=ucfirst($msg)?>
<?=$lang_msg?>
<?=ilang('action_close', class: 'action-close-toast')?>
</div>

View file

@ -30,6 +30,16 @@ class Aesthetic {
'css/post.css'
],
),
'auth' => array(
'css' => [
'css/auth.css'
],
),
'people' => array(
'css' => [
'css/people.css'
],
),
);
}
/**

View file

@ -3,5 +3,7 @@
$routes = array();
$routes['home'] = 'apps/home';
$routes['error'] = 'apps/error';
$routes['auth'] = 'apps/auth';
$routes['people'] = 'apps/people';
$routes[''] = '_index';

View file

@ -45,5 +45,20 @@ abstract class Controller {
}
}
protected function is_ajax(): bool {
$_POST = json_decode(
file_get_contents("php://input"), true
);
return isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest';
}
protected function error($code): void {
$_GET['code'] = $code;
$this->main->info['app'] = 'error';
$error_controller = $this->load->controller('apps/error');
$error_controller->index();
die();
}
}
?>

View file

@ -46,6 +46,8 @@ class DatabaseQuery {
if (!$this->where) {
$this->where = TRUE;
$this->query .= "WHERE ";
} else {
$this->query .= "AND ";
}
$this->query .= "$cond ";
return $this;
@ -85,6 +87,8 @@ class DatabaseQuery {
if (!$this->where) {
$this->where = TRUE;
$this->query .= "WHERE ";
} else {
$this->query .= "AND ";
}
if (empty($array)) {
$this->query .= "FALSE\n";
@ -95,16 +99,6 @@ class DatabaseQuery {
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;
@ -122,6 +116,11 @@ class DatabaseQuery {
return $this;
}
public function order_by($column, $order = 'ASC') {
$this->query .= "ORDER BY " . $column . ' ' . $order . ' ';
return $this;
}
public function rows() {
$stmt = $this->conn->prepare($this->query);
try {

Some files were not shown because too many files have changed in this diff Show more