Skip to content

Commit 80d96d0

Browse files
committed
Rebuilding Screen Pilot backend
1 parent 1334c65 commit 80d96d0

10 files changed

Lines changed: 325 additions & 4 deletions

File tree

compose.override.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ services:
55
- ./postgres/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro
66
- ./postgres/migrations:/etc/superstack/migrations:rw
77
- ./postgres/bin:/superstack-bin:ro
8+
- ./postgres/rc:/rc:ro
9+
environment:
10+
PSQLRC: /rc/.psqlrc
11+
INPUTRC: /rc/.inputrc
812
# Set a faster healthcheck interval for the development server
913
healthcheck:
1014
interval: 0.5s

compose.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
services:
55
postgres:
6-
image: ghcr.io/explodinglabs/superstack-postgres
6+
image: ghcr.io/explodinglabs/sp-postgres
77
build:
88
context: ./postgres
99
volumes:
@@ -33,7 +33,7 @@ services:
3333
PGRST_APP_SETTINGS_JWT_EXP: 3600
3434
PGRST_APP_SETTINGS_JWT_SECRET: ${PGRST_JWT_SECRET:?}
3535
PGRST_DB_ANON_ROLE: anon
36-
PGRST_DB_SCHEMAS: api
36+
PGRST_DB_SCHEMAS: api,auth
3737
PGRST_DB_URI: postgres://authenticator:${PGRST_AUTHENTICATOR_PASS:?}@postgres:5432/app
3838
PGRST_DB_USE_LEGACY_GUCS: false
3939
PGRST_JWT_SECRET: ${PGRST_JWT_SECRET:?}
@@ -47,7 +47,7 @@ services:
4747
- postgrest
4848

4949
caddy:
50-
image: ghcr.io/explodinglabs/superstack-caddy:0.1.0
50+
image: ghcr.io/explodinglabs/sp-caddy:0.1.0
5151
build:
5252
context: ./caddy
5353
depends_on:

postgres/Dockerfile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,19 @@ FROM postgres:17
22

33
# gettext is needed for envsubst
44
RUN apt-get update && apt-get install -y \
5-
gettext
5+
gettext \
6+
build-essential \
7+
postgresql-server-dev-17
68

79
COPY docker-entrypoint-initdb.d /docker-entrypoint-initdb.d
810
COPY migrations /etc/superstack/migrations
911
COPY bin /superstack-bin
1012

