Compare commits

...

178 Commits

Author SHA1 Message Date
626a9f0d5b chore: rename volumes for prom and grafana 2026-03-13 09:31:00 +01:00
88cb43204a fix: reflect real authentication result
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-13 09:04:30 +01:00
1e5416f4b6 fix: ensure scheme is correct
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-13 08:52:20 +01:00
f31f74eb1a fix: expand env
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-13 08:51:36 +01:00
dc02831482 revert: some stuff from prev commit 2026-03-12 22:15:04 +01:00
a77ed916c5 fix: address remaining metrics review feedback 2026-03-12 22:05:38 +01:00
96a68b46ae fix: add authentication to metrics routes 2026-03-12 21:53:41 +01:00
21e2e094d6 fix: do not expose metrics 2026-03-12 21:38:14 +01:00
fcdbc4e878 fix metrics cardinality and stream key cache counts 2026-03-12 20:16:01 +01:00
cdb0c01ffd fix(chat): possible DoS when logging to prometheus 2026-03-09 22:47:28 +01:00
3771baae8c Merge branch 'main' into feat/metrics 2026-03-09 22:36:00 +01:00
d719debf6a fix(ui): it was flexbox 2026-03-09 22:34:39 +01:00
e22a35484a fix(ui): last commit fixed arrows but this should fix all users from showing 2026-03-09 22:34:39 +01:00
2597aa8d86 fix(ui): carousel overflow 2026-03-09 22:34:39 +01:00
e0b6075900 fix(bot): do not inherit admin from owner status 2026-03-09 22:34:39 +01:00
c7cedbbfe0 chore(chat): exclude chat grants from adding numbers to viewer count 2026-03-09 22:34:39 +01:00
df4537bbe3 fix(ui): it was flexbox 2026-03-09 22:30:03 +01:00
7cd071b3b6 fix(ui): last commit fixed arrows but this should fix all users from showing 2026-03-09 16:58:30 +01:00
a8a64432a4 fix(ui): carousel overflow 2026-03-09 16:44:45 +01:00
10b77c673e fix(bot): do not inherit admin from owner status 2026-03-06 23:46:16 +01:00
960e3306e4 chore(chat): exclude chat grants from adding numbers to viewer count 2026-03-06 23:42:20 +01:00
fbfbe3ff6f chore(metrics): redesign with opus because of course 2026-03-06 21:57:52 +01:00
07eefcf9c7 feat(metrics): add more production ready metrics 2026-03-06 21:30:14 +01:00
527155a0c1 feat(metrics): initial ai impl 2026-03-06 19:32:12 +01:00
ba30d6e097 chore(ui): choose any bot as mod and fix landing page 2026-03-02 19:19:23 +01:00
70ae7ef3b3 chore(mediamtx): latency fix attempt 2026-03-02 19:11:54 +01:00
eddfebc311 chore(prod): WHY WHY WHY WHY WHY 2026-03-02 17:23:19 +01:00
460125972f chore(prod): add log level debug to mediamtx 2026-03-02 17:13:59 +01:00
91b08f00b2 fix(docker): please be over 2026-03-02 17:04:01 +01:00
eccf9e5791 fix(docker): build mediamtx image instead 2026-03-02 16:58:50 +01:00
01514931cb fix(docker): minor config issue 2026-03-02 16:22:27 +01:00
6d5f7b4fd5 feat(docker): add production docker image for mediatmx 2026-03-02 08:23:46 +01:00
2c95ddc6dd chore(docker): include mediamtx config file 2026-03-01 20:46:58 +01:00
a6fcaff5f3 fix(types): build issues 2026-02-24 22:56:36 +01:00
b4f66e01d9 fix(chat): code review and documentation update 2026-02-24 22:38:58 +01:00
ef5eab0d17 refactor(chat): platform admin fix cleanup 2026-02-24 22:27:38 +01:00
cf4cc77071 fix(chat): platform admin cannot moderate chats 2026-02-24 21:21:22 +01:00
1bbe4fdc0a chore(ui): remove zrl username block 2026-02-23 22:43:13 +01:00
67b9af57f9 fix(settings): user dropdown wasnt showing channel managers 2026-02-23 22:36:23 +01:00
398d4113c8 feat(viewer): uptime 2026-02-22 20:05:40 +01:00
32c101934d fix(ui): testing landing page fixes in prod??????? 2026-02-21 23:24:06 +01:00
48e00bada4 fix(ui): more landing page fixes 2026-02-21 23:07:30 +01:00
bc69136133 fix(ui): landing page offline carousel bug 2026-02-21 17:43:32 +01:00
a96939684b fix(admin): remove csrf checks 2026-02-21 17:01:35 +01:00
ed1608b8e3 refactor(ui): mobile friendly landing 2026-02-21 16:50:20 +01:00
f4f653614d feat: per channel chat moderator 2026-02-21 16:44:00 +01:00
5fca354c58 feat(ui): new index page 2026-02-21 15:38:53 +01:00
b4ad29853a revert(hls): some stability fixes that actually made it more unstable 2026-02-21 14:08:34 +01:00
cf2f0ac86d chore(admin): use email instead 2026-02-21 14:06:44 +01:00
f57dec65e0 chore(admin): patch vulns 2026-02-21 14:04:19 +01:00
4c7ddeeb72 refactor(admin): admin page rebuilt 2026-02-21 13:50:27 +01:00
2a4a1adcd8 feat(chat): chat reports 2026-02-21 13:38:38 +01:00
107982dbec feat(chat): chat moderation 2026-02-20 21:42:18 +01:00
a75d9e3795 chore(admin): remove all users that are not onboarded from the list 2026-02-20 20:25:09 +01:00
5336541010 revert: improve player stability 2026-02-19 22:41:04 +01:00
dd71b822ed chore: bump sdk version 2026-02-19 22:28:55 +01:00
d343335b8e chore: improve player stability 2026-02-19 22:25:33 +01:00
892cb7ab87 fix: expose hctv database name in pgbouncer 2026-02-19 16:56:28 +01:00
b274903dc1 fix: configure hctv database in compose services 2026-02-18 18:23:50 +01:00
b1c20a374a chore: open db port 2026-02-18 17:06:04 +01:00
0b6b23c42d fix: aedfbjelwbnjetlkbejlbk 2026-02-18 16:56:21 +01:00
008db9e2c8 fix: please end this torture 2026-02-18 16:48:43 +01:00
24bfcff68a fix: build emojis inside docker compose 2026-02-18 16:36:06 +01:00
dc2b01ae21 fix: rowan fixes 2026-02-18 16:31:44 +01:00
ef4563cc7c fix: can coolify be cool ffs 2026-02-17 15:57:07 +01:00
936b853536 fix: fix the issue again 2026-02-17 08:00:34 +01:00
60387773bf fix: some more fixes brought to you by 4.6 opus 2026-02-16 21:50:57 +01:00
eac101764b chore: remove restart policies 2026-02-09 08:19:47 +01:00
5f43567d89 feat: Merge pull request #62 from hackclub/main
Ensure pnpm manifests in Docker builds
2026-02-09 08:08:37 +01:00
jeninh
bf86bafbe8 Merge pull request #3 from hackclub/revert-2-codex/fix-deployment-error-for-missing-package-manifest
Revert "Copy root pnpm manifests into Docker installer stages"
2026-02-07 20:43:55 -05:00
jeninh
8f2516cabe Revert "Copy root pnpm manifests into Docker installer stages" 2026-02-07 20:43:44 -05:00
jeninh
d2cd99d3d4 Merge pull request #2 from hackclub/codex/fix-deployment-error-for-missing-package-manifest
Copy root pnpm manifests into Docker installer stages
2026-02-07 20:10:33 -05:00
jeninh
c8e3b63cc5 Ensure pnpm manifests in Docker builds 2026-02-07 20:10:10 -05:00
60e01d8207 fix: docker aaaa 2026-02-08 01:25:29 +01:00
9e0ca29a2c fix: lock prisma deps 2026-02-08 01:17:49 +01:00
ae4d88a9e0 fix: ac crashing out 2026-02-08 01:03:23 +01:00
5735074af9 fix: docker asddfasdf 2026-02-08 00:46:10 +01:00
899e8f1054 fix: last last docker file 2026-02-08 00:31:57 +01:00
acd8e0d980 fix: last docker fix hopefully 2026-02-08 00:10:01 +01:00
cb10ee1855 fix: more dockerfile fixes 2026-02-07 23:39:53 +01:00
b0eb5d4430 fix: another docker fix 2026-02-07 23:22:18 +01:00
d8b9803019 fix: docker build 2026-02-07 23:14:23 +01:00
2ceb813a98 fix: type error 2026-02-07 00:09:52 +01:00
6222f9dafe fix: dumbahh pnpm thingy 2026-02-06 23:56:35 +01:00
c7bb9aef72 feat: Merge pull request #59 from SrIzan10/feat/js-sdk
feat: javascript sdk
add basic moderation
other chat stuff
multiple ingest regions
2026-02-06 23:50:45 +01:00
7456e80473 fix: jenin changes 2026-02-06 23:45:24 +01:00
a0cabbfa63 chore: update apps/docs/src/content/docs/api/chat.mdx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 23:35:11 +01:00
2f8ac7d343 Merge branch 'main' into feat/js-sdk 2026-02-06 23:32:56 +01:00
0157eff9f3 fix: onboarding errors 2026-02-06 23:31:51 +01:00
ebcb062b6a fix: pass sentry auth token 2026-02-06 23:31:48 +01:00
fdc8e0f33c docs: change some phrasing 2026-02-06 23:31:44 +01:00
eeb44dfae7 chore: remove comments 2026-02-06 23:31:44 +01:00
copilot-swe-agent[bot]
0e9f0a54dd Add security validation and documentation for botAuth parameter
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2026-02-06 23:31:22 +01:00
copilot-swe-agent[bot]
5d81d32276 Add botAuth query parameter support for websocket authentication
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2026-02-06 23:30:21 +01:00
copilot-swe-agent[bot]
fe21d19250 Initial plan 2026-02-06 23:28:57 +01:00
eac736b9fb chore: oops 2026-02-06 23:27:11 +01:00
381f4fc523 chore: review fixes 2026-02-06 23:25:07 +01:00
7d350cfc04 chore: update apps/docs/src/content/docs/api/chat.mdx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 23:24:34 +01:00
2dfbab5d0e chore: update apps/docs/src/content/docs/api/chat.mdx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-06 23:22:14 +01:00
4eef997d63 fix: onboarding errors 2026-02-06 23:02:25 +01:00
7574b94933 fix: pass sentry auth token 2026-02-06 22:25:58 +01:00
6c26ca9d2f chore: redirect to root 2026-02-06 22:22:03 +01:00
a1727b9a3d chore: change server region 2026-02-06 17:41:51 +01:00
f486c3b28e chore: publish production docker compose to github 2026-02-06 17:27:05 +01:00
8e86be97d1 chore: remove unused route 2026-02-06 17:23:51 +01:00
099b321b79 feat: support connecting to multiple channels 2026-02-06 17:21:37 +01:00
6fdadbec28 feat: add ability to change usernames 2026-02-01 15:30:31 +01:00
92cde437af chore: gitignore autogenned sdk typedoc 2026-02-01 15:11:42 +01:00
28cbe4e8ed fix: (ai gen) chat improvements 2026-01-31 23:37:32 +01:00
09d099d0ee feat: add ai example 2026-01-31 23:30:21 +01:00
5c99fee95d feat: typedoc stuff 2026-01-31 21:34:42 +01:00
df845b5601 feat: working tests and api 2026-01-31 21:22:19 +01:00
d4a6516157 docs: change some phrasing 2026-01-31 21:22:19 +01:00
17bbba7df3 chore: remove comments 2026-01-31 21:22:19 +01:00
copilot-swe-agent[bot]
1e27c7e77a Add consistent prefix validation for both auth methods
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2026-01-31 21:22:19 +01:00
copilot-swe-agent[bot]
80595d6299 Add security validation and documentation for botAuth parameter
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2026-01-31 21:22:19 +01:00
copilot-swe-agent[bot]
aa9d0c1ca5 Add botAuth query parameter support for websocket authentication
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2026-01-31 21:22:19 +01:00
copilot-swe-agent[bot]
45894fc900 Initial plan 2026-01-31 21:22:19 +01:00
ddbdf3caf9 fix: bot account param not actually working 2026-01-31 20:42:51 +01:00
80a8e670e1 fix: add bot auth query parameter (#61) 2026-01-30 17:13:41 +01:00
3e5824093e docs: change some phrasing 2026-01-30 17:12:25 +01:00
75d6e648f9 chore: remove comments 2026-01-30 17:10:06 +01:00
copilot-swe-agent[bot]
1fadaa3600 Add consistent prefix validation for both auth methods
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2026-01-30 16:04:33 +00:00
copilot-swe-agent[bot]
7262b0e5c2 Add security validation and documentation for botAuth parameter
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2026-01-30 16:01:34 +00:00
copilot-swe-agent[bot]
70832c7de8 Add botAuth query parameter support for websocket authentication
Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2026-01-30 15:58:51 +00:00
copilot-swe-agent[bot]
61972da255 Initial plan 2026-01-30 15:54:24 +00:00
221aff0050 feat: preliminary chat api 2026-01-30 16:42:50 +01:00
5b6addac9a docs: change some things 2026-01-27 17:02:47 +01:00
5add3b0e5d feat: multiple streaming servers 2026-01-27 16:56:43 +01:00
b623de5bdd chore: make sure channel is not live already 2026-01-26 16:40:33 +01:00
cc15a06ffb fix: production latency (hopefully) 2026-01-26 16:18:36 +01:00
c0f3e9d52e feat: merge #60 from BananaJeanss/feat/js-sdk
Universalform regex filter + onboarding username filter, .env.examples + dev guide changes
2026-01-25 21:42:35 +01:00
a22dcf0746 docs: refine readme.md 2026-01-25 21:41:24 +01:00
BananaJeans
b4d3cd5bb8 docs: also add development setup guide link 2026-01-25 21:48:15 +02:00
BananaJeanss
d5c02889de feat: add input regex filter to universalform, add filter to onboarding, add .env.examples along with gitignore exemption, and improve dev guide by a bit 2026-01-25 19:49:39 +02:00
c0657cc1ce docs: populate frontmatter 2026-01-25 17:38:00 +01:00
d97add9659 feat: max 20 characters and dev docs 2026-01-25 17:36:52 +01:00
8f07dbadf3 chore: make landing page simpler 2026-01-25 17:16:42 +01:00
21ab8a5e4f chore: remove welcome workflow and add agentsmd 2026-01-21 16:06:40 +01:00
689c410828 feat: moderation features and ABAC permission system
mostly generated by claude code, but of course i have made some of my
edits.
2026-01-01 16:18:00 +01:00
593baa6505 chore: migrate to pnpm 2025-12-31 01:37:11 +01:00
786a2afb6c chore: cargo lock thing 2025-12-31 01:14:48 +01:00
75f25eb8fe feat: js sdk init 2025-12-31 00:09:45 +00:00
0e500037c4 chore: stop requiring SLACK_TOKEN 2025-12-29 11:24:10 +00:00
b49318f9e6 chore: normal img tag because lazy loading no worky 2025-12-20 20:54:36 +01:00
927d7d1bda chore: sidebar avatar images lazy loading 2025-12-20 03:13:27 +01:00
d1f5cc7a6d fix: set next public after build time 2025-12-20 03:01:21 +01:00
0afc54f0bf feat: merge #58 feat/protocol-migration 2025-12-20 02:28:03 +01:00
0581cc6a61 chore: github copilot code review nitpicks 2025-12-20 02:26:46 +01:00
18025ced9d chore: update next 2025-12-20 01:28:08 +01:00
044221f147 fix: stream keeps playing after leaving page 2025-12-19 22:04:24 +01:00
0cabbd8720 fix: constraint error when deleting channel 2025-12-18 23:37:24 +01:00
5fdb6921d9 fix: generate stream key right on channel creation 2025-12-18 23:33:03 +01:00
312ad480a2 feat: channel deletion 2025-12-18 23:23:59 +01:00
a37554d205 refactor: sidebar 2025-12-18 22:52:00 +01:00
5244275264 feat: make stream key stuff more compact + automatic url generator 2025-12-18 19:26:20 +01:00
5275e8cb2a docs: change url stuff 2025-12-18 19:25:52 +01:00
1ff51fad61 feat: streaminfo and thumbnail wiring 2025-12-17 18:33:21 +01:00
440eb407dd chore: mediamtx types 2025-12-15 22:24:46 +01:00
4ab1756230 feat: new color scheme 2025-12-15 22:09:27 +01:00
f9d11476bf refactor: optimize authentication requests 2025-12-15 21:47:00 +01:00
8e8c58e195 feat: add preliminary hls reading 2025-12-13 22:34:29 +01:00
6fcbeaa2a7 Merge branch 'main' into feat/protocol-migration 2025-12-13 21:57:57 +01:00
caef4e428a chore: update deps AGAIN 2025-12-13 21:56:36 +01:00
cf49fea907 Merge branch 'main' into feat/protocol-migration 2025-12-13 21:51:10 +01:00
7683f765b0 chore: update react dom for the vuln 2025-12-13 21:50:45 +01:00
4d91f15a43 Merge branch 'main' into feat/protocol-migration 2025-12-13 21:49:59 +01:00
3b49f8d25a feat: srt support and auth 2025-12-13 21:19:13 +01:00
c99ace0ef5 chore: patch next again 2025-12-13 20:33:54 +01:00
a834b63ac8 chore: update next to patch react2shell 2025-12-05 12:11:45 +00:00
09871d3fae fix: slight vulnerability 2025-12-03 20:49:33 +01:00
2a0a7abe1a chore: change github gist link to docs 2025-12-03 20:25:33 +01:00
2a15a6367a chore: change navbar login icon 2025-12-03 20:25:21 +01:00
6fad756bd2 chore: prevent people from signing in without slack 2025-12-03 20:24:49 +01:00
0bb44960b4 chore: nuke ome 2025-11-29 00:25:26 +01:00
ac2276b112 Merge branch 'main' into feat/protocol-migration 2025-11-25 17:00:26 +01:00
1adb9be6cc fix: change ident url 2025-11-25 15:48:59 +01:00
f8aa1454ff protocol migration thing 2025-11-24 20:41:57 +01:00
141 changed files with 36243 additions and 14614 deletions

View File

@@ -10,7 +10,7 @@ jobs:
name: Push frontend to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Wait
- name: Wait
uses: NathanFirmo/wait-for-other-action@v1.0.4
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -40,13 +40,12 @@ jobs:
RELEASE_URL=$(curl -s https://api.github.com/repos/srizan10/hctv/releases/latest | \
grep "browser_download_url.*slack-import-emojis-linux-x86_64" | \
cut -d '"' -f 4)
curl -L -o slack-import-emojis-bin $RELEASE_URL
chmod +x slack-import-emojis-bin
mkdir -p apps/web/src/lib/instrumentation/
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
./slack-import-emojis-bin default
cp emojis.json apps/web/
@@ -63,6 +62,7 @@ jobs:
secrets: |
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
chat:
name: Push chat module to Docker Hub
runs-on: ubuntu-latest
@@ -98,11 +98,43 @@ jobs:
secrets: |
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
mediamtx:
name: Push MediaMTX image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: srizan10/hclive-mediamtx
tags: latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/mediamtx/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
deploy:
name: Deploy to Coolify
runs-on: ubuntu-latest
needs: [frontend, chat]
needs: [frontend, chat, mediamtx]
steps:
- name: Send coolify redeploy webhook
run: |
curl -X POST -H "Authorization: Bearer ${{ secrets.COOLIFY_API_KEY }}" https://coolify.srizan.dev/api/v1/deploy?uuid=${{ secrets.COOLIFY_APP_UUID }}&force=true
curl -X POST -H "Authorization: Bearer ${{ secrets.COOLIFY_API_KEY }}" https://coolify.srizan.dev/api/v1/deploy?uuid=${{ secrets.COOLIFY_APP_UUID }}&force=true

5
.gitignore vendored
View File

@@ -28,6 +28,7 @@ yarn-error.log*
# local env files
.env*.local
.env*
!.env.example
# vercel
.vercel
@@ -47,4 +48,6 @@ packages/db/generated/client
slack-import-emojis/target
**/*/emojis.json
.idea
.idea
/apps/docs/src/content/docs/typedoc-sdk

343
AGENTS.md Normal file
View File

@@ -0,0 +1,343 @@
# Agent Guidelines for HackClub.tv
This document provides essential information for AI coding agents working on the HackClub.tv codebase.
## Project Overview
HackClub.tv is a live streaming platform built with Next.js 16, Prisma, and Turbo monorepo architecture.
- **Monorepo**: Turborepo with pnpm workspaces
- **Apps**: web (Next.js), chat (Hono), docs
- **Packages**: db (Prisma), auth (Lucia), hono-ws, sdk
- **Package Manager**: pnpm 10.6.5
## Build, Lint, and Test Commands
### Root Level Commands
```bash
pnpm install # Install all dependencies
pnpm build # Build all apps and packages (uses Turbo)
pnpm dev # Start all apps in dev mode (uses Turbo)
pnpm lint # Lint all apps (uses Turbo)
```
### Database Commands
```bash
pnpm db:migrate # Run Prisma migrations in dev
pnpm prisma # Run any Prisma command in db package
```
### App-Specific Commands
```bash
# Web app (Next.js)
pnpm --filter=@hctv/web dev # Start Next.js dev server
pnpm --filter=@hctv/web build # Build Next.js app
pnpm --filter=@hctv/web lint # Lint web app
pnpm --filter=@hctv/web check-types # Type check (tsc --noEmit)
pnpm --filter=@hctv/web ui:add # Add shadcn components
# Chat app (Hono)
pnpm --filter=@hctv/chat dev # Start chat server with watch
pnpm --filter=@hctv/chat build # Build chat server
# DB package (Prisma)
pnpm --filter=@hctv/db db:generate # Generate Prisma client
pnpm --filter=@hctv/db db:migrate # Run migrations
pnpm --filter=@hctv/db build # Generate client and build
```
### Running Single Tests
This project does not currently have a test suite configured. When adding tests:
- Use Vitest or Jest for unit tests
- Use Playwright for E2E tests (recommended for Next.js)
- Follow the pattern: `pnpm test -- <test-file-path>`
### Docker Commands
```bash
pnpm docker:web # Build web app Docker image
pnpm docker:chat # Build chat app Docker image
pnpm r:rtmp # Restart RTMP server
```
## Code Style Guidelines
### Formatting
- **Formatter**: Prettier (`.prettierrc.json`)
- **Indentation**: 2 spaces (no tabs)
- **Line Width**: 100 characters
- **Quotes**: Single quotes
- **Semicolons**: Required
- **Trailing Commas**: ES5 style
- **Linter**: ESLint with Next.js rules (`next/core-web-vitals`)
### Import Organization
Imports should be ordered as follows (no blank lines between groups):
1. React/Next.js core imports
2. Third-party libraries
3. Internal components (`@/components`)
4. Internal utilities/libs (`@/lib`)
5. Package imports (`@hctv/*`)
6. Type imports (use `import type` keyword)
7. Relative imports
```typescript
import { useState, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import type { User, Channel } from '@hctv/db';
import { helper } from './utils';
```
### TypeScript Usage
- **Strict mode**: Enabled
- **Type over interface**: Use `type` for unions/intersections, `interface` for object shapes
- **No implicit any**: Always type your variables
- **Type imports**: Use `import type` for type-only imports
- **Prisma types**: Import from `@hctv/db` and use type composition
```typescript
// Interfaces for props and object shapes
interface ChatMessage {
user?: User;
message: string;
type: 'message' | 'systemMsg';
}
// Type aliases for complex types
type FormFieldConfig = {
name: string;
label?: string;
};
// Prisma type composition
type StreamWithChannel = StreamInfo & { channel: Channel };
```
### Naming Conventions
**Files:**
- React components: `PascalCase.tsx` (e.g., `ChatPanel.tsx`)
- Utilities/helpers: `camelCase.ts` (e.g., `validate.ts`)
- Next.js pages: `page.tsx`, `route.ts`, `layout.tsx`
- Client components: `page.client.tsx`
**Variables & Functions:**
- Components: `PascalCase`
- Functions: `camelCase`
- Constants: `SCREAMING_SNAKE_CASE`
- Booleans: Use `is`, `has`, `should` prefixes
- Refs: `Ref` suffix (e.g., `socketRef`)
```typescript
const MESSAGE_HISTORY_SIZE = 15;
const isFollowing = await checkFollowing();
const socketRef = useRef<WebSocket | null>(null);
```
### Error Handling
**API Routes:**
- Return `Response` objects with appropriate status codes
- Use descriptive error messages
- Status codes: 400 (bad request), 401 (unauthorized), 403 (forbidden), 404 (not found)
```typescript
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
```
**Server Actions:**
- Return objects with `success` boolean and `error` or `data` fields
- Use `zodVerify` helper for validation
```typescript
const zod = await zodVerify(schema, formData);
if (!zod.success) {
return { success: false, error: zod.error };
}
return { success: true, data: result };
```
**Client-side:**
- Use try-catch for async operations
- Use toast notifications (sonner) for user feedback
- Log errors with `console.error`
### Async Patterns
- **Prefer**: async/await over Promise chains
- **Parallel operations**: Use `Promise.all()`
- **No .then() chaining**: Except in utility functions like fetchers
```typescript
// Standard async/await
const data = await prisma.model.findMany();
// Parallel operations
const [channelA, channelB] = await Promise.all([
prisma.channel.findUnique({ where: { id: 'a' } }),
prisma.channel.findUnique({ where: { id: 'b' } }),
]);
```
## React Component Patterns
### Component Structure
1. Directive (`'use client'` or `'use server'`)
2. Imports
3. Component function
4. Helper functions (if needed)
5. Type/interface definitions (at bottom)
```typescript
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
export default function ChatPanel(props: Props) {
const [message, setMessage] = useState('');
return <div>{/* JSX */}</div>;
}
interface Props {
username: string;
}
```
### Server vs Client Components
- **Server components**: Default (no directive), use for data fetching
- **Client components**: Add `'use client'`, use for interactivity
- Fetch data in server components, pass to client components as props
## Database Patterns (Prisma)
### Imports
```typescript
import { prisma } from '@hctv/db';
import type { User, Channel, StreamInfo } from '@hctv/db';
```
### Common Queries
```typescript
// FindUnique with relations
const channel = await prisma.channel.findUnique({
where: { name: channelName },
include: { owner: true, streamInfo: true },
});
// FindMany with dynamic filters
const where: Prisma.StreamInfoWhereInput = {};
if (isLive) where.isLive = true;
const streams = await prisma.streamInfo.findMany({ where });
// Create with relations
await prisma.channel.create({
data: {
name: channelName,
ownerId: user.id,
personalFor: { connect: { id: user.id } },
},
});
// Update
await prisma.streamInfo.update({
where: { username },
data: { title: newTitle },
});
// Upsert
await prisma.streamKey.upsert({
create: { key: newKey, channelId },
update: { key: newKey },
where: { channelId },
});
```
### Redis Usage
```typescript
import { getRedisConnection } from '@hctv/db';
const redis = getRedisConnection();
await redis.set('key', 'value');
await redis.get('key');
await redis.setex('key', 30, 'value'); // with expiry
```
## API Route Patterns
### Next.js App Router
```typescript
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const param = searchParams.get('param');
const data = await prisma.model.findMany();
return Response.json(data);
}
```
### Server Actions
```typescript
'use server';
export async function createChannel(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(createChannelSchema, formData);
if (!zod.success) {
return zod;
}
// ... processing
return { success: true };
}
```
## Important Notes
- **Turbo caching**: Build outputs are cached. Use `--force` to bypass cache
- **Environment variables**: Use `NEXT_PUBLIC_` prefix for client-side vars
- **Styling**: Tailwind CSS with shadcn/ui components, use `cn()` for conditional classes
- **Data fetching**: SWR for client-side, direct Prisma for server components
- **Validation**: Zod schemas for form and API validation
- **Cache invalidation**: Use `revalidatePath()` after mutations

View File

@@ -1,7 +1,7 @@
# hackclub.tv
This is the source code for [hackclub.tv (hackclub.tv)](https://hackclub.tv), a livestreaming website for hackclubbers.
This is the source code for [hackclub.tv](https://hackclub.tv), a livestreaming website for hackclubbers.
Development has been ongoing for a few months, and the site is now live! There are some half-baked features, but I'm all ears for feedback.
The development setup guide can be read at <https://docs.hackclub.tv/guides/dev/>
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the Hack Club Slack for discussion and updates!

View File

@@ -1,10 +1,13 @@
FROM node:lts-alpine AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN yarn global add turbo@^2
RUN pnpm add -g turbo@^2
COPY . .
RUN turbo prune @hctv/chat --docker
@@ -16,10 +19,10 @@ WORKDIR /app
# First install the dependencies
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
RUN pnpm install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --concurrency=1
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) pnpm turbo run build --concurrency=1
FROM base AS runner
WORKDIR /app

View File

@@ -7,14 +7,15 @@
"build": "tsc --build"
},
"dependencies": {
"@hctv/auth": "*",
"@hctv/db": "*",
"@hctv/hono-ws": "*",
"@hctv/auth": "workspace:*",
"@hctv/db": "workspace:*",
"@hctv/hono-ws": "workspace:*",
"@hono/node-server": "^1.14.0",
"@hono/node-ws": "^1.1.0",
"@leeoniya/ufuzzy": "^1.0.18",
"@oslojs/encoding": "^1.1.0",
"hono": "^4.7.5"
"hono": "^4.7.5",
"prom-client": "^15.1.3"
},
"devDependencies": {
"@types/node": "^20.11.17",

File diff suppressed because it is too large Load Diff

251
apps/chat/src/metrics.ts Normal file
View File

@@ -0,0 +1,251 @@
import { collectDefaultMetrics, Counter, Gauge, Histogram, Registry } from 'prom-client';
function createMetricsStore() {
const register = new Registry();
register.setDefaultLabels({ app: 'chat' });
collectDefaultMetrics({
prefix: 'hctv_chat_',
register,
});
const websocketConnections = new Gauge({
name: 'hctv_chat_websocket_connections',
help: 'Current number of active chat websocket connections.',
registers: [register],
});
const websocketConnectionsByChannel = new Gauge({
name: 'hctv_chat_websocket_connections_by_channel',
help: 'Current number of active chat websocket connections by target channel.',
labelNames: ['channel'],
registers: [register],
});
const websocketConnectionsByAuthMethod = new Gauge({
name: 'hctv_chat_websocket_connections_by_auth_method',
help: 'Current number of active chat websocket connections by auth method.',
labelNames: ['auth_method'],
registers: [register],
});
const websocketConnectionAttempts = new Counter({
name: 'hctv_chat_websocket_connection_attempts_total',
help: 'Total websocket connection attempts grouped by outcome, auth method, and rejection reason.',
labelNames: ['outcome', 'auth_method', 'reason'],
registers: [register],
});
const incomingMessages = new Counter({
name: 'hctv_chat_incoming_messages_total',
help: 'Total inbound websocket frames grouped by message type.',
labelNames: ['type'],
registers: [register],
});
const inboundPayloadBytes = new Counter({
name: 'hctv_chat_inbound_payload_bytes_total',
help: 'Total inbound websocket payload bytes grouped by message type.',
labelNames: ['type'],
registers: [register],
});
const messageDuration = new Histogram({
name: 'hctv_chat_message_duration_seconds',
help: 'Chat websocket message processing time in seconds.',
labelNames: ['type', 'outcome'],
buckets: [0.0005, 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1],
registers: [register],
});
const deliveredMessages = new Counter({
name: 'hctv_chat_messages_delivered_total',
help: 'Total chat messages successfully broadcast, grouped by sender type.',
labelNames: ['sender_type'],
registers: [register],
});
const deliveredMessageBytes = new Counter({
name: 'hctv_chat_message_bytes_delivered_total',
help: 'Total message body bytes successfully broadcast, grouped by sender type.',
labelNames: ['sender_type'],
registers: [register],
});
const channelHistorySize = new Gauge({
name: 'hctv_chat_channel_history_size',
help: 'Current number of messages retained in Redis history for a channel.',
labelNames: ['channel'],
registers: [register],
});
const channelHistoryLoadedMessages = new Counter({
name: 'hctv_chat_history_messages_loaded_total',
help: 'Total history messages loaded from Redis during websocket joins.',
labelNames: ['channel'],
registers: [register],
});
const moderationState = new Gauge({
name: 'hctv_chat_moderation_state',
help: 'Current moderation settings by channel.',
labelNames: ['channel', 'setting'],
registers: [register],
});
const channelUniqueChatters = new Counter({
name: 'hctv_chat_unique_chatters_total',
help: 'Users who successfully sent at least one chat message, grouped by sender type.',
labelNames: ['sender_type'],
registers: [register],
});
const moderationActions = new Counter({
name: 'hctv_chat_moderation_actions_total',
help: 'Successful moderation actions performed in chat.',
labelNames: ['action'],
registers: [register],
});
const moderationBlocks = new Counter({
name: 'hctv_chat_moderation_blocks_total',
help: 'Message blocks and throttling decisions grouped by reason.',
labelNames: ['reason'],
registers: [register],
});
const emojiSearchResults = new Histogram({
name: 'hctv_chat_emoji_search_results',
help: 'Number of emoji search results returned per query.',
labelNames: ['outcome'],
buckets: [0, 1, 2, 5, 10, 25, 50, 100, 150],
registers: [register],
});
const errors = new Counter({
name: 'hctv_chat_errors_total',
help: 'Errors observed in the chat service grouped by phase.',
labelNames: ['phase'],
registers: [register],
});
return {
deliveredMessages,
deliveredMessageBytes,
channelHistoryLoadedMessages,
channelHistorySize,
emojiSearchResults,
errors,
inboundPayloadBytes,
incomingMessages,
messageDuration,
moderationActions,
moderationBlocks,
moderationState,
register,
channelUniqueChatters,
websocketConnectionAttempts,
websocketConnections,
websocketConnectionsByAuthMethod,
websocketConnectionsByChannel,
};
}
const globalForMetrics = globalThis as typeof globalThis & {
__hctvChatMetrics?: ReturnType<typeof createMetricsStore>;
};
const metrics = (globalForMetrics.__hctvChatMetrics ??= createMetricsStore());
export const chatMetricsRegistry = metrics.register;
export function recordChatConnectionAccepted(channel: string, authMethod: string): void {
metrics.websocketConnectionAttempts.inc({
auth_method: authMethod,
outcome: 'accepted',
reason: 'none',
});
metrics.websocketConnections.inc();
metrics.websocketConnectionsByChannel.inc({ channel });
metrics.websocketConnectionsByAuthMethod.inc({ auth_method: authMethod });
}
export function recordChatConnectionRejected(authMethod: string, reason: string): void {
metrics.websocketConnectionAttempts.inc({ auth_method: authMethod, outcome: 'rejected', reason });
}
export function recordChatDisconnect(channel: string, authMethod: string): void {
metrics.websocketConnections.dec();
metrics.websocketConnectionsByChannel.dec({ channel });
metrics.websocketConnectionsByAuthMethod.dec({ auth_method: authMethod });
}
export function recordIncomingChatMessage(type: string, payloadBytes: number): void {
metrics.incomingMessages.inc({ type });
metrics.inboundPayloadBytes.inc({ type }, payloadBytes);
}
export function startChatMessageTimer(type: string) {
return metrics.messageDuration.startTimer({ type });
}
export function recordDeliveredChatMessage(senderType: string): void {
metrics.deliveredMessages.inc({ sender_type: senderType });
}
export function recordDeliveredChatMessageBytes(senderType: string, bytes: number): void {
metrics.deliveredMessageBytes.inc({ sender_type: senderType }, bytes);
}
export function setChannelHistorySize(channel: string, size: number): void {
metrics.channelHistorySize.set({ channel }, size);
}
export function recordHistoryMessagesLoaded(channel: string, count: number): void {
if (count > 0) {
metrics.channelHistoryLoadedMessages.inc({ channel }, count);
}
}
export function setChatModerationState(
channel: string,
settings: {
blockedTerms: number;
maxMessageLength: number;
rateLimitCount: number;
rateLimitWindowSeconds: number;
slowModeSeconds: number;
}
): void {
metrics.moderationState.set({ channel, setting: 'blocked_terms' }, settings.blockedTerms);
metrics.moderationState.set({ channel, setting: 'slow_mode_seconds' }, settings.slowModeSeconds);
metrics.moderationState.set(
{ channel, setting: 'max_message_length' },
settings.maxMessageLength
);
metrics.moderationState.set({ channel, setting: 'rate_limit_count' }, settings.rateLimitCount);
metrics.moderationState.set(
{ channel, setting: 'rate_limit_window_seconds' },
settings.rateLimitWindowSeconds
);
}
export function recordUniqueChatter(senderType: string): void {
metrics.channelUniqueChatters.inc({ sender_type: senderType });
}
export function recordChatModerationAction(action: string): void {
metrics.moderationActions.inc({ action });
}
export function recordChatModerationBlock(reason: string): void {
metrics.moderationBlocks.inc({ reason });
}
export function recordChatError(phase: string): void {
metrics.errors.inc({ phase });
}
export function recordEmojiSearchResults(outcome: string, count: number): void {
metrics.emojiSearchResults.observe({ outcome }, count);
}

View File

@@ -0,0 +1,70 @@
import type { ModifiedWebSocket } from '@hctv/hono-ws';
export interface ChatUser {
id: string;
username: string;
pfpUrl: string;
displayName?: string;
isBot: boolean;
moderatorUserId: string;
isPlatformAdmin: boolean;
channelRole: 'owner' | 'manager' | 'chatModerator' | 'botModerator' | null;
}
export interface ChatModerationSettingsShape {
blockedTerms: string[];
slowModeSeconds: number;
maxMessageLength: number;
rateLimitCount: number;
rateLimitWindowSeconds: number;
}
export interface ChatRestrictionState {
type: 'timeout' | 'ban';
reason: string;
expiresAt: Date | null;
}
export interface ChatSocket {
readyState: number;
OPEN: number;
send: (data: string) => void;
close: () => void;
wss: {
clients: Set<unknown>;
};
targetUsername?: string;
channelId?: string;
chatUser?: ChatUser | null;
personalChannel?: any;
viewerId?: string;
isModerator?: boolean;
metricsTracked?: boolean;
metricsAuthMethod?: string;
excludeFromViewerCount?: boolean;
raw?:
| (ModifiedWebSocket & {
targetUsername?: string;
channelId?: string;
chatUser?: ChatUser | null;
personalChannel?: any;
isModerator?: boolean;
metricsTracked?: boolean;
metricsAuthMethod?: string;
excludeFromViewerCount?: boolean;
})
| null;
}
export type ChatModerationCommand = {
type:
| 'mod:deleteMessage'
| 'mod:timeoutUser'
| 'mod:banUser'
| 'mod:unbanUser'
| 'mod:liftTimeout';
msgId?: string;
targetUserId?: string;
durationSeconds?: number;
reason?: string;
};

View File

@@ -0,0 +1,411 @@
import { ChatModerationAction, prisma } from '@hctv/db';
import { recordChatModerationAction } from '../metrics.js';
import type {
ChatModerationCommand,
ChatRestrictionState,
ChatSocket,
ChatUser,
} from '../types/chat.js';
const ROLE_RANK: Record<NonNullable<ChatUser['channelRole']> | '__none__', number> = {
owner: 100,
manager: 50,
chatModerator: 10,
botModerator: 10,
__none__: 0,
};
function roleRank(role: ChatUser['channelRole']): number {
return role ? (ROLE_RANK[role] ?? 0) : ROLE_RANK.__none__;
}
type ModerationContext = {
chatUser: ChatUser;
targetUsername: string;
channelId: string;
};
type DeleteMessageDeps = {
deleteMessageFromHistory: (targetUsername: string, msgId: string) => Promise<boolean>;
logModerationEvent: (payload: {
action: ChatModerationAction;
channelId: string;
moderatorId: string;
targetUserId?: string;
reason?: string;
details?: Record<string, unknown>;
}) => Promise<void>;
broadcastToChannel: (
targetUsername: string,
ws: ChatSocket,
payload: Record<string, unknown>
) => void;
};
type UserRestrictionDeps = {
logModerationEvent: (payload: {
action: ChatModerationAction;
channelId: string;
moderatorId: string;
targetUserId?: string;
reason?: string;
details?: Record<string, unknown>;
}) => Promise<void>;
broadcastRestrictionStateToUser: (
targetUsername: string,
targetUserId: string,
channelId: string,
ws: ChatSocket
) => Promise<void>;
broadcastToChannel: (
targetUsername: string,
ws: ChatSocket,
payload: Record<string, unknown>
) => void;
};
export function sendModerationError(
socket: ChatSocket,
code: string,
message: string,
restriction?: ChatRestrictionState
) {
socket.send(
JSON.stringify({
type: 'moderationError',
code,
message,
restriction,
})
);
}
async function requireModerationContext(
socket: ChatSocket,
socketState: ChatSocket
): Promise<ModerationContext | null> {
if (!socketState.chatUser || !socketState.targetUsername || !socketState.channelId) {
sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.');
return null;
}
const chatUser = socketState.chatUser;
const channelId = socketState.channelId;
const [channel, moderatorRecord] = await Promise.all([
prisma.channel.findUnique({
where: { id: channelId },
select: {
ownerId: true,
managers: { select: { id: true } },
chatModerators: { select: { id: true } },
chatModeratorBots: { select: { id: true } },
},
}),
prisma.user.findUnique({
where: { id: chatUser.moderatorUserId },
select: { isAdmin: true },
}),
]);
if (!channel) {
sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.');
return null;
}
const isPlatformAdmin = chatUser.isBot ? false : Boolean(moderatorRecord?.isAdmin);
let channelRole: ChatUser['channelRole'] = null;
if (chatUser.isBot) {
if (channel.chatModeratorBots.some((b) => b.id === chatUser.id)) {
channelRole = 'botModerator';
}
} else if (channel.ownerId === chatUser.id) {
channelRole = 'owner';
} else if (channel.managers.some((m) => m.id === chatUser.id)) {
channelRole = 'manager';
} else if (channel.chatModerators.some((m) => m.id === chatUser.id)) {
channelRole = 'chatModerator';
}
const isModerator =
isPlatformAdmin ||
channelRole === 'owner' ||
channelRole === 'manager' ||
channelRole === 'chatModerator' ||
channelRole === 'botModerator';
if (!isModerator) {
sendModerationError(socket, 'FORBIDDEN', 'You do not have permission to moderate this chat.');
return null;
}
const resolvedChatUser: ChatUser = { ...chatUser, isPlatformAdmin, channelRole };
return {
chatUser: resolvedChatUser,
targetUsername: socketState.targetUsername,
channelId,
};
}
async function resolveModerationTarget(
socket: ChatSocket,
actingModeratorUserId: string,
rawTargetUserId: unknown,
channelId: string
) {
const targetUserId = typeof rawTargetUserId === 'string' ? rawTargetUserId : '';
if (!targetUserId || targetUserId === actingModeratorUserId) {
sendModerationError(socket, 'INVALID_TARGET', 'Invalid moderation target.');
return null;
}
const targetUserRecord = await prisma.user.findUnique({
where: { id: targetUserId },
select: {
isAdmin: true,
personalChannel: { select: { name: true } },
ownedChannels: { where: { id: channelId }, select: { id: true } },
managedChannels: { where: { id: channelId }, select: { id: true } },
chatModeratedChannels: { where: { id: channelId }, select: { id: true } },
},
});
if (!targetUserRecord) {
sendModerationError(socket, 'INVALID_TARGET', 'Target user no longer exists.');
return null;
}
let targetChannelRole: ChatUser['channelRole'] = null;
if (targetUserRecord.ownedChannels.length > 0) {
targetChannelRole = 'owner';
} else if (targetUserRecord.managedChannels.length > 0) {
targetChannelRole = 'manager';
} else if (targetUserRecord.chatModeratedChannels.length > 0) {
targetChannelRole = 'chatModerator';
}
return {
targetUserId,
targetUserRecord,
targetChannelRole,
resolvedTargetUsername: targetUserRecord.personalChannel?.name ?? 'that user',
};
}
async function ensureAdminTargetModerationAllowed(
socket: ChatSocket,
actingModeratorUserId: string,
targetIsAdmin: boolean
) {
if (!targetIsAdmin) {
return true;
}
const actingUserRecord = await prisma.user.findUnique({
where: { id: actingModeratorUserId },
select: { isAdmin: true },
});
if (!actingUserRecord?.isAdmin) {
sendModerationError(
socket,
'FORBIDDEN',
'Platform admins cannot be moderated via chat commands.'
);
return false;
}
return true;
}
function ensureRoleHierarchyAllowed(
socket: ChatSocket,
actorRole: ChatUser['channelRole'],
actorIsPlatformAdmin: boolean,
targetRole: ChatUser['channelRole']
): boolean {
if (actorIsPlatformAdmin) return true;
if (roleRank(actorRole) <= roleRank(targetRole)) {
sendModerationError(
socket,
'FORBIDDEN',
'You cannot moderate a user with an equal or higher role than yours.'
);
return false;
}
return true;
}
export async function handleDeleteMessageCommand(
socket: ChatSocket,
socketState: ChatSocket,
msg: ChatModerationCommand,
deps: DeleteMessageDeps
) {
const context = await requireModerationContext(socket, socketState);
if (!context) {
return;
}
const msgId = typeof msg.msgId === 'string' ? msg.msgId : '';
if (!msgId) {
sendModerationError(socket, 'INVALID_REQUEST', 'Invalid message id.');
return;
}
const deleted = await deps.deleteMessageFromHistory(context.targetUsername, msgId);
if (!deleted) {
sendModerationError(socket, 'NOT_FOUND', 'Message not found.');
return;
}
await deps.logModerationEvent({
action: ChatModerationAction.MESSAGE_DELETED,
channelId: context.channelId,
moderatorId: context.chatUser.moderatorUserId,
reason: 'Message deleted by moderator',
details: { msgId },
});
recordChatModerationAction('message_deleted');
deps.broadcastToChannel(context.targetUsername, socket, { type: 'messageDeleted', msgId });
}
export async function handleUserRestrictionCommand(
socket: ChatSocket,
socketState: ChatSocket,
msg: ChatModerationCommand,
deps: UserRestrictionDeps
) {
const context = await requireModerationContext(socket, socketState);
if (!context) {
return;
}
const actingModeratorUserId = context.chatUser.moderatorUserId;
const target = await resolveModerationTarget(
socket,
actingModeratorUserId,
msg.targetUserId,
context.channelId
);
if (!target) {
return;
}
const canModerateTarget = await ensureAdminTargetModerationAllowed(
socket,
actingModeratorUserId,
target.targetUserRecord.isAdmin
);
if (!canModerateTarget) {
return;
}
const hierarchyAllowed = ensureRoleHierarchyAllowed(
socket,
context.chatUser.channelRole,
context.chatUser.isPlatformAdmin,
target.targetChannelRole
);
if (!hierarchyAllowed) {
return;
}
if (msg.type === 'mod:unbanUser' || msg.type === 'mod:liftTimeout') {
await prisma.chatUserBan.deleteMany({
where: {
channelId: context.channelId,
userId: target.targetUserId,
},
});
await deps.logModerationEvent({
action: ChatModerationAction.USER_UNBANNED,
channelId: context.channelId,
moderatorId: actingModeratorUserId,
targetUserId: target.targetUserId,
reason: 'User unbanned in chat',
});
recordChatModerationAction('user_unbanned');
await deps.broadcastRestrictionStateToUser(
context.targetUsername,
target.targetUserId,
context.channelId,
socket
);
deps.broadcastToChannel(context.targetUsername, socket, {
type: 'systemMsg',
message: `${target.resolvedTargetUsername} can chat again.`,
});
return;
}
const reason =
typeof msg.reason === 'string' && msg.reason.trim().length > 0
? msg.reason.trim().slice(0, 250)
: msg.type === 'mod:timeoutUser'
? 'Timed out by moderator'
: 'Banned by moderator';
const durationSeconds =
msg.type === 'mod:timeoutUser'
? Math.min(Math.max(Number(msg.durationSeconds) || 300, 10), 60 * 60 * 24)
: null;
const expiresAt = durationSeconds ? new Date(Date.now() + durationSeconds * 1000) : null;
await prisma.chatUserBan.upsert({
where: {
channelId_userId: {
channelId: context.channelId,
userId: target.targetUserId,
},
},
create: {
channelId: context.channelId,
userId: target.targetUserId,
bannedById: actingModeratorUserId,
reason,
expiresAt,
},
update: {
bannedById: actingModeratorUserId,
reason,
expiresAt,
},
});
await deps.logModerationEvent({
action:
msg.type === 'mod:timeoutUser'
? ChatModerationAction.USER_TIMEOUT
: ChatModerationAction.USER_BANNED,
channelId: context.channelId,
moderatorId: actingModeratorUserId,
targetUserId: target.targetUserId,
reason,
details: durationSeconds ? { durationSeconds } : undefined,
});
recordChatModerationAction(msg.type === 'mod:timeoutUser' ? 'user_timeout' : 'user_banned');
await deps.broadcastRestrictionStateToUser(
context.targetUsername,
target.targetUserId,
context.channelId,
socket
);
deps.broadcastToChannel(context.targetUsername, socket, {
type: 'systemMsg',
message:
msg.type === 'mod:timeoutUser'
? `${target.resolvedTargetUsername} was timed out for ${durationSeconds}s.`
: `${target.resolvedTargetUsername} was banned.`,
});
}

View File

@@ -2,24 +2,44 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import mermaid from 'astro-mermaid';
import catppuccin from "@catppuccin/starlight";
import catppuccin from '@catppuccin/starlight';
import starlightTypeDoc, { typeDocSidebarGroup } from 'starlight-typedoc';
// https://astro.build/config
export default defineConfig({
integrations: [
integrations: [
mermaid({
theme: 'base',
autoTheme: true
autoTheme: true,
}),
starlight({
title: 'hctv docs',
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/SrIzan10/hctv' }],
starlight({
title: 'hctv docs',
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/SrIzan10/hctv' }],
plugins: [
catppuccin({
dark: { flavor: "mocha", accent: "blue" },
light: { flavor: "latte", accent: "blue" }
dark: { flavor: 'mocha', accent: 'blue' },
light: { flavor: 'latte', accent: 'blue' },
}),
]
}),
],
starlightTypeDoc({
entryPoints: ['../../packages/sdk/src/index.ts'],
tsconfig: '../../packages/sdk/tsconfig.json',
output: 'typedoc-sdk',
sidebar: {
label: 'SDK Reference',
},
}),
],
sidebar: [
{
label: 'API',
autogenerate: { directory: 'api' },
},
{
label: 'Guides',
autogenerate: { directory: 'guides' },
},
typeDocSidebarGroup,
],
}),
],
});

View File

@@ -15,6 +15,9 @@
"astro": "^5.6.1",
"astro-mermaid": "^1.0.4",
"mermaid": "^11.10.1",
"sharp": "^0.34.2"
"sharp": "^0.34.2",
"starlight-typedoc": "^0.21.5",
"typedoc": "^0.28.16",
"typedoc-plugin-markdown": "^4.9.0"
}
}

View File

@@ -11,69 +11,249 @@ The chat system is powered by a websocket server. Please read the entire page be
The websocket server is located at `wss://hackclub.tv/api/chat/ws/:username`, where `:username` is the channel you want to connect to.
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
<Aside type="tip">
Bot accounts are now supported. You can choose to connect as a bot by providing a bot account's API key on the Authentication header: `Bearer hctvb_xxxxxxx`
Bot accounts are now supported. You can choose to connect as a bot by providing a bot account's API key in one of two ways:
- Using the `Authorization` header: `Bearer hctvb_xxxxxxx`
- Using the `?botAuth=hctvb_xxxxxxx` query parameter
**Security Note:** When using the `?botAuth=` query parameter, be aware that query parameters may be logged in server logs, and/or proxy logs. Use the `Authorization` header method whenever possible. The query parameter method should only be used when connecting from an environment where headers cannot be set.
It is highly advised to use a bot account for any automated task, and to implement anything pointed out in this page.
</Aside>
Once connected, you must implement a subroutine in your code to send ping messages every 5 seconds. This is because of Cloudflare limitations.
Once connected, you must implement a subroutine in your code to send ping messages every about 5 seconds. This is because of Cloudflare limitations.
Messages are sent and received in JSON format. The following message types are supported:
- `message`: a chat message.
- sent by client:
- `session`: sent by the server immediately upon connection.
- received by client:
```json
{
"type": "session",
"viewer": {
"id": "user_id",
"username": "your_username"
},
"permissions": {
"canModerate": false
},
"moderation": {
"hasBlockedTerms": false,
"slowModeSeconds": 0,
"maxMessageLength": 400
}
}
```
`viewer` is `null` for unauthenticated (grant-only) connections. `canModerate` is `true` for channel owners, managers, moderators, and platform admins.
- `chatAccess`: sent by the server on connect (for authenticated non-bot users) and whenever a user's restriction state changes.
- received by client:
```json
{
"type": "chatAccess",
"canSend": true,
"restriction": null
}
```
When the user is restricted, `canSend` is `false` and `restriction` contains:
```json
{
"type": "timeout",
"reason": "Timed out by moderator",
"expiresAt": "2026-01-01T00:00:00.000Z"
}
```
`type` is either `"timeout"` or `"ban"`. `expiresAt` is an ISO 8601 string for timeouts, or `null` for permanent bans.
- `ping`: a ping message to keep the connection alive.
- sent by client:
```json
{
"type": "ping"
}
```
- received by client:
```json
{
"type": "pong"
}
```
- `message`: a chat message.
- sent by client:
```json
{
"type": "message",
"content": "Hello, world!"
"message": "Hello, world!"
}
```
- received by client:
- received by client (broadcast to all viewers of the channel):
```json
{
"type": "message",
"msgId": "uuid-v4",
"user": {
"id": "user_id",
"username": "user_who_sent_message",
"avatar": "https://emoji.slack-edge.com/avatar.png"
"pfpUrl": "https://example.com/avatar.png",
"displayName": "Display Name",
"isBot": false,
"isPlatformAdmin": false,
"channelRole": null
},
"message": "Hello, world!",
"message": "Hello, world!"
}
```
- `ping`: a ping message to keep the connection alive.
- sent by client:
```json
{
"type": "ping"
}
```
- received by client:
```json
{
"type": "ping"
}
```
- `history`: a message containing the chat history. This is sent upon connection.
- received by client:
`channelRole` is one of `"owner"`, `"manager"`, `"chatModerator"`, `"botModerator"`, or `null`. `displayName` may be `undefined` for regular users.
- `history`: the recent chat history, sent upon connection.
- received by client:
```json
{
"type": "history",
"messages": [
{
"type": "message",
"msgId": "uuid-v4",
"user": {
"id": "user_id",
"username": "user_who_sent_message",
"avatar": "https://emoji.slack-edge.com/avatar.png"
"pfpUrl": "https://example.com/avatar.png",
"displayName": "Display Name",
"isBot": false,
"isPlatformAdmin": false,
"channelRole": null
},
"message": "Hello, world!",
"type": "message",
},
...
"message": "Hello, world!"
}
]
}
```
Up to 100 messages are returned. Each message has the same shape as a received `message` event.
- `systemMsg`: a system notification broadcast to all viewers, e.g. when a user is banned or unbanned.
- received by client:
```json
{
"type": "systemMsg",
"message": "username was banned."
}
```
- `moderationError`: sent to the acting client when a message or moderation action is rejected.
- received by client:
```json
{
"type": "moderationError",
"code": "RATE_LIMIT",
"message": "You are sending messages too fast.",
"restriction": null
}
```
`restriction` is only present (non-null) for `TIMED_OUT` and `BANNED` codes, and has the same shape as the `restriction` field in `chatAccess`. Possible codes:
| Code | Trigger |
| ------------------ | ---------------------------------------------- |
| `FORBIDDEN` | Not permitted to perform the action |
| `RATE_LIMIT` | Too many messages in the rate limit window |
| `SLOW_MODE` | Sent before the slow mode cooldown expired |
| `TIMED_OUT` | User is currently timed out |
| `BANNED` | User is permanently banned |
| `MESSAGE_TOO_LONG` | Message exceeds `maxMessageLength` |
| `BLOCKED_TERM` | Message contains a blocked term |
| `INVALID_TARGET` | Moderation target is invalid or does not exist |
| `INVALID_REQUEST` | Malformed moderation command |
| `NOT_FOUND` | Target message not found (delete) |
## Moderation commands
moderation commands are only available to authenticated users with the `canModerate` permission (`owner`, `manager`, `chatModerator`, `botModerator`, or platform admin). sending any of these without permission returns a `moderationError` with code `FORBIDDEN`.
obviously, role hierarchy is enforced: a `chatModerator` cannot moderate a `manager` or `owner`. Platform admins bypass hierarchy checks entirely.
- `mod:deleteMessage`: delete a message from the chat history and broadcast its removal.
- sent by client:
```json
{
"type": "mod:deleteMessage",
"msgId": "uuid-of-message-to-delete"
}
```
- received by all clients on success:
```json
{
"type": "messageDeleted",
"msgId": "uuid-of-message-to-delete"
}
```
- `mod:timeoutUser`: temporarily restrict a user from sending messages.
- sent by client:
```json
{
"type": "mod:timeoutUser",
"targetUserId": "user_id",
"durationSeconds": 300,
"reason": "Optional reason"
}
```
`durationSeconds` is clamped between 10 and 86400 (24 hours). Defaults to 300 if omitted. On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update.
- `mod:banUser`: permanently ban a user from sending messages.
- sent by client:
```json
{
"type": "mod:banUser",
"targetUserId": "user_id",
"reason": "Optional reason"
}
```
On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update.
- `mod:liftTimeout` / `mod:unbanUser`: remove an active timeout or ban.
- sent by client:
```json
{
"type": "mod:liftTimeout",
"targetUserId": "user_id"
}
```
Both types behave identically and remove any active restriction for the target user. On success, a `systemMsg` is broadcast and the target receives a `chatAccess` update with `canSend: true`.
## Emoji handling
*diagram source: devin deepwiki*
_diagram source: devin deepwiki_
```mermaid
graph TB
subgraph "Emoji Processing Pipeline"
@@ -110,21 +290,29 @@ The server then checks Redis for the emoji URL and returns it.
When a user wants to look up an emoji (by typing `:(partial name)`), the server uses uFuzzy to find matching emojis in the Redis `emojis` hash key and returns the results.
<Aside type="caution">
`emojiMsg` and `emojiSearch` require an authenticated connection. They are not available to
grant-only (OBS) viewers.
</Aside>
Here's what gets sent on the websocket:
- `emojiMsg`: Looks up emojis
- sent by client:
```json
{
"type": "emojiMsg",
"emojis": ["aga", "yapa", "heavysob", "yay", "yay-bounce"]
}
```
- received by client:
```json
{
"type": "emojiMsgResponse",
"emojis": {
// rough example of urls
"aga": "https://emoji.slack-edge.com/aga.png",
"yapa": "https://emoji.slack-edge.com/yapa.png",
"heavysob": "https://emoji.slack-edge.com/heavysob.png",
@@ -133,20 +321,23 @@ Here's what gets sent on the websocket:
}
}
```
- `emojiSearch`: Searches for emojis
- sent by client:
```json
{
"type": "emojiSearch",
"searchTerm": "aga"
}
```
- received by client:
```json
{
"type": "emojiSearchResponse",
"results": [
// real results btw
"aga",
"aga-brick-throw",
"aga-dance",

View File

@@ -3,10 +3,6 @@ title: RTMP
description: RTMP related endpoint group
---
## GET `/rtmp/hls/:path`
gets HLS segments, the backbone of hctv livestreaming. **authentication required**.
not really sure why you would need this? but check the browser console when playing a stream for an insight on how it's used.
## POST `/rtmp/streamKey`
regenerates your stream key. **authentication required**.
body parameters (json):

View File

@@ -0,0 +1,32 @@
---
title: Development Setup
description: Instructions to set up a local development environment for hackclub.tv
---
1. clone repo
2. `pnpm install`
3. `cp apps/web/.env.example apps/web/.env && cp packages/db/.env.example packages/db/.env`
4. `pnpm dev`
5. `pnpm db:migrate` (RUN THIS AFTER POPULATING ENV)
- slack notifier app manifest is as follows:
```
display_information:
# please change the name to something that can be linked to you if possible
name: hctv notifier dev
features:
bot_user:
# same with this :pray:
display_name: hctv notifier dev
always_online: false
oauth_config:
scopes:
bot:
- chat:write
- users:read
- channels:join
settings:
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
```

View File

@@ -7,7 +7,7 @@ description: Get started with OBS and streaming on hackclub.tv
- open settings
- open "Stream"
- set service to custom
- set url to `rtmp://backend.hackclub.tv/live`
- set url to `srt://localhost:8890?streamid=publish:CHANNEL_NAME:thisusernameislongonpurposesoyoudontaccidentallyleakyourstreamkey:STREAM_KEY&pkt_size=1316`
- on the website, click "Regenerate key"
- paste the key into your obs "Stream Key"
- start streaming!

31
apps/web/.env.example Normal file
View File

@@ -0,0 +1,31 @@
DATABASE_URL=postgresql://postgres:skbiditoilet@localhost:5555/postgres
# make a slack app here: https://api.slack.com/apps
SLACK_NOTIFIER_TOKEN=<make a bot for this, check app manifest below>
# invite your bot to the channel you created. below is #hctv-dev, so use that if you want!
NOTIFICATION_CHANNEL_ID=C08M3MGE6PJ
REDIS_URL=redis://localhost:6379
# get from https://uploadthing.com/
UPLOADTHING_TOKEN=<get from uploadthing>
# enable oauth mode on your hca account and make an app: https://auth.hackclub.com/identity/edit
HCID_CLIENT=<auth.hackclub.com client>
HCID_SECRET=<auth.hackclub.com secret>
# make sure to put this as one of the redirect uri
HCID_REDIRECT_URI=http://localhost:3000/auth/hackclub/callback
# mediamtx settings
NEXT_PUBLIC_MEDIAMTX_URL_HQ=http://localhost:8891
MEDIAMTX_API_HQ=http://localhost:9997
NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ=localhost:8890
# commented because we don't have another ingest server as of right now
# NEXT_PUBLIC_MEDIAMTX_URL_ASIA=http://localhost:8991
# MEDIAMTX_API_ASIA=http://localhost:9999
# NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_ASIA=localhost:8990
# idt you should change this
MEDIAMTX_PUBLISH_KEY=rjq1xdpCPA4qyt3jge

View File

@@ -1,4 +1,7 @@
FROM node:lts-slim AS base
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -7,9 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Replace <your-major-version> with the major version installed in your repository. For example:
# RUN yarn global add turbo@^2
RUN yarn global add turbo@^2
RUN pnpm add -g turbo@^2
COPY . .
# Get the git commit hash before pruning (since .git might be removed)
@@ -23,6 +24,7 @@ RUN turbo prune @hctv/web --docker
FROM base AS installer
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
libvips-dev \
python3 \
make \
@@ -35,13 +37,34 @@ WORKDIR /app
# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
RUN pnpm install --frozen-lockfile
# Install a standalone Prisma CLI for runtime migrations (no pnpm symlinks).
RUN mkdir -p /opt/prisma-cli && cd /opt/prisma-cli && npm init -y && npm install prisma@6.5.0
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM \
# Generate latest emojis.json during image build.
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then EMOJI_ARCH="x86_64"; \
elif [ "$ARCH" = "arm64" ]; then EMOJI_ARCH="aarch64"; \
else EMOJI_ARCH=""; fi && \
RELEASE_JSON=$(curl -fsSL https://api.github.com/repos/srizan10/hctv/releases/latest || true) && \
RELEASE_URL=$(printf '%s' "$RELEASE_JSON" | grep "browser_download_url.*slack-import-emojis-linux-${EMOJI_ARCH}" | cut -d '"' -f 4 || true) && \
if [ -n "$RELEASE_URL" ] && \
curl -fsSL -o /tmp/slack-import-emojis-bin "$RELEASE_URL" && \
chmod +x /tmp/slack-import-emojis-bin && \
/tmp/slack-import-emojis-bin default; then \
cp /app/emojis.json /app/apps/web/emojis.json; \
else \
cp /app/apps/web/src/lib/instrumentation/emojis.json /app/apps/web/emojis.json; \
fi
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM --mount=type=secret,id=SENTRY_AUTH_TOKEN \
COMMIT=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) \
commit=$COMMIT yarn turbo run build --env-mode=loose
SENTRY_AUTH_TOKEN=$(cat /run/secrets/SENTRY_AUTH_TOKEN) \
commit=$COMMIT pnpm turbo run build --env-mode=loose
FROM base AS runner
WORKDIR /app
@@ -52,20 +75,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 nodejs
RUN useradd --system --uid 1001 nextjs
RUN useradd --system --uid 1001 nextjs --create-home
# Copy Prisma files for migrations
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/prisma ./packages/db/prisma
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/generated ./packages/db/generated
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/package.json ./packages/db/package.json
COPY --from=installer --chown=nextjs:nodejs /app/node_modules ./node_modules
# Ensure home directory and cache directories have proper permissions
RUN mkdir -p /home/nextjs/.cache && \
chown -R nextjs:nodejs /home/nextjs
COPY --from=installer /tmp/commit_hash /tmp/commit_hash
RUN COMMIT_VALUE=$(cat /tmp/commit_hash 2>/dev/null || echo "unknown") && \
echo "#!/bin/sh" > /usr/local/bin/start.sh && \
echo "set -e" >> /usr/local/bin/start.sh && \
echo "export COREPACK_ENABLE_DOWNLOAD_PROMPT=0" >> /usr/local/bin/start.sh && \
echo "export HOME=/home/nextjs" >> /usr/local/bin/start.sh && \
echo "echo 'Running database migrations...'" >> /usr/local/bin/start.sh && \
echo "npx prisma migrate deploy --schema=/app/packages/db/prisma/schema.prisma" >> /usr/local/bin/start.sh && \
echo "node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy --schema /app/packages/db/prisma/schema.prisma" >> /usr/local/bin/start.sh && \
echo "cd /app" >> /usr/local/bin/start.sh && \
echo "export commit=$COMMIT_VALUE" >> /usr/local/bin/start.sh && \
echo "echo 'Starting Next.js application...'" >> /usr/local/bin/start.sh && \
@@ -79,6 +102,10 @@ USER nextjs
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --chown=nextjs:nodejs apps/web/emojis.json .
CMD ["/usr/local/bin/start.sh"]
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/emojis.json ./emojis.json
# Copy Prisma schema and migrations for prisma migrate deploy
COPY --from=installer --chown=nextjs:nodejs /app/packages/db/prisma ./packages/db/prisma
COPY --from=installer --chown=nextjs:nodejs /opt/prisma-cli /opt/prisma-cli
CMD ["/usr/local/bin/start.sh"]

View File

@@ -56,6 +56,9 @@ const nextConfig = {
},
];
},
logging: {
incomingRequests: false,
},
};
export default withSentryConfig(nextConfig, {
@@ -66,6 +69,9 @@ export default withSentryConfig(nextConfig, {
project: "hctv",
// Auth token for uploading source maps
authToken: process.env.SENTRY_AUTH_TOKEN,
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
@@ -75,6 +81,9 @@ export default withSentryConfig(nextConfig, {
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Hides source maps from generated client bundles
hideSourceMaps: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-

View File

@@ -5,21 +5,23 @@
"type": "module",
"scripts": {
"dd": "docker compose --file ../../dev/docker-compose.yml up -d",
"dev": "next dev --turbo",
"dev": "next dev --turbo -H 0.0.0.0",
"donly": "docker compose --file ../../dev/docker-compose.yml up",
"build": "next build",
"start": "next start",
"lint": "next lint",
"ui:add": "shadcn add",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"genMtxTypes": "bunx openapi-typescript https://github.com/bluenviron/mediamtx/raw/refs/tags/v1.15.5/api/openapi.yaml -o ./src/lib/types/mediamtx.d.ts"
},
"dependencies": {
"@hctv/auth": "*",
"@hctv/db": "*",
"@hctv/auth": "workspace:*",
"@hctv/db": "workspace:*",
"@hookform/resolvers": "^3.9.1",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@node-rs/argon2": "^2.0.2",
"@omit/react-confirm-dialog": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.5",
@@ -29,7 +31,7 @@
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
@@ -45,18 +47,23 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"hls-video-element": "^1.5.0",
"hls.js": "^1.6.15",
"lucia": "^3.2.2",
"lucide-react": "^0.473.0",
"media-chrome": "^4.8.0",
"next": "^15.6.0-canary.34",
"next": "^16.1.0",
"next-themes": "^0.4.4",
"node-cron": "^3.0.3",
"nuqs": "^2.4.3",
"pg": "^8.14.1",
"pg-boss": "^10.1.6",
"react": "19",
"react-dom": "19",
"prom-client": "^15.1.3",
"react": "^19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.3",
"react-hook-form": "^7.54.2",
"rehype-raw": "^7.0.0",
"rehype-react": "^8.0.0",

View File

@@ -5,11 +5,34 @@ export default async function Page({ params }: { params: Promise<{ username: str
const { username } = await params;
const streamInfo = await prisma.streamInfo.findUnique({
where: { username },
include: { channel: true },
include: {
channel: {
include: {
restriction: true,
},
},
},
});
if (!streamInfo) {
return <div>Stream not found</div>;
}
if (streamInfo.channel.restriction) {
const isExpired = streamInfo.channel.restriction.expiresAt &&
new Date(streamInfo.channel.restriction.expiresAt) < new Date();
if (!isExpired) {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-64px)] p-4">
<h1 className="text-2xl font-bold text-destructive mb-2">Channel Restricted</h1>
<p className="text-muted-foreground text-center max-w-md">
This channel has been restricted by a moderator and is not currently available for viewing.
</p>
</div>
);
}
}
return (
<LiveStream username={username} streamInfo={streamInfo} />
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
import { validateRequest } from '@/lib/auth/validate';
import { redirect } from 'next/navigation';
import AdminPanelClient from './page.client';
export default async function AdminPage() {
const { user } = await validateRequest();
if (!user) {
redirect('/auth/hackclub');
}
if (!user.isAdmin) {
redirect('/');
}
return <AdminPanelClient currentUser={user} />;
}

View File

@@ -0,0 +1,559 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { format, formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import {
ArrowLeft,
Gavel,
Flag,
User,
MessageSquare,
Clock,
ShieldAlert,
ShieldOff,
ShieldCheck,
Trash2,
CheckCircle2,
XCircle,
AlertTriangle,
Timer,
Ban,
Unlock,
Info,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
type ReportModerationAction =
| 'review'
| 'dismiss'
| 'delete_reported_message'
| 'timeout_10m'
| 'timeout_1h'
| 'ban_chat'
| 'lift_chat_ban'
| 'ban_platform'
| 'unban_platform';
type ActionSeverity = 'info' | 'moderate' | 'severe';
interface ActionOption {
value: ReportModerationAction;
label: string;
description: string;
severity: ActionSeverity;
requiresNote?: boolean;
icon: React.ReactNode;
}
const ACTION_OPTIONS: ActionOption[] = [
{
value: 'review',
label: 'Mark as reviewed',
description: 'Acknowledge the report without further action.',
severity: 'info',
icon: <CheckCircle2 className="h-4 w-4" />,
},
{
value: 'dismiss',
label: 'Dismiss',
description: 'Close this report as unfounded or resolved.',
severity: 'info',
icon: <XCircle className="h-4 w-4" />,
},
{
value: 'delete_reported_message',
label: 'Delete message',
description: 'Remove the reported message from the chat.',
severity: 'moderate',
requiresNote: true,
icon: <Trash2 className="h-4 w-4" />,
},
{
value: 'timeout_10m',
label: 'Timeout 10 minutes',
description: 'Prevent user from chatting for 10 minutes.',
severity: 'moderate',
requiresNote: true,
icon: <Timer className="h-4 w-4" />,
},
{
value: 'timeout_1h',
label: 'Timeout 1 hour',
description: 'Prevent user from chatting for 1 hour.',
severity: 'moderate',
requiresNote: true,
icon: <Timer className="h-4 w-4" />,
},
{
value: 'ban_chat',
label: 'Ban from chat',
description: 'Permanently ban user from chat.',
severity: 'severe',
requiresNote: true,
icon: <Ban className="h-4 w-4" />,
},
{
value: 'lift_chat_ban',
label: 'Lift chat ban',
description: 'Restore chat access for this user.',
severity: 'info',
requiresNote: true,
icon: <Unlock className="h-4 w-4" />,
},
{
value: 'ban_platform',
label: 'Ban from platform',
description: 'Permanently ban user from the entire platform.',
severity: 'severe',
requiresNote: true,
icon: <ShieldOff className="h-4 w-4" />,
},
{
value: 'unban_platform',
label: 'Unban from platform',
description: 'Restore platform access for this user.',
severity: 'info',
requiresNote: true,
icon: <ShieldCheck className="h-4 w-4" />,
},
];
const SEVERITY_STYLES: Record<
ActionSeverity,
{ card: string; selected: string; icon: string; ring: string }
> = {
info: {
card: 'border-border hover:border-muted-foreground/40 hover:bg-muted/30',
selected: 'border-primary bg-primary/5',
icon: 'text-muted-foreground',
ring: 'ring-primary/30',
},
moderate: {
card: 'border-border hover:border-amber-500/40 hover:bg-amber-500/5',
selected: 'border-amber-500 bg-amber-500/5',
icon: 'text-amber-500',
ring: 'ring-amber-500/30',
},
severe: {
card: 'border-border hover:border-destructive/40 hover:bg-destructive/5',
selected: 'border-destructive bg-destructive/5',
icon: 'text-destructive',
ring: 'ring-destructive/30',
},
};
const STATUS_CONFIG = {
OPEN: { label: 'Open', variant: 'destructive' as const, icon: <Flag className="h-3 w-3" /> },
REVIEWED: {
label: 'Reviewed',
variant: 'secondary' as const,
icon: <CheckCircle2 className="h-3 w-3" />,
},
DISMISSED: {
label: 'Dismissed',
variant: 'outline' as const,
icon: <XCircle className="h-3 w-3" />,
},
};
const ACTION_LABELS: Record<NonNullable<ChatReportCase['lastAction']>, string> = {
REVIEW: 'Marked as reviewed',
DISMISS: 'Dismissed',
DELETE_REPORTED_MESSAGE: 'Message deleted',
TIMEOUT_10M: 'User timed out (10m)',
TIMEOUT_1H: 'User timed out (1h)',
BAN_CHAT: 'Chat banned',
LIFT_CHAT_BAN: 'Chat ban lifted',
BAN_PLATFORM: 'Platform banned',
UNBAN_PLATFORM: 'Platform ban lifted',
};
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</span>
<span className="text-sm">{children}</span>
</div>
);
}
function SectionLabel({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2 mb-3">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{children}
</span>
</div>
);
}
export default function ReportCasePageClient({ report }: ReportCasePageClientProps) {
const router = useRouter();
const [selectedAction, setSelectedAction] = useState<ReportModerationAction>('review');
const [note, setNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const selectedMeta = ACTION_OPTIONS.find((o) => o.value === selectedAction)!;
const requiresNote = Boolean(selectedMeta?.requiresNote);
const isSevere = selectedMeta?.severity === 'severe';
const statusConfig = STATUS_CONFIG[report.status];
const submitAction = async () => {
if (requiresNote && note.trim().length < 10) {
toast.error('Please include at least 10 characters for enforcement context.');
return;
}
setIsSubmitting(true);
try {
const res = await fetch('/api/admin/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reportId: report.id,
action: selectedAction,
note: note.trim() || undefined,
}),
});
if (!res.ok) {
const errorText = await res.text();
toast.error(errorText || 'Failed to apply action');
return;
}
toast.success('Report action applied');
setNote('');
router.refresh();
} catch {
toast.error('Failed to apply action');
} finally {
setIsSubmitting(false);
}
};
const actionGroups: Array<{ label: string; actions: ActionOption[] }> = [
{
label: 'Informational',
actions: ACTION_OPTIONS.filter((a) => a.severity === 'info'),
},
{
label: 'Moderation',
actions: ACTION_OPTIONS.filter((a) => a.severity === 'moderate'),
},
{
label: 'Severe',
actions: ACTION_OPTIONS.filter((a) => a.severity === 'severe'),
},
];
return (
<div className="container max-w-5xl mx-auto py-8 px-4">
<div className="flex items-start justify-between gap-4 mb-8">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">Report Case</h1>
<Badge
variant={statusConfig.variant}
className="flex items-center gap-1.5 text-xs px-2 py-0.5"
>
{statusConfig.icon}
{statusConfig.label}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">{report.id}</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin?tab=reports&reportId=${report.id}`)}
className="shrink-0 gap-1.5"
>
<ArrowLeft className="h-4 w-4" />
Back to reports
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
<div className="md:col-span-3 space-y-5">
{report.reportedMessage ? (
<div className="rounded-lg border border-border bg-muted/20 overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-muted/30 flex items-center gap-2">
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Reported message
</span>
</div>
<div className="px-4 py-4">
<p className="text-sm leading-relaxed break-words">{report.reportedMessage}</p>
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-border px-4 py-5 flex items-center gap-3 text-muted-foreground">
<Info className="h-4 w-4 shrink-0" />
<span className="text-sm">No message content was captured with this report.</span>
</div>
)}
<div className="rounded-lg border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-muted/20">
<SectionLabel icon={<User className="h-3.5 w-3.5" />}>Parties</SectionLabel>
</div>
<div className="px-4 py-4 grid grid-cols-2 gap-x-6 gap-y-4">
<InfoRow label="Reporter">
<span className="font-medium">{report.reporter}</span>
</InfoRow>
<InfoRow label="Target">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{report.target}</span>
{report.targetIsAdmin && (
<Badge
variant="outline"
className="text-[10px] py-0 px-1.5 text-amber-500 border-amber-500/40"
>
Admin
</Badge>
)}
{report.targetIsPlatformBanned && (
<Badge
variant="outline"
className="text-[10px] py-0 px-1.5 text-destructive border-destructive/40"
>
Platform banned
</Badge>
)}
</div>
</InfoRow>
<InfoRow label="Channel">
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{report.channelName}
</span>
</InfoRow>
<InfoRow label="Reason">
<span>{report.reason}</span>
</InfoRow>
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<div className="px-4 py-3 border-b border-border bg-muted/20">
<SectionLabel icon={<Clock className="h-3.5 w-3.5" />}>Timeline</SectionLabel>
</div>
<div className="px-4 py-4 space-y-4">
<InfoRow label="Filed">
<span>
{format(new Date(report.createdAt), 'PPP p')}{' '}
<span className="text-muted-foreground text-xs">
({formatDistanceToNow(new Date(report.createdAt), { addSuffix: true })})
</span>
</span>
</InfoRow>
{report.handledAt ? (
<InfoRow label="Last handled">
<span>
{format(new Date(report.handledAt), 'PPP p')}{' '}
<span className="text-muted-foreground text-xs">
({formatDistanceToNow(new Date(report.handledAt), { addSuffix: true })})
</span>
</span>
</InfoRow>
) : null}
<InfoRow label="Last action">
{report.lastAction ? (
<span className="font-medium">{ACTION_LABELS[report.lastAction]}</span>
) : (
<span className="text-muted-foreground">None</span>
)}
</InfoRow>
<InfoRow label="Handled by">
{report.handledBy ? (
<span className="font-medium">{report.handledBy}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</InfoRow>
{report.handlingNote ? (
<div className="pt-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground block mb-1.5">
Last note
</span>
<p className="text-sm bg-muted/40 rounded-md px-3 py-2.5 leading-relaxed border border-border">
{report.handlingNote}
</p>
</div>
) : null}
</div>
</div>
{report.targetIsAdmin && (
<div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm text-amber-700 dark:text-amber-400 leading-snug">
<span className="font-semibold">Caution:</span> The reported user is a platform
admin. Enforcement actions will still apply.
</div>
</div>
)}
</div>
<div className="md:col-span-2">
<div className="rounded-lg border border-border overflow-hidden sticky top-6">
<div className="px-4 py-3 border-b border-border bg-muted/20 flex items-center gap-2">
<Gavel className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Enforcement
</span>
</div>
<div className="px-4 py-4 space-y-5">
{/* Action selector */}
<div className="space-y-3">
{actionGroups.map((group) => (
<div key={group.label}>
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground mb-2">
{group.label}
</p>
<div className="space-y-1.5">
{group.actions.map((action) => {
const isSelected = selectedAction === action.value;
const styles = SEVERITY_STYLES[action.severity];
return (
<button
key={action.value}
type="button"
onClick={() => setSelectedAction(action.value)}
className={cn(
'w-full flex items-start gap-3 rounded-md border px-3 py-2.5 text-left transition-all cursor-pointer',
isSelected ? `${styles.selected} ring-1 ${styles.ring}` : styles.card
)}
>
<span
className={cn(
'mt-0.5 shrink-0',
isSelected ? styles.icon : 'text-muted-foreground'
)}
>
{action.icon}
</span>
<div className="min-w-0">
<p
className={cn(
'text-sm font-medium leading-tight',
isSelected && action.severity === 'severe' && 'text-destructive',
isSelected &&
action.severity === 'moderate' &&
'text-amber-600 dark:text-amber-400'
)}
>
{action.label}
</p>
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
{action.description}
</p>
</div>
</button>
);
})}
</div>
</div>
))}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="note" className="text-xs">
Moderator note
{requiresNote ? (
<span className="text-destructive ml-1">*</span>
) : (
<span className="text-muted-foreground ml-1">(optional)</span>
)}
</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Explain why this action is being taken."
rows={3}
className="text-sm resize-none"
/>
{requiresNote && (
<p className="text-[11px] text-muted-foreground">Min. 10 characters required.</p>
)}
</div>
{isSevere && (
<div className="flex items-start gap-2.5 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<ShieldAlert className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<p className="text-[12px] text-destructive leading-snug">
This is a severe action may often not be easily undone. Double-check before
applying.
</p>
</div>
)}
<Button
onClick={submitAction}
disabled={isSubmitting}
variant={isSevere ? 'destructive' : 'default'}
className="w-full gap-2"
size="sm"
>
<Gavel className="h-3.5 w-3.5" />
{isSubmitting ? 'Applying…' : 'Apply action'}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
// ─── Types ───────────────────────────────────────────────────────────────────
interface ReportCasePageClientProps {
report: ChatReportCase;
}
interface ChatReportCase {
id: string;
status: 'OPEN' | 'REVIEWED' | 'DISMISSED';
reason: string;
reportedMessage: string | null;
reportedMessageId: string | null;
targetUsername: string | null;
channelName: string;
createdAt: string;
handledAt: string | null;
handlingNote: string | null;
lastAction:
| 'REVIEW'
| 'DISMISS'
| 'DELETE_REPORTED_MESSAGE'
| 'TIMEOUT_10M'
| 'TIMEOUT_1H'
| 'BAN_CHAT'
| 'LIFT_CHAT_BAN'
| 'BAN_PLATFORM'
| 'UNBAN_PLATFORM'
| null;
reporter: string;
target: string;
targetUserId: string | null;
targetIsAdmin: boolean;
targetIsPlatformBanned: boolean;
handledBy: string | null;
}

View File

@@ -0,0 +1,96 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { notFound, redirect } from 'next/navigation';
import ReportCasePageClient from './page.client';
export default async function ReportCasePage({ params }: ReportCasePageProps) {
const { reportId } = await params;
const { user } = await validateRequest();
if (!user) {
redirect('/auth/hackclub');
}
if (!user.isAdmin) {
redirect('/');
}
const report = await prisma.chatUserReport.findUnique({
where: {
id: reportId,
},
include: {
channel: {
select: {
name: true,
},
},
reporter: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
targetUser: {
include: {
personalChannel: {
select: {
name: true,
},
},
ban: true,
},
},
handledBy: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
},
});
if (!report) {
notFound();
}
return (
<ReportCasePageClient
report={{
id: report.id,
status: report.status,
reason: report.reason,
reportedMessage: report.reportedMessage,
reportedMessageId: report.reportedMessageId,
targetUsername: report.targetUsername,
channelName: report.channel.name,
createdAt: report.createdAt.toISOString(),
handledAt: report.handledAt?.toISOString() ?? null,
handlingNote: report.handlingNote,
lastAction: report.lastAction,
reporter: report.reporter.personalChannel?.name ?? report.reporter.slack_id,
target:
report.targetUser?.personalChannel?.name ??
report.targetUsername ??
report.targetUserId ??
'unknown',
targetUserId: report.targetUserId,
targetIsAdmin: Boolean(report.targetUser?.isAdmin),
targetIsPlatformBanned: Boolean(report.targetUser?.ban),
handledBy: report.handledBy?.personalChannel?.name ?? report.handledBy?.slack_id ?? null,
}}
/>
);
}
interface ReportCasePageProps {
params: Promise<{
reportId: string;
}>;
}

View File

@@ -0,0 +1,178 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const take = Math.min(Math.max(Number(searchParams.get('take') ?? 100), 10), 250);
const [adminLogs, chatLogs] = await Promise.all([
prisma.adminAuditLog.findMany({
orderBy: { createdAt: 'desc' },
take,
include: {
actor: {
select: {
id: true,
isAdmin: true,
slack_id: true,
personalChannel: {
select: {
name: true,
},
},
},
},
},
}),
prisma.chatModerationEvent.findMany({
orderBy: { createdAt: 'desc' },
take,
include: {
channel: {
select: {
id: true,
name: true,
},
},
moderator: {
select: {
id: true,
isAdmin: true,
slack_id: true,
personalChannel: {
select: {
name: true,
},
},
},
},
targetUser: {
select: {
id: true,
isAdmin: true,
slack_id: true,
personalChannel: {
select: {
name: true,
},
},
},
},
},
}),
]);
const targetUserIds = [
...new Set(adminLogs.map((log) => log.targetUserId).filter(Boolean)),
] as string[];
const targetUsers =
targetUserIds.length > 0
? await prisma.user.findMany({
where: {
id: {
in: targetUserIds,
},
},
include: {
personalChannel: {
select: {
name: true,
},
},
},
})
: [];
const targetUserMap = new Map(
targetUsers.map((targetUser) => [
targetUser.id,
targetUser.personalChannel?.name ?? targetUser.slack_id,
])
);
const targetUserAdminMap = new Map(
targetUsers.map((targetUser) => [targetUser.id, targetUser.isAdmin])
);
const actorIds = [
...new Set([
...adminLogs.map((log) => log.actorId),
...chatLogs.map((log) => log.moderatorId),
...chatLogs.map((log) => log.targetUserId).filter(Boolean),
...targetUserIds,
]),
] as string[];
const modRoleUsers =
actorIds.length > 0
? await prisma.user.findMany({
where: {
id: { in: actorIds },
OR: [
{ ownedChannels: { some: {} } },
{ managedChannels: { some: {} } },
{ chatModeratedChannels: { some: {} } },
],
},
select: {
id: true,
},
})
: [];
const channelModSet = new Set(modRoleUsers.map((user) => user.id));
const normalizedAdminLogs = adminLogs.map((log) => ({
id: log.id,
source: 'platform' as const,
action: log.action,
createdAt: log.createdAt,
actor: log.actor.personalChannel?.name ?? log.actor.slack_id,
target:
log.targetChannel ??
(log.targetUserId ? (targetUserMap.get(log.targetUserId) ?? log.targetUserId) : null),
reason: log.reason,
details: log.details,
actorMeta: {
isPlatformAdmin: log.actor.isAdmin,
isChannelModerator: channelModSet.has(log.actorId),
},
targetMeta: log.targetUserId
? {
isPlatformAdmin: Boolean(targetUserAdminMap.get(log.targetUserId)),
isChannelModerator: channelModSet.has(log.targetUserId),
}
: null,
}));
const normalizedChatLogs = chatLogs.map((log) => ({
id: log.id,
source: 'chat' as const,
action: log.action,
createdAt: log.createdAt,
actor: log.moderator.personalChannel?.name ?? log.moderator.slack_id,
target: log.targetUser?.personalChannel?.name ?? log.channel.name,
reason: log.reason,
details: log.details,
channelName: log.channel.name,
actorMeta: {
isPlatformAdmin: log.moderator.isAdmin,
isChannelModerator: true,
},
targetMeta: log.targetUser
? {
isPlatformAdmin: log.targetUser.isAdmin,
isChannelModerator: channelModSet.has(log.targetUser.id),
}
: null,
}));
const logs = [...normalizedAdminLogs, ...normalizedChatLogs]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, take);
return Response.json(logs);
}

View File

@@ -0,0 +1,142 @@
import { validateRequest } from '@/lib/auth/validate';
import { AdminAuditAction, prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const search = searchParams.get('search') || '';
const channels = await prisma.channel.findMany({
where: search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
}
: undefined,
include: {
restriction: true,
owner: {
select: {
id: true,
slack_id: true,
pfpUrl: true,
personalChannel: { select: { name: true } },
},
},
personalFor: {
select: {
id: true,
},
},
},
take: 50,
});
return Response.json(channels);
}
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
let body: {
channelId: string;
action: 'restrict' | 'unrestrict';
reason?: string;
expiresAt?: string;
};
try {
body = await request.json();
} catch {
return new Response('Invalid JSON body', { status: 400 });
}
const { channelId, action, reason, expiresAt } = body;
if (!channelId || !action) {
return new Response('Missing required fields', { status: 400 });
}
let expiresAtDate: Date | null = null;
if (expiresAt !== undefined && expiresAt !== null && expiresAt !== '') {
expiresAtDate = new Date(expiresAt);
if (isNaN(expiresAtDate.getTime())) {
return new Response('Invalid expiresAt date', { status: 400 });
}
if (expiresAtDate <= new Date()) {
return new Response('expiresAt must be a future date', { status: 400 });
}
}
const channel = await prisma.channel.findUnique({ where: { id: channelId } });
if (!channel) {
return new Response('Channel not found', { status: 404 });
}
if (action === 'restrict') {
if (!reason) {
return new Response('Reason is required for restricting', { status: 400 });
}
await prisma.channelRestriction.upsert({
where: { channelId },
update: {
reason,
restrictedBy: user.id,
expiresAt: expiresAtDate,
},
create: {
channelId,
reason,
restrictedBy: user.id,
expiresAt: expiresAtDate,
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.CHANNEL_RESTRICTED,
actorId: user.id,
targetChannel: channel.name,
reason,
details: {
channelId,
expiresAt: expiresAtDate?.toISOString() ?? null,
} as any,
},
});
return Response.json({ success: true, message: 'Channel restricted' });
}
if (action === 'unrestrict') {
const deleted = await prisma.channelRestriction.deleteMany({ where: { channelId } });
if (deleted.count === 0) {
return new Response('Channel does not have an active restriction', { status: 400 });
}
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.CHANNEL_UNRESTRICTED,
actorId: user.id,
targetChannel: channel.name,
details: {
channelId,
} as any,
},
});
return Response.json({ success: true, message: 'Channel unrestricted' });
}
return new Response('Invalid action', { status: 400 });
}

View File

@@ -0,0 +1,529 @@
import { validateRequest } from '@/lib/auth/validate';
import {
AdminAuditAction,
ChatModerationAction,
ChatReportAction,
ChatReportStatus,
getRedisConnection,
prisma,
} from '@hctv/db';
import { NextRequest } from 'next/server';
const redis = getRedisConnection();
const REPORT_ALREADY_HANDLED_ERROR = 'REPORT_ALREADY_HANDLED';
const NO_ACTIVE_CHAT_BAN_ERROR = 'NO_ACTIVE_CHAT_BAN';
const NO_ACTIVE_PLATFORM_BAN_ERROR = 'NO_ACTIVE_PLATFORM_BAN';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const take = Math.min(Math.max(Number(searchParams.get('take') ?? 100), 10), 250);
const reportId = searchParams.get('reportId')?.trim();
const reports = await prisma.chatUserReport.findMany({
orderBy: { createdAt: 'desc' },
take,
include: {
channel: {
select: {
name: true,
},
},
reporter: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
targetUser: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
handledBy: {
include: {
personalChannel: {
select: {
name: true,
},
},
},
},
},
});
const normalizedReports = reports.map((report) => ({
id: report.id,
status: report.status,
reason: report.reason,
reportedMessage: report.reportedMessage,
reportedMessageId: report.reportedMessageId,
targetUsername: report.targetUsername,
channelName: report.channel.name,
createdAt: report.createdAt,
handledAt: report.handledAt,
handlingNote: report.handlingNote,
lastAction: report.lastAction,
reporter: report.reporter.personalChannel?.name ?? report.reporter.slack_id,
handledBy: report.handledBy?.personalChannel?.name ?? report.handledBy?.slack_id ?? null,
target:
report.targetUser?.personalChannel?.name ??
report.targetUsername ??
report.targetUserId ??
'unknown',
}));
return Response.json({
reports: normalizedReports,
reportId,
});
}
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
let body: {
reportId?: string;
action?:
| 'review'
| 'dismiss'
| 'delete_reported_message'
| 'timeout_10m'
| 'timeout_1h'
| 'ban_chat'
| 'lift_chat_ban'
| 'ban_platform'
| 'unban_platform';
note?: string;
};
try {
body = await request.json();
} catch {
return new Response('Invalid JSON body', { status: 400 });
}
const reportId = body.reportId?.trim();
const action = body.action;
const note = body.note?.trim() || null;
if (!reportId || !action) {
return new Response('Missing required fields', { status: 400 });
}
const report = await prisma.chatUserReport.findUnique({
where: { id: reportId },
include: {
channel: {
select: {
id: true,
name: true,
},
},
targetUser: {
select: {
id: true,
isAdmin: true,
},
},
},
});
if (!report) {
return new Response('Report not found', { status: 404 });
}
const targetUserId = report.targetUserId ?? report.targetUser?.id ?? null;
const isTargetAdmin = Boolean(report.targetUser?.isAdmin);
if (
(action === 'ban_platform' ||
action === 'ban_chat' ||
action === 'timeout_10m' ||
action === 'timeout_1h') &&
isTargetAdmin
) {
return new Response('Cannot enforce this action on an admin user', { status: 400 });
}
const reportPatchBase = {
handledById: user.id,
handledAt: new Date(),
handlingNote: note,
};
if (action === 'review') {
try {
await prisma.$transaction(async (tx) => {
const claimed = await tx.chatUserReport.updateMany({
where: { id: reportId, status: ChatReportStatus.OPEN },
data: {
...reportPatchBase,
status: ChatReportStatus.REVIEWED,
lastAction: ChatReportAction.REVIEW,
},
});
if (claimed.count === 0) {
throw new Error(REPORT_ALREADY_HANDLED_ERROR);
}
await tx.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_REVIEWED,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
} as any,
},
});
});
} catch (error) {
if (error instanceof Error && error.message === REPORT_ALREADY_HANDLED_ERROR) {
return new Response('Report has already been handled', { status: 409 });
}
throw error;
}
return Response.json({ success: true });
}
if (action === 'dismiss') {
try {
await prisma.$transaction(async (tx) => {
const claimed = await tx.chatUserReport.updateMany({
where: { id: reportId, status: ChatReportStatus.OPEN },
data: {
...reportPatchBase,
status: ChatReportStatus.DISMISSED,
lastAction: ChatReportAction.DISMISS,
},
});
if (claimed.count === 0) {
throw new Error(REPORT_ALREADY_HANDLED_ERROR);
}
await tx.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_DISMISSED,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
} as any,
},
});
});
} catch (error) {
if (error instanceof Error && error.message === REPORT_ALREADY_HANDLED_ERROR) {
return new Response('Report has already been handled', { status: 409 });
}
throw error;
}
return Response.json({ success: true });
}
if (action === 'delete_reported_message') {
if (!report.reportedMessageId) {
return new Response('No reported message id available for this report', { status: 400 });
}
const channelKey = `chat:history:${report.channel.name}`;
const history = await redis.zrange(channelKey, 0, -1);
let deleted = false;
for (const entry of history) {
try {
const parsed = JSON.parse(entry) as { msgId?: string };
if (parsed.msgId === report.reportedMessageId) {
await redis.zrem(channelKey, entry);
deleted = true;
break;
}
} catch {
continue;
}
}
if (!deleted) {
return new Response('Reported message was not found in chat history', { status: 404 });
}
try {
await prisma.$transaction(async (tx) => {
const claimed = await tx.chatUserReport.updateMany({
where: { id: reportId, status: ChatReportStatus.OPEN },
data: {
...reportPatchBase,
status: ChatReportStatus.REVIEWED,
lastAction: ChatReportAction.DELETE_REPORTED_MESSAGE,
},
});
if (claimed.count === 0) {
throw new Error(REPORT_ALREADY_HANDLED_ERROR);
}
await tx.chatModerationEvent.create({
data: {
action: ChatModerationAction.MESSAGE_DELETED,
channelId: report.channel.id,
moderatorId: user.id,
targetUserId,
reason: note ?? 'Message deleted from report review',
details: {
reportId,
msgId: report.reportedMessageId,
} as any,
},
});
await tx.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_ENFORCEMENT,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
enforcement: 'DELETE_REPORTED_MESSAGE',
msgId: report.reportedMessageId,
} as any,
},
});
});
} catch (error) {
if (error instanceof Error && error.message === REPORT_ALREADY_HANDLED_ERROR) {
return new Response('Report has already been handled', { status: 409 });
}
throw error;
}
return Response.json({ success: true });
}
if (!targetUserId) {
return new Response('Report target is unavailable', { status: 400 });
}
if (
action === 'timeout_10m' ||
action === 'timeout_1h' ||
action === 'ban_chat' ||
action === 'lift_chat_ban'
) {
const timeoutSeconds = action === 'timeout_10m' ? 600 : action === 'timeout_1h' ? 3600 : null;
try {
await prisma.$transaction(async (tx) => {
const claimed = await tx.chatUserReport.updateMany({
where: { id: reportId, status: ChatReportStatus.OPEN },
data: {
...reportPatchBase,
status: ChatReportStatus.REVIEWED,
lastAction:
action === 'timeout_10m'
? ChatReportAction.TIMEOUT_10M
: action === 'timeout_1h'
? ChatReportAction.TIMEOUT_1H
: action === 'ban_chat'
? ChatReportAction.BAN_CHAT
: ChatReportAction.LIFT_CHAT_BAN,
},
});
if (claimed.count === 0) {
throw new Error(REPORT_ALREADY_HANDLED_ERROR);
}
if (action === 'lift_chat_ban') {
const deleted = await tx.chatUserBan.deleteMany({
where: {
channelId: report.channel.id,
userId: targetUserId,
},
});
if (deleted.count === 0) {
throw new Error(NO_ACTIVE_CHAT_BAN_ERROR);
}
} else {
await tx.chatUserBan.upsert({
where: {
channelId_userId: {
channelId: report.channel.id,
userId: targetUserId,
},
},
create: {
channelId: report.channel.id,
userId: targetUserId,
bannedById: user.id,
reason: note ?? report.reason,
expiresAt: timeoutSeconds ? new Date(Date.now() + timeoutSeconds * 1000) : null,
},
update: {
bannedById: user.id,
reason: note ?? report.reason,
expiresAt: timeoutSeconds ? new Date(Date.now() + timeoutSeconds * 1000) : null,
},
});
}
await tx.chatModerationEvent.create({
data: {
action:
action === 'lift_chat_ban'
? ChatModerationAction.USER_UNBANNED
: action === 'ban_chat'
? ChatModerationAction.USER_BANNED
: ChatModerationAction.USER_TIMEOUT,
channelId: report.channel.id,
moderatorId: user.id,
targetUserId,
reason: note ?? report.reason,
details:
timeoutSeconds === null
? ({ reportId } as any)
: ({ reportId, durationSeconds: timeoutSeconds } as any),
},
});
await tx.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_ENFORCEMENT,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
enforcement:
action === 'timeout_10m'
? 'TIMEOUT_10M'
: action === 'timeout_1h'
? 'TIMEOUT_1H'
: action === 'ban_chat'
? 'BAN_CHAT'
: 'LIFT_CHAT_BAN',
} as any,
},
});
});
} catch (error) {
if (error instanceof Error && error.message === REPORT_ALREADY_HANDLED_ERROR) {
return new Response('Report has already been handled', { status: 409 });
}
if (error instanceof Error && error.message === NO_ACTIVE_CHAT_BAN_ERROR) {
return new Response('User does not have an active chat ban for this channel', {
status: 400,
});
}
throw error;
}
return Response.json({ success: true });
}
if (action === 'ban_platform' || action === 'unban_platform') {
try {
await prisma.$transaction(async (tx) => {
const claimed = await tx.chatUserReport.updateMany({
where: { id: reportId, status: ChatReportStatus.OPEN },
data: {
...reportPatchBase,
status: ChatReportStatus.REVIEWED,
lastAction:
action === 'ban_platform'
? ChatReportAction.BAN_PLATFORM
: ChatReportAction.UNBAN_PLATFORM,
},
});
if (claimed.count === 0) {
throw new Error(REPORT_ALREADY_HANDLED_ERROR);
}
if (action === 'ban_platform') {
await tx.userBan.upsert({
where: { userId: targetUserId },
update: {
reason: note ?? report.reason,
bannedBy: user.id,
expiresAt: null,
},
create: {
userId: targetUserId,
reason: note ?? report.reason,
bannedBy: user.id,
expiresAt: null,
},
});
} else {
const deleted = await tx.userBan.deleteMany({ where: { userId: targetUserId } });
if (deleted.count === 0) {
throw new Error(NO_ACTIVE_PLATFORM_BAN_ERROR);
}
}
await tx.adminAuditLog.create({
data: {
action:
action === 'ban_platform'
? AdminAuditAction.USER_BANNED
: AdminAuditAction.USER_UNBANNED,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
source: 'CHAT_REPORT',
} as any,
},
});
await tx.adminAuditLog.create({
data: {
action: AdminAuditAction.REPORT_ENFORCEMENT,
actorId: user.id,
targetUserId,
targetChannel: report.channel.name,
reason: note,
details: {
reportId,
enforcement: action === 'ban_platform' ? 'BAN_PLATFORM' : 'UNBAN_PLATFORM',
} as any,
},
});
});
} catch (error) {
if (error instanceof Error && error.message === REPORT_ALREADY_HANDLED_ERROR) {
return new Response('Report has already been handled', { status: 409 });
}
if (error instanceof Error && error.message === NO_ACTIVE_PLATFORM_BAN_ERROR) {
return new Response('User does not have an active platform ban', { status: 400 });
}
throw error;
}
return Response.json({ success: true });
}
return new Response('Invalid action', { status: 400 });
}

