Spaces:
Sleeping
Sleeping
Refactor project structure: remove unused files, add FastAPI app and dependencies, and configure supervisord for process management
Browse files- .env.example +0 -50
- Dockerfile +25 -53
- README.md +1 -1
- app/main.py +50 -0
- init.sql +0 -79
- migrations/0001_chat_history.sql +0 -74
- proxy.py +0 -123
- requirements.txt +2 -0
- start.sh +0 -59
- supervisord.conf +33 -0
- tunnel.sh +56 -0
.env.example
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
# ============================================================
|
| 2 |
-
# PostgREST + PostgreSQL (Hugging Face Space) — env vars
|
| 3 |
-
# Copy to `.env` and adjust. HF Spaces only allows PORT to be
|
| 4 |
-
# set via the Space settings; everything else can live here.
|
| 5 |
-
# ============================================================
|
| 6 |
-
|
| 7 |
-
# --- Server ---
|
| 8 |
-
# Port PostgREST listens on. HF Spaces requires 7860.
|
| 9 |
-
PORT=7860
|
| 10 |
-
# Bind address (keep 0.0.0.0 for HF Spaces)
|
| 11 |
-
PGRST_SERVER_HOST=0.0.0.0
|
| 12 |
-
|
| 13 |
-
# --- Database connection ---
|
| 14 |
-
# Full connection URI. Inside this container Postgres runs on a
|
| 15 |
-
# local unix socket, so the default below is usually fine.
|
| 16 |
-
# For an external DB, use e.g.:
|
| 17 |
-
# postgresql://authenticator:secret@db.example.com:5432/prod
|
| 18 |
-
PGRST_DB_URI=postgresql://?host=/var/run/postgresql&port=5432&dbname=postgres
|
| 19 |
-
|
| 20 |
-
# Comma-separated schemas exposed by the API
|
| 21 |
-
PGRST_DB_SCHEMA=api
|
| 22 |
-
|
| 23 |
-
# Role used for unauthenticated (anonymous) requests
|
| 24 |
-
PGRST_DB_ANON_ROLE=web_anon
|
| 25 |
-
|
| 26 |
-
# Role used to apply schema changes / own objects
|
| 27 |
-
AUTHENTICATOR_PASSWORD=secret
|
| 28 |
-
|
| 29 |
-
# --- Auth (optional) ---
|
| 30 |
-
# Secret used to sign/verify JWTs. Generate one with:
|
| 31 |
-
# echo "secret" | openssl dgst -sha256 -hmac "your-key" -binary | base64
|
| 32 |
-
# Leave empty to disable JWT auth (public API).
|
| 33 |
-
PGRST_JWT_SECRET=
|
| 34 |
-
# Claim that carries the DB role in the JWT.
|
| 35 |
-
# Must be a JSON path — it MUST start with a dot (or be a bracket
|
| 36 |
-
# expression). "role" alone is a parse error and crashes PostgREST.
|
| 37 |
-
PGRST_JWT_ROLE_CLAIM_KEY=.role
|
| 38 |
-
# Audience claim to require, if any
|
| 39 |
-
PGRST_JWT_AUDIENCE=
|
| 40 |
-
|
| 41 |
-
# --- API behavior (optional) ---
|
| 42 |
-
# Max rows per request (Content-Range limiting)
|
| 43 |
-
PGRST_MAX_ROWS=
|
| 44 |
-
# Allow CORS from any origin (useful for browser clients)
|
| 45 |
-
PGRST_CORS_ALLOWED_ORIGINS=*
|
| 46 |
-
# Path prefix if served behind a proxy (usually empty on HF)
|
| 47 |
-
PGRST_SERVER_PATH_PREFIX=
|
| 48 |
-
|
| 49 |
-
# --- PostgREST version pinned in the Dockerfile ---
|
| 50 |
-
PGRST_VERSION=16.2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Dockerfile
CHANGED
|
@@ -1,65 +1,37 @@
|
|
| 1 |
-
|
| 2 |
-
# Serves a fully RESTful API from a Postgres database on port 7860.
|
| 3 |
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
PORT=7860 \
|
| 9 |
-
PGRST_DB_SCHEMAS=api \
|
| 10 |
-
PGRST_DB_ANON_ROLE=web_anon \
|
| 11 |
-
PGRST_SERVER_HOST=0.0.0.0
|
| 12 |
-
|
| 13 |
-
# --- System dependencies: PostgreSQL server + tools ---
|
| 14 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
postgresql \
|
| 19 |
-
postgresql-contrib \
|
| 20 |
-
postgresql-client \
|
| 21 |
-
sudo \
|
| 22 |
-
xz-utils \
|
| 23 |
-
python3 \
|
| 24 |
&& rm -rf /var/lib/apt/lists/*
|
| 25 |
|
| 26 |
-
#
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
arm64) PGRST_ARCH=aarch64 ;; \
|
| 32 |
-
*) echo "unsupported arch: $ARCH" && exit 1 ;; \
|
| 33 |
-
esac \
|
| 34 |
-
&& curl -fsSL \
|
| 35 |
-
"https://github.com/PostgREST/postgrest/releases/download/v${PGRST_VERSION}/postgrest-v${PGRST_VERSION}-linux-static-${PGRST_ARCH}.tar.xz" \
|
| 36 |
-
-o /tmp/postgrest.tar.xz \
|
| 37 |
-
&& tar -xJf /tmp/postgrest.tar.xz -C /usr/local/bin \
|
| 38 |
-
&& chmod +x /usr/local/bin/postgrest \
|
| 39 |
-
&& rm /tmp/postgrest.tar.xz \
|
| 40 |
-
&& postgrest --version
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
&& tar -xzf /tmp/swagger-ui.tgz -C /opt/swagger-ui --strip-components=1 \
|
| 47 |
-
&& rm /tmp/swagger-ui.tgz
|
| 48 |
|
| 49 |
-
|
| 50 |
-
RUN
|
| 51 |
-
&& mkdir -p /var/lib/postgresql/data /var/run/postgresql \
|
| 52 |
-
&& chown -R appuser:appuser /var/lib/postgresql /var/run/postgresql /usr/local/bin/postgrest
|
| 53 |
|
| 54 |
-
|
| 55 |
-
WORKDIR /home/appuser
|
| 56 |
|
| 57 |
-
#
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
RUN chmod +x /usr/local/bin/start.sh /usr/local/bin/proxy.py
|
| 62 |
|
| 63 |
EXPOSE 7860
|
| 64 |
|
| 65 |
-
|
|
|
|
|
|
| 1 |
+
FROM postgres:16
|
|
|
|
| 2 |
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive
|
| 4 |
|
| 5 |
+
# python for the FastAPI app, ssh client for the tunnel, supervisor to run
|
| 6 |
+
# postgres + the tunnel loop + the api as one PID-1-managed process tree.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
+
python3 python3-pip \
|
| 9 |
+
openssh-client \
|
| 10 |
+
supervisor \
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
&& rm -rf /var/lib/apt/lists/*
|
| 12 |
|
| 13 |
+
# Key so pinggy never blocks on an interactive password prompt (there's no
|
| 14 |
+
# TTY in a container). Accepted as an unauthenticated client key by pinggy's
|
| 15 |
+
# free tier -- it's just there to skip the "press enter" prompt.
|
| 16 |
+
RUN mkdir -p /root/.ssh \
|
| 17 |
+
&& ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N "" -q
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
COPY requirements.txt .
|
| 21 |
+
RUN pip3 install --no-cache-dir --break-system-packages --ignore-installed -r requirements.txt
|
| 22 |
+
COPY app/ ./app/
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
COPY tunnel.sh /usr/local/bin/tunnel.sh
|
| 25 |
+
RUN chmod +x /usr/local/bin/tunnel.sh
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
|
|
|
| 28 |
|
| 29 |
+
# Override these at `docker run -e ...` (don't ship real creds in the image).
|
| 30 |
+
ENV POSTGRES_PASSWORD=changeme
|
| 31 |
+
ENV POSTGRES_USER=postgres
|
| 32 |
+
ENV POSTGRES_DB=postgres
|
|
|
|
| 33 |
|
| 34 |
EXPOSE 7860
|
| 35 |
|
| 36 |
+
ENTRYPOINT ["/usr/bin/supervisord"]
|
| 37 |
+
CMD ["-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🔥
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: indigo
|
|
|
|
| 1 |
---
|
| 2 |
+
title: All DB
|
| 3 |
emoji: 🔥
|
| 4 |
colorFrom: yellow
|
| 5 |
colorTo: indigo
|
app/main.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, Header, HTTPException
|
| 7 |
+
|
| 8 |
+
app = FastAPI(title="pinggy-db-url")
|
| 9 |
+
|
| 10 |
+
URL_FILE = Path("/tmp/pinggy_url.txt")
|
| 11 |
+
SECRET_KEY = os.environ.get("SECRET_KEY", "")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _check_api_key(api_key: str):
|
| 15 |
+
if not SECRET_KEY:
|
| 16 |
+
raise HTTPException(status_code=500, detail="SECRET_KEY is not configured")
|
| 17 |
+
if api_key != SECRET_KEY:
|
| 18 |
+
raise HTTPException(status_code=401, detail="invalid api key")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _read_url() -> Optional[str]:
|
| 22 |
+
if not URL_FILE.exists():
|
| 23 |
+
return None
|
| 24 |
+
value = URL_FILE.read_text().strip()
|
| 25 |
+
return value or None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@app.get("/")
|
| 29 |
+
def root(x_api_key: str = Header(default="")):
|
| 30 |
+
_check_api_key(x_api_key)
|
| 31 |
+
return {"status": "ok", "hint": "GET /db-url for the current connection info"}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@app.get("/db-url")
|
| 35 |
+
def db_url(x_api_key: str = Header(default="")):
|
| 36 |
+
_check_api_key(x_api_key)
|
| 37 |
+
url = _read_url()
|
| 38 |
+
if url is None:
|
| 39 |
+
raise HTTPException(status_code=503, detail="tunnel not ready yet, try again shortly")
|
| 40 |
+
|
| 41 |
+
# url looks like tcp://<host>:<port>
|
| 42 |
+
host_port = url[len("tcp://"):] if url.startswith("tcp://") else url
|
| 43 |
+
host, _, port = host_port.partition(":")
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
"tunnel_url": url,
|
| 47 |
+
"host": host,
|
| 48 |
+
"port": int(port) if port.isdigit() else None,
|
| 49 |
+
"psql_example": f"psql -h {host} -p {port} -U postgres -d postgres",
|
| 50 |
+
}
|
init.sql
DELETED
|
@@ -1,79 +0,0 @@
|
|
| 1 |
-
-- Schema + roles for PostgREST, executed once at container startup.
|
| 2 |
-
-- Model: chat history (conversations -> messages).
|
| 3 |
-
-- (Mirrors migrations/0001_chat_history.sql)
|
| 4 |
-
|
| 5 |
-
-- Role PostgREST connects as (anonymous / public access)
|
| 6 |
-
CREATE ROLE web_anon LOGIN NOINHERIT;
|
| 7 |
-
|
| 8 |
-
-- Role used to apply schema changes. Password comes from start.sh
|
| 9 |
-
-- via psql variable `auth_pw` (sourced from AUTHENTICATOR_PASSWORD).
|
| 10 |
-
CREATE ROLE authenticator NOINHERIT LOGIN PASSWORD :'auth_pw';
|
| 11 |
-
GRANT web_anon TO authenticator;
|
| 12 |
-
|
| 13 |
-
CREATE SCHEMA api;
|
| 14 |
-
GRANT USAGE ON SCHEMA api TO web_anon;
|
| 15 |
-
-- authenticator owns the tables, so foreign-key checks (which run as the
|
| 16 |
-
-- table owner) need USAGE on the schema too. NOINHERIT means it does NOT
|
| 17 |
-
-- inherit web_anon's grants.
|
| 18 |
-
GRANT USAGE ON SCHEMA api TO authenticator;
|
| 19 |
-
|
| 20 |
-
-- One conversation (a chat thread)
|
| 21 |
-
CREATE TABLE api.chats (
|
| 22 |
-
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
| 23 |
-
title text NOT NULL,
|
| 24 |
-
created_at timestamptz NOT NULL DEFAULT now()
|
| 25 |
-
);
|
| 26 |
-
|
| 27 |
-
-- Messages belonging to a conversation
|
| 28 |
-
CREATE TABLE api.messages (
|
| 29 |
-
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
| 30 |
-
chat_id bigint NOT NULL REFERENCES api.chats (id) ON DELETE CASCADE,
|
| 31 |
-
role text NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
| 32 |
-
content text NOT NULL,
|
| 33 |
-
created_at timestamptz NOT NULL DEFAULT now()
|
| 34 |
-
);
|
| 35 |
-
|
| 36 |
-
CREATE INDEX messages_chat_id_idx ON api.messages (chat_id);
|
| 37 |
-
|
| 38 |
-
-- Change ownership BEFORE granting: ALTER TABLE ... OWNER TO drops
|
| 39 |
-
-- privileges granted by the previous owner.
|
| 40 |
-
ALTER TABLE api.chats OWNER TO authenticator;
|
| 41 |
-
ALTER TABLE api.messages OWNER TO authenticator;
|
| 42 |
-
|
| 43 |
-
GRANT SELECT, INSERT, UPDATE, DELETE ON api.chats TO web_anon;
|
| 44 |
-
GRANT SELECT, INSERT, UPDATE, DELETE ON api.messages TO web_anon;
|
| 45 |
-
GRANT USAGE, SELECT ON SEQUENCE api.chats_id_seq TO web_anon;
|
| 46 |
-
GRANT USAGE, SELECT ON SEQUENCE api.messages_id_seq TO web_anon;
|
| 47 |
-
|
| 48 |
-
INSERT INTO api.chats (title) VALUES
|
| 49 |
-
('Getting started'),
|
| 50 |
-
('PostgREST questions');
|
| 51 |
-
|
| 52 |
-
INSERT INTO api.messages (chat_id, role, content) VALUES
|
| 53 |
-
(1, 'user', 'How do I list all chats?'),
|
| 54 |
-
(1, 'assistant', 'GET /chats — or GET /chats?select=*,messages(*) to embed messages.'),
|
| 55 |
-
(2, 'user', 'How do I add a message to chat 1?'),
|
| 56 |
-
(2, 'assistant', 'POST /messages with {"chat_id": 1, "role": "user", "content": "..."}');
|
| 57 |
-
|
| 58 |
-
-- Row counts for Content-Range headers
|
| 59 |
-
CREATE OR REPLACE FUNCTION api.chats_count()
|
| 60 |
-
RETURNS bigint
|
| 61 |
-
LANGUAGE sql
|
| 62 |
-
AS $$
|
| 63 |
-
SELECT count(*) FROM api.chats;
|
| 64 |
-
$$;
|
| 65 |
-
|
| 66 |
-
CREATE OR REPLACE FUNCTION api.messages_count()
|
| 67 |
-
RETURNS bigint
|
| 68 |
-
LANGUAGE sql
|
| 69 |
-
AS $$
|
| 70 |
-
SELECT count(*) FROM api.messages;
|
| 71 |
-
$$;
|
| 72 |
-
|
| 73 |
-
-- OpenAPI description endpoint
|
| 74 |
-
CREATE OR REPLACE FUNCTION api.openapi_description()
|
| 75 |
-
RETURNS text
|
| 76 |
-
LANGUAGE sql
|
| 77 |
-
AS $$
|
| 78 |
-
SELECT 'A demo PostgREST chat-history API running on Hugging Face Spaces';
|
| 79 |
-
$$;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
migrations/0001_chat_history.sql
DELETED
|
@@ -1,74 +0,0 @@
|
|
| 1 |
-
-- Schema + roles for PostgREST, executed once at container startup.
|
| 2 |
-
-- Model: chat history (conversations -> messages).
|
| 3 |
-
|
| 4 |
-
-- Role PostgREST connects as (anonymous / public access)
|
| 5 |
-
CREATE ROLE web_anon LOGIN NOINHERIT;
|
| 6 |
-
|
| 7 |
-
-- Role used to apply schema changes. Password comes from start.sh
|
| 8 |
-
-- via psql variable `auth_pw` (sourced from AUTHENTICATOR_PASSWORD).
|
| 9 |
-
CREATE ROLE authenticator NOINHERIT LOGIN PASSWORD :'auth_pw';
|
| 10 |
-
GRANT web_anon TO authenticator;
|
| 11 |
-
|
| 12 |
-
CREATE SCHEMA api;
|
| 13 |
-
GRANT USAGE ON SCHEMA api TO web_anon;
|
| 14 |
-
|
| 15 |
-
-- One conversation (a chat thread)
|
| 16 |
-
CREATE TABLE api.chats (
|
| 17 |
-
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
| 18 |
-
title text NOT NULL,
|
| 19 |
-
created_at timestamptz NOT NULL DEFAULT now()
|
| 20 |
-
);
|
| 21 |
-
|
| 22 |
-
-- Messages belonging to a conversation
|
| 23 |
-
CREATE TABLE api.messages (
|
| 24 |
-
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
| 25 |
-
chat_id bigint NOT NULL REFERENCES api.chats (id) ON DELETE CASCADE,
|
| 26 |
-
role text NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
| 27 |
-
content text NOT NULL,
|
| 28 |
-
created_at timestamptz NOT NULL DEFAULT now()
|
| 29 |
-
);
|
| 30 |
-
|
| 31 |
-
CREATE INDEX messages_chat_id_idx ON api.messages (chat_id);
|
| 32 |
-
|
| 33 |
-
-- Change ownership BEFORE granting: ALTER TABLE ... OWNER TO drops
|
| 34 |
-
-- privileges granted by the previous owner.
|
| 35 |
-
ALTER TABLE api.chats OWNER TO authenticator;
|
| 36 |
-
ALTER TABLE api.messages OWNER TO authenticator;
|
| 37 |
-
|
| 38 |
-
GRANT SELECT, INSERT, UPDATE, DELETE ON api.chats TO web_anon;
|
| 39 |
-
GRANT SELECT, INSERT, UPDATE, DELETE ON api.messages TO web_anon;
|
| 40 |
-
GRANT USAGE, SELECT ON SEQUENCE api.chats_id_seq TO web_anon;
|
| 41 |
-
GRANT USAGE, SELECT ON SEQUENCE api.messages_id_seq TO web_anon;
|
| 42 |
-
|
| 43 |
-
INSERT INTO api.chats (title) VALUES
|
| 44 |
-
('Getting started'),
|
| 45 |
-
('PostgREST questions');
|
| 46 |
-
|
| 47 |
-
INSERT INTO api.messages (chat_id, role, content) VALUES
|
| 48 |
-
(1, 'user', 'How do I list all chats?'),
|
| 49 |
-
(1, 'assistant', 'GET /chats — or GET /chats?select=*,messages(*) to embed messages.'),
|
| 50 |
-
(2, 'user', 'How do I add a message to chat 1?'),
|
| 51 |
-
(2, 'assistant', 'POST /messages with {"chat_id": 1, "role": "user", "content": "..."}');
|
| 52 |
-
|
| 53 |
-
-- Row counts for Content-Range headers
|
| 54 |
-
CREATE OR REPLACE FUNCTION api.chats_count()
|
| 55 |
-
RETURNS bigint
|
| 56 |
-
LANGUAGE sql
|
| 57 |
-
AS $$
|
| 58 |
-
SELECT count(*) FROM api.chats;
|
| 59 |
-
$$;
|
| 60 |
-
|
| 61 |
-
CREATE OR REPLACE FUNCTION api.messages_count()
|
| 62 |
-
RETURNS bigint
|
| 63 |
-
LANGUAGE sql
|
| 64 |
-
AS $$
|
| 65 |
-
SELECT count(*) FROM api.messages;
|
| 66 |
-
$$;
|
| 67 |
-
|
| 68 |
-
-- OpenAPI description endpoint
|
| 69 |
-
CREATE OR REPLACE FUNCTION api.openapi_description()
|
| 70 |
-
RETURNS text
|
| 71 |
-
LANGUAGE sql
|
| 72 |
-
AS $$
|
| 73 |
-
SELECT 'A demo PostgREST chat-history API running on Hugging Face Spaces';
|
| 74 |
-
$$;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
proxy.py
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Tiny reverse proxy in front of PostgREST.
|
| 4 |
-
|
| 5 |
-
- /docs -> Swagger UI (static files from /opt/swagger-ui)
|
| 6 |
-
- /docs/openapi.json -> OpenAPI spec fetched from PostgREST
|
| 7 |
-
- everything else -> proxied verbatim to PostgREST on 127.0.0.1:$PGRST_PORT
|
| 8 |
-
|
| 9 |
-
Uses only the Python standard library. Runs as the same non-root user.
|
| 10 |
-
"""
|
| 11 |
-
|
| 12 |
-
import json
|
| 13 |
-
import os
|
| 14 |
-
import urllib.request
|
| 15 |
-
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
| 16 |
-
|
| 17 |
-
SWAGGER_DIR = "/opt/swagger-ui"
|
| 18 |
-
UPSTREAM = f"http://127.0.0.1:{os.environ.get('PGRST_PORT', '3000')}"
|
| 19 |
-
LISTEN_PORT = int(os.environ.get("PORT", "7860"))
|
| 20 |
-
|
| 21 |
-
# Swagger UI page that loads /docs/openapi.json
|
| 22 |
-
INDEX_HTML = """<!DOCTYPE html>
|
| 23 |
-
<html lang="en">
|
| 24 |
-
<head>
|
| 25 |
-
<meta charset="UTF-8">
|
| 26 |
-
<title>PostgREST API Docs</title>
|
| 27 |
-
<link rel="stylesheet" href="/docs/swagger-ui.css">
|
| 28 |
-
</head>
|
| 29 |
-
<body>
|
| 30 |
-
<div id="swagger-ui"></div>
|
| 31 |
-
<script src="/docs/swagger-ui-bundle.js" charset="UTF-8"></script>
|
| 32 |
-
<script>
|
| 33 |
-
window.onload = () => {
|
| 34 |
-
SwaggerUIBundle({
|
| 35 |
-
url: "/docs/openapi.json",
|
| 36 |
-
dom_id: "#swagger-ui",
|
| 37 |
-
deepLinking: true,
|
| 38 |
-
});
|
| 39 |
-
};
|
| 40 |
-
</script>
|
| 41 |
-
</body>
|
| 42 |
-
</html>"""
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
class Handler(SimpleHTTPRequestHandler):
|
| 46 |
-
def __init__(self, *args, **kwargs):
|
| 47 |
-
# Serve static Swagger UI files from SWAGGER_DIR
|
| 48 |
-
super().__init__(*args, directory=SWAGGER_DIR, **kwargs)
|
| 49 |
-
|
| 50 |
-
# --- /docs routes -----------------------------------------------------
|
| 51 |
-
def _send(self, code, body, ctype):
|
| 52 |
-
self.send_response(code)
|
| 53 |
-
self.send_header("Content-Type", ctype)
|
| 54 |
-
self.send_header("Content-Length", str(len(body)))
|
| 55 |
-
self.send_header("Cache-Control", "no-store")
|
| 56 |
-
self.end_headers()
|
| 57 |
-
self.wfile.write(body)
|
| 58 |
-
|
| 59 |
-
# --- proxying ---------------------------------------------------------
|
| 60 |
-
def _proxy(self):
|
| 61 |
-
url = UPSTREAM + self.path
|
| 62 |
-
req = urllib.request.Request(url, method=self.command)
|
| 63 |
-
for h in ("Content-Type", "Accept", "Prefer", "Range",
|
| 64 |
-
"If-Match", "Authorization"):
|
| 65 |
-
if v := self.headers.get(h):
|
| 66 |
-
req.add_header(h, v)
|
| 67 |
-
length = int(self.headers.get("Content-Length") or 0)
|
| 68 |
-
body = self.rfile.read(length) if length else None
|
| 69 |
-
|
| 70 |
-
try:
|
| 71 |
-
with urllib.request.urlopen(req, body) as r:
|
| 72 |
-
resp, code, rheaders = r.read(), r.status, r.headers
|
| 73 |
-
except urllib.error.HTTPError as e:
|
| 74 |
-
resp, code, rheaders = e.read(), e.code, e.headers
|
| 75 |
-
except Exception as e: # URLError, timeout, upstream down, ...
|
| 76 |
-
print(f"proxy error: {self.command} {self.path}: {e!r}", flush=True)
|
| 77 |
-
resp = json.dumps({"message": str(e)}).encode()
|
| 78 |
-
code, rheaders = 502, {"Content-Type": "application/json"}
|
| 79 |
-
|
| 80 |
-
self.send_response(code)
|
| 81 |
-
for h in ("Content-Type", "Content-Range", "Content-Location",
|
| 82 |
-
"Location", "ETag", "Preference-Applied",
|
| 83 |
-
"Accept-Profile", "Content-Profile"):
|
| 84 |
-
if v := rheaders.get(h):
|
| 85 |
-
self.send_header(h, v)
|
| 86 |
-
self.send_header("Content-Length", str(len(resp)))
|
| 87 |
-
self.end_headers()
|
| 88 |
-
self.wfile.write(resp)
|
| 89 |
-
|
| 90 |
-
def do_GET(self):
|
| 91 |
-
if self.path == "/docs" or self.path.startswith("/docs?"):
|
| 92 |
-
self._send(200, INDEX_HTML.encode(), "text/html; charset=utf-8")
|
| 93 |
-
elif self.path == "/docs/openapi.json":
|
| 94 |
-
# Fetch the spec and rewrite its host so "Try it out" requests
|
| 95 |
-
# hit the public origin instead of PostgREST's internal port
|
| 96 |
-
import json
|
| 97 |
-
spec = json.loads(urllib.request.urlopen(f"{UPSTREAM}/").read())
|
| 98 |
-
spec["host"] = self.headers.get("Host") or f"localhost:{LISTEN_PORT}"
|
| 99 |
-
spec["schemes"] = ["https"] if self.headers.get(
|
| 100 |
-
"X-Forwarded-Proto") == "https" else ["http"]
|
| 101 |
-
body = json.dumps(spec).encode()
|
| 102 |
-
self._send(200, body, "application/openapi+json")
|
| 103 |
-
elif self.path.startswith("/docs/"):
|
| 104 |
-
# static assets: strip the /docs prefix
|
| 105 |
-
self.path = self.path[len("/docs"):]
|
| 106 |
-
super().do_GET()
|
| 107 |
-
else:
|
| 108 |
-
self._proxy()
|
| 109 |
-
|
| 110 |
-
def do_HEAD(self):
|
| 111 |
-
self.do_GET()
|
| 112 |
-
|
| 113 |
-
def _proxy_with_body(self):
|
| 114 |
-
self._proxy()
|
| 115 |
-
|
| 116 |
-
do_POST = do_PATCH = do_PUT = do_DELETE = do_OPTIONS = _proxy_with_body
|
| 117 |
-
|
| 118 |
-
def log_message(self, fmt, *args):
|
| 119 |
-
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
if __name__ == "__main__":
|
| 123 |
-
ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
start.sh
DELETED
|
@@ -1,59 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env bash
|
| 2 |
-
# Start PostgreSQL, apply the schema, then launch PostgREST on $PORT.
|
| 3 |
-
set -euo pipefail
|
| 4 |
-
|
| 5 |
-
# Load .env if present (local runs; HF Spaces injects vars itself)
|
| 6 |
-
if [ -f .env ]; then
|
| 7 |
-
set -a
|
| 8 |
-
. ./.env
|
| 9 |
-
set +a
|
| 10 |
-
fi
|
| 11 |
-
|
| 12 |
-
PGDATA=/var/lib/postgresql/data
|
| 13 |
-
SOCKET_DIR=/var/run/postgresql
|
| 14 |
-
INIT_SQL=/docker-entrypoint-init.d/init.sql
|
| 15 |
-
|
| 16 |
-
# Debian's postgresql package keeps binaries in a versioned dir
|
| 17 |
-
# (e.g. /usr/lib/postgresql/15/bin) that is not on PATH.
|
| 18 |
-
for PGDIR in /usr/lib/postgresql/*/bin; do
|
| 19 |
-
[ -x "$PGDIR/pg_ctl" ] && export PATH="$PGDIR:$PATH" && break
|
| 20 |
-
done
|
| 21 |
-
|
| 22 |
-
command -v pg_ctl >/dev/null || { echo "postgres binaries not found" >&2; exit 1; }
|
| 23 |
-
|
| 24 |
-
# --- Initialize and start PostgreSQL ---
|
| 25 |
-
if [ ! -s "$PGDATA/PG_VERSION" ]; then
|
| 26 |
-
initdb --username=appuser --pgdata="$PGDATA" --auth-local=trust --auth-host=trust
|
| 27 |
-
fi
|
| 28 |
-
|
| 29 |
-
pg_ctl -D "$PGDATA" -o "-c listen_addresses=127.0.0.1 -c unix_socket_directories=$SOCKET_DIR -c port=5432" -w start
|
| 30 |
-
|
| 31 |
-
# Wait until the server accepts connections
|
| 32 |
-
until pg_isready -h "$SOCKET_DIR" -p 5432 -q; do
|
| 33 |
-
sleep 0.2
|
| 34 |
-
done
|
| 35 |
-
|
| 36 |
-
# --- Apply schema once ---
|
| 37 |
-
if ! psql -h "$SOCKET_DIR" -p 5432 -d postgres -Atqc "SELECT 1 FROM pg_roles WHERE rolname='web_anon'" | grep -q 1; then
|
| 38 |
-
psql -v ON_ERROR_STOP=1 --set=auth_pw="${AUTHENTICATOR_PASSWORD:-secret}" \
|
| 39 |
-
-h "$SOCKET_DIR" -p 5432 -d postgres -f "$INIT_SQL"
|
| 40 |
-
fi
|
| 41 |
-
|
| 42 |
-
# --- Launch PostgREST ---
|
| 43 |
-
export PGRST_DB_URI="postgresql://authenticator:${AUTHENTICATOR_PASSWORD:-secret}@/postgres?host=$SOCKET_DIR&port=5432"
|
| 44 |
-
export PGRST_DB_SCHEMA="${PGRST_DB_SCHEMA:-api}"
|
| 45 |
-
export PGRST_DB_ANON_ROLE="${PGRST_DB_ANON_ROLE:-web_anon}"
|
| 46 |
-
export PGRST_SERVER_PORT="${PGRST_PORT:-3000}"
|
| 47 |
-
|
| 48 |
-
# PGRST_JWT_ROLE_CLAIM_KEY must be a JSON path (".role", ".payload.role",
|
| 49 |
-
# '."https://example.com/role"' or "[\"role\"]"). A bare value like "role"
|
| 50 |
-
# is a parse error that crashes PostgREST at startup — normalize it.
|
| 51 |
-
if [ -n "${PGRST_JWT_ROLE_CLAIM_KEY:-}" ] && \
|
| 52 |
-
[[ ! "$PGRST_JWT_ROLE_CLAIM_KEY" =~ ^(\.|\[|\") ]]; then
|
| 53 |
-
export PGRST_JWT_ROLE_CLAIM_KEY=".$PGRST_JWT_ROLE_CLAIM_KEY"
|
| 54 |
-
fi
|
| 55 |
-
|
| 56 |
-
# PostgREST listens on the internal port; the proxy serves it (plus
|
| 57 |
-
# Swagger UI at /docs) on the public $PORT.
|
| 58 |
-
postgrest &
|
| 59 |
-
exec python3 /usr/local/bin/proxy.py
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
supervisord.conf
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[supervisord]
|
| 2 |
+
nodaemon=true
|
| 3 |
+
user=root
|
| 4 |
+
logfile=/var/log/supervisord.log
|
| 5 |
+
|
| 6 |
+
; Runs the postgres image's own entrypoint script with "postgres" as the
|
| 7 |
+
; argument -- this is what triggers its normal first-run init (creating
|
| 8 |
+
; the data dir, user, db) before it execs postgres in the foreground.
|
| 9 |
+
[program:postgres]
|
| 10 |
+
command=docker-entrypoint.sh postgres
|
| 11 |
+
priority=1
|
| 12 |
+
autostart=true
|
| 13 |
+
autorestart=true
|
| 14 |
+
stdout_logfile=/var/log/postgres.log
|
| 15 |
+
stderr_logfile=/var/log/postgres.err.log
|
| 16 |
+
|
| 17 |
+
[program:tunnel]
|
| 18 |
+
command=/usr/local/bin/tunnel.sh
|
| 19 |
+
priority=10
|
| 20 |
+
autostart=true
|
| 21 |
+
autorestart=true
|
| 22 |
+
startretries=999
|
| 23 |
+
stdout_logfile=/var/log/tunnel.log
|
| 24 |
+
stderr_logfile=/var/log/tunnel.err.log
|
| 25 |
+
|
| 26 |
+
[program:fastapi]
|
| 27 |
+
command=python3 -m uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 28 |
+
directory=/app
|
| 29 |
+
priority=20
|
| 30 |
+
autostart=true
|
| 31 |
+
autorestart=true
|
| 32 |
+
stdout_logfile=/var/log/fastapi.log
|
| 33 |
+
stderr_logfile=/var/log/fastapi.err.log
|
tunnel.sh
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Keeps a pinggy TCP tunnel to local postgres alive, recycling it before
|
| 3 |
+
# the free-tier ~60 minute cap, and writing the current public host:port
|
| 4 |
+
# to a file that the FastAPI app reads from.
|
| 5 |
+
|
| 6 |
+
set -u
|
| 7 |
+
|
| 8 |
+
URL_FILE=/tmp/pinggy_url.txt
|
| 9 |
+
LOG_FILE=/tmp/pinggy_current.log
|
| 10 |
+
TTL_SECONDS=$((50 * 60)) # refresh every 50 min, inside the 60 min free cap
|
| 11 |
+
|
| 12 |
+
echo "[tunnel] waiting for postgres..."
|
| 13 |
+
until pg_isready -h 127.0.0.1 -p 5432 >/dev/null 2>&1; do
|
| 14 |
+
sleep 1
|
| 15 |
+
done
|
| 16 |
+
echo "[tunnel] postgres is up"
|
| 17 |
+
|
| 18 |
+
while true; do
|
| 19 |
+
SECONDS=0
|
| 20 |
+
: > "$LOG_FILE"
|
| 21 |
+
echo "[tunnel] opening new pinggy tunnel..."
|
| 22 |
+
|
| 23 |
+
ssh -p 443 -T \
|
| 24 |
+
-o StrictHostKeyChecking=no \
|
| 25 |
+
-o UserKnownHostsFile=/dev/null \
|
| 26 |
+
-o ServerAliveInterval=30 \
|
| 27 |
+
-o ExitOnForwardFailure=yes \
|
| 28 |
+
-R0:127.0.0.1:5432 qr+tcp@free.pinggy.io \
|
| 29 |
+
>> "$LOG_FILE" 2>&1 &
|
| 30 |
+
SSH_PID=$!
|
| 31 |
+
|
| 32 |
+
# poll the log for the public "tcp://host:port" pinggy prints on connect
|
| 33 |
+
URL=""
|
| 34 |
+
for i in $(seq 1 30); do
|
| 35 |
+
URL=$(grep -oE 'tcp://[a-zA-Z0-9.-]+:[0-9]+' "$LOG_FILE" | head -n1)
|
| 36 |
+
[ -n "$URL" ] && break
|
| 37 |
+
kill -0 "$SSH_PID" 2>/dev/null || break # ssh already died
|
| 38 |
+
sleep 1
|
| 39 |
+
done
|
| 40 |
+
|
| 41 |
+
if [ -n "$URL" ]; then
|
| 42 |
+
echo "$URL" > "$URL_FILE"
|
| 43 |
+
echo "[tunnel] live at $URL"
|
| 44 |
+
else
|
| 45 |
+
echo "[tunnel] never got a URL from pinggy, see $LOG_FILE" >&2
|
| 46 |
+
fi
|
| 47 |
+
|
| 48 |
+
# hold this tunnel open until TTL expires or ssh dies on its own
|
| 49 |
+
while [ "$SECONDS" -lt "$TTL_SECONDS" ] && kill -0 "$SSH_PID" 2>/dev/null; do
|
| 50 |
+
sleep 5
|
| 51 |
+
done
|
| 52 |
+
|
| 53 |
+
echo "[tunnel] recycling..."
|
| 54 |
+
kill "$SSH_PID" 2>/dev/null
|
| 55 |
+
wait "$SSH_PID" 2>/dev/null
|
| 56 |
+
done
|