1113
ENV PATH="/superstack-bin:$PATH"
14+
15+
# pgjwt - used by auth schema
16+
COPY ./pgjwt /pgjwt
17+
WORKDIR /pgjwt
18+
RUN make && make install
19+
20+
WORKDIR /var/lib/postgresql
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/* pgcrypto adds public.crypt used in auth.encrypt_pass pgjwt also needs this
2+
so it must be loaded first. pgcrypto is built into Postgres, so no need to
3+
install it. */
4+
create extension pgcrypto;
5+
6+
-- pgjwt adds public.sign used in auth.generate_access_token
7+
create extension pgjwt;
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
-- 02-create_auth_schema.sql
2+
begin;
3+
4+
-- Create auth schema and tables
5+
create schema auth;
6+
7+
create table auth.user (
8+
username text primary key check (length(username) >= 3),
9+
password text not null check (length(password) < 512),
10+
role name not null check (length(role) < 512)
11+
);
12+
13+
create table auth.refresh_token (
14+
id bigint generated always as identity primary key,
15+
created_at timestamp not null default now(),
16+
token text,
17+
username text
18+
);
19+
20+
-- Enforce that roles exist in pg_roles
21+
create function auth.check_role_exists() returns trigger
22+
language plpgsql as $$
23+
begin
24+
if not exists (select 1 from pg_roles where rolname = new.role) then
25+
raise foreign_key_violation using message = 'unknown database role: ' || new.role;
26+
return null;
27+
end if;
28+
return new;
29+
end
30+
$$;
31+
32+
create constraint trigger ensure_user_role_exists
33+
after insert or update on auth.user
34+
for each row execute procedure auth.check_role_exists();
35+
36+
-- Encrypt passwords on insert/update
37+
create function auth.encrypt_pass() returns trigger
38+
language plpgsql as $$
39+
begin
40+
if tg_op = 'INSERT' or new.password <> old.password then
41+
new.password := crypt(new.password, gen_salt('bf'));
42+
end if;
43+
return new;
44+
end
45+
$$;
46+
47+
create trigger encrypt_pass
48+
before insert or update on auth.user
49+
for each row execute procedure auth.encrypt_pass();
50+
51+
-- Generate JWT access tokens
52+
create function auth.generate_access_token(
53+
role_ text, user_ text, secret text
54+
) returns text
55+
language plpgsql as $$
56+
declare
57+
access_token text;
58+
begin
59+
select public.sign(row_to_json(r), secret) into access_token from (
60+
select role_ as role, user_ as username,
61+
extract(epoch from now())::integer + 600 as exp
62+
) r;
63+
return access_token;
64+
end;
65+
$$;
66+
67+
-- Login endpoint
68+
create function auth.login(user_ text, pass text) returns void
69+
language plpgsql security definer as $$
70+
declare
71+
access_token text;
72+
headers text;
73+
refresh_token text;
74+
role_ name;
75+
begin
76+
select role into role_
77+
from auth.user
78+
where username = user_
79+
and password = public.crypt(pass, password);
80+
81+
if role_ is null then
82+
raise sqlstate 'PT401' using message = 'Invalid user or password';
83+
end if;
84+
85+
select auth.generate_access_token(role_, user_, current_setting('pgrst.jwt_secret')) into access_token;
86+
87+
refresh_token := public.gen_random_uuid();
88+
insert into auth.refresh_token (token, username) values (refresh_token, user_);
89+
90+
headers := '[' ||
91+
'{"Set-Cookie": "access_token=' || access_token || '; Path=/; HttpOnly;"},' ||
92+
'{"Set-Cookie": "refresh_token=' || refresh_token || '; Path=/rpc/refresh_token; HttpOnly;"}' ||
93+
']';
94+
perform set_config('response.headers', headers, true);
95+
end;
96+
$$;
97+
98+
-- Logout endpoint
99+
create function auth.logout() returns void
100+
language plpgsql security definer as $$
101+
declare headers text;
102+
begin
103+
headers := '[' ||
104+
'{"Set-Cookie": "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;"},' ||
105+
'{"Set-Cookie": "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT;"}' ||
106+
']';
107+
perform set_config('response.headers', headers, true);
108+
end;
109+
$$;
110+
111+
-- Refresh token endpoint
112+
create function auth.refresh_token() returns void
113+
language plpgsql security definer as $$
114+
declare
115+
user_ text;
116+
access_token text;
117+
headers text;
118+
refresh_token_ text;
119+
role_ text;
120+
begin
121+
refresh_token_ := current_setting('request.cookies', true)::json->>'refresh_token';
122+
123+
select username into user_
124+
from auth.refresh_token
125+
where token = refresh_token_
126+
and created_at > now() - interval '30 days';
127+
128+
if user_ is null then
129+
raise sqlstate 'PT401' using message = 'Invalid or expired refresh token';
130+
end if;
131+
132+
select role into role_ from auth.user where username = user_;
133+
if role_ is null then
134+
raise sqlstate 'PT401' using message = 'Unknown user';
135+
end if;
136+
137+
select auth.generate_access_token(role_, user_, current_setting('pgrst.jwt_secret')) into access_token;
138+
139+
headers := '[{"Set-Cookie": "access_token=' || access_token || '; Path=/; HttpOnly;"}]';
140+
perform set_config('response.headers', headers, true);
141+
end;
142+
$$;
143+
144+
commit;
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
begin;
2+
3+
-- Create the api schema
4+
-- No need, this is created in 00-init_postgrest.sql
5+
-- create schema api;
6+
7+
comment on schema api is 'Screen Pilot API';
8+
9+
-- Create the asset table
10+
create table api.asset (
11+
id bigint generated always as identity primary key,
12+
created_at timestamp not null default now(),
13+
filename text unique not null,
14+
mime text not null,
15+
size int not null
16+
);
17+
18+
-- Create the playlist table
19+
create table api.playlist (
20+
id bigint generated always as identity primary key,
21+
created_at timestamp not null default now(),
22+
name text not null
23+
);
24+
25+
-- Create the playlist_assets table
26+
create table api.playlist_asset (
27+
id bigint generated always as identity primary key,
28+
playlist_id bigint references api.playlist (id) not null,
29+
asset_id bigint references api.asset (id) not null,
30+
/* "The join table determines many-to-many relationships. It must contain
31+
foreign keys to other two tables and they must be part of its composite key."
32+
https://docs.postgrest.org/en/v12/references/api/resource_embedding.html#many-to-many-relationships
33+
*/
34+
primary key (id, playlist_id, asset_id)
35+
);
36+
37+
-- Send an MPV command
38+
/*
39+
create function api.mpv_command(routing_key text, command text []) returns void
40+
language plpgsql as $$
41+
begin
42+
perform amqp.publish(1, 'amq.topic', routing_key, json_build_object('event', 'MPVCommand', 'command', command)::text);
43+
end;
44+
$$;
45+
46+
comment on function api.mpv_command(text, text [])
47+
is 'Send an MPV command. Currently unused.';
48+
*/
49+
50+
-- Select playlist files from db and play
51+
create function api.play_playlist_id(
52+
routing_key text,
53+
p_id int,
54+
index int
55+
) returns void language plpgsql as $$
56+
declare
57+
files text[];
58+
playlist_name text;
59+
json text;
60+
begin
61+
select name into playlist_name
62+
from api.playlist
63+
where id = p_id;
64+
65+
select array_agg(filename) into files
66+
from api.playlist_asset
67+
left join asset on playlist_asset.id = asset.id
68+
where playlist_asset.playlist_id = p_id;
69+
70+
perform amqp.publish(1, 'amq.topic', routing_key, json_build_object('event', 'LoadList', 'files', files)::text);
71+
perform amqp.publish(1, 'amq.topic', routing_key, json_build_object('event', 'MPVCommand', 'command', array["playlist-play-index", index])::text);
72+
end;
73+
$$;
74+
75+
comment on function api.play_playlist_id(text, int, int) is
76+
'Select playlist files from the database and play them.
77+
78+
Parameters:
79+
- routing_key: The routing key of the screen
80+
- p_id: Playlist id
81+
- index: Index to begin playback from.';
82+
83+
-- Load files in a known playlist text file and play
84+
create function api.play_playlist_file(
85+
routing_key text,
86+
playlist_file text,
87+
index int
88+
) returns void language plpgsql as $$
89+
begin
90+
perform amqp.publish(1, 'amq.topic', routing_key, json_build_object('event', 'MPVCommand', 'command', array["loadlist", playlist_file])::text);
91+
perform amqp.publish(1, 'amq.topic', routing_key, json_build_object('event', 'MPVCommand', 'command', array["playlist-play-index", index])::text);
92+
end;
93+
$$;
94+
95+
comment on function api.play_playlist_file(text, text, int) is
96+
'Load files in a known playlist text file and play.
97+
98+
Parameters:
99+
- routing_key: The routing key of the screen
100+
- playlist_file: Contents of the playlist file.
101+
- index: Index to begin playback from.';
102+
103+
-- Write playlist text file and play
104+
create function api.play_files(
105+
routing_key text,
106+
files text [],
107+
index int
108+
) returns void language plpgsql as $$
109+
begin
110+
perform amqp.publish(1, 'amq.topic', routing_key, json_build_object('event', 'LoadList', 'files', files)::text);
111+
perform amqp.publish(1, 'amq.topic', routing_key, json_build_object('event', 'MPVCommand', 'command', array['playlist-play-index', index::text])::text);
112+
end;
113+
$$;
114+
115+
comment on function api.play_files(text, text [], int) is
116+
'Write playlist text file and play.
117+
118+
Tells the consumer write a list of files to a text file, then load that
119+
playlist text file.
120+
121+
Parameters:
122+
- routing_key: The routing key of the screen
123+
- files: Contents of the playlist file.
124+
- index: Index to begin playback from.';
125+
126+
commit;