View File

@@ -0,0 +1,178 @@
import { validateRequest } from '@/lib/auth/validate';
import { AdminAuditAction, prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
const searchParams = request.nextUrl.searchParams;
const search = searchParams.get('search') || '';
const users = await prisma.user.findMany({
where: search
? {
OR: [
{ slack_id: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
{ personalChannel: { name: { contains: search, mode: 'insensitive' } } },
],
hasOnboarded: true,
}
: undefined,
include: {
ban: true,
personalChannel: { select: { name: true } },
},
take: 50,
});
return Response.json(users);
}
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
if (!user?.isAdmin) {
return new Response('Forbidden', { status: 403 });
}
let body: {
userId: string;
action: 'ban' | 'unban' | 'promote' | 'demote';
reason?: string;
expiresAt?: string;
};
try {
body = await request.json();
} catch {
return new Response('Invalid JSON body', { status: 400 });
}
const { userId, action, reason, expiresAt } = body;
if (!userId || !action) {
return new Response('Missing required fields', { status: 400 });
}
let expiresAtDate: Date | null = null;
if (expiresAt !== undefined && expiresAt !== null && expiresAt !== '') {
expiresAtDate = new Date(expiresAt);
if (isNaN(expiresAtDate.getTime())) {
return new Response('Invalid expiresAt date', { status: 400 });
}
if (expiresAtDate <= new Date()) {
return new Response('expiresAt must be a future date', { status: 400 });
}
}
const targetUser = await prisma.user.findUnique({ where: { id: userId } });
if (!targetUser) {
return new Response('User not found', { status: 404 });
}
if (action === 'ban') {
if (targetUser.isAdmin) {
return new Response('Cannot ban an admin', { status: 400 });
}
if (!reason) {
return new Response('Reason is required for banning', { status: 400 });
}
await prisma.userBan.upsert({
where: { userId },
update: {
reason,
bannedBy: user.id,
expiresAt: expiresAtDate,
},
create: {
userId,
reason,
bannedBy: user.id,
expiresAt: expiresAtDate,
},
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.USER_BANNED,
actorId: user.id,
targetUserId: userId,
reason,
details: {
expiresAt: expiresAtDate?.toISOString() ?? null,
} as any,
},
});
return Response.json({ success: true, message: 'User banned' });
}
if (action === 'unban') {
const deleted = await prisma.userBan.deleteMany({ where: { userId } });
if (deleted.count === 0) {
return new Response('User does not have an active platform ban', { status: 400 });
}
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.USER_UNBANNED,
actorId: user.id,
targetUserId: userId,
},
});
return Response.json({ success: true, message: 'User unbanned' });
}
if (action === 'promote') {
if (targetUser.isAdmin) {
return new Response('User is already an admin', { status: 400 });
}
await prisma.user.update({
where: { id: userId },
data: { isAdmin: true },
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.USER_PROMOTED,
actorId: user.id,
targetUserId: userId,
},
});
return Response.json({ success: true, message: 'User promoted to admin' });
}
if (action === 'demote') {
if (!targetUser.isAdmin) {
return new Response('User is not an admin', { status: 400 });
}
if (targetUser.id === user.id) {
return new Response('Cannot demote yourself', { status: 400 });
}
await prisma.user.update({
where: { id: userId },
data: { isAdmin: false },
});
await prisma.adminAuditLog.create({
data: {
action: AdminAuditAction.USER_DEMOTED,
actorId: user.id,
targetUserId: userId,
},
});
return Response.json({ success: true, message: 'User demoted from admin' });
}
return new Response('Invalid action', { status: 400 });
}

View File

@@ -0,0 +1,96 @@
import { prisma, getRedisConnection } from '@hctv/db';
import { recordMediamtxAuth } from '@/lib/metrics';
import { NextRequest } from 'next/server';
import { z } from 'zod';
export async function POST(request: NextRequest) {
const startedAt = performance.now();
let action = 'invalid';
let protocol = 'invalid';
const finish = (body: string, status: number, outcome: string) => {
recordMediamtxAuth(action, protocol, outcome, (performance.now() - startedAt) / 1000);
return new Response(body, { status });
};
const redis = getRedisConnection();
const body = await request.json();
if (process.env.NODE_ENV !== 'production') {
console.log('Mediamtx publish auth request:', JSON.stringify(body, null, 2));
}
const parsed = schema.safeParse(body);
if (!parsed.success) {
return finish('invalid request', 400, 'invalid_request');
}
const { action: parsedAction, protocol: parsedProtocol, path, password } = parsed.data;
action = parsedAction;
protocol = parsedProtocol;
if (parsedAction === 'publish' && parsedProtocol === 'srt') {
const channelKey = await redis.get(`streamKey:${path}`);
if (channelKey) {
if (channelKey !== password) {
return finish('invalid stream key', 403, 'invalid_stream_key');
}
const channel = await prisma.channel.findUnique({
where: { name: path },
include: {
restriction: true,
owner: {
include: { ban: true },
},
streamInfo: true,
},
});
if (channel?.restriction) {
const isExpired =
channel.restriction.expiresAt && new Date(channel.restriction.expiresAt) < new Date();
if (!isExpired) {
return finish('channel restricted', 403, 'channel_restricted');
}
}
if (channel?.owner?.ban) {
const isExpired =
channel.owner.ban.expiresAt && new Date(channel.owner.ban.expiresAt) < new Date();
if (!isExpired) {
return finish('user banned', 403, 'user_banned');
}
}
if (channel?.streamInfo[0].isLive) {
return finish('stream already live', 403, 'stream_already_live');
}
return finish('youre in yay', 200, 'authorized_publish');
}
} else if (parsedAction === 'read' && parsedProtocol === 'hls') {
if (password === process.env.MEDIAMTX_PUBLISH_KEY) {
return finish('authorized (hls read key for thumbs)', 200, 'authorized_thumbnail');
}
const sessionExists = await redis.exists(`sessions:${password}`);
if (!sessionExists) {
return finish('unauthorized', 401, 'unauthorized_session');
}
return finish('authorized', 200, 'authorized_read');
}
return finish('uhh', 401, 'unauthorized');
}
const schema = z.object({
user: z.string(),
password: z.string(),
token: z.string(),
ip: z.string(),
action: z.enum(['publish', 'read', 'playback', 'api', 'metrics', 'pprof']),
path: z.string(),
protocol: z.enum(['rtsp', 'rtmp', 'hls', 'webrtc', 'srt']),
id: z.string().nullable(),
query: z.string(),
});