postgres/migrations/98-roles.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
create role basic_subscriber;

postgres/migrations/99-grants.sql

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
begin;
2+
3+
grant usage on schema auth to anon;
4+
grant execute on function auth.login(text, text) to anon;
5+
grant execute on function auth.logout() to anon;
6+
grant execute on function auth.refresh_token() to anon;
7+
8+
-- basic_subscriber
9+
grant basic_subscriber to authenticator;
10+
grant usage on schema api to basic_subscriber;
11+
-- amqp extension has its own 'amqp' schema
12+
grant usage on schema amqp to basic_subscriber;
13+
-- Grant table privileges
14+
grant select, insert, update on
15+
api.playlist,
16+
api.asset,
17+
api.playlist_asset,
18+
amqp.broker to basic_subscriber;
19+
-- Grant execute on RPC functions
20+
grant execute on function
21+
-- api.mpv_command(text, text []),
22+
api.play_playlist_id(text, int, int),
23+
api.play_playlist_file(text, text, int),
24+
api.play_files(text, text [], int)
25+
to basic_subscriber;
26+
27+
commit;

postgres/rc/.inputrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
set editing-mode vi

postgres/rc/.psqlrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
\pset pager off
2+
\setenv PAGER 'less -S'

0 commit comments

Comments
 (0)