View File

@@ -1,39 +0,0 @@
import fsP from 'fs/promises';
import fs from 'fs';
import { getRedisConnection } from '@hctv/db';
import { cookies } from 'next/headers';
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
const { path } = await params;
const c = await cookies();
const sessionCookie = c.get('auth_session')?.value;
if (!sessionCookie) {
return new Response("Unauthorized", { status: 401 });
}
const sessionExists = await getRedisConnection().exists(`sessions:${sessionCookie}`);
if (sessionExists === 0) {
return new Response("Unauthorized", { status: 401 });
}
if (path.includes('..')) {
return new Response("nuh uh", { status: 403 });
}
const basePath = '/dev/shm/hls';
const filePath = `${basePath}/${path}`;
const exists = fs.existsSync(filePath);
if (!exists) {
return new Response("Not Found", { status: 404 });
}
const file = await fsP.readFile(filePath);
return new Response(file, {
headers: {
'Content-Type': 'application/octet-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
},
});
}

View File

@@ -15,7 +15,14 @@ export async function POST(request: NextRequest) {
key: streamKey,
},
include: {
channel: true,
channel: {
include: {
restriction: true,
owner: {
include: { ban: true },
},
},
},
},
});
@@ -24,6 +31,23 @@ export async function POST(request: NextRequest) {
status: 403,
});
}
if (key.channel.restriction) {
const isExpired = key.channel.restriction.expiresAt &&
new Date(key.channel.restriction.expiresAt) < new Date();
if (!isExpired) {
return new Response('channel restricted', { status: 403 });
}
}
if (key.channel.owner?.ban) {
const isExpired = key.channel.owner.ban.expiresAt &&
new Date(key.channel.owner.ban.expiresAt) < new Date();
if (!isExpired) {
return new Response('user banned', { status: 403 });
}
}
return new Response('', {
status: 302,
headers: {

View File

@@ -1,6 +1,7 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { NextRequest } from "next/server";
import { regenerateStreamKey } from '@/lib/db/streamKey';
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
@@ -34,20 +35,9 @@ export async function POST(request: NextRequest) {
return new Response('Unauthorized', { status: 401 });
}
const dbUpdate = await prisma.streamKey.upsert({
create: {
key: crypto.randomUUID(),
channelId: channelInfo.id
},
update: {
key: crypto.randomUUID()
},
where: {
channelId: channelInfo.id
}
})
const streamKey = await regenerateStreamKey(channelInfo.id, channel);
return new Response(JSON.stringify({ key: dbUpdate.key }), {
return new Response(JSON.stringify({ key: streamKey.key }), {
status: 200,
headers: {
'Content-Type': 'application/json'

View File

@@ -0,0 +1,107 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma, getRedisConnection } from '@hctv/db';
import { NextRequest } from 'next/server';
const RATE_LIMIT_WINDOW_SECONDS = 10 * 60;
const RATE_LIMIT_MAX_REPORTS = 5;
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
let body: {
channelName?: string;
targetUserId?: string;
targetUsername?: string;
msgId?: string;
message?: string;
reason?: string;
};
try {
body = await request.json();
} catch {
return new Response('Invalid JSON body', { status: 400 });
}
const channelName = body.channelName?.trim();
const targetUserId = body.targetUserId?.trim();
const reason = body.reason?.trim();
if (!channelName || !targetUserId || !reason) {
return new Response('Missing required fields', { status: 400 });
}
if (targetUserId === user.id) {
return new Response('You cannot report yourself', { status: 400 });
}
if (reason.length < 10 || reason.length > 1000) {
return new Response('Reason must be between 10 and 1000 characters', { status: 400 });
}
const redis = getRedisConnection();
const rateLimitKey = `report_rl:${user.id}`;
const currentCount = await redis.incr(rateLimitKey);
if (currentCount === 1) {
await redis.expire(rateLimitKey, RATE_LIMIT_WINDOW_SECONDS);
}
if (currentCount > RATE_LIMIT_MAX_REPORTS) {
return new Response('Too many reports submitted. Please wait before submitting more.', {
status: 429,
});
}
const [channel, targetUser] = await Promise.all([
prisma.channel.findUnique({
where: { name: channelName },
select: { id: true },
}),
prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, personalChannel: { select: { name: true } } },
}),
]);
if (!channel) {
return new Response('Channel not found', { status: 404 });
}
if (!targetUser) {
return new Response('Target user not found', { status: 404 });
}
const msgId = body.msgId?.trim() || null;
const duplicateCheck = await prisma.chatUserReport.findFirst({
where: {
channelId: channel.id,
reporterId: user.id,
targetUserId: targetUser.id,
reportedMessageId: msgId,
status: 'OPEN',
},
select: { id: true },
});
if (duplicateCheck) {
return new Response('You have already submitted an open report for this message.', {
status: 409,
});
}
await prisma.chatUserReport.create({
data: {
channelId: channel.id,
reporterId: user.id,
targetUserId: targetUser.id,
targetUsername: body.targetUsername?.trim() || targetUser.personalChannel?.name || null,
reportedMessageId: msgId,
reportedMessage: body.message?.trim().slice(0, 1000) || null,
reason,
},
});
return Response.json({ success: true });
}

View File

@@ -9,6 +9,7 @@ export async function GET(request: NextRequest) {
const shouldGetOwned = searchParams.get('owned') === 'true';
const allPersonalChannels = searchParams.get('personal') === 'true';
const isLive = searchParams.get('live') === 'true';
const username = searchParams.get('username');
const { user } = await validateRequest();
if ((shouldGetOwned || allPersonalChannels) && !user) {
@@ -18,15 +19,20 @@ export async function GET(request: NextRequest) {
const where: Prisma.StreamInfoWhereInput = {};
const channelConditions: Prisma.ChannelWhereInput[] = [];
if (username) {
where.username = username;
}
if (shouldGetOwned && user) {
channelConditions.push({ ownerId: user.id });
channelConditions.push({ managers: { some: { id: user.id } } });
}
if (allPersonalChannels) {
channelConditions.push({
personalFor: {
isNot: null
}
channelConditions.push({
personalFor: {
isNot: null,
},
});
}
@@ -35,9 +41,8 @@ export async function GET(request: NextRequest) {
}
if (channelConditions.length > 0) {
where.channel = channelConditions.length === 1
? channelConditions[0]
: { OR: channelConditions };
where.channel =
channelConditions.length === 1 ? channelConditions[0] : { OR: channelConditions };
}
const db = await prisma.streamInfo.findMany({
@@ -46,10 +51,42 @@ export async function GET(request: NextRequest) {
channel: {
include: {
personalFor: true,
}
restriction: {
select: {
id: true,
expiresAt: true,
},
},
},
},
},
});
db.forEach((obj) => {
if (obj.channel.personalFor) {
// @ts-ignore
delete obj.channel.personalFor.email;
}
// @ts-ignore
delete obj.channel.obsChatGrantToken;
if (obj.channel.restriction) {
const isExpired =
obj.channel.restriction.expiresAt &&
new Date(obj.channel.restriction.expiresAt) < new Date();
if (isExpired) {
// @ts-ignore
obj.channel.restriction = null;
} else {
// @ts-ignore
obj.channel.isRestricted = true;
// @ts-ignore
obj.channel.restrictionExpiresAt = obj.channel.restriction.expiresAt;
// @ts-ignore
delete obj.channel.restriction;
}
}
});
return Response.json(db);
}
}

View File

@@ -1,5 +1,6 @@
import { validateRequest } from '@/lib/auth/validate';
import { redirect, RedirectType } from 'next/navigation';
import { prisma } from '@hctv/db';
export default async function Layout({ children }: { children: React.ReactNode }) {
const { user } = await validateRequest();
@@ -9,5 +10,33 @@ export default async function Layout({ children }: { children: React.ReactNode }
if (!user.hasOnboarded) {
return redirect(`/onboarding`, RedirectType.push);
}
const ban = await prisma.userBan.findUnique({
where: { userId: user.id },
});
if (ban) {
const isExpired = ban.expiresAt && new Date(ban.expiresAt) < new Date();
if (!isExpired) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-3xl font-bold text-destructive mb-4">Account Suspended</h1>
<p className="text-muted-foreground text-center max-w-md mb-4">
Your account has been suspended from hackclub.tv.
</p>
<div className="bg-muted p-4 rounded-lg max-w-md">
<p className="text-sm font-medium">Reason:</p>
<p className="text-sm text-muted-foreground">{ban.reason}</p>
</div>
{ban.expiresAt && (
<p className="text-sm text-muted-foreground mt-4">
Expires: {new Date(ban.expiresAt).toLocaleDateString()}
</p>
)}
</div>
);
}
}
return children;
}

View File

@@ -1,5 +1,6 @@
import { getBotBySlug } from '@/lib/db/resolve';
import { validateRequest } from '@/lib/auth/validate';
import { can } from '@/lib/auth/abac';
import { redirect } from 'next/navigation';
import Image from 'next/image';
import { GeneralSettings } from '@/app/(ui)/(protected)/settings/bot/[slug]/gensettings';
@@ -12,7 +13,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
const { slug } = await params;
const bot = await getBotBySlug(slug);
if (!bot || bot.ownerId !== user?.id) {
if (!user || !bot || !can(user, 'update', 'bot', { bot })) {
redirect('/settings/bot');
}

View File

@@ -3,6 +3,7 @@ import { prisma } from '@hctv/db';
import { redirect } from 'next/navigation';
import ChannelSettingsClient from './page.client';
import { resolvePersonalChannel } from '@/lib/auth/resolve';
import { can } from '@/lib/auth/abac';
export default async function ChannelSettingsPage({
params,
@@ -21,8 +22,11 @@ export default async function ChannelSettingsPage({
include: {
owner: true,
managers: true,
chatModerators: true,
chatModeratorBots: true,
streamInfo: true,
streamKey: true,
chatSettings: true,
followers: {
include: {
user: {
@@ -42,9 +46,8 @@ export default async function ChannelSettingsPage({
}
const isOwner = channel.ownerId === user.id;
const isManager = channel.managers.some((manager) => manager.id === user.id);
if (!isOwner && !isManager) {
if (!can(user, 'update', 'channel', { channel })) {
redirect('/');
}
@@ -52,17 +55,38 @@ export default async function ChannelSettingsPage({
const managerPersonalChannels = await Promise.all(
channel.managers.map((manager) => resolvePersonalChannel(manager.id))
);
const managerIds = new Set(channel.managers.map((manager) => manager.id));
const extraChatModerators = channel.chatModerators.filter(
(moderator) => moderator.id !== channel.ownerId && !managerIds.has(moderator.id)
);
const chatModeratorPersonalChannels = await Promise.all(
extraChatModerators.map((moderator) => resolvePersonalChannel(moderator.id))
);
const followerPersonalChannels = await Promise.all(
channel.followers.map((follower) => resolvePersonalChannel(follower.user.id))
);
const allBotAccounts = await prisma.botAccount.findMany({
select: {
id: true,
displayName: true,
slug: true,
pfpUrl: true,
},
orderBy: {
slug: 'asc',
},
});
return (
<ChannelSettingsClient
channel={{
...channel,
chatModerators: extraChatModerators,
ownerPersonalChannel,
managerPersonalChannels,
chatModeratorPersonalChannels,
followerPersonalChannels,
allBotAccounts,
}}
isOwner={isOwner}
currentUser={user}

View File

@@ -29,7 +29,12 @@ export async function GET(request: Request): Promise<Response> {
const userResult: HackClubUserResponse = await userResponse.json();
const identity = userResult.identity;
const slackId = identity.slack_id || identity.id;
const slackId = identity.slack_id;
if (!slackId) {
return new Response("Please make sure to have a Slack account before continuing.", {
status: 400,
});
}
const existingUser = await prisma.user.findFirst({
where: {

View File

@@ -107,7 +107,9 @@ export default function OnboardingClient() {
name: 'username',
label: 'Channel Username',
type: 'text',
placeholder: 'e.g., yourname or yourname-codes'
placeholder: 'e.g., yourname or yourname-codes',
maxChars: 20,
inputFilter: /[^a-z0-9_-]/g,
},
]}
schemaName="onboard"
@@ -119,7 +121,7 @@ export default function OnboardingClient() {
<div className="mt-4 p-3 bg-muted/30 rounded-md">
<p className="text-xs text-muted-foreground">
<strong>Username rules:</strong> Only lowercase letters (a-z), numbers (0-9),
underscores (_), and dashes (-) are allowed. Must be unique across the platform.
underscores (_), and dashes (-) are allowed. Up to 20 characters. Must be unique across the platform.
</p>
</div>
</CardContent>

View File

@@ -4,7 +4,10 @@ import OnboardingClient from "./page.client";
export default async function Page() {
const { user } = await validateRequest();
if (user!.hasOnboarded) {
if (!user) {
return redirect('/');
}
if (user.hasOnboarded) {
return redirect('/');
}
return <OnboardingClient />;

View File

@@ -1,10 +1,8 @@
import LandingPage from '@/components/app/LandingPage/LandingPage';
import { Card, CardContent } from '@/components/ui/card';
import StreamGrid from '@/components/app/StreamGrid/StreamGrid';
import ConfusedDino from '@/components/ui/confuseddino';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar';
import Image from 'next/image';
import Link from 'next/link';
import { redirect } from 'next/navigation';
@@ -13,67 +11,45 @@ export default async function Home() {
if (user && !user?.hasOnboarded) {
redirect('/onboarding');
}
const streams = await prisma.streamInfo.findMany({
where: {
isLive: true,
},
include: {
channel: true,
},
});
const [liveStreams, offlineStreams] = await Promise.all([
prisma.streamInfo.findMany({
where: { isLive: true },
include: { channel: true },
}),
prisma.streamInfo.findMany({
where: { isLive: false },
include: { channel: true },
}),
]);
if (!user) {
return <LandingPage />;
}
if (!streams.length) {
if (!liveStreams.length && !offlineStreams.length) {
return (
<div className="flex justify-center items-center text-center flex-col pt-4 gap-2">
<h2>No streams found!</h2>
<p>...maybe start one?</p>
<ConfusedDino className='w-40 h-40' />
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 text-center">
<ConfusedDino className="h-28 w-28 opacity-80" />
<div className="space-y-1.5">
<h2 className="pb-0 text-2xl font-semibold tracking-tight">Nothing live right now</h2>
<p className="text-sm text-muted-foreground">
Nobody&apos;s streaming yet why not be the first?
</p>
</div>
<Link
href="/settings/channel"
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-5 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Start streaming
</Link>
</div>
);
}
return (
<div className="p-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{streams.map((stream) => (
<Link href={`/${stream.username}`} key={stream.id}>
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-0">
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/stream/thumb/${stream.channel.name}`}
width={512}
height={512}
alt={stream.title}
className="w-full h-48 object-cover"
/>
<div className="absolute bottom-2 left-2 bg-red-600 text-white text-xs font-bold px-2 py-1 rounded">
LIVE
</div>
<div className="absolute bottom-2 right-2 bg-black bg-opacity-70 text-white text-xs px-2 py-1 rounded">
{stream.viewers} viewers
</div>
</div>
<div className="p-4">
<div className="flex items-start">
<Avatar className="h-10 w-10 mr-3">
<AvatarImage src={stream.channel.pfpUrl} />
<AvatarFallback>{stream.channel.name}</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold line-clamp-1">{stream.title}</h3>
<p className="text-sm text-muted-foreground">{stream.category}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
<div className="p-3 md:p-6">
<StreamGrid liveStreams={liveStreams} offlineStreams={offlineStreams} />
</div>
);
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { cookies } from 'next/headers';
import '../globals.css';
import Navbar from '@/components/app/NavBar/NavBar';
import { SessionProvider } from '@/lib/providers/SessionProvider';
@@ -31,6 +32,9 @@ export default async function RootLayout({
children: React.ReactNode;
}>) {
const sessionData = await validateRequest();
const cookieStore = await cookies();
const defaultOpen = cookieStore.get('sidebar:state')?.value === 'true';
return (
<html lang="en">
<body className={cn('flex flex-col h-screen', inter.className)}>
@@ -45,16 +49,20 @@ export default async function RootLayout({
<NextSSRPlugin
routerConfig={extractRouterConfig(ourFileRouter)}
/>
<ConfirmDialogProvider>
<ConfirmDialogProvider defaultOptions={{
cancelButton: {
variant: 'outline',
},
}}>
<NuqsAdapter>
<SidebarProvider>
<SidebarProvider defaultOpen={defaultOpen}>
<StreamInfoProvider>
{/* this promise is ugly but i'm lazy to fix the type errors */}
<Navbar editLivestream={Promise.resolve(<EditLivestream />)} />
<div className="flex flex-1 pt-16">
<div className="flex flex-1 pt-16 min-h-0 min-w-0">
{/* pt-16 for navbar height */}
<Sidebar className="pt-16" />
<main className="flex-1 overflow-auto">{children}</main>
<main className="flex-1 min-w-0">{children}</main>
</div>
<Toaster />
</StreamInfoProvider>

View File

@@ -0,0 +1,58 @@
import { webMetricsRegistry } from '@/lib/metrics';
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
if (process.env.NODE_ENV === 'production' && !isAuthenticated(req)) {
return new NextResponse('Authentication required', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic' },
});
}
return new Response(await webMetricsRegistry.metrics(), {
headers: {
'Content-Type': webMetricsRegistry.contentType,
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
},
});
}
// source: https://vancelucas.com/blog/how-to-add-http-basic-auth-to-next-js/
function isAuthenticated(req: NextRequest) {
const authheader = req.headers.get('authorization') ?? req.headers.get('Authorization');
if (!authheader) {
return false;
}
const parts = authheader.split(' ');
if (parts.length !== 2) {
return false;
}
const scheme = parts[0];
const encoded = parts[1];
if (scheme !== 'Basic' || !encoded) {
return false;
}
let decoded: string;
try {
decoded = Buffer.from(encoded, 'base64').toString();
} catch {
return false;
}
const separatorIndex = decoded.indexOf(':');
if (separatorIndex === -1) {
return false;
}
const user = decoded.substring(0, separatorIndex);
const pass = decoded.substring(separatorIndex + 1);
return user === process.env.METRICS_USER && pass === process.env.METRICS_PASS;
}

View File

@@ -4,111 +4,125 @@
@layer base {
:root {
--background: 220 23.077% 94.902%;
--foreground: 233.793 16.022% 35.49%;
/* Light theme - based on your color scheme */
--muted: 222.857 15.909% 82.745%;
--muted-foreground: 233.333 12.796% 41.373%;
/* Main background and foreground */
--background: 350 59% 98%; /* FDF7F8 - main background */
--foreground: 351 34% 30%; /* 5D3A3F - main text */
--popover: 220 23.077% 94.902%;
--popover-foreground: 233.793 16.022% 35.49%;
/* Muted elements */
--muted: 350 40% 93%; /* F8E8EA - muted background */
--muted-foreground: 350 30% 45%; /* Lighter version of main text */
--card: 220 23.077% 94.902%;
--card-foreground: 233.793 16.022% 35.49%;
/* Popover and card */
--popover: 0 0% 100%; /* FFFFFF - popover background */
--popover-foreground: 351 34% 30%; /* 5D3A3F - popover text */
--card: 0 0% 100%; /* FFFFFF - card background */
--card-foreground: 351 34% 30%; /* 5D3A3F - card text */
--border: 225 13.559% 76.863%;
--input: 225 13.559% 76.863%;
/* Border and input */
--border: 350 30% 85%; /* Derived border color */
--input: 350 30% 85%; /* Input background */
--primary: 219.907 91.489% 53.922%;
--primary-foreground: 220 23.077% 94.902%;
/* Primary actions */
--primary: 350 70% 50%; /* C8394F - primary button */
--primary-foreground: 0 0% 100%; /* FFFFFF - text on primary */
--secondary: 222.857 15.909% 82.745%;
--secondary-foreground: 233.793 16.022% 35.49%;
/* Secondary elements */
--secondary: 350 40% 93%; /* F8E8EA - secondary background */
--secondary-foreground: 351 34% 30%; /* 5D3A3F - text on secondary */
--accent: 222.857 15.909% 82.745%;
--accent-foreground: 233.793 16.022% 35.49%;
/* Accent elements */
--accent: 350 70% 40%; /* A12D3E - accent color */
--accent-foreground: 0 0% 100%; /* FFFFFF - text on accent */
--destructive: 347.077 86.667% 44.118%;
--destructive-foreground: 220 21.951% 91.961%;
/* Destructive actions */
--destructive: 350 70% 55%; /* D63C56 - error/destroy */
--destructive-foreground: 0 0% 100%; /* FFFFFF - text on destructive */
--ring: 233.793 16.022% 35.49%;
/* Focus ring */
--ring: 350 70% 50%; /* C8394F - focus ring */
--surface-1: 225 14% 77%;
--surface-2: 227 12% 71%;
/* Surface colors */
--surface-1: 350 40% 93%; /* F8E8EA - surface 1 */
--surface-2: 350 35% 88%; /* Derived surface 2 */
--mantle: 220 22% 92%;
/* Mantle */
--mantle: 350 59% 98%; /* FDF7F8 - mantle */
/* Radius */
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Sidebar specific */
--sidebar-background: 350 59% 98%; /* FDF7F8 - sidebar bg */
--sidebar-foreground: 351 34% 30%; /* 5D3A3F - sidebar text */
--sidebar-primary: 350 70% 50%; /* C8394F - sidebar primary */
--sidebar-primary-foreground: 0 0% 100%; /* FFFFFF - text on sidebar primary */
--sidebar-accent: 350 40% 93%; /* F8E8EA - sidebar accent */
--sidebar-accent-foreground: 351 34% 30%; /* 5D3A3F - text on sidebar accent */
--sidebar-border: 350 30% 85%; /* Derived border */
--sidebar-ring: 350 70% 50%; /* C8394F - sidebar focus ring */
}
.dark {
--background: 240 21.053% 14.902%;
--foreground: 226.154 63.934% 88.039%;
/* Dark theme - based on your color scheme */
--muted: 240 12% 19%;
--muted-foreground: 240 12% 69%;
/* Main background and foreground */
--background: 350 20% 15%; /* 2A1F21 - main background */
--foreground: 350 30% 92%; /* F5E6E8 - main text */
--popover: 240 21.053% 14.902%;
--popover-foreground: 226.154 63.934% 88.039%;
/* Muted elements */
--muted: 350 20% 25%; /* 4A2D31 - muted background */
--muted-foreground: 350 30% 75%; /* Lighter version of main text */
--card: 240 21.053% 14.902%;
--card-foreground: 226.154 63.934% 88.039%;
/* Popover and card */
--popover: 350 20% 15%; /* 2A1F21 - popover background */
--popover-foreground: 350 30% 92%; /* F5E6E8 - popover text */
--card: 350 20% 15%; /* 2A1F21 - card background */
--card-foreground: 350 30% 92%; /* F5E6E8 - card text */
--border: 234.286 13.208% 31.176%;
--input: 234.286 13.208% 31.176%;
/* Border and input */
--border: 350 20% 35%; /* Derived border color */
--input: 350 20% 35%; /* Input background */
--primary: 267 84% 81%;
--primary-foreground: 267 84% 21%;
/* Primary actions */
--primary: 350 100% 75%; /* FF7A8A - primary button */
--primary-foreground: 350 20% 15%; /* 2A1F21 - text on primary */
--secondary: 236.842 16.239% 22.941%;
--secondary-foreground: 226.154 63.934% 88.039%;
/* Secondary elements */
--secondary: 350 20% 25%; /* 4A2D31 - secondary background */
--secondary-foreground: 350 30% 92%; /* F5E6E8 - text on secondary */
--accent: 236.842 16.239% 22.941%;
--accent-foreground: 226.154 63.934% 88.039%;
/* Accent elements */
--accent: 350 100% 80%; /* FF9AAA - accent color */
--accent-foreground: 350 20% 15%; /* 2A1F21 - text on accent */
--destructive: 343.269 81.25% 74.902%;
--destructive-foreground: 240 21.311% 11.961%;
/* Destructive actions */
--destructive: 350 100% 70%; /* FF6B7D - error/destroy */
--destructive-foreground: 350 20% 15%; /* 2A1F21 - text on destructive */
--ring: 226.154 63.934% 88.039%;
/* Focus ring */
--ring: 350 100% 75%; /* FF7A8A - focus ring */
--surface-1: 234 13% 31%;
--surface-2: 233 12% 39%;
/* Surface colors */
--surface-1: 350 20% 25%; /* 4A2D31 - surface 1 */
--surface-2: 350 20% 35%; /* Derived surface 2 */
--mantle: 240 21.311% 11.961%;
/* Mantle */
--mantle: 350 20% 12%; /* 1F1617 - mantle */
/* Radius */
--radius: 0.5rem;
--sidebar-background: 240 21.311% 11.961%; /* crust - matches mantle var */
--sidebar-foreground: 226.154 63.934% 88.039%; /* matches main foreground */
--sidebar-primary: 217.168 91.87% 75.882%; /* matches primary */
--sidebar-primary-foreground: 240 21.053% 14.902%; /* matches primary-foreground */
--sidebar-accent: 236.842 16.239% 22.941%; /* matches accent */
--sidebar-accent-foreground: 226.154 63.934% 88.039%; /* matches accent-foreground */
--sidebar-border: 234.286 13.208% 31.176%; /* matches border */
--sidebar-ring: 217.168 91.87% 75.882%; /* matches primary */
/* Sidebar specific */
--sidebar-background: 350 20% 12%; /* 1F1617 - sidebar bg */
--sidebar-foreground: 350 30% 92%; /* F5E6E8 - sidebar text */
--sidebar-primary: 350 100% 75%; /* FF7A8A - sidebar primary */
--sidebar-primary-foreground: 350 20% 15%; /* 2A1F21 - text on sidebar primary */
--sidebar-accent: 350 20% 25%; /* 4A2D31 - sidebar accent */
--sidebar-accent-foreground: 350 30% 92%; /* F5E6E8 - text on sidebar accent */
--sidebar-border: 350 20% 35%; /* Derived border */
--sidebar-ring: 350 100% 75%; /* FF7A8A - sidebar focus ring */
}
}
@@ -119,17 +133,20 @@
body {
@apply bg-background text-foreground;
}
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
}
h1 {
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
}
h2 {
@apply scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0;
}
/* Media controller styles remain unchanged */
media-controller {
--media-primary-color: #ffffff;
--media-secondary-color: hsla(var(--background), 0.85);
@@ -161,7 +178,7 @@ media-time-range {
}
media-time-display {
--media-text-color: #ffffff;
--media-text-color: #ffffff;
}
media-controller::part(centered-layer) {

View File

@@ -2,8 +2,22 @@ import { NuqsAdapter } from 'nuqs/adapters/next/app';
import './globals.css';
export default function Layout({ children }: { children: React.ReactNode }) {
const publicEnv = Object.keys(process.env).reduce((acc, key) => {
if (key.startsWith('NEXT_PUBLIC_')) {
acc[key] = process.env[key];
}
return acc;
}, {} as Record<string, string | undefined>);
return (
<html lang="en">
<head>
<script
dangerouslySetInnerHTML={{
__html: `window.__ENV = ${JSON.stringify(publicEnv)}`,
}}
/>
</head>
<NuqsAdapter>
<body>
<main>{children}</main>

View File

@@ -0,0 +1,103 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import type { BotAccount } from '@hctv/db';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
export function BotCombobox({ bots, filter, value, modal, onValueChange }: Props) {
const [open, setOpen] = React.useState(false);
const selectedBot = bots.find((bot) => bot.id === value);
const availableBots = bots.filter((bot) => !filter?.includes(bot.id));
return (
<Popover open={open} onOpenChange={setOpen} modal={modal}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedBot ? (
<div className="flex items-center gap-2 overflow-hidden">
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage
src={selectedBot.pfpUrl}
alt={selectedBot.displayName}
loading="lazy"
decoding="async"
/>
<AvatarFallback>{selectedBot.displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<span className="truncate">{selectedBot.displayName}</span>
</div>
) : (
'Select bot...'
)}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search bot..." className="h-9" />
<CommandList>
<CommandEmpty>No bot found.</CommandEmpty>
<CommandGroup>
{availableBots.map((bot) => (
<CommandItem
key={bot.id}
value={bot.id}
onSelect={(currentValue) => {
onValueChange(currentValue === value ? '' : currentValue);
setOpen(false);
}}
>
<Avatar className="h-8 w-8">
<AvatarImage
src={bot.pfpUrl}
alt={bot.displayName}
loading="lazy"
decoding="async"
/>
<AvatarFallback>{bot.displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span>{bot.displayName}</span>
<span className="text-xs text-mantle-foreground">@{bot.slug}</span>
</div>
<Check
className={cn('ml-auto', value === bot.id ? 'opacity-100' : 'opacity-0')}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
type BotLookupAccount = Pick<BotAccount, 'id' | 'displayName' | 'slug' | 'pfpUrl'>;
type Props = {
bots: BotLookupAccount[];
filter?: string[];
value: string;
modal?: boolean;
onValueChange: (value: string) => void;
};

View File

@@ -9,9 +9,11 @@ import { Message } from './message';
import { useMap } from '@uidotdev/usehooks';
import { EmojiSearch } from './EmojiSearch';
import { useQueryState } from 'nuqs';
import { toast } from 'sonner';
export default function ChatPanel(props: Props) {
const { username } = useParams();
const channelName = (Array.isArray(username) ? username[0] : username) ?? '';
const [grant, setGrant] = useQueryState('grant');
const [message, setMessage] = useState('');
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
@@ -21,13 +23,19 @@ export default function ChatPanel(props: Props) {
const [emojisToReq, setEmojisToReq] = useState<string[]>([]);
const [cursorPosition, setCursorPosition] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [viewer, setViewer] = useState<{ id: string; username: string } | null>(null);
const [canModerate, setCanModerate] = useState(false);
const [chatAccess, setChatAccess] = useState<ChatAccessState>({
canSend: true,
restriction: null,
});
useEffect(() => {
console.log('Initializing WebSocket connection for user:', username);
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}?grant=${grant}`
}/api/stream/chat/ws/${channelName}?grant=${grant}`
);
socketRef.current = socket;
@@ -50,6 +58,35 @@ export default function ChatPanel(props: Props) {
return;
}
if (data.type === 'session') {
setViewer(data.viewer ?? null);
setCanModerate(Boolean(data.permissions?.canModerate));
return;
}
if (data.type === 'chatAccess') {
setChatAccess({
canSend: Boolean(data.canSend),
restriction: data.restriction ?? null,
});
return;
}
if (data.type === 'systemMsg') {
setChatMessages((prev) => [...prev, { message: data.message, type: 'systemMsg' }]);
return;
}
if (data.type === 'messageDeleted') {
setChatMessages((prev) => prev.filter((message) => message.msgId !== data.msgId));
return;
}
if (data.type === 'moderationError') {
toast.error(data.message || 'Message blocked by moderation rules.');
return;
}
if (data.type === 'message') {
console.log('Adding new chat message:', data);
setChatMessages((prev) => [...prev, data]);
@@ -72,7 +109,7 @@ export default function ChatPanel(props: Props) {
return () => {
socket.close();
};
}, [username]);
}, [channelName]);
useEffect(() => {
if (scrollRef.current) {
@@ -84,6 +121,14 @@ export default function ChatPanel(props: Props) {
}, [chatMessages]);
const sendMessage = () => {
if (!chatAccess.canSend) {
toast.error(
chatAccess.restriction?.type === 'timeout'
? 'You are currently timed out in this chat.'
: 'You are currently banned from this chat.'
);
return;
}
if (!message.trim()) return;
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
@@ -93,7 +138,7 @@ export default function ChatPanel(props: Props) {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}?grant=${grant}`
}/api/stream/chat/ws/${channelName}?grant=${grant}`
);
socket.onopen = () => {
socket.send(JSON.stringify({ type: 'message', message }));
@@ -102,6 +147,15 @@ export default function ChatPanel(props: Props) {
}
};
const sendModerationCommand = (command: ChatModerationCommand) => {
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
toast.error('Chat connection is offline.');
return;
}
socketRef.current.send(JSON.stringify(command));
};
useEffect(() => {
const interval = setInterval(() => {
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
@@ -146,7 +200,7 @@ export default function ChatPanel(props: Props) {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}?grant=${grant}`
}/api/stream/chat/ws/${channelName}?grant=${grant}`
);
socket.onopen = () => {
@@ -209,7 +263,7 @@ export default function ChatPanel(props: Props) {
setEmojisToReq([]);
};
}
}, [emojisToReq, emojiMap, username]);
}, [emojisToReq, emojiMap, channelName]);
const handleEmojiSelect = (emojiName: string) => {
if (!textareaRef.current) return;
@@ -245,9 +299,14 @@ export default function ChatPanel(props: Props) {
};
return (
<div className={`${props.isObsPanel ? 'w-full text-white' : 'md:border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}>
<div ref={scrollRef} className={`flex-1 p-4 ${props.isObsPanel ? 'scrollbar-hide' : ''} overflow-y-auto flex flex-col`}>
<div className="space-y-4 flex-1">
<div
className={`${props.isObsPanel ? 'w-full text-white' : 'md:border-l border-border bg-mantle w-[350px] max-w-[350px]'} flex flex-col h-full`}
>
<div
ref={scrollRef}
className={`flex-1 px-4 py-2 ${props.isObsPanel ? 'scrollbar-hide' : 'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'} overflow-y-auto overflow-x-hidden`}
>
<div className="space-y-1 min-h-full flex flex-col justify-end">
{chatMessages.map((msg, i) => (
<Message
key={i}
@@ -255,13 +314,25 @@ export default function ChatPanel(props: Props) {
message={msg.message}
type={msg.type}
emojiMap={emojiMap}
msgId={msg.msgId}
canModerate={canModerate && Boolean(viewer?.id)}
viewerId={viewer?.id}
channelName={channelName}
onModerationCommand={sendModerationCommand}
/>
))}
</div>
</div>
{!props.isObsPanel && (
<div className="p-4 border-t relative">
<div className="flex space-x-2">
<div className="p-3 border-t border-border relative">
{!chatAccess.canSend && (
<p className="mb-2 text-xs text-destructive">
{chatAccess.restriction?.type === 'timeout'
? `Timed out${chatAccess.restriction.expiresAt ? ` until ${new Date(chatAccess.restriction.expiresAt).toLocaleTimeString()}` : ''}.`
: 'You are banned from sending messages in this chat.'}
</p>
)}
<div className="flex gap-2">
<Textarea
ref={textareaRef}
value={message}
@@ -281,11 +352,17 @@ export default function ChatPanel(props: Props) {
onClick={(e) => {
setCursorPosition(e.currentTarget.selectionStart || 0);
}}
placeholder="Type a message"
className="flex-1 bg-transparent focus-visible:ring-offset-0 min-h-[40px] max-h-[120px] resize-none py-2"
placeholder="Send a message..."
className="flex-1 bg-background/50 border-border focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-0 min-h-[40px] max-h-[100px] resize-none py-2 text-sm"
rows={1}
disabled={!chatAccess.canSend}
/>
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
<Button
size="icon"
className="shrink-0 transition-colors"
onClick={sendMessage}
disabled={!message.trim() || !chatAccess.canSend}
>
<Send className="h-4 w-4" />
</Button>
</div>
@@ -307,6 +384,32 @@ export interface ChatMessage {
user?: User;
message: string;
type: 'message' | 'systemMsg';
msgId?: string;
}
export interface ChatModerationCommand {
type:
| 'mod:deleteMessage'
| 'mod:timeoutUser'
| 'mod:banUser'
| 'mod:unbanUser'
| 'mod:liftTimeout';
msgId?: string;
targetUserId?: string;
targetUsername?: string;
durationSeconds?: number;
reason?: string;
}
interface ChatAccessState {
canSend: boolean;
restriction: ChatRestriction | null;
}
interface ChatRestriction {
type: 'timeout' | 'ban';
reason?: string;
expiresAt?: string | null;
}
export interface User {
@@ -315,6 +418,8 @@ export interface User {
pfpUrl: string;
isBot: boolean;
displayName?: string;
isPlatformAdmin?: boolean;
channelRole?: 'owner' | 'manager' | 'chatModerator' | 'botModerator' | null;
}
interface Props {

View File

@@ -28,25 +28,27 @@ export function EmojiSearch({
useEffect(() => {
const beforeCursor = message.substring(0, cursorPosition);
const match = beforeCursor.match(/:[\w\-+]*$/);
if (match) {
const term = match[0].substring(1);
setSearchTerm(term);
if (term.length > 0) {
const localResults = Array.from(emojiMap.keys())
.filter(name => name.toLowerCase().includes(term.toLowerCase()))
.filter((name) => name.toLowerCase().includes(term.toLowerCase()))
.slice(0, 5);
if (localResults.length > 0) {
setSearchResults(localResults);
}
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'emojiSearch',
searchTerm: term
}));
socket.send(
JSON.stringify({
type: 'emojiSearch',
searchTerm: term,
})
);
}
} else {
setSearchResults([]);
@@ -63,22 +65,22 @@ export function EmojiSearch({
const handleEmojiSearchResponse = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'emojiSearchResponse') {
const serverResults = data.results || [];
const localResults = Array.from(emojiMap.keys())
.filter(name => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
.filter((name) => searchTerm && name.toLowerCase().includes(searchTerm.toLowerCase()))
.slice(0, 5);
const combinedResults = [...serverResults];
localResults.forEach(name => {
localResults.forEach((name) => {
if (!combinedResults.includes(name)) {
combinedResults.push(name);
}
});
setSearchResults(combinedResults.slice(0, 10));
setSelectedIndex(0);
}
@@ -95,18 +97,18 @@ export function EmojiSearch({
useEffect(() => {
if (!textareaRef.current) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (!searchTerm || searchResults.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev + 1) % searchResults.length);
setSelectedIndex((prev) => (prev + 1) % searchResults.length);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev - 1 + searchResults.length) % searchResults.length);
setSelectedIndex((prev) => (prev - 1 + searchResults.length) % searchResults.length);
break;
case 'Enter':
if (searchResults[selectedIndex]) {
@@ -127,10 +129,10 @@ export function EmojiSearch({
break;
}
};
const textarea = textareaRef.current;
textarea.addEventListener('keydown', handleKeyDown);
return () => {
textarea.removeEventListener('keydown', handleKeyDown);
};
@@ -150,25 +152,25 @@ export function EmojiSearch({
}
return (
<div className="absolute bottom-16 left-4 bg-mantle border rounded-md shadow-lg max-h-60 overflow-y-auto z-10 min-w-[200px] max-w-[300px]">
<div className="absolute bottom-full left-0 right-0 mb-2 mx-0 bg-mantle border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto z-10">
<div ref={resultsRef} className="py-1">
{searchResults.map((emojiName, index) => {
const isSelected = index === selectedIndex;
const emojiUrl = emojiMap.get(emojiName);
return (
<div
key={emojiName}
className={`px-3 py-1.5 flex items-center gap-2 cursor-pointer ${
className={`px-3 py-2 flex items-center gap-3 cursor-pointer transition-colors ${
isSelected ? 'bg-primary/10' : 'hover:bg-primary/5'
}`}
onClick={() => onSelect(emojiName)}
>
{emojiUrl && (
<Image src={emojiUrl} alt={emojiName} width={20} height={20} className="w-5 h-5" />
<Image src={emojiUrl} alt={emojiName} width={24} height={24} className="w-6 h-6" />
)}
<span className="flex-grow text-sm">{emojiName}</span>
{isSelected && <Check className="h-4 w-4 text-blue-500" />}
<span className="flex-grow text-sm font-medium">{emojiName}</span>
{isSelected && <Check className="h-4 w-4 text-primary" />}
</div>
);
})}

View File

@@ -1,80 +1,373 @@
import { User } from './ChatPanel';
import React from 'react';
'use client';
import { ChatModerationCommand, User } from './ChatPanel';
import { useState } from 'react';
import Image from 'next/image';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Bot } from 'lucide-react';
import {
Ban,
Bot,
Clock3,
Crown,
EllipsisVertical,
Eraser,
Flag,
Shield,
ShieldAlert,
ShieldCheck,
UserRoundCheck,
Wrench,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type ChannelRole = NonNullable<User['channelRole']>;
const ROLE_META: Record<ChannelRole, { label: string; icon: LucideIcon; className: string }> = {
owner: { label: 'Owner', icon: Crown, className: 'text-amber-500' },
manager: { label: 'Manager', icon: Wrench, className: 'text-violet-500' },
chatModerator: { label: 'Chat Mod', icon: Shield, className: 'text-emerald-500' },
botModerator: { label: 'Bot Mod', icon: ShieldCheck, className: 'text-cyan-500' },
};
function TooltipIcon({
icon: Icon,
label,
className,
}: {
icon: LucideIcon;
label: string;
className?: string;
}) {
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Icon className={cn('size-3.5 shrink-0', className)} />
</TooltipTrigger>
<TooltipContent side="top">{label}</TooltipContent>
</Tooltip>
);
}
function UsernameRow({ user, displayName }: { user?: User; displayName?: string }) {
const role = user?.channelRole ? ROLE_META[user.channelRole] : null;
return (
<TooltipProvider>
<span className="font-semibold text-primary shrink-0 flex items-center gap-1">
{user?.isBot && <TooltipIcon icon={Bot} label="Bot" className="text-muted-foreground" />}
{role && <TooltipIcon icon={role.icon} label={role.label} className={role.className} />}
{user?.isPlatformAdmin && (
<TooltipIcon icon={ShieldAlert} label="Platform Admin" className="text-destructive" />
)}
<span>{displayName}</span>
<span className="font-normal text-muted-foreground select-none">:</span>
</span>
</TooltipProvider>
);
}
function ReportDialog({
open,
onOpenChange,
displayName,
message,
reportReason,
onReasonChange,
onSubmit,
isSubmitting,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
displayName?: string;
message: string;
reportReason: string;
onReasonChange: (value: string) => void;
onSubmit: () => void;
isSubmitting: boolean;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Report message</DialogTitle>
<DialogDescription>
Message against Hack Club's Code of Conduct? Let us know!
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm text-muted-foreground rounded-md border p-3 bg-muted/30">
<p className="font-medium text-foreground mb-1">Reported user</p>
<p>{displayName}</p>
<p className="mt-2">{message}</p>
</div>
<div>
<label className="text-sm font-medium">Reason</label>
<Textarea
value={reportReason}
onChange={(e) => onReasonChange(e.target.value)}
placeholder="Describe why this should be reviewed (harassment, hate speech, spam, threats, etc)."
rows={5}
className="mt-2"
/>
<p className="text-xs text-muted-foreground mt-1">Minimum 10 characters.</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={isSubmitting || reportReason.trim().length < 10}>
Submit report
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function Message({
user,
message,
type,
emojiMap,
msgId,
canModerate,
viewerId,
channelName,
onModerationCommand,
}: MessageProps) {
const [reportOpen, setReportOpen] = useState(false);
const [reportReason, setReportReason] = useState('');
const [isSubmittingReport, setIsSubmittingReport] = useState(false);
const displayName = user?.displayName || user?.username;
export function Message({ user, message, type, emojiMap }: MessageProps) {
if (type === 'systemMsg') {
return (
<div className="flex items-center justify-center">
<span className="text-xs text-muted-foreground">{message}</span>
<div className="flex items-center justify-center py-1">
<span className="text-xs text-muted-foreground italic">{message}</span>
</div>
);
}
return (
<div className="flex">
<div lang="en" className="max-w-full break-all whitespace-pre-wrap hyphens-auto">
<p className="flex flex-wrap items-center">
<span className="font-bold mr-2 flex items-center">
{user?.isBot && (
<span className="text-xs text-muted-foreground flex mr-1">
{' '}
<Bot className="size-5" />
</span>
)}
{user?.displayName || user?.username}
</span>
const submitReport = async () => {
if (!user?.id || !viewerId || viewerId === user.id) return;
<EmojiRenderer text={message} emojiMap={emojiMap} />
</p>
</div>
</div>
);
}
const reason = reportReason.trim();
if (reason.length < 10) {
toast.error('Please include at least 10 characters explaining the report.');
return;
}
export function EmojiRenderer({ text, emojiMap }: EmojiRendererProps) {
if (!text) return null;
setIsSubmittingReport(true);
try {
const res = await fetch('/api/stream/chat/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelName,
targetUserId: user.id,
targetUsername: displayName,
msgId,
message,
reason,
}),
});
const parts = text.split(/(:[\w\-+]+:)/g);
if (!res.ok) {
toast.error((await res.text()) || 'Failed to submit report.');
return;
}
toast.success('Report submitted. Thanks for helping keep chat safe.');
setReportReason('');
setReportOpen(false);
} catch {
toast.error('Failed to submit report.');
} finally {
setIsSubmittingReport(false);
}
};
const handleReportOpenChange = (open: boolean) => {
setReportOpen(open);
if (!open) setReportReason('');
};
return (
<>
{parts.map((part, index) => {
if (part.match(/^:[\w\-+]+:$/)) {
const emojiName = part.replaceAll(':', '');
const emojiUrl = emojiMap.get(emojiName);
<div className="group hover:bg-primary/5 rounded px-2 py-1 -mx-2 transition-colors">
<div className="flex items-start gap-1.5">
<UsernameRow user={user} displayName={displayName} />
<span
lang="en"
className="text-foreground min-w-0 flex-1"
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
>
<EmojiRenderer text={message} emojiMap={emojiMap} />
</span>
{type === 'message' && user?.id && (
<MessageActionsMenu
user={user}
msgId={msgId}
canModerate={canModerate}
viewerId={viewerId}
onModerationCommand={onModerationCommand}
onOpenReport={() => setReportOpen(true)}
/>
)}
</div>
</div>
<ReportDialog
open={reportOpen}
onOpenChange={handleReportOpenChange}
displayName={displayName}
message={message}
reportReason={reportReason}
onReasonChange={setReportReason}
onSubmit={submitReport}
isSubmitting={isSubmittingReport}
/>
</>
);
}
if (emojiUrl) {
return (
<TooltipProvider key={index}>
<Tooltip delayDuration={250}>
<TooltipTrigger>
<span
key={index}
className="inline-block align-middle"
style={{ height: '1.2em' }}
>
<Image
src={emojiUrl}
alt={part}
width={20}
height={20}
className="inline-block"
/>
function MessageActionsMenu({
user,
msgId,
canModerate,
viewerId,
onModerationCommand,
onOpenReport,
}: {
user: User;
msgId?: string;
canModerate?: boolean;
viewerId?: string;
onModerationCommand?: (command: ChatModerationCommand) => void;
onOpenReport: () => void;
}) {
if (!viewerId || !user.id || user.id === viewerId) return null;
const displayName = user.displayName || user.username;
const canMod = Boolean(canModerate && onModerationCommand);
const runModeration = (command: ChatModerationCommand) => onModerationCommand?.(command);
const timeout = (durationSeconds: number) =>
runModeration({
type: 'mod:timeoutUser',
targetUserId: user.id,
targetUsername: displayName,
durationSeconds,
reason: 'Timed out by moderator',
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 opacity-0 group-hover:opacity-100">
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={onOpenReport}>
<Flag className="mr-2 h-4 w-4" />
Report user
</DropdownMenuItem>
{canMod && (
<>
<DropdownMenuItem
onClick={() => msgId && runModeration({ type: 'mod:deleteMessage', msgId })}
>
<Eraser className="mr-2 h-4 w-4" />
Delete message
</DropdownMenuItem>
<DropdownMenuItem onClick={() => timeout(300)}>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 5 min
</DropdownMenuItem>
<DropdownMenuItem onClick={() => timeout(3600)}>
<Clock3 className="mr-2 h-4 w-4" />
Timeout 1 hour
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() =>
runModeration({
type: 'mod:banUser',
targetUserId: user.id,
targetUsername: displayName,
reason: 'Banned by moderator',
})
}
>
<Ban className="mr-2 h-4 w-4" />
Ban user
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
runModeration({
type: 'mod:liftTimeout',
targetUserId: user.id,
targetUsername: displayName,
})
}
>
<UserRoundCheck className="mr-2 h-4 w-4" />
Lift timeout/ban
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function EmojiRenderer({ text, emojiMap }: { text: string; emojiMap: Map<string, string> }) {
if (!text) return null;
return (
<TooltipProvider>
<>
{text.split(/(:[\w\-+]+:)/g).map((part, i) => {
if (part.match(/^:[\w\-+]+:$/)) {
const name = part.replaceAll(':', '');
const url = emojiMap.get(name);
if (url) {
return (
<Tooltip key={i} delayDuration={250}>
<TooltipTrigger asChild>
<span className="inline-flex items-center align-middle mx-0.5">
<Image src={url} alt={part} width={20} height={20} className="inline-block" />
</span>
</TooltipTrigger>
<TooltipContent>{part}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
);
}
}
}
return <span key={index}>{part}</span>;
})}
</>
return part ? <span key={i}>{part}</span> : null;
})}
</>
</TooltipProvider>
);
}
@@ -83,9 +376,9 @@ interface MessageProps {
message: string;
type: 'message' | 'systemMsg';
emojiMap: Map<string, string>;
}
interface EmojiRendererProps {
text: string;
emojiMap: Map<string, string>;
msgId?: string;
canModerate?: boolean;
viewerId?: string;
channelName: string;
onModerationCommand?: (command: ChatModerationCommand) => void;
}

View File

@@ -8,22 +8,16 @@ export default function LandingPage() {
return (
<>
<main className="flex-1">
{/* Hero Section */}
<section className="relative w-full py-20 md:py-32 lg:py-40 xl:py-48 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/10 via-transparent to-secondary/10"></div>
<div className="absolute inset-0"></div>
<div className="container px-4 md:px-6 relative">
<div className="flex flex-col items-center space-y-8 text-center">
<Badge variant="outline" className="px-4 py-2 text-sm font-medium bg-primary/10 text-primary border-primary/20">
<Heart className="w-4 h-4 mr-2" />
Made with by Hack Clubbers
</Badge>
<div className="flex flex-col items-center space-y-8 text-center">
<div className="space-y-6">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl bg-gradient-to-r from-primary via-primary/80 to-primary/60 bg-clip-text text-transparent">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl text-primary">
hackclub.tv
</h1>
<p className="mx-auto max-w-[600px] text-lg text-muted-foreground md:text-xl">
The streaming platform where Hack Clubbers share their coding journeys, workshops, and hackathon adventures with the world.
The streaming website for Hack Clubbers, by Hack Clubbers.
</p>
</div>
@@ -42,133 +36,6 @@ export default function LandingPage() {
</div>
</div>
</section>
{/* Features Section */}
<section className="w-full py-16 md:py-24 lg:py-32 bg-muted/30" id="features">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center text-center space-y-8 mb-16">
<Badge variant="secondary" className="px-4 py-2">
Platform Features
</Badge>
<div className="space-y-4">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl">
Built for creators, by creators
</h2>
<p className="max-w-[700px] text-lg text-muted-foreground">
Everything you need to connect, create, and grow within the Hack Club community.
</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 max-w-5xl mx-auto">
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-4">
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
<Zap className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-xl">Low-Latency Streaming</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
Share your coding sessions with ultra-low latency. Your audience stays engaged with real-time interaction.
</CardDescription>
</CardContent>
</Card>
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-4">
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
<MessageCircle className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-xl">Real-Time Chat</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
Engage with your community through integrated chat. Get instant feedback and build connections.
</CardDescription>
</CardContent>
</Card>
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-4">
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
<Users className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-xl">Community First</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
Follow your favorite streamers, discover new creators, and be part of the vibrant Hack Club ecosystem.
</CardDescription>
</CardContent>
</Card>
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-4">
<div className="w-12 h-12 bg-accent/50 rounded-lg flex items-center justify-center mb-4">
<Code className="w-6 h-6 text-accent-foreground" />
</div>
<CardTitle className="text-xl">Code-Focused</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
Built specifically for developers. Perfect for coding sessions, tutorials, and technical workshops.
</CardDescription>
</CardContent>
</Card>
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-4">
<div className="w-12 h-12 bg-secondary/50 rounded-lg flex items-center justify-center mb-4">
<Play className="w-6 h-6 text-secondary-foreground" />
</div>
<CardTitle className="text-xl">Easy to Use</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
Simple, intuitive interface. Start streaming in minutes, not hours. Focus on what you love: coding.
</CardDescription>
</CardContent>
</Card>
<Card className="border-0 shadow-sm hover:shadow-md transition-shadow">
<CardHeader className="pb-4">
<div className="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center mb-4">
<Heart className="w-6 h-6 text-primary" />
</div>
<CardTitle className="text-xl">Open Source</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-base">
Transparent, community-driven, and built in the open. Contribute, customize, and make it yours.
</CardDescription>
</CardContent>
</Card>
</div>
</div>
</section>
{/* CTA Section */}
<section className="w-full py-16 md:py-24">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center text-center space-y-8">
<div className="space-y-4">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
Ready to share your journey?
</h2>
<p className="max-w-[600px] text-lg text-muted-foreground">
Join the community of makers, builders, and dreamers. Start streaming your coding adventures today.
</p>
</div>
<Link href="/login">
<Button size="lg" className="px-8 py-3 text-lg font-semibold">
<Play className="w-5 h-5 mr-2" />
Get Started Now
</Button>
</Link>
</div>
</div>
</section>
</main>
</>
);

View File

@@ -1,28 +1,77 @@
'use client';
import { useEffect, useState } from 'react';
import { format } from 'date-fns';
import StreamPlayer from '../StreamPlayer/StreamPlayer';
import UserInfoCard from '../UserInfoCard/UserInfoCard';
import ChatPanel from '../ChatPanel/ChatPanel';
import type { StreamInfo, User, Channel } from '@hctv/db';
import { Button } from '@/components/ui/button';
import type { StreamInfo, Channel } from '@hctv/db';
import { useIsMobile } from '@/lib/hooks/useMobile';
import { useAllChannels } from '@/lib/hooks/useUserList';
import { RefreshCw } from 'lucide-react';
export default function LiveStream(props: Props) {
const isMobile = useIsMobile();
const { channels, refresh } = useAllChannels(5000);
const [isRestricted, setIsRestricted] = useState(false);
const [restrictionExpiresAt, setRestrictionExpiresAt] = useState<string | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => {
const currentStream = channels.find((s) => s.username === props.username);
if (currentStream?.channel?.isRestricted) {
setIsRestricted(true);
setRestrictionExpiresAt(currentStream.channel.restrictionExpiresAt || null);
} else if (isRestricted && currentStream && !currentStream.channel?.isRestricted) {
setIsRestricted(false);
setRestrictionExpiresAt(null);
}
}, [channels, props.username, isRestricted]);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refresh();
} finally {
setIsRefreshing(false);
}
};
if (isRestricted) {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-64px)] p-4">
<h1 className="text-2xl font-bold text-destructive mb-2">Channel Restricted</h1>
<p className="text-muted-foreground text-center max-w-md mb-4">
This channel has been restricted by a moderator and is no longer available for viewing.
</p>
{restrictionExpiresAt && (
<p className="text-sm text-muted-foreground mb-4">
Restriction lifts: {format(new Date(restrictionExpiresAt), 'PPP p')}
</p>
)}
<Button variant="outline" onClick={handleRefresh} disabled={isRefreshing}>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Checking...' : 'Check again'}
</Button>
</div>
);
}
return (
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full`}>
<div className="flex-1 flex flex-col">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<StreamPlayer />
{isMobile && (
<div className="h-[300px]">
<div className="flex-1 min-h-[250px] max-h-[400px] border-t border-border">
<ChatPanel />
</div>
)}
<UserInfoCard streamInfo={props.streamInfo} />
</div>
{!isMobile && (
<div>
<div className="h-full shrink-0">
<ChatPanel />
</div>
)}
@@ -33,4 +82,4 @@ export default function LiveStream(props: Props) {
interface Props {
username: string;
streamInfo: StreamInfo & { channel: Channel };
}
}

View File

@@ -15,7 +15,7 @@ import { logout } from '@/lib/auth/actions';
import { useSession } from '@/lib/providers/SessionProvider';
import Link from 'next/link';
import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
import { Slack } from 'lucide-react';
import { IdCard, Shield } from 'lucide-react';
import { SidebarTrigger } from '@/components/ui/sidebar';
export default function Navbar(props: Props) {
@@ -57,6 +57,17 @@ export default function Navbar(props: Props) {
<Link href={`/settings/bot`}>
<DropdownMenuItem className="cursor-pointer">Bot accounts</DropdownMenuItem>
</Link>
{user.isAdmin && (
<>
<DropdownMenuSeparator />
<Link href={`/admin`}>
<DropdownMenuItem className="cursor-pointer text-primary">
<Shield className="w-4 h-4 mr-2" />
Admin Panel
</DropdownMenuItem>
</Link>
</>
)}
<DropdownMenuSeparator />
<Link href={'https://docs.hackclub.tv'} target="_blank" rel="noreferrer">
<DropdownMenuItem className="cursor-pointer">API Docs</DropdownMenuItem>
@@ -99,7 +110,7 @@ export default function Navbar(props: Props) {
) : (
<Link href="/auth/hackclub">
<Button variant="outline" size="sm" className="gap-1 md:gap-2 text-xs md:text-sm">
<Slack className="w-3 h-3 md:w-4 md:h-4" />
<IdCard className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Sign in</span>
<span className="sm:hidden">Login</span>
</Button>

View File

@@ -1,143 +1,162 @@
'use client';
import * as React from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { Avatar } from '@/components/ui/avatar';
import {
Sidebar as UISidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { StreamInfoResponse, useStreams } from '@/lib/providers/StreamInfoProvider';
import { StreamInfoResponse } from '@/lib/providers/StreamInfoProvider';
import { useRouter } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { useAllChannels } from '@/lib/hooks/useUserList';
import { Separator } from '@/components/ui/separator';
export default function Sidebar({ ...props }: React.ComponentProps<typeof UISidebar>) {
const { channels: stream, isLoading } = useAllChannels(5000);
const [followedExpanded, setFollowedExpanded] = React.useState(true);
const { state } = useSidebar();
const isCollapsed = state === 'collapsed';
if (isLoading) return <SidebarSkeleton />;
if (isLoading) return <SidebarSkeleton {...props} />;
const liveStreamers = stream?.filter((s) => s.isLive) || [];
const offlineStreamers = stream?.filter((s) => !s.isLive) || [];
return (
<UISidebar {...props}>
<UISidebar collapsible="icon" {...props}>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel asChild>
<button
onClick={() => setFollowedExpanded(!followedExpanded)}
className="w-full flex items-center justify-between"
>
<span>Live Channels</span>
{followedExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
<SidebarGroupLabel className="flex items-center justify-between px-2 py-1.5">
<span className="text-xs font-semibold uppercase text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
Live Channels
</span>
<span className="text-xs text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
{liveStreamers.length}
</span>
</SidebarGroupLabel>
{followedExpanded && (
<SidebarGroupContent>
<SidebarMenu>
{liveStreamers.map((streamer) => (
<StreamerItem key={streamer.id} streamer={streamer} />
))}
</SidebarMenu>
</SidebarGroupContent>
)}
<SidebarGroupContent>
<SidebarMenu>
{liveStreamers.length === 0 && !isCollapsed && (
<div className="px-4 py-2 text-sm text-muted-foreground">
No channels live
</div>
)}
{liveStreamers.map((streamer) => (
<StreamerItem key={streamer.id} streamer={streamer} isCollapsed={isCollapsed} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{offlineStreamers.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel>Offline Channels</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{offlineStreamers.map((streamer) => (
<StreamerItem key={streamer.id} streamer={streamer} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
<Separator className="group-data-[collapsible=icon]:block hidden" />
<SidebarGroup>
<SidebarGroupLabel className="flex items-center justify-between px-2 py-1.5">
<span className="text-xs font-semibold uppercase text-muted-foreground group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200">
Offline Channels
</span>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{offlineStreamers.map((streamer) => (
<StreamerItem key={streamer.id} streamer={streamer} isCollapsed={isCollapsed} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</UISidebar>
);
}
function StreamerItem({ streamer }: { streamer: StreamInfoResponse[0] }) {
function StreamerItem({ streamer, isCollapsed }: { streamer: StreamInfoResponse[0], isCollapsed: boolean }) {
const router = useRouter();
return (
<SidebarMenuItem key={streamer.id} className={streamer.isLive ? '' : '*:text-muted-foreground'}>
<SidebarMenuButton className="flex items-center gap-3 h-full" onClick={() => {
router.push(`/${streamer.username}`);
}}>
<div className="relative">
<Avatar className="h-9 w-9">
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={streamer.username}
className="h-12"
onClick={() => router.push(`/${streamer.username}`)}
>
<button className="flex w-full items-center gap-3">
<div className="relative flex-shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={streamer.channel.pfpUrl}
alt={streamer.username}
width={36}
height={36}
className="rounded-full h-9 w-9 object-cover"
className="h-8 w-8 rounded-full object-cover"
loading="lazy"
/>
</Avatar>
{streamer.isLive && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-primary rounded-full border-2 border-black" />
{streamer.isLive && (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3 items-center justify-center rounded-full bg-background ring-2 ring-background">
<span className="h-2 w-2 rounded-full bg-red-500 animate-pulse" />
</span>
)}
</div>
{!isCollapsed && (
<div className="flex flex-1 flex-col items-start overflow-hidden">
<div className="flex w-full items-center justify-between">
<span className="truncate font-medium text-sm leading-none">
{streamer.username}
</span>
{streamer.isLive && (
<div className="flex items-center gap-1 text-xs text-red-500">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
<span>{streamer.viewers}</span>
</div>
)}
</div>
<span className="truncate text-xs text-muted-foreground w-full text-left">
{streamer.isLive ? streamer.title || streamer.category || 'Live' : 'Offline'}
</span>
</div>
)}
</div>
<div className="flex-1">
<p className="font-medium truncate">{streamer.username}</p>
<p className="text-sm truncate">{streamer.category}</p>
{streamer.isLive && (
<p className="text-sm">
{streamer.viewers} viewer{streamer.viewers === 1 ? '' : 's'}
</p>
)}
</div>
</button>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
const { state } = useSidebar();
const isCollapsed = state === 'collapsed';
return (
<UISidebar {...props}>
<UISidebar collapsible="icon" {...props}>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel asChild>
<button className="w-full flex items-center justify-between">
<span>Live Channels</span>
<ChevronUp className="h-4 w-4" />
</button>
<SidebarGroupLabel className="px-2 py-1.5">
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200" />
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{Array(3).fill(0).map((_, i) => (
<StreamerItemSkeleton key={i} />
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<Separator className="group-data-[collapsible=icon]:block hidden" />
<SidebarGroup>
<SidebarGroupLabel>Offline Channels</SidebarGroupLabel>
<SidebarGroupLabel className="px-2 py-1.5">
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:opacity-0 transition-opacity duration-200" />
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{Array(5).fill(0).map((_, i) => (
<StreamerItemSkeleton key={i} />
<StreamerItemSkeleton key={i} isCollapsed={isCollapsed} />
))}
</SidebarMenu>
</SidebarGroupContent>
@@ -147,16 +166,18 @@ function SidebarSkeleton({ ...props }: React.ComponentProps<typeof UISidebar>) {
);
}
function StreamerItemSkeleton() {
function StreamerItemSkeleton({ isCollapsed }: { isCollapsed: boolean }) {
return (
<SidebarMenuItem>
<SidebarMenuButton className="flex items-center gap-3 h-full">
<div className="relative">
<Skeleton className="h-9 w-9 rounded-full" />
</div>
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
<SidebarMenuButton className="h-12">
<div className="flex w-full items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full flex-shrink-0" />
{!isCollapsed && (
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3 w-16" />
</div>
)}
</div>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@@ -0,0 +1,187 @@
'use client';
import Link from 'next/link';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import ConfusedDino from '@/components/ui/confuseddino';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import type { Channel, StreamInfo } from '@hctv/db';
type StreamWithChannel = StreamInfo & { channel: Channel };
interface StreamGridProps {
liveStreams: StreamWithChannel[];
offlineStreams: StreamWithChannel[];
}
export default function StreamGrid({ liveStreams, offlineStreams }: StreamGridProps) {
const sortedLiveStreams = [...liveStreams].sort((a, b) => b.viewers - a.viewers);
return (
<div className="space-y-8 md:space-y-10 min-w-0">
{sortedLiveStreams.length === 0 && (
<div className="flex flex-col items-center gap-4 py-10 text-center">
<ConfusedDino className="h-24 w-24 opacity-70" />
<div className="space-y-1">
<p className="font-semibold">Nobody&apos;s live right now</p>
<p className="text-sm text-muted-foreground">Why not be the first?</p>
</div>
<Link
href="/settings/channel"
className="inline-flex h-9 items-center justify-center rounded-md bg-primary px-5 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Start streaming
</Link>
</div>
)}
{sortedLiveStreams.length > 0 && (
<section>
<SectionHeading label="Live now" count={sortedLiveStreams.length} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{sortedLiveStreams.map((stream) => (
<StreamCard key={stream.id} stream={stream} />
))}
</div>
</section>
)}
{offlineStreams.length > 0 && (
<section className="w-full min-w-0">
<SectionHeading label="Offline channels" count={offlineStreams.length} />
<div className="px-10">
<Carousel className="w-full max-w-full" opts={{ dragFree: true, containScroll: 'trimSnaps' }}>
<CarouselContent className="-ml-2">
{offlineStreams.map((stream) => (
<CarouselItem key={stream.id} className="basis-auto pl-2 md:pl-3">
<OfflineCard stream={stream} />
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="hidden md:flex" />
<CarouselNext className="hidden md:flex" />
</Carousel>
</div>
</section>
)}
</div>
);
}
function StreamCard({ stream }: { stream: StreamWithChannel }) {
return (
<Link href={`/${stream.username}`} className="group block w-full max-w-sm">
<div className="overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 group-hover:shadow-md">
<div className="relative aspect-video overflow-hidden bg-muted">
<img
src={`/api/stream/thumb/${stream.channel.name}`}
alt={stream.title}
className="absolute inset-0 object-cover"
loading="lazy"
decoding="async"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute bottom-1.5 left-1.5 md:bottom-2 md:left-2">
<LiveBadge small />
</div>
<div className="absolute bottom-1.5 right-1.5 md:bottom-2 md:right-2">
<ViewerCount count={stream.viewers} small />
</div>
</div>
<div className="flex items-start gap-2 p-2 md:gap-3 md:p-3">
<Avatar className="h-7 w-7 shrink-0 ring-1 ring-primary/20 md:h-8 md:w-8">
<AvatarImage
src={stream.channel.pfpUrl}
alt={stream.channel.name}
loading="lazy"
decoding="async"
/>
<AvatarFallback className="text-[10px]">
{stream.channel.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium leading-snug md:text-sm">{stream.title}</p>
<p className="truncate text-[10px] text-muted-foreground md:text-xs">
{stream.channel.name}
</p>
{stream.category && (
<Badge
variant="secondary"
className="mt-1 rounded-full px-1.5 py-0 text-[9px] font-medium md:mt-1.5 md:px-2 md:text-[10px]"
>
{stream.category}
</Badge>
)}
</div>
</div>
</div>
</Link>
);
}
function OfflineCard({ stream }: { stream: StreamWithChannel }) {
return (
<Link href={`/${stream.username}`} className="group inline-flex w-[70px]">
<div className="flex w-[70px] flex-col items-center gap-1 rounded-lg p-1.5 transition-colors duration-150 hover:bg-muted/50 sm:w-[78px] md:w-[86px] md:gap-1.5 md:p-2">
<div className="relative">
<Avatar className="h-9 w-9 ring-2 ring-border transition-colors duration-150 group-hover:ring-border/60 sm:h-10 sm:w-10 md:h-11 md:w-11">
<AvatarImage
src={stream.channel.pfpUrl}
alt={stream.channel.name}
loading="lazy"
decoding="async"
/>
<AvatarFallback className="text-xs font-semibold">
{stream.channel.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-background bg-muted-foreground/40" />
</div>
<p className="w-full truncate text-center text-[10px] font-medium">{stream.channel.name}</p>
</div>
</Link>
);
}
function LiveBadge({ small }: { small?: boolean }) {
return (
<span
className={`flex items-center gap-1 rounded-full bg-red-600 font-bold uppercase tracking-wide text-white ${small ? 'px-1.5 py-0.5 text-[9px]' : 'px-2 py-0.5 text-[10px]'}`}
>
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-white" />
Live
</span>
);
}
function ViewerCount({ count, small }: { count: number; small?: boolean }) {
return (
<span
className={`flex items-center gap-1 rounded-full bg-black/70 font-medium text-white backdrop-blur-sm ${small ? 'px-1.5 py-0.5 text-[9px]' : 'px-2 py-0.5 text-xs'}`}
>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-red-400" />
{count.toLocaleString()}
</span>
);
}
function SectionHeading({ label, count }: { label: string; count?: number }) {
return (
<div className="mb-3 flex items-center gap-2">
<h2 className="pb-0 text-sm font-semibold tracking-tight md:text-base">{label}</h2>
{count !== undefined && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{count}
</span>
)}
<div className="ml-2 h-px flex-1 bg-border" />
</div>
);
}

View File

@@ -1,58 +1,63 @@
'use client';
import { useParams } from 'next/navigation';
import { useRef, useEffect } from 'react';
import {
MediaController,
MediaLoadingIndicator,
MediaControlBar,
MediaPlayButton,
MediaSeekBackwardButton,
MediaSeekForwardButton,
MediaMuteButton,
MediaVolumeRange,
MediaFullscreenButton,
} from 'media-chrome/react';
import HlsVideo from 'hls-video-element/react';
import { useSession } from '@/lib/providers/SessionProvider';
import { useUserStreamInfo } from '@/lib/hooks/useUserList';
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
export default function StreamPlayer() {
const { username } = useParams();
const { session } = useSession();
const { streamInfo: userInfo } = useUserStreamInfo(username!.toString());
const videoRef = useRef(null);
useEffect(() => {
const video = videoRef.current;
if (video && username && session) {
const user = 'skibiditoilet';
const credentials = btoa(`${user}:${session.id}`);
// @ts-ignore
video.config = {
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Authorization', `Basic ${credentials}`);
},
lowLatencyMode: true,
debug: process.env.NODE_ENV === 'development',
backBufferLength: 90,
enableWorker: true,
maxLiveSyncPlaybackRate: 1,
liveSyncDurationCount: 2,
liveMaxLatencyDurationCount: 4,
};
// @ts-ignore
video.src = `${getMediamtxClientEnvs(userInfo?.streamRegion!).publicUrl}/${username}/index.m3u8`;
}
return () => {
if (video) {
// @ts-ignore
video.src = '';
}
};
}, [username, session]);
return (
<MediaController className="w-full aspect-video">
<HlsVideo
src={`/api/rtmp/hls/${username}.m3u8`}
slot="media"
crossOrigin="anonymous"
autoplay
config={{
lowLatencyMode: true,
liveSyncDurationCount: 1,
liveMaxLatencyDurationCount: 2,
liveDurationInfinity: true,
enableWorker: true,
backBufferLength: 1,
startLevel: -1,
maxBufferLength: 2,
maxMaxBufferLength: 4,
startFragPrefetch: true,
testBandwidth: false,
progressive: false,
maxBufferSize: 10 * 1000 * 1000,
maxBufferHole: 0.1,
highBufferWatchdogPeriod: 0.5,
nudgeOffset: 0.01,
nudgeMaxRetry: 3,
manifestLoadingTimeOut: 3000,
manifestLoadingMaxRetry: 3,
levelLoadingTimeOut: 3000,
fragLoadingTimeOut: 5000,
debug: process.env.NODE_ENV === 'development',
liveSyncDuration: 1,
liveMaxLatencyDuration: 3,
maxLiveSyncPlaybackRate: 1.5,
liveBackBufferLength: 0,
}}
/>
<HlsVideo ref={videoRef} slot="media" crossOrigin="anonymous" autoplay />
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
<MediaControlBar className="w-full px-2">
<div className="flex items-center gap-2">

View File

@@ -21,7 +21,13 @@ import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import {
createBotSchema,
createChannelSchema, editBotSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema
createChannelSchema,
changeUsernameSchema,
editBotSchema,
onboardSchema,
streamInfoEditSchema,
updateChatModerationSchema,
updateChannelSettingsSchema,
} from '@/lib/form/zod';
export const schemaDb = [
@@ -30,7 +36,9 @@ export const schemaDb = [
{ name: 'createChannel', zod: createChannelSchema },
{ name: 'updateChannelSettings', zod: updateChannelSettingsSchema },
{ name: 'createBot', zod: createBotSchema },
{ name: 'editBot', zod: editBotSchema }
{ name: 'editBot', zod: editBotSchema },
{ name: 'changeUsername', zod: changeUsernameSchema },
{ name: 'updateChatModeration', zod: updateChatModerationSchema },
] as const;
export function UniversalForm<T extends z.ZodType>({
@@ -62,7 +70,7 @@ export function UniversalForm<T extends z.ZodType>({
}, [fields, defaultValues]);
type FormData = z.infer<T>;
const form = useForm<FormData>({
resolver: zodResolver(schema as any),
defaultValues: initialValues as FormData,
@@ -86,8 +94,8 @@ export function UniversalForm<T extends z.ZodType>({
control={form.control}
name={field.name as Path<FormData>}
render={({ field: formField }) => (
<FormItem>
{(field.type !== 'hidden' || field.label) && <FormLabel>{field.label}</FormLabel>}
<FormItem className={field.type === 'hidden' ? 'hidden' : undefined}>
{field.type !== 'hidden' && field.label && <FormLabel>{field.label}</FormLabel>}
<FormControl>
{field.component ? (
field.component({ field: formField, ...field.componentProps })
@@ -97,27 +105,37 @@ export function UniversalForm<T extends z.ZodType>({
{...formField}
value={formField.value ?? ''}
rows={field.textAreaRows ?? 5}
maxLength={field.maxChars}
/>
) : (
<Input
type={field.type || 'text'}
placeholder={field.placeholder}
{...formField}
onChange={(e) => {
if (field.inputFilter) {
e.target.value = e.target.value.replace(field.inputFilter, '');
}
formField.onChange(e);
}}
value={formField.value ?? ''}
maxLength={field.maxChars}
/>
)}
</FormControl>
{field.description && <FormDescription>{field.description}</FormDescription>}
<FormMessage />
{field.type !== 'hidden' && field.description && (
<FormDescription>{field.description}</FormDescription>
)}
{field.type !== 'hidden' && <FormMessage />}
</FormItem>
)}
/>
))}
<div className={cn("flex gap-2 py-2", submitButtonDivClassname)}>
<div className={cn('flex gap-2 py-2', submitButtonDivClassname)}>
{otherSubmitButton}
<SubmitButton buttonText={submitText} className={submitClassname} />
</div>
</form>
</Form>
);
}
}

View File

@@ -12,6 +12,8 @@ export type FormFieldConfig = {
value?: any;
textArea?: boolean;
textAreaRows?: number;
maxChars?: number;
inputFilter?: RegExp;
component?: (props: { field: ControllerRenderProps<any, any> } & any) => React.ReactNode;
componentProps?: Record<string, any>;
required?: boolean;

View File

@@ -22,7 +22,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
export function UserCombobox(props: Props) {
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] = React.useState('');
// Use external value if provided, otherwise use internal state
const value = props.value ?? internalValue;
const setValue = props.onValueChange ?? setInternalValue;
@@ -30,10 +30,7 @@ export function UserCombobox(props: Props) {
data: fetchedUsers,
error,
isLoading,
} = useSWR<APIResponse>(
props.users ? null : '/api/stream/info?personal=true',
fetcher
);
} = useSWR<APIResponse>(props.users ? null : '/api/stream/info?personal=true', fetcher);
const users = props.users || fetchedUsers;
@@ -48,17 +45,22 @@ export function UserCombobox(props: Props) {
aria-expanded={open}
className="w-[200px] justify-between"
>
{value
? (
<div className='flex items-center gap-2'>
<Avatar className="h-8 w-8">
<AvatarImage src={users?.find((user) => user.username === value)?.channel.pfpUrl} alt={value} />
<AvatarFallback>{value[0]}</AvatarFallback>
</Avatar>
<span>{users?.find((user) => user.username === value)?.username}</span>
</div>
)
: 'Select user...'}
{value ? (
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage
src={users?.find((user) => user.username === value)?.channel.pfpUrl}
alt={value}
loading="lazy"
decoding="async"
/>
<AvatarFallback>{value[0]}</AvatarFallback>
</Avatar>
<span>{users?.find((user) => user.username === value)?.username}</span>
</div>
) : (
'Select user...'
)}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
@@ -68,28 +70,35 @@ export function UserCombobox(props: Props) {
<CommandList>
<CommandEmpty>No user found.</CommandEmpty>
<CommandGroup>
{users?.filter(user => !props.filter?.some(filterStr => user.userId === filterStr)).map((user) => (
<CommandItem
key={user.channelId}
value={user.username}
onSelect={(currentValue) => {
setValue(currentValue === value ? '' : currentValue);
setOpen(false);
}}
>
<Avatar className="h-8 w-8">
<AvatarImage src={user.channel.pfpUrl} alt={user.username} />
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
{user.username}
<Check
className={cn(
'ml-auto',
value === user.username ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
{users
?.filter((user) => !props.filter?.some((filterStr) => user.userId === filterStr))
.map((user) => (
<CommandItem
key={user.channelId}
value={user.username}
onSelect={(currentValue) => {
setValue(currentValue === value ? '' : currentValue);
setOpen(false);
}}
>
<Avatar className="h-8 w-8">
<AvatarImage
src={user.channel.pfpUrl}
alt={user.username}
loading="lazy"
decoding="async"
/>
<AvatarFallback>{user.username[0]}</AvatarFallback>
</Avatar>
{user.username}
<Check
className={cn(
'ml-auto',
value === user.username ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
@@ -105,4 +114,4 @@ type Props = {
filter?: string[];
modal?: boolean;
onValueChange?: (value: string) => void;
}
};

View File

@@ -2,6 +2,7 @@ import { Avatar, AvatarImage } from '@/components/ui/avatar';
import type { StreamInfo, Channel } from '@hctv/db';
import FollowButton from './follow';
import FollowCountText from './followCount';
import StreamUptime from './streamUptime';
import ViewerCount from './viewerCount';
import { Preview } from '@/components/ui/channel-desc-fancy-area/preview';
@@ -21,6 +22,7 @@ export default function UserInfoCard(props: Props) {
</div>
<div className="flex items-center space-x-4">
<ViewerCount />
<StreamUptime />
<FollowButton channel={props.streamInfo.username} />
</div>
</div>
@@ -33,4 +35,4 @@ export default function UserInfoCard(props: Props) {
interface Props {
streamInfo: StreamInfo & { channel: Channel };
}
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Clock3 } from 'lucide-react';
import { useParams } from 'next/navigation';
import { useStreams } from '@/lib/providers/StreamInfoProvider';
export default function StreamUptime() {
const { stream, isLoading } = useStreams();
const { username } = useParams<{ username: string }>();
const [now, setNow] = useState(Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNow(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
const startedAt = useMemo(() => {
if (!stream || !username) {
return null;
}
const currentStream = stream.find((entry) => entry.username === username);
if (!currentStream?.isLive) {
return null;
}
return new Date(currentStream.startedAt).getTime();
}, [stream, username]);
if (isLoading || !startedAt) {
return null;
}
const elapsedSeconds = Math.max(0, Math.floor((now - startedAt) / 1000));
const hours = Math.floor(elapsedSeconds / 3600)
.toString()
.padStart(2, '0');
const minutes = Math.floor((elapsedSeconds % 3600) / 60)
.toString()
.padStart(2, '0');
const seconds = (elapsedSeconds % 60).toString().padStart(2, '0');
return (
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Clock3 className="h-4 w-4" />
<span>{`${hours}:${minutes}:${seconds}`}</span>
</div>
);
}

View File

@@ -26,7 +26,8 @@ const buttonVariants = cva(
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10"
icon: "h-10 w-10",
smicon: "h-9 w-9",
},
},
defaultVariants: {

View File

@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,262 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -23,7 +23,7 @@ const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_WIDTH_ICON = "4rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
@@ -512,7 +512,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-12 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {

View File

@@ -6,6 +6,7 @@ export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await (await import('@/lib/instrumentation/streamInfo')).default();
await (await import('@/lib/instrumentation/writeSessions')).default();
await (await import('@/lib/instrumentation/syncStreamKeys')).default();
}
if (process.env.NEXT_RUNTIME === 'nodejs') {

View File

@@ -0,0 +1,121 @@
import { prisma } from '@hctv/db';
export type Resource = 'channel' | 'bot' | 'streamInfo';
export type Action = 'read' | 'update' | 'delete' | 'manage';
type User = { id: string };
type ChannelWithRelations = {
ownerId: string;
managers?: { id: string }[];
personalFor?: { id: string } | null;
};
type BotWithRelations = {
ownerId: string;
};
type PolicyContext = {
channel?: ChannelWithRelations;
bot?: BotWithRelations;
};
const policies: Record<Resource, Record<Action, (user: User, ctx: PolicyContext) => boolean>> = {
channel: {
read: () => true,
update: (user, { channel }) => {
if (!channel) return false;
return channel.ownerId === user.id || (channel.managers?.some((m) => m.id === user.id) ?? false);
},
delete: (user, { channel }) => {
if (!channel) return false;
if (channel.personalFor) return false;
return channel.ownerId === user.id;
},
manage: (user, { channel }) => {
if (!channel) return false;
return channel.ownerId === user.id;
},
},
bot: {
read: () => true,
update: (user, { bot }) => {
if (!bot) return false;
return bot.ownerId === user.id;
},
delete: (user, { bot }) => {
if (!bot) return false;
return bot.ownerId === user.id;
},
manage: (user, { bot }) => {
if (!bot) return false;
return bot.ownerId === user.id;
},
},
streamInfo: {
read: () => true,
update: (user, { channel }) => {
if (!channel) return false;
return channel.ownerId === user.id || (channel.managers?.some((m) => m.id === user.id) ?? false);
},
delete: () => false,
manage: (user, { channel }) => {
if (!channel) return false;
return channel.ownerId === user.id;
},
},
};
export function can(user: User, action: Action, resource: Resource, context: PolicyContext): boolean {
const policy = policies[resource]?.[action];
if (!policy) return false;
return policy(user, context);
}
export async function canAccessChannel(
user: User,
action: Action,
channelId: string
): Promise<boolean> {
const channel = await prisma.channel.findUnique({
where: { id: channelId },
include: { managers: { select: { id: true } }, personalFor: { select: { id: true } } },
});
if (!channel) return false;
return can(user, action, 'channel', { channel });
}
export async function canAccessChannelByName(
user: User,
action: Action,
channelName: string
): Promise<boolean> {
const channel = await prisma.channel.findUnique({
where: { name: channelName },
include: { managers: { select: { id: true } }, personalFor: { select: { id: true } } },
});
if (!channel) return false;
return can(user, action, 'channel', { channel });
}
export async function canAccessBot(user: User, action: Action, botId: string): Promise<boolean> {
const bot = await prisma.botAccount.findUnique({
where: { id: botId },
select: { ownerId: true },
});
if (!bot) return false;
return can(user, action, 'bot', { bot });
}
export async function canAccessBotBySlug(
user: User,
action: Action,
slug: string
): Promise<boolean> {
const bot = await prisma.botAccount.findUnique({
where: { slug },
select: { ownerId: true },
});
if (!bot) return false;
return can(user, action, 'bot', { bot });
}

View File

@@ -12,5 +12,5 @@ export async function logout() {
await lucia.invalidateSession(session!.id);
const sessionCookie = lucia.createBlankSessionCookie();
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect('/login');
return redirect('/');
}

View File

@@ -0,0 +1,35 @@
import { prisma, getRedisConnection } from '@hctv/db';
export async function generateStreamKey(channelId: string, channelName: string) {
const streamKey = await prisma.streamKey.create({
data: {
key: crypto.randomUUID(),
channelId,
},
});
const redis = getRedisConnection();
await redis.set(`streamKey:${channelName}`, streamKey.key);
return streamKey;
}
export async function regenerateStreamKey(channelId: string, channelName: string) {
const streamKey = await prisma.streamKey.upsert({
create: {
key: crypto.randomUUID(),
channelId,
},
update: {
key: crypto.randomUUID(),
},
where: {
channelId,
},
});
const redis = getRedisConnection();
await redis.set(`streamKey:${channelName}`, streamKey.key);
return streamKey;
}

10
apps/web/src/lib/env.ts Normal file
View File

@@ -0,0 +1,10 @@
export const getEnv = (key: string): string | undefined => {
if (typeof window !== 'undefined') {
// @ts-ignore
return window.__ENV?.[key];
}
return process.env[key];
};
export const MEDIAMTX_URL = getEnv('NEXT_PUBLIC_MEDIAMTX_URL');
export const MEDIAMTX_INGEST_ROUTE = getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE');

View File

@@ -2,15 +2,27 @@
import { revalidatePath } from 'next/cache';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { prisma, getRedisConnection } from '@hctv/db';
import zodVerify from '../zodVerify';
import {
createBotSchema,
createChannelSchema, editBotSchema, onboardSchema, streamInfoEditSchema, updateChannelSettingsSchema
createChannelSchema,
changeUsernameSchema,
editBotSchema,
onboardSchema,
streamInfoEditSchema,
updateChatModerationSchema,
updateChannelSettingsSchema,
} from './zod';
import { initializeStreamInfo } from '../instrumentation/streamInfo';
import { resolveFollowedChannels, resolveStreamInfo, resolveUserFromPersonalChannelName } from '../auth/resolve';
import {
resolveFollowedChannels,
resolveStreamInfo,
resolveUserFromPersonalChannelName,
} from '../auth/resolve';
import { can } from '../auth/abac';
import { genIdenticonUpload } from '../utils/genIdenticonUpload';
import { generateStreamKey } from '../db/streamKey';
export async function editStreamInfo(prev: any, formData: FormData) {
const { user } = await validateRequest();
@@ -33,9 +45,7 @@ export async function editStreamInfo(prev: any, formData: FormData) {
return { success: false, error: 'Channel not found' };
}
const isBroadcaster =
channelInfo.ownerId === user.id || channelInfo.managers.some((m) => m.id === user.id);
if (!isBroadcaster) {
if (!can(user, 'update', 'streamInfo', { channel: channelInfo })) {
return { success: false, error: 'Unauthorized' };
}
@@ -81,8 +91,8 @@ export async function onboard(prev: any, formData: FormData) {
ownerId: user.id,
personalFor: { connect: { id: user.id } },
pfpUrl: user.pfpUrl,
}
})
},
});
await prisma.user.update({
where: { id: user.id },
data: {
@@ -94,13 +104,15 @@ export async function onboard(prev: any, formData: FormData) {
});
await initializeStreamInfo(createdChannel.id);
if (process.env.NODE_ENV === 'production') {
await fetch(process.env.WELCOME_WORKFLOW_URL!, {
await generateStreamKey(createdChannel.id, createdChannel.name);
if (process.env.NODE_ENV === 'production' && process.env.WELCOME_WORKFLOW_URL) {
await fetch(process.env.WELCOME_WORKFLOW_URL, {
method: 'POST',
body: JSON.stringify({
username: zod.data.username,
}),
})
});
}
return { success: true };
@@ -153,11 +165,13 @@ export async function createChannel(prev: any, formData: FormData) {
name: zod.data.name,
ownerId: user.id,
pfpUrl: identicon,
}
},
});
await initializeStreamInfo(createdChannel.id);
await generateStreamKey(createdChannel.id, createdChannel.name);
return { success: true, channel: createdChannel.name };
}
@@ -166,9 +180,10 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(updateChannelSettingsSchema, formData);
const urlRegex = /(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm;
const urlRegex =
/(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm;
if (!zod.success) {
return zod;
}
@@ -188,10 +203,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
return { success: false, error: 'Channel not found' };
}
const isOwner = channel.ownerId === user.id;
const isManager = channel.managers.some(manager => manager.id === user.id);
if (!isOwner && !isManager) {
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
@@ -228,12 +240,12 @@ export async function addChannelManager(channelId: string, userChannel: string)
return { success: false, error: 'Channel not found OR is personal.' };
}
if (channel.ownerId !== user.id) {
if (!can(user, 'manage', 'channel', { channel })) {
return { success: false, error: 'Only channel owners can add managers' };
}
if (channel.ownerId === userChannel) {
return { success: false, error: 'Owner can\'t add themselves as managers' };
return { success: false, error: "Owner can't add themselves as managers" };
}
const userDb = await resolveUserFromPersonalChannelName(userChannel);
@@ -250,6 +262,9 @@ export async function addChannelManager(channelId: string, userChannel: string)
managers: {
connect: { id: userDb.id },
},
chatModerators: {
connect: { id: userDb.id },
},
},
});
@@ -257,6 +272,224 @@ export async function addChannelManager(channelId: string, userChannel: string)
return { success: true };
}
export async function addChatModerator(channelId: string, userChannel: string) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const channel = await prisma.channel.findUnique({
where: { id: channelId },
include: { owner: true, managers: true, chatModerators: true },
});
if (!channel) {
return { success: false, error: 'Channel not found' };
}
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
const userDb = await resolveUserFromPersonalChannelName(userChannel);
if (!userDb) {
return { success: false, error: 'User not found' };
}
if (
channel.ownerId === userDb.id ||
channel.managers.some((manager) => manager.id === userDb.id)
) {
return { success: false, error: 'This user is already a built-in moderator' };
}
if (channel.chatModerators.some((moderator) => moderator.id === userDb.id)) {
return { success: false, error: 'User is already a chat moderator' };
}
await prisma.channel.update({
where: { id: channelId },
data: {
chatModerators: {
connect: { id: userDb.id },
},
},
});
revalidatePath(`/settings/channel/${channel.name}`);
return { success: true };
}
export async function removeChatModerator(channelId: string, userId: string) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const channel = await prisma.channel.findUnique({
where: { id: channelId },
include: { owner: true, managers: true },
});
if (!channel) {
return { success: false, error: 'Channel not found' };
}
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
await prisma.channel.update({
where: { id: channelId },
data: {
chatModerators: {
disconnect: { id: userId },
},
},
});
revalidatePath(`/settings/channel/${channel.name}`);
return { success: true };
}
export async function addChatBotModerator(channelId: string, botId: string) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const channel = await prisma.channel.findUnique({
where: { id: channelId },
include: { owner: true, managers: true, chatModeratorBots: true },
});
if (!channel) {
return { success: false, error: 'Channel not found' };
}
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
const bot = await prisma.botAccount.findUnique({
where: { id: botId },
select: { id: true },
});
if (!bot) {
return { success: false, error: 'Bot not found' };
}
if (channel.chatModeratorBots.some((existingBot) => existingBot.id === bot.id)) {
return { success: false, error: 'Bot is already a chat moderator' };
}
await prisma.channel.update({
where: { id: channelId },
data: {
chatModeratorBots: {
connect: { id: bot.id },
},
},
});
revalidatePath(`/settings/channel/${channel.name}`);
return { success: true };
}
export async function removeChatBotModerator(channelId: string, botId: string) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const channel = await prisma.channel.findUnique({
where: { id: channelId },
include: { owner: true, managers: true },
});
if (!channel) {
return { success: false, error: 'Channel not found' };
}
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
await prisma.channel.update({
where: { id: channelId },
data: {
chatModeratorBots: {
disconnect: { id: botId },
},
},
});
revalidatePath(`/settings/channel/${channel.name}`);
return { success: true };
}
export async function updateChatModeration(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(updateChatModerationSchema, formData);
if (!zod.success) {
return zod;
}
const channel = await prisma.channel.findUnique({
where: { id: zod.data.channelId },
include: {
owner: true,
managers: true,
},
});
if (!channel) {
return { success: false, error: 'Channel not found' };
}
if (!can(user, 'update', 'channel', { channel })) {
return { success: false, error: 'Unauthorized' };
}
const blockedTerms = (zod.data.blockedTerms ?? '')
.split(/[\n,]/)
.map((term) => term.trim().toLowerCase())
.filter((term) => term.length >= 2)
.slice(0, 200);
await prisma.chatModerationSettings.upsert({
where: {
channelId: channel.id,
},
create: {
channelId: channel.id,
blockedTerms,
slowModeSeconds: zod.data.slowModeSeconds,
maxMessageLength: zod.data.maxMessageLength,
rateLimitCount: zod.data.rateLimitCount,
rateLimitWindowSeconds: zod.data.rateLimitWindowSeconds,
},
update: {
blockedTerms,
slowModeSeconds: zod.data.slowModeSeconds,
maxMessageLength: zod.data.maxMessageLength,
rateLimitCount: zod.data.rateLimitCount,
rateLimitWindowSeconds: zod.data.rateLimitWindowSeconds,
},
});
const redis = getRedisConnection();
await redis.del(`chat:moderation:settings:${channel.id}`);
revalidatePath(`/settings/channel/${channel.name}`);
return { success: true };
}
export async function removeChannelManager(channelId: string, userId: string) {
const { user } = await validateRequest();
if (!user) {
@@ -272,7 +505,7 @@ export async function removeChannelManager(channelId: string, userId: string) {
return { success: false, error: 'Channel not found' };
}
if (channel.ownerId !== user.id) {
if (!can(user, 'manage', 'channel', { channel })) {
return { success: false, error: 'Only channel owners can remove managers' };
}
@@ -282,6 +515,9 @@ export async function removeChannelManager(channelId: string, userId: string) {
managers: {
disconnect: { id: userId },
},
chatModerators: {
disconnect: { id: userId },
},
},
});
@@ -315,8 +551,8 @@ export async function toggleGlobalChannelNotifs(channelId: string) {
},
data: {
enableNotifications: !streamInfo.enableNotifications,
}
})
},
});
revalidatePath(`/settings/channel/${channel.name}`);
@@ -324,15 +560,14 @@ export async function toggleGlobalChannelNotifs(channelId: string) {
}
export async function deleteChannel(channelId: string) {
return { success: false, error: 'disabled atm. dm @eth0 if you want to request a deletion.' }
/* const { user } = await validateRequest();
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const channel = await prisma.channel.findUnique({
where: { id: channelId },
include: {
include: {
owner: true,
personalFor: true,
},
@@ -342,20 +577,18 @@ export async function deleteChannel(channelId: string) {
return { success: false, error: 'Channel not found' };
}
if (channel.ownerId !== user.id) {
return { success: false, error: 'Only channel owners can delete channels' };
}
// Prevent deletion of personal channels
if (channel.personalFor) {
return { success: false, error: 'Cannot delete personal channels' };
if (!can(user, 'delete', 'channel', { channel })) {
return {
success: false,
error: 'Only channel owners can delete channels (personal channels cannot be deleted)',
};
}
await prisma.channel.delete({
where: { id: channelId },
});
return { success: true }; */
return { success: true };
}
export async function createBot(prev: any, formData: FormData) {
@@ -382,10 +615,10 @@ export async function createBot(prev: any, formData: FormData) {
ownerId: user.id,
description: zod.data.description,
pfpUrl: await genIdenticonUpload(zod.data.slug, 'botpfp'),
}
},
});
return { success: true, slug: createdBot.slug }
return { success: true, slug: createdBot.slug };
}
export async function editBot(prev: any, formData: FormData) {
@@ -404,7 +637,7 @@ export async function editBot(prev: any, formData: FormData) {
if (!bot) {
return { success: false, error: 'Bot not found' };
}
if (bot.ownerId !== user.id) {
if (!can(user, 'update', 'bot', { bot })) {
return { success: false, error: 'Unauthorized' };
}
if (bot.slug !== zod.data.slug) {
@@ -422,10 +655,132 @@ export async function editBot(prev: any, formData: FormData) {
displayName: zod.data.name,
slug: zod.data.slug,
description: zod.data.description,
}
},
});
revalidatePath(`/settings/bot/${updatedBot.slug}`);
return { success: true, slug: updatedBot.slug }
}
return { success: true, slug: updatedBot.slug };
}
const USERNAME_CHANGE_COOLDOWN_DAYS = 30;
export async function changeUsername(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const zod = await zodVerify(changeUsernameSchema, formData);
if (!zod.success) {
return zod;
}
const channel = await prisma.channel.findUnique({
where: { id: zod.data.channelId },
include: {
owner: true,
managers: true,
personalFor: true,
streamInfo: true,
streamKey: true,
},
});
if (!channel) {
return { success: false, error: 'Channel not found' };
}
if (!channel.personalFor || channel.personalFor.id !== user.id) {
return { success: false, error: 'You can only change the username of your personal channel' };
}
if (channel.ownerId !== user.id) {
return { success: false, error: 'Unauthorized' };
}
if (channel.nameLastChanged) {
const daysSinceLastChange = Math.floor(
(Date.now() - new Date(channel.nameLastChanged).getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceLastChange < USERNAME_CHANGE_COOLDOWN_DAYS) {
const daysRemaining = USERNAME_CHANGE_COOLDOWN_DAYS - daysSinceLastChange;
return {
success: false,
error: `Please wait ${daysRemaining} more day${daysRemaining === 1 ? '' : 's'}.`,
};
}
}
const oldName = channel.name;
const newName = zod.data.newUsername;
if (oldName === newName) {
return { success: false, error: 'New username must be different from the current one' };
}
const existingChannel = await prisma.channel.findUnique({
where: { name: newName },
});
if (existingChannel) {
return { success: false, error: 'This username is already taken' };
}
const redis = getRedisConnection();
try {
await prisma.channel.update({
where: { id: channel.id },
data: {
name: newName,
nameLastChanged: process.env.NODE_ENV === 'production' ? new Date() : null,
},
});
if (channel.streamInfo.length > 0) {
await prisma.streamInfo.updateMany({
where: { channelId: channel.id },
data: { username: newName },
});
}
if (channel.streamKey) {
const oldStreamKey = `streamKey:${oldName}`;
const newStreamKey = `streamKey:${newName}`;
if (await redis.exists(oldStreamKey)) {
await redis.rename(oldStreamKey, newStreamKey);
}
}
const oldHistoryKey = `chat:history:${oldName}`;
const newHistoryKey = `chat:history:${newName}`;
if (await redis.exists(oldHistoryKey)) {
const messagesWithScores = await redis.zrange(oldHistoryKey, 0, -1, 'WITHSCORES');
if (messagesWithScores.length > 0) {
const args: (string | number)[] = [];
for (let i = 0; i < messagesWithScores.length; i += 2) {
const msgStr = messagesWithScores[i];
const score = messagesWithScores[i + 1];
try {
const msg = JSON.parse(msgStr);
msg.user.username = newName;
args.push(score, JSON.stringify(msg));
} catch {
args.push(score, msgStr);
}
}
await redis.zadd(newHistoryKey, ...args);
}
await redis.del(oldHistoryKey);
}
revalidatePath(`/settings/channel/${newName}`);
revalidatePath(`/${oldName}`);
revalidatePath(`/${newName}`);
return { success: true, newUsername: newName };
} catch (error) {
console.error('Failed to change username:', error);
return { success: false, error: 'Failed to change username. Please try again.' };
}
}

View File

@@ -1,16 +1,10 @@
import { z } from 'zod';
const disallowedUsernames = [
'admin',
'administrator',
'settings',
'create',
// i hope this doesn't age well tbh
'zrl',
];
const disallowedUsernames = ['admin', 'administrator', 'settings', 'create'];
const username = z
.string()
.min(1)
.max(20)
.regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' })
.refine((val) => !disallowedUsernames.includes(val.toLowerCase()), {
message: 'This username is reserved',
@@ -38,6 +32,15 @@ export const updateChannelSettingsSchema = z.object({
is247: z.boolean(),
});
export const updateChatModerationSchema = z.object({
channelId: z.string().min(1),
blockedTerms: z.string().max(5000).optional(),
slowModeSeconds: z.coerce.number().int().min(0).max(120),
maxMessageLength: z.coerce.number().int().min(50).max(2000),
rateLimitCount: z.coerce.number().int().min(3).max(30),
rateLimitWindowSeconds: z.coerce.number().int().min(5).max(60),
});
export const createBotSchema = z.object({
name: z.string().min(1, { message: 'Name is required' }),
slug: username.refine((val) => val !== 'settings', { message: 'This slug is reserved' }),
@@ -49,3 +52,8 @@ export const editBotSchema = createBotSchema.and(
from: z.string().min(1),
})
);
export const changeUsernameSchema = z.object({
channelId: z.string().min(1),
newUsername: username,
});

View File

@@ -27,6 +27,7 @@ function createCacheKey(options: UseUserListOptions): string {
if (options.owned) params.push('owned')
if (options.personal) params.push('personal')
if (options.live) params.push('live')
if (options.username) params.push(`user-${options.username}`)
return params.length > 0
? `stream-info:${params.join('-')}`
@@ -76,6 +77,8 @@ export interface UseUserListOptions {
personal?: boolean
/** Only fetch live channels */
live?: boolean
/** Search for a specific user's streaminfo */
username?: string
/** Refresh interval in milliseconds */
refreshInterval?: number
/** Cache time to live in milliseconds (default: 5 minutes) */
@@ -132,6 +135,7 @@ export function useUserList(options: UseUserListOptions = {}): UseUserListReturn
owned = false,
personal = false,
live = false,
username,
refreshInterval = 30000,
cacheTTL = 5 * 60 * 1000, // 5 minutes
revalidateOnFocus = false,
@@ -151,8 +155,9 @@ export function useUserList(options: UseUserListOptions = {}): UseUserListReturn
if (owned) searchParams.set('owned', 'true')
if (personal) searchParams.set('personal', 'true')
if (live) searchParams.set('live', 'true')
if (username) searchParams.set('username', username)
return searchParams
}, [owned, personal, live])
}, [owned, personal, live, username])
const queryString = params.toString()
const url = `/api/stream/info${queryString ? `?${queryString}` : ''}`
@@ -325,6 +330,67 @@ export function usePersonalChannels(refreshInterval?: number): UseUserListReturn
})
}
export interface UseUserStreamInfoReturn extends Omit<UseUserListReturn, 'channels'> {
/** The found stream info for the specific user */
streamInfo: StreamInfoResponse[0] | null
/** All matching channels (usually just one) */
channels: StreamInfoResponse
}
/**
* Hook to fetch stream info for a specific user
* Returns the first match if multiple channels exist for that user
*/
export function useUserStreamInfo(
username: string | undefined,
refresh = true,
refreshInterval?: number,
): UseUserStreamInfoReturn {
const result = useUserList({
username,
refreshInterval: refresh ? (refreshInterval ?? 15000) : undefined,
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
revalidateOnFocus: true,
isPaused: !username, // Don't fetch if no username provided
errorRetryCount: 3,
})
return {
...result,
streamInfo: result.channels[0] || null,
}
}
/**
* Lazy version that doesn't automatically fetch - useful for on-demand lookups
*/
export function useUserStreamInfoLazy(refreshInterval?: number) {
const result = useUserList({
refreshInterval: refreshInterval ?? 15000,
cacheTTL: 2 * 60 * 1000,
revalidateOnFocus: true,
isPaused: true, // Start paused
errorRetryCount: 3,
})
const lookupUser = useCallback(async (username: string) => {
if (!username) return null
try {
const response = await enhancedFetcher(`/api/stream/info?username=${encodeURIComponent(username)}`)
return response[0] || null
} catch (error) {
console.error('[useUserStreamInfoLazy] Error looking up user:', error)
throw error
}
}, [])
return {
...result,
lookupUser,
}
}
// Cache management utilities with proper error handling
export const channelCacheUtils = {
/** Clear all channel caches */
@@ -379,6 +445,7 @@ export const channelCacheUtils = {
if (options.owned) params.set('owned', 'true')
if (options.personal) params.set('personal', 'true')
if (options.live) params.set('live', 'true')
if (options.username) params.set('username', options.username)
const queryString = params.toString()
const url = `/api/stream/info${queryString ? `?${queryString}` : ''}`

View File

@@ -1,21 +1,30 @@
import { prisma } from "@hctv/db";
import { getThumbnailQueue } from "../workers";
import { prisma } from '@hctv/db';
import { recordThumbnailJobsEnqueued, setThumbnailRefreshTargets, trackWebJob } from '../metrics';
import { getThumbnailQueue } from '../workers';
export default async function getLiveThumb() {
const liveChannels = await prisma.streamInfo.findMany({
where: {
isLive: true,
},
include: {
channel: true,
}
});
const liveChannelNames = liveChannels.map((channel) => channel.channel.name);
const thumbQueue = getThumbnailQueue();
for (const channel of liveChannelNames) {
await thumbQueue.add("getLiveThumb", {
name: channel,
return trackWebJob('thumbnail_refresh', async () => {
const liveChannels = await prisma.streamInfo.findMany({
where: {
isLive: true,
},
include: {
channel: true,
},
});
}
}
const thumbQueue = getThumbnailQueue();
const jobsByRegion: Record<string, number> = {};
setThumbnailRefreshTargets(liveChannels.length);
for (const liveChannel of liveChannels) {
await thumbQueue.add('getLiveThumb', {
name: liveChannel.channel.name,
server: liveChannel.streamRegion,
});
jobsByRegion[liveChannel.streamRegion] = (jobsByRegion[liveChannel.streamRegion] ?? 0) + 1;
}
recordThumbnailJobsEnqueued(jobsByRegion);
});
}

View File

@@ -1,16 +1,47 @@
import { prisma } from '@hctv/db';
import {
recordLiveStreamTransition,
recordNotificationsEnqueued,
recordStreamSyncScrape,
setLiveStreamsByRegion,
setPlatformInventory,
setStreamPathsByRegion,
trackWebJob,
} from '../metrics';
import { HttpFlv } from '../types/liveBackendJson';
import { getNotificationQueue } from '../workers';
import client from '../services/slackNotifier';
import type { paths } from '../types/mediamtx.d.ts';
import { MEDIAMTX_SERVER_REGIONS } from '../utils/mediamtx/server';
export default async function runner() {
// if there are no users it explodes so yeah
if ((await prisma.user.count()) === 0) {
return;
}
await refreshPlatformInventory();
await initializeStreamInfo();
await syncStream();
setInterval(syncStream, 5000);
setInterval(refreshPlatformInventory, 60_000);
}
async function refreshPlatformInventory() {
const [channels, liveStreams, follows, botAccounts, users] = await Promise.all([
prisma.channel.count(),
prisma.streamInfo.count({ where: { isLive: true } }),
prisma.follow.count(),
prisma.botAccount.count(),
prisma.user.count(),
]);
setPlatformInventory({
bot_accounts: botAccounts,
channels,
follows,
live_stream_rows: liveStreams,
users,
});
}
export async function initializeStreamInfo(channelId?: string) {
@@ -48,69 +79,80 @@ export async function initializeStreamInfo(channelId?: string) {
export async function syncStream() {
try {
const response = await fetch(`${process.env.LIVE_SERVER_URL}/stat`, {
headers: {
Authorization: process.env.STAT_AUTH!,
},
});
await trackWebJob('stream_sync', async () => {
const regions = Object.keys(MEDIAMTX_SERVER_REGIONS) as Array<
keyof typeof MEDIAMTX_SERVER_REGIONS
>;
if (!response.ok) {
console.error(`Failed to fetch stream stats: ${response.status} ${response.statusText}`);
return;
}
const allActiveStreams = new Map<string, keyof typeof MEDIAMTX_SERVER_REGIONS>();
const liveStreamsByRegion = Object.fromEntries(regions.map((region) => [region, 0]));
const pathsSeenByRegion = Object.fromEntries(regions.map((region) => [region, 0]));
const data = await response.json();
const httpFlv = data['http-flv'] as HttpFlv;
for (const r of regions) {
const region = MEDIAMTX_SERVER_REGIONS[r];
const response = await fetch(`${region.apiUrl}/v3/paths/list?itemsPerPage=1000`);
if (!httpFlv?.servers?.[0]?.applications) {
return;
}
if (!response.ok) {
recordStreamSyncScrape(r, 'error');
console.error(
`Failed to fetch ${r} stream stats: ${response.status} ${response.statusText}`
);
continue;
}
const channelLiveApp = httpFlv.servers[0].applications.find(
(app) => app.name === 'channel-live'
);
const activeStreams = channelLiveApp?.live?.streams || [];
recordStreamSyncScrape(r, 'success');
const currentLiveStreams = await prisma.streamInfo.findMany({
where: { isLive: true },
});
type ResponseType =
paths['/v3/paths/list']['get']['responses']['200']['content']['application/json'];
const data = (await response.json()) as ResponseType;
const activeStreamMap = new Map();
for (const stream of activeStreams) {
activeStreamMap.set(stream.name, {
isLive: stream.active,
viewers: stream.clients.filter((c) => !c.publishing).length,
});
}
for (const dbStream of currentLiveStreams) {
const streamStats = activeStreamMap.get(dbStream.username);
if (!streamStats || !streamStats.isLive) {
await prisma.streamInfo.update({
where: { username: dbStream.username },
data: {
isLive: false,
viewers: 0,
startedAt: new Date(0),
},
});
if (data?.items) {
for (const stream of data.items) {
if (stream.ready && stream.name) {
allActiveStreams.set(stream.name, r);
liveStreamsByRegion[r] += 1;
pathsSeenByRegion[r] += 1;
}
}
}
}
}
for (const stream of activeStreams) {
if (stream.active) {
setLiveStreamsByRegion(liveStreamsByRegion);
setStreamPathsByRegion(pathsSeenByRegion);
const currentLiveStreams = await prisma.streamInfo.findMany({
where: { isLive: true },
});
for (const dbStream of currentLiveStreams) {
if (!allActiveStreams.has(dbStream.username)) {
recordLiveStreamTransition('offline', dbStream.streamRegion);
await prisma.streamInfo.update({
where: { username: dbStream.username },
data: {
isLive: false,
viewers: 0,
startedAt: new Date(0),
},
});
}
}
for (const [username, regionKey] of allActiveStreams) {
const existingStream = await prisma.streamInfo.findUnique({
where: { username: stream.name },
where: { username },
include: { channel: true },
});
if (existingStream && !existingStream.isLive) {
console.log(`Stream ${username} is now live in region ${regionKey}`);
recordLiveStreamTransition('online', regionKey);
await prisma.streamInfo.update({
where: { username: stream.name },
where: { username },
data: {
isLive: true,
startedAt: new Date(),
streamRegion: regionKey,
},
});
@@ -123,9 +165,8 @@ export async function syncStream() {
user: true,
},
});
const queue = getNotificationQueue();
const queue = getNotificationQueue();
if (!existingStream.channel.is247) {
queue.add(`streamStartChannel:${existingStream.username}`, {
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hackclub.tv/${existingStream.username}|Go check them out>`,
@@ -133,6 +174,7 @@ export async function syncStream() {
unfurl_links: true,
});
}
if (existingStream.enableNotifications && !existingStream.channel.is247) {
for (const follower of subscribedFollowers) {
queue.add(`streamStartDm:${follower.user.id}`, {
@@ -142,9 +184,17 @@ export async function syncStream() {
});
}
}
recordNotificationsEnqueued('channel', existingStream.channel.is247 ? 0 : 1);
recordNotificationsEnqueued(
'dm',
existingStream.enableNotifications && !existingStream.channel.is247
? subscribedFollowers.length
: 0
);
}
}
}
});
} catch (error) {
console.error('Error syncing stream status:', error);
}

View File

@@ -0,0 +1,38 @@
import { prisma, getRedisConnection } from '@hctv/db';
import { setCacheEntryCount, trackWebJob } from '../metrics';
export default async function syncStreamKeys() {
console.log('Syncing stream keys to Redis...');
try {
await trackWebJob('sync_stream_keys', async () => {
const keys = await prisma.streamKey.findMany({
include: {
channel: true,
},
});
if (keys.length === 0) {
setCacheEntryCount('stream_keys', 0);
console.log('No stream keys found to sync.');
return;
}
const redis = getRedisConnection();
const pipeline = redis.pipeline();
let syncedKeyCount = 0;
for (const key of keys) {
if (key.channel && key.channel.name) {
pipeline.set(`streamKey:${key.channel.name}`, key.key);
syncedKeyCount += 1;
}
}
await pipeline.exec();
setCacheEntryCount('stream_keys', syncedKeyCount);
console.log(`Synced ${syncedKeyCount} stream keys to Redis`);
});
} catch (error) {
console.error('Failed to sync stream keys to Redis:', error);
}
}

View File

@@ -1,40 +1,101 @@
import { getRedisConnection, prisma } from "@hctv/db";
import { getRedisConnection, prisma } from '@hctv/db';
import { setViewerSnapshot, trackWebJob } from '../metrics';
async function countViewersForChannel(channelName: string): Promise<number> {
const redis = getRedisConnection();
let cursor = '0';
let total = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
'MATCH',
`viewer:${channelName}:*`,
'COUNT',
200
);
cursor = nextCursor;
total += keys.length;
} while (cursor !== '0');
return total;
}
export async function viewerCountSync() {
const streams = await prisma.streamInfo.findMany({
where: {
isLive: true
},
include: {
channel: true
}
})
if (streams.length === 0) {
return;
}
const redis = getRedisConnection();
const multi = redis.multi();
for (const stream of streams) {
multi.keys(`viewer:${stream.channel.name}:*`);
}
const results = await multi.exec();
await prisma.$transaction(async (tx) => {
const updates = results?.map((res, index) => {
const count = Array.isArray(res[1]) ? res[1].length : 0;
const stream = streams[index];
return tx.streamInfo.update({
try {
await trackWebJob('viewer_count_sync', async () => {
const streams = await prisma.streamInfo.findMany({
where: {
// using username here because it uses a map
username: stream.username
isLive: true,
},
data: {
viewers: count
select: {
username: true,
streamRegion: true,
channel: {
select: {
name: true,
},
},
},
});
if (streams.length === 0) {
setViewerSnapshot({
totalViewers: 0,
trackedStreams: 0,
streamsWithViewers: 0,
hottestStreamViewers: 0,
viewersByRegion: {},
});
return;
}
const viewersByRegion: Record<string, number> = {};
let totalViewers = 0;
let streamsWithViewers = 0;
let hottestStreamViewers = 0;
const streamCounts = await Promise.all(
streams.map(async (stream) => ({
stream,
count: await countViewersForChannel(stream.channel.name),
}))
);
for (const { stream, count } of streamCounts) {
totalViewers += count;
if (stream.streamRegion) {
viewersByRegion[stream.streamRegion] =
(viewersByRegion[stream.streamRegion] ?? 0) + count;
}
})
})
await Promise.all(updates || []);
})
}
if (count > 0) {
streamsWithViewers += 1;
}
if (count > hottestStreamViewers) {
hottestStreamViewers = count;
}
await prisma.streamInfo.update({
where: {
username: stream.username,
},
data: {
viewers: count,
},
});
}
setViewerSnapshot({
totalViewers,
trackedStreams: streams.length,
streamsWithViewers,
hottestStreamViewers,
viewersByRegion,
});
});
} catch (error) {
console.error('Error syncing viewer counts:', error);
}
}

View File

@@ -1,17 +1,35 @@
import { prisma } from "@hctv/db";
import { getRedisConnection } from "@hctv/db";
import { getRedisConnection, prisma } from '@hctv/db';
import { setCacheEntryCount, trackWebJob } from '../metrics';
async function deleteSessionKeys() {
const redis = getRedisConnection();
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', 'sessions:*', 'COUNT', 200);
cursor = nextCursor;
if (keys.length > 0) {
await redis.unlink(...keys);
}
} while (cursor !== '0');
}
export default async function writeSessions() {
const sessions = await prisma.session.findMany();
const sessionIds = sessions.map((session) => session.id);
const redis = getRedisConnection();
const multi = redis.multi();
multi.del('sessions:*')
for (const sessionId of sessionIds) {
multi.set(`sessions:${sessionId}`, '');
}
await multi.exec();
return trackWebJob('write_sessions', async () => {
const sessions = await prisma.session.findMany();
const sessionIds = sessions.map((session) => session.id);
console.log("Sessions written to Redis");
}
await deleteSessionKeys();
const redis = getRedisConnection();
const multi = redis.multi();
for (const sessionId of sessionIds) {
multi.set(`sessions:${sessionId}`, '');
}
await multi.exec();
setCacheEntryCount('sessions', sessionIds.length);
console.log('Sessions written to Redis');
});
}

261
apps/web/src/lib/metrics.ts Normal file
View File

@@ -0,0 +1,261 @@
import { collectDefaultMetrics, Counter, Gauge, Histogram, Registry } from 'prom-client';
function createMetricsStore() {
const register = new Registry();
register.setDefaultLabels({ app: 'web' });
collectDefaultMetrics({
prefix: 'hctv_web_',
register,
});
const backgroundJobRuns = new Counter({
name: 'hctv_web_background_job_runs_total',
help: 'Total number of background jobs run by the web app.',
labelNames: ['job', 'status'],
registers: [register],
});
const backgroundJobDuration = new Histogram({
name: 'hctv_web_background_job_duration_seconds',
help: 'Background job execution time in seconds.',
labelNames: ['job', 'status'],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30],
registers: [register],
});
const liveStreams = new Gauge({
name: 'hctv_web_live_streams',
help: 'Current number of live streams grouped by MediaMTX region.',
labelNames: ['region'],
registers: [register],
});
const streamPathsSeen = new Gauge({
name: 'hctv_web_stream_paths_seen',
help: 'Current number of ready MediaMTX paths seen during the latest sync.',
labelNames: ['region'],
registers: [register],
});
const liveStreamTransitions = new Counter({
name: 'hctv_web_live_stream_transitions_total',
help: 'Live stream state transitions observed by the web app.',
labelNames: ['transition', 'region'],
registers: [register],
});
const streamSyncScrapes = new Counter({
name: 'hctv_web_stream_sync_scrapes_total',
help: 'MediaMTX region scrapes attempted by stream sync.',
labelNames: ['region', 'status'],
registers: [register],
});
const activeViewers = new Gauge({
name: 'hctv_web_active_viewers',
help: 'Current number of active viewers across all live streams.',
registers: [register],
});
const activeViewersByRegion = new Gauge({
name: 'hctv_web_active_viewers_by_region',
help: 'Current number of active viewers grouped by stream region.',
labelNames: ['region'],
registers: [register],
});
const viewerCountTrackedStreams = new Gauge({
name: 'hctv_web_viewer_count_tracked_streams',
help: 'Number of live streams included in the latest viewer sync.',
registers: [register],
});
const streamsWithViewers = new Gauge({
name: 'hctv_web_streams_with_viewers',
help: 'Current number of live streams with at least one viewer.',
registers: [register],
});
const hottestStreamViewers = new Gauge({
name: 'hctv_web_hottest_stream_viewers',
help: 'Current viewer count of the most watched live stream.',
registers: [register],
});
const thumbnailJobsEnqueued = new Counter({
name: 'hctv_web_thumbnail_jobs_enqueued_total',
help: 'Total thumbnail refresh jobs enqueued by region.',
labelNames: ['region'],
registers: [register],
});
const thumbnailRefreshTargets = new Gauge({
name: 'hctv_web_thumbnail_refresh_targets',
help: 'Number of live streams targeted in the latest thumbnail refresh run.',
registers: [register],
});
const notificationsEnqueued = new Counter({
name: 'hctv_web_notifications_enqueued_total',
help: 'Notification jobs enqueued when streams go live.',
labelNames: ['target'],
registers: [register],
});
const cacheEntries = new Gauge({
name: 'hctv_web_cache_entries',
help: 'Current number of records mirrored into Redis by cache-sync jobs.',
labelNames: ['cache'],
registers: [register],
});
const platformInventory = new Gauge({
name: 'hctv_web_platform_inventory',
help: 'High-level counts of important platform records.',
labelNames: ['entity'],
registers: [register],
});
const mediamtxAuthRequests = new Counter({
name: 'hctv_web_mediamtx_auth_requests_total',
help: 'Total MediaMTX auth decisions handled by the web app.',
labelNames: ['action', 'protocol', 'outcome'],
registers: [register],
});
const mediamtxAuthDuration = new Histogram({
name: 'hctv_web_mediamtx_auth_duration_seconds',
help: 'MediaMTX auth request duration in seconds.',
labelNames: ['action', 'protocol', 'outcome'],
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
registers: [register],
});
return {
register,
activeViewers,
activeViewersByRegion,
backgroundJobDuration,
backgroundJobRuns,
cacheEntries,
hottestStreamViewers,
liveStreams,
liveStreamTransitions,
mediamtxAuthDuration,
mediamtxAuthRequests,
notificationsEnqueued,
platformInventory,
streamPathsSeen,
streamsWithViewers,
streamSyncScrapes,
thumbnailRefreshTargets,
thumbnailJobsEnqueued,
viewerCountTrackedStreams,
};
}
const globalForMetrics = globalThis as typeof globalThis & {
__hctvWebMetrics?: ReturnType<typeof createMetricsStore>;
};
const metrics = (globalForMetrics.__hctvWebMetrics ??= createMetricsStore());
export const webMetricsRegistry = metrics.register;
export async function trackWebJob<T>(job: string, fn: () => Promise<T>): Promise<T> {
const stopTimer = metrics.backgroundJobDuration.startTimer({ job });
let status = 'success';
try {
return await fn();
} catch (error) {
status = 'error';
throw error;
} finally {
metrics.backgroundJobRuns.inc({ job, status });
stopTimer({ job, status });
}
}
export function setLiveStreamsByRegion(streamsByRegion: Record<string, number>): void {
metrics.liveStreams.reset();
for (const [region, count] of Object.entries(streamsByRegion)) {
metrics.liveStreams.set({ region }, count);
}
}
export function setStreamPathsByRegion(pathsByRegion: Record<string, number>): void {
metrics.streamPathsSeen.reset();
for (const [region, count] of Object.entries(pathsByRegion)) {
metrics.streamPathsSeen.set({ region }, count);
}
}
export function recordLiveStreamTransition(transition: 'online' | 'offline', region: string): void {
metrics.liveStreamTransitions.inc({ transition, region });
}
export function recordStreamSyncScrape(region: string, status: 'success' | 'error'): void {
metrics.streamSyncScrapes.inc({ region, status });
}
export function setViewerSnapshot(snapshot: {
totalViewers: number;
trackedStreams: number;
viewersByRegion: Record<string, number>;
streamsWithViewers: number;
hottestStreamViewers: number;
}): void {
metrics.activeViewers.set(snapshot.totalViewers);
metrics.viewerCountTrackedStreams.set(snapshot.trackedStreams);
metrics.streamsWithViewers.set(snapshot.streamsWithViewers);
metrics.hottestStreamViewers.set(snapshot.hottestStreamViewers);
metrics.activeViewersByRegion.reset();
for (const [region, count] of Object.entries(snapshot.viewersByRegion)) {
metrics.activeViewersByRegion.set({ region }, count);
}
}
export function recordThumbnailJobsEnqueued(jobsByRegion: Record<string, number>): void {
for (const [region, count] of Object.entries(jobsByRegion)) {
if (count > 0) {
metrics.thumbnailJobsEnqueued.inc({ region }, count);
}
}
}
export function setThumbnailRefreshTargets(count: number): void {
metrics.thumbnailRefreshTargets.set(count);
}
export function recordNotificationsEnqueued(target: 'channel' | 'dm', count: number): void {
if (count > 0) {
metrics.notificationsEnqueued.inc({ target }, count);
}
}
export function setCacheEntryCount(cache: 'sessions' | 'stream_keys', count: number): void {
metrics.cacheEntries.set({ cache }, count);
}
export function setPlatformInventory(snapshot: Record<string, number>): void {
metrics.platformInventory.reset();
for (const [entity, count] of Object.entries(snapshot)) {
metrics.platformInventory.set({ entity }, count);
}
}
export function recordMediamtxAuth(
action: string,
protocol: string,
outcome: string,
durationSeconds: number
): void {
metrics.mediamtxAuthRequests.inc({ action, protocol, outcome });
metrics.mediamtxAuthDuration.observe({ action, protocol, outcome }, durationSeconds);
}

View File

@@ -37,4 +37,9 @@ export function useStreams() {
return context
}
export type StreamInfoResponse = (StreamInfo & { channel: Channel })[]
export type StreamInfoResponse = (StreamInfo & {
channel: Channel & {
isRestricted?: boolean;
restrictionExpiresAt?: string | null;
};
})[]

3313
apps/web/src/lib/types/mediamtx.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { utapi } from "../services/uploadthing/server";
import sharp from "sharp";
import sharp from 'sharp';
import { utapi } from '../services/uploadthing/server';
export async function genIdenticonUpload(str: string, type?: string) {
const identicon = await fetch(`https://api.dicebear.com/9.x/identicon/svg?seed=${str}&size=256`);
@@ -7,13 +7,13 @@ export async function genIdenticonUpload(str: string, type?: string) {
.webp({ quality: 80 })
.toBuffer();
const file = new File([webpBuffer], `${str}${type ? `-${type}` : ""}.webp`, {
type: "image/webp",
const file = new File([new Uint8Array(webpBuffer)], `${str}${type ? `-${type}` : ''}.webp`, {
type: 'image/webp',
});
const ul = await utapi.uploadFiles(file);
if (ul.error) {
throw new Error("Failed to upload identicon: " + ul.error);
throw new Error('Failed to upload identicon: ' + ul.error);
}
return ul.data?.ufsUrl
}
return ul.data?.ufsUrl;
}

View File

@@ -0,0 +1,29 @@
import { MediaMTXRegion } from './regions';
import { getEnv } from '@/lib/env';
export interface MediaMTXClientEnvs {
publicUrl: string;
ingestRoute: string;
emoji: string;
string: string;
}
export function getMediamtxClientEnvs(region: MediaMTXRegion = 'hq'): MediaMTXClientEnvs {
const envs: Record<MediaMTXRegion, MediaMTXClientEnvs> = {
hq: {
publicUrl: getEnv('NEXT_PUBLIC_MEDIAMTX_URL_HQ')!,
ingestRoute: getEnv('NEXT_PUBLIC_MEDIAMTX_INGEST_ROUTE_HQ')!,
emoji: '🇺🇸',
string: 'HQ Server A',
},
};
const regionEnvs = envs[region];
if (!regionEnvs) {
throw new Error(`Invalid MediaMTX region: ${region}`);
}
return regionEnvs;
}

View File

@@ -0,0 +1 @@
export type MediaMTXRegion = 'hq';

View File

@@ -0,0 +1,21 @@
import { MediaMTXRegion } from './regions';
export interface MediaMTXEnvs {
apiUrl: string;
}
export const MEDIAMTX_SERVER_REGIONS: Record<MediaMTXRegion, MediaMTXEnvs> = {
hq: {
apiUrl: process.env.MEDIAMTX_API_HQ!,
},
};
export function getMediamtxEnvs(region: MediaMTXRegion = 'hq'): MediaMTXEnvs {
const envs = MEDIAMTX_SERVER_REGIONS[region];
if (!envs) {
throw new Error(`Invalid MediaMTX region: ${region}`);
}
return envs;
}

View File

@@ -1,8 +1,11 @@
import { registerNotificationWorker } from './worker/notification';
import { registerThumbnailWorker } from './worker/thumbnails';
import { trackWebJob } from '../metrics';
export async function registerWorkers(): Promise<void> {
await registerNotificationWorker();
await registerThumbnailWorker();
console.log('All workers registered successfully');
}
await trackWebJob('register_workers', async () => {
await registerNotificationWorker();
await registerThumbnailWorker();
console.log('All workers registered successfully');
});
}

View File

@@ -1,9 +1,10 @@
import { Worker } from 'bullmq';
import { getRedisConnection } from '@hctv/db';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync } from 'node:fs';
const pExec = promisify(exec);
import { exec as execCallback } from 'node:child_process';
import { getMediamtxClientEnvs } from '@/lib/utils/mediamtx/client';
const pExec = promisify(execCallback);
const globalForWorker = global as unknown as {
thumbnailWorker: Worker | null;
@@ -26,30 +27,29 @@ export async function registerThumbnailWorker(): Promise<void> {
try {
// this is totally unnecessary, but i'll keep it for security purposes.
const name = job.data.name.replace(/[^a-zA-Z0-9]/g, '_');
const m3u8location = `/dev/shm/hls/${name}.m3u8`;
const server = job.data.server || 'hq';
const srvValues = getMediamtxClientEnvs(server);
const m3u8location = `${srvValues.publicUrl}/${name}/index.m3u8`;
const thumbDir = '/dev/shm/hctv-thumb';
if (!existsSync(m3u8location)) return;
if (!existsSync(thumbDir)) {
await pExec(`mkdir -p ${thumbDir}`);
}
// unnecessary for development, but maybe docker volumes mess with permissions in prod
// also ik it's not the best practice to use 777, but it'll be fiiiiiine
// await pExec('chown -R 777 /dev/shm/hctv-thumb');
exec(
`ffmpeg -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`,
(error) => {
if (error) {
console.error(`Error: ${error.message}`);
return { success: false, error: error.message };
}
}
);
return { success: true };
const header = `-headers "Authorization: Basic ${Buffer.from(`skibiditoilet:${process.env.MEDIAMTX_PUBLISH_KEY}`).toString('base64')}\r\n" `;
try {
await pExec(
`ffmpeg ${header} -i ${m3u8location} -vframes 1 -an -y -f image2 ${thumbDir}/${name}.webp`
);
return { success: true };
} catch (ffmpegError) {
console.error(`FFmpeg error for ${name} on server ${server}:`, ffmpegError);
return { success: false, error: ffmpegError instanceof Error ? ffmpegError.message : String(ffmpegError) };
}
} catch (e) {
console.error('Slack notification failed:', e);
console.error('Thumbnail generation failed:', e);
// @ts-ignore e is unknown
return { success: false, error: e.message };
}

View File

@@ -1,5 +1,6 @@
import type { Config } from "tailwindcss"
import { withUt } from "uploadthing/tw";
import { uploadthingPlugin } from 'uploadthing/tw'
import * as tan from 'tailwindcss-animate'
const config = {
darkMode: ["class"],
@@ -102,7 +103,7 @@ const config = {
}
}
},
plugins: [require("tailwindcss-animate")],
plugins: [tan, uploadthingPlugin],
} satisfies Config
export default withUt(config)
export default config

View File

@@ -32,7 +32,9 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"tailwind.config.mts"
],
"exclude": [
"node_modules"

106
compose.yml Normal file
View File

@@ -0,0 +1,106 @@
services:
hctv:
container_name: hctv
depends_on:
postgres:
condition: service_healthy
pgbouncer:
condition: service_started
env_file:
- .env
build:
context: .
dockerfile: apps/web/Dockerfile
chat:
depends_on:
postgres:
condition: service_healthy
hctv:
condition: service_started
env_file:
- .env
build:
context: .
dockerfile: apps/chat/Dockerfile
postgres:
image: 'postgres:17-alpine'
ports:
- '6767:5432'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: '${PG_PASS}'
POSTGRES_DB: hctv
volumes:
- 'hctv_pgdata:/var/lib/postgresql/data'
healthcheck:
test:
- CMD-SHELL
- 'pg_isready -U postgres'
interval: 5s
timeout: 5s
retries: 5
pgbouncer:
image: 'bitnamilegacy/pgbouncer:1'
environment:
- POSTGRESQL_HOST=postgres
- POSTGRESQL_PORT=5432
- POSTGRESQL_USERNAME=postgres
- 'POSTGRESQL_PASSWORD=${PG_PASS}'
- PGBOUNCER_DATABASE=hctv
- POSTGRESQL_DATABASE=hctv
- PGBOUNCER_POOL_MODE=transaction
- PGBOUNCER_MAX_CLIENT_CONN=100
- PGBOUNCER_DEFAULT_POOL_SIZE=20
depends_on:
- postgres
redis:
image: 'redis:7.4-alpine'
volumes:
- 'hctv_redis:/data'
mediamtx:
build:
context: .
dockerfile: docker/mediamtx/Dockerfile
ports:
- '8890:8890/udp'
postgres-exporter:
image: 'prometheuscommunity/postgres-exporter:v0.17.1'
environment:
DATA_SOURCE_NAME: 'postgresql://postgres:${PG_PASS}@postgres:5432/hctv?sslmode=disable'
redis-exporter:
image: 'oliver006/redis_exporter:v1.67.0'
environment:
REDIS_ADDR: 'redis://redis:6379'
prometheus:
image: 'prom/prometheus:v3.4.2'
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--config.expand-env'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
env_file:
- .env
volumes:
- './observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro'
- 'hctv_prometheus_data:/prometheus'
extra_hosts:
- 'host.docker.internal:host-gateway'
grafana:
image: 'grafana/grafana:11.6.0'
depends_on:
- prometheus
environment:
GF_SECURITY_ADMIN_USER: '${GRAFANA_ADMIN_USER:-admin}'
GF_SECURITY_ADMIN_PASSWORD: '${GRAFANA_ADMIN_PASSWORD:-admin}'
GF_USERS_DEFAULT_THEME: light
volumes:
- './observability/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro'
- './observability/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro'
- './observability/grafana/dashboards:/var/lib/grafana/dashboards:ro'
- 'hctv_grafana_data:/var/lib/grafana'
volumes:
hctv_pgdata:
hctv_redis:
hctv_prometheus_data:
hctv_grafana_data:

View File

@@ -1,52 +1,78 @@
services:
psql:
image: postgres
image: postgres:18-alpine
environment:
POSTGRES_USER: postgres
# my condolences
POSTGRES_PASSWORD: skbiditoilet
volumes:
- ./psql:/var/lib/postgresql/data
- ./psql:/var/lib/postgresql
ports:
- 5555:5432
postgres-exporter:
image: prometheuscommunity/postgres-exporter:v0.17.1
environment:
DATA_SOURCE_NAME: postgresql://postgres:skbiditoilet@psql:5432/postgres?sslmode=disable
redis:
image: redis:7.4-alpine
volumes:
- ./redis:/data
ports:
- 6379:6379
nginx-rtmp:
# ports:
# - 1935:1935
# - 8888:8888
network_mode: host
redis-exporter:
image: oliver006/redis_exporter:v1.67.0
environment:
UID: 1000
GID: 1000
API_AUTH: skibiditoilet
REDIS_ADDR: redis://redis:6379
mediamtx:
image: bluenviron/mediamtx:latest
ports:
- 8890:8890/udp
- 8891:8888
- 9997:9997
- 9998:9998
volumes:
- ./nginx.conf:/etc/nginx/templates/nginx.conf.template
- ./html:/var/www/html
- /dev/shm/hls:/dev/shm/hls
image: srizan10/flv-module
entrypoint:
- /bin/sh
- -c
- |
# Process the template file
mkdir -p /usr/local/nginx/conf
envsubst '$${API_AUTH}' < /etc/nginx/templates/nginx.conf.template > /usr/local/nginx/conf/nginx.conf
- ./mediamtx.yml:/mediamtx.yml
extra_hosts:
- 'host.docker.internal:host-gateway'
prometheus:
image: prom/prometheus:v3.4.2
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --web.enable-lifecycle
volumes:
- ../observability/prometheus.dev.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
ports:
- 9090:9090
extra_hosts:
- 'host.docker.internal:host-gateway'
grafana:
image: grafana/grafana:11.6.0
depends_on:
- prometheus
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_DEFAULT_THEME: light
volumes:
- ../observability/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
- ../observability/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
- ../observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana_data:/var/lib/grafana
ports:
- 3001:3000
# mediamtx2:
# image: bluenviron/mediamtx:latest
# ports:
# - 8990:8890/udp
# - 8991:8891
# - 9999:9997
# volumes:
# - ./mediamtx.yml:/mediamtx.yml
# extra_hosts:
# - "host.docker.internal:host-gateway"
echo "Setting UID to $${UID} and GID to $${GID}"
usermod -u $${UID} nginx || echo "failed to change uid"
groupmod -g $${GID} nginx || echo "failed to change gid"
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp
chown -R nginx:nginx /usr/local/nginx
mkdir -p /var/www/html
chown -R nginx:nginx /var/www/html
echo "testing nginx config..."
/usr/local/nginx/sbin/nginx -t
/usr/local/nginx/sbin/nginx -g 'daemon off;'
volumes:
prometheus_data:
grafana_data:

19
dev/mediamtx.yml Normal file
View File

@@ -0,0 +1,19 @@
paths:
all:
source: publisher
srt: yes
srtAddress: :8890
hls: yes
hlsVariant: lowLatency
hlsSegmentDuration: 2s
hlsPartDuration: 500ms
hlsSegmentCount: 10
authMethod: http
authHTTPAddress: http://host.docker.internal:3000/api/mediamtx/publish
api: yes
metrics: yes
metricsAddress: :9998

View File

@@ -0,0 +1,8 @@
FROM bluenviron/mediamtx:1 AS mediamtx
FROM ubuntu:24.04
COPY --from=mediamtx /mediamtx /
COPY ./docker/mediamtx/mediamtx.yml /mediamtx.yml
ENTRYPOINT ["/mediamtx"]

View File

@@ -0,0 +1,19 @@
paths:
all:
source: publisher
srt: yes
srtAddress: :8890
hls: yes
hlsVariant: lowLatency
hlsSegmentDuration: 2s
hlsPartDuration: 1s
hlsSegmentCount: 10
authMethod: http
authHTTPAddress: http://hctv:3000/api/mediamtx/publish
api: yes
metrics: yes
metricsAddress: :9998

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
apiVersion: 1
providers:
- name: HackClubTV
folder: HackClub TV
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards

View File

@@ -0,0 +1,10 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false

View File

@@ -0,0 +1,39 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: web
metrics_path: /api/metrics
static_configs:
- targets:
- host.docker.internal:3000
- job_name: chat
metrics_path: /metrics
static_configs:
- targets:
- host.docker.internal:8000
- job_name: mediamtx
metrics_path: /metrics
static_configs:
- targets:
- mediamtx:9998
- job_name: redis
metrics_path: /metrics
static_configs:
- targets:
- redis-exporter:9121
- job_name: postgres
metrics_path: /metrics
static_configs:
- targets:
- postgres-exporter:9187
- job_name: prometheus
static_configs:
- targets:
- localhost:9090

View File

@@ -0,0 +1,45 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: web
metrics_path: /api/metrics
basic_auth:
username: ${METRICS_USER}
password: ${METRICS_PASS}
static_configs:
- targets:
- hctv:3000
- job_name: chat
metrics_path: /metrics
basic_auth:
username: ${METRICS_USER}
password: ${METRICS_PASS}
static_configs:
- targets:
- chat:8000
- job_name: mediamtx
metrics_path: /metrics
static_configs:
- targets:
- mediamtx:9998
- job_name: redis
metrics_path: /metrics
static_configs:
- targets:
- redis-exporter:9121
- job_name: postgres
metrics_path: /metrics
static_configs:
- targets:
- postgres-exporter:9187
- job_name: prometheus
static_configs:
- targets:
- localhost:9090

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